feat: 动态粒度趋势图,支持 auto bucket 选择 + P95 延迟 + 状态条
This commit is contained in:
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
|
||||
|
||||
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic } from "tdesign-react";
|
||||
|
||||
import type { TargetMetricsResponse, TargetStatus } from "../../shared/api";
|
||||
import type { MetricsBucket, TargetMetricsResponse, TargetStatus } from "../../shared/api";
|
||||
|
||||
import { formatDurationUnit } from "../utils/time";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
@@ -78,14 +78,14 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
|
||||
<OverviewStatItem color="red" suffix="次" title="故障次数" value={stats.incidentCount} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem color="green" suffix="次" title="连续正常" value={currentUpStreak} />
|
||||
<OverviewStatItem color="green" suffix="次" title="窗口内连续正常" value={currentUpStreak} />
|
||||
</Col>
|
||||
</Row>
|
||||
) : (
|
||||
<div className="trend-empty">暂无指标数据</div>
|
||||
)}
|
||||
|
||||
<Divider align="left">趋势</Divider>
|
||||
<Divider align="left">趋势{metricsData ? ` · ${formatBucketLabel(metricsData.window.bucket)}` : ""}</Divider>
|
||||
{metricsLoading ? (
|
||||
<Skeleton animation="gradient" />
|
||||
) : metricsData ? (
|
||||
@@ -104,3 +104,20 @@ function OverviewStatItem({ color, suffix, title, value }: OverviewStatItemProps
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BUCKET_LABELS: Record<MetricsBucket, string> = {
|
||||
"1d": "1天",
|
||||
"1h": "1小时",
|
||||
"1m": "1分钟",
|
||||
"3h": "3小时",
|
||||
"5m": "5分钟",
|
||||
"6h": "6小时",
|
||||
"12h": "12小时",
|
||||
"15m": "15分钟",
|
||||
"30m": "30分钟",
|
||||
"30s": "30秒",
|
||||
};
|
||||
|
||||
function formatBucketLabel(bucket: MetricsBucket): string {
|
||||
return BUCKET_LABELS[bucket] ?? bucket;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { Area, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { 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[];
|
||||
}
|
||||
|
||||
export const TrendChart = memo(function TrendChart({ data }: TrendChartProps) {
|
||||
const windowMs = useMemo(() => {
|
||||
if (data.length < 2) return 0;
|
||||
const first = new Date(data[0]!.bucketStart).getTime();
|
||||
const last = new Date(data[data.length - 1]!.bucketEnd).getTime();
|
||||
return last - first;
|
||||
}, [data]);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
data.map((point) => ({
|
||||
...point,
|
||||
durationRange:
|
||||
point.minDurationMs !== null && point.maxDurationMs !== null
|
||||
? [point.minDurationMs, point.maxDurationMs]
|
||||
: null,
|
||||
label: formatBucketLabel(point.bucketStart),
|
||||
label: formatBucketLabel(point.bucketStart, windowMs),
|
||||
statusLevel: getStatusLevel(point),
|
||||
})),
|
||||
[data],
|
||||
[data, windowMs],
|
||||
);
|
||||
|
||||
if (data.length === 0) {
|
||||
@@ -33,7 +31,7 @@ export const TrendChart = memo(function TrendChart({ data }: TrendChartProps) {
|
||||
|
||||
return (
|
||||
<div className="trend-chart">
|
||||
<ResponsiveContainer height={240} width="100%">
|
||||
<ResponsiveContainer height={280} width="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid stroke="var(--td-border-level-2-color)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" stroke="var(--td-text-color-secondary)" tick={{ fontSize: 12 }} />
|
||||
@@ -43,57 +41,98 @@ export const TrendChart = memo(function TrendChart({ data }: TrendChartProps) {
|
||||
tick={{ fontSize: 12 }}
|
||||
yAxisId="duration"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: unknown, name: unknown) => {
|
||||
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`, "平均耗时"];
|
||||
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"
|
||||
/>
|
||||
<Tooltip content={<TrendTooltip />} />
|
||||
<Line
|
||||
connectNulls={false}
|
||||
dataKey="avgDurationMs"
|
||||
dot={renderIncidentDot}
|
||||
dot={false}
|
||||
name="avgDurationMs"
|
||||
stroke="var(--td-brand-color)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="duration"
|
||||
/>
|
||||
<Line
|
||||
connectNulls={false}
|
||||
dataKey="p95DurationMs"
|
||||
dot={false}
|
||||
name="p95DurationMs"
|
||||
stroke="var(--td-warning-color)"
|
||||
strokeDasharray="4 2"
|
||||
strokeWidth={1}
|
||||
type="monotone"
|
||||
yAxisId="duration"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="trend-status-bar">
|
||||
{chartData.map((point) => (
|
||||
<div
|
||||
className={`trend-status-block trend-status-block--${point.statusLevel}`}
|
||||
key={point.bucketStart}
|
||||
title={`${point.label}: ${getStatusText(point)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function formatBucketLabel(bucketStart: string): string {
|
||||
return new Date(bucketStart).toLocaleTimeString("zh-CN", { hour: "2-digit", hour12: false, minute: "2-digit" });
|
||||
function formatBucketLabel(bucketStart: string, windowMs: number): string {
|
||||
const date = new Date(bucketStart);
|
||||
if (windowMs > 24 * 60 * 60 * 1000) {
|
||||
return date.toLocaleString("zh-CN", {
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
minute: "2-digit",
|
||||
month: "2-digit",
|
||||
});
|
||||
}
|
||||
return date.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 <></>;
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString("zh-CN", { hour: "2-digit", hour12: false, minute: "2-digit" });
|
||||
}
|
||||
|
||||
function getStatusLevel(point: TrendPoint): "down" | "empty" | "ok" | "partial" {
|
||||
if (point.totalChecks === 0) return "empty";
|
||||
if (point.availability >= 100) return "ok";
|
||||
if (point.availability > 0) return "partial";
|
||||
return "down";
|
||||
}
|
||||
|
||||
function getStatusText(point: TrendPoint): string {
|
||||
const level = getStatusLevel(point);
|
||||
if (level === "empty") return "无检查数据";
|
||||
if (level === "ok") return "正常";
|
||||
if (level === "partial") return "部分异常";
|
||||
return "全异常";
|
||||
}
|
||||
|
||||
function TrendTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: TrendPoint }> }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const point = payload[0]!.payload;
|
||||
const level = getStatusLevel(point);
|
||||
const timeRange = `${formatTime(point.bucketStart)} - ${formatTime(point.bucketEnd)}`;
|
||||
|
||||
return (
|
||||
<circle
|
||||
cx={Number(cx)}
|
||||
cy={Number(cy)}
|
||||
fill="var(--td-error-color)"
|
||||
r={4}
|
||||
stroke="var(--td-bg-color-container)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<div className="trend-tooltip">
|
||||
<div className="trend-tooltip-time">{timeRange}</div>
|
||||
{level === "empty" ? (
|
||||
<div className="trend-tooltip-empty">无检查数据</div>
|
||||
) : (
|
||||
<>
|
||||
<div>可用率:{point.availability.toFixed(1)}%</div>
|
||||
<div>
|
||||
成功/总数:{point.upChecks}/{point.totalChecks}
|
||||
</div>
|
||||
<div>失败数:{point.downChecks}</div>
|
||||
{point.avgDurationMs !== null && <div>平均耗时:{Math.round(point.avgDurationMs)}ms</div>}
|
||||
{point.p95DurationMs !== null && <div>P95:{Math.round(point.p95DurationMs)}ms</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { DashboardResponse, MetaResponse, TargetMetricsResponse } from "../../shared/api";
|
||||
import type { DashboardResponse, MetaResponse, MetricsBucket, TargetMetricsResponse } from "../../shared/api";
|
||||
|
||||
const queryKeys = {
|
||||
dashboard: () => ["dashboard", "24h", 30] as const,
|
||||
meta: () => ["meta"] as const,
|
||||
metrics: (targetId: string, from: string, to: string, bucket: "1h") =>
|
||||
metrics: (targetId: string, from: string, to: string, bucket: "auto" | MetricsBucket) =>
|
||||
["metrics", targetId, from, to, bucket] as const,
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ export function useMeta() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargetMetrics(targetId: null | string, from: string, to: string, bucket: "1h") {
|
||||
export function useTargetMetrics(targetId: null | string, from: string, to: string, bucket: "auto" | MetricsBucket) {
|
||||
return useQuery({
|
||||
enabled: targetId !== null && !!from && !!to,
|
||||
queryFn: () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ export function useTargetDetail() {
|
||||
? (dashboardData?.targets.find((target) => target.id === selectedTargetId) ?? null)
|
||||
: null;
|
||||
|
||||
const metrics = useTargetMetrics(selectedTargetId, timeFrom, timeTo, "1h");
|
||||
const metrics = useTargetMetrics(selectedTargetId, timeFrom, timeTo, "auto");
|
||||
|
||||
const history = useQuery({
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo && activeTab === "history",
|
||||
|
||||
@@ -135,6 +135,57 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trend-status-bar {
|
||||
display: flex;
|
||||
height: 6px;
|
||||
gap: 1px;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.trend-status-block {
|
||||
flex: 1;
|
||||
min-width: 2px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.trend-status-block--ok {
|
||||
background: var(--td-success-color);
|
||||
}
|
||||
|
||||
.trend-status-block--partial {
|
||||
background: var(--td-warning-color);
|
||||
}
|
||||
|
||||
.trend-status-block--down {
|
||||
background: var(--td-error-color);
|
||||
}
|
||||
|
||||
.trend-status-block--empty {
|
||||
background: var(--td-bg-color-component-disabled);
|
||||
}
|
||||
|
||||
.trend-tooltip {
|
||||
background: var(--td-bg-color-container);
|
||||
border: 1px solid var(--td-border-level-2-color);
|
||||
border-radius: var(--td-radius-default);
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--td-text-color-primary);
|
||||
box-shadow: var(--td-shadow-2);
|
||||
}
|
||||
|
||||
.trend-tooltip-time {
|
||||
color: var(--td-text-color-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.trend-tooltip-empty {
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
|
||||
.trend-loading,
|
||||
.trend-empty {
|
||||
padding: 24px;
|
||||
|
||||
Reference in New Issue
Block a user