feat: 优化监控仪表盘 UI

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

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

@@ -0,0 +1,605 @@
// Package monitor 提供 taskq 的 HTTP 监控服务
package monitor
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"code.tczkiot.com/wlw/taskq/x/inspector"
)
//go:embed ui/*
var uiFS embed.FS
// Options 配置监控服务的选项
type Options struct {
// Inspector 检查器实例(必需)
Inspector *inspector.Inspector
// Queues 队列优先级映射(必需)
Queues map[string]int
// RootPath 监控服务的根路径,默认为 "/monitor"
RootPath string
// ReadOnly 是否只读模式,禁用所有修改操作,默认为 false
ReadOnly bool
}
// Monitor 监控服务的 HTTP 处理器
type Monitor struct {
router *http.ServeMux
rootPath string
readOnly bool
closeCh chan struct{}
closeOnce sync.Once
inspector *inspector.Inspector
queues map[string]int
}
// New 创建新的监控服务
func New(opts Options) (*Monitor, error) {
if opts.Inspector == nil {
return nil, fmt.Errorf("monitor: inspector is required")
}
if opts.Queues == nil {
return nil, fmt.Errorf("monitor: queues is required")
}
// 设置默认值
if opts.RootPath == "" {
opts.RootPath = "/monitor"
}
// 确保路径以 / 开头且不以 / 结尾
if !strings.HasPrefix(opts.RootPath, "/") {
opts.RootPath = "/" + opts.RootPath
}
opts.RootPath = strings.TrimSuffix(opts.RootPath, "/")
m := &Monitor{
router: http.NewServeMux(),
rootPath: opts.RootPath,
readOnly: opts.ReadOnly,
closeCh: make(chan struct{}),
inspector: opts.Inspector,
queues: opts.Queues,
}
m.setupRoutes()
return m, nil
}
// ServeHTTP 实现 http.Handler 接口
func (m *Monitor) ServeHTTP(w http.ResponseWriter, r *http.Request) {
m.router.ServeHTTP(w, r)
}
// RootPath 返回监控服务的根路径
func (m *Monitor) RootPath() string {
return m.rootPath
}
// Close 关闭监控服务
func (m *Monitor) Close() error {
m.closeOnce.Do(func() {
close(m.closeCh)
})
return nil
}
// setupRoutes 设置路由
func (m *Monitor) setupRoutes() {
// API 路由
apiPath := m.rootPath + "/api/"
m.router.HandleFunc(apiPath+"queues", m.handleQueues)
m.router.HandleFunc(apiPath+"queues/", m.handleQueueDetail)
m.router.HandleFunc(apiPath+"tasks/", m.handleTasks)
m.router.HandleFunc(apiPath+"stats/", m.handleStats)
m.router.HandleFunc(apiPath+"sse", m.handleSSE)
// 静态文件路由
uiSubFS, _ := fs.Sub(uiFS, "ui")
fileServer := http.FileServer(http.FS(uiSubFS))
m.router.Handle(m.rootPath+"/static/", http.StripPrefix(m.rootPath+"/static/", fileServer))
// 主页路由(包含 History API 的路由)
m.router.HandleFunc(m.rootPath+"/queues/", m.handleIndex)
m.router.HandleFunc(m.rootPath+"/", m.handleIndex)
m.router.HandleFunc(m.rootPath, m.handleIndex)
}
// handleStats 处理队列统计数据请求
func (m *Monitor) handleStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 从 URL 中提取队列名称(可选,为空则查询所有队列汇总)
path := strings.TrimPrefix(r.URL.Path, m.rootPath+"/api/stats/")
queueName := strings.TrimSuffix(path, "/")
// 构建查询参数
query := inspector.StatsQuery{
Queue: queueName,
Limit: 500,
}
// 解析 limit 参数
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 10000 {
query.Limit = parsed
}
}
// 解析 start 参数Unix 时间戳)
if s := r.URL.Query().Get("start"); s != "" {
if parsed, err := strconv.ParseInt(s, 10, 64); err == nil && parsed > 0 {
query.Start = parsed
}
}
// 解析 end 参数Unix 时间戳)
if e := r.URL.Query().Get("end"); e != "" {
if parsed, err := strconv.ParseInt(e, 10, 64); err == nil && parsed > 0 {
query.End = parsed
}
}
stats, err := m.inspector.QueryStats(query)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to get stats: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
// queueInfoJSON 用于 JSON 输出的队列信息
type queueInfoJSON struct {
Name string `json:"name"`
Priority int `json:"priority"`
Size int `json:"size"`
Active int `json:"active"`
Pending int `json:"pending"`
Scheduled int `json:"scheduled"`
Retry int `json:"retry"`
Archived int `json:"archived"`
Completed int `json:"completed"`
Processed int `json:"processed"`
Failed int `json:"failed"`
Paused bool `json:"paused"`
MemoryUsage int64 `json:"memory_usage"`
Latency int64 `json:"latency"`
}
// handleQueues 处理队列列表请求
func (m *Monitor) handleQueues(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var queueInfos []queueInfoJSON
// 首先显示所有注册的队列即使Redis中还没有任务
for queueName, priority := range m.queues {
info, err := m.inspector.GetQueueInfo(queueName)
if err != nil {
// 如果队列不存在,创建一个空的状态
queueInfos = append(queueInfos, queueInfoJSON{
Name: queueName,
Priority: priority,
})
} else {
queueInfos = append(queueInfos, queueInfoJSON{
Name: queueName,
Priority: priority,
Size: info.Size,
Active: info.Active,
Pending: info.Pending,
Scheduled: info.Scheduled,
Retry: info.Retry,
Archived: info.Archived,
Completed: info.Completed,
Processed: info.Processed,
Failed: info.Failed,
Paused: info.Paused,
MemoryUsage: info.MemoryUsage,
Latency: info.LatencyMS,
})
}
}
// 按优先级排序
sort.Slice(queueInfos, func(i, j int) bool {
return queueInfos[i].Priority > queueInfos[j].Priority
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(queueInfos)
}
// handleQueueDetail 处理队列详情请求和队列操作
func (m *Monitor) handleQueueDetail(w http.ResponseWriter, r *http.Request) {
// 从 URL 中提取队列名称
path := strings.TrimPrefix(r.URL.Path, m.rootPath+"/api/queues/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
http.Error(w, "Queue name is required", http.StatusBadRequest)
return
}
queueName := parts[0]
// 检查队列是否已注册
if _, exists := m.queues[queueName]; !exists {
http.Error(w, "Queue not found", http.StatusNotFound)
return
}
// 处理暂停/恢复请求
if r.Method == http.MethodPost && len(parts) >= 2 {
if m.readOnly {
http.Error(w, "Operation not allowed in read-only mode", http.StatusForbidden)
return
}
action := parts[1]
switch action {
case "pause":
if err := m.inspector.PauseQueue(queueName); err != nil {
http.Error(w, fmt.Sprintf("Failed to pause queue: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "paused"})
return
case "unpause":
if err := m.inspector.UnpauseQueue(queueName); err != nil {
http.Error(w, fmt.Sprintf("Failed to unpause queue: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "unpaused"})
return
default:
http.Error(w, "Invalid action", http.StatusBadRequest)
return
}
}
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 获取队列详细信息
info, err := m.inspector.GetQueueInfo(queueName)
if err != nil {
// 如果队列在 Redis 中不存在,返回空状态
if strings.Contains(err.Error(), "queue not found") {
emptyStats := map[string]any{
"queue": queueName,
"active": 0,
"pending": 0,
"retry": 0,
"archived": 0,
"completed": 0,
"processed": 0,
"failed": 0,
"paused": false,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(emptyStats)
return
}
http.Error(w, fmt.Sprintf("Failed to get queue info: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(info)
}
// taskInfoJSON 转换任务信息用于 JSON 输出
type taskInfoJSON struct {
ID string `json:"id"`
Type string `json:"type"`
Payload string `json:"payload"`
Queue string `json:"queue"`
Retried int `json:"retried"`
LastFailed string `json:"last_failed,omitempty"`
LastError string `json:"last_error,omitempty"`
NextProcess string `json:"next_process,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
}
// handleTasks 处理任务列表请求和任务操作
func (m *Monitor) handleTasks(w http.ResponseWriter, r *http.Request) {
// 从 URL 中提取队列名称和任务状态
path := strings.TrimPrefix(r.URL.Path, m.rootPath+"/api/tasks/")
parts := strings.Split(path, "/")
if len(parts) < 2 {
http.Error(w, "Queue name and task state are required", http.StatusBadRequest)
return
}
queueName := parts[0]
taskState := parts[1]
// 处理重试请求: POST /api/tasks/{queue}/archived/{taskId}/retry
if r.Method == http.MethodPost && len(parts) >= 4 && parts[1] == "archived" && parts[3] == "retry" {
if m.readOnly {
http.Error(w, "Operation not allowed in read-only mode", http.StatusForbidden)
return
}
taskID := parts[2]
m.handleRetryTask(w, r, queueName, taskID)
return
}
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 检查队列是否已注册
if _, exists := m.queues[queueName]; !exists {
http.Error(w, "Queue not found", http.StatusNotFound)
return
}
// 解析分页参数
page := 1
pageSize := 20
if p := r.URL.Query().Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if ps := r.URL.Query().Get("page_size"); ps != "" {
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 {
pageSize = parsed
}
}
// 获取队列信息以获取任务总数
var total int
queueInfo, queueErr := m.inspector.GetQueueInfo(queueName)
if queueErr == nil {
switch taskState {
case "active":
total = queueInfo.Active
case "pending":
total = queueInfo.Pending
case "scheduled":
total = queueInfo.Scheduled
case "retry":
total = queueInfo.Retry
case "archived":
total = queueInfo.Archived
case "completed":
total = queueInfo.Completed
}
}
// 根据任务状态获取任务列表
var tasks []*inspector.TaskInfo
var err error
switch taskState {
case "active":
tasks, err = m.inspector.ListActiveTasks(queueName, pageSize, page-1)
case "pending":
tasks, err = m.inspector.ListPendingTasks(queueName, pageSize, page-1)
case "scheduled":
tasks, err = m.inspector.ListScheduledTasks(queueName, pageSize, page-1)
case "retry":
tasks, err = m.inspector.ListRetryTasks(queueName, pageSize, page-1)
case "archived":
tasks, err = m.inspector.ListArchivedTasks(queueName, pageSize, page-1)
case "completed":
tasks, err = m.inspector.ListCompletedTasks(queueName, pageSize, page-1)
default:
http.Error(w, "Invalid task state. Valid states: active, pending, scheduled, retry, archived, completed", http.StatusBadRequest)
return
}
// 如果队列在 Redis 中不存在(没有任务),返回空列表而不是错误
if err != nil {
if strings.Contains(err.Error(), "queue not found") {
tasks = []*inspector.TaskInfo{}
total = 0
} else {
http.Error(w, fmt.Sprintf("Failed to get tasks: %v", err), http.StatusInternalServerError)
return
}
}
var taskInfos []taskInfoJSON
for _, task := range tasks {
info := taskInfoJSON{
ID: task.ID,
Type: task.Type,
Payload: string(task.Payload),
Queue: task.Queue,
Retried: task.Retried,
}
if !task.LastFailedAt.IsZero() {
info.LastFailed = task.LastFailedAt.Format(time.RFC3339)
}
if task.LastErr != "" {
info.LastError = task.LastErr
}
if !task.NextProcessAt.IsZero() {
info.NextProcess = task.NextProcessAt.Format(time.RFC3339)
}
if !task.CompletedAt.IsZero() {
info.CompletedAt = task.CompletedAt.Format(time.RFC3339)
}
taskInfos = append(taskInfos, info)
}
response := map[string]any{
"tasks": taskInfos,
"page": page,
"page_size": pageSize,
"total": total,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleRetryTask 重试失败任务
func (m *Monitor) handleRetryTask(w http.ResponseWriter, r *http.Request, queueName, taskID string) {
// 检查队列是否已注册
if _, exists := m.queues[queueName]; !exists {
http.Error(w, "Queue not found", http.StatusNotFound)
return
}
// 运行重试
err := m.inspector.RunTask(queueName, taskID)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to retry task: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleIndex 处理主页请求,返回 SPA 入口页面
func (m *Monitor) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 读取 index.html 并替换模板变量
indexHTML, err := uiFS.ReadFile("ui/index.html")
if err != nil {
http.Error(w, fmt.Sprintf("Failed to read index.html: %v", err), http.StatusInternalServerError)
return
}
// 替换模板变量
content := strings.ReplaceAll(string(indexHTML), "{{.RootPath}}", m.rootPath)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(content))
}
// handleSSE 处理 Server-Sent Events 实时数据推送
func (m *Monitor) handleSSE(w http.ResponseWriter, r *http.Request) {
// 设置 SSE 响应头
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
// 获取 flusher
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "SSE not supported", http.StatusInternalServerError)
return
}
// 两个定时器:统计数据频率高,队列数据频率低
statsTicker := time.NewTicker(2 * time.Second)
queuesTicker := time.NewTicker(5 * time.Second)
defer statsTicker.Stop()
defer queuesTicker.Stop()
// 监听客户端断开连接
ctx := r.Context()
// 立即发送一次数据
m.sendQueuesEvent(w, flusher)
m.sendStatsEvent(w, flusher)
for {
select {
case <-ctx.Done():
return
case <-m.closeCh:
return
case <-statsTicker.C:
m.sendStatsEvent(w, flusher)
case <-queuesTicker.C:
m.sendQueuesEvent(w, flusher)
}
}
}
// sendStatsEvent 发送统计图表数据
func (m *Monitor) sendStatsEvent(w http.ResponseWriter, flusher http.Flusher) {
stats, err := m.inspector.QueryStats(inspector.StatsQuery{Limit: 1})
if err != nil || len(stats) == 0 {
return
}
data, err := json.Marshal(stats[0])
if err != nil {
return
}
fmt.Fprintf(w, "event: stats\ndata: %s\n\n", data)
flusher.Flush()
}
// sendQueuesEvent 发送队列表格数据
func (m *Monitor) sendQueuesEvent(w http.ResponseWriter, flusher http.Flusher) {
var queueInfos []queueInfoJSON
for queueName, priority := range m.queues {
info, err := m.inspector.GetQueueInfo(queueName)
if err != nil {
queueInfos = append(queueInfos, queueInfoJSON{
Name: queueName,
Priority: priority,
})
} else {
queueInfos = append(queueInfos, queueInfoJSON{
Name: queueName,
Priority: priority,
Size: info.Size,
Active: info.Active,
Pending: info.Pending,
Scheduled: info.Scheduled,
Retry: info.Retry,
Archived: info.Archived,
Completed: info.Completed,
Processed: info.Processed,
Failed: info.Failed,
Paused: info.Paused,
MemoryUsage: info.MemoryUsage,
Latency: info.LatencyMS,
})
}
}
// 按优先级排序
sort.Slice(queueInfos, func(i, j int) bool {
return queueInfos[i].Priority > queueInfos[j].Priority
})
data, err := json.Marshal(queueInfos)
if err != nil {
return
}
fmt.Fprintf(w, "event: queues\ndata: %s\n\n", data)
flusher.Flush()
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

63
x/monitor/ui/index.html Normal file
View File

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

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

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