Files
taskq/handler.go
hupeh 1f9f1cab53 feat: 添加监控仪表盘
- 新增 Lit.js 组件化 UI (ui/ 目录)
  - tasks-chart: 带十字准星和拖拽选择的图表
  - queue-table: 队列列表,支持暂停/恢复
  - queue-modal: 队列详情弹窗,支持任务重试
  - time-range-picker: Prometheus 风格时间选择器
  - help-tooltip: 可复用的提示组件

- HTTPHandler 功能
  - SSE 实时推送 (stats + queues)
  - 队列暂停/恢复 API
  - 任务重试 API
  - 时间范围查询 API

- Inspector 改进
  - Prometheus 风格单表存储
  - 集成到 Start/Stop 生命周期
  - 新增 PauseQueue/UnpauseQueue/RunTask 方法

- 代码重构
  - Start 函数拆分为小函数
  - 优雅关闭流程优化

- 其他
  - 忽略 SQLite 数据库文件
  - example 添加延迟/定点任务示例
2025-12-09 19:58:18 +08:00

587 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package taskq 提供基于 Redis 的异步任务队列功能
// handler.go 文件包含 HTTP 监控服务处理器
package taskq
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/hibiken/asynq"
)
//go:embed ui/*
var uiFS embed.FS
// HTTPHandlerOptions 配置监控服务的选项
type HTTPHandlerOptions struct {
// RootPath 监控服务的根路径,默认为 "/monitor"
RootPath string
// ReadOnly 是否只读模式,禁用所有修改操作,默认为 false
ReadOnly bool
}
// HTTPHandler 监控服务的 HTTP 处理器
type HTTPHandler struct {
router *http.ServeMux
rootPath string
readOnly bool
closeCh chan struct{}
closeOnce sync.Once
}
// NewHTTPHandler 创建新的监控 HTTP 处理器
func NewHTTPHandler(opts HTTPHandlerOptions) (*HTTPHandler, error) {
if redisClient == nil {
return nil, fmt.Errorf("taskq: redis client not initialized, call SetRedis() first")
}
// 设置默认值
if opts.RootPath == "" {
opts.RootPath = "/monitor"
}
// 确保路径以 / 开头且不以 / 结尾
if !strings.HasPrefix(opts.RootPath, "/") {
opts.RootPath = "/" + opts.RootPath
}
opts.RootPath = strings.TrimSuffix(opts.RootPath, "/")
handler := &HTTPHandler{
router: http.NewServeMux(),
rootPath: opts.RootPath,
readOnly: opts.ReadOnly,
closeCh: make(chan struct{}),
}
handler.setupRoutes()
return handler, nil
}
// ServeHTTP 实现 http.Handler 接口
func (h *HTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.router.ServeHTTP(w, r)
}
// RootPath 返回监控服务的根路径
func (h *HTTPHandler) RootPath() string {
return h.rootPath
}
// Close 关闭 HTTP 处理器
func (h *HTTPHandler) Close() error {
h.closeOnce.Do(func() {
close(h.closeCh)
})
return nil
}
// setupRoutes 设置路由
func (h *HTTPHandler) setupRoutes() {
// API 路由
apiPath := h.rootPath + "/api/"
h.router.HandleFunc(apiPath+"queues", h.handleQueues)
h.router.HandleFunc(apiPath+"queues/", h.handleQueueDetail)
h.router.HandleFunc(apiPath+"tasks/", h.handleTasks)
h.router.HandleFunc(apiPath+"stats/", h.handleStats)
h.router.HandleFunc(apiPath+"sse", h.handleSSE)
// 静态文件路由
uiSubFS, _ := fs.Sub(uiFS, "ui")
fileServer := http.FileServer(http.FS(uiSubFS))
h.router.Handle(h.rootPath+"/static/", http.StripPrefix(h.rootPath+"/static/", fileServer))
// 主页路由(包含 History API 的路由)
h.router.HandleFunc(h.rootPath+"/queues/", h.handleIndex)
h.router.HandleFunc(h.rootPath+"/", h.handleIndex)
h.router.HandleFunc(h.rootPath, h.handleIndex)
}
// handleStats 处理队列统计数据请求
// GET /api/stats/{queue}?start=1234567890&end=1234567899&limit=500
// GET /api/stats/?start=1234567890&end=1234567899&limit=500 (查询所有队列汇总)
func (h *HTTPHandler) handleStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 从 URL 中提取队列名称(可选,为空则查询所有队列汇总)
path := strings.TrimPrefix(r.URL.Path, h.rootPath+"/api/stats/")
queueName := strings.TrimSuffix(path, "/")
// 构建查询参数
query := StatsQuery{
Queue: queueName,
Limit: 500,
}
// 解析 limit 参数
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 10000 {
query.Limit = parsed
}
}
// 解析 start 参数Unix 时间戳)
if s := r.URL.Query().Get("start"); s != "" {
if parsed, err := strconv.ParseInt(s, 10, 64); err == nil && parsed > 0 {
query.Start = parsed
}
}
// 解析 end 参数Unix 时间戳)
if e := r.URL.Query().Get("end"); e != "" {
if parsed, err := strconv.ParseInt(e, 10, 64); err == nil && parsed > 0 {
query.End = parsed
}
}
stats, err := getQueueStatsWithQuery(query)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to get stats: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
// handleQueues 处理队列列表请求
func (h *HTTPHandler) handleQueues(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var queueInfos []QueueInfo
// 首先显示所有注册的队列即使Redis中还没有任务
for queueName, priority := range queues {
stats, err := inspector.GetQueueInfo(queueName)
if err != nil {
// 如果队列不存在,创建一个空的状态
queueInfos = append(queueInfos, QueueInfo{
Name: queueName,
Priority: priority,
})
} else {
queueInfos = append(queueInfos, QueueInfo{
Name: queueName,
Priority: priority,
Size: stats.Size,
Active: stats.Active,
Pending: stats.Pending,
Scheduled: stats.Scheduled,
Retry: stats.Retry,
Archived: stats.Archived,
Completed: stats.Completed,
Processed: stats.Processed,
Failed: stats.Failed,
Paused: stats.Paused,
MemoryUsage: stats.MemoryUsage,
Latency: stats.Latency.Milliseconds(),
})
}
}
// 按优先级排序
sort.Slice(queueInfos, func(i, j int) bool {
return queueInfos[i].Priority > queueInfos[j].Priority
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(queueInfos)
}
// handleQueueDetail 处理队列详情请求和队列操作
// GET /api/queues/{queue} - 获取队列详情
// POST /api/queues/{queue}/pause - 暂停队列
// POST /api/queues/{queue}/unpause - 恢复队列
func (h *HTTPHandler) handleQueueDetail(w http.ResponseWriter, r *http.Request) {
// 从 URL 中提取队列名称
path := strings.TrimPrefix(r.URL.Path, h.rootPath+"/api/queues/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
http.Error(w, "Queue name is required", http.StatusBadRequest)
return
}
queueName := parts[0]
// 检查队列是否已注册
if _, exists := queues[queueName]; !exists {
http.Error(w, "Queue not found", http.StatusNotFound)
return
}
// 处理暂停/恢复请求
if r.Method == http.MethodPost && len(parts) >= 2 {
if h.readOnly {
http.Error(w, "Operation not allowed in read-only mode", http.StatusForbidden)
return
}
action := parts[1]
switch action {
case "pause":
if err := inspector.PauseQueue(queueName); err != nil {
http.Error(w, fmt.Sprintf("Failed to pause queue: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "paused"})
return
case "unpause":
if err := inspector.UnpauseQueue(queueName); err != nil {
http.Error(w, fmt.Sprintf("Failed to unpause queue: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "unpaused"})
return
default:
http.Error(w, "Invalid action", http.StatusBadRequest)
return
}
}
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 获取队列详细信息
stats, err := inspector.GetQueueInfo(queueName)
if err != nil {
// 如果队列在 Redis 中不存在,返回空状态
if strings.Contains(err.Error(), "queue not found") {
emptyStats := map[string]any{
"queue": queueName,
"active": 0,
"pending": 0,
"retry": 0,
"archived": 0,
"completed": 0,
"processed": 0,
"failed": 0,
"paused": false,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(emptyStats)
return
}
http.Error(w, fmt.Sprintf("Failed to get queue info: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
// TaskInfo 转换任务信息
type TaskInfo struct {
ID string `json:"id"`
Type string `json:"type"`
Payload string `json:"payload"`
Queue string `json:"queue"`
Retried int `json:"retried"`
LastFailed string `json:"last_failed,omitempty"`
LastError string `json:"last_error,omitempty"`
NextProcess string `json:"next_process,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
}
// handleTasks 处理任务列表请求和任务操作
// GET /api/tasks/{queue}/{state} - 获取任务列表
// POST /api/tasks/{queue}/archived/{taskId}/retry - 重试失败任务
func (h *HTTPHandler) handleTasks(w http.ResponseWriter, r *http.Request) {
// 从 URL 中提取队列名称和任务状态
path := strings.TrimPrefix(r.URL.Path, h.rootPath+"/api/tasks/")
parts := strings.Split(path, "/")
if len(parts) < 2 {
http.Error(w, "Queue name and task state are required", http.StatusBadRequest)
return
}
queueName := parts[0]
taskState := parts[1]
// 处理重试请求: POST /api/tasks/{queue}/archived/{taskId}/retry
if r.Method == http.MethodPost && len(parts) >= 4 && parts[1] == "archived" && parts[3] == "retry" {
if h.readOnly {
http.Error(w, "Operation not allowed in read-only mode", http.StatusForbidden)
return
}
taskID := parts[2]
h.handleRetryTask(w, r, queueName, taskID)
return
}
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 检查队列是否已注册
if _, exists := queues[queueName]; !exists {
http.Error(w, "Queue not found", http.StatusNotFound)
return
}
// 解析分页参数
page := 1
pageSize := 20
if p := r.URL.Query().Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if ps := r.URL.Query().Get("page_size"); ps != "" {
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 {
pageSize = parsed
}
}
// 获取队列信息以获取任务总数
var total int
queueInfo, queueErr := inspector.GetQueueInfo(queueName)
if queueErr == nil {
switch taskState {
case "active":
total = queueInfo.Active
case "pending":
total = queueInfo.Pending
case "scheduled":
total = queueInfo.Scheduled
case "retry":
total = queueInfo.Retry
case "archived":
total = queueInfo.Archived
case "completed":
total = queueInfo.Completed
}
}
// 根据任务状态获取任务列表
var tasks []*asynq.TaskInfo
var err error
switch taskState {
case "active":
tasks, err = inspector.ListActiveTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
case "pending":
tasks, err = inspector.ListPendingTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
case "scheduled":
tasks, err = inspector.ListScheduledTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
case "retry":
tasks, err = inspector.ListRetryTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
case "archived":
tasks, err = inspector.ListArchivedTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
case "completed":
tasks, err = inspector.ListCompletedTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
default:
http.Error(w, "Invalid task state. Valid states: active, pending, scheduled, retry, archived, completed", http.StatusBadRequest)
return
}
// 如果队列在 Redis 中不存在(没有任务),返回空列表而不是错误
if err != nil {
if strings.Contains(err.Error(), "queue not found") {
tasks = []*asynq.TaskInfo{}
total = 0
} else {
http.Error(w, fmt.Sprintf("Failed to get tasks: %v", err), http.StatusInternalServerError)
return
}
}
var taskInfos []TaskInfo
for _, task := range tasks {
info := TaskInfo{
ID: task.ID,
Type: task.Type,
Payload: string(task.Payload),
Queue: task.Queue,
Retried: task.Retried,
}
if !task.LastFailedAt.IsZero() {
info.LastFailed = task.LastFailedAt.Format(time.RFC3339)
}
if task.LastErr != "" {
info.LastError = task.LastErr
}
if !task.NextProcessAt.IsZero() {
info.NextProcess = task.NextProcessAt.Format(time.RFC3339)
}
if !task.CompletedAt.IsZero() {
info.CompletedAt = task.CompletedAt.Format(time.RFC3339)
}
taskInfos = append(taskInfos, info)
}
response := map[string]any{
"tasks": taskInfos,
"page": page,
"page_size": pageSize,
"total": total,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleRetryTask 重试失败任务
func (h *HTTPHandler) handleRetryTask(w http.ResponseWriter, r *http.Request, queueName, taskID string) {
// 检查队列是否已注册
if _, exists := queues[queueName]; !exists {
http.Error(w, "Queue not found", http.StatusNotFound)
return
}
// 运行重试
err := inspector.RunTask(queueName, taskID)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to retry task: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleIndex 处理主页请求,返回 SPA 入口页面
func (h *HTTPHandler) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 读取 index.html 并替换模板变量
indexHTML, err := uiFS.ReadFile("ui/index.html")
if err != nil {
http.Error(w, fmt.Sprintf("Failed to read index.html: %v", err), http.StatusInternalServerError)
return
}
// 替换模板变量
content := strings.ReplaceAll(string(indexHTML), "{{.RootPath}}", h.rootPath)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(content))
}
// handleSSE 处理 Server-Sent Events 实时数据推送
// 交叉推送两种数据:
// - stats: 统计图表数据(来自 SQLite每 2 秒)
// - queues: 队列表格数据(来自 Redis每 5 秒)
func (h *HTTPHandler) handleSSE(w http.ResponseWriter, r *http.Request) {
// 设置 SSE 响应头
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
// 获取 flusher
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "SSE not supported", http.StatusInternalServerError)
return
}
// 两个定时器:统计数据频率高,队列数据频率低
statsTicker := time.NewTicker(2 * time.Second)
queuesTicker := time.NewTicker(5 * time.Second)
defer statsTicker.Stop()
defer queuesTicker.Stop()
// 监听客户端断开连接
ctx := r.Context()
// 立即发送一次数据
h.sendQueuesEvent(w, flusher)
h.sendStatsEvent(w, flusher)
for {
select {
case <-ctx.Done():
return
case <-h.closeCh:
return
case <-statsTicker.C:
h.sendStatsEvent(w, flusher)
case <-queuesTicker.C:
h.sendQueuesEvent(w, flusher)
}
}
}
// sendStatsEvent 发送统计图表数据(来自 SQLite
func (h *HTTPHandler) sendStatsEvent(w http.ResponseWriter, flusher http.Flusher) {
// 获取最近的统计数据点(用于图表增量更新)
stats, err := getQueueStatsWithQuery(StatsQuery{Limit: 1})
if err != nil || len(stats) == 0 {
return
}
data, err := json.Marshal(stats[0])
if err != nil {
return
}
fmt.Fprintf(w, "event: stats\ndata: %s\n\n", data)
flusher.Flush()
}
// sendQueuesEvent 发送队列表格数据(来自 Redis
func (h *HTTPHandler) sendQueuesEvent(w http.ResponseWriter, flusher http.Flusher) {
var queueInfos []QueueInfo
for queueName, priority := range queues {
stats, err := inspector.GetQueueInfo(queueName)
if err != nil {
queueInfos = append(queueInfos, QueueInfo{
Name: queueName,
Priority: priority,
})
} else {
queueInfos = append(queueInfos, QueueInfo{
Name: queueName,
Priority: priority,
Size: stats.Size,
Active: stats.Active,
Pending: stats.Pending,
Scheduled: stats.Scheduled,
Retry: stats.Retry,
Archived: stats.Archived,
Completed: stats.Completed,
Processed: stats.Processed,
Failed: stats.Failed,
Paused: stats.Paused,
MemoryUsage: stats.MemoryUsage,
Latency: stats.Latency.Milliseconds(),
})
}
}
// 按优先级排序
sort.Slice(queueInfos, func(i, j int) bool {
return queueInfos[i].Priority > queueInfos[j].Priority
})
data, err := json.Marshal(queueInfos)
if err != nil {
return
}
fmt.Fprintf(w, "event: queues\ndata: %s\n\n", data)
flusher.Flush()
}