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.
This commit is contained in:
justme committed 2026-10-21 16:59:13 +08:00
1 parent 08f261293c
commit 5245f03a78
2 files changed
+216 -5

No files matched your search

+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>
) : (