Files
taskq/inspect.go

333 lines
8.7 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 的异步任务队列功能
// inspect.go 文件包含任务队列的监控和检查功能
package taskq
import (
_ "embed"
"encoding/json"
"fmt"
"html/template"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/hibiken/asynq"
)
//go:embed dashboard.html
var dashboardHTML string
// InspectOptions 配置监控服务的选项
type InspectOptions struct {
// RootPath 监控服务的根路径
// 默认为 "/monitor"
RootPath string
// ReadOnly 是否只读模式,禁用所有修改操作
// 默认为 false
ReadOnly bool
}
// HTTPHandler 监控服务的 HTTP 处理器
type HTTPHandler struct {
router *http.ServeMux
rootPath string
readOnly bool
inspector *asynq.Inspector
}
// NewInspectHandler 创建新的监控处理器
// 使用全局的 redisClient 创建 asynq.Inspector
func NewInspectHandler(opts InspectOptions) (*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, "/")
// 创建 asynq inspector
inspector := asynq.NewInspectorFromRedisClient(redisClient)
handler := &HTTPHandler{
router: http.NewServeMux(),
rootPath: opts.RootPath,
readOnly: opts.ReadOnly,
inspector: inspector,
}
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 关闭 inspector 连接
func (h *HTTPHandler) Close() error {
return h.inspector.Close()
}
// 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(h.rootPath+"/", h.handleDashboard)
h.router.HandleFunc(h.rootPath, h.handleDashboard)
}
// 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
}
// 获取所有队列信息
asynqQueues, err := h.inspector.Queues()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to get queues: %v", err), http.StatusInternalServerError)
return
}
fmt.Println("Redis中的队列:", asynqQueues)
fmt.Println("注册的队列:", queues)
// 获取每个队列的详细信息
type QueueInfo struct {
Name string `json:"name"`
Priority int `json:"priority"`
Active int `json:"active"`
Pending int `json:"pending"`
Retry int `json:"retry"`
Archived int `json:"archived"`
}
var queueInfos []QueueInfo
// 首先显示所有注册的队列即使Redis中还没有任务
for queueName, priority := range queues {
stats, err := h.inspector.GetQueueInfo(queueName)
if err != nil {
// 如果队列不存在,创建一个空的状态
queueInfos = append(queueInfos, QueueInfo{
Name: queueName,
Priority: priority,
Active: 0,
Pending: 0,
Retry: 0,
Archived: 0,
})
} else {
queueInfos = append(queueInfos, QueueInfo{
Name: queueName,
Priority: priority,
Active: stats.Active,
Pending: stats.Pending,
Retry: stats.Retry,
Archived: stats.Archived,
})
}
}
// 按优先级排序
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 处理队列详情请求
func (h *HTTPHandler) handleQueueDetail(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/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
}
// 获取队列详细信息
stats, err := h.inspector.GetQueueInfo(queueName)
if err != nil {
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)
}
// 转换任务信息
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 处理任务列表请求
func (h *HTTPHandler) handleTasks(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/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]
// 检查队列是否已注册
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 tasks []*asynq.TaskInfo
var err error
switch taskState {
case "active":
tasks, err = h.inspector.ListActiveTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
case "pending":
tasks, err = h.inspector.ListPendingTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
case "retry":
tasks, err = h.inspector.ListRetryTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
case "archived":
tasks, err = h.inspector.ListArchivedTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
case "completed":
tasks, err = h.inspector.ListCompletedTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page-1))
default:
http.Error(w, "Invalid task state. Valid states: active, pending, retry, archived, completed", http.StatusBadRequest)
return
}
if err != nil {
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": len(taskInfos),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleDashboard 处理仪表板页面
func (h *HTTPHandler) handleDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 使用嵌入的 HTML 模板
tmpl, err := template.New("dashboard").Parse(dashboardHTML)
if err != nil {
http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
return
}
data := struct {
RootPath string
}{
RootPath: h.rootPath,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, data)
}