feat: 前端指标体系增强 — Dashboard/Metrics API、2×4 统计区、趋势图面积+异常标记、连续状态列
- 新增 GET /api/dashboard 合并原 summary+targets 首屏接口 - 新增 GET /api/targets/:id/metrics 合并原 stats+trend 概览接口 - 后端指标纯函数:可用率、百分位、故障段分析、连续状态、UTC 小时分桶 - ProbeStore 窗口取数方法替代全量历史查询 - SummaryCards 扩展为 4 卡片(新增异常事件数)+ 数据新鲜度展示 - 表格新增「连续」列(Tag 渲染 capped 状态) - OverviewTab 重构为 2×4 Statistic 多维度布局 - TrendChart 改为延迟范围面积图 + 红色异常标记点 - 删除旧路由(summary/targets/trend)和 computeTrendStats - 同步 delta specs 到主 specs 并归档变更
This commit is contained in:
@@ -19,13 +19,14 @@ function getColumn(columns: Array<PrimaryTableCol<TargetStatus>>, colKey: string
|
||||
|
||||
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
||||
return {
|
||||
currentStreak: null,
|
||||
group: "default",
|
||||
id: 1,
|
||||
interval: "5s",
|
||||
latestCheck: null,
|
||||
name: "test",
|
||||
recentSamples: [],
|
||||
stats: { availability: 100, totalChecks: 0 },
|
||||
stats: { availability: 100, downChecks: 0, totalChecks: 0, upChecks: 0 },
|
||||
target: "https://example.com",
|
||||
type: "http",
|
||||
...overrides,
|
||||
@@ -33,7 +34,7 @@ function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
||||
}
|
||||
|
||||
describe("createTargetTableColumns", () => {
|
||||
test("生成 7 个目标表格列", () => {
|
||||
test("生成 8 个目标表格列", () => {
|
||||
const columns = createTargetTableColumns(["http", "cmd"]);
|
||||
|
||||
expect(columns.map((column) => column.colKey)).toEqual([
|
||||
@@ -42,6 +43,7 @@ describe("createTargetTableColumns", () => {
|
||||
"type",
|
||||
"stats.availability",
|
||||
"recentSamples",
|
||||
"currentStreak",
|
||||
"latestCheck.durationMs",
|
||||
"interval",
|
||||
]);
|
||||
@@ -81,4 +83,19 @@ describe("createTargetTableColumns", () => {
|
||||
|
||||
expect(element.props.children).toBe("tcp");
|
||||
});
|
||||
|
||||
test("连续状态列渲染 capped 标记", () => {
|
||||
const streakColumn = getColumn(createTargetTableColumns(["http"]), "currentStreak");
|
||||
const renderCell = streakColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => {
|
||||
props: { children: unknown[] };
|
||||
};
|
||||
const element = renderCell({
|
||||
col: streakColumn,
|
||||
colIndex: 5,
|
||||
row: makeTarget({ currentStreak: { capped: true, count: 30, up: false } }),
|
||||
rowIndex: 0,
|
||||
});
|
||||
|
||||
expect(element.props.children.join("")).toBe("▼ 30+");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,13 +11,14 @@ import {
|
||||
|
||||
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
||||
return {
|
||||
currentStreak: null,
|
||||
group: "default",
|
||||
id: 1,
|
||||
interval: "5s",
|
||||
latestCheck: null,
|
||||
name: "test",
|
||||
recentSamples: [],
|
||||
stats: { availability: 100, totalChecks: 0 },
|
||||
stats: { availability: 100, downChecks: 0, totalChecks: 0, upChecks: 0 },
|
||||
target: "https://example.com",
|
||||
type: "http",
|
||||
...overrides,
|
||||
@@ -57,20 +58,20 @@ describe("statusSorter", () => {
|
||||
|
||||
describe("availabilitySorter", () => {
|
||||
test("低可用率排前面", () => {
|
||||
const low = makeTarget({ stats: { availability: 95, totalChecks: 100 } });
|
||||
const high = makeTarget({ stats: { availability: 99.9, totalChecks: 100 } });
|
||||
const low = makeTarget({ stats: { availability: 95, downChecks: 5, totalChecks: 100, upChecks: 95 } });
|
||||
const high = makeTarget({ stats: { availability: 99.9, downChecks: 1, totalChecks: 100, upChecks: 99 } });
|
||||
expect(availabilitySorter(low, high)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test("相同可用率返回 0", () => {
|
||||
const a = makeTarget({ stats: { availability: 99.9, totalChecks: 100 } });
|
||||
const b = makeTarget({ stats: { availability: 99.9, totalChecks: 50 } });
|
||||
const a = makeTarget({ stats: { availability: 99.9, downChecks: 1, totalChecks: 100, upChecks: 99 } });
|
||||
const b = makeTarget({ stats: { availability: 99.9, downChecks: 1, totalChecks: 50, upChecks: 49 } });
|
||||
expect(availabilitySorter(a, b)).toBe(0);
|
||||
});
|
||||
|
||||
test("无 stats 按 0 处理", () => {
|
||||
const noStats = makeTarget({ stats: undefined as unknown as TargetStatus["stats"] });
|
||||
const high = makeTarget({ stats: { availability: 99.9, totalChecks: 100 } });
|
||||
const high = makeTarget({ stats: { availability: 99.9, downChecks: 1, totalChecks: 100, upChecks: 99 } });
|
||||
expect(availabilitySorter(noStats, high)).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { TrendPoint } from "../../../src/shared/api";
|
||||
|
||||
import { computeTrendStats } from "../../../src/web/utils/stats";
|
||||
|
||||
describe("computeTrendStats", () => {
|
||||
test("空趋势返回 0 统计", () => {
|
||||
expect(computeTrendStats([])).toEqual({ downChecks: 0, totalChecks: 0, upChecks: 0 });
|
||||
});
|
||||
|
||||
test("汇总总检查、正常和异常数量", () => {
|
||||
const points: TrendPoint[] = [
|
||||
{ availability: 80, avgDurationMs: 100, hour: "2025-01-01T00:00:00.000Z", totalChecks: 10 },
|
||||
{ availability: 40, avgDurationMs: 200, hour: "2025-01-01T01:00:00.000Z", totalChecks: 5 },
|
||||
];
|
||||
|
||||
expect(computeTrendStats(points)).toEqual({ downChecks: 5, totalChecks: 15, upChecks: 10 });
|
||||
});
|
||||
|
||||
test("按每个趋势点四舍五入正常数量", () => {
|
||||
const points: TrendPoint[] = [
|
||||
{ availability: 33.3, avgDurationMs: null, hour: "2025-01-01T00:00:00.000Z", totalChecks: 3 },
|
||||
{ availability: 66.7, avgDurationMs: null, hour: "2025-01-01T01:00:00.000Z", totalChecks: 3 },
|
||||
];
|
||||
|
||||
expect(computeTrendStats(points)).toEqual({ downChecks: 3, totalChecks: 6, upChecks: 3 });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { subtractHours } from "../../../src/web/utils/time";
|
||||
import { formatDurationUnit, formatRelativeTime, isOlderThan, subtractHours } from "../../../src/web/utils/time";
|
||||
|
||||
describe("subtractHours", () => {
|
||||
test("正常扣减小时", () => {
|
||||
@@ -27,3 +27,38 @@ describe("subtractHours", () => {
|
||||
expect(result.toISOString()).toBe("2025-01-15T12:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeTime", () => {
|
||||
const now = new Date("2025-01-01T00:02:00.000Z");
|
||||
|
||||
test("格式化秒和分钟", () => {
|
||||
expect(formatRelativeTime("2025-01-01T00:01:45.000Z", now)).toBe("15秒前");
|
||||
expect(formatRelativeTime("2025-01-01T00:00:00.000Z", now)).toBe("2分钟前");
|
||||
});
|
||||
|
||||
test("无时间返回占位", () => {
|
||||
expect(formatRelativeTime(null, now)).toBe("尚无检查数据");
|
||||
expect(formatRelativeTime("invalid", now)).toBe("尚无检查数据");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDurationUnit", () => {
|
||||
test("按秒、分钟、小时动态格式化", () => {
|
||||
expect(formatDurationUnit(1500)).toEqual({ suffix: "秒", value: 1.5 });
|
||||
expect(formatDurationUnit(120000)).toEqual({ suffix: "分钟", value: 2 });
|
||||
expect(formatDurationUnit(5400000)).toEqual({ suffix: "小时", value: 1.5 });
|
||||
});
|
||||
|
||||
test("空时长返回占位", () => {
|
||||
expect(formatDurationUnit(null)).toEqual({ suffix: "", value: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOlderThan", () => {
|
||||
test("判断时间是否超过阈值", () => {
|
||||
const now = new Date("2025-01-01T00:02:00.000Z");
|
||||
|
||||
expect(isOlderThan("2025-01-01T00:00:59.000Z", 60000, now)).toBe(true);
|
||||
expect(isOlderThan("2025-01-01T00:01:30.000Z", 60000, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user