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 }) { return ( {agent.status === 'pending' ? 待审批 : null} ) } 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() const [controllers, setControllers] = useState(null) if (created) return return ( } >

添加 Agent 就是创建一个加入令牌:在目标 VPS 上运行安装命令,Agent 会用它注册并出现在列表里。一个令牌可以装任意多台(可用次数不设上限)。

setNote(e.target.value)} placeholder="HK-1" />
setLabels(e.target.value)} placeholder="hk, cn2" />
setMaxUses(Number(e.target.value))} /> setHours(Number(e.target.value))} />
) } export function Agents() { const navigate = useNavigate() const agents = useQuery(() => api.get>('/api/admin/agents'), [], 10_000) const infos = useNodeInfos() const tokens = useQuery(() => api.get>('/api/admin/join-tokens'), []) const [filter, setFilter] = useState<'all' | 'pending' | 'disabled'>('all') const [adding, setAdding] = useState(false) const [revoking, setRevoking] = useState() const [deleting, setDeleting] = useState() 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 ( <>

Agent

{agents.loading && !agents.data ? : null}
{list.length === 0 ? ( {agents.data ? '没有符合条件的 Agent。点击右上角「添加 Agent」创建加入令牌。' : '加载中…'} ) : ( {list.map((a) => ( navigate(`/agents/${a.id}`)}> ))}
状态 名称 地址 版本 连接(拨出 / 接入) 最后在线 标签
{a.name} {a.public_host || '—'} {a.agent_version ?? '—'} 0 ? 'ok' : ''}>{a.outgoing.length} / {a.dialers.length} {fmtAgo(a.last_seen_at)}
{a.labels.map((x) => ( {x} ))}
)}
加入令牌
{(tokens.data?.items ?? []).length === 0 ? ( 没有加入令牌 ) : ( {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 ( ) })}
备注 创建者 使用 注册后 过期 状态
{t.note || —} {t.created_by ?? '—'} {t.uses} / {t.max_uses} {t.auto_approve ? '自动启用' : '需要审批'} {fmtTime(t.expires_at)} {state} {state === '可用' ? ( ) : null}
)}
{adding ? setAdding(false)} onCreated={tokens.reload} /> : null} {revoking ? ( setRevoking(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/join-tokens/${revoking.id}`), '已吊销') tokens.reload() }} > 吊销之后,这个令牌不能再用来注册 Agent。已经注册的 Agent 不受影响。 ) : null} {deleting ? ( 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 不受影响。 ) : null} ) }