web: filter lines by agent with a searchable multi-select, and let the line cards adapt to their width [CI]
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:
1 parent
08f261293c
commit
5245f03a78
2 files changed
+216
-5
No files matched your search
+138
-1
@@ -2041,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 {
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user