) => {
+ 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 ? (
+
+ {ticks.map((tick) => (
+
+ {tick.label}
+
+ ))}
+
+ ) : 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}
+
+ )
+}