Files
MoShiro/web/admin/src/pages/Probes.tsx
T
justme 84f9d211bc
ci/woodpecker/pr/verify Pipeline failed
ci/woodpecker/pr/web-build unknown status
backend: require confirmation for signed channel updates [CI]
2026-10-07 12:57:20 +08:00

337 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<LinkMatrixDto>('/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 (
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<b>线路</b>
<span className="muted">Probe 主动连接 Agent。点格子手动开关;「↺」回到自动。</span>
<label className="switch right">
<input type="checkbox" checked={matrix.data?.auto_link ?? true} disabled={busy} onChange={(e) => setAuto(e.target.checked)} />
自动连接 {matrix.data?.auto_link === false ? '关' : '开'}
</label>
</div>
<ErrorNotice error={matrix.error} />
<table className="table">
<thead>
<tr>
<th>Probe \ Agent</th>
{agents.map(([id, name]) => (
<th key={id}>{name}</th>
))}
</tr>
</thead>
<tbody>
{probes.map(([probeId, probeName]) => (
<tr key={probeId}>
<td style={{ fontWeight: 600 }}>{probeName}</td>
{agents.map(([agentId]) => {
const link = cell(probeId, agentId)
if (!link) return <td key={agentId} />
const tone = link.connected ? 'ok' : link.linked ? 'warn' : ''
const text = link.connected ? '已连接' : link.linked ? '待连接' : '不连'
return (
<td key={agentId}>
<span className="row" style={{ gap: 6 }}>
<button type="button" className="btn small" disabled={busy} title={link.linked ? '点一下:手动关闭' : '点一下:手动开启'} onClick={() => set(link, !link.linked)}>
<Chip tone={tone}>{text}</Chip>
</button>
{link.override !== null && link.override !== undefined ? (
<>
<span className="faint" title="手动设置">
手动
</span>
<button type="button" className="btn small text" disabled={busy} title="回到自动" onClick={() => set(link, null)}>
↺
</button>
</>
) : null}
</span>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)
}
/** probe.json 只在创建和轮换密钥时返回一次:这里让管理员马上下载或复制。 */
function IdentityDialog({ identity, title, onClose }: { identity: ProbeIdentityDto; title: string; onClose: () => void }) {
const json = JSON.stringify(identity, null, 2)
return (
<Dialog
title={title}
onClose={onClose}
actions={
<button type="button" className="btn primary" onClick={onClose}>
我已保存
</button>
}
>
<div className="stack">
<div className="notice warn">私钥只显示这一次,关闭之后无法再找回。请现在保存 probe.json。</div>
<div className="row wrap">
<button type="button" className="btn primary" onClick={() => downloadText('probe.json', json)}>
<Download size={16} />
下载 probe.json
</button>
<CopyButton text={json} label="复制内容" />
</div>
<Field label="在家宽机器上" hint="保存为 /etc/moshiro/probe.json,然后启动 Probe;它会连接 probe.json 里的面板地址,Agent 列表和更新都由面板下发。">
<pre className="code">{`moshiro-probe --config /etc/moshiro/probe.json`}</pre>
</Field>
<details>
<summary className="muted" style={{ cursor: 'pointer' }}>
查看内容
</summary>
<pre className="code" style={{ marginTop: 8 }}>
{json}
</pre>
</details>
</div>
</Dialog>
)
}
function NameDialog({ title, initial, onClose, onSubmit }: { title: string; initial: string; onClose: () => void; onSubmit: (name: string) => Promise<boolean> }) {
const [name, setName] = useState(initial)
const [busy, setBusy] = useState(false)
return (
<Dialog
title={title}
onClose={onClose}
actions={
<>
<button type="button" className="btn text" onClick={onClose}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={busy || !name.trim()}
onClick={async () => {
setBusy(true)
const ok = await onSubmit(name.trim())
setBusy(false)
if (ok) onClose()
}}
>
确定
</button>
</>
}
>
<Field label="名称" hint="字母、数字、点、下划线和连字符,例如 home 或 office">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</Field>
</Dialog>
)
}
type Pending = { kind: 'rotate' | 'revoke' | 'delete'; probe: ProbeDto }
export function Probes() {
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 8000)
const infos = useNodeInfos()
const { run } = useAction()
const [creating, setCreating] = useState(false)
const [renaming, setRenaming] = useState<ProbeDto>()
const [pending, setPending] = useState<Pending>()
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 (
<>
<header>
<h1>Probe</h1>
<span className="right row">
{probes.loading && !probes.data ? <Spinner /> : null}
<button type="button" className="btn primary" onClick={() => setCreating(true)}>
<Plus size={18} />
创建 Probe
</button>
</span>
</header>
<ErrorNotice error={probes.error} />
<div className="stack">
<LinkMatrix />
{probes.data && list.length === 0 ? (
<div className="card">
<Empty>还没有 Probe。点击右上角「创建 Probe」,由后端生成身份,再把 probe.json 放到家宽机器上。</Empty>
</div>
) : null}
{list.map((p) => (
<div key={p.id} className="card">
<div className="row wrap">
<span style={{ fontSize: 18, fontWeight: 700 }}>{p.name}</span>
<NodeTag info={infos.get(p.id)} />
<Chip tone={p.ipv6?.state === 'ready' ? 'ok' : 'warn'}>{p.ipv6 ? IPV6_STATES[p.ipv6.state] ?? 'IPv6 状态未知' : '等待 IPv6 诊断'}</Chip>
{p.revoked ? (
<Chip tone="bad">已吊销</Chip>
) : (
<>
<Chip tone={p.online ? 'ok' : ''}>{p.online ? '已连面板' : '未连面板'}</Chip>
<Chip tone={p.connections.length > 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${new Set(p.connections.map((c) => c.agent_id)).size} 个 Agent` : '没有连接'}</Chip>
</>
)}
<span className="mono faint">{p.id}</span>
<span className="right row wrap">
<button type="button" className="btn small" onClick={() => setRenaming(p)}>
<Pencil size={14} />
改名
</button>
{!p.revoked ? (
<>
<button type="button" className="btn small" onClick={() => setPending({ kind: 'rotate', probe: p })}>
<KeyRound size={14} />
轮换密钥
</button>
<button type="button" className="btn small danger" onClick={() => setPending({ kind: 'revoke', probe: p })}>
<ShieldOff size={14} />
吊销
</button>
</>
) : null}
<button type="button" className="btn small danger" onClick={() => setPending({ kind: 'delete', probe: p })}>
<Trash2 size={14} />
删除
</button>
</span>
</div>
<p className="faint" style={{ marginTop: 4 }}>
创建于 {fmtTime(p.created_at)}
{p.revoked_at ? `,吊销于 ${fmtTime(p.revoked_at)}` : ''}
</p>
{p.connections.length > 0 ? (
<table className="table" style={{ marginTop: 8 }}>
<thead>
<tr>
<th>Agent</th>
<th>版本</th>
<th>来源地址</th>
<th>连接时间</th>
</tr>
</thead>
<tbody>
{p.connections.map((c) => (
<tr key={`${c.agent_id}~${c.ip_version}`}>
<td>
<Link to={`/agents/${c.agent_id}`} style={{ fontWeight: 600 }}>
{c.agent_name}
<Chip>{c.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'}</Chip>
</Link>
</td>
<td>{c.version ?? '—'}</td>
<td className="mono">{c.remote ?? '—'}</td>
<td className="muted">
{fmtTime(c.connected_since)}({fmtAgo(c.connected_since)})
</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
))}
</div>
{creating ? (
<NameDialog
title="创建 Probe"
initial=""
onClose={() => setCreating(false)}
onSubmit={async (name) => {
let created: ProbeIdentityDto | undefined
const ok = await run(async () => {
created = await api.post<ProbeIdentityDto>('/api/admin/probes', { name })
})
if (ok && created) {
setIdentity({ title: `Probe ${name} 已创建`, value: created })
probes.reload()
}
return ok
}}
/>
) : null}
{renaming ? (
<NameDialog
title="修改名称"
initial={renaming.name}
onClose={() => setRenaming(undefined)}
onSubmit={async (name) => {
const ok = await run(() => api.patch(`/api/admin/probes/${renaming.id}`, { name }), '已保存')
probes.reload()
return ok
}}
/>
) : null}
{pending ? (
<Confirm
title={confirmText[pending.kind].title}
danger={confirmText[pending.kind].danger}
confirmText={confirmText[pending.kind].title}
onClose={() => setPending(undefined)}
onConfirm={async () => {
const { kind, probe } = pending
if (kind === 'rotate') {
let rotated: ProbeIdentityDto | undefined
const ok = await run(async () => {
rotated = await api.post<ProbeIdentityDto>(`/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}
</Confirm>
) : null}
{identity ? <IdentityDialog title={identity.title} identity={identity.value} onClose={() => setIdentity(undefined)} /> : null}
</>
)
}