Files
taskq/ui/components/queue-modal.js
hupeh 1f9f1cab53 feat: 添加监控仪表盘
- 新增 Lit.js 组件化 UI (ui/ 目录)
  - tasks-chart: 带十字准星和拖拽选择的图表
  - queue-table: 队列列表,支持暂停/恢复
  - queue-modal: 队列详情弹窗,支持任务重试
  - time-range-picker: Prometheus 风格时间选择器
  - help-tooltip: 可复用的提示组件

- HTTPHandler 功能
  - SSE 实时推送 (stats + queues)
  - 队列暂停/恢复 API
  - 任务重试 API
  - 时间范围查询 API

- Inspector 改进
  - Prometheus 风格单表存储
  - 集成到 Start/Stop 生命周期
  - 新增 PauseQueue/UnpauseQueue/RunTask 方法

- 代码重构
  - Start 函数拆分为小函数
  - 优雅关闭流程优化

- 其他
  - 忽略 SQLite 数据库文件
  - example 添加延迟/定点任务示例
2025-12-09 19:58:18 +08:00

870 lines
26 KiB
JavaScript

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