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

@@ -3,6 +3,7 @@ import type {
CheckFailure,
CheckResult,
HealthResponse,
HistoryResponse,
RuntimeMode,
SummaryResponse,
TargetStatus,
@@ -97,20 +98,47 @@ function handleHistory(idStr: string, url: URL, method: string, store: ProbeStor
return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 });
}
const limitParam = url.searchParams.get("limit");
let limit = 20;
const from = url.searchParams.get("from");
const to = url.searchParams.get("to");
if (limitParam !== null) {
limit = Number(limitParam);
if (!Number.isInteger(limit) || limit <= 0) {
return jsonResponse(createApiError("Invalid limit parameter", 400), { method, mode, status: 400 });
if (!from || !to) {
return jsonResponse(createApiError("from and to parameters are required", 400), { method, mode, status: 400 });
}
const fromDate = new Date(from);
const toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { method, mode, status: 400 });
}
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");
let page = 1;
let pageSize = 20;
if (pageParam !== null) {
page = Number(pageParam);
if (!Number.isInteger(page) || page <= 0) {
return jsonResponse(createApiError("Invalid page parameter", 400), { method, mode, status: 400 });
}
}
const rows = store.getHistory(id, limit);
const results: CheckResult[] = rows.map(mapCheckResult);
if (pageSizeParam !== null) {
pageSize = Number(pageSizeParam);
if (!Number.isInteger(pageSize) || pageSize <= 0) {
return jsonResponse(createApiError("Invalid pageSize parameter", 400), { method, mode, status: 400 });
}
}
return jsonResponse(results, { method, mode });
const result = store.getHistory(id, from, to, page, pageSize);
const response: HistoryResponse = {
items: result.items.map(mapCheckResult),
total: result.total,
page: result.page,
pageSize: result.pageSize,
};
return jsonResponse(response, { method, mode });
}
function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
@@ -125,17 +153,20 @@ function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore,
return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 });
}
const hoursParam = url.searchParams.get("hours");
let hours = 24;
const from = url.searchParams.get("from");
const to = url.searchParams.get("to");
if (hoursParam !== null) {
hours = Number(hoursParam);
if (!Number.isInteger(hours) || hours <= 0) {
return jsonResponse(createApiError("Invalid hours parameter", 400), { method, mode, status: 400 });
}
if (!from || !to) {
return jsonResponse(createApiError("from and to parameters are required", 400), { method, mode, status: 400 });
}
const trend: TrendPoint[] = store.getTrend(id, hours).map((row) => ({
const fromDate = new Date(from);
const toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { method, mode, status: 400 });
}
const trend: TrendPoint[] = store.getTrend(id, from, to).map((row) => ({
hour: row.hour,
avgDurationMs: row.avgDurationMs,
availability: Math.round(row.availability * 100) / 100,
@@ -151,7 +182,6 @@ function createSummaryResponse(store: ProbeStore): SummaryResponse {
total: summary.total,
up: summary.up,
down: summary.down,
avgDurationMs: summary.avgDurationMs,
lastCheckTime: summary.lastCheckTime,
};
}
@@ -162,20 +192,24 @@ function createTargetsResponse(store: ProbeStore): TargetStatus[] {
return targets.map((target) => {
const latest = store.getLatestCheck(target.id);
const stats = store.getTargetStats(target.id);
const recentSamples = store.getRecentSamples(target.id, 30);
return {
id: target.id,
name: target.name,
type: target.type,
target: target.target,
group: target.grp,
interval: formatDuration(target.interval_ms),
latestCheck: latest ? mapCheckResult(latest) : null,
sparkline: store.getSparkline(target.id),
recentSamples: recentSamples.map((s) => ({
timestamp: s.timestamp,
durationMs: s.duration_ms,
up: s.success === 1 && s.matched === 1,
})),
stats: {
totalChecks: stats.totalChecks,
availability: stats.availability,
avgDurationMs: stats.avgDurationMs,
p99DurationMs: stats.p99DurationMs,
},
};
});

View File

@@ -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}"`);
}

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 {

View File

@@ -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 {