feat: 重构 Dashboard 为卡片式分组布局
表格布局替换为按分组展示的卡片式布局,新增 group 字段配置和 TargetBoard/TargetCard 等组件。模态框详情页支持时间范围筛选和分页,SummaryCards 减为 3 个。API 端点变更:trend/history 改用 from/to 参数,history 支持分页。recentSampleCount 硬编码为 30。
This commit is contained in:
@@ -100,12 +100,13 @@ function resolveTarget(
|
||||
): ResolvedTarget {
|
||||
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
|
||||
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
|
||||
const group = target.group ?? "default";
|
||||
|
||||
if (target.type === "http") {
|
||||
return resolveHttpTarget(target, defaults.http, intervalMs, timeoutMs);
|
||||
return resolveHttpTarget(target, defaults.http, intervalMs, timeoutMs, group);
|
||||
}
|
||||
|
||||
return resolveCommandTarget(target, defaults.command, intervalMs, timeoutMs, configDir);
|
||||
return resolveCommandTarget(target, defaults.command, intervalMs, timeoutMs, configDir, group);
|
||||
}
|
||||
|
||||
function resolveHttpTarget(
|
||||
@@ -113,12 +114,14 @@ function resolveHttpTarget(
|
||||
httpDefaults: HttpDefaultsConfig | undefined,
|
||||
intervalMs: number,
|
||||
timeoutMs: number,
|
||||
group: string,
|
||||
): ResolvedHttpTarget {
|
||||
const maxBodyBytes = parseSize(target.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
|
||||
|
||||
return {
|
||||
type: "http",
|
||||
name: target.name,
|
||||
group,
|
||||
http: {
|
||||
url: target.http.url,
|
||||
method: target.http.method ?? httpDefaults?.method ?? DEFAULT_HTTP_METHOD,
|
||||
@@ -138,6 +141,7 @@ function resolveCommandTarget(
|
||||
intervalMs: number,
|
||||
timeoutMs: number,
|
||||
configDir: string,
|
||||
group: string,
|
||||
): ResolvedCommandTarget {
|
||||
const cwd = target.command.cwd ?? commandDefaults?.cwd ?? ".";
|
||||
const resolvedCwd = resolve(configDir, cwd);
|
||||
@@ -151,6 +155,7 @@ function resolveCommandTarget(
|
||||
return {
|
||||
type: "command",
|
||||
name: target.name,
|
||||
group,
|
||||
command: {
|
||||
exec: target.command.exec,
|
||||
args: target.command.args ?? [],
|
||||
@@ -202,6 +207,11 @@ function validateConfig(config: ProbeConfig): void {
|
||||
}
|
||||
}
|
||||
|
||||
const group = raw["group"];
|
||||
if (group !== undefined && typeof group !== "string") {
|
||||
throw new Error(`target "${name}" 的 group 字段必须为字符串`);
|
||||
}
|
||||
|
||||
if (names.has(name as string)) {
|
||||
throw new Error(`target name 重复: "${name}"`);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -56,6 +56,7 @@ export type TargetConfig = BaseTargetConfig &
|
||||
|
||||
interface BaseTargetConfig {
|
||||
name: string;
|
||||
group?: string;
|
||||
interval?: string;
|
||||
timeout?: string;
|
||||
expect?: ExpectConfig;
|
||||
@@ -111,6 +112,7 @@ export type ExpectConfig = HttpExpectConfig | CommandExpectConfig;
|
||||
export interface ResolvedHttpTarget {
|
||||
type: "http";
|
||||
name: string;
|
||||
group: string;
|
||||
http: ResolvedHttpConfig;
|
||||
intervalMs: number;
|
||||
timeoutMs: number;
|
||||
@@ -128,6 +130,7 @@ export interface ResolvedHttpConfig {
|
||||
export interface ResolvedCommandTarget {
|
||||
type: "command";
|
||||
name: string;
|
||||
group: string;
|
||||
command: ResolvedCommandConfig;
|
||||
intervalMs: number;
|
||||
timeoutMs: number;
|
||||
@@ -172,6 +175,7 @@ export interface StoredTarget {
|
||||
interval_ms: number;
|
||||
timeout_ms: number;
|
||||
expect: string | null;
|
||||
grp: string;
|
||||
}
|
||||
|
||||
export interface StoredCheckResult {
|
||||
|
||||
Reference in New Issue
Block a user