web: show single-agent targets on the visitor page and open a detail page from the card [CI]
This commit is contained in:
1 parent
7ad226f29c
commit
caee03b8cc
7 files changed
+466
-3
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user