Files
MoShiro/web/admin/src/pages/Agents.tsx
T

279 lines
11 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 { Ban, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { AgentDto, CreatedJoinTokenDto, JoinTokenDto, Page } from '@shared/api/types'
import { fmtAgo, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { NodeTag, RuntimeStatusChip, useNodeInfos } from '@shared/nodes'
import { Chip, Confirm, Dialog, Dot, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
import { OwnerPicker } from './OwnerPicker'
import { JoinTokenCreated } from './JoinTokenCreated'
export function AgentStatus({ agent }: { agent: Pick<AgentDto, 'status' | 'online' | 'runtime_status'> }) {
return (
<span className="row" style={{ gap: 8 }}>
<Dot tone={agent.online ? 'ok' : 'bad'} />
<RuntimeStatusChip status={agent.runtime_status} />
{agent.status === 'pending' ? <Chip tone="warn">待审批</Chip> : null}
</span>
)
}
function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const { busy, run } = useAction()
const [note, setNote] = useState('')
const [labels, setLabels] = useState('')
const [autoApprove, setAutoApprove] = useState(true)
const [maxUses, setMaxUses] = useState(1)
const [hours, setHours] = useState(24)
const [created, setCreated] = useState<CreatedJoinTokenDto>()
const [controllers, setControllers] = useState<string[] | null>(null)
if (created) return <JoinTokenCreated created={created} kind="Agent" onClose={onClose} />
return (
<Dialog
title="添加 Agent"
onClose={onClose}
actions={
<>
<button type="button" className="btn text" onClick={onClose}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={busy || !controllers?.length}
onClick={async () => {
await run(async () => {
const token = await api.post<CreatedJoinTokenDto>('/api/admin/join-tokens', {
note: note || null,
labels: labels.split(',').map((x) => x.trim()).filter(Boolean),
auto_approve: autoApprove,
max_uses: maxUses,
expires_in_hours: hours,
controllers: controllers ?? undefined,
})
setCreated(token)
onCreated()
})
}}
>
创建加入令牌
</button>
</>
}
>
<p className="muted" style={{ marginBottom: 16 }}>
添加 Agent 就是创建一个加入令牌:在目标 VPS 上运行安装命令,Agent 会用它注册并出现在列表里。一个令牌可以装任意多台(可用次数不设上限)。
</p>
<div className="form-grid">
<div className="wide">
<Field label="备注" hint="例如这台 VPS 的位置或用途">
<input className="input" value={note} onChange={(e) => setNote(e.target.value)} placeholder="HK-1" />
</Field>
</div>
<div className="wide">
<Field label="标签" hint="逗号分隔,注册后会带在这个 Agent 上">
<input className="input" value={labels} onChange={(e) => setLabels(e.target.value)} placeholder="hk, cn2" />
</Field>
</div>
<Field label="可用次数">
<input className="input" type="number" min={1} max={100000} value={maxUses} onChange={(e) => setMaxUses(Number(e.target.value))} />
</Field>
<Field label="有效期(小时)">
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
</Field>
<div className="wide">
<Field label="属主(passkey)" hint="必选(至少一把):这个节点绑定到哪几个账户的哪几把 Passkey 上,它的敏感操作(web ssh 等)和加密内容只认它们。只是选,不用触碰;装上后固定在节点本地,面板改不了。">
<OwnerPicker value={controllers} onChange={setControllers} />
</Field>
</div>
<div className="wide">
<label className="switch">
<input type="checkbox" checked={autoApprove} onChange={(e) => setAutoApprove(e.target.checked)} />
注册后自动启用(不勾选则需要手动审批)
</label>
</div>
</div>
</Dialog>
)
}
export function Agents() {
const navigate = useNavigate()
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [], 10_000)
const infos = useNodeInfos()
const tokens = useQuery(() => api.get<Page<JoinTokenDto>>('/api/admin/join-tokens'), [])
const [filter, setFilter] = useState<'all' | 'pending' | 'disabled'>('all')
const [adding, setAdding] = useState(false)
const [revoking, setRevoking] = useState<JoinTokenDto>()
const [deleting, setDeleting] = useState<JoinTokenDto>()
const { run } = useAction()
const list = (agents.data?.items ?? []).filter((a) => filter === 'all' || a.status === filter)
const pending = (agents.data?.items ?? []).filter((a) => a.status === 'pending').length
return (
<>
<header>
<h1>Agent</h1>
<span className="right row">
{agents.loading && !agents.data ? <Spinner /> : null}
<button type="button" className="btn primary" onClick={() => setAdding(true)}>
<Plus size={18} />
添加 Agent
</button>
</span>
</header>
<ErrorNotice error={agents.error} />
<div className="stack">
<Segmented
options={[
{ value: 'all' as const, label: '全部' },
{ value: 'pending' as const, label: `待审批${pending ? ` (${pending})` : ''}` },
{ value: 'disabled' as const, label: '已停用' },
]}
value={filter}
onChange={setFilter}
/>
<div className="card" style={{ padding: '8px 12px' }}>
{list.length === 0 ? (
<Empty>{agents.data ? '没有符合条件的 Agent。点击右上角「添加 Agent」创建加入令牌。' : '加载中…'}</Empty>
) : (
<table className="table hover">
<thead>
<tr>
<th>状态</th>
<th>名称</th>
<th>地址</th>
<th>版本</th>
<th>连着的 Probe</th>
<th>最后在线</th>
<th>标签</th>
</tr>
</thead>
<tbody>
{list.map((a) => (
<tr key={a.id} onClick={() => navigate(`/agents/${a.id}`)}>
<td>
<AgentStatus agent={a} />
</td>
<td style={{ fontWeight: 600 }}>
<span className="row" style={{ gap: 8 }}>
{a.name}
<NodeTag info={infos.get(a.id)} />
</span>
</td>
<td className="mono muted">{a.public_host || '—'}</td>
<td className="muted">{a.agent_version ?? '—'}</td>
<td>
<Chip tone={a.probes.length > 0 ? 'ok' : ''}>{a.probes.length} 个</Chip>
</td>
<td className="muted">{fmtAgo(a.last_seen_at)}</td>
<td>
<div className="tags">
{a.labels.map((x) => (
<Chip key={x}>{x}</Chip>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="card">
<div className="card-title">加入令牌</div>
{(tokens.data?.items ?? []).length === 0 ? (
<Empty>没有加入令牌</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>备注</th>
<th>创建者</th>
<th>使用</th>
<th>注册后</th>
<th>过期</th>
<th>状态</th>
<th />
</tr>
</thead>
<tbody>
{tokens.data?.items.map((t) => {
const expired = Date.parse(t.expires_at) < Date.now()
const state = t.revoked_at ? '已吊销' : expired ? '已过期' : t.uses >= t.max_uses ? '已用完' : '可用'
return (
<tr key={t.id}>
<td>{t.note || <span className="faint">—</span>}</td>
<td className="muted">{t.created_by ?? '—'}</td>
<td className="num">
{t.uses} / {t.max_uses}
</td>
<td>{t.auto_approve ? '自动启用' : '需要审批'}</td>
<td className="muted">{fmtTime(t.expires_at)}</td>
<td>
<Chip tone={state === '可用' ? 'ok' : ''}>{state}</Chip>
</td>
<td className="r">
<span className="row" style={{ gap: 8, justifyContent: 'flex-end' }}>
{state === '可用' ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
<Ban size={14} />
吊销
</button>
) : null}
<button type="button" className="btn small danger" onClick={() => setDeleting(t)}>
<Trash2 size={14} />
删除
</button>
</span>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
{adding ? <AddAgentDialog onClose={() => setAdding(false)} onCreated={tokens.reload} /> : null}
{revoking ? (
<Confirm
title="吊销加入令牌"
danger
confirmText="吊销"
onClose={() => setRevoking(undefined)}
onConfirm={async () => {
await run(() => api.del(`/api/admin/join-tokens/${revoking.id}`), '已吊销')
tokens.reload()
}}
>
吊销之后,这个令牌不能再用来注册 Agent。已经注册的 Agent 不受影响。
</Confirm>
) : null}
{deleting ? (
<Confirm
title="删除加入令牌"
danger
confirmText="删除"
onClose={() => setDeleting(undefined)}
onConfirm={async () => {
await run(() => api.del(`/api/admin/join-tokens/${deleting.id}?purge=true`), '已删除')
tokens.reload()
}}
>
从列表里彻底删除这条记录。{deleting.revoked_at || deleting.uses >= deleting.max_uses || Date.parse(deleting.expires_at) < Date.now() ? '它已经不能再用了。' : '它现在还能用,删除之后会立刻失效。'}已经注册的 Agent 不受影响。
</Confirm>
) : null}
</>
)
}