Files
taskq/ui/app.js
hupeh 1f9f1cab53 feat: 添加监控仪表盘
- 新增 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 添加延迟/定点任务示例
2025-12-09 19:58:18 +08:00

411 lines
14 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 }
};
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`
<div class="chart-card">
<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">
<tasks-chart
.data=${this.chartData}
.timestamps=${this.chartData.timestamps || []}
@time-range-select=${this.handleTimeRangeSelect}
></tasks-chart>
</div>
</div>
<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-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);