feat: Agent CLI 与终端审计回放全面优化

- Agent CLI: 多轮对话、SSH 长连接、流式推理、Evolver 按用户隔离、历史侧栏
- 终端审计: 回放播放器、命令书签、筛选分页导出、命令输出统计、录制落盘修复
- 修复 session_commands 表迁移、Kill 竞态导致录制丢失、无录制会话回放提示
- 审计历史页只读(移除删除),移除回放弹框录制搜索
This commit is contained in:
Logan committed 2026-07-01 12:13:53 +08:00
1 parent 9a3dd304f7
commit 1acb0c49af
81 files changed
+3916 -1655

No files matched your search

@@ -45,6 +45,7 @@ import { AgentWizard } from '@/components/agent/AgentWizard';
import { Checkbox } from '@/components/ui/checkbox';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { useNavigate } from 'react-router-dom';
import { cn } from '@/lib/utils';
// ═══════════ Tab 配置 ═══════════
type TabId = 'agent' | 'zabbix' | 'prometheus' | 'dashboard';
@@ -56,7 +57,7 @@ const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [
];
// ═══════════ Agent 主机监控表格 ═══════════
interface AgentHost { server_id: number; hostname: string; ip: string; cpu_usage: number; cpu_cores: number; mem_usage: number; mem_total: number; mem_used: number; disk_usage: number; agent_version: string; agent_status: string; os: string; region: string; }
interface AgentHost { server_id: number; hostname: string; ip: string; cpu_usage: number; cpu_cores: number; mem_usage: number; mem_total: number; mem_used: number; disk_usage: number; agent_version: string; agent_status: string; os: string; region: string; has_auth?: boolean; }
type SortField = 'hostname' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'mem_usage' | 'disk_usage' | 'region' | 'ip';
const AgentTab: React.FC = () => {
@@ -64,6 +65,7 @@ const AgentTab: React.FC = () => {
const navigate = useNavigate();
const [hosts, setHosts] = useState<AgentHost[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [sf, setSf] = useState<SortField>('cpu_usage');
const [sd, setSd] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(1);
@@ -76,6 +78,8 @@ const AgentTab: React.FC = () => {
const [batchMode, setBatchMode] = useState<'install' | 'upgrade'>('install');
const [batchAck, setBatchAck] = useState(false);
const [batchSubmitting, setBatchSubmitting] = useState(false);
// 批量安装临时凭据(用于未保存凭据的主机)// Logan.Li
const [batchCreds, setBatchCreds] = useState<{ username: string; password: string; sshKey: string; authType: 'password' | 'key' }>({ username: 'root', password: '', sshKey: '', authType: 'password' });
// 正在执行操作的主机 ID 集合(版本列 loading)
const [operating, setOperating] = useState<Set<number>>(new Set());
// 批量卸载
@@ -94,7 +98,28 @@ const AgentTab: React.FC = () => {
finally { setBatchUninstalling(false); }
};
const totalPages = Math.max(1, Math.ceil(hosts.length / pageSize));
const fetch = async () => { try { setHosts(asList(await agentApi.getLatest()) as typeof hosts); } catch {} finally { setLoading(false); } };
const fetch = async () => {
setRefreshing(true);
try {
const list = asList(await agentApi.getLatest()) as typeof hosts;
setHosts(list);
// 操作中的主机一旦状态变为 online 即可移除 loading(部署中)状态 // Logan.Li
setOperating(prev => {
const next = new Set(prev);
for (const h of list) {
if (next.has(h.server_id) && h.agent_status === 'online') {
next.delete(h.server_id);
}
}
return next;
});
} catch (e: any) {
toast.error('刷新 Agent 主机列表失败:' + (e?.message || '未知错误'));
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => { fetch(); const t = setInterval(fetch, 30000); return () => clearInterval(t); }, []);
const toggle = (f: SortField) => { if (sf === f) setSd(d => d === 'asc' ? 'desc' : 'asc'); else { setSf(f); setSd('desc'); } };
const sorted = [...hosts].sort((a, b) => {
@@ -143,7 +168,20 @@ const AgentTab: React.FC = () => {
// 安装:只安装无 Agent 的
const installIds = needInstall.map(h => h.server_id);
if (installIds.length === 0) { toast.warning('所选主机均已安装 Agent,无需安装'); return; }
const res = await serverApi.batchInstallAgent(installIds, token);
// 为未保存凭据的主机构造临时凭据 map;后端遇到空字段不会覆盖已有凭据 // Logan.Li
const credentials: Record<number, { username?: string; password?: string; ssh_key?: string; auth_type?: string }> = {};
const needCreds = batchCreds.username && (batchCreds.authType === 'key' ? batchCreds.sshKey : batchCreds.password);
if (needCreds) {
for (const sid of installIds) {
credentials[sid] = {
username: batchCreds.username,
auth_type: batchCreds.authType,
password: batchCreds.authType === 'password' ? batchCreds.password : undefined,
ssh_key: batchCreds.authType === 'key' ? batchCreds.sshKey : undefined,
};
}
}
const res = await serverApi.batchInstallAgent(installIds, token, Object.keys(credentials).length ? credentials : undefined);
toast.success(`已提交:批量安装 ${res?.total ?? installIds.length} 台主机的 Agent,后台执行中,30秒后自动刷新`);
setSelected(new Set());
setTimeout(() => { fetch(); setOperating(new Set()); }, 30000);
@@ -171,7 +209,7 @@ const AgentTab: React.FC = () => {
<div className="flex-1" />
<Button size="sm" variant="outline" className="h-7 text-xs gap-1" onClick={() => {
const picked = selAny ? hosts.find(h => selected.has(String(h.server_id))) : null;
setWizardServer(picked ? { id: picked.server_id, hostname: picked.hostname, ip: picked.ip, has_auth: (picked as Record<string, any>).has_auth } : null);
setWizardServer(picked ? { id: picked.server_id, hostname: picked.hostname, ip: picked.ip, has_auth: picked.has_auth } : null);
setWizardOpen(true);
}}>
<Rocket className="w-3 h-3" />安装 Agent
@@ -186,15 +224,18 @@ const AgentTab: React.FC = () => {
</Button>
</>
)}
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" onClick={fetch}><RefreshCw className="w-3 h-3" />刷新</Button>
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" onClick={fetch} disabled={refreshing}>
<RefreshCw className={cn('w-3 h-3', refreshing && 'animate-spin')} />刷新
</Button>
</div>
<AgentWizard open={wizardOpen} onOpenChange={setWizardOpen} server={wizardServer}
onInstalled={() => { toast.success('Agent 安装成功'); setWizardOpen(false); fetch(); }}
onFailed={() => toast.error('安装失败')} />
onStart={(id) => { setOperating(prev => new Set(prev).add(id)); }}
onInstalled={(id) => { toast.success('Agent 安装任务已提交,后台执行中…'); setWizardOpen(false); fetch(); }}
onFailed={(id) => { toast.error('安装失败'); setOperating(prev => { const next = new Set(prev); next.delete(id); return next; }); }} />
{/* ────────── 批量部署 Agent 对话框 ────────── */}
<Dialog open={batchOpen} onOpenChange={(v) => { if (!v) { setBatchOpen(false); setBatchAck(false); } }}>
<Dialog open={batchOpen} onOpenChange={(v) => { if (!v) { setBatchOpen(false); setBatchAck(false); setBatchCreds({ username: 'root', password: '', sshKey: '', authType: 'password' }); } }}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-auto bg-slate-900 border-slate-700 text-slate-100">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
@@ -259,6 +300,45 @@ const AgentTab: React.FC = () => {
</table>
</div>
{/* 安装凭据(安装模式且存在未保存凭据的主机时显示)// Logan.Li */}
{batchMode === 'install' && needInstall.some(h => !h.has_auth) && (
<div className="rounded-md border border-slate-700 bg-slate-800/50 p-3 space-y-3">
<div className="flex items-center gap-2">
<Info className="w-4 h-4 text-blue-400 shrink-0" />
<span className="text-xs text-slate-300">以下主机未保存 SSH 凭据,请提供临时凭据用于批量安装(仅本次使用):</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-[11px] text-slate-400 mb-1 block">认证方式</Label>
<Select value={batchCreds.authType} onValueChange={(v: 'password' | 'key') => setBatchCreds(c => ({ ...c, authType: v }))}>
<SelectTrigger className="h-8 text-xs bg-slate-900 border-slate-700 text-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-700 text-slate-200">
<SelectItem value="password">密码</SelectItem>
<SelectItem value="key">SSH 私钥</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[11px] text-slate-400 mb-1 block">用户名</Label>
<Input value={batchCreds.username} onChange={e => setBatchCreds(c => ({ ...c, username: e.target.value }))} placeholder="root" className="h-8 text-xs bg-slate-900 border-slate-700 text-slate-200" />
</div>
</div>
{batchCreds.authType === 'password' ? (
<div>
<Label className="text-[11px] text-slate-400 mb-1 block">密码</Label>
<Input type="password" value={batchCreds.password} onChange={e => setBatchCreds(c => ({ ...c, password: e.target.value }))} placeholder="输入 SSH 密码" className="h-8 text-xs bg-slate-900 border-slate-700 text-slate-200" />
</div>
) : (
<div>
<Label className="text-[11px] text-slate-400 mb-1 block">SSH 私钥</Label>
<textarea value={batchCreds.sshKey} onChange={e => setBatchCreds(c => ({ ...c, sshKey: e.target.value }))} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" className="w-full h-24 rounded-md border border-slate-700 bg-slate-900 text-slate-200 text-xs p-2 resize-none focus:outline-none focus:ring-1 focus:ring-blue-500" />
</div>
)}
</div>
)}
{/* ⚠️ 警告文案 — 参考阿里云批量安装/升级风格 */}
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3 space-y-2">
<div className="flex items-start gap-2">
@@ -429,9 +509,20 @@ const DashboardTab: React.FC = () => {
const [addOpen, setAddOpen] = useState(false);
const [nd, setNd] = useState({ name: '', description: '', datasource: 'prometheus' as const, datasourceUrl: '' });
const [np, setNp] = useState({ title: '', type: 'graph' as const, query: '', width: 6, height: 4 });
const [refreshing, setRefreshing] = useState(false);
const fetch = async () => {
try { const res = await dashboardApi.getDashboards(); const list = res.data || []; setDashboards(list); if (list.length > 0 && !active) setActive(list[0]); } catch {}
setRefreshing(true);
try {
const res = await dashboardApi.getDashboards();
const list = res.data || [];
setDashboards(list);
if (list.length > 0 && !active) setActive(list[0]);
} catch (e: any) {
toast.error('刷新自定义仪表盘失败:' + (e?.message || '未知错误'));
} finally {
setRefreshing(false);
}
};
useEffect(() => { fetch(); }, []);
@@ -455,7 +546,9 @@ const DashboardTab: React.FC = () => {
<SelectTrigger className="w-[240px] h-8 text-xs"><SelectValue placeholder="选择仪表盘" /></SelectTrigger>
<SelectContent>{dashboards.map(d => <SelectItem key={d.id} value={d.id}>{d.name}{d.isDefault ? ' (默认)' : ''}</SelectItem>)}</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={fetch}><RefreshCw className="w-3 h-3 mr-1" />刷新</Button>
<Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={fetch} disabled={refreshing}>
<RefreshCw className={cn('w-3 h-3 mr-1', refreshing && 'animate-spin')} />刷新
</Button>
{active && <Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={() => setAddOpen(true)}><Plus className="w-3 h-3 mr-1" />添加面板</Button>}
<Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={() => setCreateOpen(true)}><Plus className="w-3 h-3 mr-1" />新建</Button>
</div>