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>
@@ -26,8 +26,9 @@ const DEFAULT_TIME_RANGE: TimeRange = {
// 引擎默认查询语句 // Logan.Li
const DEFAULT_QUERIES: Record<'loki' | 'elasticsearch', string> = {
loki: '{app="cex-admin-api"} |= ""',
elasticsearch: 'app:cex-admin-api',
// 默认查询语句:Loki 用 LogQL / ES 用 Lucene,对齐平台 admin-api 服务 // Logan.Li
loki: '{app="admin-api"} |= "INFO"',
elasticsearch: 'app:admin-api',
};
// 引擎徽标:左侧小圆点 + 语言标签 // Logan.Li
@@ -51,7 +52,7 @@ const ALL_TEMPLATES: Record<'loki' | 'elasticsearch', { label: string; query: st
{ label: '高延迟筛选', query: '{app="cex-admin-api"} | json | response_time > 500' },
{ label: '异常堆栈', query: '{app="cex-admin-api"} |= "Exception"' },
{ label: '日志趋势', query: 'sum(count_over_time({app="cex-admin-api"}[1h]))' },
{ label: '正则提取', query: '{app="cex-admin-api"} | regexp "(?P<method>\\w+) (?P<path>\\S+)"' },
{ label: '正则提取', query: '{app="cex-admin-api"} | regexp "(?P<method>[A-Za-z0-9_]+) (?P<path>[^ ]+)"' }, // LogQL regexp 用 RE2,不支持 \w \S 等 Perl 简写,须用显式字符类,否则 Loki 返回 400 invalid char escape // Logan.Li
{ label: '格式输出', query: '{app="cex-admin-api"} | json | line_format "{{.level}} {{.message}}"' },
],
elasticsearch: [
@@ -151,8 +152,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const [dsId, setDsId] = useState(String(sources[0]?.id || ''));
// ES 查询允许在面板内临时覆盖索引(默认取自数据源配置)// Logan.Li
const [esIndex, setEsIndex] = useState('');
// API 单次查询返回条数上限(后端限制 500),Loki 用 limit / ES 用 size // Logan.Li
const [queryLimit, setQueryLimit] = useState(200);
// API 单次查询返回条数上限(后端限制 1000),Loki 用 limit / ES 用 size // Logan.Li
const [queryLimit, setQueryLimit] = useState(1000);
const [timeRange, setTimeRange] = useState<TimeRange>(DEFAULT_TIME_RANGE);
const [loading, setLoading] = useState(false);
const [entries, setEntries] = useState<LogEntry[]>([]);
@@ -195,7 +196,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
// 执行查询 — 接入真实 API(无 mock)// Logan.Li
const handleRun = useCallback(async () => {
if (!currentSource) return;
// 空查询兜底:后端 Query 字段 required,空值会 400,这里提前拦截 // Logan.Li
if (!currentSource || !query.trim()) return;
const dsIdNum = currentSource.id;
setLoading(true);
const t0 = performance.now();
@@ -280,7 +282,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
<span className="text-[11px] font-semibold" style={{ color: badge.dotColor }}>{badge.label}</span>
</div>
<SyntaxHighlightInput value={query} onChange={setQuery} engine={effEngine} placeholder={meta.queryPlaceholder} />
<button onClick={handleRun} disabled={loading || !currentSource} className="h-10 px-4 rounded text-xs font-medium text-white flex items-center gap-1.5 flex-shrink-0 transition-opacity hover:opacity-90" style={{ background: '#22c55e' }}>
<button onClick={handleRun} disabled={loading || !currentSource || !query.trim()} className="h-10 px-4 rounded text-xs font-medium text-white flex items-center gap-1.5 flex-shrink-0 transition-opacity hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed" style={{ background: '#22c55e' }}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} 查询
</button>
</div>
@@ -310,11 +312,11 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
<input
type="number"
min={1}
max={500}
max={1000}
value={queryLimit}
onChange={e => {
const v = parseInt(e.target.value, 10);
setQueryLimit(Number.isNaN(v) ? 200 : Math.min(500, Math.max(1, v)));
setQueryLimit(Number.isNaN(v) ? 1000 : Math.min(1000, Math.max(1, v)));
}}
className="w-14 h-6 text-xs text-center rounded outline-none"
style={{ background: 'var(--dg-bg-secondary)', color: 'var(--dg-text-primary)', border: '1px solid var(--dg-border)' }}
@@ -328,8 +330,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
{saveMenuOpen && (<>
<div className="fixed inset-0 z-40" onClick={() => setSaveMenuOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-50 rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<button onClick={() => handleSaveMenu('query')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)' }}><Save className="h-3.5 w-3.5" /> 另存为查询</button>
<button onClick={() => handleSaveMenu('alert')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)', borderTop: '1px solid var(--dg-border)' }}><Bell className="h-3.5 w-3.5" /> 另存为告警</button>
<button onClick={() => handleSaveMenu('query')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)' }}><Save className="h-3.5 w-3.5" /> 查询</button>
<button onClick={() => handleSaveMenu('alert')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)', borderTop: '1px solid var(--dg-border)' }}><Bell className="h-3.5 w-3.5" /> 告警</button>
</div>
</>)}
</div>