587 lines
16 KiB
Go
587 lines
16 KiB
Go
|
|
// 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()
|
|||
|
|
}
|