- 新增 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 并归档变更
158 lines
4.9 KiB
TypeScript
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));
|
|
}
|