- 新增 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 添加延迟/定点任务示例
870 lines
26 KiB
JavaScript
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}>×</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);
|