feat: 优化监控仪表盘 UI

- 添加 appbar 导航栏,支持 Chart/Queues 视图切换
- appbar 切换使用 history API,支持浏览器前进/后退
- 图表视图占满整个可视区域
- queue-modal 共享 appbar 样式
- 修复 queue tab count 字段名大小写问题
- tooltip 跟随鼠标显示在右下方,移除箭头
- 图表 canvas 鼠标样式改为准星
- pause/resume 队列后刷新列表
- example 添加 flag 配置参数
This commit is contained in:
2025-12-10 00:53:30 +08:00
parent 1f9f1cab53
commit 326f2a371c
19 changed files with 1626 additions and 909 deletions

484
x/inspector/inspector.go Normal file
View File

@@ -0,0 +1,484 @@
// Package inspector 提供 taskq 的统计采集功能
package inspector
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"code.tczkiot.com/wlw/taskq"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
)
// Options 配置统计采集器的选项
type Options struct {
// Interval 采集间隔,默认 2 秒
Interval time.Duration
// DBPath SQLite 数据库文件路径,默认为 "./taskq_stats.db"
DBPath string
}
// Inspector 统计采集器,独立于 HTTP 服务运行
// 实现 taskq.Plugin 接口
type Inspector struct {
opts Options
rdb redis.UniversalClient
queues func() map[string]int
inspector *asynq.Inspector
db *sql.DB
closeCh chan struct{}
closeOnce sync.Once
}
// New 创建新的统计采集器
// 创建后需要通过 Servlet.Use() 注册
func New(opts Options) *Inspector {
if opts.Interval <= 0 {
opts.Interval = 2 * time.Second
}
if opts.DBPath == "" {
opts.DBPath = "./taskq_stats.db"
}
return &Inspector{
opts: opts,
closeCh: make(chan struct{}),
}
}
// Name 返回插件名称
func (ins *Inspector) Name() string {
return "inspector"
}
// Init 初始化插件,从 Context 获取 Redis 和 Queues
func (ins *Inspector) Init(ctx *taskq.Context) error {
ins.rdb = ctx.Redis()
ins.queues = ctx.Queues
return nil
}
// Start 启动采集器,初始化数据库并开始后台采集
func (ins *Inspector) Start(ctx *taskq.Context) error {
// 确保目录存在
dir := filepath.Dir(ins.opts.DBPath)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("inspector: failed to create directory: %v", err)
}
}
// 打开 SQLite 数据库
db, err := sql.Open("sqlite3", ins.opts.DBPath)
if err != nil {
return fmt.Errorf("inspector: failed to open database: %v", err)
}
// 初始化数据库表
if err := initStatsDB(db); err != nil {
db.Close()
return fmt.Errorf("inspector: failed to init database: %v", err)
}
ins.db = db
ins.inspector = asynq.NewInspectorFromRedisClient(ins.rdb)
// 启动后台统计采集
go ins.startCollector(ctx)
return nil
}
// Stop 停止采集器,关闭数据库连接
func (ins *Inspector) Stop() error {
ins.closeOnce.Do(func() {
close(ins.closeCh)
})
if ins.db != nil {
ins.db.Close()
}
if ins.inspector != nil {
ins.inspector.Close()
}
return nil
}
// initStatsDB 初始化数据库
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
);
CREATE INDEX IF NOT EXISTS idx_metrics_queue_time ON metrics(queue, timestamp DESC);
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
}
// startCollector 启动后台统计采集任务
func (ins *Inspector) startCollector(ctx context.Context) {
ticker := time.NewTicker(ins.opts.Interval)
defer ticker.Stop()
for {
select {
case <-ins.closeCh:
return
case <-ctx.Done():
return
case <-ticker.C:
ins.collectStats()
}
}
}
// collectStats 采集所有队列的统计数据
func (ins *Inspector) collectStats() {
if ins.queues == nil || ins.inspector == nil {
return
}
now := time.Now().Unix()
queueList := ins.queues()
for queueName := range queueList {
info, err := ins.inspector.GetQueueInfo(queueName)
if err != nil {
continue
}
stats := Stats{
Queue: queueName,
Timestamp: now,
Active: info.Active,
Pending: info.Pending,
Scheduled: info.Scheduled,
Retry: info.Retry,
Archived: info.Archived,
Completed: info.Completed,
Succeeded: info.Processed - info.Failed,
Failed: info.Failed,
}
ins.saveMetrics(stats)
}
}
// saveMetrics 保存统计数据到 metrics 表
func (ins *Inspector) saveMetrics(stats Stats) error {
if ins.db == nil {
return nil
}
_, 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)
return err
}
// ==================== 数据类型 ====================
// StatsQuery 统计查询参数
type StatsQuery struct {
Queue string // 队列名称,为空则查询所有队列汇总
Start int64 // 开始时间戳0 表示不限制
End int64 // 结束时间戳0 表示不限制
Limit int // 返回数量限制,默认 500
}
// Stats 队列统计数据点
type Stats 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"` // 失败数
}
// 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 time.Duration `json:"-"`
LatencyMS int64 `json:"latency"`
}
// TaskInfo 任务信息
type TaskInfo struct {
ID string `json:"id"`
Type string `json:"type"`
Payload []byte `json:"payload"`
Queue string `json:"queue"`
Retried int `json:"retried"`
LastFailedAt time.Time `json:"last_failed_at,omitempty"`
LastErr string `json:"last_error,omitempty"`
NextProcessAt time.Time `json:"next_process_at,omitempty"`
CompletedAt time.Time `json:"completed_at,omitempty"`
}
// ==================== 查询方法 ====================
// QueryStats 查询统计数据
func (ins *Inspector) QueryStats(q StatsQuery) ([]Stats, error) {
if ins.db == nil {
return nil, nil
}
limit := q.Limit
if limit <= 0 {
limit = 500
}
var args []any
var whereClause string
var conditions []string
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 {
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, limit)
rows, err := ins.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var statsList []Stats
for rows.Next() {
var s Stats
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)
}
// 反转顺序,使时间从早到晚
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
}
// GetQueueInfo 获取队列信息
func (ins *Inspector) GetQueueInfo(queueName string) (*QueueInfo, error) {
if ins.inspector == nil {
return nil, fmt.Errorf("inspector: not started")
}
info, err := ins.inspector.GetQueueInfo(queueName)
if err != nil {
return nil, err
}
return &QueueInfo{
Name: info.Queue,
Size: info.Size,
Active: info.Active,
Pending: info.Pending,
Scheduled: info.Scheduled,
Retry: info.Retry,
Archived: info.Archived,
Completed: info.Completed,
Processed: info.Processed,
Failed: info.Failed,
Paused: info.Paused,
MemoryUsage: info.MemoryUsage,
Latency: info.Latency,
LatencyMS: info.Latency.Milliseconds(),
}, nil
}
// convertTaskInfo 将 asynq.TaskInfo 转换为 TaskInfo
func convertTaskInfo(task *asynq.TaskInfo) *TaskInfo {
return &TaskInfo{
ID: task.ID,
Type: task.Type,
Payload: task.Payload,
Queue: task.Queue,
Retried: task.Retried,
LastFailedAt: task.LastFailedAt,
LastErr: task.LastErr,
NextProcessAt: task.NextProcessAt,
CompletedAt: task.CompletedAt,
}
}
// convertTaskList 批量转换任务列表
func convertTaskList(tasks []*asynq.TaskInfo) []*TaskInfo {
result := make([]*TaskInfo, len(tasks))
for i, t := range tasks {
result[i] = convertTaskInfo(t)
}
return result
}
// ListActiveTasks 获取活跃任务列表
func (ins *Inspector) ListActiveTasks(queueName string, pageSize, page int) ([]*TaskInfo, error) {
if ins.inspector == nil {
return nil, fmt.Errorf("inspector: not started")
}
tasks, err := ins.inspector.ListActiveTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page))
if err != nil {
return nil, err
}
return convertTaskList(tasks), nil
}
// ListPendingTasks 获取等待任务列表
func (ins *Inspector) ListPendingTasks(queueName string, pageSize, page int) ([]*TaskInfo, error) {
if ins.inspector == nil {
return nil, fmt.Errorf("inspector: not started")
}
tasks, err := ins.inspector.ListPendingTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page))
if err != nil {
return nil, err
}
return convertTaskList(tasks), nil
}
// ListScheduledTasks 获取计划任务列表
func (ins *Inspector) ListScheduledTasks(queueName string, pageSize, page int) ([]*TaskInfo, error) {
if ins.inspector == nil {
return nil, fmt.Errorf("inspector: not started")
}
tasks, err := ins.inspector.ListScheduledTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page))
if err != nil {
return nil, err
}
return convertTaskList(tasks), nil
}
// ListRetryTasks 获取重试任务列表
func (ins *Inspector) ListRetryTasks(queueName string, pageSize, page int) ([]*TaskInfo, error) {
if ins.inspector == nil {
return nil, fmt.Errorf("inspector: not started")
}
tasks, err := ins.inspector.ListRetryTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page))
if err != nil {
return nil, err
}
return convertTaskList(tasks), nil
}
// ListArchivedTasks 获取归档任务列表
func (ins *Inspector) ListArchivedTasks(queueName string, pageSize, page int) ([]*TaskInfo, error) {
if ins.inspector == nil {
return nil, fmt.Errorf("inspector: not started")
}
tasks, err := ins.inspector.ListArchivedTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page))
if err != nil {
return nil, err
}
return convertTaskList(tasks), nil
}
// ListCompletedTasks 获取已完成任务列表
func (ins *Inspector) ListCompletedTasks(queueName string, pageSize, page int) ([]*TaskInfo, error) {
if ins.inspector == nil {
return nil, fmt.Errorf("inspector: not started")
}
tasks, err := ins.inspector.ListCompletedTasks(queueName, asynq.PageSize(pageSize), asynq.Page(page))
if err != nil {
return nil, err
}
return convertTaskList(tasks), nil
}
// RunTask 立即运行归档任务(重试失败任务)
func (ins *Inspector) RunTask(queueName, taskID string) error {
if ins.inspector == nil {
return fmt.Errorf("inspector: not started")
}
return ins.inspector.RunTask(queueName, taskID)
}
// PauseQueue 暂停队列
func (ins *Inspector) PauseQueue(queueName string) error {
if ins.inspector == nil {
return fmt.Errorf("inspector: not started")
}
return ins.inspector.PauseQueue(queueName)
}
// UnpauseQueue 恢复队列
func (ins *Inspector) UnpauseQueue(queueName string) error {
if ins.inspector == nil {
return fmt.Errorf("inspector: not started")
}
return ins.inspector.UnpauseQueue(queueName)
}

