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 添加延迟/定点任务示例
This commit is contained in:
2025-12-09 19:58:18 +08:00
parent c88bde7b11
commit 1f9f1cab53
17 changed files with 3665 additions and 787 deletions

6
.gitignore vendored
View File

@@ -49,3 +49,9 @@ bin/
# Temporary files
tmp/
temp/
# SQLite database files
*.db
*.db-journal
*.db-shm
*.db-wal

View File

@@ -1,420 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TaskQ 监控面板</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 2em;
}
.content {
padding: 20px;
}
.queues-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.queue-card {
border: 1px solid #e1e5e9;
border-radius: 6px;
padding: 15px;
background: #fafafa;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.queue-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.queue-name {
font-weight: bold;
color: #333;
margin-bottom: 10px;
font-size: 1.1em;
}
.queue-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.stat {
text-align: center;
padding: 8px;
background: white;
border-radius: 4px;
border: 1px solid #e1e5e9;
}
.stat-value {
font-size: 1.2em;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 0.8em;
color: #666;
margin-top: 2px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.error {
background: #fee;
color: #c33;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
}
.refresh-btn {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
margin-bottom: 20px;
}
.refresh-btn:hover {
background: #5a6fd8;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 20px;
border-radius: 8px;
width: 80%;
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover {
color: #000;
}
.task-tabs {
display: flex;
border-bottom: 1px solid #e1e5e9;
margin-bottom: 20px;
}
.task-tab {
padding: 10px 20px;
cursor: pointer;
border: none;
background: none;
border-bottom: 2px solid transparent;
}
.task-tab.active {
border-bottom-color: #667eea;
color: #667eea;
font-weight: bold;
}
.task-list {
max-height: 400px;
overflow-y: auto;
}
.task-item {
border: 1px solid #e1e5e9;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
background: #fafafa;
}
.task-id {
font-family: monospace;
font-size: 0.9em;
color: #666;
}
.task-type {
font-weight: bold;
color: #333;
}
.task-payload {
font-family: monospace;
font-size: 0.8em;
color: #666;
background: white;
padding: 5px;
border-radius: 3px;
margin-top: 5px;
word-break: break-all;
}
.pagination {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 20px;
}
.pagination button {
padding: 5px 10px;
border: 1px solid #e1e5e9;
background: white;
cursor: pointer;
border-radius: 3px;
}
.pagination button:hover {
background: #f0f0f0;
}
.pagination button.active {
background: #667eea;
color: white;
border-color: #667eea;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 TaskQ 监控面板</h1>
<p>实时监控异步任务队列状态</p>
</div>
<div class="content">
<button class="refresh-btn" onclick="loadQueues()">🔄 刷新</button>
<div id="loading" class="loading">加载中...</div>
<div id="error" class="error" style="display: none;"></div>
<div id="queues" class="queues-grid" style="display: none;"></div>
</div>
</div>
<!-- 任务详情模态框 -->
<div id="taskModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeTaskModal()">&times;</span>
<h2 id="modalTitle">队列任务详情</h2>
<div class="task-tabs">
<button class="task-tab active" onclick="switchTab('active')">活跃</button>
<button class="task-tab" onclick="switchTab('pending')">等待</button>
<button class="task-tab" onclick="switchTab('retry')">重试</button>
<button class="task-tab" onclick="switchTab('archived')">归档</button>
<button class="task-tab" onclick="switchTab('completed')">完成</button>
</div>
<div id="taskContent" class="task-list"></div>
<div id="pagination" class="pagination"></div>
</div>
</div>
<script>
let currentQueue = '';
let currentTab = 'active';
let currentPage = 1;
const pageSize = 20;
async function loadQueues() {
const loading = document.getElementById('loading');
const error = document.getElementById('error');
const queues = document.getElementById('queues');
loading.style.display = 'block';
error.style.display = 'none';
queues.style.display = 'none';
try {
const response = await fetch('{{.RootPath}}/api/queues');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const queueData = await response.json();
displayQueues(queueData);
} catch (err) {
error.textContent = '加载队列数据失败: ' + err.message;
error.style.display = 'block';
} finally {
loading.style.display = 'none';
}
}
function displayQueues(queueData) {
const queues = document.getElementById('queues');
if (queueData.length === 0) {
queues.innerHTML = '<div style="text-align: center; padding: 40px; color: #666;">暂无队列数据</div>';
queues.style.display = 'block';
return;
}
queues.innerHTML = queueData.map(queue => `
<div class="queue-card" onclick="openTaskModal('${queue.name}')">
<div class="queue-name">${queue.name} (优先级: ${queue.priority})</div>
<div class="queue-stats">
<div class="stat">
<div class="stat-value">${queue.active}</div>
<div class="stat-label">活跃</div>
</div>
<div class="stat">
<div class="stat-value">${queue.pending}</div>
<div class="stat-label">等待</div>
</div>
<div class="stat">
<div class="stat-value">${queue.retry}</div>
<div class="stat-label">重试</div>
</div>
<div class="stat">
<div class="stat-value">${queue.archived}</div>
<div class="stat-label">归档</div>
</div>
</div>
</div>
`).join('');
queues.style.display = 'grid';
}
function openTaskModal(queueName) {
currentQueue = queueName;
currentPage = 1;
document.getElementById('modalTitle').textContent = `队列 "${queueName}" 任务详情`;
document.getElementById('taskModal').style.display = 'block';
loadTasks();
}
function closeTaskModal() {
document.getElementById('taskModal').style.display = 'none';
}
function switchTab(tab) {
currentTab = tab;
currentPage = 1;
// 更新标签样式
document.querySelectorAll('.task-tab').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
loadTasks();
}
async function loadTasks() {
const taskContent = document.getElementById('taskContent');
const pagination = document.getElementById('pagination');
taskContent.innerHTML = '<div class="loading">加载任务中...</div>';
pagination.innerHTML = '';
try {
const response = await fetch(`{{.RootPath}}/api/tasks/${currentQueue}/${currentTab}?page=${currentPage}&page_size=${pageSize}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
displayTasks(data.tasks);
displayPagination(data.page, data.page_size, data.total);
} catch (err) {
taskContent.innerHTML = `<div class="error">加载任务失败: ${err.message}</div>`;
}
}
function displayTasks(tasks) {
const taskContent = document.getElementById('taskContent');
if (tasks.length === 0) {
taskContent.innerHTML = '<div style="text-align: center; padding: 20px; color: #666;">暂无任务</div>';
return;
}
taskContent.innerHTML = tasks.map(task => `
<div class="task-item">
<div class="task-id">ID: ${task.id}</div>
<div class="task-type">类型: ${task.type}</div>
${task.payload ? `<div class="task-payload">载荷: ${task.payload}</div>` : ''}
${task.retried > 0 ? `<div>重试次数: ${task.retried}</div>` : ''}
${task.last_error ? `<div style="color: #c33; font-size: 0.9em;">错误: ${task.last_error}</div>` : ''}
${task.next_process ? `<div style="color: #666; font-size: 0.9em;">下次处理: ${new Date(task.next_process).toLocaleString()}</div>` : ''}
${task.completed_at ? `<div style="color: #4caf50; font-size: 0.9em;">完成时间: ${new Date(task.completed_at).toLocaleString()}</div>` : ''}
</div>
`).join('');
}
function displayPagination(page, pageSize, total) {
const pagination = document.getElementById('pagination');
if (total === 0) return;
const totalPages = Math.ceil(total / pageSize);
let paginationHTML = '';
// 上一页
if (page > 1) {
paginationHTML += `<button onclick="changePage(${page - 1})">上一页</button>`;
}
// 页码
const startPage = Math.max(1, page - 2);
const endPage = Math.min(totalPages, page + 2);
for (let i = startPage; i <= endPage; i++) {
const activeClass = i === page ? 'active' : '';
paginationHTML += `<button class="${activeClass}" onclick="changePage(${i})">${i}</button>`;
}
// 下一页
if (page < totalPages) {
paginationHTML += `<button onclick="changePage(${page + 1})">下一页</button>`;
}
pagination.innerHTML = paginationHTML;
}
function changePage(page) {
currentPage = page;
loadTasks();
}
// 点击模态框外部关闭
window.onclick = function(event) {
const modal = document.getElementById('taskModal');
if (event.target === modal) {
closeTaskModal();
}
}
// 页面加载时自动加载数据
document.addEventListener('DOMContentLoaded', loadQueues);
// 每30秒自动刷新
setInterval(loadQueues, 30000);
</script>
</body>
</html>

View File

@@ -5,6 +5,9 @@ import (
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"code.tczkiot.com/wlw/taskq"
@@ -75,8 +78,8 @@ func main() {
log.Fatal("注册图片任务失败:", err)
}
// 创建监控处理器
handler, err := taskq.NewInspectHandler(taskq.InspectOptions{
// 创建监控 HTTP 处理器
handler, err := taskq.NewHTTPHandler(taskq.HTTPHandlerOptions{
RootPath: "/monitor",
ReadOnly: false,
})
@@ -84,10 +87,16 @@ func main() {
log.Fatal("创建监控处理器失败:", err)
}
// 启动 taskq 服务器
ctx := context.Background()
// 创建可取消的 context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 启动 taskq 服务器(包含统计采集器)
go func() {
err := taskq.Start(ctx)
err := taskq.Start(ctx, taskq.StartOptions{
StatsInterval: 2 * time.Second,
StatsDBPath: "./taskq_stats.db",
})
if err != nil {
log.Fatal("启动 taskq 服务器失败:", err)
}
@@ -107,7 +116,7 @@ func main() {
case <-ticker.C:
taskCounter++
// 发布邮件任务
// 发布即时邮件任务
err := emailTask.Publish(ctx, EmailTask{
UserID: taskCounter,
TemplateID: "welcome",
@@ -118,7 +127,29 @@ func main() {
log.Printf("发布邮件任务成功: 用户ID=%d", taskCounter)
}
// 发布图片调整任务
// 发布延迟任务30秒后执行
err = emailTask.Publish(ctx, EmailTask{
UserID: taskCounter + 1000,
TemplateID: "reminder",
}, taskq.Delay(30*time.Second))
if err != nil {
log.Printf("发布延迟邮件任务失败: %v", err)
} else {
log.Printf("发布延迟邮件任务成功: 用户ID=%d (30秒后执行)", taskCounter+1000)
}
// 发布定点任务1分钟后的整点执行
scheduledTime := time.Now().Add(1 * time.Minute).Truncate(time.Minute)
err = imageTask.Publish(ctx, ImageResizeTask{
SourceURL: fmt.Sprintf("https://example.com/scheduled%d.jpg", taskCounter),
}, taskq.DelayUntil(scheduledTime))
if err != nil {
log.Printf("发布定点图片任务失败: %v", err)
} else {
log.Printf("发布定点图片任务成功: 任务ID=%d (在 %s 执行)", taskCounter, scheduledTime.Format("15:04:05"))
}
// 发布即时图片任务
err = imageTask.Publish(ctx, ImageResizeTask{
SourceURL: fmt.Sprintf("https://example.com/image%d.jpg", taskCounter),
})
@@ -131,7 +162,44 @@ func main() {
}
}()
// 启动 HTTP 服务器提供监控界面
log.Printf("启动监控服务器在 http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", handler))
// 创建 HTTP 服务器
server := &http.Server{
Addr: ":8081",
Handler: handler,
}
// 启动 HTTP 服务器(非阻塞)
go func() {
log.Printf("启动监控服务器在 http://localhost:8081")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("HTTP 服务器错误:", err)
}
}()
// 等待中断信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("收到关闭信号,正在优雅关停...")
// 1. 取消 context停止任务发布
cancel()
// 2. 关闭监控 HTTP 处理器(会断开 SSE 连接)
handler.Close()
// 3. 关闭 HTTP 服务器(设置 5 秒超时)
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP 服务器关闭错误: %v", err)
}
// 4. 停止 taskq 服务器(会等待完全关闭)
taskq.Stop()
log.Println("服务已安全关闭")
// rdb.Close() 由 defer 执行
}

1
go.mod
View File

@@ -12,6 +12,7 @@ require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-sqlite3 v1.14.32
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/spf13/cast v1.7.0 // indirect
golang.org/x/sys v0.27.0 // indirect

2
go.sum
View File

@@ -18,6 +18,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=

586
handler.go Normal file
View File

@@ -0,0 +1,586 @@
// 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()
}

View File

@@ -1,332 +1,381 @@
// Package taskq 提供基于 Redis 的异步任务队列功能
// inspect.go 文件包含任务队列的监控和检查功能
// inspect.go 文件包含统计采集器和相关数据结构
package taskq
import (
_ "embed"
"encoding/json"
"database/sql"
"fmt"
"html/template"
"net/http"
"sort"
"strconv"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/hibiken/asynq"
_ "github.com/mattn/go-sqlite3"
)
//go:embed dashboard.html
var dashboardHTML string
// ==================== Inspector 统计采集器 ====================
// 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 统计采集器,独立于 HTTP 服务运行
type Inspector struct {
inspector *asynq.Inspector
db *sql.DB
closeCh chan struct{}
closeOnce sync.Once
interval time.Duration
}
// NewInspectHandler 创建新的监控处理器
// 使用全局的 redisClient 创建 asynq.Inspector
func NewInspectHandler(opts InspectOptions) (*HTTPHandler, error) {
// InspectorOptions 配置统计采集器的选项
type InspectorOptions struct {
// Interval 采集间隔,默认 2 秒
Interval time.Duration
// DBPath SQLite 数据库文件路径,默认为 "./taskq_stats.db"
DBPath string
}
// NewInspector 创建新的统计采集器
func NewInspector(opts InspectorOptions) (*Inspector, error) {
if redisClient == nil {
return nil, fmt.Errorf("taskq: redis client not initialized, call SetRedis() first")
}
// 设置默认值
if opts.RootPath == "" {
opts.RootPath = "/monitor"
if opts.Interval <= 0 {
opts.Interval = 2 * time.Second
}
// 确保路径以 / 开头且不以 / 结尾
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,
if opts.DBPath == "" {
opts.DBPath = "./taskq_stats.db"
}
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,
})
// 确保目录存在
dir := filepath.Dir(opts.DBPath)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("taskq: failed to create directory: %v", err)
}
}
// 按优先级排序
sort.Slice(queueInfos, func(i, j int) bool {
return queueInfos[i].Priority > queueInfos[j].Priority
// 打开 SQLite 数据库
db, err := sql.Open("sqlite3", opts.DBPath)
if err != nil {
return nil, fmt.Errorf("taskq: failed to open database: %v", err)
}
// 初始化数据库表
if err := initStatsDB(db); err != nil {
db.Close()
return nil, fmt.Errorf("taskq: failed to init database: %v", err)
}
ins := &Inspector{
inspector: asynq.NewInspectorFromRedisClient(redisClient),
db: db,
closeCh: make(chan struct{}),
interval: opts.Interval,
}
// 启动后台统计采集
go ins.startCollector()
return ins, nil
}
// initStatsDB 初始化数据库Prometheus 风格:单表 + 标签)
// 设计思路:
// - 单表存储所有队列的统计数据,通过 queue 列区分
// - 复合索引支持按时间和队列两个维度高效查询
// - 类似 Prometheus 的 (timestamp, labels, value) 模型
func initStatsDB(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
queue TEXT NOT NULL,
active INTEGER DEFAULT 0,
pending INTEGER DEFAULT 0,
scheduled INTEGER DEFAULT 0,
retry INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
completed INTEGER DEFAULT 0,
succeeded INTEGER DEFAULT 0,
failed INTEGER DEFAULT 0
);
-- 按队列查询WHERE queue = ? ORDER BY timestamp
CREATE INDEX IF NOT EXISTS idx_metrics_queue_time ON metrics(queue, timestamp DESC);
-- 按时间查询所有队列WHERE timestamp BETWEEN ? AND ?
CREATE INDEX IF NOT EXISTS idx_metrics_time ON metrics(timestamp DESC);
-- 唯一约束:同一时间同一队列只有一条记录
CREATE UNIQUE INDEX IF NOT EXISTS idx_metrics_unique ON metrics(timestamp, queue);
`)
return err
}
// Close 关闭统计采集器
func (ins *Inspector) Close() error {
ins.closeOnce.Do(func() {
close(ins.closeCh)
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(queueInfos)
if ins.db != nil {
ins.db.Close()
}
return ins.inspector.Close()
}
// 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
// startCollector 启动后台统计采集任务
func (ins *Inspector) startCollector() {
ticker := time.NewTicker(ins.interval)
defer ticker.Stop()
for {
select {
case <-ins.closeCh:
return
case <-ticker.C:
ins.collectStats()
}
}
}
// collectStats 采集所有队列的统计数据
func (ins *Inspector) collectStats() {
now := time.Now().Unix()
for queueName := range queues {
stats, err := ins.inspector.GetQueueInfo(queueName)
if err != nil {
continue
}
qs := QueueStats{
Queue: queueName,
Timestamp: now,
Active: stats.Active,
Pending: stats.Pending,
Scheduled: stats.Scheduled,
Retry: stats.Retry,
Archived: stats.Archived,
Completed: stats.Completed,
Succeeded: stats.Processed - stats.Failed,
Failed: stats.Failed,
}
ins.saveMetrics(qs)
}
}
// saveMetrics 保存统计数据到 metrics 表
func (ins *Inspector) saveMetrics(stats QueueStats) error {
if ins.db == nil {
return nil
}
// 从 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]
_, err := ins.db.Exec(`
INSERT OR REPLACE INTO metrics (timestamp, queue, active, pending, scheduled, retry, archived, completed, succeeded, failed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, stats.Timestamp, stats.Queue, stats.Active, stats.Pending, stats.Scheduled, stats.Retry, stats.Archived, stats.Completed, stats.Succeeded, stats.Failed)
// 检查队列是否已注册
if _, exists := queues[queueName]; !exists {
http.Error(w, "Queue not found", http.StatusNotFound)
return
return err
}
// GetQueueInfo 获取队列信息
func (ins *Inspector) GetQueueInfo(queueName string) (*asynq.QueueInfo, error) {
return ins.inspector.GetQueueInfo(queueName)
}
// ListActiveTasks 获取活跃任务列表
func (ins *Inspector) ListActiveTasks(queueName string, opts ...asynq.ListOption) ([]*asynq.TaskInfo, error) {
return ins.inspector.ListActiveTasks(queueName, opts...)
}
// ListPendingTasks 获取等待任务列表
func (ins *Inspector) ListPendingTasks(queueName string, opts ...asynq.ListOption) ([]*asynq.TaskInfo, error) {
return ins.inspector.ListPendingTasks(queueName, opts...)
}
// ListScheduledTasks 获取计划任务列表
func (ins *Inspector) ListScheduledTasks(queueName string, opts ...asynq.ListOption) ([]*asynq.TaskInfo, error) {
return ins.inspector.ListScheduledTasks(queueName, opts...)
}
// ListRetryTasks 获取重试任务列表
func (ins *Inspector) ListRetryTasks(queueName string, opts ...asynq.ListOption) ([]*asynq.TaskInfo, error) {
return ins.inspector.ListRetryTasks(queueName, opts...)
}
// ListArchivedTasks 获取归档任务列表
func (ins *Inspector) ListArchivedTasks(queueName string, opts ...asynq.ListOption) ([]*asynq.TaskInfo, error) {
return ins.inspector.ListArchivedTasks(queueName, opts...)
}
// ListCompletedTasks 获取已完成任务列表
func (ins *Inspector) ListCompletedTasks(queueName string, opts ...asynq.ListOption) ([]*asynq.TaskInfo, error) {
return ins.inspector.ListCompletedTasks(queueName, opts...)
}
// RunTask 立即运行归档任务(重试失败任务)
func (ins *Inspector) RunTask(queueName, taskID string) error {
return ins.inspector.RunTask(queueName, taskID)
}
// PauseQueue 暂停队列
func (ins *Inspector) PauseQueue(queueName string) error {
return ins.inspector.PauseQueue(queueName)
}
// UnpauseQueue 恢复队列
func (ins *Inspector) UnpauseQueue(queueName string) error {
return ins.inspector.UnpauseQueue(queueName)
}
// ==================== 统计数据结构 ====================
// QueueInfo 获取每个队列的详细信息
type QueueInfo struct {
Name string `json:"name"`
Priority int `json:"priority"`
Size int `json:"size"` // 队列中任务总数
Active int `json:"active"` // 活跃任务数
Pending int `json:"pending"` // 等待任务数
Scheduled int `json:"scheduled"` // 计划任务数
Retry int `json:"retry"` // 重试任务数
Archived int `json:"archived"` // 归档任务数
Completed int `json:"completed"` // 已完成任务数
Processed int `json:"processed"` // 累计处理数(今日)
Failed int `json:"failed"` // 累计失败数(今日)
Paused bool `json:"paused"` // 是否暂停
MemoryUsage int64 `json:"memory_usage"` // 内存使用(字节)
Latency int64 `json:"latency"` // 延迟(毫秒)
}
// QueueStats 队列统计数据点(用于存储历史数据)
type QueueStats struct {
Timestamp int64 `json:"t"` // Unix 时间戳(秒)
Queue string `json:"q,omitempty"` // 队列名称(汇总查询时为空)
Active int `json:"a"` // 活跃任务数
Pending int `json:"p"` // 等待任务数
Scheduled int `json:"s"` // 计划任务数
Retry int `json:"r"` // 重试任务数
Archived int `json:"ar"` // 归档任务数
Completed int `json:"c"` // 已完成任务数
Succeeded int `json:"su"` // 成功数
Failed int `json:"f"` // 失败数
}
// ==================== 全局统计数据查询 ====================
var statsDB *sql.DB
var statsDBMu sync.RWMutex
// SetStatsDB 设置全局统计数据库(供 HTTPHandler 使用)
func SetStatsDB(db *sql.DB) {
statsDBMu.Lock()
defer statsDBMu.Unlock()
statsDB = db
}
// StatsQuery 统计查询参数
type StatsQuery struct {
Queue string // 队列名称,为空则查询所有队列汇总
Start int64 // 开始时间戳0 表示不限制
End int64 // 结束时间戳0 表示不限制
Limit int // 返回数量限制,默认 500
}
// getQueueStats 获取队列历史统计数据
func getQueueStats(queueName string, limit int) ([]QueueStats, error) {
return getQueueStatsWithQuery(StatsQuery{
Queue: queueName,
Limit: limit,
})
}
// getQueueStatsWithQuery 根据查询条件获取统计数据Prometheus 风格单表查询)
// - 按队列查询:使用 idx_metrics_queue_time 索引
// - 按时间汇总:使用 idx_metrics_time 索引 + GROUP BY
func getQueueStatsWithQuery(q StatsQuery) ([]QueueStats, error) {
statsDBMu.RLock()
db := statsDB
statsDBMu.RUnlock()
if db == nil {
return nil, nil
}
// 获取队列详细信息
stats, err := h.inspector.GetQueueInfo(queueName)
if q.Limit <= 0 {
q.Limit = 500
}
var args []any
var whereClause string
var conditions []string
// 构建 WHERE 条件
if q.Queue != "" {
conditions = append(conditions, "queue = ?")
args = append(args, q.Queue)
}
if q.Start > 0 {
conditions = append(conditions, "timestamp >= ?")
args = append(args, q.Start)
}
if q.End > 0 {
conditions = append(conditions, "timestamp <= ?")
args = append(args, q.End)
}
if len(conditions) > 0 {
whereClause = "WHERE " + strings.Join(conditions, " AND ")
}
var query string
if q.Queue != "" {
// 查询单个队列
query = fmt.Sprintf(`
SELECT timestamp, queue, active, pending, scheduled, retry, archived, completed, succeeded, failed
FROM metrics
%s
ORDER BY timestamp DESC
LIMIT ?
`, whereClause)
} else {
// 查询所有队列汇总(按时间 GROUP BY
query = fmt.Sprintf(`
SELECT timestamp, '' as queue, SUM(active), SUM(pending), SUM(scheduled), SUM(retry), SUM(archived), SUM(completed), SUM(succeeded), SUM(failed)
FROM metrics
%s
GROUP BY timestamp
ORDER BY timestamp DESC
LIMIT ?
`, whereClause)
}
args = append(args, q.Limit)
rows, err := db.Query(query, args...)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to get queue info: %v", err), http.StatusInternalServerError)
return
return nil, err
}
defer rows.Close()
var statsList []QueueStats
for rows.Next() {
var s QueueStats
if err := rows.Scan(&s.Timestamp, &s.Queue, &s.Active, &s.Pending, &s.Scheduled, &s.Retry, &s.Archived, &s.Completed, &s.Succeeded, &s.Failed); err != nil {
continue
}
statsList = append(statsList, s)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
// 反转顺序,使时间从早到晚
for i, j := 0, len(statsList)-1; i < j; i, j = i+1, j-1 {
statsList[i], statsList[j] = statsList[j], statsList[i]
}
return statsList, nil
}
// 转换任务信息
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)
// GetStatsDB 返回 Inspector 的数据库连接(供外部设置给 HTTPHandler
func (ins *Inspector) GetStatsDB() *sql.DB {
return ins.db
}

26
task.go
View File

@@ -87,13 +87,21 @@ func (t *Task[T]) Publish(ctx context.Context, data T, options ...PublishOption)
// 构建任务选项
opts := []asynq.Option{
asynq.Queue(t.Queue), // 设置队列名称
asynq.Group(t.Group), // 设置任务组
asynq.MaxRetry(t.MaxRetries), // 设置最大重试次数
asynq.TaskID(xid.New().String()), // 生成唯一任务ID
asynq.Timeout(t.TTR), // 设置超时时间
asynq.Retention(time.Hour * 24), // 设置结果保留24小时
}
// 只有设置了 TTR 才添加超时选项,避免 TTR=0 导致立即超时
if t.TTR > 0 {
opts = append(opts, asynq.Timeout(t.TTR))
}
// 只有设置了 Group 才添加分组选项
if t.Group != "" {
opts = append(opts, asynq.Group(t.Group))
}
// 应用用户自定义选项
for _, option := range options {
if opt := option(); opt != nil {
@@ -126,14 +134,15 @@ func (t *Task[T]) ProcessTask(ctx context.Context, tsk *asynq.Task) error {
// 根据配置添加数据参数
if t.inputData {
// 创建数据类型的指针实例
dataValue := reflect.New(t.dataType)
// 创建数据类型的指针实例用于反序列化
dataPtr := reflect.New(t.dataType)
// 反序列化任务载荷
err := json.Unmarshal(tsk.Payload(), dataValue.Interface())
err := json.Unmarshal(tsk.Payload(), dataPtr.Interface())
if err != nil {
return err
}
in = append(in, dataValue)
// 传递值类型而非指针,因为 Handler 期望的是值类型
in = append(in, dataPtr.Elem())
}
// 通过反射调用处理器函数
@@ -141,7 +150,10 @@ func (t *Task[T]) ProcessTask(ctx context.Context, tsk *asynq.Task) error {
// 处理返回值
if t.returnError {
// Register 已确保返回类型为 error无需类型断言
// 当返回值为 nil 时Interface() 返回 nil不能直接类型断言
if out[0].IsNil() {
return nil
}
return out[0].Interface().(error)
}
return nil

195
taskq.go
View File

@@ -19,10 +19,12 @@ import (
var (
started atomic.Bool // 服务器启动状态
exit chan chan struct{} // 优雅退出信号通道
done chan struct{} // 关闭完成信号通道
handlers map[string]asynq.Handler // 任务处理器映射表
queues map[string]int // 队列优先级配置
client atomic.Pointer[asynq.Client] // asynq 客户端实例
redisClient redis.UniversalClient // Redis 客户端实例
inspector *Inspector // 统计采集器实例
errorType = reflect.TypeOf((*error)(nil)).Elem() // error 类型反射
contextType = reflect.TypeOf((*context.Context)(nil)).Elem() // context.Context 类型反射
)
@@ -31,6 +33,7 @@ var (
// 创建必要的全局变量和映射表,必须在调用其他函数之前调用
func Init() {
exit = make(chan chan struct{}) // 创建优雅退出通道
done = make(chan struct{}) // 创建关闭完成通道
handlers = make(map[string]asynq.Handler) // 创建任务处理器映射
queues = make(map[string]int) // 创建队列优先级映射
}
@@ -39,6 +42,19 @@ func Init() {
// 使用泛型确保类型安全,通过反射验证处理器函数签名
// 处理器函数签名必须是func(context.Context, T) error 或 func(context.Context) 或 func(T) error 或 func()
func Register[T any](t *Task[T]) error {
if t.Queue == "" {
return errors.New("taskq: queue name cannot be empty")
}
if t.Priority < 0 || t.Priority > 255 {
return errors.New("taskq: priority must be between 0 and 255")
}
if t.MaxRetries < 0 {
return errors.New("taskq: retry count must be non-negative")
}
if t.Handler == nil {
return errors.New("taskq: handler cannot be nil")
}
rv := reflect.ValueOf(t.Handler)
if rv.Kind() != reflect.Func {
return errors.New("taskq: handler must be a function")
@@ -56,28 +72,36 @@ func Register[T any](t *Task[T]) error {
}
}
// 验证参数:最多2个参数第一个必须是 context.Context第二个必须是结构体
// 验证参数:支持以下签名
// - func(context.Context, T) error
// - func(context.Context) error
// - func(T) error
// - func()
var inContext bool
var inData bool
var dataType reflect.Type
for i := range rt.NumIn() {
if i == 0 {
fi := rt.In(i)
if !fi.Implements(contextType) {
return errors.New("taskq: handler function first parameter must be context.Context")
numIn := rt.NumIn()
if numIn > 2 {
return errors.New("taskq: handler function can have at most 2 parameters")
}
for i := range numIn {
fi := rt.In(i)
if fi.Implements(contextType) {
if i != 0 {
return errors.New("taskq: context.Context must be the first parameter")
}
inContext = true
continue
} else if fi.Kind() == reflect.Struct {
if inData {
return errors.New("taskq: handler function can only have one data parameter")
}
inData = true
dataType = fi
} else {
return errors.New("taskq: handler parameter must be context.Context or a struct")
}
if i != 1 {
return errors.New("taskq: handler function can have at most 2 parameters")
}
fi := rt.In(i)
if fi.Kind() != reflect.Struct {
return errors.New("taskq: handler function second parameter must be a struct")
}
inData = true
dataType = fi
}
// 检查服务器是否已启动
@@ -112,73 +136,130 @@ func SetRedis(rdb redis.UniversalClient) error {
return nil
}
// StartOptions 启动选项
type StartOptions struct {
// StatsInterval 统计采集间隔,默认 2 秒
StatsInterval time.Duration
// StatsDBPath SQLite 数据库文件路径,默认 "./taskq_stats.db"
StatsDBPath string
}
// Start 启动 taskq 服务器
// 开始监听任务队列并处理任务,包含健康检查和优雅退出机制
func Start(ctx context.Context) error {
// 原子操作确保只启动一次
func Start(ctx context.Context, opts ...StartOptions) error {
if !started.CompareAndSwap(false, true) {
return errors.New("taskq: server is already running")
}
// 检查 Redis 客户端是否已初始化
if redisClient == nil {
started.Store(false)
return errors.New("taskq: redis client not initialized, call SetRedis() first")
}
// 创建任务路由器
var opt StartOptions
if len(opts) > 0 {
opt = opts[0]
}
if err := startInspector(opt); err != nil {
started.Store(false)
return err
}
srv := createServer(ctx)
go runServer(srv)
go runMonitor(ctx, srv)
return nil
}
// startInspector 启动统计采集器
func startInspector(opt StartOptions) error {
ins, err := NewInspector(InspectorOptions{
Interval: opt.StatsInterval,
DBPath: opt.StatsDBPath,
})
if err != nil {
return err
}
inspector = ins
SetStatsDB(ins.GetStatsDB())
return nil
}
// createServer 创建 asynq 服务器
func createServer(ctx context.Context) *asynq.Server {
return asynq.NewServerFromRedisClient(redisClient, asynq.Config{
Concurrency: 30,
Queues: maps.Clone(queues),
BaseContext: func() context.Context { return ctx },
LogLevel: asynq.WarnLevel,
})
}
// runServer 运行任务处理服务器
func runServer(srv *asynq.Server) {
mux := asynq.NewServeMux()
for name, handler := range handlers {
mux.Handle(name, handler)
}
if err := srv.Run(mux); err != nil {
log.Fatal(err)
}
}
// 创建 asynq 服务器
srv := asynq.NewServerFromRedisClient(redisClient, asynq.Config{
Concurrency: 30, // 并发处理数
Queues: maps.Clone(queues), // 队列配置
BaseContext: func() context.Context { return ctx }, // 基础上下文
LogLevel: asynq.DebugLevel, // 日志级别
})
// runMonitor 运行监控协程,处理优雅退出和健康检查
func runMonitor(ctx context.Context, srv *asynq.Server) {
defer close(done)
defer started.Store(false)
defer closeInspector()
defer srv.Shutdown()
// 启动监控协程:处理优雅退出和健康检查
ctx, cancel := context.WithCancel(ctx)
go func() {
defer cancel()
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
ticker := time.NewTicker(time.Minute) // 每分钟健康检查
defer ticker.Stop()
for {
for {
select {
case quit := <-exit:
quit <- struct{}{}
return
case <-ctx.Done():
// ctx 取消时,排空 exit 通道中可能的信号
select {
case <-ctx.Done():
case quit := <-exit:
quit <- struct{}{}
default:
}
return
case <-ticker.C:
if err := srv.Ping(); err != nil {
log.Println(err)
return
case exit := <-exit: // 收到退出信号
srv.Stop()
exit <- struct{}{}
return
case <-ticker.C: // 定期健康检查
err := srv.Ping()
if err != nil {
log.Println(err)
Stop()
}
}
}
}()
}
}
// 启动任务处理服务
go func() {
if err := srv.Run(mux); err != nil {
log.Fatal(err)
}
}()
return nil
// closeInspector 关闭统计采集
func closeInspector() {
if inspector != nil {
inspector.Close()
inspector = nil
}
}
// Stop 优雅停止 taskq 服务器
// 发送停止信号并等待服务器完全关闭
func Stop() {
if !started.Load() {
return
}
quit := make(chan struct{})
exit <- quit // 发送退出信号
<-quit // 等待确认退出
select {
case exit <- quit:
<-quit // 等待确认收到退出信号
default:
// monitor 已经退出
}
<-done // 等待 runMonitor 完全结束
}

410
ui/app.js Normal file
View File

@@ -0,0 +1,410 @@
import { LitElement, html } from 'lit';
import './components/time-range-picker.js';
import './components/tasks-chart.js';
import './components/queue-table.js';
import './components/queue-modal.js';
class TaskqApp extends LitElement {
static properties = {
rootPath: { type: String, attribute: 'root-path' },
queues: { type: Array, state: true },
loading: { type: Boolean, state: true },
modalOpen: { type: Boolean, state: true },
currentQueue: { type: String, state: true },
currentTab: { type: String, state: true },
currentPage: { type: Number, state: true },
// Time range state
duration: { type: String, state: true },
endTime: { type: Number, state: true },
isLiveMode: { type: Boolean, state: true },
// Chart data
chartData: { type: Object, state: true }
};
constructor() {
super();
this.rootPath = '/monitor';
this.queues = [];
this.loading = true;
this.modalOpen = false;
this.currentQueue = '';
this.currentTab = 'active';
this.currentPage = 1;
this.duration = '1h';
this.endTime = null;
this.isLiveMode = true;
this.chartData = { labels: [], timestamps: [], datasets: {} };
this.eventSource = null;
}
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.initRoute();
this.loadStatsForTimeRange();
this.connectSSE();
window.addEventListener('popstate', this.handlePopState.bind(this));
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.eventSource) {
this.eventSource.close();
}
window.removeEventListener('popstate', this.handlePopState.bind(this));
}
initRoute() {
const path = window.location.pathname;
const relativePath = path.replace(this.rootPath, '').replace(/^\/+/, '');
const match = relativePath.match(/^queues\/([^\/]+)\/([^\/]+)/);
if (match) {
this.currentQueue = decodeURIComponent(match[1]);
this.currentTab = match[2];
const params = new URLSearchParams(window.location.search);
this.currentPage = parseInt(params.get('page')) || 1;
this.modalOpen = true;
}
}
handlePopState(event) {
if (event.state && event.state.queue) {
this.currentQueue = event.state.queue;
this.currentTab = event.state.tab;
this.currentPage = event.state.page;
this.modalOpen = true;
} else {
this.modalOpen = false;
}
}
connectSSE() {
if (this.eventSource) {
this.eventSource.close();
}
this.eventSource = new EventSource(`${this.rootPath}/api/sse`);
this.eventSource.addEventListener('queues', (event) => {
this.queues = JSON.parse(event.data);
this.loading = false;
});
this.eventSource.addEventListener('stats', (event) => {
if (!this.isLiveMode) return;
const stats = JSON.parse(event.data);
// 检查数据是否在当前时间范围内
const durationSecs = this.parseDuration(this.duration);
const now = Math.floor(Date.now() / 1000);
const start = now - durationSecs;
if (stats.t < start) return; // 数据不在时间范围内,忽略
this.appendStatsPoint(stats);
// 移除超出时间范围的旧数据
this.trimOldData(start);
});
this.eventSource.onerror = () => {
setTimeout(() => this.connectSSE(), 3000);
};
}
appendStatsPoint(stats) {
const date = new Date(stats.t * 1000);
const timeLabel = date.toLocaleTimeString('zh-CN', {
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
// 检查是否已有相同时间戳的数据
if (this.chartData.timestamps.length > 0 &&
this.chartData.timestamps[this.chartData.timestamps.length - 1] === stats.t) {
return;
}
const newData = { ...this.chartData };
newData.labels = [...newData.labels, timeLabel];
newData.timestamps = [...newData.timestamps, stats.t];
newData.datasets = {
active: [...(newData.datasets.active || []), stats.a || 0],
pending: [...(newData.datasets.pending || []), stats.p || 0],
scheduled: [...(newData.datasets.scheduled || []), stats.s || 0],
retry: [...(newData.datasets.retry || []), stats.r || 0],
archived: [...(newData.datasets.archived || []), stats.ar || 0],
completed: [...(newData.datasets.completed || []), stats.c || 0],
succeeded: [...(newData.datasets.succeeded || []), stats.su || 0],
failed: [...(newData.datasets.failed || []), stats.f || 0]
};
this.chartData = newData;
}
trimOldData(startTimestamp) {
const timestamps = this.chartData.timestamps || [];
if (timestamps.length === 0) return;
// 找到第一个在时间范围内的数据索引
let trimIndex = 0;
while (trimIndex < timestamps.length && timestamps[trimIndex] < startTimestamp) {
trimIndex++;
}
if (trimIndex === 0) return; // 没有需要移除的数据
const newData = { ...this.chartData };
newData.labels = newData.labels.slice(trimIndex);
newData.timestamps = newData.timestamps.slice(trimIndex);
newData.datasets = {
active: (newData.datasets.active || []).slice(trimIndex),
pending: (newData.datasets.pending || []).slice(trimIndex),
scheduled: (newData.datasets.scheduled || []).slice(trimIndex),
retry: (newData.datasets.retry || []).slice(trimIndex),
archived: (newData.datasets.archived || []).slice(trimIndex),
completed: (newData.datasets.completed || []).slice(trimIndex),
succeeded: (newData.datasets.succeeded || []).slice(trimIndex),
failed: (newData.datasets.failed || []).slice(trimIndex)
};
this.chartData = newData;
}
async loadStatsForTimeRange() {
const durationSecs = this.parseDuration(this.duration);
const end = this.endTime !== null ? this.endTime : Math.floor(Date.now() / 1000);
const start = end - durationSecs;
try {
const response = await fetch(
`${this.rootPath}/api/stats/?start=${start}&end=${end}&limit=10000`
);
if (!response.ok) return;
const stats = await response.json();
// 将 API 数据转为 map 便于查找
const statsMap = new Map();
if (stats && stats.length > 0) {
stats.forEach(s => {
statsMap.set(s.t, s);
});
}
// 计算采样间隔(根据时间范围动态调整)
const interval = this.getInterval(durationSecs);
// 生成完整的时间序列
const newData = {
labels: [], timestamps: [], datasets: {
active: [], pending: [], scheduled: [], retry: [],
archived: [], completed: [], succeeded: [], failed: []
}
};
// 对齐到间隔
const alignedStart = Math.floor(start / interval) * interval;
for (let t = alignedStart; t <= end; t += interval) {
const date = new Date(t * 1000);
const timeLabel = this.formatTimeLabel(date, durationSecs);
newData.labels.push(timeLabel);
newData.timestamps.push(t);
// 查找该时间点附近的数据(允许一定误差)
const s = this.findNearestStats(statsMap, t, interval);
newData.datasets.active.push(s ? (s.a || 0) : null);
newData.datasets.pending.push(s ? (s.p || 0) : null);
newData.datasets.scheduled.push(s ? (s.s || 0) : null);
newData.datasets.retry.push(s ? (s.r || 0) : null);
newData.datasets.archived.push(s ? (s.ar || 0) : null);
newData.datasets.completed.push(s ? (s.c || 0) : null);
newData.datasets.succeeded.push(s ? (s.su || 0) : null);
newData.datasets.failed.push(s ? (s.f || 0) : null);
}
this.chartData = newData;
} catch (err) {
console.error('Failed to load stats:', err);
}
}
// 根据时间范围计算采样间隔
getInterval(durationSecs) {
if (durationSecs <= 300) return 2; // 5m -> 2s
if (durationSecs <= 900) return 5; // 15m -> 5s
if (durationSecs <= 1800) return 10; // 30m -> 10s
if (durationSecs <= 3600) return 20; // 1h -> 20s
if (durationSecs <= 10800) return 60; // 3h -> 1m
if (durationSecs <= 21600) return 120; // 6h -> 2m
if (durationSecs <= 43200) return 300; // 12h -> 5m
if (durationSecs <= 86400) return 600; // 1d -> 10m
if (durationSecs <= 259200) return 1800; // 3d -> 30m
return 3600; // 7d -> 1h
}
// 格式化时间标签
formatTimeLabel(date, durationSecs) {
if (durationSecs <= 86400) {
// 1天内只显示时间
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
} else {
// 超过1天显示日期和时间
return date.toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
});
}
}
// 查找最接近的数据点
findNearestStats(statsMap, timestamp, interval) {
// 精确匹配
if (statsMap.has(timestamp)) {
return statsMap.get(timestamp);
}
// 在间隔范围内查找
for (let offset = 1; offset < interval; offset++) {
if (statsMap.has(timestamp + offset)) {
return statsMap.get(timestamp + offset);
}
if (statsMap.has(timestamp - offset)) {
return statsMap.get(timestamp - offset);
}
}
return null;
}
parseDuration(dur) {
const match = dur.match(/^(\d+)([mhd])$/);
if (!match) return 3600;
const value = parseInt(match[1]);
const unit = match[2];
switch (unit) {
case 'm': return value * 60;
case 'h': return value * 3600;
case 'd': return value * 86400;
default: return 3600;
}
}
handleTimeRangeChange(e) {
const { duration, endTime, isLiveMode } = e.detail;
this.duration = duration;
this.endTime = endTime;
this.isLiveMode = isLiveMode;
this.loadStatsForTimeRange();
}
handleTimeRangeSelect(e) {
const { start, end } = e.detail;
if (!start || !end) return;
// 计算选择的时间范围对应的 duration
const durationSecs = end - start;
// 设置为非实时模式,结束时间为选择的结束时间
this.isLiveMode = false;
this.endTime = end;
// 根据选择的秒数设置合适的 duration
if (durationSecs <= 300) this.duration = '5m';
else if (durationSecs <= 900) this.duration = '15m';
else if (durationSecs <= 1800) this.duration = '30m';
else if (durationSecs <= 3600) this.duration = '1h';
else if (durationSecs <= 10800) this.duration = '3h';
else if (durationSecs <= 21600) this.duration = '6h';
else if (durationSecs <= 43200) this.duration = '12h';
else if (durationSecs <= 86400) this.duration = '1d';
else if (durationSecs <= 259200) this.duration = '3d';
else this.duration = '7d';
this.loadStatsForTimeRange();
}
handleQueueClick(e) {
const { queue } = e.detail;
this.currentQueue = queue;
this.currentTab = 'active';
this.currentPage = 1;
this.modalOpen = true;
const url = `${this.rootPath}/queues/${queue}/${this.currentTab}?page=1`;
history.pushState({ queue, tab: this.currentTab, page: 1 }, '', url);
}
handleModalClose() {
this.modalOpen = false;
history.pushState({}, '', `${this.rootPath}/`);
}
handleTabChange(e) {
const { tab } = e.detail;
this.currentTab = tab;
this.currentPage = 1;
const url = `${this.rootPath}/queues/${this.currentQueue}/${tab}?page=1`;
history.pushState({ queue: this.currentQueue, tab, page: 1 }, '', url);
}
handlePageChange(e) {
const { page } = e.detail;
this.currentPage = page;
const url = `${this.rootPath}/queues/${this.currentQueue}/${this.currentTab}?page=${page}`;
history.pushState({ queue: this.currentQueue, tab: this.currentTab, page }, '', url);
}
render() {
return html`
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">Tasks Overview</span>
<time-range-picker
.duration=${this.duration}
.endTime=${this.endTime}
.isLiveMode=${this.isLiveMode}
@change=${this.handleTimeRangeChange}
></time-range-picker>
</div>
<div class="chart-container">
<tasks-chart
.data=${this.chartData}
.timestamps=${this.chartData.timestamps || []}
@time-range-select=${this.handleTimeRangeSelect}
></tasks-chart>
</div>
</div>
<div class="table-card">
${this.loading ? html`
<div class="loading">
<div class="loading-spinner"></div>
<div>Loading...</div>
</div>
` : html`
<queue-table
.queues=${this.queues}
.rootPath=${this.rootPath}
@queue-click=${this.handleQueueClick}
></queue-table>
`}
</div>
<queue-modal
.open=${this.modalOpen}
.queue=${this.currentQueue}
.tab=${this.currentTab}
.page=${this.currentPage}
.rootPath=${this.rootPath}
@close=${this.handleModalClose}
@tab-change=${this.handleTabChange}
@page-change=${this.handlePageChange}
></queue-modal>
`;
}
}
customElements.define('taskq-app', TaskqApp);

View File

@@ -0,0 +1,80 @@
import { LitElement, html, css } from 'lit';
class HelpTooltip extends LitElement {
static properties = {
text: { type: String }
};
static styles = css`
:host {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
background: #616161;
color: #9e9e9e;
font-size: 10px;
cursor: help;
}
.icon:hover {
background: #757575;
color: #e0e0e0;
}
.icon:hover + .tooltip {
display: block;
}
.tooltip {
display: none;
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 8px;
padding: 8px 12px;
background: #212121;
color: #e0e0e0;
font-size: 12px;
font-weight: normal;
border-radius: 4px;
white-space: nowrap;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.tooltip::before {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-bottom-color: #212121;
}
`;
constructor() {
super();
this.text = '';
}
render() {
return html`
<span class="icon">?</span>
<span class="tooltip">${this.text}</span>
`;
}
}
customElements.define('help-tooltip', HelpTooltip);

View File

@@ -0,0 +1,869 @@
import { LitElement, html, css } from 'lit';
import { Chart, registerables } from 'chart.js';
import './time-range-picker.js';
Chart.register(...registerables);
// 十字准星 + 拖拽选择插件(与 tasks-chart 共用逻辑)
const crosshairPlugin = {
id: 'queueCrosshair',
afterEvent(chart, args) {
const event = args.event;
if (event.type === 'mousemove') {
chart.crosshair = { x: event.x, y: event.y };
} else if (event.type === 'mouseout') {
chart.crosshair = null;
}
},
afterDraw(chart) {
const ctx = chart.ctx;
const topY = chart.scales.y.top;
const bottomY = chart.scales.y.bottom;
const leftX = chart.scales.x.left;
const rightX = chart.scales.x.right;
// 绘制选择区域
if (chart.dragSelect && chart.dragSelect.startX !== null) {
const { startX, currentX } = chart.dragSelect;
const x1 = Math.max(leftX, Math.min(startX, currentX));
const x2 = Math.min(rightX, Math.max(startX, currentX));
ctx.save();
ctx.fillStyle = 'rgba(255, 193, 7, 0.2)';
ctx.fillRect(x1, topY, x2 - x1, bottomY - topY);
ctx.restore();
}
// 绘制十字准星
if (!chart.crosshair) return;
const { x, y } = chart.crosshair;
if (x < leftX || x > rightX || y < topY || y > bottomY) return;
ctx.save();
ctx.setLineDash([4, 4]);
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
ctx.beginPath();
ctx.moveTo(x, topY);
ctx.lineTo(x, bottomY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(leftX, y);
ctx.lineTo(rightX, y);
ctx.stroke();
ctx.restore();
}
};
Chart.register(crosshairPlugin);
class QueueModal extends LitElement {
static properties = {
open: { type: Boolean },
queue: { type: String },
tab: { type: String },
page: { type: Number },
rootPath: { type: String },
tasks: { type: Array, state: true },
total: { type: Number, state: true },
loading: { type: Boolean, state: true },
chartData: { type: Object, state: true },
queueInfo: { type: Object, state: true },
// Time range state
duration: { type: String, state: true },
endTime: { type: Number, state: true },
isLiveMode: { type: Boolean, state: true },
timestamps: { type: Array, state: true }
};
static styles = css`
:host {
display: block;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: #424242;
}
.modal.open {
display: block;
}
.modal-content {
background-color: #424242;
width: 100%;
height: 100%;
overflow: hidden;
}
.modal-header {
background: #515151;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #616161;
}
.modal-header h2 {
font-size: 1.1em;
font-weight: 500;
margin: 0;
}
.close-btn {
color: #9e9e9e;
font-size: 24px;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: transparent;
border: none;
}
.close-btn:hover {
background: #616161;
color: #e0e0e0;
}
.modal-body {
padding: 20px;
height: calc(100vh - 60px);
overflow-y: auto;
}
.queue-chart-card {
margin-bottom: 15px;
background: #424242;
border-radius: 4px;
}
.queue-chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
border-bottom: 1px solid #515151;
}
.queue-chart-title {
font-size: 0.9em;
color: #bdbdbd;
}
.queue-chart-container {
height: 180px;
padding: 10px;
}
.task-tabs {
display: flex;
gap: 5px;
margin-bottom: 20px;
background: #424242;
padding: 5px;
border-radius: 4px;
}
.task-tab {
padding: 8px 16px;
cursor: pointer;
border: none;
background: transparent;
color: #9e9e9e;
border-radius: 4px;
font-size: 0.85em;
display: flex;
align-items: center;
gap: 6px;
}
.task-tab:hover {
color: #e0e0e0;
}
.task-tab.active {
background: #42a5f5;
color: #fff;
}
.tab-count {
font-size: 0.8em;
padding: 2px 6px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.15);
}
.task-tab.active .tab-count {
background: rgba(255, 255, 255, 0.25);
}
.task-list {
max-height: 400px;
overflow-y: auto;
}
.task-item {
background: #5a5a5a;
border: 1px solid #616161;
border-radius: 4px;
padding: 12px;
margin-bottom: 8px;
}
.task-item:hover {
border-color: #42a5f5;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.task-type {
font-weight: 500;
color: #42a5f5;
}
.task-info {
display: flex;
gap: 12px;
align-items: center;
font-size: 0.8em;
color: #9e9e9e;
}
.task-id {
font-family: monospace;
}
.task-retried {
color: #ffa726;
}
.task-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
.retry-btn {
padding: 4px 12px;
font-size: 0.8em;
background: #42a5f5;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.retry-btn:hover {
background: #1e88e5;
}
.retry-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.task-payload {
font-family: monospace;
font-size: 0.75em;
color: #9e9e9e;
background: #424242;
padding: 8px;
border-radius: 4px;
margin-top: 8px;
word-break: break-all;
}
.task-meta {
display: flex;
gap: 12px;
margin-top: 8px;
font-size: 0.8em;
color: #9e9e9e;
}
.task-error {
color: #ef5350;
background: rgba(239, 83, 80, 0.1);
padding: 6px 8px;
border-radius: 4px;
margin-top: 8px;
font-size: 0.8em;
}
.pagination {
display: flex;
justify-content: center;
gap: 6px;
margin-top: 16px;
}
.pagination button {
padding: 6px 12px;
border: 1px solid #616161;
background: #424242;
color: #e0e0e0;
cursor: pointer;
border-radius: 4px;
font-size: 0.85em;
}
.pagination button:hover {
background: #5a5a5a;
}
.pagination button.active {
background: #42a5f5;
border-color: #42a5f5;
}
.pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.loading {
text-align: center;
padding: 60px;
color: #9e9e9e;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #616161;
border-top-color: #42a5f5;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state {
text-align: center;
padding: 60px;
color: #9e9e9e;
}
canvas {
width: 100% !important;
height: 100% !important;
}
`;
static tabs = ['active', 'pending', 'scheduled', 'retry', 'archived', 'completed'];
constructor() {
super();
this.open = false;
this.queue = '';
this.tab = 'active';
this.page = 1;
this.rootPath = '/monitor';
this.tasks = [];
this.total = 0;
this.loading = false;
this.chartData = { labels: [], datasets: {} };
this.chart = null;
this.pageSize = 20;
this.queueInfo = null;
// Time range defaults
this.duration = '1h';
this.endTime = null;
this.isLiveMode = true;
this.timestamps = [];
this.isDragging = false;
}
updated(changedProperties) {
if (changedProperties.has('open') && this.open) {
this.loadQueueInfo();
this.loadQueueHistory();
this.loadTasks();
}
if ((changedProperties.has('tab') || changedProperties.has('page')) && this.open) {
this.loadTasks();
}
if (changedProperties.has('chartData') && this.chart) {
this.updateChart();
}
}
async loadQueueInfo() {
try {
const response = await fetch(`${this.rootPath}/api/queues/${this.queue}`);
if (!response.ok) return;
this.queueInfo = await response.json();
} catch (err) {
console.error('Failed to load queue info:', err);
}
}
parseDuration(dur) {
const match = dur.match(/^(\d+)([mhd])$/);
if (!match) return 3600;
const value = parseInt(match[1]);
const unit = match[2];
switch (unit) {
case 'm': return value * 60;
case 'h': return value * 3600;
case 'd': return value * 86400;
default: return 3600;
}
}
getInterval(durationSecs) {
if (durationSecs <= 300) return 2;
if (durationSecs <= 900) return 5;
if (durationSecs <= 1800) return 10;
if (durationSecs <= 3600) return 20;
if (durationSecs <= 10800) return 60;
if (durationSecs <= 21600) return 120;
if (durationSecs <= 43200) return 300;
if (durationSecs <= 86400) return 600;
if (durationSecs <= 259200) return 1800;
return 3600;
}
formatTimeLabel(date, durationSecs) {
if (durationSecs <= 86400) {
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
} else {
return date.toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
});
}
}
findNearestStats(statsMap, timestamp, interval) {
if (statsMap.has(timestamp)) return statsMap.get(timestamp);
for (let offset = 1; offset < interval; offset++) {
if (statsMap.has(timestamp + offset)) return statsMap.get(timestamp + offset);
if (statsMap.has(timestamp - offset)) return statsMap.get(timestamp - offset);
}
return null;
}
async loadQueueHistory() {
const durationSecs = this.parseDuration(this.duration);
const end = this.endTime !== null ? this.endTime : Math.floor(Date.now() / 1000);
const start = end - durationSecs;
try {
const response = await fetch(
`${this.rootPath}/api/stats/${this.queue}?start=${start}&end=${end}&limit=10000`
);
if (!response.ok) return;
const stats = await response.json();
// Build stats map
const statsMap = new Map();
if (stats && stats.length > 0) {
stats.forEach(s => statsMap.set(s.t, s));
}
// Calculate interval
const interval = this.getInterval(durationSecs);
const alignedStart = Math.floor(start / interval) * interval;
const newData = {
labels: [], datasets: {
active: [], pending: [], scheduled: [], retry: [],
archived: [], completed: [], succeeded: [], failed: []
}
};
const newTimestamps = [];
for (let t = alignedStart; t <= end; t += interval) {
const date = new Date(t * 1000);
const timeLabel = this.formatTimeLabel(date, durationSecs);
newData.labels.push(timeLabel);
newTimestamps.push(t);
const s = this.findNearestStats(statsMap, t, interval);
newData.datasets.active.push(s ? (s.a || 0) : null);
newData.datasets.pending.push(s ? (s.p || 0) : null);
newData.datasets.scheduled.push(s ? (s.s || 0) : null);
newData.datasets.retry.push(s ? (s.r || 0) : null);
newData.datasets.archived.push(s ? (s.ar || 0) : null);
newData.datasets.completed.push(s ? (s.c || 0) : null);
newData.datasets.succeeded.push(s ? (s.su || 0) : null);
newData.datasets.failed.push(s ? (s.f || 0) : null);
}
this.chartData = newData;
this.timestamps = newTimestamps;
await this.updateComplete;
this.initChart();
} catch (err) {
console.error('Failed to load queue history:', err);
}
}
async loadTasks() {
this.loading = true;
try {
const response = await fetch(
`${this.rootPath}/api/tasks/${this.queue}/${this.tab}?page=${this.page}&page_size=${this.pageSize}`
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
this.tasks = data.tasks || [];
this.total = data.total || 0;
} catch (err) {
console.error('Failed to load tasks:', err);
this.tasks = [];
this.total = 0;
} finally {
this.loading = false;
}
}
initChart() {
const canvas = this.shadowRoot.querySelector('canvas');
if (!canvas) return;
if (this.chart) {
this.chart.destroy();
}
const ctx = canvas.getContext('2d');
this.chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [
{ label: 'Active', data: [], borderColor: '#42a5f5', backgroundColor: 'transparent', fill: false, hidden: true },
{ label: 'Pending', data: [], borderColor: '#5c6bc0', backgroundColor: 'transparent', fill: false },
{ label: 'Scheduled', data: [], borderColor: '#ffa726', backgroundColor: 'transparent', fill: false, hidden: true },
{ label: 'Retry', data: [], borderColor: '#ef5350', backgroundColor: 'transparent', fill: false, hidden: true },
{ label: 'Archived', data: [], borderColor: '#ab47bc', backgroundColor: 'transparent', fill: false, hidden: true },
{ label: 'Completed', data: [], borderColor: '#66bb6a', backgroundColor: 'transparent', fill: false, hidden: true },
{ label: 'Succeeded', data: [], borderColor: '#81c784', backgroundColor: 'transparent', fill: false, borderDash: [5, 5], hidden: true },
{ label: 'Failed', data: [], borderColor: '#e57373', backgroundColor: 'transparent', fill: false, borderDash: [5, 5], hidden: true }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 0 },
clip: false,
interaction: { mode: 'index', intersect: false },
hover: { mode: 'index', intersect: false },
plugins: {
legend: {
position: 'bottom',
labels: { color: '#e0e0e0', padding: 10, usePointStyle: true, pointStyle: 'circle', font: { size: 11 } }
},
tooltip: { enabled: true, backgroundColor: 'rgba(30, 30, 30, 0.9)', titleColor: '#e0e0e0', bodyColor: '#e0e0e0' }
},
scales: {
x: { grid: { color: '#616161' }, ticks: { color: '#9e9e9e', maxTicksLimit: 8 } },
y: { grid: { color: '#616161' }, ticks: { color: '#9e9e9e' }, beginAtZero: true }
},
elements: {
point: {
radius: 0,
hoverRadius: 5,
hitRadius: 10,
hoverBackgroundColor: (ctx) => ctx.dataset.borderColor,
hoverBorderColor: (ctx) => ctx.dataset.borderColor,
hoverBorderWidth: 0
},
line: { tension: 0.3, borderWidth: 1.5, spanGaps: false }
}
}
});
this.setupDragSelect(canvas);
this.updateChart();
}
setupDragSelect(canvas) {
canvas.addEventListener('mousedown', (e) => {
if (!this.chart) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const leftX = this.chart.scales.x.left;
const rightX = this.chart.scales.x.right;
if (x >= leftX && x <= rightX) {
this.isDragging = true;
this.chart.dragSelect = { startX: x, currentX: x };
this.chart.update('none');
}
});
canvas.addEventListener('mousemove', (e) => {
if (!this.isDragging || !this.chart) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
this.chart.dragSelect.currentX = x;
this.chart.update('none');
});
canvas.addEventListener('mouseup', (e) => {
if (!this.isDragging || !this.chart) return;
this.isDragging = false;
const { startX, currentX } = this.chart.dragSelect;
const leftX = this.chart.scales.x.left;
const rightX = this.chart.scales.x.right;
const x1 = Math.max(leftX, Math.min(startX, currentX));
const x2 = Math.min(rightX, Math.max(startX, currentX));
if (Math.abs(x2 - x1) > 10 && this.timestamps.length > 0) {
const startIndex = Math.round((x1 - leftX) / (rightX - leftX) * (this.timestamps.length - 1));
const endIndex = Math.round((x2 - leftX) / (rightX - leftX) * (this.timestamps.length - 1));
if (startIndex >= 0 && endIndex < this.timestamps.length) {
const startTime = this.timestamps[startIndex];
const endTime = this.timestamps[endIndex];
this.handleTimeRangeSelect(startTime, endTime);
}
}
this.chart.dragSelect = null;
this.chart.update('none');
});
canvas.addEventListener('mouseleave', () => {
if (this.isDragging && this.chart) {
this.isDragging = false;
this.chart.dragSelect = null;
this.chart.update('none');
}
});
}
handleTimeRangeSelect(start, end) {
const durationSecs = end - start;
this.isLiveMode = false;
this.endTime = end;
if (durationSecs <= 300) this.duration = '5m';
else if (durationSecs <= 900) this.duration = '15m';
else if (durationSecs <= 1800) this.duration = '30m';
else if (durationSecs <= 3600) this.duration = '1h';
else if (durationSecs <= 10800) this.duration = '3h';
else if (durationSecs <= 21600) this.duration = '6h';
else if (durationSecs <= 43200) this.duration = '12h';
else if (durationSecs <= 86400) this.duration = '1d';
else if (durationSecs <= 259200) this.duration = '3d';
else this.duration = '7d';
this.loadQueueHistory();
}
handleChartTimeRangeChange(e) {
const { duration, endTime, isLiveMode } = e.detail;
this.duration = duration;
this.endTime = endTime;
this.isLiveMode = isLiveMode;
this.loadQueueHistory();
}
updateChart() {
if (!this.chart || !this.chartData) return;
this.chart.data.labels = this.chartData.labels || [];
this.chart.data.datasets[0].data = this.chartData.datasets?.active || [];
this.chart.data.datasets[1].data = this.chartData.datasets?.pending || [];
this.chart.data.datasets[2].data = this.chartData.datasets?.scheduled || [];
this.chart.data.datasets[3].data = this.chartData.datasets?.retry || [];
this.chart.data.datasets[4].data = this.chartData.datasets?.archived || [];
this.chart.data.datasets[5].data = this.chartData.datasets?.completed || [];
this.chart.data.datasets[6].data = this.chartData.datasets?.succeeded || [];
this.chart.data.datasets[7].data = this.chartData.datasets?.failed || [];
this.chart.update('none');
}
handleClose() {
if (this.chart) {
this.chart.destroy();
this.chart = null;
}
this.dispatchEvent(new CustomEvent('close'));
}
handleTabClick(tab) {
this.dispatchEvent(new CustomEvent('tab-change', { detail: { tab } }));
}
getTabCount(tab) {
if (!this.queueInfo) return 0;
switch (tab) {
case 'active': return this.queueInfo.Active || 0;
case 'pending': return this.queueInfo.Pending || 0;
case 'scheduled': return this.queueInfo.Scheduled || 0;
case 'retry': return this.queueInfo.Retry || 0;
case 'archived': return this.queueInfo.Archived || 0;
case 'completed': return this.queueInfo.Completed || 0;
default: return 0;
}
}
handlePageClick(page) {
this.dispatchEvent(new CustomEvent('page-change', { detail: { page } }));
}
async retryTask(taskId) {
try {
const response = await fetch(
`${this.rootPath}/api/tasks/${this.queue}/archived/${taskId}/retry`,
{ method: 'POST' }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
// Reload tasks after retry
this.loadTasks();
} catch (err) {
console.error('Failed to retry task:', err);
alert('Failed to retry task: ' + err.message);
}
}
renderPagination() {
if (!this.total) return '';
const totalPages = Math.ceil(this.total / this.pageSize);
const startPage = Math.max(1, this.page - 2);
const endPage = Math.min(totalPages, this.page + 2);
const pages = [];
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return html`
<div class="pagination">
<button ?disabled=${this.page <= 1} @click=${() => this.handlePageClick(this.page - 1)}>
Prev
</button>
${pages.map(p => html`
<button class="${p === this.page ? 'active' : ''}" @click=${() => this.handlePageClick(p)}>
${p}
</button>
`)}
<button ?disabled=${this.page >= totalPages} @click=${() => this.handlePageClick(this.page + 1)}>
Next
</button>
</div>
`;
}
renderTasks() {
if (this.loading) {
return html`
<div class="loading">
<div class="loading-spinner"></div>
<div>Loading...</div>
</div>
`;
}
if (!this.tasks || this.tasks.length === 0) {
return html`<div class="empty-state">No tasks</div>`;
}
return html`
<div class="task-list">
${this.tasks.map(task => html`
<div class="task-item">
<div class="task-header">
<div class="task-type">${task.type}</div>
<div class="task-info">
${task.retried > 0 ? html`<span class="task-retried">Retried: ${task.retried}</span>` : ''}
<span class="task-id">${task.id}</span>
</div>
</div>
${task.payload ? html`<div class="task-payload">${task.payload}</div>` : ''}
<div class="task-meta">
${task.next_process ? html`<span>Next: ${new Date(task.next_process).toLocaleString()}</span>` : ''}
${task.completed_at ? html`<span>Completed: ${new Date(task.completed_at).toLocaleString()}</span>` : ''}
</div>
${task.last_error ? html`<div class="task-error">${task.last_error}</div>` : ''}
${this.tab === 'archived' ? html`
<div class="task-actions">
<button class="retry-btn" @click=${() => this.retryTask(task.id)}>Retry</button>
</div>
` : ''}
</div>
`)}
</div>
${this.renderPagination()}
`;
}
render() {
return html`
<div class="modal ${this.open ? 'open' : ''}">
<div class="modal-content">
<div class="modal-header">
<h2>Queue: ${this.queue}</h2>
<button class="close-btn" @click=${this.handleClose}>&times;</button>
</div>
<div class="modal-body">
<div class="queue-chart-card">
<div class="queue-chart-header">
<span class="queue-chart-title">Queue Statistics</span>
<time-range-picker
.duration=${this.duration}
.endTime=${this.endTime}
.isLiveMode=${this.isLiveMode}
@change=${this.handleChartTimeRangeChange}
></time-range-picker>
</div>
<div class="queue-chart-container">
<canvas></canvas>
</div>
</div>
<div class="task-tabs">
${QueueModal.tabs.map(t => html`
<button
class="task-tab ${t === this.tab ? 'active' : ''}"
@click=${() => this.handleTabClick(t)}
>
${t.charAt(0).toUpperCase() + t.slice(1)}
<span class="tab-count">${this.getTabCount(t)}</span>
</button>
`)}
</div>
${this.renderTasks()}
</div>
</div>
</div>
`;
}
}
customElements.define('queue-modal', QueueModal);

View File

@@ -0,0 +1,233 @@
import { LitElement, html, css } from 'lit';
import './help-tooltip.js';
class QueueTable extends LitElement {
static properties = {
queues: { type: Array },
rootPath: { type: String }
};
static styles = css`
:host {
display: block;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
background: #424242;
padding: 14px 16px;
text-align: left;
font-weight: 500;
color: #bdbdbd;
font-size: 0.9em;
border-bottom: 1px solid #616161;
}
.th-content {
display: flex;
align-items: center;
gap: 4px;
}
td {
padding: 14px 16px;
border-bottom: 1px solid #616161;
}
tr:last-child td {
border-bottom: none;
}
tbody tr:hover {
background: #5a5a5a;
}
.queue-name {
font-weight: 500;
color: #4fc3f7;
cursor: pointer;
}
.queue-name:hover {
text-decoration: underline;
}
.state-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.8em;
font-weight: 500;
background: #66bb6a;
color: #1b5e20;
}
.state-badge.paused {
background: #ffb74d;
color: #e65100;
}
.memory-value {
font-size: 0.9em;
color: #bdbdbd;
}
.latency-value {
color: #bdbdbd;
}
.action-btn {
background: transparent;
border: 1px solid #757575;
color: #bdbdbd;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.8em;
margin-right: 4px;
}
.action-btn:hover {
background: #616161;
color: #e0e0e0;
}
.action-btn.pause {
border-color: #ffb74d;
color: #ffb74d;
}
.action-btn.pause:hover {
background: rgba(255, 183, 77, 0.2);
}
.action-btn.resume {
border-color: #66bb6a;
color: #66bb6a;
}
.action-btn.resume:hover {
background: rgba(102, 187, 106, 0.2);
}
.empty-state {
text-align: center;
padding: 60px;
color: #9e9e9e;
}
`;
constructor() {
super();
this.queues = [];
this.rootPath = '/monitor';
}
handleQueueClick(queue) {
this.dispatchEvent(new CustomEvent('queue-click', {
detail: { queue: queue.name }
}));
}
async togglePause(queue) {
const action = queue.paused ? 'unpause' : 'pause';
try {
const response = await fetch(
`${this.rootPath}/api/queues/${queue.name}/${action}`,
{ method: 'POST' }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
// 触发刷新事件
this.dispatchEvent(new CustomEvent('queue-updated'));
} catch (err) {
console.error(`Failed to ${action} queue:`, err);
alert(`Failed to ${action} queue: ${err.message}`);
}
}
formatMemory(bytes) {
if (!bytes || bytes === 0) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1024 / 1024).toFixed(2) + ' MB';
}
renderTh(label, tooltip) {
if (!tooltip) {
return html`<th>${label}</th>`;
}
return html`
<th>
<span class="th-content">
${label}
<help-tooltip text="${tooltip}"></help-tooltip>
</span>
</th>
`;
}
render() {
if (!this.queues || this.queues.length === 0) {
return html`<div class="empty-state">No queues</div>`;
}
return html`
<table>
<thead>
<tr>
<th>Queue</th>
${this.renderTh('State', 'run: 正常处理任务 | paused: 暂停处理新任务')}
${this.renderTh('Active', '正在被 worker 处理的任务数')}
${this.renderTh('Pending', '等待处理的任务数')}
${this.renderTh('Scheduled', '定时/延迟任务,到达指定时间后进入 Pending')}
${this.renderTh('Retry', '处理失败后等待重试的任务数')}
${this.renderTh('Archived', '超过最大重试次数的失败任务')}
${this.renderTh('Memory', '队列在 Redis 中占用的内存')}
${this.renderTh('Latency', '最老 Pending 任务的等待时间,反映处理及时性')}
<th>Actions</th>
</tr>
</thead>
<tbody>
${this.queues.map(queue => html`
<tr>
<td>
<span class="queue-name" @click=${() => this.handleQueueClick(queue)}>
${queue.name}
</span>
</td>
<td>
<span class="state-badge ${queue.paused ? 'paused' : ''}">
${queue.paused ? 'paused' : 'run'}
</span>
</td>
<td>${queue.active || 0}</td>
<td>${queue.pending || 0}</td>
<td>${queue.scheduled || 0}</td>
<td>${queue.retry || 0}</td>
<td>${queue.archived || 0}</td>
<td class="memory-value">${this.formatMemory(queue.memory_usage)}</td>
<td class="latency-value">${queue.latency || 0}ms</td>
<td>
<button class="action-btn" @click=${() => this.handleQueueClick(queue)}>
View
</button>
<button class="action-btn ${queue.paused ? 'resume' : 'pause'}"
@click=${() => this.togglePause(queue)}>
${queue.paused ? 'Resume' : 'Pause'}
</button>
</td>
</tr>
`)}
</tbody>
</table>
`;
}
}
customElements.define('queue-table', QueueTable);

View File

@@ -0,0 +1,267 @@
import { LitElement, html, css } from 'lit';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
// 十字准星 + 拖拽选择插件
const crosshairPlugin = {
id: 'crosshair',
afterEvent(chart, args) {
const event = args.event;
if (event.type === 'mousemove') {
chart.crosshair = { x: event.x, y: event.y };
} else if (event.type === 'mouseout') {
chart.crosshair = null;
}
},
afterDraw(chart) {
const ctx = chart.ctx;
const topY = chart.scales.y.top;
const bottomY = chart.scales.y.bottom;
const leftX = chart.scales.x.left;
const rightX = chart.scales.x.right;
// 绘制选择区域
if (chart.dragSelect && chart.dragSelect.startX !== null) {
const { startX, currentX } = chart.dragSelect;
const x1 = Math.max(leftX, Math.min(startX, currentX));
const x2 = Math.min(rightX, Math.max(startX, currentX));
ctx.save();
ctx.fillStyle = 'rgba(255, 193, 7, 0.2)';
ctx.fillRect(x1, topY, x2 - x1, bottomY - topY);
ctx.restore();
}
// 绘制十字准星
if (!chart.crosshair) return;
const { x, y } = chart.crosshair;
// 检查鼠标是否在图表区域内
if (x < leftX || x > rightX || y < topY || y > bottomY) return;
ctx.save();
ctx.setLineDash([4, 4]);
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
// 垂直线
ctx.beginPath();
ctx.moveTo(x, topY);
ctx.lineTo(x, bottomY);
ctx.stroke();
// 水平线跟随鼠标
ctx.beginPath();
ctx.moveTo(leftX, y);
ctx.lineTo(rightX, y);
ctx.stroke();
ctx.restore();
}
};
Chart.register(crosshairPlugin);
class TasksChart extends LitElement {
static properties = {
data: { type: Object },
timestamps: { type: Array }
};
static styles = css`
:host {
display: block;
width: 100%;
height: 100%;
}
canvas {
width: 100% !important;
height: 100% !important;
}
`;
constructor() {
super();
this.data = { labels: [], datasets: {} };
this.timestamps = [];
this.chart = null;
this.isDragging = false;
}
firstUpdated() {
this.initChart();
this.setupDragSelect();
}
setupDragSelect() {
const canvas = this.shadowRoot.querySelector('canvas');
if (!canvas) return;
canvas.addEventListener('mousedown', (e) => {
if (!this.chart) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const leftX = this.chart.scales.x.left;
const rightX = this.chart.scales.x.right;
if (x >= leftX && x <= rightX) {
this.isDragging = true;
this.chart.dragSelect = { startX: x, currentX: x };
this.chart.update('none');
}
});
canvas.addEventListener('mousemove', (e) => {
if (!this.isDragging || !this.chart) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
this.chart.dragSelect.currentX = x;
this.chart.update('none');
});
canvas.addEventListener('mouseup', (e) => {
if (!this.isDragging || !this.chart) return;
this.isDragging = false;
const { startX, currentX } = this.chart.dragSelect;
const leftX = this.chart.scales.x.left;
const rightX = this.chart.scales.x.right;
// 计算选择的时间范围
const x1 = Math.max(leftX, Math.min(startX, currentX));
const x2 = Math.min(rightX, Math.max(startX, currentX));
// 最小选择宽度检查
if (Math.abs(x2 - x1) > 10) {
const startIndex = Math.round((x1 - leftX) / (rightX - leftX) * (this.timestamps.length - 1));
const endIndex = Math.round((x2 - leftX) / (rightX - leftX) * (this.timestamps.length - 1));
if (startIndex >= 0 && endIndex < this.timestamps.length) {
const startTime = this.timestamps[startIndex];
const endTime = this.timestamps[endIndex];
this.dispatchEvent(new CustomEvent('time-range-select', {
detail: { start: startTime, end: endTime },
bubbles: true,
composed: true
}));
}
}
this.chart.dragSelect = null;
this.chart.update('none');
});
canvas.addEventListener('mouseleave', () => {
if (this.isDragging && this.chart) {
this.isDragging = false;
this.chart.dragSelect = null;
this.chart.update('none');
}
});
}
initChart() {
const canvas = this.shadowRoot.querySelector('canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
this.chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [
{ label: 'Active', data: [], borderColor: '#42a5f5', backgroundColor: 'transparent', fill: false, hidden: true },
{ label: 'Pending', data: [], borderColor: '#5c6bc0', backgroundColor: 'transparent', fill: false },
{ label: 'Scheduled', data: [], borderColor: '#ffa726', backgroundColor: 'transparent', fill: false, hidden: true },
{ label: 'Retry', data: [], borderColor: '#ef5350', backgroundColor: 'transparent', fill: false, hidden: true },
{ label: 'Archived', data: [], borderColor: '#ab47bc', backgroundColor: 'transparent', fill: false, hidden: true },
{ label: 'Completed', data: [], borderColor: '#66bb6a', backgroundColor: 'transparent', fill: false, hidden: true },
{ label: 'Succeeded', data: [], borderColor: '#81c784', backgroundColor: 'transparent', fill: false, borderDash: [5, 5], hidden: true },
{ label: 'Failed', data: [], borderColor: '#e57373', backgroundColor: 'transparent', fill: false, borderDash: [5, 5], hidden: true }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 0 },
clip: false,
interaction: { mode: 'index', intersect: false },
hover: { mode: 'index', intersect: false },
plugins: {
legend: {
position: 'bottom',
labels: {
color: '#e0e0e0',
padding: 15,
usePointStyle: true,
pointStyle: 'circle'
}
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(30, 30, 30, 0.9)',
titleColor: '#e0e0e0',
bodyColor: '#e0e0e0',
borderColor: '#616161',
borderWidth: 1,
padding: 10,
displayColors: true
}
},
scales: {
x: {
grid: { color: '#616161' },
ticks: { color: '#9e9e9e', maxTicksLimit: 10 }
},
y: {
grid: { color: '#616161' },
ticks: { color: '#9e9e9e' },
beginAtZero: true
}
},
elements: {
point: {
radius: 0,
hoverRadius: 5,
hitRadius: 10,
hoverBackgroundColor: (ctx) => ctx.dataset.borderColor,
hoverBorderColor: (ctx) => ctx.dataset.borderColor,
hoverBorderWidth: 0
},
line: { tension: 0.3, borderWidth: 1.5, spanGaps: false }
}
}
});
this.updateChart();
}
updated(changedProperties) {
if (changedProperties.has('data') && this.chart) {
this.updateChart();
}
}
updateChart() {
if (!this.chart || !this.data) return;
this.chart.data.labels = this.data.labels || [];
this.chart.data.datasets[0].data = this.data.datasets?.active || [];
this.chart.data.datasets[1].data = this.data.datasets?.pending || [];
this.chart.data.datasets[2].data = this.data.datasets?.scheduled || [];
this.chart.data.datasets[3].data = this.data.datasets?.retry || [];
this.chart.data.datasets[4].data = this.data.datasets?.archived || [];
this.chart.data.datasets[5].data = this.data.datasets?.completed || [];
this.chart.data.datasets[6].data = this.data.datasets?.succeeded || [];
this.chart.data.datasets[7].data = this.data.datasets?.failed || [];
this.chart.update('none');
}
render() {
return html`<canvas></canvas>`;
}
}
customElements.define('tasks-chart', TasksChart);

View File

@@ -0,0 +1,165 @@
import { LitElement, html, css } from 'lit';
class TimeRangePicker extends LitElement {
static properties = {
duration: { type: String },
endTime: { type: Number },
isLiveMode: { type: Boolean }
};
static durations = ['5m', '15m', '30m', '1h', '3h', '6h', '12h', '1d', '3d', '7d'];
static styles = css`
:host {
display: flex;
gap: 10px;
align-items: center;
}
.time-control {
display: flex;
align-items: center;
background: #424242;
border: 1px solid #616161;
border-radius: 4px;
overflow: hidden;
}
button {
background: transparent;
border: none;
color: #9e9e9e;
padding: 6px 10px;
cursor: pointer;
font-size: 1em;
}
button:hover {
background: #515151;
color: #e0e0e0;
}
.value {
padding: 6px 12px;
color: #e0e0e0;
font-size: 0.85em;
min-width: 40px;
text-align: center;
}
.end-value {
cursor: pointer;
}
.end-value:hover {
background: #515151;
}
.reset-btn:hover {
color: #ef5350;
}
`;
constructor() {
super();
this.duration = '1h';
this.endTime = null;
this.isLiveMode = true;
}
get durationIndex() {
return TimeRangePicker.durations.indexOf(this.duration);
}
parseDuration(dur) {
const match = dur.match(/^(\d+)([mhd])$/);
if (!match) return 3600;
const value = parseInt(match[1]);
const unit = match[2];
switch (unit) {
case 'm': return value * 60;
case 'h': return value * 3600;
case 'd': return value * 86400;
default: return 3600;
}
}
adjustDuration(delta) {
const durations = TimeRangePicker.durations;
const newIndex = Math.max(0, Math.min(durations.length - 1, this.durationIndex + delta));
this.duration = durations[newIndex];
this.emitChange();
}
adjustEndTime(delta) {
const durationSecs = this.parseDuration(this.duration);
const step = durationSecs / 2;
let newEndTime = this.endTime;
if (newEndTime === null) {
newEndTime = Math.floor(Date.now() / 1000);
}
newEndTime += delta * step;
const now = Math.floor(Date.now() / 1000);
if (newEndTime >= now) {
this.resetToNow();
return;
}
this.endTime = newEndTime;
this.isLiveMode = false;
this.emitChange();
}
resetToNow() {
this.endTime = null;
this.isLiveMode = true;
this.emitChange();
}
emitChange() {
this.dispatchEvent(new CustomEvent('change', {
detail: {
duration: this.duration,
endTime: this.endTime,
isLiveMode: this.isLiveMode
}
}));
}
formatEndTime() {
if (this.endTime === null) {
return 'now';
}
const date = new Date(this.endTime * 1000);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).replace(/\//g, '-');
}
render() {
return html`
<div class="time-control">
<button @click=${() => this.adjustDuration(-1)}></button>
<span class="value">${this.duration}</span>
<button @click=${() => this.adjustDuration(1)}>+</button>
</div>
<div class="time-control">
<button @click=${() => this.adjustEndTime(-1)}></button>
<span class="value end-value" @click=${this.resetToNow}>
${this.formatEndTime()}
</span>
<button @click=${() => this.adjustEndTime(1)}></button>
<button class="reset-btn" @click=${this.resetToNow} title="Reset to now">×</button>
</div>
`;
}
}
customElements.define('time-range-picker', TimeRangePicker);

63
ui/index.html Normal file
View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TaskQ Monitor</title>
<script type="importmap">
{
"imports": {
"lit": "https://cdn.jsdelivr.net/npm/lit@3/+esm",
"lit/": "https://cdn.jsdelivr.net/npm/lit@3/",
"chart.js": "https://cdn.jsdelivr.net/npm/chart.js@4/+esm"
}
}
</script>
<script type="module" src="{{.RootPath}}/static/app.js"></script>
<link rel="stylesheet" href="{{.RootPath}}/static/styles.css">
<style>
.browser-warning {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #424242;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 40px;
text-align: center;
}
.browser-warning h1 {
color: #ef5350;
margin-bottom: 20px;
}
.browser-warning p {
margin-bottom: 15px;
line-height: 1.6;
}
.browser-warning a {
color: #42a5f5;
}
</style>
</head>
<body>
<div id="browser-warning" class="browser-warning">
<h1>Browser Not Supported</h1>
<p>TaskQ Monitor requires a modern browser with ES Module support.</p>
<p>Please upgrade to one of the following browsers:</p>
<p>
<a href="https://www.google.com/chrome/" target="_blank">Chrome 61+</a> |
<a href="https://www.mozilla.org/firefox/" target="_blank">Firefox 60+</a> |
<a href="https://www.apple.com/safari/" target="_blank">Safari 11+</a> |
<a href="https://www.microsoft.com/edge" target="_blank">Edge 79+</a>
</p>
</div>
<taskq-app root-path="{{.RootPath}}"></taskq-app>
<script nomodule>
document.getElementById('browser-warning').style.display = 'block';
document.querySelector('taskq-app').style.display = 'none';
</script>
</body>
</html>

406
ui/styles.css Normal file
View File

@@ -0,0 +1,406 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #424242;
color: #e0e0e0;
min-height: 100vh;
padding: 20px;
}
/* Chart Card */
.chart-card {
background: #515151;
border-radius: 4px;
padding: 20px;
border: 1px solid #616161;
margin-bottom: 20px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.chart-title {
font-size: 1.1em;
font-weight: 500;
}
.chart-container {
height: 280px;
position: relative;
}
/* Time Range Picker */
.time-range-picker {
display: flex;
gap: 10px;
align-items: center;
}
.time-control {
display: flex;
align-items: center;
background: #424242;
border: 1px solid #616161;
border-radius: 4px;
overflow: hidden;
}
.time-control button {
background: transparent;
border: none;
color: #9e9e9e;
padding: 6px 10px;
cursor: pointer;
font-size: 1em;
}
.time-control button:hover {
background: #515151;
color: #e0e0e0;
}
.time-control .value {
padding: 6px 12px;
color: #e0e0e0;
font-size: 0.85em;
min-width: 40px;
text-align: center;
}
.time-control .end-value {
cursor: pointer;
}
.time-control .end-value:hover {
background: #515151;
}
.time-control .reset-btn:hover {
color: #ef5350;
}
/* Table Card */
.table-card {
background: #515151;
border-radius: 4px;
border: 1px solid #616161;
overflow: hidden;
}
.queues-table {
width: 100%;
border-collapse: collapse;
}
.queues-table th {
background: #424242;
padding: 14px 16px;
text-align: left;
font-weight: 500;
color: #bdbdbd;
font-size: 0.9em;
border-bottom: 1px solid #616161;
}
.queues-table td {
padding: 14px 16px;
border-bottom: 1px solid #616161;
}
.queues-table tr:last-child td {
border-bottom: none;
}
.queues-table tbody tr:hover {
background: #5a5a5a;
}
.queue-name {
font-weight: 500;
color: #4fc3f7;
cursor: pointer;
}
.queue-name:hover {
text-decoration: underline;
}
.state-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.8em;
font-weight: 500;
background: #66bb6a;
color: #1b5e20;
}
.state-badge.paused {
background: #ffb74d;
color: #e65100;
}
.memory-bar {
width: 120px;
height: 6px;
background: #424242;
border-radius: 3px;
overflow: hidden;
}
.memory-bar-fill {
height: 100%;
background: #42a5f5;
border-radius: 3px;
}
.latency-value {
color: #bdbdbd;
}
.action-btn {
background: transparent;
border: 1px solid #757575;
color: #bdbdbd;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.8em;
}
.action-btn:hover {
background: #616161;
color: #e0e0e0;
}
/* Loading & Empty State */
.loading {
text-align: center;
padding: 60px;
color: #9e9e9e;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #616161;
border-top-color: #42a5f5;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state {
text-align: center;
padding: 60px;
color: #9e9e9e;
}
/* Modal */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: #424242;
}
.modal.open {
display: block;
}
.modal-content {
background-color: #424242;
width: 100%;
height: 100%;
overflow: hidden;
}
.modal-header {
background: #515151;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #616161;
}
.modal-header h2 {
font-size: 1.1em;
font-weight: 500;
}
.close-btn {
color: #9e9e9e;
font-size: 24px;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: transparent;
border: none;
}
.close-btn:hover {
background: #616161;
color: #e0e0e0;
}
.modal-body {
padding: 20px;
height: calc(100vh - 60px);
overflow-y: auto;
}
/* Task Tabs */
.task-tabs {
display: flex;
gap: 5px;
margin-bottom: 20px;
background: #424242;
padding: 5px;
border-radius: 4px;
}
.task-tab {
padding: 8px 16px;
cursor: pointer;
border: none;
background: transparent;
color: #9e9e9e;
border-radius: 4px;
font-size: 0.85em;
}
.task-tab:hover {
color: #e0e0e0;
}
.task-tab.active {
background: #42a5f5;
color: #fff;
}
/* Task List */
.task-list {
max-height: 400px;
overflow-y: auto;
}
.task-item {
background: #5a5a5a;
border: 1px solid #616161;
border-radius: 4px;
padding: 12px;
margin-bottom: 8px;
}
.task-item:hover {
border-color: #42a5f5;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.task-id {
font-family: monospace;
font-size: 0.8em;
color: #9e9e9e;
}
.task-type {
font-weight: 500;
color: #42a5f5;
}
.task-payload {
font-family: monospace;
font-size: 0.75em;
color: #9e9e9e;
background: #424242;
padding: 8px;
border-radius: 4px;
margin-top: 8px;
word-break: break-all;
}
.task-meta {
display: flex;
gap: 12px;
margin-top: 8px;
font-size: 0.8em;
color: #9e9e9e;
}
.task-error {
color: #ef5350;
background: rgba(239, 83, 80, 0.1);
padding: 6px 8px;
border-radius: 4px;
margin-top: 8px;
font-size: 0.8em;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
gap: 6px;
margin-top: 16px;
}
.pagination button {
padding: 6px 12px;
border: 1px solid #616161;
background: #424242;
color: #e0e0e0;
cursor: pointer;
border-radius: 4px;
font-size: 0.85em;
}
.pagination button:hover {
background: #5a5a5a;
}
.pagination button.active {
background: #42a5f5;
border-color: #42a5f5;
}
.pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Queue Detail Chart */
.queue-chart-container {
height: 200px;
margin-bottom: 15px;
background: #424242;
border-radius: 4px;
padding: 10px;
}