1
0

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:
2026-05-14 12:32:41 +08:00
parent e983e5d75d
commit 1c5cfafda6
47 changed files with 1768 additions and 1231 deletions

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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),