337 lines
14 KiB
TypeScript
337 lines
14 KiB
TypeScript
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}
|
||
</>
|
||
)
|
||
}
|