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:
@@ -60,6 +60,8 @@ export class ProbeStore {
|
||||
getAllRecentSamples(
|
||||
limit: number,
|
||||
): Map<number, Array<{ duration_ms: null | number; matched: number; timestamp: string }>> {
|
||||
if (this.closed) return new Map();
|
||||
|
||||
const rows = this.db
|
||||
.query(
|
||||
`SELECT target_id, timestamp, duration_ms, matched
|
||||
@@ -91,24 +93,55 @@ export class ProbeStore {
|
||||
return result;
|
||||
}
|
||||
|
||||
getAllTargetStats(): Map<number, { availability: number; totalChecks: number }> {
|
||||
getAllTargetWindowStats(
|
||||
from: string,
|
||||
to: string,
|
||||
): Map<number, { availability: number; downChecks: number; totalChecks: number; upChecks: number }> {
|
||||
if (this.closed) return new Map();
|
||||
|
||||
const rows = this.db
|
||||
.query(
|
||||
`SELECT target_id, COUNT(*) as totalChecks,
|
||||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
|
||||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upChecks,
|
||||
COALESCE(SUM(CASE WHEN matched = 0 THEN 1 ELSE 0 END), 0) as downChecks
|
||||
FROM check_results
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
GROUP BY target_id`,
|
||||
)
|
||||
.all() as Array<{ target_id: number; totalChecks: number; upCount: number }>;
|
||||
.all(from, to) as Array<{ downChecks: number; target_id: number; totalChecks: number; upChecks: number }>;
|
||||
|
||||
const result = new Map<number, { availability: number; totalChecks: number }>();
|
||||
const result = new Map<
|
||||
number,
|
||||
{ availability: number; downChecks: number; totalChecks: number; upChecks: number }
|
||||
>();
|
||||
for (const row of rows) {
|
||||
const availability = row.totalChecks > 0 ? Math.round((row.upCount / row.totalChecks) * 100 * 100) / 100 : 0;
|
||||
result.set(row.target_id, { availability, totalChecks: row.totalChecks });
|
||||
const availability = row.totalChecks > 0 ? Math.round((row.upChecks / row.totalChecks) * 100 * 100) / 100 : 0;
|
||||
result.set(row.target_id, {
|
||||
availability,
|
||||
downChecks: row.downChecks,
|
||||
totalChecks: row.totalChecks,
|
||||
upChecks: row.upChecks,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getDashboardIncidentStates(
|
||||
from: string,
|
||||
to: string,
|
||||
): Array<{ matched: number; target_id: number; timestamp: string }> {
|
||||
if (this.closed) return [];
|
||||
|
||||
return this.db
|
||||
.query(
|
||||
`SELECT target_id, timestamp, matched
|
||||
FROM check_results
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
ORDER BY target_id ASC, timestamp ASC`,
|
||||
)
|
||||
.all(from, to) as Array<{ matched: number; target_id: number; timestamp: string }>;
|
||||
}
|
||||
|
||||
getHistory(
|
||||
targetId: number,
|
||||
from: string,
|
||||
@@ -165,49 +198,43 @@ export class ProbeStore {
|
||||
}>;
|
||||
}
|
||||
|
||||
getSummary(): {
|
||||
down: number;
|
||||
lastCheckTime: null | string;
|
||||
total: number;
|
||||
up: number;
|
||||
} {
|
||||
const targets = this.getTargets();
|
||||
const latestChecksMap = this.getLatestChecksMap();
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
let lastCheckTime: null | string = null;
|
||||
|
||||
for (const target of targets) {
|
||||
const latest = latestChecksMap.get(target.id);
|
||||
|
||||
if (latest) {
|
||||
if (latest.matched) {
|
||||
up++;
|
||||
} else {
|
||||
down++;
|
||||
}
|
||||
|
||||
if (!lastCheckTime || latest.timestamp > lastCheckTime) {
|
||||
lastCheckTime = latest.timestamp;
|
||||
}
|
||||
} else {
|
||||
down++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
down,
|
||||
lastCheckTime,
|
||||
total: targets.length,
|
||||
up,
|
||||
};
|
||||
}
|
||||
|
||||
getTargetById(id: number): null | StoredTarget {
|
||||
if (this.closed) return null;
|
||||
return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as null | StoredTarget;
|
||||
}
|
||||
|
||||
getTargetCheckpoints(
|
||||
targetId: number,
|
||||
from: string,
|
||||
to: string,
|
||||
): Array<{ duration_ms: null | number; matched: number; timestamp: string }> {
|
||||
if (this.closed) return [];
|
||||
|
||||
return this.db
|
||||
.query(
|
||||
`SELECT timestamp, matched, duration_ms
|
||||
FROM check_results
|
||||
WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?
|
||||
ORDER BY timestamp ASC`,
|
||||
)
|
||||
.all(targetId, from, to) as Array<{ duration_ms: null | number; matched: number; timestamp: string }>;
|
||||
}
|
||||
|
||||
getTargetDurations(targetId: number, from: string, to: string): number[] {
|
||||
if (this.closed) return [];
|
||||
|
||||
const rows = this.db
|
||||
.query(
|
||||
`SELECT duration_ms
|
||||
FROM check_results
|
||||
WHERE target_id = ? AND timestamp >= ? AND timestamp <= ? AND matched = 1 AND duration_ms IS NOT NULL
|
||||
ORDER BY duration_ms ASC`,
|
||||
)
|
||||
.all(targetId, from, to) as Array<{ duration_ms: number }>;
|
||||
|
||||
return rows.map((row) => row.duration_ms);
|
||||
}
|
||||
|
||||
getTargets(): StoredTarget[] {
|
||||
if (this.closed) return [];
|
||||
return this.db
|
||||
@@ -215,59 +242,40 @@ export class ProbeStore {
|
||||
.all() as StoredTarget[];
|
||||
}
|
||||
|
||||
getTargetStats(targetId: number): {
|
||||
getTargetWindowStats(
|
||||
targetId: number,
|
||||
from: string,
|
||||
to: string,
|
||||
): {
|
||||
availability: number;
|
||||
downChecks: number;
|
||||
totalChecks: number;
|
||||
upChecks: number;
|
||||
} {
|
||||
if (this.closed) return { availability: 0, downChecks: 0, totalChecks: 0, upChecks: 0 };
|
||||
|
||||
const row = this.db
|
||||
.query(
|
||||
`SELECT
|
||||
COUNT(*) as totalChecks,
|
||||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
|
||||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upChecks,
|
||||
COALESCE(SUM(CASE WHEN matched = 0 THEN 1 ELSE 0 END), 0) as downChecks
|
||||
FROM check_results
|
||||
WHERE target_id = ?`,
|
||||
WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?`,
|
||||
)
|
||||
.get(targetId) as { totalChecks: number; upCount: number };
|
||||
.get(targetId, from, to) as { downChecks: number; totalChecks: number; upChecks: number };
|
||||
|
||||
const totalChecks = row.totalChecks;
|
||||
const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0;
|
||||
const availability = totalChecks > 0 ? (row.upChecks / totalChecks) * 100 : 0;
|
||||
|
||||
return {
|
||||
availability: Math.round(availability * 100) / 100,
|
||||
downChecks: row.downChecks,
|
||||
totalChecks,
|
||||
upChecks: row.upChecks,
|
||||
};
|
||||
}
|
||||
|
||||
getTrend(
|
||||
targetId: number,
|
||||
from: string,
|
||||
to: string,
|
||||
): Array<{
|
||||
availability: number;
|
||||
avgDurationMs: null | number;
|
||||
hour: string;
|
||||
totalChecks: number;
|
||||
}> {
|
||||
return this.db
|
||||
.query(
|
||||
`SELECT
|
||||
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour,
|
||||
AVG(CASE WHEN matched = 1 THEN duration_ms END) as avgDurationMs,
|
||||
CASE WHEN COUNT(*) > 0 THEN (SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)) ELSE 0 END as availability,
|
||||
COUNT(*) as totalChecks
|
||||
FROM check_results
|
||||
WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?
|
||||
GROUP BY hour
|
||||
ORDER BY hour`,
|
||||
)
|
||||
.all(targetId, from, to) as Array<{
|
||||
availability: number;
|
||||
avgDurationMs: null | number;
|
||||
hour: string;
|
||||
totalChecks: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
insertCheckResult(result: {
|
||||
durationMs: null | number;
|
||||
failure: CheckFailure | null;
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -3,6 +3,30 @@ import type { RuntimeMode } from "../shared/api";
|
||||
import { createApiError, jsonResponse } from "./helpers";
|
||||
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
const MAX_RECENT_LIMIT = 200;
|
||||
|
||||
export function validateDashboardWindow(
|
||||
windowParam: null | string,
|
||||
mode: RuntimeMode,
|
||||
): Response | { from: string; label: string; to: string } {
|
||||
const window = windowParam ?? "24h";
|
||||
if (window !== "24h") {
|
||||
return jsonResponse(createApiError("Unsupported window parameter", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime() - 24 * 60 * 60 * 1000);
|
||||
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") {
|
||||
return jsonResponse(createApiError("Unsupported bucket parameter", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
return { bucket };
|
||||
}
|
||||
|
||||
export function validatePagination(
|
||||
pageParam: null | string,
|
||||
@@ -32,6 +56,19 @@ export function validatePagination(
|
||||
return { page, pageSize };
|
||||
}
|
||||
|
||||
export function validateRecentLimit(limitParam: null | string, mode: RuntimeMode): Response | { recentLimit: number } {
|
||||
const recentLimit = limitParam === null ? 30 : Number(limitParam);
|
||||
if (!Number.isInteger(recentLimit) || recentLimit <= 0) {
|
||||
return jsonResponse(createApiError("Invalid recentLimit parameter", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (recentLimit > MAX_RECENT_LIMIT) {
|
||||
return jsonResponse(createApiError(`recentLimit must not exceed ${MAX_RECENT_LIMIT}`, 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
return { recentLimit };
|
||||
}
|
||||
|
||||
export function validateTargetId(idStr: string, mode: RuntimeMode): Response | { id: number } {
|
||||
const id = Number(idStr);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
@@ -49,9 +86,16 @@ export function validateTimeRange(
|
||||
return jsonResponse(createApiError("from and to parameters are required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (isNaN(new Date(from).getTime()) || isNaN(new Date(to).getTime())) {
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
return { from, to };
|
||||
if (fromDate.getTime() > toDate.getTime()) {
|
||||
return jsonResponse(createApiError("from must be earlier than to", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
return { from: fromDate.toISOString(), to: toDate.toISOString() };
|
||||
}
|
||||
|
||||
100
src/server/routes/dashboard.ts
Normal file
100
src/server/routes/dashboard.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { DashboardResponse, RuntimeMode } from "../../shared/api";
|
||||
import type { ProbeStore } from "../checker/store";
|
||||
|
||||
import { formatDuration, jsonResponse, mapCheckResult } from "../helpers";
|
||||
import { analyzeIncidentSequence, calculateCurrentStreak, type MetricCheckpoint } from "../metrics";
|
||||
import { validateDashboardWindow, validateRecentLimit } from "../middleware";
|
||||
|
||||
export function handleDashboard(url: URL, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const windowResult = validateDashboardWindow(url.searchParams.get("window"), mode);
|
||||
if (windowResult instanceof Response) return windowResult;
|
||||
|
||||
const limitResult = validateRecentLimit(url.searchParams.get("recentLimit"), mode);
|
||||
if (limitResult instanceof Response) return limitResult;
|
||||
|
||||
const targets = store.getTargets();
|
||||
const latestChecksMap = store.getLatestChecksMap();
|
||||
const windowStats = store.getAllTargetWindowStats(windowResult.from, windowResult.to);
|
||||
const recentSamplesMap = store.getAllRecentSamples(limitResult.recentLimit);
|
||||
const incidentStates = groupDashboardIncidentStates(
|
||||
store.getDashboardIncidentStates(windowResult.from, windowResult.to),
|
||||
);
|
||||
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
let lastCheckTime: null | string = null;
|
||||
let incidents = 0;
|
||||
|
||||
const responseTargets: DashboardResponse["targets"] = targets.map((target) => {
|
||||
const latest = latestChecksMap.get(target.id) ?? null;
|
||||
const stats = windowStats.get(target.id) ?? { availability: 0, downChecks: 0, totalChecks: 0, upChecks: 0 };
|
||||
const recentSamples = recentSamplesMap.get(target.id) ?? [];
|
||||
const currentStreak = calculateCurrentStreak(
|
||||
recentSamples.map((sample) => ({
|
||||
durationMs: sample.duration_ms,
|
||||
matched: sample.matched === 1,
|
||||
timestamp: sample.timestamp,
|
||||
})),
|
||||
limitResult.recentLimit,
|
||||
);
|
||||
|
||||
if (latest?.matched === 1) {
|
||||
up++;
|
||||
} else {
|
||||
down++;
|
||||
}
|
||||
|
||||
if (latest && (!lastCheckTime || latest.timestamp > lastCheckTime)) {
|
||||
lastCheckTime = latest.timestamp;
|
||||
}
|
||||
|
||||
incidents += analyzeIncidentSequence(
|
||||
incidentStates.get(target.id) ?? [],
|
||||
windowResult.from,
|
||||
windowResult.to,
|
||||
).incidentCount;
|
||||
|
||||
return {
|
||||
currentStreak,
|
||||
group: target.grp,
|
||||
id: target.id,
|
||||
interval: formatDuration(target.interval_ms),
|
||||
latestCheck: latest ? mapCheckResult(latest) : null,
|
||||
name: target.name,
|
||||
recentSamples: recentSamples.map((sample) => ({
|
||||
durationMs: sample.duration_ms,
|
||||
timestamp: sample.timestamp,
|
||||
up: sample.matched === 1,
|
||||
})),
|
||||
stats,
|
||||
target: target.target,
|
||||
type: target.type,
|
||||
};
|
||||
});
|
||||
|
||||
const response: DashboardResponse = {
|
||||
summary: {
|
||||
down,
|
||||
incidents,
|
||||
lastCheckTime,
|
||||
total: targets.length,
|
||||
up,
|
||||
window: windowResult,
|
||||
},
|
||||
targets: responseTargets,
|
||||
};
|
||||
|
||||
return jsonResponse(response, { mode });
|
||||
}
|
||||
|
||||
function groupDashboardIncidentStates(
|
||||
states: Array<{ matched: number; target_id: number; timestamp: string }>,
|
||||
): Map<number, MetricCheckpoint[]> {
|
||||
const result = new Map<number, MetricCheckpoint[]>();
|
||||
for (const state of states) {
|
||||
const list = result.get(state.target_id) ?? [];
|
||||
list.push({ durationMs: null, matched: state.matched === 1, timestamp: state.timestamp });
|
||||
result.set(state.target_id, list);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
67
src/server/routes/metrics.ts
Normal file
67
src/server/routes/metrics.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { RuntimeMode, TargetMetricsResponse } from "../../shared/api";
|
||||
import type { ProbeStore } from "../checker/store";
|
||||
|
||||
import { jsonResponse } from "../helpers";
|
||||
import {
|
||||
analyzeIncidentSequence,
|
||||
buildHourlyTrend,
|
||||
calculateAverageDuration,
|
||||
calculateCurrentStreak,
|
||||
calculatePercentile,
|
||||
type MetricCheckpoint,
|
||||
} from "../metrics";
|
||||
import { validateMetricsBucket, validateTargetId, validateTimeRange } from "../middleware";
|
||||
|
||||
export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const idResult = validateTargetId(idStr, mode);
|
||||
if (idResult instanceof Response) return idResult;
|
||||
|
||||
const target = store.getTargetById(idResult.id);
|
||||
if (!target) {
|
||||
return jsonResponse({ error: "Target not found", status: 404 } as const, { mode, status: 404 });
|
||||
}
|
||||
|
||||
const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode);
|
||||
if (timeResult instanceof Response) return timeResult;
|
||||
|
||||
const bucketResult = validateMetricsBucket(url.searchParams.get("bucket"), mode);
|
||||
if (bucketResult instanceof Response) return bucketResult;
|
||||
|
||||
const checkpoints = store
|
||||
.getTargetCheckpoints(idResult.id, timeResult.from, timeResult.to)
|
||||
.map((checkpoint): MetricCheckpoint => {
|
||||
return {
|
||||
durationMs: checkpoint.duration_ms,
|
||||
matched: checkpoint.matched === 1,
|
||||
timestamp: checkpoint.timestamp,
|
||||
};
|
||||
});
|
||||
const durations = store.getTargetDurations(idResult.id, timeResult.from, timeResult.to);
|
||||
const stats = store.getTargetWindowStats(idResult.id, timeResult.from, timeResult.to);
|
||||
const incidentAnalysis = analyzeIncidentSequence(checkpoints, timeResult.from, timeResult.to);
|
||||
|
||||
const response: TargetMetricsResponse = {
|
||||
stats: {
|
||||
availability: stats.availability,
|
||||
avgDurationMs: calculateAverageDuration(durations),
|
||||
currentStreak: calculateCurrentStreak(checkpoints),
|
||||
downChecks: stats.downChecks,
|
||||
incidentCount: incidentAnalysis.incidentCount,
|
||||
longestOutage: incidentAnalysis.longestOutage,
|
||||
mttr: incidentAnalysis.mttr,
|
||||
p95DurationMs: calculatePercentile(durations, 95),
|
||||
p99DurationMs: calculatePercentile(durations, 99),
|
||||
totalChecks: stats.totalChecks,
|
||||
upChecks: stats.upChecks,
|
||||
},
|
||||
targetId: idResult.id,
|
||||
trend: buildHourlyTrend(checkpoints),
|
||||
window: {
|
||||
bucket: bucketResult.bucket,
|
||||
from: timeResult.from,
|
||||
to: timeResult.to,
|
||||
},
|
||||
};
|
||||
|
||||
return jsonResponse(response, { mode });
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { RuntimeMode, SummaryResponse } from "../../shared/api";
|
||||
import type { ProbeStore } from "../checker/store";
|
||||
|
||||
import { jsonResponse } from "../helpers";
|
||||
|
||||
export function handleSummary(store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const summary = store.getSummary();
|
||||
const response: SummaryResponse = {
|
||||
down: summary.down,
|
||||
lastCheckTime: summary.lastCheckTime,
|
||||
total: summary.total,
|
||||
up: summary.up,
|
||||
};
|
||||
|
||||
return jsonResponse(response, { mode });
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { RuntimeMode, TargetStatus } from "../../shared/api";
|
||||
import type { ProbeStore } from "../checker/store";
|
||||
|
||||
import { formatDuration, jsonResponse, mapCheckResult } from "../helpers";
|
||||
|
||||
export function handleTargets(store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const targets = store.getTargets();
|
||||
const latestChecksMap = store.getLatestChecksMap();
|
||||
const allStats = store.getAllTargetStats();
|
||||
const allRecentSamples = store.getAllRecentSamples(30);
|
||||
|
||||
const result: TargetStatus[] = targets.map((target) => {
|
||||
const latest = latestChecksMap.get(target.id) ?? null;
|
||||
const stats = allStats.get(target.id) ?? { availability: 0, totalChecks: 0 };
|
||||
const recentSamples = allRecentSamples.get(target.id) ?? [];
|
||||
|
||||
return {
|
||||
group: target.grp,
|
||||
id: target.id,
|
||||
interval: formatDuration(target.interval_ms),
|
||||
latestCheck: latest ? mapCheckResult(latest) : null,
|
||||
name: target.name,
|
||||
recentSamples: recentSamples.map((s) => ({
|
||||
durationMs: s.duration_ms,
|
||||
timestamp: s.timestamp,
|
||||
up: s.matched === 1,
|
||||
})),
|
||||
stats: {
|
||||
availability: stats.availability,
|
||||
totalChecks: stats.totalChecks,
|
||||
},
|
||||
target: target.target,
|
||||
type: target.type,
|
||||
};
|
||||
});
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { RuntimeMode, TrendPoint } from "../../shared/api";
|
||||
import type { ProbeStore } from "../checker/store";
|
||||
|
||||
import { jsonResponse } from "../helpers";
|
||||
import { validateTargetId, validateTimeRange } from "../middleware";
|
||||
|
||||
export function handleTrend(idStr: string, url: URL, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const idResult = validateTargetId(idStr, mode);
|
||||
if (idResult instanceof Response) return idResult;
|
||||
|
||||
const target = store.getTargetById(idResult.id);
|
||||
if (!target) {
|
||||
return jsonResponse({ error: "Target not found", status: 404 } as const, { mode, status: 404 });
|
||||
}
|
||||
|
||||
const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode);
|
||||
if (timeResult instanceof Response) return timeResult;
|
||||
|
||||
const trend: TrendPoint[] = store.getTrend(idResult.id, timeResult.from, timeResult.to).map((row) => ({
|
||||
availability: Math.round(row.availability * 100) / 100,
|
||||
avgDurationMs: row.avgDurationMs,
|
||||
hour: row.hour,
|
||||
totalChecks: row.totalChecks,
|
||||
}));
|
||||
|
||||
return jsonResponse(trend, { mode });
|
||||
}
|
||||
@@ -4,12 +4,11 @@ import type { RuntimeConfig } from "./config";
|
||||
|
||||
import homepage from "../web/index.html";
|
||||
import { createApiError, jsonResponse } from "./helpers";
|
||||
import { handleDashboard } from "./routes/dashboard";
|
||||
import { handleHealth } from "./routes/health";
|
||||
import { handleHistory } from "./routes/history";
|
||||
import { handleMeta } from "./routes/meta";
|
||||
import { handleSummary } from "./routes/summary";
|
||||
import { handleTargets } from "./routes/targets";
|
||||
import { handleTrend } from "./routes/trend";
|
||||
import { handleMetrics } from "./routes/metrics";
|
||||
|
||||
export interface StartServerOptions {
|
||||
config: RuntimeConfig;
|
||||
@@ -30,20 +29,17 @@ export function startServer(options: StartServerOptions) {
|
||||
routes: {
|
||||
"/*": homepage,
|
||||
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
|
||||
"/api/dashboard": {
|
||||
GET: (req) => handleDashboard(new URL(req.url), store, mode),
|
||||
},
|
||||
"/api/meta": {
|
||||
GET: () => handleMeta(mode),
|
||||
},
|
||||
"/api/summary": {
|
||||
GET: () => handleSummary(store, mode),
|
||||
},
|
||||
"/api/targets": {
|
||||
GET: () => handleTargets(store, mode),
|
||||
},
|
||||
"/api/targets/:id/history": {
|
||||
GET: (req) => handleHistory(req.params.id, new URL(req.url), store, mode),
|
||||
},
|
||||
"/api/targets/:id/trend": {
|
||||
GET: (req) => handleTrend(req.params.id, new URL(req.url), store, mode),
|
||||
"/api/targets/:id/metrics": {
|
||||
GET: (req) => handleMetrics(req.params.id, new URL(req.url), store, mode),
|
||||
},
|
||||
"/health": {
|
||||
GET: () => handleHealth(mode),
|
||||
|
||||
@@ -20,6 +20,28 @@ export interface CheckResult {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface CurrentStreak {
|
||||
capped?: boolean;
|
||||
count: number;
|
||||
up: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardResponse {
|
||||
summary: {
|
||||
down: number;
|
||||
incidents: number;
|
||||
lastCheckTime: null | string;
|
||||
total: number;
|
||||
up: number;
|
||||
window: {
|
||||
from: string;
|
||||
label: string;
|
||||
to: string;
|
||||
};
|
||||
};
|
||||
targets: TargetStatus[];
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
ok: true;
|
||||
service: "dial-server";
|
||||
@@ -45,19 +67,38 @@ export interface RecentSample {
|
||||
|
||||
export type RuntimeMode = "development" | "production" | "test";
|
||||
|
||||
export interface SummaryResponse {
|
||||
down: number;
|
||||
lastCheckTime: null | string;
|
||||
total: number;
|
||||
up: number;
|
||||
export interface TargetMetricsResponse {
|
||||
stats: {
|
||||
availability: number;
|
||||
avgDurationMs: null | number;
|
||||
currentStreak: CurrentStreak | null;
|
||||
downChecks: number;
|
||||
incidentCount: number;
|
||||
longestOutage: null | number;
|
||||
mttr: null | number;
|
||||
p95DurationMs: null | number;
|
||||
p99DurationMs: null | number;
|
||||
totalChecks: number;
|
||||
upChecks: number;
|
||||
};
|
||||
targetId: number;
|
||||
trend: TrendPoint[];
|
||||
window: {
|
||||
bucket: "1h";
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TargetStats {
|
||||
availability: number;
|
||||
downChecks: number;
|
||||
totalChecks: number;
|
||||
upChecks: number;
|
||||
}
|
||||
|
||||
export interface TargetStatus {
|
||||
currentStreak: CurrentStreak | null;
|
||||
group: string;
|
||||
id: number;
|
||||
interval: string;
|
||||
@@ -72,6 +113,10 @@ export interface TargetStatus {
|
||||
export interface TrendPoint {
|
||||
availability: number;
|
||||
avgDurationMs: null | number;
|
||||
hour: string;
|
||||
bucketStart: string;
|
||||
downChecks: number;
|
||||
maxDurationMs: null | number;
|
||||
minDurationMs: null | number;
|
||||
totalChecks: number;
|
||||
upChecks: number;
|
||||
}
|
||||
|
||||
@@ -3,28 +3,25 @@ import { Alert, Loading, Typography } from "tdesign-react";
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { TargetBoard } from "./components/TargetBoard";
|
||||
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
|
||||
import { useSummary, useTargets } from "./hooks/use-queries";
|
||||
import { useDashboard } from "./hooks/use-queries";
|
||||
import { useTargetDetail } from "./hooks/use-target-detail";
|
||||
|
||||
export function App() {
|
||||
const { data: summary, error: summaryError, isLoading: summaryLoading } = useSummary();
|
||||
const { data: targets, error: targetsError, isLoading: targetsLoading } = useTargets();
|
||||
const { data: dashboard, error: dashboardError, isLoading: dashboardLoading } = useDashboard();
|
||||
const {
|
||||
closeDrawer,
|
||||
handlePageChange,
|
||||
handleTimeChange,
|
||||
historyData,
|
||||
historyLoading,
|
||||
metricsData,
|
||||
metricsLoading,
|
||||
openDrawer,
|
||||
selectedTarget,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
trendData,
|
||||
trendLoading,
|
||||
} = useTargetDetail();
|
||||
|
||||
const error = summaryError ?? targetsError;
|
||||
|
||||
return (
|
||||
<main className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
@@ -32,14 +29,14 @@ export function App() {
|
||||
<Typography.Text theme="secondary">统一拨测平台</Typography.Text>
|
||||
</header>
|
||||
|
||||
{error && <Alert closeBtn message={`请求失败: ${error.message}`} theme="error" />}
|
||||
{dashboardError && <Alert closeBtn message={`请求失败: ${dashboardError.message}`} theme="error" />}
|
||||
|
||||
{summaryLoading && targetsLoading ? (
|
||||
{dashboardLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<SummaryCards summary={summary ?? null} />
|
||||
<TargetBoard onTargetClick={openDrawer} targets={targets ?? []} />
|
||||
<SummaryCards summary={dashboard?.summary ?? null} />
|
||||
<TargetBoard onTargetClick={openDrawer} targets={dashboard?.targets ?? []} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -47,14 +44,14 @@ export function App() {
|
||||
historyData={historyData}
|
||||
historyLoading={historyLoading}
|
||||
key={selectedTarget?.id}
|
||||
metricsData={metricsData}
|
||||
metricsLoading={metricsLoading}
|
||||
onClose={closeDrawer}
|
||||
onPageChange={handlePageChange}
|
||||
onTimeChange={handleTimeChange}
|
||||
target={selectedTarget}
|
||||
timeFrom={timeFrom}
|
||||
timeTo={timeTo}
|
||||
trendData={trendData}
|
||||
trendLoading={trendLoading}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,44 +1,88 @@
|
||||
import { useMemo } from "react";
|
||||
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic } from "tdesign-react";
|
||||
|
||||
import type { TargetStatus, TrendPoint } from "../../shared/api";
|
||||
import type { TargetMetricsResponse, TargetStatus } from "../../shared/api";
|
||||
|
||||
import { computeTrendStats } from "../utils/stats";
|
||||
import { formatDurationUnit } from "../utils/time";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
|
||||
interface OverviewTabProps {
|
||||
metricsData: null | TargetMetricsResponse;
|
||||
metricsLoading: boolean;
|
||||
target: TargetStatus;
|
||||
trendData: TrendPoint[];
|
||||
trendLoading: boolean;
|
||||
}
|
||||
|
||||
export function OverviewTab({ target, trendData, trendLoading }: OverviewTabProps) {
|
||||
const { downChecks, totalChecks, upChecks } = useMemo(() => computeTrendStats(trendData), [trendData]);
|
||||
export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTabProps) {
|
||||
const stats = metricsData?.stats ?? null;
|
||||
const mttr = formatDurationUnit(stats?.mttr ?? null);
|
||||
const longestOutage = formatDurationUnit(stats?.longestOutage ?? null);
|
||||
const currentUpStreak = stats?.currentStreak?.up ? stats.currentStreak.count : 0;
|
||||
|
||||
return (
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<Divider align="left">统计</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={3}>
|
||||
<Statistic color="blue" title="总检查" value={totalChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" title="正常" value={upChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="red" title="异常" value={downChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" suffix="%" title="可用率" value={target.stats?.availability ?? 0} />
|
||||
</Col>
|
||||
</Row>
|
||||
{metricsLoading ? (
|
||||
<Skeleton animation="gradient" />
|
||||
) : stats ? (
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<Row gutter={16}>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" suffix="%" title="可用率" value={stats.availability} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic
|
||||
suffix={stats.avgDurationMs === null ? "" : "ms"}
|
||||
title="平均延迟"
|
||||
value={stats.avgDurationMs ?? 0}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic
|
||||
suffix={stats.p95DurationMs === null ? "" : "ms"}
|
||||
title="P95 延迟"
|
||||
value={stats.p95DurationMs ?? 0}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="blue" title="检查总数" value={stats.totalChecks} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={3}>
|
||||
<Statistic suffix={mttr.suffix} title="MTTR" value={mttr.value} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic suffix={longestOutage.suffix} title="最长故障" value={longestOutage.value} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="red" suffix="次" title="故障次数" value={stats.incidentCount} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" suffix="次" title="连续正常" value={currentUpStreak} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
) : (
|
||||
<div className="trend-empty">暂无指标数据</div>
|
||||
)}
|
||||
|
||||
<Divider align="left">趋势</Divider>
|
||||
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} />}
|
||||
{metricsLoading ? (
|
||||
<Skeleton animation="gradient" />
|
||||
) : metricsData ? (
|
||||
<TrendChart data={metricsData.trend} />
|
||||
) : (
|
||||
<div className="trend-empty">暂无趋势数据</div>
|
||||
)}
|
||||
|
||||
<Divider align="left">状态分布</Divider>
|
||||
<StatusDonut down={downChecks} up={upChecks} />
|
||||
{metricsLoading ? (
|
||||
<Skeleton animation="gradient" />
|
||||
) : stats ? (
|
||||
<StatusDonut down={stats.downChecks} up={stats.upChecks} />
|
||||
) : (
|
||||
<div className="trend-empty">暂无状态数据</div>
|
||||
)}
|
||||
|
||||
<Divider align="left">基本信息</Divider>
|
||||
<Descriptions
|
||||
|
||||
@@ -34,7 +34,7 @@ export function StatusDonut({ down, up }: StatusDonutProps) {
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="donut-center-label">{availability}%</div>
|
||||
<div className="donut-center-label">{total > 0 ? `${availability}%` : "-"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,49 @@
|
||||
import { Card, Col, Row, Statistic } from "tdesign-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Col, Row, Statistic, Typography } from "tdesign-react";
|
||||
|
||||
import type { SummaryResponse } from "../../shared/api";
|
||||
import type { DashboardResponse } from "../../shared/api";
|
||||
|
||||
import { formatRelativeTime, isOlderThan } from "../utils/time";
|
||||
|
||||
interface SummaryCardsProps {
|
||||
summary: null | SummaryResponse;
|
||||
summary: DashboardResponse["summary"] | null;
|
||||
}
|
||||
|
||||
export function SummaryCards({ summary }: SummaryCardsProps) {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(new Date()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
if (!summary) return null;
|
||||
|
||||
const cards = [
|
||||
{ color: "blue" as const, label: "全部目标", value: summary.total },
|
||||
{ color: "green" as const, label: "正常", value: summary.up },
|
||||
{ color: "red" as const, label: "异常", value: summary.down },
|
||||
{ color: "orange" as const, label: `${summary.window.label} 异常事件数`, value: summary.incidents },
|
||||
];
|
||||
const freshnessWarning = isOlderThan(summary.lastCheckTime, 60000, now);
|
||||
|
||||
return (
|
||||
<Row className="summary-cards-row" gutter={16}>
|
||||
{cards.map((card) => (
|
||||
<Col key={card.label} span={4}>
|
||||
<Card bordered>
|
||||
<Statistic color={card.color} title={card.label} value={card.value} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<section className="summary-cards-row">
|
||||
<Row gutter={16}>
|
||||
{cards.map((card) => (
|
||||
<Col key={card.label} span={3}>
|
||||
<Card bordered>
|
||||
<Statistic color={card.color} title={card.label} value={card.value} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<Typography.Text
|
||||
className={freshnessWarning ? "summary-freshness summary-freshness--warning" : "summary-freshness"}
|
||||
theme="secondary"
|
||||
>
|
||||
{summary.lastCheckTime ? `最后更新: ${formatRelativeTime(summary.lastCheckTime, now)}` : "尚无检查数据"}
|
||||
</Typography.Text>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { TabValue } from "tdesign-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { DateRangePicker, Drawer, RadioGroup, Space, Tabs, Tag, Typography } from "tdesign-react";
|
||||
|
||||
import type { HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
import type { HistoryResponse, TargetMetricsResponse, TargetStatus } from "../../shared/api";
|
||||
|
||||
import { subtractHours } from "../utils/time";
|
||||
import { HistoryTab } from "./HistoryTab";
|
||||
@@ -13,14 +13,14 @@ import { StatusDot } from "./StatusDot";
|
||||
interface TargetDetailDrawerProps {
|
||||
historyData: HistoryResponse;
|
||||
historyLoading: boolean;
|
||||
metricsData: null | TargetMetricsResponse;
|
||||
metricsLoading: boolean;
|
||||
onClose: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onTimeChange: (from: string, to: string) => void;
|
||||
target: null | TargetStatus;
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
trendData: TrendPoint[];
|
||||
trendLoading: boolean;
|
||||
}
|
||||
|
||||
const TIME_SHORTCUTS = [
|
||||
@@ -33,14 +33,14 @@ const TIME_SHORTCUTS = [
|
||||
export function TargetDetailDrawer({
|
||||
historyData,
|
||||
historyLoading,
|
||||
metricsData,
|
||||
metricsLoading,
|
||||
onClose,
|
||||
onPageChange,
|
||||
onTimeChange,
|
||||
target,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
trendData,
|
||||
trendLoading,
|
||||
}: TargetDetailDrawerProps) {
|
||||
const [activeShortcut, setActiveShortcut] = useState<string>("24h");
|
||||
const [activeTab, setActiveTab] = useState<TabValue>("overview");
|
||||
@@ -109,7 +109,7 @@ export function TargetDetailDrawer({
|
||||
/>
|
||||
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
|
||||
<OverviewTab target={target} trendData={trendData} trendLoading={trendLoading} />
|
||||
<OverviewTab metricsData={metricsData} metricsLoading={metricsLoading} target={target} />
|
||||
</Tabs.TabPanel>
|
||||
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="记录" value="history">
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { Area, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
|
||||
import type { TrendPoint } from "../../shared/api";
|
||||
|
||||
interface IncidentDotProps {
|
||||
cx?: number | string;
|
||||
cy?: number | string;
|
||||
payload?: TrendPoint;
|
||||
}
|
||||
|
||||
interface TrendChartProps {
|
||||
data: TrendPoint[];
|
||||
}
|
||||
@@ -13,7 +19,9 @@ export function TrendChart({ data }: TrendChartProps) {
|
||||
|
||||
const chartData = data.map((point) => ({
|
||||
...point,
|
||||
hour: point.hour.slice(11, 16),
|
||||
durationRange:
|
||||
point.minDurationMs !== null && point.maxDurationMs !== null ? [point.minDurationMs, point.maxDurationMs] : null,
|
||||
label: formatBucketLabel(point.bucketStart),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -21,50 +29,64 @@ export function TrendChart({ data }: TrendChartProps) {
|
||||
<ResponsiveContainer height={240} width="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid stroke="var(--td-border-level-2-color)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="hour" stroke="var(--td-text-color-secondary)" tick={{ fontSize: 12 }} />
|
||||
<XAxis dataKey="label" stroke="var(--td-text-color-secondary)" tick={{ fontSize: 12 }} />
|
||||
<YAxis
|
||||
label={{ fontSize: 11, position: "insideTopRight", value: "ms" }}
|
||||
stroke="var(--td-text-color-secondary)"
|
||||
tick={{ fontSize: 12 }}
|
||||
yAxisId="duration"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
label={{ fontSize: 11, position: "insideTopLeft", value: "%" }}
|
||||
orientation="right"
|
||||
stroke="var(--td-text-color-secondary)"
|
||||
tick={{ fontSize: 12 }}
|
||||
yAxisId="availability"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: unknown, name: unknown) => {
|
||||
const num = Number(value);
|
||||
const nameStr = String(name);
|
||||
if (nameStr === "durationRange" && Array.isArray(value)) {
|
||||
return [`${Math.round(Number(value[0]))}ms - ${Math.round(Number(value[1]))}ms`, "延迟范围"];
|
||||
}
|
||||
const num = Number(value);
|
||||
if (nameStr === "avgDurationMs") return [`${Math.round(num)}ms`, "平均耗时"];
|
||||
if (nameStr === "availability") return [`${num.toFixed(1)}%`, "可用率"];
|
||||
return [String(value), nameStr];
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
dataKey="durationRange"
|
||||
fill="var(--td-brand-color-light)"
|
||||
fillOpacity={0.2}
|
||||
name="durationRange"
|
||||
stroke="var(--td-brand-color-light)"
|
||||
type="monotone"
|
||||
yAxisId="duration"
|
||||
/>
|
||||
<Line
|
||||
dataKey="avgDurationMs"
|
||||
dot={false}
|
||||
dot={renderIncidentDot}
|
||||
name="avgDurationMs"
|
||||
stroke="var(--td-brand-color)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="duration"
|
||||
/>
|
||||
<Line
|
||||
dataKey="availability"
|
||||
dot={false}
|
||||
name="availability"
|
||||
stroke="var(--td-success-color)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="availability"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBucketLabel(bucketStart: string): string {
|
||||
return new Date(bucketStart).toLocaleTimeString("zh-CN", { hour: "2-digit", hour12: false, minute: "2-digit" });
|
||||
}
|
||||
|
||||
function renderIncidentDot(props: IncidentDotProps) {
|
||||
const { cx, cy, payload } = props;
|
||||
if (!payload || payload.availability >= 100 || payload.avgDurationMs === null) return <></>;
|
||||
|
||||
return (
|
||||
<circle
|
||||
cx={Number(cx)}
|
||||
cy={Number(cy)}
|
||||
fill="var(--td-error-color)"
|
||||
r={4}
|
||||
stroke="var(--td-bg-color-container)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
|
||||
colKey: "stats.availability",
|
||||
sorter: availabilitySorter,
|
||||
sortType: "all",
|
||||
title: "可用率",
|
||||
title: "可用率(24h)",
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
@@ -66,6 +66,22 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
|
||||
title: "最近状态",
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
align: "center",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
const streak = row.currentStreak;
|
||||
if (!streak) return "-";
|
||||
return (
|
||||
<Tag size="small" theme={streak.up ? "success" : "danger"} variant="light">
|
||||
{streak.up ? "▲" : "▼"} {streak.count}
|
||||
{streak.capped ? "+" : "次"}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
colKey: "currentStreak",
|
||||
title: "连续",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
align: "right",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { MetaResponse, SummaryResponse, TargetStatus } from "../../shared/api";
|
||||
import type { DashboardResponse, MetaResponse, TargetMetricsResponse } from "../../shared/api";
|
||||
|
||||
const queryKeys = {
|
||||
dashboard: () => ["dashboard", "24h", 30] as const,
|
||||
meta: () => ["meta"] as const,
|
||||
summary: () => ["summary"] as const,
|
||||
targets: () => ["targets"] as const,
|
||||
metrics: (targetId: number, from: string, to: string, bucket: "1h") =>
|
||||
["metrics", targetId, from, to, bucket] as const,
|
||||
};
|
||||
|
||||
export async function fetchJson<T>(url: string): Promise<T> {
|
||||
@@ -14,6 +15,15 @@ export async function fetchJson<T>(url: string): Promise<T> {
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function useDashboard() {
|
||||
return useQuery({
|
||||
queryFn: () => fetchJson<DashboardResponse>("/api/dashboard?window=24h&recentLimit=30"),
|
||||
queryKey: queryKeys.dashboard(),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMeta() {
|
||||
return useQuery({
|
||||
queryFn: () => fetchJson<MetaResponse>("/api/meta"),
|
||||
@@ -22,20 +32,15 @@ export function useMeta() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSummary() {
|
||||
export function useTargetMetrics(targetId: null | number, from: string, to: string, bucket: "1h") {
|
||||
return useQuery({
|
||||
queryFn: () => fetchJson<SummaryResponse>("/api/summary"),
|
||||
queryKey: queryKeys.summary(),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargets() {
|
||||
return useQuery({
|
||||
queryFn: () => fetchJson<TargetStatus[]>("/api/targets"),
|
||||
queryKey: queryKeys.targets(),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
enabled: targetId !== null && !!from && !!to,
|
||||
queryFn: () => {
|
||||
if (targetId === null) throw new Error("未选择目标");
|
||||
return fetchJson<TargetMetricsResponse>(
|
||||
`/api/targets/${targetId}/metrics?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&bucket=${bucket}`,
|
||||
);
|
||||
},
|
||||
queryKey: targetId !== null && from && to ? queryKeys.metrics(targetId, from, to, bucket) : ["metrics", "disabled"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
import type { HistoryResponse, TargetStatus } from "../../shared/api";
|
||||
|
||||
import { subtractHours } from "../utils/time";
|
||||
import { fetchJson, useTargets } from "./use-queries";
|
||||
import { fetchJson, useDashboard, useTargetMetrics } from "./use-queries";
|
||||
|
||||
const detailQueryKeys = {
|
||||
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
||||
trend: (targetId: number, from: string, to: string) => ["trend", targetId, from, to] as const,
|
||||
};
|
||||
|
||||
export function useTargetDetail() {
|
||||
@@ -18,28 +17,22 @@ export function useTargetDetail() {
|
||||
const [timeTo, setTimeTo] = useState("");
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
|
||||
const { data: targetsData } = useTargets();
|
||||
const { data: dashboardData } = useDashboard();
|
||||
const selectedTarget =
|
||||
selectedTargetId !== null ? (targetsData?.find((target) => target.id === selectedTargetId) ?? null) : null;
|
||||
selectedTargetId !== null
|
||||
? (dashboardData?.targets.find((target) => target.id === selectedTargetId) ?? null)
|
||||
: null;
|
||||
|
||||
const trend = useQuery({
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||
queryFn: () =>
|
||||
fetchJson<TrendPoint[]>(
|
||||
`/api/targets/${selectedTargetId}/trend?from=${encodeURIComponent(timeFrom)}&to=${encodeURIComponent(timeTo)}`,
|
||||
),
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? detailQueryKeys.trend(selectedTargetId, timeFrom, timeTo)
|
||||
: ["trend", "disabled"],
|
||||
});
|
||||
const metrics = useTargetMetrics(selectedTargetId, timeFrom, timeTo, "1h");
|
||||
|
||||
const history = useQuery({
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||
queryFn: () =>
|
||||
fetchJson<HistoryResponse>(
|
||||
queryFn: () => {
|
||||
if (selectedTargetId === null) throw new Error("未选择目标");
|
||||
return fetchJson<HistoryResponse>(
|
||||
`/api/targets/${selectedTargetId}/history?from=${encodeURIComponent(timeFrom)}&to=${encodeURIComponent(timeTo)}&page=${historyPage}&pageSize=20`,
|
||||
),
|
||||
);
|
||||
},
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? detailQueryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage)
|
||||
@@ -57,7 +50,7 @@ export function useTargetDetail() {
|
||||
|
||||
const closeDrawer = useCallback(() => {
|
||||
setSelectedTargetId(null);
|
||||
queryClient.removeQueries({ queryKey: ["trend"] });
|
||||
queryClient.removeQueries({ queryKey: ["metrics"] });
|
||||
queryClient.removeQueries({ queryKey: ["history"] });
|
||||
}, [queryClient]);
|
||||
|
||||
@@ -77,11 +70,11 @@ export function useTargetDetail() {
|
||||
handleTimeChange,
|
||||
historyData: history.data ?? { items: [], page: 1, pageSize: 20, total: 0 },
|
||||
historyLoading: history.isLoading,
|
||||
metricsData: metrics.data ?? null,
|
||||
metricsLoading: metrics.isLoading,
|
||||
openDrawer,
|
||||
selectedTarget,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
trendData: trend.data ?? [],
|
||||
trendLoading: trend.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -157,6 +157,15 @@
|
||||
margin-bottom: var(--td-comp-margin-xl);
|
||||
}
|
||||
|
||||
.summary-freshness {
|
||||
display: block;
|
||||
margin-top: var(--td-comp-margin-s);
|
||||
}
|
||||
|
||||
.summary-freshness--warning {
|
||||
color: var(--td-warning-color);
|
||||
}
|
||||
|
||||
.error-boundary-fallback {
|
||||
padding-top: 20vh;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { TrendPoint } from "../../shared/api";
|
||||
|
||||
export interface TrendStats {
|
||||
downChecks: number;
|
||||
totalChecks: number;
|
||||
upChecks: number;
|
||||
}
|
||||
|
||||
export function computeTrendStats(points: TrendPoint[]): TrendStats {
|
||||
let totalChecks = 0;
|
||||
let upChecks = 0;
|
||||
|
||||
for (const point of points) {
|
||||
totalChecks += point.totalChecks;
|
||||
upChecks += Math.round((point.availability / 100) * point.totalChecks);
|
||||
}
|
||||
|
||||
return {
|
||||
downChecks: totalChecks - upChecks,
|
||||
totalChecks,
|
||||
upChecks,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,41 @@
|
||||
export function formatDurationUnit(ms: null | number): { suffix: string; value: number } {
|
||||
if (ms === null) return { suffix: "", value: 0 };
|
||||
if (ms < 60000) return { suffix: "秒", value: roundToOne(ms / 1000) };
|
||||
if (ms < 3600000) return { suffix: "分钟", value: roundToOne(ms / 60000) };
|
||||
return { suffix: "小时", value: roundToOne(ms / 3600000) };
|
||||
}
|
||||
|
||||
export function formatRelativeTime(timestamp: null | string, now = new Date()): string {
|
||||
if (!timestamp) return "尚无检查数据";
|
||||
|
||||
const time = new Date(timestamp).getTime();
|
||||
if (Number.isNaN(time)) return "尚无检查数据";
|
||||
|
||||
const diffSeconds = Math.max(0, Math.floor((now.getTime() - time) / 1000));
|
||||
if (diffSeconds < 60) return `${diffSeconds}秒前`;
|
||||
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
if (diffMinutes < 60) return `${diffMinutes}分钟前`;
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours}小时前`;
|
||||
|
||||
return `${Math.floor(diffHours / 24)}天前`;
|
||||
}
|
||||
|
||||
export function isOlderThan(timestamp: null | string, ageMs: number, now = new Date()): boolean {
|
||||
if (!timestamp) return false;
|
||||
const time = new Date(timestamp).getTime();
|
||||
if (Number.isNaN(time)) return false;
|
||||
return now.getTime() - time > ageMs;
|
||||
}
|
||||
|
||||
export function subtractHours(date: Date, hours: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setTime(result.getTime() - hours * 60 * 60 * 1000);
|
||||
return result;
|
||||
}
|
||||
|
||||
function roundToOne(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user