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

+138 -1
View File
@@ -2041,11 +2041,148 @@ input {
.line-grid { .line-grid {
display: grid; display: grid;
gap: 12px; 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 { .line-card {
padding: 10px 12px; 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 { .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 { Link, useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client' import { api } from '@shared/api/client'
import type { AgentIpv6Dto, NodeInfoDto, OverviewDto, OverviewLineDto, Page } from '@shared/api/types' 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 看两个方向的重传。 */ /** 一条线路(拨号方 ⇄ 监听方):RTT 是往返;下面每个方向一行,写明是 A → B 还是 B → A(A 是拨号方)。QUIC 看两个方向的丢包,TCP 看两个方向的重传。 */
function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map<string, NodeInfoDto> }) { function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
const dialerInfo = infos.get(l.dialer_id) const dialerInfo = infos.get(l.dialer_id)
@@ -184,6 +236,18 @@ export function Overview() {
const [params, setParams] = useSearchParams() const [params, setParams] = useSearchParams()
const ipVersion = ipVersionOf(params.get('ip_version')) 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) 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 range: OverviewRange = OVERVIEW_RANGES.find((r) => r.value === params.get('range'))?.value ?? '3h'
const setRange = (v: OverviewRange) => const setRange = (v: OverviewRange) =>
setParams( setParams(
@@ -207,7 +271,7 @@ export function Overview() {
}, [hosts, data]) }, [hosts, data])
// 线路面板:按 IP 版本过滤,再排序 // 线路面板:按 IP 版本过滤,再排序
const lines = useMemo(() => { 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' const key = sort === 'rtt' ? 'rtt.p50' : 'loss.total'
return picked.sort((a, b) => { return picked.sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1 if (a.online !== b.online) return a.online ? -1 : 1
@@ -220,7 +284,16 @@ export function Overview() {
if (y === null || y === undefined) return -1 if (y === null || y === undefined) return -1
return x - y 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 的运营商分组(固定顺序,没有线路的组不显示) // 按 来源 Agent 的运营商分组(固定顺序,没有线路的组不显示)
const groups = useMemo( 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), () => 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"> <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 }) }} /> <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} {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"> <span className="right row">
<Segmented<OverviewRange> options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: r.label }))} value={range} onChange={setRange} /> <Segmented<OverviewRange> options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: r.label }))} value={range} onChange={setRange} />
<Segmented <Segmented
@@ -278,7 +352,7 @@ export function Overview() {
{data ? ( {data ? (
lines.length === 0 ? ( lines.length === 0 ? (
<div> <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} {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> </div>
) : ( ) : (