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

+125 -119
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useUserStore } from '@/store';
import {
Dialog,
@@ -20,30 +20,38 @@ interface AgentWizardProps {
onOpenChange: (open: boolean) => void;
/** 可选:从服务器列表传入的目标服务器信息 */
server?: { id: number; hostname: string; ip: string; has_auth?: boolean; username?: string; os?: string } | null;
/** SSH一键安装开始回调 */
onStart?: (serverId: number) => void;
/** SSH一键安装成功回调 */
onInstalled?: (serverId: number) => void;
/** SSH一键安装失败回调 */
onFailed?: (serverId: number, message: string) => void;
}
/** 自动检测地址:origin(通过 Nginx 80 代理)用于安装脚本下载,backend 8080 用于 agent 直连 */
/** 自动检测地址:统一使用页面 origin。
* 生产环境 Nginx 会把 /agent 和 /api 代理到后端;本地开发通过 Vite proxy 同样走 origin。
* 不再 hardcode :8080,避免生产环境生成无法访问的地址。// Logan.Li
*/
function detectServerAddress() {
if (typeof window !== 'undefined' && window.location) {
return {
origin: window.location.origin, // http://10.10.10.75 → Nginx
backend: `${window.location.protocol}//${window.location.hostname}:8080`, // http://10.10.10.75:8080 → 后端直达
};
return window.location.origin;
}
return { origin: 'http://127.0.0.1', backend: 'http://127.0.0.1:8080' };
return 'http://127.0.0.1:8080';
}
export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed }: AgentWizardProps) {
export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled, onFailed }: AgentWizardProps) {
const token = useUserStore((s) => s.token) || '<YOUR_TOKEN>';
const { origin, backend } = detectServerAddress();
const origin = detectServerAddress();
const [copied, setCopied] = useState(false);
const [showManualInput, setShowManualInput] = useState(false);
const [sshInstalling, setSshInstalling] = useState(false);
const [activeTab, setActiveTab] = useState('auto');
// 有目标主机时默认展示 SSH 一键安装页,无需用户再点一次 tab // Logan.Li
useEffect(() => {
if (open) {
setActiveTab(server ? 'ssh' : 'auto');
}
}, [open, server]);
// 凭据输入(当服务器无认证凭据时显示)
const [sshUsername, setSshUsername] = useState('root');
const [sshPassword, setSshPassword] = useState('');
@@ -52,8 +60,8 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
const needsCreds = server && !server.has_auth;
// install.sh 通过 Nginx 代理下载(origin:80 → /agent/ → backend:8080)
const installCommand = `curl -fsSL ${origin}/agent/install.sh | bash -s -- --server ${backend} --token ${token}`;
// install.sh 与 Agent 上报均走页面 origin(Nginx/Vite proxy 统一转发到后端)// Logan.Li
const installCommand = `curl -fsSL ${origin}/agent/install.sh | bash -s -- --server ${origin} --token ${token}`;
const handleCopy = () => {
const textArea = document.createElement('textarea');
@@ -130,7 +138,7 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
``,
`[Service]`,
`Type=simple`,
`ExecStart=/usr/local/bin/aiops-agent --server ${backend} --token ${token}`,
`ExecStart=/usr/local/bin/aiops-agent --server ${origin} --token ${token}`,
`Restart=always`,
`RestartSec=5`,
``,
@@ -151,7 +159,7 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
];
// SSH 一键安装(当从服务器列表传入 server 上下文时可用)
// 如果服务器未配置凭据,将输入框中的凭据传给后端,首次连接成功后自动保存
// 改为调用批量异步接口,单台也走后台 goroutine 执行,避免前端长时间等待 // Logan.Li
const handleSshInstall = async () => {
if (!server || !token) return;
if (needsCreds && !sshPassword && !sshKey) {
@@ -159,24 +167,24 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
return;
}
setSshInstalling(true);
onStart?.(server.id);
try {
const creds = needsCreds ? {
username: sshUsername,
password: sshPassword,
ssh_key: sshKey,
auth_type: authType,
} : undefined;
const res: Record<string, unknown> = await serverApi.installAgent(server.id, token, creds);
const installed = res?.installed || false;
if (installed) {
toast.success(`${server.hostname} Agent 安装成功`);
onInstalled?.(server.id);
onOpenChange(false);
} else {
toast.warning(`${server.hostname}: Agent 安装命令已执行,但服务未检测到运行`);
onInstalled?.(server.id);
onOpenChange(false);
}
// 组装临时凭据(如有),key 为服务器 ID,供后端批量接口覆盖使用 // Logan.Li
const credentials = needsCreds
? {
[server.id]: {
username: sshUsername,
password: sshPassword,
ssh_key: sshKey,
auth_type: authType,
},
}
: undefined;
// 优先尝试异步批量接口,即使单台也后台执行
await serverApi.batchInstallAgent([server.id], token, credentials);
toast.success(`${server.hostname}: 已提交 Agent 安装任务,后台执行中…`);
onInstalled?.(server.id);
onOpenChange(false);
} catch (e) {
const msg = e?.message || '安装失败';
toast.error(`${server.hostname} ${msg}`);
@@ -261,113 +269,111 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
<div className="flex items-start gap-2 text-xs text-slate-500">
<Terminal className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>
服务器地址已自动检测 — 安装脚本通过 Nginx 代理 ({origin}),Agent 直连后端 ({backend})。安装成功后将自动连接并开始上报指标。
服务器地址已自动检测 — 安装脚本与 Agent 上报均使用 {origin}(通过 Nginx/Vite proxy 转发到后端)。安装成功后将自动连接并开始上报指标。
</span>
</div>
</TabsContent>
{/* SSH 一键安装 — 无服务器时提示先选择 */}
{!server && (
<TabsContent value="ssh" className="mt-4 space-y-4">
{/* SSH 一键安装 */}
<TabsContent value="ssh" className="mt-4 space-y-4">
{!server ? (
<div className="text-center py-12 text-slate-400">
<Server className="h-10 w-10 mx-auto mb-3 opacity-50" />
<p className="text-sm font-medium mb-1">未选择目标主机</p>
<p className="text-xs">请先在主机列表中勾选一台主机,再点击「安装 Agent」打开此页面</p>
</div>
</TabsContent>
)}
{/* SSH 一键安装 — 有服务器时显示 */}
{server && (
<TabsContent value="ssh" className="mt-4 space-y-4">
<div className="rounded-lg bg-slate-950 border border-slate-800 p-4">
<div className="flex items-center gap-3 mb-3">
<Server className="h-5 w-5 text-blue-400" />
<div>
<p className="text-sm font-medium text-slate-200">{server.hostname}</p>
<code className="text-xs text-slate-400">{server.ip}</code>
</div>
</div>
{/* 未配置凭据时显示输入框 */}
{needsCreds && (
<div className="mb-4 p-3 rounded-lg bg-amber-950/30 border border-amber-800/40 space-y-3">
<div className="flex items-center gap-2 text-xs text-amber-300/80">
<Shield className="h-3.5 w-3.5" />
<span>该服务器未配置 SSH 凭据,请填写以下信息(首次连接成功后自动保存)</span>
) : (
<>
<div className="rounded-lg bg-slate-950 border border-slate-800 p-4">
<div className="flex items-center gap-3 mb-3">
<Server className="h-5 w-5 text-blue-400" />
<div>
<p className="text-sm font-medium text-slate-200">{server.hostname}</p>
<code className="text-xs text-slate-400">{server.ip}</code>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs text-slate-400">用户名</Label>
<Input
value={sshUsername}
onChange={e => setSshUsername(e.target.value)}
placeholder="root"
className="h-8 text-sm bg-slate-900 border-slate-700 text-slate-200 mt-1"
/>
</div>
{/* 未配置凭据时显示输入框 */}
{needsCreds && (
<div className="mb-4 p-3 rounded-lg bg-amber-950/30 border border-amber-800/40 space-y-3">
<div className="flex items-center gap-2 text-xs text-amber-300/80">
<Shield className="h-3.5 w-3.5" />
<span>该服务器未配置 SSH 凭据,请填写以下信息(首次连接成功后自动保存)</span>
</div>
<div className="flex items-end gap-1">
<div className="flex-1">
<Label className="text-xs text-slate-400">密码</Label>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs text-slate-400">用户名</Label>
<Input
type="password"
value={sshPassword}
onChange={e => { setSshPassword(e.target.value); setAuthType('password'); }}
placeholder="******"
value={sshUsername}
onChange={e => setSshUsername(e.target.value)}
placeholder="root"
className="h-8 text-sm bg-slate-900 border-slate-700 text-slate-200 mt-1"
/>
</div>
<span className="text-xs text-slate-500 px-1 pb-1">或</span>
<Button variant="ghost" size="sm"
onClick={() => { setAuthType('key'); setSshPassword(''); }}
className={`text-xs h-7 px-2 ${authType==='key'?'text-blue-400':'text-slate-500'}`}>
密钥
</Button>
<div className="flex items-end gap-1">
<div className="flex-1">
<Label className="text-xs text-slate-400">密码</Label>
<Input
type="password"
value={sshPassword}
onChange={e => { setSshPassword(e.target.value); setAuthType('password'); }}
placeholder="******"
className="h-8 text-sm bg-slate-900 border-slate-700 text-slate-200 mt-1"
/>
</div>
<span className="text-xs text-slate-500 px-1 pb-1">或</span>
<Button variant="ghost" size="sm"
onClick={() => { setAuthType('key'); setSshPassword(''); }}
className={`text-xs h-7 px-2 ${authType==='key'?'text-blue-400':'text-slate-500'}`}>
密钥
</Button>
</div>
</div>
{authType === 'key' && (
<div>
<Label className="text-xs text-slate-400">SSH 私钥(PEM 格式)</Label>
<textarea
value={sshKey}
onChange={e => setSshKey(e.target.value)}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
rows={3}
className="w-full mt-1 bg-slate-900 border border-slate-700 text-slate-200 font-mono text-xs p-2 rounded-md resize-none"
/>
</div>
)}
</div>
{authType === 'key' && (
<div>
<Label className="text-xs text-slate-400">SSH 私钥(PEM 格式)</Label>
<textarea
value={sshKey}
onChange={e => setSshKey(e.target.value)}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
rows={3}
className="w-full mt-1 bg-slate-900 border border-slate-700 text-slate-200 font-mono text-xs p-2 rounded-md resize-none"
/>
</div>
)}
</div>
)}
<p className="text-sm text-slate-400 mb-4">
通过 SSH 远程连接到目标主机,自动下载并安装 Agent,完成后立即启动运行。
</p>
<Button
onClick={handleSshInstall}
disabled={sshInstalling}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
{sshInstalling ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
SSH 连接中,正在安装...
</>
) : (
<>
<Zap className="h-4 w-4 mr-2" />
{needsCreds ? '连接并安装 Agent(凭据将自动保存)' : '一键安装 Agent'}
</>
)}
</Button>
</div>
<div className="flex items-start gap-2 text-xs text-slate-500">
<Terminal className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>
系统将通过 SSH 连接 {server.ip},执行安装脚本。请确保服务器 SSH 凭据已正确配置(编辑服务器可修改密码/密钥)。
</span>
</div>
</TabsContent>
)}
<p className="text-sm text-slate-400 mb-4">
通过 SSH 远程连接到目标主机,自动下载并安装 Agent,完成后立即启动运行。
</p>
<Button
onClick={handleSshInstall}
disabled={sshInstalling}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
{sshInstalling ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
SSH 连接中,正在安装...
</>
) : (
<>
<Zap className="h-4 w-4 mr-2" />
{needsCreds ? '连接并安装 Agent(凭据将自动保存)' : '一键安装 Agent'}
</>
)}
</Button>
</div>
<div className="flex items-start gap-2 text-xs text-slate-500">
<Terminal className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>
系统将通过 SSH 连接 {server.ip},执行安装脚本。请确保服务器 SSH 凭据已正确配置(编辑服务器可修改密码/密钥)。
</span>
</div>
</>
)}
</TabsContent>
{/* 手动安装 */}
<TabsContent value="manual" className="mt-4 space-y-4">