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
@@ -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() {
|
||||
<Route path="/" element={<Overview />} />
|
||||
<Route path="/line/:id" element={<LineRoute />} />
|
||||
<Route path="/host/:id" element={<Host />} />
|
||||
<Route path="/target/:agent/:task" element={<Target />} />
|
||||
<Route path="/compare" element={<Compare />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, KindView> = {
|
||||
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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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<number | null>(null)
|
||||
const last = useRef<number | null>(null) // 提示收回去的动画期间,内容还要留着
|
||||
if (hover !== null) last.current = hover
|
||||
const shown = hover ?? last.current
|
||||
const box = useRef<HTMLAnchorElement>(null)
|
||||
const onMove = (e: MouseEvent<HTMLAnchorElement>) => {
|
||||
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 (
|
||||
<Link to={`/target/${encodeURIComponent(t.agent_id)}/${encodeURIComponent(t.task_id)}?range=${range.value}`} className="card target-card" ref={box} onMouseMove={onMove} onMouseLeave={() => setHover(null)} aria-label={`查看 ${t.agent_name} 对 ${t.target} 的详情`}>
|
||||
<div className="target-head">
|
||||
<b>{view.name}</b>
|
||||
<span className="muted target-agent">{t.agent_name}</span>
|
||||
<span className="right">
|
||||
<Chip tone={status.tone}>{status.label}</Chip>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mono target-url" title={t.target}>
|
||||
{t.target}
|
||||
</div>
|
||||
{t.status === 'rejected' ? <p className="muted target-note">{rejectText(t.reason)}</p> : null}
|
||||
{t.status === 'waiting' || t.status === 'stale' ? <p className="muted target-note">{status.hint}</p> : null}
|
||||
{t.last_sample && t.status !== 'rejected' ? <p className="faint target-note">最后一次数据:{fmtAgo(t.last_sample)}</p> : null}
|
||||
{view.figures.length > 0 && t.status !== 'rejected' ? (
|
||||
<div className="target-figures">
|
||||
{view.figures.map((f) => (
|
||||
<span key={f.key}>
|
||||
<span className="faint">{f.label}</span> <b className="num">{f.fmt(t.summary.metrics[f.key])}</b>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{view.bar && t.status !== 'rejected' ? (
|
||||
<div className="target-bars">
|
||||
<div className="bars" style={{ ['--cells' as string]: cells }} role="img" aria-label={`${view.name},${range.label}`}>
|
||||
{(bar.length ? bar : Array.from({ length: cells }, () => null)).map((v, i) => (
|
||||
<span key={t.t[i] ?? i} className={`bar ${levelOf(view, v)}${hover === i ? ' on' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
{ticks.length > 0 ? (
|
||||
<div className="line-mini-axis" aria-hidden="true">
|
||||
{ticks.map((tick) => (
|
||||
<span key={tick.pos} style={{ left: `${tick.pos}%` }}>
|
||||
{tick.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
|
||||
{at !== null ? (
|
||||
<>
|
||||
<b className="pop-time">
|
||||
{clock(t.t[at], range.hours >= 24)}–{clock(t.t[at] + range.stepMin * 60_000, range.hours >= 24)}
|
||||
</b>
|
||||
<div className="pop-group">
|
||||
<span className="pop-name">{view.name}</span>
|
||||
{view.figures.map((f) => (
|
||||
<span key={f.key}>
|
||||
{f.label} <em className={f.key === view.bar ? levelOf(view, cellValue(f.key, at)) : ''}>{f.fmt(cellValue(f.key, at))}</em>
|
||||
</span>
|
||||
))}
|
||||
{Object.entries(FAILURES)
|
||||
.filter(([key]) => (cellValue(key, at) ?? 0) > 0)
|
||||
.map(([key, label]) => (
|
||||
<span key={key} className="pop-fail">
|
||||
<span>{SHORT_FAILURES[key] ?? label}</span>
|
||||
<em className="bad">{cellValue(key, at)}</em>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/** 单个 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<Page<TargetDto>>('/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 (
|
||||
<section className="card agent-card targets-panel">
|
||||
<div className="agent-head">
|
||||
<span className="agent-name">外部目标</span>
|
||||
<span className="faint">
|
||||
{items.length} 个 · {info.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="target-grid">
|
||||
{items.map((t) => (
|
||||
<TargetCard key={`${t.agent_id}/${t.task_id}`} target={t} range={info} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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