268
x/metrics/metrics.go Normal file
View File

@@ -0,0 +1,268 @@
// Package metrics 提供 taskq 的 Prometheus 指标采集功能
package metrics
import (
"context"
"sync"
"time"
"code.tczkiot.com/wlw/taskq"
"github.com/hibiken/asynq"
"github.com/prometheus/client_golang/prometheus"
"github.com/redis/go-redis/v9"
)
// Options 配置指标采集器的选项
type Options struct {
// Namespace 指标命名空间,默认为 "taskq"
Namespace string
// Interval 采集间隔,默认 15 秒
Interval time.Duration
// Registry Prometheus 注册器,默认使用 prometheus.DefaultRegisterer
Registry prometheus.Registerer
}
// Metrics Prometheus 指标采集器
// 实现 taskq.Plugin 接口
type Metrics struct {
opts Options
rdb redis.UniversalClient
queues func() map[string]int
inspector *asynq.Inspector
closeCh chan struct{}
closeOnce sync.Once
// Prometheus 指标
queueSize *prometheus.GaugeVec
activeTasks *prometheus.GaugeVec
pendingTasks *prometheus.GaugeVec
scheduledTasks *prometheus.GaugeVec
retryTasks *prometheus.GaugeVec
archivedTasks *prometheus.GaugeVec
completedTasks *prometheus.GaugeVec
processedTotal *prometheus.GaugeVec
failedTotal *prometheus.GaugeVec
latencySeconds *prometheus.GaugeVec
memoryBytes *prometheus.GaugeVec
paused *prometheus.GaugeVec
}
// New 创建新的指标采集器
func New(opts Options) *Metrics {
if opts.Namespace == "" {
opts.Namespace = "taskq"
}
if opts.Interval <= 0 {
opts.Interval = 15 * time.Second
}
if opts.Registry == nil {
opts.Registry = prometheus.DefaultRegisterer
}
m := &Metrics{
opts: opts,
closeCh: make(chan struct{}),
}
// 初始化指标
m.initMetrics()
return m
}
// initMetrics 初始化 Prometheus 指标
func (m *Metrics) initMetrics() {
labels := []string{"queue"}
m.queueSize = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: m.opts.Namespace,
Name: "queue_size",
Help: "Total number of tasks in the queue",
}, labels)
m.activeTasks = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: m.opts.Namespace,
Name: "tasks_active",
Help: "Number of currently active tasks",
}, labels)
m.pendingTasks = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: m.opts.Namespace,
Name: "tasks_pending",
Help: "Number of pending tasks",
}, labels)
m.scheduledTasks = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: m.opts.Namespace,
Name: "tasks_scheduled",
Help: "Number of scheduled tasks",
}, labels)
m.retryTasks = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: m.opts.Namespace,
Name: "tasks_retry",
Help: "Number of tasks in retry queue",
}, labels)
m.archivedTasks = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: m.opts.Namespace,
Name: "tasks_archived",
Help: "Number of archived (dead) tasks",
}, labels)
m.completedTasks = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: m.opts.Namespace,
Name: "tasks_completed",
Help: "Number of completed tasks (retained)",
}, labels)
m.processedTotal = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: m.opts.Namespace,
Name: "tasks_processed_total",
Help: "Total number of processed tasks (today)",
}, labels)
m.failedTotal = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: m.opts.Namespace,
Name: "tasks_failed_total",
Help: "Total number of failed tasks (today)",
}, labels)
m.latencySeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: m.opts.Namespace,
Name: "queue_latency_seconds",
Help: "Queue latency in seconds",
}, labels)
m.memoryBytes = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: m.opts.Namespace,
Name: "queue_memory_bytes",
Help: "Memory usage of the queue in bytes",
}, labels)
m.paused = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: m.opts.Namespace,
Name: "queue_paused",
Help: "Whether the queue is paused (1) or not (0)",
}, labels)
}
// Name 返回插件名称
func (m *Metrics) Name() string {
return "metrics"
}
// Init 初始化插件,从 Context 获取 Redis 和 Queues
func (m *Metrics) Init(ctx *taskq.Context) error {
m.rdb = ctx.Redis()
m.queues = ctx.Queues
return nil
}
// Start 启动指标采集
func (m *Metrics) Start(ctx *taskq.Context) error {
m.inspector = asynq.NewInspectorFromRedisClient(m.rdb)
// 注册指标
collectors := []prometheus.Collector{
m.queueSize,
m.activeTasks,
m.pendingTasks,
m.scheduledTasks,
m.retryTasks,
m.archivedTasks,
m.completedTasks,
m.processedTotal,
m.failedTotal,
m.latencySeconds,
m.memoryBytes,
m.paused,
}
for _, c := range collectors {
if err := m.opts.Registry.Register(c); err != nil {
// 如果已注册则忽略
if _, ok := err.(prometheus.AlreadyRegisteredError); !ok {
return err
}
}
}
// 启动后台采集
go m.startCollector(ctx)
return nil
}
// Stop 停止指标采集
func (m *Metrics) Stop() error {
m.closeOnce.Do(func() {
close(m.closeCh)
})
if m.inspector != nil {
m.inspector.Close()
}
return nil
}
// startCollector 启动后台指标采集
func (m *Metrics) startCollector(ctx context.Context) {
// 立即采集一次
m.collect()
ticker := time.NewTicker(m.opts.Interval)
defer ticker.Stop()
for {
select {
case <-m.closeCh:
return
case <-ctx.Done():
return
case <-ticker.C:
m.collect()
}
}
}
// collect 采集所有队列的指标
func (m *Metrics) collect() {
if m.queues == nil || m.inspector == nil {
return
}
queues := m.queues()
for queueName := range queues {
info, err := m.inspector.GetQueueInfo(queueName)
if err != nil {
continue
}
m.queueSize.WithLabelValues(queueName).Set(float64(info.Size))
m.activeTasks.WithLabelValues(queueName).Set(float64(info.Active))
m.pendingTasks.WithLabelValues(queueName).Set(float64(info.Pending))
m.scheduledTasks.WithLabelValues(queueName).Set(float64(info.Scheduled))
m.retryTasks.WithLabelValues(queueName).Set(float64(info.Retry))
m.archivedTasks.WithLabelValues(queueName).Set(float64(info.Archived))
m.completedTasks.WithLabelValues(queueName).Set(float64(info.Completed))
m.processedTotal.WithLabelValues(queueName).Set(float64(info.Processed))
m.failedTotal.WithLabelValues(queueName).Set(float64(info.Failed))
m.latencySeconds.WithLabelValues(queueName).Set(info.Latency.Seconds())
m.memoryBytes.WithLabelValues(queueName).Set(float64(info.MemoryUsage))
if info.Paused {
m.paused.WithLabelValues(queueName).Set(1)
} else {
m.paused.WithLabelValues(queueName).Set(0)
}
}
}

