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 添加延迟/定点任务示例
This commit is contained in:
410
ui/app.js
Normal file
410
ui/app.js
Normal file
@@ -0,0 +1,410 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user