diff --git a/web/display/src/display.css b/web/display/src/display.css index a7d7503..e692250 100644 --- a/web/display/src/display.css +++ b/web/display/src/display.css @@ -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 { diff --git a/web/display/src/pages/Overview.tsx b/web/display/src/pages/Overview.tsx index 946ad1b..faa10d0 100644 --- a/web/display/src/pages/Overview.tsx +++ b/web/display/src/pages/Overview.tsx @@ -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(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 ( +
+ + {chosen.slice(0, 3).map((id) => ( + + ))} + {chosen.length > 3 ? +{chosen.length - 3} : null} + {open ? ( +
+ setQuery(event.target.value)} autoFocus /> +
+ + +
+
    + {visible.map((o) => ( +
  • + +
  • + ))} + {visible.length === 0 ?
  • 没有匹配的 Agent
  • : null} +
+
+ ) : null} +
+ ) +} + /** 一条线路(拨号方 ⇄ 监听方):RTT 是往返;下面每个方向一行,写明是 A → B 还是 B → A(A 是拨号方)。QUIC 看两个方向的丢包,TCP 看两个方向的重传。 */ function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map }) { 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>('/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() + 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() {
{ const next = new URLSearchParams(params); next.set('ip_version', v); setCarrier(''); setParams(next, { replace: true }) }} /> {groups.length > 1 ? options={[{ value: '', label: '全部' }, ...groups.map((g) => ({ value: g.key, label: g.name }))]} value={carrier} onChange={setCarrier} /> : null} + {agentOptions.length > 1 ? : null} options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: r.label }))} value={range} onChange={setRange} /> - {ipVersion === 'ipv6' ? '还没有 IPv6 线路' : '还没有线路'} + {selectedAgents.length > 0 ? '没有和所选 Agent 有关的线路' : ipVersion === 'ipv6' ? '还没有 IPv6 线路' : '还没有线路'} {ipVersion === 'ipv6' ? (ipv6.data?.items ?? []).map((a) =>

{a.name}:{IPV6_STATES[a.ipv6.state] ?? '状态未知'}{!a.ipv6.address_configured && a.ipv6.state !== 'address_not_configured' ? ';面板也尚未配置 IPv6 地址' : ''}{!a.online ? '(Agent 离线,显示上次诊断)' : ''}

) : null}
) : (