Files
MoShiro/web/admin/src/pages/Probes.tsx
T
2026-10-04 21:46:39 +08:00

246 lines
9.7 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 { Page, ProbeDto, ProbeIdentityDto } from '@shared/api/types'
import { fmtAgo, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Confirm, CopyButton, Dialog, downloadText, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
/** 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/monitor/probe.json,然后启动 Probe;它会根据 bootstrap 列表连接 Agent,之后的 Agent 列表由面板同步。">
<pre className="code">{`monitor-probe --config /etc/monitor/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 { 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">
{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>
{p.revoked ? <Chip tone="bad">已吊销</Chip> : <Chip tone={p.connections.length > 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${p.connections.length} 个 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}>
<td>
<Link to={`/agents/${c.agent_id}`} style={{ fontWeight: 600 }}>
{c.agent_name}
</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}
</>
)
}