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;
|
||||
|
||||
Reference in New Issue
Block a user