605
x/monitor/monitor.go Normal file
View File

@@ -0,0 +1,605 @@
// Package monitor 提供 taskq 的 HTTP 监控服务
package monitor
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"code.tczkiot.com/wlw/taskq/x/inspector"
)
//go:embed ui/*
var uiFS embed.FS
// Options 配置监控服务的选项
type Options struct {
// Inspector 检查器实例(必需)
Inspector *inspector.Inspector
// Queues 队列优先级映射(必需)
Queues map[string]int
// RootPath 监控服务的根路径,默认为 "/monitor"
RootPath string
// ReadOnly 是否只读模式,禁用所有修改操作,默认为 false
ReadOnly bool
}
// Monitor 监控服务的 HTTP 处理器
type Monitor struct {
router *http.ServeMux
rootPath string
readOnly bool
closeCh chan struct{}
closeOnce sync.Once
inspector *inspector.Inspector
queues map[string]int
}
// New 创建新的监控服务
func New(opts Options) (*Monitor, error) {
if opts.Inspector == nil {
return nil, fmt.Errorf("monitor: inspector is required")
}
if opts.Queues == nil {
return nil, fmt.Errorf("monitor: queues is required")
}
// 设置默认值
if opts.RootPath == "" {
opts.RootPath = "/monitor"
}
// 确保路径以 / 开头且不以 / 结尾
if !strings.HasPrefix(opts.RootPath, "/") {
opts.RootPath = "/" + opts.RootPath
}
opts.RootPath = strings.TrimSuffix(opts.RootPath, "/")
m := &Monitor{
router: http.NewServeMux(),
rootPath: opts.RootPath,
readOnly: opts.ReadOnly,
closeCh: make(chan struct{}),
inspector: opts.Inspector,
queues: opts.Queues,
}
m.setupRoutes()
return m, nil
}
// ServeHTTP 实现 http.Handler 接口
func (m *Monitor) ServeHTTP(w http.ResponseWriter, r *http.Request) {
m.router.ServeHTTP(w, r)
}
// RootPath 返回监控服务的根路径
func (m *Monitor) RootPath() string {
return m.rootPath
}
// Close 关闭监控服务
func (m *Monitor) Close() error {
m.closeOnce.Do(func() {
close(m.closeCh)
})
return nil
}
// setupRoutes 设置路由
func (m *Monitor) setupRoutes() {
// API 路由
apiPath := m.rootPath + "/api/"
m.router.HandleFunc(apiPath+"queues", m.handleQueues)
m.router.HandleFunc(apiPath+"queues/", m.handleQueueDetail)
m.router.HandleFunc(apiPath+"tasks/", m.handleTasks)
m.router.HandleFunc(apiPath+"stats/", m.handleStats)
m.router.HandleFunc(apiPath+"sse", m.handleSSE)
// 静态文件路由
uiSubFS, _ := fs.Sub(uiFS, "ui")
fileServer := http.FileServer(http.FS(uiSubFS))
m.router.Handle(m.rootPath+"/static/", http.StripPrefix(m.rootPath+"/static/", fileServer))
// 主页路由(包含 History API 的路由)
m.router.HandleFunc(m.rootPath+"/queues/", m.handleIndex)
m.router.HandleFunc(m.rootPath+"/", m.handleIndex)
m.router.HandleFunc(m.rootPath, m.handleIndex)
}
// handleStats 处理队列统计数据请求
func (m *Monitor) 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, m.rootPath+"/api/stats/")
queueName := strings.TrimSuffix(path, "/")
// 构建查询参数
query := inspector.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 := m.inspector.QueryStats(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)
}
// queueInfoJSON 用于 JSON 输出的队列信息
type queueInfoJSON 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"`
}
// handleQueues 处理队列列表请求
func (m *Monitor) handleQueues(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var queueInfos []queueInfoJSON
// 首先显示所有注册的队列即使Redis中还没有任务
for queueName, priority := range m.queues {
info, err := m.inspector.GetQueueInfo(queueName)
if err != nil {
// 如果队列不存在,创建一个空的状态
queueInfos = append(queueInfos, queueInfoJSON{
Name: queueName,
Priority: priority,
})
} else {
queueInfos = append(queueInfos, queueInfoJSON{
Name: queueName,
Priority: priority,
Size: info.Size,
Active: info.Active,
Pending: info.Pending,
Scheduled: info.Scheduled,
Retry: info.Retry,
Archived: info.Archived,
Completed: info.Completed,
Processed: info.Processed,
Failed: info.Failed,
Paused: info.Paused,
MemoryUsage: info.MemoryUsage,
Latency: info.LatencyMS,
})
}
}
// 按优先级排序
sort.Slice(queueInfos, func(i, j int) bool {
return queueInfos[i].Priority > queueInfos[j].Priority
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(queueInfos)
}
// handleQueueDetail 处理队列详情请求和队列操作
func (m *Monitor) handleQueueDetail(w http.ResponseWriter, r *http.Request) {
// 从 URL 中提取队列名称
path := strings.TrimPrefix(r.URL.Path, m.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 := m.queues[queueName]; !exists {
http.Error(w, "Queue not found", http.StatusNotFound)
return
}
// 处理暂停/恢复请求
if r.Method == http.MethodPost && len(parts) >= 2 {
if m.readOnly {
http.Error(w, "Operation not allowed in read-only mode", http.StatusForbidden)
return
}
action := parts[1]
switch action {
case "pause":
if err := m.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 := m.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
}
// 获取队列详细信息
info, err := m.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(info)
}
// taskInfoJSON 转换任务信息用于 JSON 输出
type taskInfoJSON 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 (m *Monitor) handleTasks(w http.ResponseWriter, r *http.Request) {
// 从 URL 中提取队列名称和任务状态
path := strings.TrimPrefix(r.URL.Path, m.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 m.readOnly {
http.Error(w, "Operation not allowed in read-only mode", http.StatusForbidden)
return
}
taskID := parts[2]
m.handleRetryTask(w, r, queueName, taskID)
return
}
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 检查队列是否已注册
if _, exists := m.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 := m.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 []*inspector.TaskInfo
var err error
switch taskState {
case "active":
tasks, err = m.inspector.ListActiveTasks(queueName, pageSize, page-1)
case "pending":
tasks, err = m.inspector.ListPendingTasks(queueName, pageSize, page-1)
case "scheduled":
tasks, err = m.inspector.ListScheduledTasks(queueName, pageSize, page-1)
case "retry":
tasks, err = m.inspector.ListRetryTasks(queueName, pageSize, page-1)
case "archived":
tasks, err = m.inspector.ListArchivedTasks(queueName, pageSize, page-1)
case "completed":
tasks, err = m.inspector.ListCompletedTasks(queueName, pageSize, 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 = []*inspector.TaskInfo{}
total = 0
} else {
http.Error(w, fmt.Sprintf("Failed to get tasks: %v", err), http.StatusInternalServerError)
return
}
}
var taskInfos []taskInfoJSON
for _, task := range tasks {
info := taskInfoJSON{
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 (m *Monitor) handleRetryTask(w http.ResponseWriter, r *http.Request, queueName, taskID string) {
// 检查队列是否已注册
if _, exists := m.queues[queueName]; !exists {
http.Error(w, "Queue not found", http.StatusNotFound)
return
}
// 运行重试
err := m.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 (m *Monitor) 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}}", m.rootPath)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(content))
}
// handleSSE 处理 Server-Sent Events 实时数据推送
func (m *Monitor) 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()
// 立即发送一次数据
m.sendQueuesEvent(w, flusher)
m.sendStatsEvent(w, flusher)
for {
select {
case <-ctx.Done():
return
case <-m.closeCh:
return
case <-statsTicker.C:
m.sendStatsEvent(w, flusher)
case <-queuesTicker.C:
m.sendQueuesEvent(w, flusher)
}
}
}
// sendStatsEvent 发送统计图表数据
func (m *Monitor) sendStatsEvent(w http.ResponseWriter, flusher http.Flusher) {
stats, err := m.inspector.QueryStats(inspector.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 发送队列表格数据
func (m *Monitor) sendQueuesEvent(w http.ResponseWriter, flusher http.Flusher) {
var queueInfos []queueInfoJSON
for queueName, priority := range m.queues {
info, err := m.inspector.GetQueueInfo(queueName)
if err != nil {
queueInfos = append(queueInfos, queueInfoJSON{
Name: queueName,
Priority: priority,
})
} else {
queueInfos = append(queueInfos, queueInfoJSON{
Name: queueName,
Priority: priority,
Size: info.Size,
Active: info.Active,
Pending: info.Pending,
Scheduled: info.Scheduled,
Retry: info.Retry,
Archived: info.Archived,
Completed: info.Completed,
Processed: info.Processed,
Failed: info.Failed,
Paused: info.Paused,
MemoryUsage: info.MemoryUsage,
Latency: info.LatencyMS,
})
}
}
// 按优先级排序
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()
}

460
x/monitor/ui/app.js Normal file
View File

@@ -0,0 +1,460 @@
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 },
// View mode: 'chart' or 'table'
viewMode: { type: String, 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;
this.viewMode = 'chart';
}
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(/^\/+/, '');
// 检查是否是 queues 视图
if (relativePath === 'queues' || relativePath === 'queues/') {
this.viewMode = 'table';
return;
}
// 检查是否是具体队列详情
const match = relativePath.match(/^queues\/([^\/]+)\/([^\/]+)/);
if (match) {
this.viewMode = 'table';
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.viewMode = 'table';
this.currentQueue = event.state.queue;
this.currentTab = event.state.tab;
this.currentPage = event.state.page;
this.modalOpen = true;
} else if (event.state && event.state.view) {
this.viewMode = event.state.view;
this.modalOpen = false;
} 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({ view: 'table' }, '', `${this.rootPath}/queues`);
}
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);
}
handleViewChange(mode) {
this.viewMode = mode;
const url = mode === 'table' ? `${this.rootPath}/queues` : `${this.rootPath}/`;
history.pushState({ view: mode }, '', url);
}
async handleQueueUpdated() {
try {
const response = await fetch(`${this.rootPath}/api/queues`);
if (response.ok) {
this.queues = await response.json();
}
} catch (err) {
console.error('Failed to refresh queues:', err);
}
}
render() {
return html`
<div class="appbar">
<div class="appbar-title">TaskQ Monitor</div>
<div class="appbar-tabs">
<button
class="appbar-tab ${this.viewMode === 'chart' ? 'active' : ''}"
@click=${() => this.handleViewChange('chart')}
>Chart</button>
<button
class="appbar-tab ${this.viewMode === 'table' ? 'active' : ''}"
@click=${() => this.handleViewChange('table')}
>Queues</button>
</div>
</div>
${this.viewMode === 'chart' ? html`
<div class="chart-card chart-fullheight">
<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-large">
<tasks-chart
.data=${this.chartData}
.timestamps=${this.chartData.timestamps || []}
@time-range-select=${this.handleTimeRangeSelect}
></tasks-chart>
</div>
</div>
` : html`
<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-updated=${this.handleQueueUpdated}
></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,875 @@
import { LitElement, html, css } from 'lit';
import { Chart, registerables, Tooltip } from 'chart.js';
import './time-range-picker.js';
Chart.register(...registerables);
// 自定义 tooltip positioner显示在鼠标右下方
if (!Tooltip.positioners.cursor) {
Tooltip.positioners.cursor = function (elements, eventPosition, tooltip) {
return {
x: eventPosition.x + 20,
y: eventPosition.y + 15,
xAlign: 'left',
yAlign: 'top'
};
};
}
// 十字准星 + 拖拽选择插件(与 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;
}
.appbar {
display: flex;
justify-content: space-between;
align-items: center;
background: #333;
padding: 0 20px;
height: 56px;
border-bottom: 1px solid #616161;
}
.appbar-title {
font-size: 1.2em;
font-weight: 600;
color: #e0e0e0;
}
.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: #424242;
color: #e0e0e0;
}
.modal-body {
padding: 20px;
height: calc(100vh - 56px);
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;
cursor: crosshair;
}
`;
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', position: 'cursor', caretSize: 0 }
},
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;
return this.queueInfo[tab] || 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="appbar">
<div class="appbar-title">Queue: ${this.queue}</div>
<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,280 @@
import { LitElement, html, css } from 'lit';
import { Chart, registerables, Tooltip } from 'chart.js';
Chart.register(...registerables);
// 自定义 tooltip positioner显示在鼠标右下方
Tooltip.positioners.cursor = function (elements, eventPosition, tooltip) {
return {
x: eventPosition.x + 20,
y: eventPosition.y + 15,
xAlign: 'left',
yAlign: 'top'
};
};
// 十字准星 + 拖拽选择插件
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;
cursor: crosshair;
}
`;
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,
position: 'cursor',
caretSize: 0
}
},
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
x/monitor/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>

