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