- 新增 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 添加延迟/定点任务示例
411 lines
14 KiB
JavaScript
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);
|