import { LitElement, html } from 'lit'; import './components/time-range-picker.js'; import './components/tasks-chart.js'; import './components/queue-table.js'; import './components/queue-modal.js'; class TaskqApp extends LitElement { static properties = { rootPath: { type: String, attribute: 'root-path' }, queues: { type: Array, state: true }, loading: { type: Boolean, state: true }, modalOpen: { type: Boolean, state: true }, currentQueue: { type: String, state: true }, currentTab: { type: String, state: true }, currentPage: { type: Number, state: true }, // Time range state duration: { type: String, state: true }, endTime: { type: Number, state: true }, isLiveMode: { type: Boolean, state: true }, // Chart data chartData: { type: Object, state: true } }; constructor() { super(); this.rootPath = '/monitor'; this.queues = []; this.loading = true; this.modalOpen = false; this.currentQueue = ''; this.currentTab = 'active'; this.currentPage = 1; this.duration = '1h'; this.endTime = null; this.isLiveMode = true; this.chartData = { labels: [], timestamps: [], datasets: {} }; this.eventSource = null; } createRenderRoot() { return this; } connectedCallback() { super.connectedCallback(); this.initRoute(); this.loadStatsForTimeRange(); this.connectSSE(); window.addEventListener('popstate', this.handlePopState.bind(this)); } disconnectedCallback() { super.disconnectedCallback(); if (this.eventSource) { this.eventSource.close(); } window.removeEventListener('popstate', this.handlePopState.bind(this)); } initRoute() { const path = window.location.pathname; const relativePath = path.replace(this.rootPath, '').replace(/^\/+/, ''); const match = relativePath.match(/^queues\/([^\/]+)\/([^\/]+)/); if (match) { this.currentQueue = decodeURIComponent(match[1]); this.currentTab = match[2]; const params = new URLSearchParams(window.location.search); this.currentPage = parseInt(params.get('page')) || 1; this.modalOpen = true; } } handlePopState(event) { if (event.state && event.state.queue) { this.currentQueue = event.state.queue; this.currentTab = event.state.tab; this.currentPage = event.state.page; this.modalOpen = true; } else { this.modalOpen = false; } } connectSSE() { if (this.eventSource) { this.eventSource.close(); } this.eventSource = new EventSource(`${this.rootPath}/api/sse`); this.eventSource.addEventListener('queues', (event) => { this.queues = JSON.parse(event.data); this.loading = false; }); this.eventSource.addEventListener('stats', (event) => { if (!this.isLiveMode) return; const stats = JSON.parse(event.data); // 检查数据是否在当前时间范围内 const durationSecs = this.parseDuration(this.duration); const now = Math.floor(Date.now() / 1000); const start = now - durationSecs; if (stats.t < start) return; // 数据不在时间范围内,忽略 this.appendStatsPoint(stats); // 移除超出时间范围的旧数据 this.trimOldData(start); }); this.eventSource.onerror = () => { setTimeout(() => this.connectSSE(), 3000); }; } appendStatsPoint(stats) { const date = new Date(stats.t * 1000); const timeLabel = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); // 检查是否已有相同时间戳的数据 if (this.chartData.timestamps.length > 0 && this.chartData.timestamps[this.chartData.timestamps.length - 1] === stats.t) { return; } const newData = { ...this.chartData }; newData.labels = [...newData.labels, timeLabel]; newData.timestamps = [...newData.timestamps, stats.t]; newData.datasets = { active: [...(newData.datasets.active || []), stats.a || 0], pending: [...(newData.datasets.pending || []), stats.p || 0], scheduled: [...(newData.datasets.scheduled || []), stats.s || 0], retry: [...(newData.datasets.retry || []), stats.r || 0], archived: [...(newData.datasets.archived || []), stats.ar || 0], completed: [...(newData.datasets.completed || []), stats.c || 0], succeeded: [...(newData.datasets.succeeded || []), stats.su || 0], failed: [...(newData.datasets.failed || []), stats.f || 0] }; this.chartData = newData; } trimOldData(startTimestamp) { const timestamps = this.chartData.timestamps || []; if (timestamps.length === 0) return; // 找到第一个在时间范围内的数据索引 let trimIndex = 0; while (trimIndex < timestamps.length && timestamps[trimIndex] < startTimestamp) { trimIndex++; } if (trimIndex === 0) return; // 没有需要移除的数据 const newData = { ...this.chartData }; newData.labels = newData.labels.slice(trimIndex); newData.timestamps = newData.timestamps.slice(trimIndex); newData.datasets = { active: (newData.datasets.active || []).slice(trimIndex), pending: (newData.datasets.pending || []).slice(trimIndex), scheduled: (newData.datasets.scheduled || []).slice(trimIndex), retry: (newData.datasets.retry || []).slice(trimIndex), archived: (newData.datasets.archived || []).slice(trimIndex), completed: (newData.datasets.completed || []).slice(trimIndex), succeeded: (newData.datasets.succeeded || []).slice(trimIndex), failed: (newData.datasets.failed || []).slice(trimIndex) }; this.chartData = newData; } async loadStatsForTimeRange() { 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/?start=${start}&end=${end}&limit=10000` ); if (!response.ok) return; const stats = await response.json(); // 将 API 数据转为 map 便于查找 const statsMap = new Map(); if (stats && stats.length > 0) { stats.forEach(s => { statsMap.set(s.t, s); }); } // 计算采样间隔(根据时间范围动态调整) const interval = this.getInterval(durationSecs); // 生成完整的时间序列 const newData = { labels: [], timestamps: [], datasets: { active: [], pending: [], scheduled: [], retry: [], archived: [], completed: [], succeeded: [], failed: [] } }; // 对齐到间隔 const alignedStart = Math.floor(start / interval) * interval; for (let t = alignedStart; t <= end; t += interval) { const date = new Date(t * 1000); const timeLabel = this.formatTimeLabel(date, durationSecs); newData.labels.push(timeLabel); newData.timestamps.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; } catch (err) { console.error('Failed to load stats:', err); } } // 根据时间范围计算采样间隔 getInterval(durationSecs) { if (durationSecs <= 300) return 2; // 5m -> 2s if (durationSecs <= 900) return 5; // 15m -> 5s if (durationSecs <= 1800) return 10; // 30m -> 10s if (durationSecs <= 3600) return 20; // 1h -> 20s if (durationSecs <= 10800) return 60; // 3h -> 1m if (durationSecs <= 21600) return 120; // 6h -> 2m if (durationSecs <= 43200) return 300; // 12h -> 5m if (durationSecs <= 86400) return 600; // 1d -> 10m if (durationSecs <= 259200) return 1800; // 3d -> 30m return 3600; // 7d -> 1h } // 格式化时间标签 formatTimeLabel(date, durationSecs) { if (durationSecs <= 86400) { // 1天内只显示时间 return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } else { // 超过1天显示日期和时间 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; } 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; } } handleTimeRangeChange(e) { const { duration, endTime, isLiveMode } = e.detail; this.duration = duration; this.endTime = endTime; this.isLiveMode = isLiveMode; this.loadStatsForTimeRange(); } handleTimeRangeSelect(e) { const { start, end } = e.detail; if (!start || !end) return; // 计算选择的时间范围对应的 duration const durationSecs = end - start; // 设置为非实时模式,结束时间为选择的结束时间 this.isLiveMode = false; this.endTime = end; // 根据选择的秒数设置合适的 duration 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.loadStatsForTimeRange(); } handleQueueClick(e) { const { queue } = e.detail; this.currentQueue = queue; this.currentTab = 'active'; this.currentPage = 1; this.modalOpen = true; const url = `${this.rootPath}/queues/${queue}/${this.currentTab}?page=1`; history.pushState({ queue, tab: this.currentTab, page: 1 }, '', url); } handleModalClose() { this.modalOpen = false; history.pushState({}, '', `${this.rootPath}/`); } handleTabChange(e) { const { tab } = e.detail; this.currentTab = tab; this.currentPage = 1; const url = `${this.rootPath}/queues/${this.currentQueue}/${tab}?page=1`; history.pushState({ queue: this.currentQueue, tab, page: 1 }, '', url); } handlePageChange(e) { const { page } = e.detail; this.currentPage = page; const url = `${this.rootPath}/queues/${this.currentQueue}/${this.currentTab}?page=${page}`; history.pushState({ queue: this.currentQueue, tab: this.currentTab, page }, '', url); } render() { return html`
Tasks Overview
${this.loading ? html`
Loading...
` : html` `}
`; } } customElements.define('taskq-app', TaskqApp);