Files
aiops/frontend/src/components/server/RdpToolsPanel.tsx
T
Logan.Li e0d652d211 feat: 完善堡垒机与 Agent 智能运维能力
- 支持 Windows Agent 构建、安装脚本和统一上报协议

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

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

- 重构 docs 模块文档并补充终端、堡垒机、监控、AI Agent 设计
2026-07-05 23:49:36 +08:00

342 lines
13 KiB
TypeScript

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 {
remoteClipboard: string;
onSyncToRemote: (text: string) => void;
onRequestRemoteCopy: () => void;
onUploadVirtualFile: (name: string, base64: string) => boolean;
}
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 工具面板:提供剪贴板同步和 Go-client 上的 AIOps 盘上传。 // Logan.Li
export function RdpToolsPanel({
remoteClipboard,
onSyncToRemote,
onRequestRemoteCopy,
onUploadVirtualFile,
}: RdpToolsPanelProps) {
const { t } = useTranslation();
const [localClipDraft, setLocalClipDraft] = useState("");
const [uploading, setUploading] = useState(false);
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"));
return;
}
onSyncToRemote(trimmed);
toast.success(t("server.syncedToRemote"));
};
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 {
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);
}
};
return (
<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="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)}
rows={3}
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>
</div>
</details>
<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>
<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>
)}
</div>
</div>
);
}