1
0

feat: 重构 Dashboard 为卡片式分组布局

表格布局替换为按分组展示的卡片式布局,新增 group 字段配置和 TargetBoard/TargetCard 等组件。模态框详情页支持时间范围筛选和分页,SummaryCards 减为 3 个。API 端点变更:trend/history 改用 from/to 参数,history 支持分页。recentSampleCount 硬编码为 30。
This commit is contained in:
2026-05-11 08:54:21 +08:00
parent b8810f1182
commit 548b44d28e
44 changed files with 1676 additions and 557 deletions

View File

@@ -12,7 +12,8 @@ CREATE TABLE IF NOT EXISTS targets (
config TEXT NOT NULL DEFAULT '{}',
interval_ms INTEGER NOT NULL,
timeout_ms INTEGER NOT NULL,
expect TEXT
expect TEXT,
grp TEXT NOT NULL DEFAULT 'default'
)
`;
@@ -58,10 +59,10 @@ export class ProbeStore {
const configNames = new Set(targets.map((t) => t.name));
const insertStmt = this.db.prepare(
"INSERT INTO targets (name, type, target, config, interval_ms, timeout_ms, expect) VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO targets (name, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
);
const updateStmt = this.db.prepare(
"UPDATE targets SET type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ? WHERE id = ?",
"UPDATE targets SET type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?",
);
const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
@@ -73,9 +74,9 @@ export class ProbeStore {
const expect = t.expect ? JSON.stringify(t.expect) : null;
if (existingMap.has(t.name)) {
updateStmt.run(type, target, config, t.intervalMs, t.timeoutMs, expect, existingMap.get(t.name)!);
updateStmt.run(type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, existingMap.get(t.name)!);
} else {
insertStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect);
insertStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group);
}
}
@@ -91,7 +92,9 @@ export class ProbeStore {
getTargets(): StoredTarget[] {
if (this.closed) return [];
return this.db.query("SELECT * FROM targets ORDER BY id").all() as StoredTarget[];
return this.db
.query("SELECT * FROM targets ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, grp, id")
.all() as StoredTarget[];
}
getTargetById(id: number): StoredTarget | null {
@@ -130,39 +133,40 @@ export class ProbeStore {
.get(targetId) as StoredCheckResult | null;
}
getHistory(targetId: number, limit = 20): StoredCheckResult[] {
return this.db
.prepare("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?")
.all(targetId, limit) as StoredCheckResult[];
getHistory(
targetId: number,
from: string,
to: string,
page = 1,
pageSize = 20,
): { items: StoredCheckResult[]; total: number; page: number; pageSize: number } {
const countRow = this.db
.prepare("SELECT COUNT(*) as total FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?")
.get(targetId, from, to) as { total: number };
const offset = (page - 1) * pageSize;
const items = this.db
.prepare(
"SELECT * FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
)
.all(targetId, from, to, pageSize, offset) as StoredCheckResult[];
return { items, total: countRow.total, page, pageSize };
}
getTargetStats(targetId: number): {
totalChecks: number;
availability: number;
avgDurationMs: number | null;
p99DurationMs: number | null;
} {
const row = this.db
.prepare(
`SELECT
COUNT(*) as totalChecks,
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount,
AVG(CASE WHEN success = 1 THEN duration_ms END) as avgDurationMs
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
FROM check_results
WHERE target_id = ?`,
)
.get(targetId) as { totalChecks: number; upCount: number; avgDurationMs: number | null };
const p99Row = this.db
.prepare(
`SELECT duration_ms as p99DurationMs
FROM check_results
WHERE target_id = ? AND success = 1
ORDER BY duration_ms DESC
LIMIT 1
OFFSET (SELECT COUNT(*) FROM check_results WHERE target_id = ? AND success = 1) * 99 / 100`,
)
.get(targetId, targetId) as { p99DurationMs: number | null } | undefined;
.get(targetId) as { totalChecks: number; upCount: number };
const totalChecks = row.totalChecks;
const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0;
@@ -170,14 +174,13 @@ export class ProbeStore {
return {
totalChecks,
availability: Math.round(availability * 100) / 100,
avgDurationMs: row.avgDurationMs !== null ? Math.round(row.avgDurationMs * 100) / 100 : null,
p99DurationMs: p99Row?.p99DurationMs ?? null,
};
}
getTrend(
targetId: number,
hours = 24,
from: string,
to: string,
): Array<{
hour: string;
avgDurationMs: number | null;
@@ -192,11 +195,11 @@ export class ProbeStore {
CASE WHEN COUNT(*) > 0 THEN (SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)) ELSE 0 END as availability,
COUNT(*) as totalChecks
FROM check_results
WHERE target_id = ? AND timestamp >= datetime('now', '-' || ? || ' hours')
WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?
GROUP BY hour
ORDER BY hour`,
)
.all(targetId, hours) as Array<{
.all(targetId, from, to) as Array<{
hour: string;
avgDurationMs: number | null;
availability: number;
@@ -208,14 +211,11 @@ export class ProbeStore {
total: number;
up: number;
down: number;
avgDurationMs: number | null;
lastCheckTime: string | null;
} {
const targets = this.getTargets();
let up = 0;
let down = 0;
let totalDuration = 0;
let durationCount = 0;
let lastCheckTime: string | null = null;
for (const target of targets) {
@@ -228,11 +228,6 @@ export class ProbeStore {
down++;
}
if (latest.duration_ms !== null) {
totalDuration += latest.duration_ms;
durationCount++;
}
if (!lastCheckTime || latest.timestamp > lastCheckTime) {
lastCheckTime = latest.timestamp;
}
@@ -245,18 +240,24 @@ export class ProbeStore {
total: targets.length,
up,
down,
avgDurationMs: durationCount > 0 ? Math.round((totalDuration / durationCount) * 100) / 100 : null,
lastCheckTime,
};
}
getSparkline(targetId: number, limit = 20): number[] {
const rows = this.db
getRecentSamples(
targetId: number,
limit: number,
): Array<{ timestamp: string; duration_ms: number | null; success: number; matched: number }> {
return this.db
.prepare(
"SELECT duration_ms FROM check_results WHERE target_id = ? AND success = 1 ORDER BY timestamp DESC LIMIT ?",
"SELECT timestamp, duration_ms, success, matched FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?",
)
.all(targetId, limit) as Array<{ duration_ms: number }>;
return rows.map((r) => r.duration_ms).reverse();
.all(targetId, limit) as Array<{
timestamp: string;
duration_ms: number | null;
success: number;
matched: number;
}>;
}
close(): void {