1
0
Files
DiAL/src/server/metrics.ts
lanyuanxiaoyao 1c5cfafda6 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 并归档变更
2026-05-14 12:32:41 +08:00

158 lines
4.9 KiB
TypeScript

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