333 lines
8.7 KiB
Go
333 lines
8.7 KiB
Go
// 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)
|
||
}
|