import { Download, KeyRound, Pencil, Plus, ShieldOff, Trash2 } from 'lucide-react' import { useState } from 'react' import { Link } from 'react-router-dom' import { api } from '@shared/api/client' import type { LinkDto, LinkMatrixDto, Page, ProbeDto, ProbeIdentityDto } from '@shared/api/types' import { fmtAgo, fmtTime } from '@shared/format' import { useQuery } from '@shared/hooks' import { NodeTag, useNodeInfos } from '@shared/nodes' import { IPV6_STATES } from '@shared/ipVersions' import { Chip, Confirm, CopyButton, Dialog, downloadText, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui' /** * 线路:哪个 Probe 连哪个 Agent。自动连接开着时所有 Probe 和 Agent 两两连,关着时只连手动选中的; * 每一对都可以手动强制开或关(点一下切换,「↺」回到自动)。Probe 始终是主动拨出的一方。 */ function LinkMatrix() { const matrix = useQuery(() => api.get('/api/admin/links'), [], 8000) const { busy, run } = useAction() const items = matrix.data?.items ?? [] const probes = [...new Map(items.map((i) => [i.probe_id, i.probe_name])).entries()] const agents = [...new Map(items.map((i) => [i.agent_id, i.agent_name])).entries()] const cell = (probeId: string, agentId: string) => items.find((i) => i.probe_id === probeId && i.agent_id === agentId) const set = async (link: LinkDto, enabled: boolean | null) => { await run(() => api.put(`/api/admin/links/${link.probe_id}/${link.agent_id}`, { enabled })) matrix.reload() } const setAuto = async (on: boolean) => { await run(() => api.patch('/api/admin/settings', { 'fleet.auto_link': on }), on ? '已开启自动连接' : '已关闭自动连接') matrix.reload() } if (items.length === 0 && !matrix.loading) return null return (
线路 Probe 主动连接 Agent。点格子手动开关;「↺」回到自动。
{agents.map(([id, name]) => ( ))} {probes.map(([probeId, probeName]) => ( {agents.map(([agentId]) => { const link = cell(probeId, agentId) if (!link) return ) })} ))}
Probe \ Agent{name}
{probeName} const tone = link.connected ? 'ok' : link.linked ? 'warn' : '' const text = link.connected ? '已连接' : link.linked ? '待连接' : '不连' return ( {link.override !== null && link.override !== undefined ? ( <> 手动 ) : null}
) } /** probe.json 只在创建和轮换密钥时返回一次:这里让管理员马上下载或复制。 */ function IdentityDialog({ identity, title, onClose }: { identity: ProbeIdentityDto; title: string; onClose: () => void }) { const json = JSON.stringify(identity, null, 2) return ( 我已保存 } >
私钥只显示这一次,关闭之后无法再找回。请现在保存 probe.json。
{`moshiro-probe --config /etc/moshiro/probe.json`}
查看内容
            {json}
          
) } function NameDialog({ title, initial, onClose, onSubmit }: { title: string; initial: string; onClose: () => void; onSubmit: (name: string) => Promise }) { const [name, setName] = useState(initial) const [busy, setBusy] = useState(false) return ( } > setName(e.target.value)} autoFocus /> ) } type Pending = { kind: 'rotate' | 'revoke' | 'delete'; probe: ProbeDto } export function Probes() { const probes = useQuery(() => api.get>('/api/admin/probes'), [], 8000) const infos = useNodeInfos() const { run } = useAction() const [creating, setCreating] = useState(false) const [renaming, setRenaming] = useState() const [pending, setPending] = useState() const [identity, setIdentity] = useState<{ title: string; value: ProbeIdentityDto }>() const list = probes.data?.items ?? [] const confirmText = { rotate: { title: '轮换密钥', danger: true, text: '旧密钥立即失效,所有 Agent 会断开这个 Probe 的会话。你需要把新的 probe.json 放到这台 Probe 上并重启它。' }, revoke: { title: '吊销 Probe', danger: true, text: '所有 Agent 会立刻断开并拒绝这个 Probe。记录和历史数据保留,但它不能再连接,也不能恢复。' }, delete: { title: '删除 Probe', danger: true, text: '删除这个 Probe 和它的线路。已经入库的数据按保留策略自然过期。' }, } return ( <>

Probe

{probes.loading && !probes.data ? : null}
{probes.data && list.length === 0 ? (
还没有 Probe。点击右上角「创建 Probe」,由后端生成身份,再把 probe.json 放到家宽机器上。
) : null} {list.map((p) => (
{p.name} {p.ipv6 ? IPV6_STATES[p.ipv6.state] ?? 'IPv6 状态未知' : '等待 IPv6 诊断'} {p.revoked ? ( 已吊销 ) : ( <> {p.online ? '已连面板' : '未连面板'} 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${new Set(p.connections.map((c) => c.agent_id)).size} 个 Agent` : '没有连接'} )} {p.id} {!p.revoked ? ( <> ) : null}

创建于 {fmtTime(p.created_at)} {p.revoked_at ? `,吊销于 ${fmtTime(p.revoked_at)}` : ''}

{p.connections.length > 0 ? ( {p.connections.map((c) => ( ))}
Agent 版本 来源地址 连接时间
{c.agent_name} {c.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'} {c.version ?? '—'} {c.remote ?? '—'} {fmtTime(c.connected_since)}({fmtAgo(c.connected_since)})
) : null}
))}
{creating ? ( setCreating(false)} onSubmit={async (name) => { let created: ProbeIdentityDto | undefined const ok = await run(async () => { created = await api.post('/api/admin/probes', { name }) }) if (ok && created) { setIdentity({ title: `Probe ${name} 已创建`, value: created }) probes.reload() } return ok }} /> ) : null} {renaming ? ( setRenaming(undefined)} onSubmit={async (name) => { const ok = await run(() => api.patch(`/api/admin/probes/${renaming.id}`, { name }), '已保存') probes.reload() return ok }} /> ) : null} {pending ? ( setPending(undefined)} onConfirm={async () => { const { kind, probe } = pending if (kind === 'rotate') { let rotated: ProbeIdentityDto | undefined const ok = await run(async () => { rotated = await api.post(`/api/admin/probes/${probe.id}/rotate`) }) if (ok && rotated) setIdentity({ title: `${probe.name} 的新密钥`, value: rotated }) } else if (kind === 'revoke') { await run(() => api.post(`/api/admin/probes/${probe.id}/revoke`), '已吊销') } else { await run(() => api.del(`/api/admin/probes/${probe.id}`), '已删除') } probes.reload() }} > {confirmText[pending.kind].text} ) : null} {identity ? setIdentity(undefined)} /> : null} ) }