feat: 动态粒度趋势图,支持 auto bucket 选择 + P95 延迟 + 状态条
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user