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);
|
||||
80
ui/components/help-tooltip.js
Normal file
80
ui/components/help-tooltip.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
|
||||
class HelpTooltip extends LitElement {
|
||||
static properties = {
|
||||
text: { type: String }
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #616161;
|
||||
color: #9e9e9e;
|
||||
font-size: 10px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
background: #757575;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.icon:hover + .tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #212121;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.tooltip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-bottom-color: #212121;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.text = '';
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<span class="icon">?</span>
|
||||
<span class="tooltip">${this.text}</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('help-tooltip', HelpTooltip);
|
||||
869
ui/components/queue-modal.js
Normal file
869
ui/components/queue-modal.js
Normal file
@@ -0,0 +1,869 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import './time-range-picker.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
// 十字准星 + 拖拽选择插件(与 tasks-chart 共用逻辑)
|
||||
const crosshairPlugin = {
|
||||
id: 'queueCrosshair',
|
||||
afterEvent(chart, args) {
|
||||
const event = args.event;
|
||||
if (event.type === 'mousemove') {
|
||||
chart.crosshair = { x: event.x, y: event.y };
|
||||
} else if (event.type === 'mouseout') {
|
||||
chart.crosshair = null;
|
||||
}
|
||||
},
|
||||
afterDraw(chart) {
|
||||
const ctx = chart.ctx;
|
||||
const topY = chart.scales.y.top;
|
||||
const bottomY = chart.scales.y.bottom;
|
||||
const leftX = chart.scales.x.left;
|
||||
const rightX = chart.scales.x.right;
|
||||
|
||||
// 绘制选择区域
|
||||
if (chart.dragSelect && chart.dragSelect.startX !== null) {
|
||||
const { startX, currentX } = chart.dragSelect;
|
||||
const x1 = Math.max(leftX, Math.min(startX, currentX));
|
||||
const x2 = Math.min(rightX, Math.max(startX, currentX));
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(255, 193, 7, 0.2)';
|
||||
ctx.fillRect(x1, topY, x2 - x1, bottomY - topY);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 绘制十字准星
|
||||
if (!chart.crosshair) return;
|
||||
const { x, y } = chart.crosshair;
|
||||
|
||||
if (x < leftX || x > rightX || y < topY || y > bottomY) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, topY);
|
||||
ctx.lineTo(x, bottomY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(leftX, y);
|
||||
ctx.lineTo(rightX, y);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
Chart.register(crosshairPlugin);
|
||||
|
||||
class QueueModal extends LitElement {
|
||||
static properties = {
|
||||
open: { type: Boolean },
|
||||
queue: { type: String },
|
||||
tab: { type: String },
|
||||
page: { type: Number },
|
||||
rootPath: { type: String },
|
||||
tasks: { type: Array, state: true },
|
||||
total: { type: Number, state: true },
|
||||
loading: { type: Boolean, state: true },
|
||||
chartData: { type: Object, state: true },
|
||||
queueInfo: { type: Object, state: true },
|
||||
// Time range state
|
||||
duration: { type: String, state: true },
|
||||
endTime: { type: Number, state: true },
|
||||
isLiveMode: { type: Boolean, state: true },
|
||||
timestamps: { type: Array, state: true }
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #424242;
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #424242;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background: #515151;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #616161;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.1em;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
color: #9e9e9e;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #616161;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
height: calc(100vh - 60px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.queue-chart-card {
|
||||
margin-bottom: 15px;
|
||||
background: #424242;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.queue-chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 15px;
|
||||
border-bottom: 1px solid #515151;
|
||||
}
|
||||
|
||||
.queue-chart-title {
|
||||
font-size: 0.9em;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.queue-chart-container {
|
||||
height: 180px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.task-tabs {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-bottom: 20px;
|
||||
background: #424242;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.task-tab {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9e9e9e;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.task-tab:hover {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.task-tab.active {
|
||||
background: #42a5f5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
font-size: 0.8em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.task-tab.active .tab-count {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.task-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
background: #5a5a5a;
|
||||
border: 1px solid #616161;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
border-color: #42a5f5;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.task-type {
|
||||
font-weight: 500;
|
||||
color: #42a5f5;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
font-size: 0.8em;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
.task-id {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.task-retried {
|
||||
color: #ffa726;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8em;
|
||||
background: #42a5f5;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: #1e88e5;
|
||||
}
|
||||
|
||||
.retry-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.task-payload {
|
||||
font-family: monospace;
|
||||
font-size: 0.75em;
|
||||
color: #9e9e9e;
|
||||
background: #424242;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
font-size: 0.8em;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
.task-error {
|
||||
color: #ef5350;
|
||||
background: rgba(239, 83, 80, 0.1);
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #616161;
|
||||
background: #424242;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.pagination button:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.pagination button.active {
|
||||
background: #42a5f5;
|
||||
border-color: #42a5f5;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #616161;
|
||||
border-top-color: #42a5f5;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 15px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
`;
|
||||
|
||||
static tabs = ['active', 'pending', 'scheduled', 'retry', 'archived', 'completed'];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.open = false;
|
||||
this.queue = '';
|
||||
this.tab = 'active';
|
||||
this.page = 1;
|
||||
this.rootPath = '/monitor';
|
||||
this.tasks = [];
|
||||
this.total = 0;
|
||||
this.loading = false;
|
||||
this.chartData = { labels: [], datasets: {} };
|
||||
this.chart = null;
|
||||
this.pageSize = 20;
|
||||
this.queueInfo = null;
|
||||
// Time range defaults
|
||||
this.duration = '1h';
|
||||
this.endTime = null;
|
||||
this.isLiveMode = true;
|
||||
this.timestamps = [];
|
||||
this.isDragging = false;
|
||||
}
|
||||
|
||||
updated(changedProperties) {
|
||||
if (changedProperties.has('open') && this.open) {
|
||||
this.loadQueueInfo();
|
||||
this.loadQueueHistory();
|
||||
this.loadTasks();
|
||||
}
|
||||
if ((changedProperties.has('tab') || changedProperties.has('page')) && this.open) {
|
||||
this.loadTasks();
|
||||
}
|
||||
if (changedProperties.has('chartData') && this.chart) {
|
||||
this.updateChart();
|
||||
}
|
||||
}
|
||||
|
||||
async loadQueueInfo() {
|
||||
try {
|
||||
const response = await fetch(`${this.rootPath}/api/queues/${this.queue}`);
|
||||
if (!response.ok) return;
|
||||
this.queueInfo = await response.json();
|
||||
} catch (err) {
|
||||
console.error('Failed to load queue info:', err);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
getInterval(durationSecs) {
|
||||
if (durationSecs <= 300) return 2;
|
||||
if (durationSecs <= 900) return 5;
|
||||
if (durationSecs <= 1800) return 10;
|
||||
if (durationSecs <= 3600) return 20;
|
||||
if (durationSecs <= 10800) return 60;
|
||||
if (durationSecs <= 21600) return 120;
|
||||
if (durationSecs <= 43200) return 300;
|
||||
if (durationSecs <= 86400) return 600;
|
||||
if (durationSecs <= 259200) return 1800;
|
||||
return 3600;
|
||||
}
|
||||
|
||||
formatTimeLabel(date, durationSecs) {
|
||||
if (durationSecs <= 86400) {
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
});
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
|
||||
async loadQueueHistory() {
|
||||
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/${this.queue}?start=${start}&end=${end}&limit=10000`
|
||||
);
|
||||
if (!response.ok) return;
|
||||
|
||||
const stats = await response.json();
|
||||
|
||||
// Build stats map
|
||||
const statsMap = new Map();
|
||||
if (stats && stats.length > 0) {
|
||||
stats.forEach(s => statsMap.set(s.t, s));
|
||||
}
|
||||
|
||||
// Calculate interval
|
||||
const interval = this.getInterval(durationSecs);
|
||||
const alignedStart = Math.floor(start / interval) * interval;
|
||||
|
||||
const newData = {
|
||||
labels: [], datasets: {
|
||||
active: [], pending: [], scheduled: [], retry: [],
|
||||
archived: [], completed: [], succeeded: [], failed: []
|
||||
}
|
||||
};
|
||||
const newTimestamps = [];
|
||||
|
||||
for (let t = alignedStart; t <= end; t += interval) {
|
||||
const date = new Date(t * 1000);
|
||||
const timeLabel = this.formatTimeLabel(date, durationSecs);
|
||||
|
||||
newData.labels.push(timeLabel);
|
||||
newTimestamps.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;
|
||||
this.timestamps = newTimestamps;
|
||||
|
||||
await this.updateComplete;
|
||||
this.initChart();
|
||||
} catch (err) {
|
||||
console.error('Failed to load queue history:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async loadTasks() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.rootPath}/api/tasks/${this.queue}/${this.tab}?page=${this.page}&page_size=${this.pageSize}`
|
||||
);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const data = await response.json();
|
||||
this.tasks = data.tasks || [];
|
||||
this.total = data.total || 0;
|
||||
} catch (err) {
|
||||
console.error('Failed to load tasks:', err);
|
||||
this.tasks = [];
|
||||
this.total = 0;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
initChart() {
|
||||
const canvas = this.shadowRoot.querySelector('canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
this.chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{ label: 'Active', data: [], borderColor: '#42a5f5', backgroundColor: 'transparent', fill: false, hidden: true },
|
||||
{ label: 'Pending', data: [], borderColor: '#5c6bc0', backgroundColor: 'transparent', fill: false },
|
||||
{ label: 'Scheduled', data: [], borderColor: '#ffa726', backgroundColor: 'transparent', fill: false, hidden: true },
|
||||
{ label: 'Retry', data: [], borderColor: '#ef5350', backgroundColor: 'transparent', fill: false, hidden: true },
|
||||
{ label: 'Archived', data: [], borderColor: '#ab47bc', backgroundColor: 'transparent', fill: false, hidden: true },
|
||||
{ label: 'Completed', data: [], borderColor: '#66bb6a', backgroundColor: 'transparent', fill: false, hidden: true },
|
||||
{ label: 'Succeeded', data: [], borderColor: '#81c784', backgroundColor: 'transparent', fill: false, borderDash: [5, 5], hidden: true },
|
||||
{ label: 'Failed', data: [], borderColor: '#e57373', backgroundColor: 'transparent', fill: false, borderDash: [5, 5], hidden: true }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 0 },
|
||||
clip: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
hover: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: '#e0e0e0', padding: 10, usePointStyle: true, pointStyle: 'circle', font: { size: 11 } }
|
||||
},
|
||||
tooltip: { enabled: true, backgroundColor: 'rgba(30, 30, 30, 0.9)', titleColor: '#e0e0e0', bodyColor: '#e0e0e0' }
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { color: '#616161' }, ticks: { color: '#9e9e9e', maxTicksLimit: 8 } },
|
||||
y: { grid: { color: '#616161' }, ticks: { color: '#9e9e9e' }, beginAtZero: true }
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0,
|
||||
hoverRadius: 5,
|
||||
hitRadius: 10,
|
||||
hoverBackgroundColor: (ctx) => ctx.dataset.borderColor,
|
||||
hoverBorderColor: (ctx) => ctx.dataset.borderColor,
|
||||
hoverBorderWidth: 0
|
||||
},
|
||||
line: { tension: 0.3, borderWidth: 1.5, spanGaps: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
this.setupDragSelect(canvas);
|
||||
this.updateChart();
|
||||
}
|
||||
|
||||
setupDragSelect(canvas) {
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
if (!this.chart) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const leftX = this.chart.scales.x.left;
|
||||
const rightX = this.chart.scales.x.right;
|
||||
|
||||
if (x >= leftX && x <= rightX) {
|
||||
this.isDragging = true;
|
||||
this.chart.dragSelect = { startX: x, currentX: x };
|
||||
this.chart.update('none');
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
if (!this.isDragging || !this.chart) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
this.chart.dragSelect.currentX = x;
|
||||
this.chart.update('none');
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseup', (e) => {
|
||||
if (!this.isDragging || !this.chart) return;
|
||||
this.isDragging = false;
|
||||
|
||||
const { startX, currentX } = this.chart.dragSelect;
|
||||
const leftX = this.chart.scales.x.left;
|
||||
const rightX = this.chart.scales.x.right;
|
||||
|
||||
const x1 = Math.max(leftX, Math.min(startX, currentX));
|
||||
const x2 = Math.min(rightX, Math.max(startX, currentX));
|
||||
|
||||
if (Math.abs(x2 - x1) > 10 && this.timestamps.length > 0) {
|
||||
const startIndex = Math.round((x1 - leftX) / (rightX - leftX) * (this.timestamps.length - 1));
|
||||
const endIndex = Math.round((x2 - leftX) / (rightX - leftX) * (this.timestamps.length - 1));
|
||||
|
||||
if (startIndex >= 0 && endIndex < this.timestamps.length) {
|
||||
const startTime = this.timestamps[startIndex];
|
||||
const endTime = this.timestamps[endIndex];
|
||||
this.handleTimeRangeSelect(startTime, endTime);
|
||||
}
|
||||
}
|
||||
|
||||
this.chart.dragSelect = null;
|
||||
this.chart.update('none');
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseleave', () => {
|
||||
if (this.isDragging && this.chart) {
|
||||
this.isDragging = false;
|
||||
this.chart.dragSelect = null;
|
||||
this.chart.update('none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleTimeRangeSelect(start, end) {
|
||||
const durationSecs = end - start;
|
||||
|
||||
this.isLiveMode = false;
|
||||
this.endTime = end;
|
||||
|
||||
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.loadQueueHistory();
|
||||
}
|
||||
|
||||
handleChartTimeRangeChange(e) {
|
||||
const { duration, endTime, isLiveMode } = e.detail;
|
||||
this.duration = duration;
|
||||
this.endTime = endTime;
|
||||
this.isLiveMode = isLiveMode;
|
||||
this.loadQueueHistory();
|
||||
}
|
||||
|
||||
updateChart() {
|
||||
if (!this.chart || !this.chartData) return;
|
||||
|
||||
this.chart.data.labels = this.chartData.labels || [];
|
||||
this.chart.data.datasets[0].data = this.chartData.datasets?.active || [];
|
||||
this.chart.data.datasets[1].data = this.chartData.datasets?.pending || [];
|
||||
this.chart.data.datasets[2].data = this.chartData.datasets?.scheduled || [];
|
||||
this.chart.data.datasets[3].data = this.chartData.datasets?.retry || [];
|
||||
this.chart.data.datasets[4].data = this.chartData.datasets?.archived || [];
|
||||
this.chart.data.datasets[5].data = this.chartData.datasets?.completed || [];
|
||||
this.chart.data.datasets[6].data = this.chartData.datasets?.succeeded || [];
|
||||
this.chart.data.datasets[7].data = this.chartData.datasets?.failed || [];
|
||||
this.chart.update('none');
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
this.chart = null;
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('close'));
|
||||
}
|
||||
|
||||
handleTabClick(tab) {
|
||||
this.dispatchEvent(new CustomEvent('tab-change', { detail: { tab } }));
|
||||
}
|
||||
|
||||
getTabCount(tab) {
|
||||
if (!this.queueInfo) return 0;
|
||||
switch (tab) {
|
||||
case 'active': return this.queueInfo.Active || 0;
|
||||
case 'pending': return this.queueInfo.Pending || 0;
|
||||
case 'scheduled': return this.queueInfo.Scheduled || 0;
|
||||
case 'retry': return this.queueInfo.Retry || 0;
|
||||
case 'archived': return this.queueInfo.Archived || 0;
|
||||
case 'completed': return this.queueInfo.Completed || 0;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
handlePageClick(page) {
|
||||
this.dispatchEvent(new CustomEvent('page-change', { detail: { page } }));
|
||||
}
|
||||
|
||||
async retryTask(taskId) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.rootPath}/api/tasks/${this.queue}/archived/${taskId}/retry`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
// Reload tasks after retry
|
||||
this.loadTasks();
|
||||
} catch (err) {
|
||||
console.error('Failed to retry task:', err);
|
||||
alert('Failed to retry task: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
renderPagination() {
|
||||
if (!this.total) return '';
|
||||
|
||||
const totalPages = Math.ceil(this.total / this.pageSize);
|
||||
const startPage = Math.max(1, this.page - 2);
|
||||
const endPage = Math.min(totalPages, this.page + 2);
|
||||
|
||||
const pages = [];
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="pagination">
|
||||
<button ?disabled=${this.page <= 1} @click=${() => this.handlePageClick(this.page - 1)}>
|
||||
Prev
|
||||
</button>
|
||||
${pages.map(p => html`
|
||||
<button class="${p === this.page ? 'active' : ''}" @click=${() => this.handlePageClick(p)}>
|
||||
${p}
|
||||
</button>
|
||||
`)}
|
||||
<button ?disabled=${this.page >= totalPages} @click=${() => this.handlePageClick(this.page + 1)}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderTasks() {
|
||||
if (this.loading) {
|
||||
return html`
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this.tasks || this.tasks.length === 0) {
|
||||
return html`<div class="empty-state">No tasks</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="task-list">
|
||||
${this.tasks.map(task => html`
|
||||
<div class="task-item">
|
||||
<div class="task-header">
|
||||
<div class="task-type">${task.type}</div>
|
||||
<div class="task-info">
|
||||
${task.retried > 0 ? html`<span class="task-retried">Retried: ${task.retried}</span>` : ''}
|
||||
<span class="task-id">${task.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
${task.payload ? html`<div class="task-payload">${task.payload}</div>` : ''}
|
||||
<div class="task-meta">
|
||||
${task.next_process ? html`<span>Next: ${new Date(task.next_process).toLocaleString()}</span>` : ''}
|
||||
${task.completed_at ? html`<span>Completed: ${new Date(task.completed_at).toLocaleString()}</span>` : ''}
|
||||
</div>
|
||||
${task.last_error ? html`<div class="task-error">${task.last_error}</div>` : ''}
|
||||
${this.tab === 'archived' ? html`
|
||||
<div class="task-actions">
|
||||
<button class="retry-btn" @click=${() => this.retryTask(task.id)}>Retry</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
${this.renderPagination()}
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="modal ${this.open ? 'open' : ''}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Queue: ${this.queue}</h2>
|
||||
<button class="close-btn" @click=${this.handleClose}>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="queue-chart-card">
|
||||
<div class="queue-chart-header">
|
||||
<span class="queue-chart-title">Queue Statistics</span>
|
||||
<time-range-picker
|
||||
.duration=${this.duration}
|
||||
.endTime=${this.endTime}
|
||||
.isLiveMode=${this.isLiveMode}
|
||||
@change=${this.handleChartTimeRangeChange}
|
||||
></time-range-picker>
|
||||
</div>
|
||||
<div class="queue-chart-container">
|
||||
<canvas></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-tabs">
|
||||
${QueueModal.tabs.map(t => html`
|
||||
<button
|
||||
class="task-tab ${t === this.tab ? 'active' : ''}"
|
||||
@click=${() => this.handleTabClick(t)}
|
||||
>
|
||||
${t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
<span class="tab-count">${this.getTabCount(t)}</span>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
${this.renderTasks()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('queue-modal', QueueModal);
|
||||
233
ui/components/queue-table.js
Normal file
233
ui/components/queue-table.js
Normal file
@@ -0,0 +1,233 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import './help-tooltip.js';
|
||||
|
||||
class QueueTable extends LitElement {
|
||||
static properties = {
|
||||
queues: { type: Array },
|
||||
rootPath: { type: String }
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #424242;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: #bdbdbd;
|
||||
font-size: 0.9em;
|
||||
border-bottom: 1px solid #616161;
|
||||
}
|
||||
|
||||
.th-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #616161;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.queue-name {
|
||||
font-weight: 500;
|
||||
color: #4fc3f7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.queue-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.state-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
background: #66bb6a;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.state-badge.paused {
|
||||
background: #ffb74d;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.memory-value {
|
||||
font-size: 0.9em;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.latency-value {
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #757575;
|
||||
color: #bdbdbd;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8em;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #616161;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.action-btn.pause {
|
||||
border-color: #ffb74d;
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
.action-btn.pause:hover {
|
||||
background: rgba(255, 183, 77, 0.2);
|
||||
}
|
||||
|
||||
.action-btn.resume {
|
||||
border-color: #66bb6a;
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
.action-btn.resume:hover {
|
||||
background: rgba(102, 187, 106, 0.2);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.queues = [];
|
||||
this.rootPath = '/monitor';
|
||||
}
|
||||
|
||||
handleQueueClick(queue) {
|
||||
this.dispatchEvent(new CustomEvent('queue-click', {
|
||||
detail: { queue: queue.name }
|
||||
}));
|
||||
}
|
||||
|
||||
async togglePause(queue) {
|
||||
const action = queue.paused ? 'unpause' : 'pause';
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.rootPath}/api/queues/${queue.name}/${action}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
// 触发刷新事件
|
||||
this.dispatchEvent(new CustomEvent('queue-updated'));
|
||||
} catch (err) {
|
||||
console.error(`Failed to ${action} queue:`, err);
|
||||
alert(`Failed to ${action} queue: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
formatMemory(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / 1024 / 1024).toFixed(2) + ' MB';
|
||||
}
|
||||
|
||||
renderTh(label, tooltip) {
|
||||
if (!tooltip) {
|
||||
return html`<th>${label}</th>`;
|
||||
}
|
||||
return html`
|
||||
<th>
|
||||
<span class="th-content">
|
||||
${label}
|
||||
<help-tooltip text="${tooltip}"></help-tooltip>
|
||||
</span>
|
||||
</th>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
render() {
|
||||
if (!this.queues || this.queues.length === 0) {
|
||||
return html`<div class="empty-state">No queues</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Queue</th>
|
||||
${this.renderTh('State', 'run: 正常处理任务 | paused: 暂停处理新任务')}
|
||||
${this.renderTh('Active', '正在被 worker 处理的任务数')}
|
||||
${this.renderTh('Pending', '等待处理的任务数')}
|
||||
${this.renderTh('Scheduled', '定时/延迟任务,到达指定时间后进入 Pending')}
|
||||
${this.renderTh('Retry', '处理失败后等待重试的任务数')}
|
||||
${this.renderTh('Archived', '超过最大重试次数的失败任务')}
|
||||
${this.renderTh('Memory', '队列在 Redis 中占用的内存')}
|
||||
${this.renderTh('Latency', '最老 Pending 任务的等待时间,反映处理及时性')}
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this.queues.map(queue => html`
|
||||
<tr>
|
||||
<td>
|
||||
<span class="queue-name" @click=${() => this.handleQueueClick(queue)}>
|
||||
${queue.name}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="state-badge ${queue.paused ? 'paused' : ''}">
|
||||
${queue.paused ? 'paused' : 'run'}
|
||||
</span>
|
||||
</td>
|
||||
<td>${queue.active || 0}</td>
|
||||
<td>${queue.pending || 0}</td>
|
||||
<td>${queue.scheduled || 0}</td>
|
||||
<td>${queue.retry || 0}</td>
|
||||
<td>${queue.archived || 0}</td>
|
||||
<td class="memory-value">${this.formatMemory(queue.memory_usage)}</td>
|
||||
<td class="latency-value">${queue.latency || 0}ms</td>
|
||||
<td>
|
||||
<button class="action-btn" @click=${() => this.handleQueueClick(queue)}>
|
||||
View
|
||||
</button>
|
||||
<button class="action-btn ${queue.paused ? 'resume' : 'pause'}"
|
||||
@click=${() => this.togglePause(queue)}>
|
||||
${queue.paused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('queue-table', QueueTable);
|
||||
267
ui/components/tasks-chart.js
Normal file
267
ui/components/tasks-chart.js
Normal file
@@ -0,0 +1,267 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
// 十字准星 + 拖拽选择插件
|
||||
const crosshairPlugin = {
|
||||
id: 'crosshair',
|
||||
afterEvent(chart, args) {
|
||||
const event = args.event;
|
||||
if (event.type === 'mousemove') {
|
||||
chart.crosshair = { x: event.x, y: event.y };
|
||||
} else if (event.type === 'mouseout') {
|
||||
chart.crosshair = null;
|
||||
}
|
||||
},
|
||||
afterDraw(chart) {
|
||||
const ctx = chart.ctx;
|
||||
const topY = chart.scales.y.top;
|
||||
const bottomY = chart.scales.y.bottom;
|
||||
const leftX = chart.scales.x.left;
|
||||
const rightX = chart.scales.x.right;
|
||||
|
||||
// 绘制选择区域
|
||||
if (chart.dragSelect && chart.dragSelect.startX !== null) {
|
||||
const { startX, currentX } = chart.dragSelect;
|
||||
const x1 = Math.max(leftX, Math.min(startX, currentX));
|
||||
const x2 = Math.min(rightX, Math.max(startX, currentX));
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(255, 193, 7, 0.2)';
|
||||
ctx.fillRect(x1, topY, x2 - x1, bottomY - topY);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 绘制十字准星
|
||||
if (!chart.crosshair) return;
|
||||
const { x, y } = chart.crosshair;
|
||||
|
||||
// 检查鼠标是否在图表区域内
|
||||
if (x < leftX || x > rightX || y < topY || y > bottomY) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
||||
|
||||
// 垂直线
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, topY);
|
||||
ctx.lineTo(x, bottomY);
|
||||
ctx.stroke();
|
||||
|
||||
// 水平线跟随鼠标
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(leftX, y);
|
||||
ctx.lineTo(rightX, y);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
Chart.register(crosshairPlugin);
|
||||
|
||||
class TasksChart extends LitElement {
|
||||
static properties = {
|
||||
data: { type: Object },
|
||||
timestamps: { type: Array }
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.data = { labels: [], datasets: {} };
|
||||
this.timestamps = [];
|
||||
this.chart = null;
|
||||
this.isDragging = false;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.initChart();
|
||||
this.setupDragSelect();
|
||||
}
|
||||
|
||||
setupDragSelect() {
|
||||
const canvas = this.shadowRoot.querySelector('canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
if (!this.chart) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const leftX = this.chart.scales.x.left;
|
||||
const rightX = this.chart.scales.x.right;
|
||||
|
||||
if (x >= leftX && x <= rightX) {
|
||||
this.isDragging = true;
|
||||
this.chart.dragSelect = { startX: x, currentX: x };
|
||||
this.chart.update('none');
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
if (!this.isDragging || !this.chart) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
this.chart.dragSelect.currentX = x;
|
||||
this.chart.update('none');
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseup', (e) => {
|
||||
if (!this.isDragging || !this.chart) return;
|
||||
this.isDragging = false;
|
||||
|
||||
const { startX, currentX } = this.chart.dragSelect;
|
||||
const leftX = this.chart.scales.x.left;
|
||||
const rightX = this.chart.scales.x.right;
|
||||
|
||||
// 计算选择的时间范围
|
||||
const x1 = Math.max(leftX, Math.min(startX, currentX));
|
||||
const x2 = Math.min(rightX, Math.max(startX, currentX));
|
||||
|
||||
// 最小选择宽度检查
|
||||
if (Math.abs(x2 - x1) > 10) {
|
||||
const startIndex = Math.round((x1 - leftX) / (rightX - leftX) * (this.timestamps.length - 1));
|
||||
const endIndex = Math.round((x2 - leftX) / (rightX - leftX) * (this.timestamps.length - 1));
|
||||
|
||||
if (startIndex >= 0 && endIndex < this.timestamps.length) {
|
||||
const startTime = this.timestamps[startIndex];
|
||||
const endTime = this.timestamps[endIndex];
|
||||
|
||||
this.dispatchEvent(new CustomEvent('time-range-select', {
|
||||
detail: { start: startTime, end: endTime },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
this.chart.dragSelect = null;
|
||||
this.chart.update('none');
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseleave', () => {
|
||||
if (this.isDragging && this.chart) {
|
||||
this.isDragging = false;
|
||||
this.chart.dragSelect = null;
|
||||
this.chart.update('none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initChart() {
|
||||
const canvas = this.shadowRoot.querySelector('canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
this.chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{ label: 'Active', data: [], borderColor: '#42a5f5', backgroundColor: 'transparent', fill: false, hidden: true },
|
||||
{ label: 'Pending', data: [], borderColor: '#5c6bc0', backgroundColor: 'transparent', fill: false },
|
||||
{ label: 'Scheduled', data: [], borderColor: '#ffa726', backgroundColor: 'transparent', fill: false, hidden: true },
|
||||
{ label: 'Retry', data: [], borderColor: '#ef5350', backgroundColor: 'transparent', fill: false, hidden: true },
|
||||
{ label: 'Archived', data: [], borderColor: '#ab47bc', backgroundColor: 'transparent', fill: false, hidden: true },
|
||||
{ label: 'Completed', data: [], borderColor: '#66bb6a', backgroundColor: 'transparent', fill: false, hidden: true },
|
||||
{ label: 'Succeeded', data: [], borderColor: '#81c784', backgroundColor: 'transparent', fill: false, borderDash: [5, 5], hidden: true },
|
||||
{ label: 'Failed', data: [], borderColor: '#e57373', backgroundColor: 'transparent', fill: false, borderDash: [5, 5], hidden: true }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 0 },
|
||||
clip: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
hover: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#e0e0e0',
|
||||
padding: 15,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(30, 30, 30, 0.9)',
|
||||
titleColor: '#e0e0e0',
|
||||
bodyColor: '#e0e0e0',
|
||||
borderColor: '#616161',
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
displayColors: true
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: '#616161' },
|
||||
ticks: { color: '#9e9e9e', maxTicksLimit: 10 }
|
||||
},
|
||||
y: {
|
||||
grid: { color: '#616161' },
|
||||
ticks: { color: '#9e9e9e' },
|
||||
beginAtZero: true
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0,
|
||||
hoverRadius: 5,
|
||||
hitRadius: 10,
|
||||
hoverBackgroundColor: (ctx) => ctx.dataset.borderColor,
|
||||
hoverBorderColor: (ctx) => ctx.dataset.borderColor,
|
||||
hoverBorderWidth: 0
|
||||
},
|
||||
line: { tension: 0.3, borderWidth: 1.5, spanGaps: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.updateChart();
|
||||
}
|
||||
|
||||
updated(changedProperties) {
|
||||
if (changedProperties.has('data') && this.chart) {
|
||||
this.updateChart();
|
||||
}
|
||||
}
|
||||
|
||||
updateChart() {
|
||||
if (!this.chart || !this.data) return;
|
||||
|
||||
this.chart.data.labels = this.data.labels || [];
|
||||
this.chart.data.datasets[0].data = this.data.datasets?.active || [];
|
||||
this.chart.data.datasets[1].data = this.data.datasets?.pending || [];
|
||||
this.chart.data.datasets[2].data = this.data.datasets?.scheduled || [];
|
||||
this.chart.data.datasets[3].data = this.data.datasets?.retry || [];
|
||||
this.chart.data.datasets[4].data = this.data.datasets?.archived || [];
|
||||
this.chart.data.datasets[5].data = this.data.datasets?.completed || [];
|
||||
this.chart.data.datasets[6].data = this.data.datasets?.succeeded || [];
|
||||
this.chart.data.datasets[7].data = this.data.datasets?.failed || [];
|
||||
this.chart.update('none');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<canvas></canvas>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tasks-chart', TasksChart);
|
||||
165
ui/components/time-range-picker.js
Normal file
165
ui/components/time-range-picker.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
|
||||
class TimeRangePicker extends LitElement {
|
||||
static properties = {
|
||||
duration: { type: String },
|
||||
endTime: { type: Number },
|
||||
isLiveMode: { type: Boolean }
|
||||
};
|
||||
|
||||
static durations = ['5m', '15m', '30m', '1h', '3h', '6h', '12h', '1d', '3d', '7d'];
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.time-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #424242;
|
||||
border: 1px solid #616161;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9e9e9e;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #515151;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.value {
|
||||
padding: 6px 12px;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.85em;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.end-value {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.end-value:hover {
|
||||
background: #515151;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
color: #ef5350;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.duration = '1h';
|
||||
this.endTime = null;
|
||||
this.isLiveMode = true;
|
||||
}
|
||||
|
||||
get durationIndex() {
|
||||
return TimeRangePicker.durations.indexOf(this.duration);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
adjustDuration(delta) {
|
||||
const durations = TimeRangePicker.durations;
|
||||
const newIndex = Math.max(0, Math.min(durations.length - 1, this.durationIndex + delta));
|
||||
this.duration = durations[newIndex];
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
adjustEndTime(delta) {
|
||||
const durationSecs = this.parseDuration(this.duration);
|
||||
const step = durationSecs / 2;
|
||||
|
||||
let newEndTime = this.endTime;
|
||||
if (newEndTime === null) {
|
||||
newEndTime = Math.floor(Date.now() / 1000);
|
||||
}
|
||||
newEndTime += delta * step;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (newEndTime >= now) {
|
||||
this.resetToNow();
|
||||
return;
|
||||
}
|
||||
|
||||
this.endTime = newEndTime;
|
||||
this.isLiveMode = false;
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
resetToNow() {
|
||||
this.endTime = null;
|
||||
this.isLiveMode = true;
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
emitChange() {
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: {
|
||||
duration: this.duration,
|
||||
endTime: this.endTime,
|
||||
isLiveMode: this.isLiveMode
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
formatEndTime() {
|
||||
if (this.endTime === null) {
|
||||
return 'now';
|
||||
}
|
||||
const date = new Date(this.endTime * 1000);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).replace(/\//g, '-');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="time-control">
|
||||
<button @click=${() => this.adjustDuration(-1)}>−</button>
|
||||
<span class="value">${this.duration}</span>
|
||||
<button @click=${() => this.adjustDuration(1)}>+</button>
|
||||
</div>
|
||||
<div class="time-control">
|
||||
<button @click=${() => this.adjustEndTime(-1)}>‹</button>
|
||||
<span class="value end-value" @click=${this.resetToNow}>
|
||||
${this.formatEndTime()}
|
||||
</span>
|
||||
<button @click=${() => this.adjustEndTime(1)}>›</button>
|
||||
<button class="reset-btn" @click=${this.resetToNow} title="Reset to now">×</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('time-range-picker', TimeRangePicker);
|
||||
63
ui/index.html
Normal file
63
ui/index.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TaskQ Monitor</title>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"lit": "https://cdn.jsdelivr.net/npm/lit@3/+esm",
|
||||
"lit/": "https://cdn.jsdelivr.net/npm/lit@3/",
|
||||
"chart.js": "https://cdn.jsdelivr.net/npm/chart.js@4/+esm"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="{{.RootPath}}/static/app.js"></script>
|
||||
<link rel="stylesheet" href="{{.RootPath}}/static/styles.css">
|
||||
<style>
|
||||
.browser-warning {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #424242;
|
||||
color: #e0e0e0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.browser-warning h1 {
|
||||
color: #ef5350;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.browser-warning p {
|
||||
margin-bottom: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.browser-warning a {
|
||||
color: #42a5f5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="browser-warning" class="browser-warning">
|
||||
<h1>Browser Not Supported</h1>
|
||||
<p>TaskQ Monitor requires a modern browser with ES Module support.</p>
|
||||
<p>Please upgrade to one of the following browsers:</p>
|
||||
<p>
|
||||
<a href="https://www.google.com/chrome/" target="_blank">Chrome 61+</a> |
|
||||
<a href="https://www.mozilla.org/firefox/" target="_blank">Firefox 60+</a> |
|
||||
<a href="https://www.apple.com/safari/" target="_blank">Safari 11+</a> |
|
||||
<a href="https://www.microsoft.com/edge" target="_blank">Edge 79+</a>
|
||||
</p>
|
||||
</div>
|
||||
<taskq-app root-path="{{.RootPath}}"></taskq-app>
|
||||
<script nomodule>
|
||||
document.getElementById('browser-warning').style.display = 'block';
|
||||
document.querySelector('taskq-app').style.display = 'none';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
406
ui/styles.css
Normal file
406
ui/styles.css
Normal file
@@ -0,0 +1,406 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #424242;
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Chart Card */
|
||||
.chart-card {
|
||||
background: #515151;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
border: 1px solid #616161;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 1.1em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 280px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Time Range Picker */
|
||||
.time-range-picker {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.time-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #424242;
|
||||
border: 1px solid #616161;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.time-control button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9e9e9e;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.time-control button:hover {
|
||||
background: #515151;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.time-control .value {
|
||||
padding: 6px 12px;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.85em;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.time-control .end-value {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time-control .end-value:hover {
|
||||
background: #515151;
|
||||
}
|
||||
|
||||
.time-control .reset-btn:hover {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
/* Table Card */
|
||||
.table-card {
|
||||
background: #515151;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #616161;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.queues-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.queues-table th {
|
||||
background: #424242;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: #bdbdbd;
|
||||
font-size: 0.9em;
|
||||
border-bottom: 1px solid #616161;
|
||||
}
|
||||
|
||||
.queues-table td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #616161;
|
||||
}
|
||||
|
||||
.queues-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.queues-table tbody tr:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.queue-name {
|
||||
font-weight: 500;
|
||||
color: #4fc3f7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.queue-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.state-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
background: #66bb6a;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.state-badge.paused {
|
||||
background: #ffb74d;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.memory-bar {
|
||||
width: 120px;
|
||||
height: 6px;
|
||||
background: #424242;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memory-bar-fill {
|
||||
height: 100%;
|
||||
background: #42a5f5;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.latency-value {
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #757575;
|
||||
color: #bdbdbd;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #616161;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Loading & Empty State */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #616161;
|
||||
border-top-color: #42a5f5;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 15px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #424242;
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #424242;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background: #515151;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #616161;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.1em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
color: #9e9e9e;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #616161;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
height: calc(100vh - 60px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Task Tabs */
|
||||
.task-tabs {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-bottom: 20px;
|
||||
background: #424242;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.task-tab {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9e9e9e;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.task-tab:hover {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.task-tab.active {
|
||||
background: #42a5f5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Task List */
|
||||
.task-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
background: #5a5a5a;
|
||||
border: 1px solid #616161;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
border-color: #42a5f5;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.task-id {
|
||||
font-family: monospace;
|
||||
font-size: 0.8em;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
.task-type {
|
||||
font-weight: 500;
|
||||
color: #42a5f5;
|
||||
}
|
||||
|
||||
.task-payload {
|
||||
font-family: monospace;
|
||||
font-size: 0.75em;
|
||||
color: #9e9e9e;
|
||||
background: #424242;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
font-size: 0.8em;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
.task-error {
|
||||
color: #ef5350;
|
||||
background: rgba(239, 83, 80, 0.1);
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #616161;
|
||||
background: #424242;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.pagination button:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.pagination button.active {
|
||||
background: #42a5f5;
|
||||
border-color: #42a5f5;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Queue Detail Chart */
|
||||
.queue-chart-container {
|
||||
height: 200px;
|
||||
margin-bottom: 15px;
|
||||
background: #424242;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
Reference in New Issue
Block a user