feat: Agent CLI 与终端审计回放全面优化
- Agent CLI: 多轮对话、SSH 长连接、流式推理、Evolver 按用户隔离、历史侧栏 - 终端审计: 回放播放器、命令书签、筛选分页导出、命令输出统计、录制落盘修复 - 修复 session_commands 表迁移、Kill 竞态导致录制丢失、无录制会话回放提示 - 审计历史页只读(移除删除),移除回放弹框录制搜索
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user