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:
157
src/server/metrics.ts
Normal file
157
src/server/metrics.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { CurrentStreak, TrendPoint } from "../shared/api";
|
||||
|
||||
export interface IncidentAnalysis {
|
||||
incidentCount: number;
|
||||
longestOutage: null | number;
|
||||
mttr: null | number;
|
||||
}
|
||||
|
||||
export interface MetricCheckpoint {
|
||||
durationMs: null | number;
|
||||
matched: boolean;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export function analyzeIncidentSequence(checkpoints: MetricCheckpoint[], from: string, to: string): IncidentAnalysis {
|
||||
const sorted = sortCheckpoints(checkpoints);
|
||||
const fromTime = new Date(from).getTime();
|
||||
const toTime = new Date(to).getTime();
|
||||
const recoveredDurations: number[] = [];
|
||||
|
||||
let incidentCount = 0;
|
||||
let longestOutage: null | number = null;
|
||||
let outageStart: null | number = null;
|
||||
let outageStartedAtWindowBoundary = false;
|
||||
let previousMatched: boolean | null = null;
|
||||
|
||||
for (const checkpoint of sorted) {
|
||||
const timestamp = new Date(checkpoint.timestamp).getTime();
|
||||
|
||||
if (!checkpoint.matched) {
|
||||
if (previousMatched !== false) {
|
||||
incidentCount++;
|
||||
outageStart = previousMatched === null ? fromTime : timestamp;
|
||||
outageStartedAtWindowBoundary = previousMatched === null;
|
||||
}
|
||||
} else if (previousMatched === false && outageStart !== null) {
|
||||
const duration = Math.max(0, timestamp - outageStart);
|
||||
longestOutage = maxNullable(longestOutage, duration);
|
||||
if (!outageStartedAtWindowBoundary) {
|
||||
recoveredDurations.push(duration);
|
||||
}
|
||||
outageStart = null;
|
||||
outageStartedAtWindowBoundary = false;
|
||||
}
|
||||
|
||||
previousMatched = checkpoint.matched;
|
||||
}
|
||||
|
||||
if (previousMatched === false && outageStart !== null) {
|
||||
const duration = Math.max(0, toTime - outageStart);
|
||||
longestOutage = maxNullable(longestOutage, duration);
|
||||
}
|
||||
|
||||
return {
|
||||
incidentCount,
|
||||
longestOutage,
|
||||
mttr: calculateAverageDuration(recoveredDurations),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHourlyTrend(checkpoints: MetricCheckpoint[]): TrendPoint[] {
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{
|
||||
downChecks: number;
|
||||
durations: number[];
|
||||
totalChecks: number;
|
||||
upChecks: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const checkpoint of checkpoints) {
|
||||
const bucketStart = getUtcHourStart(checkpoint.timestamp);
|
||||
const bucket = buckets.get(bucketStart) ?? { downChecks: 0, durations: [], totalChecks: 0, upChecks: 0 };
|
||||
|
||||
bucket.totalChecks++;
|
||||
if (checkpoint.matched) {
|
||||
bucket.upChecks++;
|
||||
if (checkpoint.durationMs !== null) {
|
||||
bucket.durations.push(checkpoint.durationMs);
|
||||
}
|
||||
} else {
|
||||
bucket.downChecks++;
|
||||
}
|
||||
|
||||
buckets.set(bucketStart, bucket);
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
export function calculateAvailability(upChecks: number, totalChecks: number): number {
|
||||
if (totalChecks <= 0) return 0;
|
||||
return roundToTwo((upChecks / totalChecks) * 100);
|
||||
}
|
||||
|
||||
export function calculateAverageDuration(durations: number[]): null | number {
|
||||
if (durations.length === 0) return null;
|
||||
const total = durations.reduce((sum, duration) => sum + duration, 0);
|
||||
return roundToTwo(total / durations.length);
|
||||
}
|
||||
|
||||
export function calculateCurrentStreak(checkpoints: MetricCheckpoint[], limit?: number): CurrentStreak | null {
|
||||
const sorted = sortCheckpoints(checkpoints);
|
||||
const latest = sorted.at(-1);
|
||||
if (!latest) return null;
|
||||
|
||||
let count = 0;
|
||||
for (let index = sorted.length - 1; index >= 0; index--) {
|
||||
const checkpoint = sorted[index];
|
||||
if (checkpoint?.matched !== latest.matched) break;
|
||||
count++;
|
||||
}
|
||||
|
||||
return {
|
||||
...(limit !== undefined && count >= limit ? { capped: true } : {}),
|
||||
count,
|
||||
up: latest.matched,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculatePercentile(durations: number[], percentile: number): null | number {
|
||||
if (durations.length === 0) return null;
|
||||
|
||||
const sorted = [...durations].sort((a, b) => a - b);
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((sorted.length * percentile) / 100) - 1));
|
||||
return sorted[index] ?? null;
|
||||
}
|
||||
|
||||
function getUtcHourStart(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
date.setUTCMinutes(0, 0, 0);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function maxNullable(left: null | number, right: number): number {
|
||||
return left === null ? right : Math.max(left, right);
|
||||
}
|
||||
|
||||
function roundToTwo(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function sortCheckpoints(checkpoints: MetricCheckpoint[]): MetricCheckpoint[] {
|
||||
return [...checkpoints].sort((left, right) => left.timestamp.localeCompare(right.timestamp));
|
||||
}
|
||||
Reference in New Issue
Block a user