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:
484
x/inspector/inspector.go
Normal file
484
x/inspector/inspector.go
Normal 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
268
x/metrics/metrics.go
Normal 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
605
x/monitor/monitor.go
Normal 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
460
x/monitor/ui/app.js
Normal 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);
|
||||
80
x/monitor/ui/components/help-tooltip.js
Normal file
80
x/monitor/ui/components/help-tooltip.js
Normal 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);
|
||||
875
x/monitor/ui/components/queue-modal.js
Normal file
875
x/monitor/ui/components/queue-modal.js
Normal 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}>×</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);
|
||||
233
x/monitor/ui/components/queue-table.js
Normal file
233
x/monitor/ui/components/queue-table.js
Normal 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);
|
||||
280
x/monitor/ui/components/tasks-chart.js
Normal file
280
x/monitor/ui/components/tasks-chart.js
Normal 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);
|
||||
165
x/monitor/ui/components/time-range-picker.js
Normal file
165
x/monitor/ui/components/time-range-picker.js
Normal 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
63
x/monitor/ui/index.html
Normal 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
473
x/monitor/ui/styles.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user