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

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

View File

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

View File

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

View File

@@ -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">

View File

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