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,
},
};
});