- 支持 Windows Agent 构建、安装脚本和统一上报协议 - 增强 RDP 剪贴板、虚拟盘和文件传输能力 - 新增 AIOps 上下文工具、告警 RCA trace 落库和回放接口 - 重构 docs 模块文档并补充终端、堡垒机、监控、AI Agent 设计
342 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|