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`