Files
taskq/x/monitor/ui/components/queue-modal.js
hupeh 326f2a371c feat: 优化监控仪表盘 UI
- 添加 appbar 导航栏,支持 Chart/Queues 视图切换
- appbar 切换使用 history API,支持浏览器前进/后退
- 图表视图占满整个可视区域
- queue-modal 共享 appbar 样式
- 修复 queue tab count 字段名大小写问题
- tooltip 跟随鼠标显示在右下方,移除箭头
- 图表 canvas 鼠标样式改为准星
- pause/resume 队列后刷新列表
- example 添加 flag 配置参数
2025-12-10 00:53:30 +08:00

876 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);