- 添加 appbar 导航栏,支持 Chart/Queues 视图切换 - appbar 切换使用 history API,支持浏览器前进/后退 - 图表视图占满整个可视区域 - queue-modal 共享 appbar 样式 - 修复 queue tab count 字段名大小写问题 - tooltip 跟随鼠标显示在右下方,移除箭头 - 图表 canvas 鼠标样式改为准星 - pause/resume 队列后刷新列表 - example 添加 flag 配置参数
876 lines
26 KiB
JavaScript
876 lines
26 KiB
JavaScript
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}>×</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);
|