feat: 完善堡垒机与 Agent 智能运维能力
- 支持 Windows Agent 构建、安装脚本和统一上报协议 - 增强 RDP 剪贴板、虚拟盘和文件传输能力 - 新增 AIOps 上下文工具、告警 RCA trace 落库和回放接口 - 重构 docs 模块文档并补充终端、堡垒机、监控、AI Agent 设计
This commit is contained in:
1 parent
a9513c2056
commit
e0d652d211
169 files changed
+12192
-9116
No files matched your search
@@ -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>共享盘 > <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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user