1
0

feat: 动态粒度趋势图,支持 auto bucket 选择 + P95 延迟 + 状态条

This commit is contained in:
2026-05-23 23:53:18 +08:00
parent 6601ab458d
commit 4f33fba793
16 changed files with 315 additions and 106 deletions

View File

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

View File

@@ -1,4 +1,6 @@
import type { RuntimeMode } from "../shared/api";
import type { MetricsBucket, RuntimeMode } from "../shared/api";
const STANDARD_BUCKETS = new Set<string>(["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(

View File

@@ -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,
},

View File

@@ -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;
}

View File

@@ -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
<OverviewStatItem color="red" suffix="次" title="故障次数" value={stats.incidentCount} />
</Col>
<Col span={3}>
<OverviewStatItem color="green" suffix="次" title="连续正常" value={currentUpStreak} />
<OverviewStatItem color="green" suffix="次" title="窗口内连续正常" value={currentUpStreak} />
</Col>
</Row>
) : (
<div className="trend-empty"></div>
)}
<Divider align="left"></Divider>
<Divider align="left">{metricsData ? ` · ${formatBucketLabel(metricsData.window.bucket)}` : ""}</Divider>
{metricsLoading ? (
<Skeleton animation="gradient" />
) : metricsData ? (
@@ -104,3 +104,20 @@ function OverviewStatItem({ color, suffix, title, value }: OverviewStatItemProps
</div>
);
}
const BUCKET_LABELS: Record<MetricsBucket, string> = {
"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;
}

View File

@@ -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 (
<div className="trend-chart">
<ResponsiveContainer height={240} width="100%">
<ResponsiveContainer height={280} width="100%">
<LineChart data={chartData}>
<CartesianGrid stroke="var(--td-border-level-2-color)" strokeDasharray="3 3" />
<XAxis dataKey="label" stroke="var(--td-text-color-secondary)" tick={{ fontSize: 12 }} />
@@ -43,57 +41,98 @@ export const TrendChart = memo(function TrendChart({ data }: TrendChartProps) {
tick={{ fontSize: 12 }}
yAxisId="duration"
/>
<Tooltip
formatter={(value: unknown, name: unknown) => {
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];
}}
/>
<Area
dataKey="durationRange"
fill="var(--td-brand-color-light)"
fillOpacity={0.2}
name="durationRange"
stroke="var(--td-brand-color-light)"
type="monotone"
yAxisId="duration"
/>
<Tooltip content={<TrendTooltip />} />
<Line
connectNulls={false}
dataKey="avgDurationMs"
dot={renderIncidentDot}
dot={false}
name="avgDurationMs"
stroke="var(--td-brand-color)"
strokeWidth={2}
type="monotone"
yAxisId="duration"
/>
<Line
connectNulls={false}
dataKey="p95DurationMs"
dot={false}
name="p95DurationMs"
stroke="var(--td-warning-color)"
strokeDasharray="4 2"
strokeWidth={1}
type="monotone"
yAxisId="duration"
/>
</LineChart>
</ResponsiveContainer>
<div className="trend-status-bar">
{chartData.map((point) => (
<div
className={`trend-status-block trend-status-block--${point.statusLevel}`}
key={point.bucketStart}
title={`${point.label}: ${getStatusText(point)}`}
/>
))}
</div>
</div>
);
});
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 (
<circle
cx={Number(cx)}
cy={Number(cy)}
fill="var(--td-error-color)"
r={4}
stroke="var(--td-bg-color-container)"
strokeWidth={2}
/>
<div className="trend-tooltip">
<div className="trend-tooltip-time">{timeRange}</div>
{level === "empty" ? (
<div className="trend-tooltip-empty"></div>
) : (
<>
<div>{point.availability.toFixed(1)}%</div>
<div>
/{point.upChecks}/{point.totalChecks}
</div>
<div>{point.downChecks}</div>
{point.avgDurationMs !== null && <div>{Math.round(point.avgDurationMs)}ms</div>}
{point.p95DurationMs !== null && <div>P95{Math.round(point.p95DurationMs)}ms</div>}
</>
)}
</div>
);
}

View File

@@ -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: () => {

View File

@@ -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",

View File

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