473
x/monitor/ui/styles.css Normal file
View File

@@ -0,0 +1,473 @@
* {
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: 0;
}
/* AppBar */
.appbar {
display: flex;
justify-content: space-between;
align-items: center;
background: #333;
padding: 0 20px;
height: 56px;
border-bottom: 1px solid #616161;
position: sticky;
top: 0;
z-index: 100;
}
.appbar-title {
font-size: 1.2em;
font-weight: 600;
color: #e0e0e0;
}
.appbar-tabs {
display: flex;
gap: 4px;
}
.appbar-tab {
background: transparent;
border: none;
color: #9e9e9e;
padding: 8px 16px;
cursor: pointer;
font-size: 0.9em;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.appbar-tab:hover {
background: #424242;
color: #e0e0e0;
}
.appbar-tab.active {
background: #42a5f5;
color: #fff;
}
/* Chart Card */
.chart-card {
background: #515151;
border-radius: 4px;
padding: 20px;
border: 1px solid #616161;
margin: 20px;
}
.chart-fullheight {
margin: 0;
border-radius: 0;
border-left: none;
border-right: none;
border-bottom: none;
height: calc(100vh - 56px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.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;
}
.chart-container-large {
flex: 1;
position: relative;
overflow: hidden;
}
/* 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;
margin: 20px;
}
.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;
}