Files
taskq/x/monitor/ui/app.js
hupeh 326f2a371c feat: 优化监控仪表盘 UI
- 添加 appbar 导航栏,支持 Chart/Queues 视图切换
- appbar 切换使用 history API,支持浏览器前进/后退
- 图表视图占满整个可视区域
- queue-modal 共享 appbar 样式
- 修复 queue tab count 字段名大小写问题
- tooltip 跟随鼠标显示在右下方,移除箭头
- 图表 canvas 鼠标样式改为准星
- pause/resume 队列后刷新列表
- example 添加 flag 配置参数
2025-12-10 00:53:30 +08:00

461 lines
15 KiB
JavaScript

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 },
// View mode: 'chart' or 'table'
viewMode: { type: String, 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;
this.viewMode = 'chart';
}
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(/^\/+/, '');
// 检查是否是 queues 视图
if (relativePath === 'queues' || relativePath === 'queues/') {
this.viewMode = 'table';
return;
}
// 检查是否是具体队列详情
const match = relativePath.match(/^queues\/([^\/]+)\/([^\/]+)/);
if (match) {
this.viewMode = 'table';
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.viewMode = 'table';
this.currentQueue = event.state.queue;
this.currentTab = event.state.tab;
this.currentPage = event.state.page;
this.modalOpen = true;
} else if (event.state && event.state.view) {
this.viewMode = event.state.view;
this.modalOpen = false;
} 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({ view: 'table' }, '', `${this.rootPath}/queues`);
}
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);
}
handleViewChange(mode) {
this.viewMode = mode;
const url = mode === 'table' ? `${this.rootPath}/queues` : `${this.rootPath}/`;
history.pushState({ view: mode }, '', url);
}
async handleQueueUpdated() {
try {
const response = await fetch(`${this.rootPath}/api/queues`);
if (response.ok) {
this.queues = await response.json();
}
} catch (err) {
console.error('Failed to refresh queues:', err);
}
}
render() {
return html`
<div class="appbar">
<div class="appbar-title">TaskQ Monitor</div>
<div class="appbar-tabs">
<button
class="appbar-tab ${this.viewMode === 'chart' ? 'active' : ''}"
@click=${() => this.handleViewChange('chart')}
>Chart</button>
<button
class="appbar-tab ${this.viewMode === 'table' ? 'active' : ''}"
@click=${() => this.handleViewChange('table')}
>Queues</button>
</div>
</div>
${this.viewMode === 'chart' ? html`
<div class="chart-card chart-fullheight">
<div class="chart-header">
<span class="chart-title">Tasks Overview</span>
<time-range-picker
.duration=${this.duration}
.endTime=${this.endTime}
.isLiveMode=${this.isLiveMode}
@change=${this.handleTimeRangeChange}
></time-range-picker>
</div>
<div class="chart-container-large">
<tasks-chart
.data=${this.chartData}
.timestamps=${this.chartData.timestamps || []}
@time-range-select=${this.handleTimeRangeSelect}
></tasks-chart>
</div>
</div>
` : html`
<div class="table-card">
${this.loading ? html`
<div class="loading">
<div class="loading-spinner"></div>
<div>Loading...</div>
</div>
` : html`
<queue-table
.queues=${this.queues}
.rootPath=${this.rootPath}
@queue-click=${this.handleQueueClick}
@queue-updated=${this.handleQueueUpdated}
></queue-table>
`}
</div>
`}
<queue-modal
.open=${this.modalOpen}
.queue=${this.currentQueue}
.tab=${this.currentTab}
.page=${this.currentPage}
.rootPath=${this.rootPath}
@close=${this.handleModalClose}
@tab-change=${this.handleTabChange}
@page-change=${this.handlePageChange}
></queue-modal>
`;
}
}
customElements.define('taskq-app', TaskqApp);