2 Commits
Author SHA1 Message Date
justme 5245f03a78 web: filter lines by agent with a searchable multi-select, and let the line cards adapt to their width [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
ci/woodpecker/push/dev-backend-image Pipeline was successful
A line shows when either end is selected; the selection is kept in ?agents=. Cards use container queries to tighten the columns and drop the hint text when narrow.
2026-10-21 16:59:13 +08:00
justme 08f261293c web: equal-width cells, grey in-progress bucket and a time axis under each direction on the line cards
Rows are laid out as protocol, cells and value on one line per metric. Each direction gets its own axis with ticks on the hour; cells without data and the bucket still being filled are grey.
2026-10-21 16:59:13 +08:00
3 changed files with 343 additions and 53 deletions

No files matched your search

+53 -19
View File
@@ -1,4 +1,4 @@
import { useRef, useState, type MouseEvent } from 'react'
import { useRef, useState, type CSSProperties, type MouseEvent } from 'react'
import { api } from '@shared/api/client'
import type { SeriesDto } from '@shared/api/types'
import { fmtMs, fmtPct } from '@shared/format'
@@ -22,6 +22,24 @@ const levelOf = (loss: number | null | undefined): Level => (loss === null || lo
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 }[] {
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
const ticks: { pos: number; label: string }[] = []
for (let i = 0; i <= t.length; i++) {
const at = i < t.length ? t[i] : t[t.length - 1] + stepMin * 60_000
if (Math.round((at - offset) / 60_000) % interval !== 0) continue
const day = new Date(at)
const label = day.getHours() === 0 && day.getMinutes() === 0 ? `${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}` : hhmm(at)
ticks.push({ pos: (i / t.length) * 100, label })
}
return ticks
}
interface Props {
lineId: string
/** 拨号方和监听方的名字:去程是拨号方到监听方,回程反过来。 */
@@ -62,21 +80,37 @@ export function LineStrips({ lineId, dialer, listener, range, m }: Props) {
const lossDown = col('loss.down')
const retransUp = col('tcp.retrans.up')
const retransDown = col('tcp.retrans.down')
// 最后一格是还没走完的时间段:统计要等这一格结束才有值,不能当成「没有数据」
const lastIndex = t.length - 1
const pendingLast = Boolean(s?.edge_partial.last) && lastIndex >= 0 && [lossUp, lossDown, retransUp, retransDown].every((c) => c[lastIndex] === null || c[lastIndex] === undefined)
const ticks = axisTicks(t, info.stepMin, info.hours)
const lossText = (v: number | null | undefined) => (v === null || v === undefined ? '丢包 —' : `${fmtPct(v)} 丢包`)
const retransText = (v: number | null | undefined) => (v === null || v === undefined ? '重传 —' : `${fmtPct(v)} 重传`)
const cells = s ? Math.max(t.length, 1) : common.cells
const cellsOf = (values: (number | null)[]) => (s ? values : Array.from({ length: common.cells }, () => null))
// 一个方向下的一行:协议名、数值徽标和质量点阵。
const row = (id: string, proto: string, label: string, value: number | null | undefined, text: string, values: (number | null)[]) => (
// 一个方向下的一行:协议名、质量点阵和数值徽标,三列和同一方向的另一行对齐。
const row = (id: string, gridRow: number, proto: string, label: string, value: number | null | undefined, text: string, values: (number | null)[]) => (
<div className="line-mini-row" key={id}>
<div className="line-mini-direction-head" title={`${proto} ${label}`}>
<span>{proto}</span>
<span className={`line-mini-badge ${levelOf(value)}`}>{text}</span>
</div>
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的 ${proto} ${label}`}>
<span className="line-mini-proto" style={{ gridRow }} title={`${proto} ${label}`}>{proto}</span>
<div className="bars" style={{ gridRow, '--cells': cells } as CSSProperties} role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的 ${proto} ${label}`}>
{cellsOf(values).map((v, i) => (
<span key={`${id}-${t[i] ?? i}`} className={`bar ${s ? levelOf(v) : 'none'}${hover === i ? ' on' : ''}`} />
<span key={`${id}-${t[i] ?? i}`} className={`bar ${s ? levelOf(v) : 'none'}${pendingLast && i === lastIndex ? ' pending' : ''}${hover === i ? ' on' : ''}`} />
))}
</div>
<span className={`line-mini-badge ${levelOf(value)}`} style={{ gridRow }}>{text}</span>
</div>
)
const direction = (id: string, route: string, title: string, rows: ReturnType<typeof row>[]) => (
<div className="line-mini-direction" key={id}>
<div className="line-mini-route" title={title}>{route}</div>
{rows}
{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>
)
return (
@@ -89,16 +123,14 @@ export function LineStrips({ lineId, dialer, listener, range, m }: Props) {
<small>悬停看 p95 / p99</small>
{series.error ? <span className="line-mini-badge bad" title={`上一次取数失败:${series.error.message}`}>数据未更新</span> : null}
</div>
<div className="line-mini-direction">
<div className="line-mini-route" title={`去程:${dialer} → ${listener}`}>{dialer} → {listener}</div>
{row('up-quic', 'QUIC', '去程丢包', m['loss.up'], lossText(m['loss.up']), lossUp)}
{row('up-tcp', 'TCP', '去程重传', m['tcp.retrans.up'], retransText(m['tcp.retrans.up']), retransUp)}
</div>
<div className="line-mini-direction">
<div className="line-mini-route" title={`回程:${listener} → ${dialer}`}>{listener} → {dialer}</div>
{row('down-quic', 'QUIC', '回程丢包', m['loss.down'], lossText(m['loss.down']), lossDown)}
{row('down-tcp', 'TCP', '回程重传', m['tcp.retrans.down'], retransText(m['tcp.retrans.down']), retransDown)}
</div>
{direction('up', `${dialer} → ${listener}`, `去程:${dialer} → ${listener}`, [
row('up-quic', 2, 'QUIC', '去程丢包', m['loss.up'], lossText(m['loss.up']), lossUp),
row('up-tcp', 3, 'TCP', '去程重传', m['tcp.retrans.up'], retransText(m['tcp.retrans.up']), retransUp),
])}
{direction('down', `${listener} → ${dialer}`, `回程:${listener} → ${dialer}`, [
row('down-quic', 2, 'QUIC', '回程丢包', m['loss.down'], lossText(m['loss.down']), lossDown),
row('down-tcp', 3, 'TCP', '回程重传', m['tcp.retrans.down'], retransText(m['tcp.retrans.down']), retransDown),
])}
</div>
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
{at !== null ? (
@@ -106,6 +138,7 @@ export function LineStrips({ lineId, dialer, listener, range, m }: Props) {
<b className="pop-time">
{clock(t[at], info.hours >= 24)}–{clock(t[at] + info.stepMin * 60_000, info.hours >= 24)}
</b>
{pendingLast && at === lastIndex ? <span className="pop-note">这一格还没走完,结束后才有统计</span> : <>
<div className="pop-group">
<span className="pop-name">QUIC Datagram</span>
<span>RTT p50 <em>{fmtMs(col('rtt.p50')[at])}</em></span>
@@ -130,6 +163,7 @@ export function LineStrips({ lineId, dialer, listener, range, m }: Props) {
RTT p50 <em>{fmtMs(col('tcp.rtt.p50')[at])}</em>
</span>
</div>
</>}
</>
) : null}
</div>
+212 -30
View File
@@ -1374,6 +1374,11 @@
}
.line-mini-stack {
--lm-label: 32px;
--lm-badge: 78px;
--lm-gap: 8px;
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
@@ -1387,8 +1392,7 @@
line-height: 1.2;
}
.line-mini-quic-head > span,
.line-mini-direction-head > span:first-child {
.line-mini-quic-head > span {
color: var(--app-ink-muted);
font-size: 10px;
font-weight: 600;
@@ -1410,28 +1414,14 @@
.line-mini-direction {
display: grid;
gap: 3px;
grid-template-columns: var(--lm-label) minmax(0, 1fr) var(--lm-badge);
gap: 4px var(--lm-gap);
align-items: center;
min-width: 0;
}
.line-mini-direction-head {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
line-height: 1.25;
}
.line-mini-direction-head > span:first-child {
font-weight: 500;
white-space: nowrap;
}
.line-mini-direction-head .line-mini-badge {
margin-left: auto;
}
.line-mini-route {
grid-column: 1 / -1;
min-width: 0;
overflow: hidden;
color: var(--app-ink);
@@ -1442,9 +1432,59 @@
}
.line-mini-row {
display: grid;
gap: 3px;
min-width: 0;
display: contents;
}
.line-mini-proto {
grid-column: 1;
color: var(--app-ink-muted);
font-size: 10px;
font-weight: 600;
}
.line-mini-row .bars {
position: relative;
z-index: 1;
grid-column: 2;
}
.line-mini-row .line-mini-badge {
grid-column: 3;
justify-self: end;
}
.line-mini-axis {
position: relative;
grid-column: 2;
grid-row: 4;
height: 14px;
border-top: 1px solid var(--app-ink-faint);
color: var(--app-ink-muted);
font-size: 10px;
line-height: 1;
pointer-events: none;
}
.line-mini-axis > span {
position: absolute;
top: 0;
padding-top: 5px;
transform: translateX(-50%);
white-space: nowrap;
}
.line-mini-axis > span::before {
position: absolute;
top: -1px;
left: 50%;
height: 5px;
border-left: 1px solid var(--app-ink-muted);
content: '';
}
.pop-note {
color: var(--app-ink-muted);
font-size: 11px;
}
.line-mini-rtt {
@@ -1488,23 +1528,28 @@
}
.bars {
display: flex;
display: grid;
grid-template-columns: repeat(var(--cells, 36), minmax(0, 1fr));
min-width: 0;
gap: 1.5px;
height: 8px;
gap: 2px;
height: 10px;
}
/* 每格等宽等高;没有数据和还没走完的格子是灰色 */
.bar {
flex: 1;
min-width: 0;
border-radius: 2px;
background: var(--app-idle);
opacity: 0.35;
opacity: 0.4;
}
.bar.pending {
opacity: 0.4;
}
.bar.ok {
background: var(--app-ok);
opacity: 0.9;
opacity: 1;
}
.bar.warn {
@@ -1996,11 +2041,148 @@ input {
.line-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(100%, 440px), 1fr));
}
.agent-filter {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-width: 0;
}
.agent-filter-trigger,
.agent-filter-tag {
padding: 5px 12px;
border: 0;
border-radius: var(--r-pill);
background: var(--app-card-inset);
color: var(--app-ink-muted);
cursor: pointer;
font: inherit;
font-size: 13px;
}
.agent-filter-trigger.on,
.agent-filter-tag {
background: var(--app-accent-container);
color: var(--app-accent);
}
.agent-filter-tag {
max-width: 160px;
overflow: hidden;
padding: 4px 10px;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-filter-panel {
position: absolute;
z-index: 5;
top: calc(100% + 6px);
left: 0;
display: grid;
gap: 8px;
width: min(320px, 86vw);
padding: 10px;
border-radius: var(--r-control);
background: var(--app-card);
box-shadow: var(--glass-bar-drop);
}
.agent-filter-search {
width: 100%;
box-sizing: border-box;
padding: 6px 10px;
border: 1px solid var(--app-outline-soft);
border-radius: var(--r-control);
background: var(--app-card-inset);
color: var(--app-ink);
font: inherit;
font-size: 13px;
}
.agent-filter-actions {
display: flex;
gap: 8px;
}
.agent-filter-actions > button {
padding: 3px 10px;
border: 0;
border-radius: var(--r-pill);
background: var(--app-card-inset);
color: var(--app-ink-muted);
cursor: pointer;
font: inherit;
font-size: 12px;
}
.agent-filter-actions > button:disabled {
cursor: default;
opacity: 0.45;
}
.agent-filter-list {
display: grid;
max-height: 280px;
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
.agent-filter-list label {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 4px;
cursor: pointer;
font-size: 13px;
}
.agent-filter-list label > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.line-card {
padding: 10px 12px;
container-type: inline-size;
}
/* 卡片窄了就收紧:数值徽标和协议名的列变窄,去掉提示文字;再窄把 QUIC/TCP 并排的 RTT 换行 */
@container (width < 400px) {
.line-mini-stack {
--lm-label: 28px;
--lm-badge: 66px;
--lm-gap: 6px;
}
.line-mini-quic-head > small {
display: none;
}
}
@container (width < 320px) {
.line-mini-stack {
--lm-label: 0px;
--lm-badge: 58px;
}
.line-mini-proto {
display: none;
}
.line-mini-quic-head {
flex-wrap: wrap;
}
}
.line-card .line-name {
+78 -4
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { AgentIpv6Dto, NodeInfoDto, OverviewDto, OverviewLineDto, Page } from '@shared/api/types'
@@ -136,6 +136,58 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
)
}
/**
* 只看选中的 Agent:多选,一条线路的任意一端在选中里就显示;一个都不选就是全部。
* Agent 很多时用带搜索的下拉:输入名字过滤,「全选结果」一次勾上搜到的全部,已选的 Agent 在旁边显示成可以去掉的小标签。
*/
function AgentFilter({ options, selected, onChange }: { options: { id: string; name: string }[]; selected: string[]; onChange: (ids: string[]) => void }) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const box = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const outside = (event: MouseEvent) => { if (!box.current?.contains(event.target as Node)) setOpen(false) }
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', outside)
document.addEventListener('keydown', escape)
return () => { document.removeEventListener('mousedown', outside); document.removeEventListener('keydown', escape) }
}, [open])
const names = new Map(options.map((o) => [o.id, o.name]))
const chosen = selected.filter((id) => names.has(id))
const needle = query.trim().toLowerCase()
const visible = needle ? options.filter((o) => o.name.toLowerCase().includes(needle)) : options
const toggle = (id: string) => onChange(chosen.includes(id) ? chosen.filter((x) => x !== id) : [...chosen, id])
const addVisible = () => onChange([...new Set([...chosen, ...visible.map((o) => o.id)])])
return (
<div className="agent-filter" ref={box}>
<button type="button" className={`agent-filter-trigger${chosen.length > 0 ? ' on' : ''}`} aria-expanded={open} onClick={() => setOpen(!open)}>
Agent · {chosen.length === 0 ? '全部' : `已选 ${chosen.length}`} ▾
</button>
{chosen.slice(0, 3).map((id) => (
<button key={id} type="button" className="agent-filter-tag" title={`取消只看 ${names.get(id)}`} onClick={() => toggle(id)}>{names.get(id)} ×</button>
))}
{chosen.length > 3 ? <span className="faint">+{chosen.length - 3}</span> : null}
{open ? (
<div className="agent-filter-panel" role="dialog" aria-label="选择要看的 Agent">
<input className="agent-filter-search" type="search" placeholder={`搜索 ${options.length} 个 Agent`} value={query} onChange={(event) => setQuery(event.target.value)} autoFocus />
<div className="agent-filter-actions">
<button type="button" onClick={addVisible} disabled={visible.length === 0}>全选{needle ? '搜索结果' : ''}</button>
<button type="button" onClick={() => onChange([])} disabled={chosen.length === 0}>清空(看全部)</button>
</div>
<ul className="agent-filter-list">
{visible.map((o) => (
<li key={o.id}>
<label><input type="checkbox" checked={chosen.includes(o.id)} onChange={() => toggle(o.id)} /> <span title={o.name}>{o.name}</span></label>
</li>
))}
{visible.length === 0 ? <li className="faint">没有匹配的 Agent</li> : null}
</ul>
</div>
) : null}
</div>
)
}
/** 一条线路(拨号方 ⇄ 监听方):RTT 是往返;下面每个方向一行,写明是 A → B 还是 B → A(A 是拨号方)。QUIC 看两个方向的丢包,TCP 看两个方向的重传。 */
function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
const dialerInfo = infos.get(l.dialer_id)
@@ -184,6 +236,18 @@ export function Overview() {
const [params, setParams] = useSearchParams()
const ipVersion = ipVersionOf(params.get('ip_version'))
const ipv6 = useQuery(() => ipVersion === 'ipv6' ? api.get<Page<AgentIpv6Dto>>('/api/public/ipv6') : Promise.resolve(undefined), [ipVersion], 15_000)
// 只看选中的 Agent:记在链接里(?agents=agt_a,agt_b),刷新和分享都保留
const selectedAgents = useMemo(() => (params.get('agents') ?? '').split(',').filter(Boolean), [params])
const setSelectedAgents = (ids: string[]) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev)
if (ids.length === 0) next.delete('agents')
else next.set('agents', ids.join(','))
return next
},
{ replace: true },
)
const range: OverviewRange = OVERVIEW_RANGES.find((r) => r.value === params.get('range'))?.value ?? '3h'
const setRange = (v: OverviewRange) =>
setParams(
@@ -207,7 +271,7 @@ export function Overview() {
}, [hosts, data])
// 线路面板:按 IP 版本过滤,再排序
const lines = useMemo(() => {
const picked = (data?.lines ?? []).filter((l) => ipVersionOf(l.ip_version) === ipVersion)
const picked = (data?.lines ?? []).filter((l) => ipVersionOf(l.ip_version) === ipVersion && (selectedAgents.length === 0 || selectedAgents.includes(l.dialer_id) || selectedAgents.includes(l.agent_id)))
const key = sort === 'rtt' ? 'rtt.p50' : 'loss.total'
return picked.sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1
@@ -220,7 +284,16 @@ export function Overview() {
if (y === null || y === undefined) return -1
return x - y
})
}, [data, sort, ipVersion])
}, [data, sort, ipVersion, selectedAgents])
// 筛选用的 Agent 列表:所有出现在线路两端的 Agent,按名字排
const agentOptions = useMemo(() => {
const byId = new Map<string, string>()
for (const l of data?.lines ?? []) {
byId.set(l.dialer_id, l.dialer_name)
byId.set(l.agent_id, l.name)
}
return [...byId].map(([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
}, [data])
// 按 来源 Agent 的运营商分组(固定顺序,没有线路的组不显示)
const groups = useMemo(
() => CARRIERS.map((c) => ({ ...c, lines: lines.filter((l) => carrierOf(infos.get(l.dialer_id)) === c.key) })).filter((g) => g.lines.length > 0),
@@ -262,6 +335,7 @@ export function Overview() {
<div className="toolbar lines-toolbar">
<IpVersionPicker value={ipVersion} onChange={(v) => { const next = new URLSearchParams(params); next.set('ip_version', v); setCarrier(''); setParams(next, { replace: true }) }} />
{groups.length > 1 ? <Segmented<'' | CarrierKey> options={[{ value: '', label: '全部' }, ...groups.map((g) => ({ value: g.key, label: g.name }))]} value={carrier} onChange={setCarrier} /> : null}
{agentOptions.length > 1 ? <AgentFilter options={agentOptions} selected={selectedAgents} onChange={setSelectedAgents} /> : null}
<span className="right row">
<Segmented<OverviewRange> options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: r.label }))} value={range} onChange={setRange} />
<Segmented
@@ -278,7 +352,7 @@ export function Overview() {
{data ? (
lines.length === 0 ? (
<div>
<Empty>{ipVersion === 'ipv6' ? '还没有 IPv6 线路' : '还没有线路'}</Empty>
<Empty>{selectedAgents.length > 0 ? '没有和所选 Agent 有关的线路' : ipVersion === 'ipv6' ? '还没有 IPv6 线路' : '还没有线路'}</Empty>
{ipVersion === 'ipv6' ? (ipv6.data?.items ?? []).map((a) => <p key={a.agent_id} className="muted">{a.name}:{IPV6_STATES[a.ipv6.state] ?? '状态未知'}{!a.ipv6.address_configured && a.ipv6.state !== 'address_not_configured' ? ';面板也尚未配置 IPv6 地址' : ''}{!a.online ? '(Agent 离线,显示上次诊断)' : ''}</p>) : null}
</div>
) : (