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 添加延迟/定点任务示例
This commit is contained in:
267
ui/components/tasks-chart.js
Normal file
267
ui/components/tasks-chart.js
Normal file
@@ -0,0 +1,267 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
// 十字准星 + 拖拽选择插件
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
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
|
||||
}
|
||||
},
|
||||
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);
|
||||
Reference in New Issue
Block a user