web: show single-agent targets on the visitor page and open a detail page from the card [CI]
ci/woodpecker/push/dev-backend-image Pipeline is running
ci/woodpecker/push/dev-image Pipeline is running
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful

This commit is contained in:
justme committed 2026-10-22 07:02:31 +08:00
1 parent 7ad226f29c
commit caee03b8cc
7 files changed
+466 -3

No files matched your search

+1 -1
View File
@@ -349,7 +349,7 @@ export function Line() {
))}
</div> : null}
<div className="line-data-notes">
<span>样本数:QUIC Datagram {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}</span>
<span>样本数:QUIC Datagram {fmtNum(s.summary.samples['quic_ping'])} · TCP {fmtNum(s.summary.samples['tcp_ping'])}</span>
{s.summary.coverage < 0.95 ? <span>数据覆盖率 {(s.summary.coverage * 100).toFixed(0)}%;阴影部分为无数据时段</span> : null}
</div>
{chartSections}
+3
View File
@@ -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}
</section>
<TargetCards range={range} />
</main>
)
}
+169
View File
@@ -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<string, ChartDef[]> = {
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<Page<TargetDto>>('/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 (
<main className="page line-page target-page">
<div className="crumbs">
<Link to="/" className="row">
<ChevronLeft size={16} />
总览
</Link>
</div>
<PageError error={query.error} />
<section className="hero">
<h1>{view ? view.name : '外部目标'}</h1>
{t ? (
<p className="sub">
<span className="mono">{t.target}</span> · {t.agent_name}
{status ? (
<>
{' '}
<Chip tone={status.tone}>{status.label}</Chip>
</>
) : null}
{t.last_sample ? <span className="faint"> · 最后一次数据 {fmtAgo(t.last_sample)}({fmtTime(t.last_sample)})</span> : null}
</p>
) : query.loading ? (
<p className="sub">
<Spinner />
</p>
) : (
<p className="sub">没有找到这个目标:任务可能已被删除,或这个 Agent 不再执行它。</p>
)}
</section>
{t?.status === 'rejected' ? <p className="muted">{rejectText(t.reason)}</p> : null}
{t && view ? (
<>
<div className="toolbar">
<Segmented<OverviewRange>
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 },
)
}
/>
</div>
<div className="card">
<div className="grid stats">
{view.figures.map((f) => (
<Stat key={f.key} label={f.label} value={f.fmt(metrics[f.key])} tone={f.key === view.bar ? (({ ok: 'ok', warn: 'warn', bad: 'bad', none: '' }) as const)[levelOf(view, metrics[f.key])] : ''} />
))}
{Object.entries(t.summary.samples)[0] ? <Stat label="请求数" value={fmtNum(Object.values(t.summary.samples)[0])} /> : null}
</div>
</div>
{failures.length > 0 ? (
<section className="card target-failures">
<h3>失败原因({info.label})</h3>
{failures.map(([key, label]) => (
<p key={key}>
{label}:<b className="bad-text">{metrics[key]} 次</b>
</p>
))}
</section>
) : null}
<section className="chart-section">
<div className="grid cols-2">
{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 }) => (
<div className="card chart-card has-loading" key={def.id}>
<LoadingBar active={query.loading} />
<h3>{def.title}</h3>
<TimeChart t={t.t} series={series} unit={def.unit} zero height={200} />
</div>
))}
</div>
</section>
</>
) : null}
</main>
)
}