From caee03b8cca3cfc3e7047219f6904380260b893a Mon Sep 17 00:00:00 2001 From: justme Date: Thu, 22 Oct 2026 07:02:31 +0800 Subject: [PATCH] web: show single-agent targets on the visitor page and open a detail page from the card [CI] --- web/display/src/App.tsx | 2 + web/display/src/LineStrips.tsx | 4 +- web/display/src/TargetCards.tsx | 199 +++++++++++++++++++++++++++++ web/display/src/display.css | 90 +++++++++++++ web/display/src/pages/Line.tsx | 2 +- web/display/src/pages/Overview.tsx | 3 + web/display/src/pages/Target.tsx | 169 ++++++++++++++++++++++++ 7 files changed, 466 insertions(+), 3 deletions(-) create mode 100644 web/display/src/TargetCards.tsx create mode 100644 web/display/src/pages/Target.tsx diff --git a/web/display/src/App.tsx b/web/display/src/App.tsx index b2d1895..15e82ff 100644 --- a/web/display/src/App.tsx +++ b/web/display/src/App.tsx @@ -5,6 +5,7 @@ import { NavLink, Navigate, Route, Routes, useLocation, useParams } from 'react- import { SourceButton, ThemeToggle } from '@shared/ui' import { Compare } from './pages/Compare' import { Line } from './pages/Line' +import { Target } from './pages/Target' import { Host } from './pages/Host' import { Overview } from './pages/Overview' import { useBackgroundTint } from './backgroundTint' @@ -53,6 +54,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/web/display/src/LineStrips.tsx b/web/display/src/LineStrips.tsx index 1d378b4..2420e45 100644 --- a/web/display/src/LineStrips.tsx +++ b/web/display/src/LineStrips.tsx @@ -20,12 +20,12 @@ type Level = 'ok' | 'warn' | 'bad' | 'none' const levelOf = (loss: number | null | undefined): Level => (loss === null || loss === undefined ? 'none' : loss <= 0.5 ? 'ok' : loss < 3 ? 'warn' : 'bad') -const clock = (ms: number, showDate: boolean) => new Date(ms).toLocaleString('zh-CN', { ...(showDate ? { month: '2-digit', day: '2-digit' } : {}), hour: '2-digit', minute: '2-digit', hour12: false }) +export const clock = (ms: number, showDate: boolean) => new Date(ms).toLocaleString('zh-CN', { ...(showDate ? { month: '2-digit', day: '2-digit' } : {}), hour: '2-digit', minute: '2-digit', hour12: false }) const hhmm = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) /** 时间轴刻度:在整点(或整半点、整几小时)的格子边界上画一条细线并标时间,最多约 6 个;零点那条标日期。 */ -function axisTicks(t: number[], stepMin: number, hours: number): { pos: number; label: string }[] { +export function axisTicks(t: number[], stepMin: number, hours: number): { pos: number; label: string }[] { if (t.length === 0) return [] const interval = [30, 60, 120, 180, 360, 720, 1440].find((m) => (hours * 60) / m <= 6) ?? 1440 const offset = new Date(t[0]).getTimezoneOffset() * 60_000 diff --git a/web/display/src/TargetCards.tsx b/web/display/src/TargetCards.tsx new file mode 100644 index 0000000..0c11da2 --- /dev/null +++ b/web/display/src/TargetCards.tsx @@ -0,0 +1,199 @@ +import { useRef, useState, type MouseEvent } from 'react' +import { Link } from 'react-router-dom' +import { api } from '@shared/api/client' +import type { Page, TargetDto } from '@shared/api/types' +import { fmtAgo, fmtMs, fmtPct } from '@shared/format' +import { useQuery } from '@shared/hooks' +import { TARGET_STATUS, rejectText } from '@shared/targets' +import { Chip } from '@shared/ui' +import { OVERVIEW_RANGES, axisTicks, clock, type OverviewRange } from './LineStrips' + +type Level = 'ok' | 'warn' | 'bad' | 'none' + +/** 每种方法在卡片上怎么看:用哪个指标画格子、怎么判断好坏,以及要显示的几个数。 */ +export interface KindView { + name: string + bar: string + /** 值越大越好(成功率)还是越小越好(丢包) */ + higherIsBetter: boolean + figures: { key: string; label: string; fmt: (v: number | null | undefined) => string }[] +} + +const ms = (v: number | null | undefined) => fmtMs(v) +const pct = (v: number | null | undefined) => fmtPct(v) + +export const KIND_VIEWS: Record = { + http_request: { + name: 'HTTP 请求', + bar: 'http.success', + higherIsBetter: true, + figures: [ + { key: 'http.success', label: '成功率', fmt: pct }, + { key: 'http.ttfb_ms.p50', label: '首字节 p50', fmt: ms }, + { key: 'http.ttfb_ms.p95', label: 'p95', fmt: ms }, + { key: 'http.total_ms.p95', label: '总耗时 p95', fmt: ms }, + ], + }, + tcp_connect: { + name: 'TCP 建连', + bar: 'connect.success', + higherIsBetter: true, + figures: [ + { key: 'connect.success', label: '成功率', fmt: pct }, + { key: 'connect.tcp_ms.p95', label: 'TCP p95', fmt: ms }, + { key: 'connect.tls_ms.p95', label: 'TLS p95', fmt: ms }, + ], + }, + icmp_ping: { + name: 'ICMP 往返', + bar: 'loss.total', + higherIsBetter: false, + figures: [ + { key: 'rtt.p50', label: 'RTT p50', fmt: ms }, + { key: 'rtt.p95', label: 'p95', fmt: ms }, + { key: 'loss.total', label: '丢包', fmt: pct }, + ], + }, +} + +export const levelOf = (view: KindView, v: number | null | undefined): Level => { + if (v === null || v === undefined) return 'none' + if (view.higherIsBetter) return v >= 99 ? 'ok' : v >= 90 ? 'warn' : 'bad' + return v <= 0.5 ? 'ok' : v < 3 ? 'warn' : 'bad' +} + +/** 失败的原因分布:只列出出现过的。 */ +export const FAILURES: Record = { + 'http.fail.timeout': '超时', + 'http.fail.dns': 'DNS 失败', + 'http.fail.rst': '连接被重置或拒绝', + 'http.fail.tls': 'TLS 失败', + 'http.fail.status': '状态码不合格', + 'http.fail.unsupported': '不支持所选 HTTP 版本(HTTP/3 需要系统装有 MsQuic,且服务器支持)', + 'http.fail.other': '其他错误', +} + +/** 悬浮面板里宽度有限,用短名字;完整说明在详情页。 */ +const SHORT_FAILURES: Record = { + 'http.fail.timeout': '超时', + 'http.fail.dns': 'DNS 失败', + 'http.fail.rst': '被重置或拒绝', + 'http.fail.tls': 'TLS 失败', + 'http.fail.status': '状态码不合格', + 'http.fail.unsupported': '版本不支持', + 'http.fail.other': '其他错误', +} + +function TargetCard({ target: t, range }: { target: TargetDto; range: (typeof OVERVIEW_RANGES)[number] }) { + const view = KIND_VIEWS[t.kind] ?? { name: t.kind, bar: '', higherIsBetter: true, figures: [] } + const status = TARGET_STATUS[t.status] + const bar = t.values[view.bar] ?? [] + const cells = bar.length || 36 + const ticks = axisTicks(t.t, range.stepMin, range.hours) + // 悬停的那一格:和线路卡片一样,按鼠标横向位置对应到采样格,弹出这一格的数值 + const [hover, setHover] = useState(null) + const last = useRef(null) // 提示收回去的动画期间,内容还要留着 + if (hover !== null) last.current = hover + const shown = hover ?? last.current + const box = useRef(null) + const onMove = (e: MouseEvent) => { + const bars = box.current?.querySelector('.bars') + if (!bars || t.t.length === 0) return + const r = bars.getBoundingClientRect() + setHover(Math.min(t.t.length - 1, Math.max(0, Math.floor(((e.clientX - r.left) / r.width) * t.t.length)))) + } + const at = shown !== null && t.t[shown] !== undefined ? shown : null + const cellValue = (key: string, i: number) => t.values[key]?.[i] + return ( + setHover(null)} aria-label={`查看 ${t.agent_name} 对 ${t.target} 的详情`}> +
+ {view.name} + {t.agent_name} + + {status.label} + +
+
+ {t.target} +
+ {t.status === 'rejected' ?

{rejectText(t.reason)}

: null} + {t.status === 'waiting' || t.status === 'stale' ?

{status.hint}

: null} + {t.last_sample && t.status !== 'rejected' ?

最后一次数据:{fmtAgo(t.last_sample)}

: null} + {view.figures.length > 0 && t.status !== 'rejected' ? ( +
+ {view.figures.map((f) => ( + + {f.label} {f.fmt(t.summary.metrics[f.key])} + + ))} +
+ ) : null} + {view.bar && t.status !== 'rejected' ? ( +
+
+ {(bar.length ? bar : Array.from({ length: cells }, () => null)).map((v, i) => ( + + ))} +
+ {ticks.length > 0 ? ( + + ) : null} +
+ ) : null} +
+ {at !== null ? ( + <> + + {clock(t.t[at], range.hours >= 24)}–{clock(t.t[at] + range.stepMin * 60_000, range.hours >= 24)} + +
+ {view.name} + {view.figures.map((f) => ( + + {f.label} {f.fmt(cellValue(f.key, at))} + + ))} + {Object.entries(FAILURES) + .filter(([key]) => (cellValue(key, at) ?? 0) > 0) + .map(([key, label]) => ( + + {SHORT_FAILURES[key] ?? label} + {cellValue(key, at)} + + ))} +
+ + ) : null} +
+ + ) +} + +/** 单个 Agent 任务(Agent 自己测外部地址)的卡片:目标、状态、关键数字和一排质量格子。没有这类任务时不显示。 */ +export function TargetCards({ range }: { range: OverviewRange }) { + const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0] + const targets = useQuery(() => api.get>('/api/public/targets', { range: info.value, step: info.step }), [info.value], 15_000) + const items = targets.data?.items ?? [] + if (items.length === 0) return null + return ( +
+
+ 外部目标 + + {items.length} 个 · {info.label} + +
+
+ {items.map((t) => ( + + ))} +
+
+ ) +} diff --git a/web/display/src/display.css b/web/display/src/display.css index e692250..1916f5f 100644 --- a/web/display/src/display.css +++ b/web/display/src/display.css @@ -2588,3 +2588,93 @@ input { 0 0 0 2px var(--app-primary), 0 0 0 4px color-mix(in srgb, var(--app-primary) 25%, transparent); } + +.targets-panel { + margin-top: 16px; +} + +.target-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); + gap: 12px; + margin-top: 12px; +} + +.target-card { + display: grid; + gap: 6px; + padding: 12px 14px; +} + +.target-head { + display: flex; + align-items: center; + gap: 8px; +} + +.target-agent { + font-size: 13px; +} + +.target-url { + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.target-note { + margin: 0; + font-size: 12px; +} + +.target-figures { + display: flex; + flex-wrap: wrap; + gap: 2px 14px; + font-size: 13px; +} + +.bad-text { + color: var(--app-bad); +} + +.target-bars { + display: grid; + gap: 3px; +} + +/* 时间轴原本是线路卡片网格里的第 2 列,这里是普通的一行 */ +.target-bars .line-mini-axis { + grid-column: auto; + grid-row: auto; +} + +.target-card { + position: relative; +} + +.target-card:has(.pop.open) { + z-index: 30; +} + +/* 整张卡片是链接:悬停时也不要下划线(全局的 a:hover 会加) */ +.target-card, +.target-card:hover { + color: inherit; + text-decoration: none; +} + +/* 弹出面板是固定宽度、不换行的;失败原因的名字可能很长,允许它换行,次数靠右 */ +.pop .pop-fail { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + white-space: normal; +} + +/* 「这一格还没走完」这类整句提示:面板不换行,长句会冲出去,这里允许换行 */ +.pop .pop-note { + white-space: normal; +} diff --git a/web/display/src/pages/Line.tsx b/web/display/src/pages/Line.tsx index 9b0ce0c..afb43cb 100644 --- a/web/display/src/pages/Line.tsx +++ b/web/display/src/pages/Line.tsx @@ -349,7 +349,7 @@ export function Line() { ))} : null}
- 样本数:QUIC Datagram {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])} + 样本数:QUIC Datagram {fmtNum(s.summary.samples['quic_ping'])} · TCP {fmtNum(s.summary.samples['tcp_ping'])} {s.summary.coverage < 0.95 ? 数据覆盖率 {(s.summary.coverage * 100).toFixed(0)}%;阴影部分为无数据时段 : null}
{chartSections} diff --git a/web/display/src/pages/Overview.tsx b/web/display/src/pages/Overview.tsx index ec94d3a..a2f89bd 100644 --- a/web/display/src/pages/Overview.tsx +++ b/web/display/src/pages/Overview.tsx @@ -11,6 +11,7 @@ import { OsIcon } from '@shared/osIcon' import { PageError } from '../lib' import { IPV6_STATES, IpVersionPicker, ipVersionOf } from '@shared/ipVersions' import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips' +import { TargetCards } from '../TargetCards' const METRICS = ['rtt.p50', 'rtt.p95', 'rtt.p99', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans', 'tcp.retrans.up', 'tcp.retrans.down'].join(',') @@ -381,6 +382,8 @@ export function Overview() { ) ) : null} + + ) } diff --git a/web/display/src/pages/Target.tsx b/web/display/src/pages/Target.tsx new file mode 100644 index 0000000..ff7dffa --- /dev/null +++ b/web/display/src/pages/Target.tsx @@ -0,0 +1,169 @@ +import { ChevronLeft } from 'lucide-react' +import { Link, useParams, useSearchParams } from 'react-router-dom' +import { api } from '@shared/api/client' +import type { Page, TargetDto } from '@shared/api/types' +import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart' +import { fmtAgo, fmtNum, fmtTime } from '@shared/format' +import { useQuery } from '@shared/hooks' +import { TARGET_STATUS, rejectText } from '@shared/targets' +import { Chip, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui' +import { PageError } from '../lib' +import { OVERVIEW_RANGES, type OverviewRange } from '../LineStrips' +import { FAILURES, KIND_VIEWS, levelOf } from '../TargetCards' + +interface ChartDef { + id: string + title: string + unit: string + /** 失败计数这类图:只在有过失败时显示 */ + onlyIfAny?: boolean + series: { key: string; label: string; color: string; dash?: number[] }[] +} + +/** 每种方法的图:卡片上只有一排格子,这里把每个指标画成曲线。 */ +const CHARTS: Record = { + http_request: [ + { id: 'success', title: '成功率', unit: '%', series: [{ key: 'http.success', label: '成功率', color: '--chart-1' }] }, + { id: 'ttfb', title: '首字节时间', unit: 'ms', series: [{ key: 'http.ttfb_ms.p50', label: 'p50', color: '--chart-1' }, { key: 'http.ttfb_ms.p95', label: 'p95', color: '--chart-2' }, { key: 'http.ttfb_ms.p99', label: 'p99', color: '--chart-3' }] }, + { id: 'total', title: '总耗时', unit: 'ms', series: [{ key: 'http.total_ms.p50', label: 'p50', color: '--chart-1' }, { key: 'http.total_ms.p95', label: 'p95', color: '--chart-2' }, { key: 'http.total_ms.p99', label: 'p99', color: '--chart-3' }] }, + { id: 'phases', title: 'DNS 和 TCP 建连(p95)', unit: 'ms', series: [{ key: 'http.dns_ms.p95', label: 'DNS', color: '--chart-1' }, { key: 'http.tcp_ms.p95', label: 'TCP 建连', color: '--chart-2' }] }, + { + id: 'fails', + title: '失败次数', + unit: '', + onlyIfAny: true, + series: Object.keys(FAILURES).map((key, i) => ({ key, label: FAILURES[key].split('(')[0], color: `--chart-${(i % 5) + 1}` })), + }, + ], + tcp_connect: [ + { id: 'success', title: '成功率', unit: '%', series: [{ key: 'connect.success', label: '成功率', color: '--chart-1' }] }, + { id: 'phases', title: '建连耗时(p95)', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS', color: '--chart-2' }] }, + { + id: 'fails', + title: '失败次数', + unit: '', + onlyIfAny: true, + series: [{ key: 'connect.fail.timeout', label: '超时', color: '--chart-1' }, { key: 'connect.fail.rst', label: '被重置或拒绝', color: '--chart-2' }, { key: 'connect.fail.tls', label: 'TLS 失败', color: '--chart-3' }], + }, + ], + icmp_ping: [ + { id: 'rtt', title: '往返时延', unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] }, + { id: 'loss', title: '丢包', unit: '%', series: [{ key: 'loss.total', label: '丢包', color: '--chart-1' }] }, + ], +} + +/** 单个 Agent 任务的详情:一个 Agent 对一个外部目标的状态、关键数字、各指标曲线和失败原因。 */ +export function Target() { + const { agent = '', task = '' } = useParams() + const [params, setParams] = useSearchParams() + const range: OverviewRange = OVERVIEW_RANGES.find((r) => r.value === params.get('range'))?.value ?? '3h' + const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0] + const query = useQuery( + () => api.get>('/api/public/targets', { agent_id: agent, task_id: task, full: 'true', range: info.value, step: info.step }), + [agent, task, info.value], + 15_000, + ) + const t = query.data?.items[0] + const view = t ? (KIND_VIEWS[t.kind] ?? { name: t.kind, bar: '', higherIsBetter: true, figures: [] }) : null + const status = t ? TARGET_STATUS[t.status] : null + const charts = t ? (CHARTS[t.kind] ?? []) : [] + const metrics = t?.summary.metrics ?? {} + const failures = Object.entries(FAILURES).filter(([key]) => (metrics[key] ?? 0) > 0) + + return ( +
+
+ + + 总览 + +
+ + + +
+

{view ? view.name : '外部目标'}

+ {t ? ( +

+ {t.target} · {t.agent_name} + {status ? ( + <> + {' '} + {status.label} + + ) : null} + {t.last_sample ? · 最后一次数据 {fmtAgo(t.last_sample)}({fmtTime(t.last_sample)}) : null} +

+ ) : query.loading ? ( +

+ +

+ ) : ( +

没有找到这个目标:任务可能已被删除,或这个 Agent 不再执行它。

+ )} +
+ + {t?.status === 'rejected' ?

{rejectText(t.reason)}

: null} + + {t && view ? ( + <> +
+ + options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: r.label }))} + value={range} + onChange={(v) => + setParams( + (prev) => { + const next = new URLSearchParams(prev) + if (v === '3h') next.delete('range') + else next.set('range', v) + return next + }, + { replace: true }, + ) + } + /> +
+ +
+
+ {view.figures.map((f) => ( + + ))} + {Object.entries(t.summary.samples)[0] ? : null} +
+
+ + {failures.length > 0 ? ( +
+

失败原因({info.label})

+ {failures.map(([key, label]) => ( +

+ {label}:{metrics[key]} 次 +

+ ))} +
+ ) : null} + +
+
+ {charts + .map((def) => { + const series: ChartSeries[] = def.series.map((x) => ({ label: x.label, values: t.values[x.key] ?? [], color: x.color, dash: x.dash })) + return { def, series } + }) + .filter(({ def, series }) => series.some((x) => x.values.some((v) => v !== null && (!def.onlyIfAny || v > 0)))) + .map(({ def, series }) => ( +
+ +

{def.title}

+ +
+ ))} +
+
+ + ) : null} +
+ ) +}