From 4f33fba793c896f782cbd13105cf7f0fa0c6ab29 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Sat, 23 May 2026 23:53:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8A=A8=E6=80=81=E7=B2=92=E5=BA=A6?= =?UTF-8?q?=E8=B6=8B=E5=8A=BF=E5=9B=BE=EF=BC=8C=E6=94=AF=E6=8C=81=20auto?= =?UTF-8?q?=20bucket=20=E9=80=89=E6=8B=A9=20+=20P95=20=E5=BB=B6=E8=BF=9F?= =?UTF-8?q?=20+=20=E7=8A=B6=E6=80=81=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT.md | 4 +- README.md | 2 +- openspec/config.yaml | 4 +- src/server/metrics.ts | 116 ++++++++++++++----- src/server/middleware.ts | 15 ++- src/server/routes/metrics.ts | 18 ++- src/shared/api.ts | 6 +- src/web/components/OverviewTab.tsx | 23 +++- src/web/components/TrendChart.tsx | 135 +++++++++++++++-------- src/web/hooks/use-queries.ts | 6 +- src/web/hooks/use-target-detail.ts | 2 +- src/web/styles.css | 51 +++++++++ tests/server/app.test.ts | 10 +- tests/server/metrics.test.ts | 19 +++- tests/server/middleware.test.ts | 6 +- tests/web/components/TrendChart.test.tsx | 4 + 16 files changed, 315 insertions(+), 106 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index dab7e67..47b4f1f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -551,7 +551,7 @@ if (r.body) { **轻数据库指标计算规范**: - 数据库只负责存储、筛选、排序、分页、LIMIT 和标准 SQL 基础聚合(如 `COUNT`、`SUM(CASE)`、`AVG`、`MIN`、`MAX`、`GROUP BY`),用于减少应用层输入数据量 -- 指标语义必须在后端应用层实现,包括可用率舍入、百分位、状态翻转、故障段识别、MTTR、最长故障、连续状态、趋势 UTC 小时分桶和窗口边界处理 +- 指标语义必须在后端应用层实现,包括可用率舍入、百分位、状态翻转、故障段识别、MTTR、最长故障、连续状态、趋势按标准 bucket 聚合(`resolveAutoBucket` 自动选择粒度,`buildTrend` 按 UTC 整点/整分钟对齐)和窗口边界处理 - 禁止用 SQLite 专有时间函数承载趋势分桶语义,禁止用复杂 SQL/window function 承载故障事件或恢复时长等业务规则 **Schema**: @@ -791,7 +791,7 @@ hooks/use-theme-preference.ts(浏览器 UI 偏好) const queryKeys = { dashboard: () => ["dashboard", "24h", 30] as const, meta: () => ["meta"] as const, - metrics: (targetId: number, from: string, to: string, bucket: "1h") => + metrics: (targetId: number, from: string, to: string, bucket: "auto" | MetricsBucket) => ["metrics", targetId, from, to, bucket] as const, history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const, }; diff --git a/README.md b/README.md index 8af55f9..eba2aea 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行** - 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/MySQL/SQLite)、TCP(端口可达性 + Banner 探测)、UDP(自定义 payload 请求-响应)、ICMP(存活检测、延迟、丢包率)、LLM(大模型服务应用层健康检查) - 丰富的校验规则:状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等 - 结构化观测数据:检查结果保留按需读取的 HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出预览、LLM token 用量等 observation,便于排障和后续分析 -- 响应式 Dashboard:实时状态、可用率统计、耗时趋势图、手动/自动刷新、版本号展示 +- 响应式 Dashboard:实时状态、可用率统计、动态粒度趋势图(avg/P95 + 状态条)、手动/自动刷新、版本号展示 - 多主题支持:系统、明亮、黑暗三种主题模式 - 零外部依赖:数据存储使用 SQLite,无需额外数据库服务 diff --git a/openspec/config.yaml b/openspec/config.yaml index 2b804b8..f2e8ff1 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -1,4 +1,4 @@ -schema: spec-driven +schema: fast-drive context: | - 使用中文(注释、文档、交流),面向中文开发者 @@ -20,8 +20,6 @@ context: | - (当前项目未上线,不需要考虑向前兼容) rules: - proposal: - - 仔细审查每一个过往spec判断是否存在Modified Capabilities design: - 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线 tasks: diff --git a/src/server/metrics.ts b/src/server/metrics.ts index 72a3c79..8808ec8 100644 --- a/src/server/metrics.ts +++ b/src/server/metrics.ts @@ -1,4 +1,19 @@ -import type { CurrentStreak, TrendPoint } from "../shared/api"; +import type { CurrentStreak, MetricsBucket, TrendPoint } from "../shared/api"; + +const BUCKET_STEPS: Array<{ label: MetricsBucket; ms: number }> = [ + { label: "30s", ms: 30_000 }, + { label: "1m", ms: 60_000 }, + { label: "5m", ms: 300_000 }, + { label: "15m", ms: 900_000 }, + { label: "30m", ms: 1_800_000 }, + { label: "1h", ms: 3_600_000 }, + { label: "3h", ms: 10_800_000 }, + { label: "6h", ms: 21_600_000 }, + { label: "12h", ms: 43_200_000 }, + { label: "1d", ms: 86_400_000 }, +]; + +const DESIRED_POINTS = 168; export interface IncidentAnalysis { incidentCount: number; @@ -58,9 +73,20 @@ export function analyzeIncidentSequence(checkpoints: MetricCheckpoint[], from: s }; } -export function buildHourlyTrend(checkpoints: MetricCheckpoint[]): TrendPoint[] { +export function buildTrend( + checkpoints: MetricCheckpoint[], + from: string, + to: string, + bucket: MetricsBucket, +): TrendPoint[] { + const bucketMs = getBucketDurationMs(bucket); + const fromTime = new Date(from).getTime(); + const toTime = new Date(to).getTime(); + + const firstBucketStart = alignToBucketBoundary(fromTime, bucketMs); + const buckets = new Map< - string, + number, { downChecks: number; durations: number[]; @@ -70,34 +96,60 @@ export function buildHourlyTrend(checkpoints: MetricCheckpoint[]): TrendPoint[] >(); for (const checkpoint of checkpoints) { - const bucketStart = getUtcHourStart(checkpoint.timestamp); - const bucket = buckets.get(bucketStart) ?? { downChecks: 0, durations: [], totalChecks: 0, upChecks: 0 }; + const ts = new Date(checkpoint.timestamp).getTime(); + const offset = ts - firstBucketStart; + const bucketIndex = Math.max(0, Math.floor(offset / bucketMs)); + const bucketStart = firstBucketStart + bucketIndex * bucketMs; - bucket.totalChecks++; + const b = buckets.get(bucketStart) ?? { downChecks: 0, durations: [], totalChecks: 0, upChecks: 0 }; + b.totalChecks++; if (checkpoint.matched) { - bucket.upChecks++; + b.upChecks++; if (checkpoint.durationMs !== null) { - bucket.durations.push(checkpoint.durationMs); + b.durations.push(checkpoint.durationMs); } } else { - bucket.downChecks++; + b.downChecks++; } - - buckets.set(bucketStart, bucket); + buckets.set(bucketStart, b); } - return [...buckets.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([bucketStart, bucket]) => ({ - availability: calculateAvailability(bucket.upChecks, bucket.totalChecks), - avgDurationMs: calculateAverageDuration(bucket.durations), - bucketStart, - downChecks: bucket.downChecks, - maxDurationMs: bucket.durations.length > 0 ? Math.max(...bucket.durations) : null, - minDurationMs: bucket.durations.length > 0 ? Math.min(...bucket.durations) : null, - totalChecks: bucket.totalChecks, - upChecks: bucket.upChecks, - })); + const result: TrendPoint[] = []; + for (let start = firstBucketStart; start < toTime; start += bucketMs) { + if (start + bucketMs <= fromTime) continue; + const effectiveStart = Math.max(start, fromTime); + const effectiveEnd = Math.min(start + bucketMs, toTime); + const b = buckets.get(start); + if (b) { + result.push({ + availability: calculateAvailability(b.upChecks, b.totalChecks), + avgDurationMs: calculateAverageDuration(b.durations), + bucketEnd: new Date(effectiveEnd).toISOString(), + bucketStart: new Date(effectiveStart).toISOString(), + downChecks: b.downChecks, + maxDurationMs: b.durations.length > 0 ? Math.max(...b.durations) : null, + minDurationMs: b.durations.length > 0 ? Math.min(...b.durations) : null, + p95DurationMs: calculatePercentile(b.durations, 95), + totalChecks: b.totalChecks, + upChecks: b.upChecks, + }); + } else { + result.push({ + availability: 0, + avgDurationMs: null, + bucketEnd: new Date(effectiveEnd).toISOString(), + bucketStart: new Date(effectiveStart).toISOString(), + downChecks: 0, + maxDurationMs: null, + minDurationMs: null, + p95DurationMs: null, + totalChecks: 0, + upChecks: 0, + }); + } + } + + return result; } export function calculateAvailability(upChecks: number, totalChecks: number): number { @@ -138,10 +190,20 @@ export function calculatePercentile(durations: number[], percentile: number): nu return sorted[index] ?? null; } -function getUtcHourStart(timestamp: string): string { - const date = new Date(timestamp); - date.setUTCMinutes(0, 0, 0); - return date.toISOString(); +export function getBucketDurationMs(bucket: MetricsBucket): number { + return BUCKET_STEPS.find((s) => s.label === bucket)?.ms ?? 3_600_000; +} + +export function resolveAutoBucket(intervalMs: number, windowMs: number): MetricsBucket { + const roughBucket = Math.max(intervalMs, windowMs / DESIRED_POINTS); + for (const step of BUCKET_STEPS) { + if (step.ms >= roughBucket) return step.label; + } + return "1d"; +} + +function alignToBucketBoundary(timestampMs: number, bucketMs: number): number { + return Math.floor(timestampMs / bucketMs) * bucketMs; } function maxNullable(left: null | number, right: number): number { diff --git a/src/server/middleware.ts b/src/server/middleware.ts index 995886e..d805f5d 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -1,4 +1,6 @@ -import type { RuntimeMode } from "../shared/api"; +import type { MetricsBucket, RuntimeMode } from "../shared/api"; + +const STANDARD_BUCKETS = new Set(["1d", "1h", "1m", "3h", "5m", "6h", "12h", "15m", "30m", "30s"]); import { createApiError, jsonResponse } from "./helpers"; @@ -19,13 +21,16 @@ export function validateDashboardWindow( return { from: from.toISOString(), label: window, to: to.toISOString() }; } -export function validateMetricsBucket(bucketParam: null | string, mode: RuntimeMode): Response | { bucket: "1h" } { - const bucket = bucketParam ?? "1h"; - if (bucket !== "1h") { +export function validateMetricsBucket( + bucketParam: null | string, + mode: RuntimeMode, +): Response | { bucket: "auto" | MetricsBucket } { + const bucket = bucketParam ?? "auto"; + if (bucket !== "auto" && !STANDARD_BUCKETS.has(bucket)) { return jsonResponse(createApiError("Unsupported bucket parameter", 400), { mode, status: 400 }); } - return { bucket }; + return { bucket: bucket as "auto" | MetricsBucket }; } export function validatePagination( diff --git a/src/server/routes/metrics.ts b/src/server/routes/metrics.ts index 91f0733..e41381b 100644 --- a/src/server/routes/metrics.ts +++ b/src/server/routes/metrics.ts @@ -1,14 +1,15 @@ -import type { RuntimeMode, TargetMetricsResponse } from "../../shared/api"; +import type { MetricsBucket, RuntimeMode, TargetMetricsResponse } from "../../shared/api"; import type { ProbeStore } from "../checker/store"; import { jsonResponse } from "../helpers"; import { analyzeIncidentSequence, - buildHourlyTrend, + buildTrend, calculateAverageDuration, calculateCurrentStreak, calculatePercentile, type MetricCheckpoint, + resolveAutoBucket, } from "../metrics"; import { validateMetricsBucket, validateTargetId, validateTimeRange } from "../middleware"; @@ -27,6 +28,15 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode: const bucketResult = validateMetricsBucket(url.searchParams.get("bucket"), mode); if (bucketResult instanceof Response) return bucketResult; + const requestedBucket = bucketResult.bucket; + let resolvedBucket: MetricsBucket; + if (requestedBucket === "auto") { + const windowMs = new Date(timeResult.to).getTime() - new Date(timeResult.from).getTime(); + resolvedBucket = resolveAutoBucket(target.interval_ms, windowMs); + } else { + resolvedBucket = requestedBucket; + } + const checkpoints = store .getTargetCheckpoints(idResult.id, timeResult.from, timeResult.to) .map((checkpoint): MetricCheckpoint => { @@ -55,9 +65,9 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode: upChecks: stats.upChecks, }, targetId: idResult.id, - trend: buildHourlyTrend(checkpoints), + trend: buildTrend(checkpoints, timeResult.from, timeResult.to, resolvedBucket), window: { - bucket: bucketResult.bucket, + bucket: resolvedBucket, from: timeResult.from, to: timeResult.to, }, diff --git a/src/shared/api.ts b/src/shared/api.ts index 4e262bc..dab98a3 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -61,6 +61,8 @@ export interface MetaResponse { version: string; } +export type MetricsBucket = "1d" | "1h" | "1m" | "3h" | "5m" | "6h" | "12h" | "15m" | "30m" | "30s"; + export interface RecentSample { durationMs: null | number; timestamp: string; @@ -86,7 +88,7 @@ export interface TargetMetricsResponse { targetId: string; trend: TrendPoint[]; window: { - bucket: "1h"; + bucket: MetricsBucket; from: string; to: string; }; @@ -116,10 +118,12 @@ export interface TargetStatus { export interface TrendPoint { availability: number; avgDurationMs: null | number; + bucketEnd: string; bucketStart: string; downChecks: number; maxDurationMs: null | number; minDurationMs: null | number; + p95DurationMs: null | number; totalChecks: number; upChecks: number; } diff --git a/src/web/components/OverviewTab.tsx b/src/web/components/OverviewTab.tsx index 6e1cffe..743491f 100644 --- a/src/web/components/OverviewTab.tsx +++ b/src/web/components/OverviewTab.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic } from "tdesign-react"; -import type { TargetMetricsResponse, TargetStatus } from "../../shared/api"; +import type { MetricsBucket, TargetMetricsResponse, TargetStatus } from "../../shared/api"; import { formatDurationUnit } from "../utils/time"; import { TrendChart } from "./TrendChart"; @@ -78,14 +78,14 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab - + ) : (
暂无指标数据
)} - 趋势 + 趋势{metricsData ? ` · ${formatBucketLabel(metricsData.window.bucket)}` : ""} {metricsLoading ? ( ) : metricsData ? ( @@ -104,3 +104,20 @@ function OverviewStatItem({ color, suffix, title, value }: OverviewStatItemProps ); } + +const BUCKET_LABELS: Record = { + "1d": "1天", + "1h": "1小时", + "1m": "1分钟", + "3h": "3小时", + "5m": "5分钟", + "6h": "6小时", + "12h": "12小时", + "15m": "15分钟", + "30m": "30分钟", + "30s": "30秒", +}; + +function formatBucketLabel(bucket: MetricsBucket): string { + return BUCKET_LABELS[bucket] ?? bucket; +} diff --git a/src/web/components/TrendChart.tsx b/src/web/components/TrendChart.tsx index 3333c89..30bd530 100644 --- a/src/web/components/TrendChart.tsx +++ b/src/web/components/TrendChart.tsx @@ -1,30 +1,28 @@ import { memo, useMemo } from "react"; -import { Area, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import type { TrendPoint } from "../../shared/api"; -interface IncidentDotProps { - cx?: number | string; - cy?: number | string; - payload?: TrendPoint; -} - interface TrendChartProps { data: TrendPoint[]; } export const TrendChart = memo(function TrendChart({ data }: TrendChartProps) { + const windowMs = useMemo(() => { + if (data.length < 2) return 0; + const first = new Date(data[0]!.bucketStart).getTime(); + const last = new Date(data[data.length - 1]!.bucketEnd).getTime(); + return last - first; + }, [data]); + const chartData = useMemo( () => data.map((point) => ({ ...point, - durationRange: - point.minDurationMs !== null && point.maxDurationMs !== null - ? [point.minDurationMs, point.maxDurationMs] - : null, - label: formatBucketLabel(point.bucketStart), + label: formatBucketLabel(point.bucketStart, windowMs), + statusLevel: getStatusLevel(point), })), - [data], + [data, windowMs], ); if (data.length === 0) { @@ -33,7 +31,7 @@ export const TrendChart = memo(function TrendChart({ data }: TrendChartProps) { return (
- + @@ -43,57 +41,98 @@ export const TrendChart = memo(function TrendChart({ data }: TrendChartProps) { tick={{ fontSize: 12 }} yAxisId="duration" /> - { - const nameStr = String(name); - if (nameStr === "durationRange" && Array.isArray(value)) { - return [`${Math.round(Number(value[0]))}ms - ${Math.round(Number(value[1]))}ms`, "延迟范围"]; - } - const num = Number(value); - if (nameStr === "avgDurationMs") return [`${Math.round(num)}ms`, "平均耗时"]; - return [String(value), nameStr]; - }} - /> - + } /> + +
+ {chartData.map((point) => ( +
+ ))} +
); }); -function formatBucketLabel(bucketStart: string): string { - return new Date(bucketStart).toLocaleTimeString("zh-CN", { hour: "2-digit", hour12: false, minute: "2-digit" }); +function formatBucketLabel(bucketStart: string, windowMs: number): string { + const date = new Date(bucketStart); + if (windowMs > 24 * 60 * 60 * 1000) { + return date.toLocaleString("zh-CN", { + day: "2-digit", + hour: "2-digit", + hour12: false, + minute: "2-digit", + month: "2-digit", + }); + } + return date.toLocaleTimeString("zh-CN", { hour: "2-digit", hour12: false, minute: "2-digit" }); } -function renderIncidentDot(props: IncidentDotProps) { - const { cx, cy, payload } = props; - if (!payload || payload.availability >= 100 || payload.avgDurationMs === null) return <>; +function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString("zh-CN", { hour: "2-digit", hour12: false, minute: "2-digit" }); +} + +function getStatusLevel(point: TrendPoint): "down" | "empty" | "ok" | "partial" { + if (point.totalChecks === 0) return "empty"; + if (point.availability >= 100) return "ok"; + if (point.availability > 0) return "partial"; + return "down"; +} + +function getStatusText(point: TrendPoint): string { + const level = getStatusLevel(point); + if (level === "empty") return "无检查数据"; + if (level === "ok") return "正常"; + if (level === "partial") return "部分异常"; + return "全异常"; +} + +function TrendTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: TrendPoint }> }) { + if (!active || !payload?.length) return null; + const point = payload[0]!.payload; + const level = getStatusLevel(point); + const timeRange = `${formatTime(point.bucketStart)} - ${formatTime(point.bucketEnd)}`; return ( - +
+
{timeRange}
+ {level === "empty" ? ( +
无检查数据
+ ) : ( + <> +
可用率:{point.availability.toFixed(1)}%
+
+ 成功/总数:{point.upChecks}/{point.totalChecks} +
+
失败数:{point.downChecks}
+ {point.avgDurationMs !== null &&
平均耗时:{Math.round(point.avgDurationMs)}ms
} + {point.p95DurationMs !== null &&
P95:{Math.round(point.p95DurationMs)}ms
} + + )} +
); } diff --git a/src/web/hooks/use-queries.ts b/src/web/hooks/use-queries.ts index c622f61..791df98 100644 --- a/src/web/hooks/use-queries.ts +++ b/src/web/hooks/use-queries.ts @@ -1,11 +1,11 @@ import { useQuery } from "@tanstack/react-query"; -import type { DashboardResponse, MetaResponse, TargetMetricsResponse } from "../../shared/api"; +import type { DashboardResponse, MetaResponse, MetricsBucket, TargetMetricsResponse } from "../../shared/api"; const queryKeys = { dashboard: () => ["dashboard", "24h", 30] as const, meta: () => ["meta"] as const, - metrics: (targetId: string, from: string, to: string, bucket: "1h") => + metrics: (targetId: string, from: string, to: string, bucket: "auto" | MetricsBucket) => ["metrics", targetId, from, to, bucket] as const, }; @@ -32,7 +32,7 @@ export function useMeta() { }); } -export function useTargetMetrics(targetId: null | string, from: string, to: string, bucket: "1h") { +export function useTargetMetrics(targetId: null | string, from: string, to: string, bucket: "auto" | MetricsBucket) { return useQuery({ enabled: targetId !== null && !!from && !!to, queryFn: () => { diff --git a/src/web/hooks/use-target-detail.ts b/src/web/hooks/use-target-detail.ts index a52f4f5..f9a3aff 100644 --- a/src/web/hooks/use-target-detail.ts +++ b/src/web/hooks/use-target-detail.ts @@ -23,7 +23,7 @@ export function useTargetDetail() { ? (dashboardData?.targets.find((target) => target.id === selectedTargetId) ?? null) : null; - const metrics = useTargetMetrics(selectedTargetId, timeFrom, timeTo, "1h"); + const metrics = useTargetMetrics(selectedTargetId, timeFrom, timeTo, "auto"); const history = useQuery({ enabled: selectedTargetId !== null && !!timeFrom && !!timeTo && activeTab === "history", diff --git a/src/web/styles.css b/src/web/styles.css index cf54165..26d4832 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -135,6 +135,57 @@ width: 100%; } +.trend-status-bar { + display: flex; + height: 6px; + gap: 1px; + margin-top: 2px; + overflow: hidden; + border-radius: 2px; +} + +.trend-status-block { + flex: 1; + min-width: 2px; + border-radius: 1px; +} + +.trend-status-block--ok { + background: var(--td-success-color); +} + +.trend-status-block--partial { + background: var(--td-warning-color); +} + +.trend-status-block--down { + background: var(--td-error-color); +} + +.trend-status-block--empty { + background: var(--td-bg-color-component-disabled); +} + +.trend-tooltip { + background: var(--td-bg-color-container); + border: 1px solid var(--td-border-level-2-color); + border-radius: var(--td-radius-default); + padding: 8px 12px; + font-size: 12px; + line-height: 1.6; + color: var(--td-text-color-primary); + box-shadow: var(--td-shadow-2); +} + +.trend-tooltip-time { + color: var(--td-text-color-secondary); + margin-bottom: 4px; +} + +.trend-tooltip-empty { + color: var(--td-text-color-placeholder); +} + .trend-loading, .trend-empty { padding: 24px; diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index aa3fd57..04df6e2 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -319,10 +319,12 @@ describe("API 路由", () => { expect(body.trend[0]).toMatchObject({ availability: 50, avgDurationMs: 150, + bucketEnd: "2025-01-01T01:00:00.000Z", bucketStart: "2025-01-01T00:00:00.000Z", downChecks: 2, maxDurationMs: 200, minDurationMs: 100, + p95DurationMs: 200, totalChecks: 4, upChecks: 2, }); @@ -350,7 +352,11 @@ describe("API 路由", () => { totalChecks: 0, upChecks: 0, }); - expect(body.trend).toEqual([]); + expect(body.trend.length).toBeGreaterThan(0); + body.trend.forEach((point: { availability: number; totalChecks: number }) => { + expect(point.totalChecks).toBe(0); + expect(point.availability).toBe(0); + }); }); test("查询不存在的目标返回 404", async () => { @@ -394,7 +400,7 @@ describe("API 路由", () => { test("metrics 无效 bucket 和不存在目标返回错误", async () => { const targets = store.getTargets(); const invalidBucket = await fetch( - `${baseUrl}/api/targets/${targets[0]!.id}/metrics?from=2025-01-01T00:00:00.000Z&to=2025-01-01T01:00:00.000Z&bucket=5m`, + `${baseUrl}/api/targets/${targets[0]!.id}/metrics?from=2025-01-01T00:00:00.000Z&to=2025-01-01T01:00:00.000Z&bucket=invalid`, ); const missingTarget = await fetch( `${baseUrl}/api/targets/99999/metrics?from=2025-01-01T00:00:00.000Z&to=2025-01-01T01:00:00.000Z`, diff --git a/tests/server/metrics.test.ts b/tests/server/metrics.test.ts index b983909..ecd85a9 100644 --- a/tests/server/metrics.test.ts +++ b/tests/server/metrics.test.ts @@ -2,11 +2,12 @@ import { describe, expect, test } from "bun:test"; import { analyzeIncidentSequence, - buildHourlyTrend, + buildTrend, calculateAvailability, calculateCurrentStreak, calculatePercentile, type MetricCheckpoint, + resolveAutoBucket, } from "../../src/server/metrics"; describe("后端指标计算", () => { @@ -90,35 +91,47 @@ describe("后端指标计算", () => { }); test("UTC 小时趋势分桶返回 up/down 和延迟范围", () => { - const trend = buildHourlyTrend([ + const checkpoints = [ checkpoint("2025-01-01T00:10:00.000Z", true, 100), checkpoint("2025-01-01T00:40:00.000Z", false, null), checkpoint("2025-01-01T01:05:00.000Z", true, 300), - ]); + ]; + + const trend = buildTrend(checkpoints, "2025-01-01T00:00:00.000Z", "2025-01-01T01:59:59.999Z", "1h"); expect(trend).toEqual([ { availability: 50, avgDurationMs: 100, + bucketEnd: "2025-01-01T01:00:00.000Z", bucketStart: "2025-01-01T00:00:00.000Z", downChecks: 1, maxDurationMs: 100, minDurationMs: 100, + p95DurationMs: 100, totalChecks: 2, upChecks: 1, }, { availability: 100, avgDurationMs: 300, + bucketEnd: "2025-01-01T01:59:59.999Z", bucketStart: "2025-01-01T01:00:00.000Z", downChecks: 0, maxDurationMs: 300, minDurationMs: 300, + p95DurationMs: 300, totalChecks: 1, upChecks: 1, }, ]); }); + + test("resolveAutoBucket 按窗口大小选择合适桶", () => { + expect(resolveAutoBucket(30_000, 7 * 24 * 60 * 60 * 1000)).toBe("1h"); + expect(resolveAutoBucket(30_000, 60 * 60 * 1000)).toBe("30s"); + expect(resolveAutoBucket(30_000, 24 * 60 * 60 * 1000)).toBe("15m"); + }); }); function checkpoint(timestamp: string, matched: boolean, durationMs: null | number = null): MetricCheckpoint { diff --git a/tests/server/middleware.test.ts b/tests/server/middleware.test.ts index f95a2b0..e3968aa 100644 --- a/tests/server/middleware.test.ts +++ b/tests/server/middleware.test.ts @@ -154,9 +154,9 @@ describe("validateDashboardWindow", () => { }); describe("validateMetricsBucket", () => { - test("默认值:bucket=1h", () => { + test("默认值:bucket=auto", () => { const result = validateMetricsBucket(null, "production"); - expect(result).toEqual({ bucket: "1h" }); + expect(result).toEqual({ bucket: "auto" }); }); test("bucket=1h 返回成功", () => { @@ -165,7 +165,7 @@ describe("validateMetricsBucket", () => { }); test("不支持的 bucket 参数返回 400", () => { - const result = validateMetricsBucket("5m", "production"); + const result = validateMetricsBucket("invalid", "production"); expect(result).toHaveProperty("status", 400); }); }); diff --git a/tests/web/components/TrendChart.test.tsx b/tests/web/components/TrendChart.test.tsx index 2204c3f..39ae15c 100644 --- a/tests/web/components/TrendChart.test.tsx +++ b/tests/web/components/TrendChart.test.tsx @@ -11,20 +11,24 @@ describe("TrendChart", () => { { availability: 100, avgDurationMs: 100, + bucketEnd: "2025-01-15T11:00:00.000Z", bucketStart: "2025-01-15T10:00:00.000Z", downChecks: 0, maxDurationMs: 150, minDurationMs: 50, + p95DurationMs: 120, totalChecks: 10, upChecks: 10, }, { availability: 95, avgDurationMs: 120, + bucketEnd: "2025-01-15T12:00:00.000Z", bucketStart: "2025-01-15T11:00:00.000Z", downChecks: 1, maxDurationMs: 200, minDurationMs: 80, + p95DurationMs: 180, totalChecks: 20, upChecks: 19, },