feat: 完善堡垒机与 Agent 智能运维能力

- 支持 Windows Agent 构建、安装脚本和统一上报协议

- 增强 RDP 剪贴板、虚拟盘和文件传输能力

- 新增 AIOps 上下文工具、告警 RCA trace 落库和回放接口

- 重构 docs 模块文档并补充终端、堡垒机、监控、AI Agent 设计
This commit is contained in:
Logan.Li committed 2026-07-05 23:49:36 +08:00
1 parent a9513c2056
commit e0d652d211
169 files changed
+12192 -9116

No files matched your search

+295 -181
View File
@@ -1,227 +1,341 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { readFromClipboard } from '@/utils/clipboard';
import { rdpFilesApi, type RdpFileItem } from '@/api/server/rdp-files';
import { Button } from '@/components/ui/button';
import { Download, FolderOpen, RefreshCw, Settings2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { RDP_VIRTUAL_DOWNLOAD_ROOT } from './rdpConstants';
type ToolsTab = 'general' | 'files';
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { readFromClipboard, copyToClipboard } from "@/utils/clipboard";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import { useSessionSecurityStore } from "@/store";
import {
CheckCircle2,
ClipboardPaste,
Copy,
FileUp,
FolderDown,
FolderUp,
Upload,
} from "lucide-react";
import { toast } from "sonner";
interface RdpToolsPanelProps {
serverId: number;
remoteClipboard: string;
onSyncToRemote: (text: string) => void;
onAutoExec: (command: string) => void;
onRequestRemoteCopy: () => void;
onUploadVirtualFile: (name: string, base64: string) => boolean;
}
function fmtSize(size: number): string {
if (size >= 1 << 20) return `${(size / (1 << 20)).toFixed(1)} MB`;
if (size >= 1 << 10) return `${(size / (1 << 10)).toFixed(1)} KB`;
interface UploadState {
name: string;
sizeLabel: string;
progress: number;
status: "reading" | "sending" | "staged" | "failed";
}
const RDP_UPLOAD_LIMIT = 50 * 1024 * 1024;
function formatBytes(size: number) {
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
if (size >= 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${size} B`;
}
// RDP 工具面板:通用(剪贴板)+ 文件(SMB 直传,失败时自动回退)// Logan.Li
// RDP 工具面板:提供剪贴板同步和 Go-client 上的 AIOps 盘上传。 // Logan.Li
export function RdpToolsPanel({
serverId,
remoteClipboard,
onSyncToRemote,
onAutoExec,
onRequestRemoteCopy,
onUploadVirtualFile,
}: RdpToolsPanelProps) {
const { t } = useTranslation();
const [tab, setTab] = useState<ToolsTab>('general');
const [localClipDraft, setLocalClipDraft] = useState('');
const [files, setFiles] = useState<RdpFileItem[]>([]);
const [loadingFiles, setLoadingFiles] = useState(false);
const [localClipDraft, setLocalClipDraft] = useState("");
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const refreshFiles = useCallback(async () => {
setLoadingFiles(true);
try {
const data = await rdpFilesApi.list(serverId);
setFiles(data?.list ?? []);
} catch (err) {
setFiles([]);
toast.error(err instanceof Error ? err.message : t('server.listFileError'));
} finally {
setLoadingFiles(false);
}
}, [serverId]);
useEffect(() => {
if (tab === 'files') void refreshFiles();
}, [tab, refreshFiles]);
const fillLocalClipOnFocus = async () => {
const text = await readFromClipboard();
if (text) {
setLocalClipDraft(text);
return;
}
toast.info(t('server.clipboardHint'));
};
const [uploadState, setUploadState] = useState<UploadState | null>(null);
const [dragActive, setDragActive] = useState(false);
const clipboardEnabled = useSessionSecurityStore(
(state) => state.clipboardEnabled,
);
const fileUploadEnabled = useSessionSecurityStore(
(state) => state.fileUploadEnabled,
);
const fileDownloadEnabled = useSessionSecurityStore(
(state) => state.fileDownloadEnabled,
);
const syncLocalClip = () => {
const trimmed = localClipDraft.trim();
if (!trimmed) {
toast.info(t('server.emptyContent'));
toast.info(t("server.emptyContent"));
return;
}
onSyncToRemote(trimmed);
toast.success(t('server.syncedToRemote'));
toast.success(t("server.syncedToRemote"));
};
const uploadWithFallback = async (file: File) => {
const uploadToVirtualDrive = async (file: File) => {
if (file.size > RDP_UPLOAD_LIMIT) {
toast.error(
t("server.rdpUploadTooLarge", { limit: formatBytes(RDP_UPLOAD_LIMIT) }),
);
return;
}
if (!fileUploadEnabled) {
toast.info(t("server.sessionPolicy.fileUploadDisabled"));
return;
}
setUploading(true);
setUploadState({
name: file.name,
sizeLabel: formatBytes(file.size),
progress: 0,
status: "reading",
});
try {
try {
await rdpFilesApi.upload(serverId, file);
toast.success(t('server.uploadedToDownload', { name: file.name }));
void refreshFiles();
return;
} catch {
// SMB 不可达时走 AIOps 暂存 + RDP 自动执行 // Logan.Li
}
const staged = await rdpFilesApi.stage(serverId, file);
onAutoExec(staged.deploy_command);
toast.success(t('server.autoSent', { name: file.name }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('server.rdpUploadFailed'));
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error("read failed"));
reader.onprogress = (event) => {
if (!event.lengthComputable) return;
// 当前链路先读入浏览器内存,进度条展示本地读取进度。 // Logan.Li
const progress = Math.min(
90,
Math.round((event.loaded / event.total) * 90),
);
setUploadState((prev) =>
prev ? { ...prev, progress, status: "reading" } : prev,
);
};
reader.onload = () => {
const raw = String(reader.result || "");
resolve(raw.includes(",") ? raw.split(",")[1] : raw);
};
reader.readAsDataURL(file);
});
setUploadState((prev) =>
prev ? { ...prev, progress: 95, status: "sending" } : prev,
);
const sent = onUploadVirtualFile(file.name, base64);
if (!sent) throw new Error("websocket closed");
setUploadState((prev) =>
prev ? { ...prev, progress: 100, status: "staged" } : prev,
);
toast.success(t("server.uploadedToVirtualDrive", { name: file.name }));
} catch {
setUploadState((prev) => (prev ? { ...prev, status: "failed" } : prev));
toast.error(t("server.rdpUploadFailed"));
} finally {
setUploading(false);
}
};
const handleUploadPick = async (picked: File | undefined) => {
if (!picked) return;
await uploadWithFallback(picked);
setTab('files');
};
const handleDownload = async (name: string) => {
try {
await rdpFilesApi.download(serverId, name);
toast.success(t('server.downloadStarted', { name }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('server.rdpDownloadFailed'));
}
};
return (
<div className="w-[380px]">
<div className="flex border-b border-[var(--dg-border)]">
<button
type="button"
onClick={() => setTab('general')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs transition-colors',
tab === 'general'
? 'text-[var(--dg-text-primary)] border-b-2 border-[var(--dg-blue)]'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]',
)}
>
<Settings2 className="h-3.5 w-3.5" />
{t('server.general')}
</button>
<button
type="button"
onClick={() => setTab('files')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs transition-colors',
tab === 'files'
? 'text-[var(--dg-text-primary)] border-b-2 border-[var(--dg-blue)]'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]',
)}
>
<FolderOpen className="h-3.5 w-3.5" />
{t('server.rdpFiles')}
</button>
</div>
{tab === 'general' && (
<div className="p-3 space-y-3">
<div className="space-y-1.5">
<label className="text-xs text-[var(--dg-text-muted)]">{t('server.remoteClipboard')}</label>
<textarea
readOnly
value={remoteClipboard}
rows={3}
placeholder={t('server.remoteClipPlaceholder')}
className="w-full resize-none rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 py-1.5 text-xs text-[var(--dg-text-secondary)] outline-none"
/>
<div className="w-[336px] p-3">
<div className="space-y-3">
{/* 剪贴板区块保持两个主动作,避免远端复制和本地粘贴混在一起。 // Logan.Li */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-[var(--dg-text-secondary)]">
{t("server.rdpClipboard")}
</span>
{remoteClipboard ? (
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-500">
<CheckCircle2 className="h-3.5 w-3.5" />
{t("server.clipboardReady")}
</span>
) : null}
</div>
<div className="space-y-1.5">
<label className="text-xs text-[var(--dg-text-muted)]">{t('server.syncToRemote')}</label>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
size="sm"
className="h-9"
disabled={!clipboardEnabled}
onClick={async () => {
if (!clipboardEnabled) return;
// 一键粘贴到远端:读取本地剪贴板 → 发送到 RDP。 // Logan.Li
const text = await readFromClipboard();
if (!text) {
toast.info(t("server.clipboardHint"));
return;
}
onSyncToRemote(text);
toast.success(t("server.syncedToRemote"));
}}
>
<ClipboardPaste className="h-4 w-4 mr-1.5" />
{t("server.pasteToRemote")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-9"
disabled={!clipboardEnabled}
onClick={async () => {
if (!clipboardEnabled) return;
// 一键复制远端剪贴板:无缓存时先请求远端 Ctrl+C,避免误报“远端为空”。 // Logan.Li
if (!remoteClipboard) {
onRequestRemoteCopy();
toast.info(t("server.remoteCopyRequested"));
return;
}
const ok = await copyToClipboard(remoteClipboard);
if (ok) toast.success(t("server.copiedToLocal"));
else toast.error(t("server.copyFailed"));
}}
>
<Copy className="h-4 w-4 mr-1.5" />
{t("server.copyFromRemote")}
</Button>
</div>
</section>
{/* 远端剪贴板预览 // Logan.Li */}
<textarea
readOnly
value={remoteClipboard}
rows={4}
placeholder={t("server.remoteClipPlaceholder")}
className="w-full resize-none rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 py-1.5 text-xs text-[var(--dg-text-secondary)] outline-none"
/>
{/* 手动输入同步到远端(高级) // Logan.Li */}
<details className="text-xs">
<summary className="cursor-pointer text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]">
{t("server.manualSync")}
</summary>
<div className="mt-2 space-y-1.5">
<textarea
value={localClipDraft}
onChange={(e) => setLocalClipDraft(e.target.value)}
onFocus={() => { void fillLocalClipOnFocus(); }}
onPaste={(e) => {
const pasted = e.clipboardData.getData('text/plain');
if (pasted) setLocalClipDraft(pasted);
}}
rows={3}
placeholder={t('server.syncPlaceholder')}
className="w-full resize-none rounded border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-2 py-1.5 text-xs text-[var(--dg-text-primary)] outline-none"
placeholder={t("server.syncPlaceholder")}
className="w-full resize-none rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 py-1.5 text-xs text-[var(--dg-text-primary)] outline-none"
/>
<Button type="button" size="sm" className="w-full" onClick={syncLocalClip}>
{t('server.syncButton')}
<Button
type="button"
size="sm"
className="w-full"
onClick={syncLocalClip}
>
{t("server.syncButton")}
</Button>
</div>
</div>
)}
</details>
{tab === 'files' && (
<div className="p-3 space-y-3">
<div className="flex items-center justify-between text-xs text-[var(--dg-text-muted)]">
<span>共享盘 &gt; <span className="text-[var(--dg-text-secondary)]">Download</span></span>
<button type="button" onClick={() => { void refreshFiles(); }} className="p-1 rounded hover:bg-[var(--dg-bg-hover)]" title="刷新">
<RefreshCw className={cn('h-3.5 w-3.5', loadingFiles && 'animate-spin')} />
</button>
<div className="h-px bg-[var(--dg-border)]" />
{/* 虚拟盘区块按 Windows 资源管理器里的展示名称组织。 // Logan.Li */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<div className="min-w-0">
<div className="text-xs font-medium text-[var(--dg-text-secondary)]">
{t("server.aiopsVirtualDrive")}
</div>
<div className="mt-0.5 truncate text-[11px] text-[var(--dg-text-muted)]">
{t("server.aiopsVirtualDriveLocation")}
</div>
</div>
<span
className="shrink-0 rounded bg-[var(--dg-bg-secondary)] px-1.5 py-0.5 text-[10px] text-[var(--dg-text-muted)]"
title={"\\\\tsclient\\AIOps"}
>
UNC
</span>
</div>
<code className="block text-[10px] leading-relaxed text-[var(--dg-cyan)] bg-[var(--dg-bg-primary)] rounded px-2 py-1.5 break-all">
{RDP_VIRTUAL_DOWNLOAD_ROOT}
</code>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => {
void handleUploadPick(e.target.files?.[0]);
e.currentTarget.value = '';
}}
/>
<Button type="button" size="sm" className="w-full" disabled={uploading} onClick={() => fileInputRef.current?.click()}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
{uploading ? t('server.rdpUploading') : t('server.uploadFile')}
</Button>
<div className="max-h-40 overflow-y-auto rounded border border-[var(--dg-border)]">
{files.length === 0 ? (
<p className="px-2 py-3 text-[10px] text-[var(--dg-text-muted)] text-center">
{loadingFiles ? t('server.rdpLoading') : t('server.noRdpFiles')}
</p>
) : (
files.map((f) => (
<div key={f.name} className="flex items-center justify-between gap-2 px-2 py-1.5 text-xs border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-hover)]">
<div className="min-w-0 flex-1">
<p className="truncate text-[var(--dg-text-primary)]">{f.name}</p>
<p className="text-[10px] text-[var(--dg-text-muted)]">{fmtSize(f.size)}</p>
</div>
<button type="button" onClick={() => { void handleDownload(f.name); }} className="p-1 rounded hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]" title="下载">
<Download className="h-3.5 w-3.5" />
</button>
</div>
))
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div className="flex items-center gap-1.5 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 py-1.5 text-[var(--dg-text-secondary)]">
<FolderUp className="h-3.5 w-3.5 text-sky-500" />
Upload
</div>
<div className="flex items-center gap-1.5 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 py-1.5 text-[var(--dg-text-secondary)]">
<FolderDown className="h-3.5 w-3.5 text-emerald-500" />
Download
</div>
</div>
<label
className={cn(
"flex min-h-[82px] cursor-pointer flex-col items-center justify-center gap-1 rounded border border-dashed border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 py-3 text-center text-xs text-[var(--dg-text-secondary)] transition-colors",
dragActive &&
"border-primary bg-primary/10 text-[var(--dg-text-primary)]",
uploading && "cursor-not-allowed opacity-70",
)}
onDragEnter={(e) => {
e.preventDefault();
setDragActive(true);
}}
onDragOver={(e) => {
e.preventDefault();
setDragActive(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setDragActive(false);
}}
onDrop={(e) => {
e.preventDefault();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file && !uploading && fileUploadEnabled)
void uploadToVirtualDrive(file);
}}
>
{uploading ? (
<Upload className="h-5 w-5 text-primary" />
) : (
<FileUp className="h-5 w-5 text-primary" />
)}
<span className="font-medium">
{uploading
? t("server.rdpUploading")
: t("server.uploadToVirtualDrive")}
</span>
<span className="text-[11px] text-[var(--dg-text-muted)]">
{t("server.rdpUploadHint", {
limit: formatBytes(RDP_UPLOAD_LIMIT),
})}
</span>
<input
type="file"
className="hidden"
disabled={uploading || !fileUploadEnabled}
onChange={(e) => {
const file = e.target.files?.[0];
e.currentTarget.value = "";
if (file) void uploadToVirtualDrive(file);
}}
/>
</label>
{(!fileUploadEnabled ||
!fileDownloadEnabled ||
!clipboardEnabled) && (
<div className="rounded border border-amber-500/25 bg-amber-500/10 px-2 py-1.5 text-[11px] text-amber-500">
{t("server.sessionPolicy.restricted")}
</div>
)}
</section>
{uploadState && (
<div className="rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 py-2 text-xs">
<div className="flex items-center justify-between gap-3">
<span
className="truncate text-[var(--dg-text-primary)]"
title={uploadState.name}
>
{uploadState.name}
</span>
<span className="shrink-0 text-[var(--dg-text-muted)]">
{uploadState.sizeLabel}
</span>
</div>
<Progress className="mt-2 h-1.5" value={uploadState.progress} />
<div className="mt-1 flex items-center justify-between gap-2 text-[var(--dg-text-muted)]">
<span className="truncate">
{t(`server.rdpUploadStatus.${uploadState.status}`)}
</span>
<span className="shrink-0">{uploadState.progress}%</span>
</div>
</div>
<p className="text-[10px] text-[var(--dg-text-muted)] leading-relaxed">
{t('server.smbHint')}
</p>
</div>
)}
)}
</div>
</div>
);
}
}