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,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Credential } from '@/components/deploy/credential-manager/types';
|
||||
|
||||
@@ -28,6 +29,7 @@ export function ImportCredentialSettings({
|
||||
onSelectedRdpCredentialIdChange,
|
||||
compact = false,
|
||||
}: ImportCredentialSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const labelClass = compact
|
||||
? 'block text-xs font-medium text-[var(--dg-text-secondary)] mb-1'
|
||||
: 'block text-sm font-medium text-[var(--dg-text-secondary)] mb-2';
|
||||
@@ -40,8 +42,8 @@ export function ImportCredentialSettings({
|
||||
<div className={panelClass}>
|
||||
<div>
|
||||
<label className={labelClass}>
|
||||
SSH 端口
|
||||
<span className="text-[var(--dg-text-muted)] ml-1">(Linux 默认)</span>
|
||||
{t('server.importCred.sshPort')}
|
||||
<span className="text-[var(--dg-text-muted)] ml-1">{t('server.importCred.linuxDefault')}</span>
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -53,18 +55,18 @@ export function ImportCredentialSettings({
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>
|
||||
Linux SSH 凭证
|
||||
<span className="text-[var(--dg-text-muted)] ml-1">(可选)</span>
|
||||
{t('server.importCred.sshCredential')}
|
||||
<span className="text-[var(--dg-text-muted)] ml-1">{t('server.importCred.optional')}</span>
|
||||
</label>
|
||||
{sshCredentials.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] py-2">暂无 SSH 凭证</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] py-2">{t('server.importCred.noSshCred')}</p>
|
||||
) : (
|
||||
<select
|
||||
value={selectedSshCredentialId}
|
||||
onChange={(e) => onSelectedSshCredentialIdChange(e.target.value)}
|
||||
className={`w-full px-3 py-2 ${inputBg} border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm`}
|
||||
>
|
||||
<option value="">不使用凭证</option>
|
||||
<option value="">{t('server.importCred.noCredential')}</option>
|
||||
{sshCredentials.map((cred) => (
|
||||
<option key={cred.id} value={String(cred.id)}>
|
||||
{credentialLabel(cred)}
|
||||
@@ -75,18 +77,18 @@ export function ImportCredentialSettings({
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>
|
||||
Windows RDP 密码
|
||||
<span className="text-[var(--dg-text-muted)] ml-1">(可选)</span>
|
||||
{t('server.importCred.rdpPassword')}
|
||||
<span className="text-[var(--dg-text-muted)] ml-1">{t('server.importCred.optional')}</span>
|
||||
</label>
|
||||
{rdpCredentials.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] py-2">暂无 RDP 凭证</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] py-2">{t('server.importCred.noRdpCred')}</p>
|
||||
) : (
|
||||
<select
|
||||
value={selectedRdpCredentialId}
|
||||
onChange={(e) => onSelectedRdpCredentialIdChange(e.target.value)}
|
||||
className={`w-full px-3 py-2 ${inputBg} border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm`}
|
||||
>
|
||||
<option value="">不使用凭证</option>
|
||||
<option value="">{t('server.importCred.noCredential')}</option>
|
||||
{rdpCredentials.map((cred) => (
|
||||
<option key={cred.id} value={String(cred.id)}>
|
||||
{credentialLabel(cred)}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getWsBase } from '@/utils/request';
|
||||
import { useUserStore } from '@/store';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
|
||||
import { RdpToolsPanel } from './RdpToolsPanel';
|
||||
import { Loader2, Maximize2, Minimize2, Wrench, X } from 'lucide-react';
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getWsBase } from "@/utils/request";
|
||||
import { useSessionSecurityStore, useUserStore } from "@/store";
|
||||
import { copyToClipboard, readFromClipboard } from "@/utils/clipboard";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import { RdpToolsPanel } from "./RdpToolsPanel";
|
||||
import { SessionWatermark } from "@/components/common/SessionWatermark";
|
||||
import { Loader2, Maximize2, Minimize2, Wrench, X } from "lucide-react";
|
||||
|
||||
interface RdpDialogProps {
|
||||
open: boolean;
|
||||
@@ -30,6 +36,9 @@ interface RdpFrame {
|
||||
image?: string;
|
||||
message?: string;
|
||||
text?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface RdpStats {
|
||||
@@ -38,9 +47,15 @@ interface RdpStats {
|
||||
failed: number;
|
||||
}
|
||||
|
||||
const RDP_WIDTH = 1024;
|
||||
const RDP_HEIGHT = 800;
|
||||
const RDP_DEFAULT_WIDTH = 900;
|
||||
const RDP_DEFAULT_HEIGHT = 620;
|
||||
const RDP_MIN_WIDTH = 720;
|
||||
const RDP_MIN_HEIGHT = 520;
|
||||
const RDP_MAX_WIDTH = 1920;
|
||||
const RDP_MAX_HEIGHT = 1200;
|
||||
const RDP_BINARY_HEADER = 20;
|
||||
const RDP_HEADER_HEIGHT = 41;
|
||||
const RDP_DIALOG_VERTICAL_GAP = 96;
|
||||
|
||||
interface RdpRawFrame {
|
||||
x: number;
|
||||
@@ -50,27 +65,87 @@ interface RdpRawFrame {
|
||||
rgba: Uint8ClampedArray;
|
||||
}
|
||||
|
||||
export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }: RdpDialogProps) {
|
||||
function clampRdpSize(value: number, min: number, max: number) {
|
||||
const clamped = Math.max(min, Math.min(max, value));
|
||||
return clamped % 2 === 0 ? clamped : clamped - 1;
|
||||
}
|
||||
|
||||
function calcRdpViewport(fullscreen: boolean) {
|
||||
if (!fullscreen) {
|
||||
return {
|
||||
// 13 寸屏默认保留外层操作空间,最大化时再提升远端分辨率。 // Logan.Li
|
||||
width: Math.min(
|
||||
RDP_DEFAULT_WIDTH,
|
||||
clampRdpSize(
|
||||
Math.floor(window.innerWidth * 0.78),
|
||||
RDP_MIN_WIDTH,
|
||||
RDP_DEFAULT_WIDTH,
|
||||
),
|
||||
),
|
||||
height: Math.min(
|
||||
RDP_DEFAULT_HEIGHT,
|
||||
clampRdpSize(
|
||||
Math.floor(window.innerHeight - 180),
|
||||
RDP_MIN_HEIGHT,
|
||||
RDP_DEFAULT_HEIGHT,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
const maxWidth = fullscreen
|
||||
? window.innerWidth
|
||||
: Math.min(window.innerWidth * 0.94, RDP_MAX_WIDTH);
|
||||
const maxHeight = fullscreen
|
||||
? window.innerHeight - RDP_HEADER_HEIGHT
|
||||
: window.innerHeight - RDP_DIALOG_VERTICAL_GAP;
|
||||
return {
|
||||
width: clampRdpSize(Math.floor(maxWidth), RDP_MIN_WIDTH, RDP_MAX_WIDTH),
|
||||
height: clampRdpSize(Math.floor(maxHeight), RDP_MIN_HEIGHT, RDP_MAX_HEIGHT),
|
||||
};
|
||||
}
|
||||
|
||||
export function RdpDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
serverId,
|
||||
serverName,
|
||||
serverIp,
|
||||
}: RdpDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const paintQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const statsRef = useRef<RdpStats>({ received: 0, drawn: 0, failed: 0 });
|
||||
const pendingMouseMoveRef = useRef<Record<string, unknown> | null>(null);
|
||||
const mouseMoveTimerRef = useRef<number | null>(null);
|
||||
const keyModifierRef = useRef({
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false,
|
||||
});
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [status, setStatus] = useState('等待连接');
|
||||
const [status, setStatus] = useState(t("server.rdpDialog.waiting"));
|
||||
const [hasFrame, setHasFrame] = useState(false);
|
||||
const [canvasSize, setCanvasSize] = useState({ width: RDP_WIDTH, height: RDP_HEIGHT });
|
||||
const [displaySize, setDisplaySize] = useState({ width: RDP_WIDTH, height: RDP_HEIGHT });
|
||||
const [canvasSize, setCanvasSize] = useState(() => calcRdpViewport(false));
|
||||
const [displaySize, setDisplaySize] = useState(() => calcRdpViewport(false));
|
||||
const [toolsOpen, setToolsOpen] = useState(false);
|
||||
const [remoteClipboard, setRemoteClipboard] = useState('');
|
||||
const [remoteClipboard, setRemoteClipboard] = useState("");
|
||||
const clipboardEnabled = useSessionSecurityStore(
|
||||
(state) => state.clipboardEnabled,
|
||||
);
|
||||
const fileDownloadEnabled = useSessionSecurityStore(
|
||||
(state) => state.fileDownloadEnabled,
|
||||
);
|
||||
|
||||
const sendEvent = (event: Record<string, unknown>) => {
|
||||
const ws = wsRef.current;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(event));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const flushMouseMove = () => {
|
||||
@@ -95,17 +170,90 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
|
||||
}, 16);
|
||||
};
|
||||
|
||||
const patchKeyModifier = (
|
||||
e: KeyboardEvent<HTMLCanvasElement>,
|
||||
down: boolean,
|
||||
) => {
|
||||
const next = keyModifierRef.current;
|
||||
if (e.code === "ControlLeft" || e.code === "ControlRight") next.ctrl = down;
|
||||
if (e.code === "AltLeft" || e.code === "AltRight") next.alt = down;
|
||||
if (e.code === "ShiftLeft" || e.code === "ShiftRight") next.shift = down;
|
||||
if (e.code === "MetaLeft" || e.code === "MetaRight") next.meta = down;
|
||||
};
|
||||
|
||||
const currentModifiers = (e: KeyboardEvent<HTMLCanvasElement>) => {
|
||||
const state = keyModifierRef.current;
|
||||
// keyup 事件经常丢失 ctrlKey/metaKey,使用本地状态保证组合键释放也能发到远端。 // Logan.Li
|
||||
return {
|
||||
ctrl: state.ctrl || e.ctrlKey,
|
||||
alt: state.alt || e.altKey,
|
||||
shift: state.shift || e.shiftKey,
|
||||
meta: state.meta || e.metaKey,
|
||||
};
|
||||
};
|
||||
|
||||
const handleKeyDown = async (e: KeyboardEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
patchKeyModifier(e, true);
|
||||
const modifiers = currentModifiers(e);
|
||||
const pasteCombo = (e.metaKey || e.ctrlKey) && e.code === "KeyV";
|
||||
if (pasteCombo) {
|
||||
if (!clipboardEnabled) {
|
||||
setStatus(t("server.sessionPolicy.clipboardDisabled"));
|
||||
return;
|
||||
}
|
||||
const text = await readFromClipboard();
|
||||
if (text) sendEvent({ type: "clipboard", text });
|
||||
return;
|
||||
}
|
||||
sendEvent({
|
||||
type: "key",
|
||||
code: e.code,
|
||||
key: e.key,
|
||||
down: true,
|
||||
...modifiers,
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
if ((e.metaKey || e.ctrlKey) && e.code === "KeyV") return;
|
||||
const modifiers = currentModifiers(e);
|
||||
patchKeyModifier(e, false);
|
||||
sendEvent({
|
||||
type: "key",
|
||||
code: e.code,
|
||||
key: e.key,
|
||||
down: false,
|
||||
...modifiers,
|
||||
});
|
||||
};
|
||||
|
||||
const requestRemoteCopy = () => {
|
||||
// 工具按钮使用独立事件,避免和用户在远端窗口手按 Ctrl+C 的键盘状态互相污染。 // Logan.Li
|
||||
if (!clipboardEnabled) {
|
||||
setStatus(t("server.sessionPolicy.clipboardDisabled"));
|
||||
return;
|
||||
}
|
||||
flushMouseMove();
|
||||
sendEvent({ type: "remote_copy" });
|
||||
setStatus(t("server.rdpDialog.remoteCopyRequested"));
|
||||
};
|
||||
|
||||
const patchStats = (patch: Partial<RdpStats>) => {
|
||||
statsRef.current = { ...statsRef.current, ...patch };
|
||||
};
|
||||
|
||||
const paintBitmap = async (ctx: CanvasRenderingContext2D, frame: RdpFrame) => {
|
||||
const paintBitmap = async (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
frame: RdpFrame,
|
||||
) => {
|
||||
const dx = frame.x || 0;
|
||||
const dy = frame.y || 0;
|
||||
const dw = frame.width || 0;
|
||||
const dh = frame.height || 0;
|
||||
try {
|
||||
if ('createImageBitmap' in window && frame.image) {
|
||||
if ("createImageBitmap" in window && frame.image) {
|
||||
const blob = await fetch(frame.image).then((resp) => resp.blob());
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
ctx.drawImage(bitmap, dx, dy, dw || bitmap.width, dh || bitmap.height);
|
||||
@@ -122,12 +270,15 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
|
||||
ctx.drawImage(img, dx, dy, dw || img.width, dh || img.height);
|
||||
resolve();
|
||||
};
|
||||
img.onerror = () => reject(new Error('RDP 位图解码失败'));
|
||||
img.src = frame.image || '';
|
||||
img.onerror = () =>
|
||||
reject(new Error(t("server.rdpDialog.bitmapDecodeFailed")));
|
||||
img.src = frame.image || "";
|
||||
});
|
||||
};
|
||||
|
||||
const parseRawFrame = async (data: Blob | ArrayBuffer): Promise<RdpRawFrame | null> => {
|
||||
const parseRawFrame = async (
|
||||
data: Blob | ArrayBuffer,
|
||||
): Promise<RdpRawFrame | null> => {
|
||||
const buffer = data instanceof Blob ? await data.arrayBuffer() : data;
|
||||
if (buffer.byteLength < RDP_BINARY_HEADER) return null;
|
||||
const view = new DataView(buffer);
|
||||
@@ -136,13 +287,18 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
|
||||
String.fromCharCode(view.getUint8(1)) +
|
||||
String.fromCharCode(view.getUint8(2)) +
|
||||
String.fromCharCode(view.getUint8(3));
|
||||
if (magic !== 'RDPR') return null;
|
||||
if (magic !== "RDPR") return null;
|
||||
const x = view.getUint32(4, true);
|
||||
const y = view.getUint32(8, true);
|
||||
const width = view.getUint32(12, true);
|
||||
const height = view.getUint32(16, true);
|
||||
const bytes = width * height * 4;
|
||||
if (width <= 0 || height <= 0 || buffer.byteLength < RDP_BINARY_HEADER + bytes) return null;
|
||||
if (
|
||||
width <= 0 ||
|
||||
height <= 0 ||
|
||||
buffer.byteLength < RDP_BINARY_HEADER + bytes
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
@@ -155,45 +311,70 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
|
||||
const drawRawFrame = async (data: Blob | ArrayBuffer) => {
|
||||
const frame = await parseRawFrame(data);
|
||||
if (!frame) return;
|
||||
setStatus('');
|
||||
setStatus("");
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx) return;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
patchStats({ received: statsRef.current.received + 1 });
|
||||
try {
|
||||
const pixels = new Uint8ClampedArray(frame.rgba.length);
|
||||
pixels.set(frame.rgba);
|
||||
ctx.putImageData(new ImageData(pixels, frame.width, frame.height), frame.x, frame.y);
|
||||
ctx.putImageData(
|
||||
new ImageData(pixels, frame.width, frame.height),
|
||||
frame.x,
|
||||
frame.y,
|
||||
);
|
||||
setHasFrame(true);
|
||||
patchStats({ drawn: statsRef.current.drawn + 1 });
|
||||
} catch {
|
||||
patchStats({ failed: statsRef.current.failed + 1 });
|
||||
setStatus('RDP 原始帧绘制失败');
|
||||
setStatus(t("server.rdpDialog.rawFrameDrawFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const drawFrame = async (frame: RdpFrame) => {
|
||||
if (frame.type === 'status' || frame.type === 'error') {
|
||||
setStatus(frame.message || '');
|
||||
if (frame.type === "status" || frame.type === "error") {
|
||||
setStatus(frame.message || "");
|
||||
return;
|
||||
}
|
||||
if (frame.type === 'session') {
|
||||
if (frame.type === "session") {
|
||||
setConnected(true);
|
||||
if (frame.width && frame.height) setCanvasSize({ width: frame.width, height: frame.height });
|
||||
if (frame.width && frame.height)
|
||||
setCanvasSize({ width: frame.width, height: frame.height });
|
||||
return;
|
||||
}
|
||||
if (frame.type === 'clipboard') {
|
||||
if (frame.type === "clipboard") {
|
||||
if (!clipboardEnabled) return;
|
||||
if (frame.text) {
|
||||
setRemoteClipboard(frame.text);
|
||||
await copyToClipboard(frame.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (frame.type !== 'bitmap' || !frame.image) return;
|
||||
setStatus('');
|
||||
if (frame.type === "rdp_file_download") {
|
||||
if (!fileDownloadEnabled) {
|
||||
setStatus(t("server.sessionPolicy.fileDownloadDisabled"));
|
||||
return;
|
||||
}
|
||||
if (frame.url) {
|
||||
const link = document.createElement("a");
|
||||
link.href = frame.url;
|
||||
link.download = frame.name || "";
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setStatus(
|
||||
t("server.rdpDialog.downloadStarted", { name: frame.name || "file" }),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (frame.type !== "bitmap" || !frame.image) return;
|
||||
setStatus("");
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx) return;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
const received = statsRef.current.received + 1;
|
||||
@@ -204,44 +385,41 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
|
||||
patchStats({ drawn: statsRef.current.drawn + 1 });
|
||||
} catch {
|
||||
patchStats({ failed: statsRef.current.failed + 1 });
|
||||
setStatus(`RDP 位图绘制失败,已收 ${received} 帧`);
|
||||
setStatus(t("server.rdpDialog.bitmapDrawFailed", { count: received }));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !serverId) return;
|
||||
|
||||
const token = useUserStore.getState().token || localStorage.getItem('token') || '';
|
||||
const maxDisplayWidth = fullscreen ? window.innerWidth : Math.min(window.innerWidth * 0.94, RDP_WIDTH);
|
||||
const maxDisplayHeight = fullscreen ? window.innerHeight - 41 : window.innerHeight - 96;
|
||||
const scale = Math.min(maxDisplayWidth / RDP_WIDTH, maxDisplayHeight / RDP_HEIGHT, 1);
|
||||
const displayWidth = Math.round(RDP_WIDTH * scale);
|
||||
const displayHeight = Math.round(RDP_HEIGHT * scale);
|
||||
// 固定 1024x800 会话尺寸,先排除分辨率变化对画质诊断的干扰。 // Logan.Li
|
||||
const width = RDP_WIDTH;
|
||||
const height = RDP_HEIGHT;
|
||||
setDisplaySize({ width: displayWidth, height: displayHeight });
|
||||
const token =
|
||||
useUserStore.getState().token || localStorage.getItem("token") || "";
|
||||
const { width, height } = calcRdpViewport(false);
|
||||
// 会话分辨率跟随弹窗可用空间,减少大屏下固定 1024x800 造成的左右黑边。 // Logan.Li
|
||||
setDisplaySize({ width, height });
|
||||
setCanvasSize({ width, height });
|
||||
setStatus('正在创建 RDP 会话...');
|
||||
setStatus(t("server.rdpDialog.creating"));
|
||||
setHasFrame(false);
|
||||
setRemoteClipboard('');
|
||||
setRemoteClipboard("");
|
||||
statsRef.current = { received: 0, drawn: 0, failed: 0 };
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (canvas && ctx) {
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
const ws = new WebSocket(`${getWsBase()}/ws/rdp?id=${serverId}&width=${width}&height=${height}&token=${encodeURIComponent(token)}`);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
const ws = new WebSocket(
|
||||
`${getWsBase()}/ws/rdp?id=${serverId}&width=${width}&height=${height}&token=${encodeURIComponent(token)}`,
|
||||
);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => setConnected(true);
|
||||
ws.onmessage = (event) => {
|
||||
if (typeof event.data !== 'string') {
|
||||
if (typeof event.data !== "string") {
|
||||
paintQueueRef.current = paintQueueRef.current
|
||||
.catch(() => undefined)
|
||||
.then(() => drawRawFrame(event.data as Blob | ArrayBuffer));
|
||||
@@ -253,13 +431,13 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
|
||||
.catch(() => undefined)
|
||||
.then(() => drawFrame(frame));
|
||||
} catch {
|
||||
setStatus(String(event.data || '收到无法解析的 RDP 帧'));
|
||||
setStatus(String(event.data || t("server.rdpDialog.unparseableFrame")));
|
||||
}
|
||||
};
|
||||
ws.onerror = () => setStatus('RDP 连接发生错误');
|
||||
ws.onerror = () => setStatus(t("server.rdpDialog.connectionError"));
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
setStatus((prev) => prev || 'RDP 连接已关闭');
|
||||
setStatus((prev) => prev || t("server.rdpDialog.connectionClosed"));
|
||||
};
|
||||
|
||||
return () => {
|
||||
@@ -270,16 +448,23 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
|
||||
setConnected(false);
|
||||
setToolsOpen(false);
|
||||
};
|
||||
}, [open, serverId, fullscreen]);
|
||||
}, [open, serverId]);
|
||||
|
||||
const canvasPoint = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = Math.round((event.clientX - rect.left) * (canvasSize.width / rect.width));
|
||||
const y = Math.round((event.clientY - rect.top) * (canvasSize.height / rect.height));
|
||||
const x = Math.round(
|
||||
(event.clientX - rect.left) * (canvasSize.width / rect.width),
|
||||
);
|
||||
const y = Math.round(
|
||||
(event.clientY - rect.top) * (canvasSize.height / rect.height),
|
||||
);
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const showConnectionIssue = Boolean(status && /失败|错误|关闭|无法解析/.test(status));
|
||||
// 状态文本匹配错误关键词(中英双语,兼容 i18n 切换) // Logan.Li
|
||||
const showConnectionIssue = Boolean(
|
||||
status && /失败|错误|关闭|无法解析|fail|error|clos|unpars/i.test(status),
|
||||
);
|
||||
const showCenterLoading = open && !hasFrame && !showConnectionIssue;
|
||||
|
||||
return (
|
||||
@@ -287,19 +472,32 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0 overflow-hidden',
|
||||
"bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0 overflow-hidden",
|
||||
fullscreen
|
||||
? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
|
||||
: '',
|
||||
? "!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0"
|
||||
: "",
|
||||
)}
|
||||
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '1120px', width: '94vw' }}
|
||||
style={
|
||||
fullscreen
|
||||
? { maxWidth: "100vw", width: "100vw", height: "100vh" }
|
||||
: { maxWidth: displaySize.width, width: displaySize.width }
|
||||
}
|
||||
>
|
||||
<DialogTitle className="sr-only">RDP - {serverName}</DialogTitle>
|
||||
<DialogDescription className="sr-only">Windows RDP 图形会话</DialogDescription>
|
||||
<DialogDescription className="sr-only">
|
||||
{t("server.rdpDialog.graphicSession")}
|
||||
</DialogDescription>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className={cn('w-2 h-2 rounded-full', connected ? 'bg-emerald-400' : 'bg-red-400')} />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">{serverName}</span>
|
||||
<div
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
connected ? "bg-emerald-400" : "bg-red-400",
|
||||
)}
|
||||
/>
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">
|
||||
{serverName}
|
||||
</span>
|
||||
<span className="text-[var(--dg-text-muted)]">({serverIp})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -310,25 +508,35 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
|
||||
type="button"
|
||||
onClick={() => setToolsOpen((v) => !v)}
|
||||
className={cn(
|
||||
'p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]',
|
||||
toolsOpen && 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]',
|
||||
"p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]",
|
||||
toolsOpen &&
|
||||
"bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]",
|
||||
)}
|
||||
title="工具"
|
||||
title={t("server.rdpDialog.tools")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
</button>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent align="end" className="p-0 w-auto border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="p-0 w-auto border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]"
|
||||
>
|
||||
<RdpToolsPanel
|
||||
serverId={serverId}
|
||||
remoteClipboard={remoteClipboard}
|
||||
onSyncToRemote={(text) => {
|
||||
sendEvent({ type: 'clipboard', text });
|
||||
setStatus('已同步到远端');
|
||||
sendEvent({ type: "clipboard", text });
|
||||
setStatus(t("server.rdpDialog.synced"));
|
||||
}}
|
||||
onAutoExec={(command) => {
|
||||
sendEvent({ type: 'auto_exec', text: command });
|
||||
setStatus('正在自动执行上传命令...');
|
||||
onRequestRemoteCopy={requestRemoteCopy}
|
||||
onUploadVirtualFile={(name, text) => {
|
||||
const sent = sendEvent({
|
||||
type: "rdp_file_upload",
|
||||
name,
|
||||
text,
|
||||
});
|
||||
if (!sent) return false;
|
||||
setStatus(t("server.rdpDialog.uploadStaged", { name }));
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
@@ -339,7 +547,11 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
>
|
||||
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
{fullscreen ? (
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -352,7 +564,11 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
|
||||
</div>
|
||||
<div
|
||||
className="relative bg-black flex items-center justify-center"
|
||||
style={{ height: fullscreen ? 'calc(100vh - 41px)' : displaySize.height }}
|
||||
style={{
|
||||
height: fullscreen
|
||||
? `calc(100vh - ${RDP_HEADER_HEIGHT}px)`
|
||||
: displaySize.height,
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
@@ -360,19 +576,54 @@ export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }
|
||||
height={canvasSize.height}
|
||||
tabIndex={0}
|
||||
className="max-w-full max-h-full outline-none"
|
||||
style={{ width: displaySize.width, height: displaySize.height }}
|
||||
onMouseMove={(e) => sendMouseMove({ type: 'mouse_move', ...canvasPoint(e) })}
|
||||
onMouseDown={(e) => { e.preventDefault(); e.currentTarget.focus(); flushMouseMove(); sendEvent({ type: 'mouse_down', button: e.button, ...canvasPoint(e) }); }}
|
||||
onMouseUp={(e) => { e.preventDefault(); flushMouseMove(); sendEvent({ type: 'mouse_up', button: e.button, ...canvasPoint(e) }); }}
|
||||
onWheel={(e) => { flushMouseMove(); sendEvent({ type: 'wheel', delta_y: Math.trunc(e.deltaY), ...canvasPoint(e) }); }}
|
||||
style={{
|
||||
// 最大化只缩放当前画面,不重连 RDP,避免登录过程闪黑。 // Logan.Li
|
||||
width: fullscreen ? "100%" : displaySize.width,
|
||||
height: fullscreen ? "100%" : displaySize.height,
|
||||
}}
|
||||
onMouseMove={(e) =>
|
||||
sendMouseMove({ type: "mouse_move", ...canvasPoint(e) })
|
||||
}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.focus();
|
||||
flushMouseMove();
|
||||
sendEvent({
|
||||
type: "mouse_down",
|
||||
button: e.button,
|
||||
...canvasPoint(e),
|
||||
});
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
e.preventDefault();
|
||||
flushMouseMove();
|
||||
sendEvent({
|
||||
type: "mouse_up",
|
||||
button: e.button,
|
||||
...canvasPoint(e),
|
||||
});
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
flushMouseMove();
|
||||
sendEvent({
|
||||
type: "wheel",
|
||||
delta_y: Math.trunc(e.deltaY),
|
||||
...canvasPoint(e),
|
||||
});
|
||||
}}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onPaste={(e) => {
|
||||
const text = e.clipboardData.getData('text/plain');
|
||||
if (text) sendEvent({ type: 'clipboard', text });
|
||||
if (!clipboardEnabled) {
|
||||
setStatus(t("server.sessionPolicy.clipboardDisabled"));
|
||||
return;
|
||||
}
|
||||
const text = e.clipboardData.getData("text/plain");
|
||||
if (text) sendEvent({ type: "clipboard", text });
|
||||
}}
|
||||
onKeyDown={(e) => { e.preventDefault(); sendEvent({ type: 'key', code: e.code, key: e.key, down: true }); }}
|
||||
onKeyUp={(e) => { e.preventDefault(); sendEvent({ type: 'key', code: e.code, key: e.key, down: false }); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
/>
|
||||
<SessionWatermark />
|
||||
{showCenterLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/25">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-white/80" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -12,7 +11,7 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -20,14 +19,14 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
@@ -39,34 +38,32 @@ import {
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { useServers, useServerMutations, queryKeys } from '@/hooks/api';
|
||||
import { ServerStatsStrip } from './ServerStatsStrip';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { useUserStore } from '@/store';
|
||||
import { CloudServerImport } from './CloudServerImport';
|
||||
import { TerminalDialog } from './TerminalDialog';
|
||||
import { RdpDialog } from './RdpDialog';
|
||||
import { ServerFileBrowser } from './ServerFileBrowser';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { ExcelServerImport } from './ExcelServerImport';
|
||||
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
|
||||
import { getTags } from './serverHelpers';
|
||||
import { ServerTableRow } from './ServerTableRow';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||||
import { Server } from 'lucide-react';
|
||||
} from "lucide-react";
|
||||
import { useServers, useServerMutations } from "@/hooks/api";
|
||||
import { ServerStatsStrip } from "./ServerStatsStrip";
|
||||
import { useConfirm } from "@/components/ui/confirm-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { CloudServerImport } from "./CloudServerImport";
|
||||
import { TerminalDialog } from "./TerminalDialog";
|
||||
import { RdpDialog } from "./RdpDialog";
|
||||
import { ServerFileBrowser } from "./ServerFileBrowser";
|
||||
import type { JsonData } from "@/types/common";
|
||||
import { ExcelServerImport } from "./ExcelServerImport";
|
||||
import { ServerForm, EMPTY_FORM, type ServerFormData } from "./ServerForm";
|
||||
import { getTags } from "./serverHelpers";
|
||||
import { ServerTableRow } from "./ServerTableRow";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import { PageShell } from "@/components/layout/PageShell";
|
||||
import { ContentPanel } from "@/components/layout/ContentPanel";
|
||||
import { Server } from "lucide-react";
|
||||
|
||||
export function ServerManager() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const { hasPermission } = usePermission();
|
||||
const [selectedServers, setSelectedServers] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [cloudImportOpen, setCloudImportOpen] = useState(false);
|
||||
const [excelImportOpen, setExcelImportOpen] = useState(false);
|
||||
|
||||
@@ -79,13 +76,25 @@ export function ServerManager() {
|
||||
|
||||
// 终端 Dialog 状态
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string; hasAuth?: boolean } | null>(null);
|
||||
const [terminalServer, setTerminalServer] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
hasAuth?: boolean;
|
||||
} | null>(null);
|
||||
const [rdpOpen, setRdpOpen] = useState(false);
|
||||
const [rdpServer, setRdpServer] = useState<{ id: number; name: string; ip: string } | null>(null);
|
||||
const [rdpServer, setRdpServer] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
} | null>(null);
|
||||
|
||||
// 文件浏览 Dialog 状态
|
||||
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||
const [fileBrowserServer, setFileBrowserServer] = useState<{ id: number; name: string } | null>(null);
|
||||
const [fileBrowserServer, setFileBrowserServer] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
// 分页状态
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -94,17 +103,32 @@ export function ServerManager() {
|
||||
const queryParams = useMemo(() => {
|
||||
const params: Record<string, unknown> = { page, page_size: pageSize };
|
||||
if (searchQuery) params.keyword = searchQuery;
|
||||
if (statusFilter && statusFilter !== 'all') params.status = statusFilter;
|
||||
if (statusFilter && statusFilter !== "all") params.status = statusFilter;
|
||||
return params;
|
||||
}, [page, pageSize, searchQuery, statusFilter]);
|
||||
|
||||
const { data, isLoading: loading, isFetching, refetch } = useServers(queryParams);
|
||||
const { createMutation, updateMutation, deleteMutation, batchDeleteMutation } = useServerMutations();
|
||||
const {
|
||||
data,
|
||||
isLoading: loading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useServers(queryParams);
|
||||
const {
|
||||
createMutation,
|
||||
updateMutation,
|
||||
deleteMutation,
|
||||
batchDeleteMutation,
|
||||
} = useServerMutations();
|
||||
const servers = data?.list ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
const openTerminal = (server: JsonData) => {
|
||||
setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip, hasAuth: server.has_auth ?? true });
|
||||
setTerminalServer({
|
||||
id: server.id,
|
||||
name: server.hostname,
|
||||
ip: server.ip,
|
||||
hasAuth: server.has_auth ?? true,
|
||||
});
|
||||
setTerminalOpen(true);
|
||||
};
|
||||
|
||||
@@ -128,85 +152,6 @@ export function ServerManager() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// WebSocket 实时接收 Agent 指标推送 // Logan.Li
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = useUserStore.getState().token;
|
||||
if (!token) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/metrics?token=${token}`;
|
||||
|
||||
const connect = () => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
// WebSocket 已连接,静默接收指标推送
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'metrics_update') {
|
||||
queryClient.setQueryData(queryKeys.servers(queryParams), (old: typeof data) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
list: old.list.map((s) => {
|
||||
if (s.hostname === msg.hostname) {
|
||||
return {
|
||||
...s,
|
||||
cpu_usage: msg.cpu_usage,
|
||||
memory_usage: msg.memory_usage,
|
||||
disk_usage: msg.disk_usage,
|
||||
status: msg.status,
|
||||
tcp_established: msg.tcp_established,
|
||||
tcp_time_wait: msg.tcp_time_wait,
|
||||
net_rx_bps: msg.net_rx_bps,
|
||||
net_tx_bps: msg.net_tx_bps,
|
||||
disk_read_bps: msg.disk_read_bps,
|
||||
disk_write_bps: msg.disk_write_bps,
|
||||
};
|
||||
}
|
||||
return s;
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 忽略非 JSON 消息
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
// 连接断开,3 秒后重连
|
||||
wsRef.current = null;
|
||||
reconnectTimerRef.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('[MetricsWS] 连接错误:', err);
|
||||
ws.close();
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null; // 阻止自动重连
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [queryClient, queryParams]);
|
||||
|
||||
// 导出 CSV
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedServers.length === servers.length) {
|
||||
@@ -227,18 +172,18 @@ export function ServerManager() {
|
||||
const handleDelete = async (id: string) => {
|
||||
const server = servers.find((s) => String(s.id) === String(id));
|
||||
const ok = await confirm({
|
||||
title: t('server.deleteTitle'),
|
||||
title: t("server.deleteTitle"),
|
||||
description: `${server?.hostname || id}`,
|
||||
confirmText: t('common.delete'),
|
||||
variant: 'destructive',
|
||||
confirmText: t("common.delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
toast.success(t('server.deleted'));
|
||||
toast.success(t("server.deleted"));
|
||||
} catch (e) {
|
||||
console.error('[ServerManager] delete failed:', e);
|
||||
toast.error(t('server.deleteFailed'));
|
||||
console.error("[ServerManager] delete failed:", e);
|
||||
toast.error(t("server.deleteFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,19 +191,23 @@ export function ServerManager() {
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedServers.length === 0) return;
|
||||
const ok = await confirm({
|
||||
title: t('server.batchDeleteTitle'),
|
||||
title: t("server.batchDeleteTitle"),
|
||||
description: `${selectedServers.length}`,
|
||||
confirmText: t('server.batchDelete'),
|
||||
variant: 'destructive',
|
||||
confirmText: t("server.batchDelete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await batchDeleteMutation.mutateAsync(selectedServers.map((id) => Number(id)));
|
||||
toast.success(t('server.deletedCount', { count: selectedServers.length }));
|
||||
await batchDeleteMutation.mutateAsync(
|
||||
selectedServers.map((id) => Number(id)),
|
||||
);
|
||||
toast.success(
|
||||
t("server.deletedCount", { count: selectedServers.length }),
|
||||
);
|
||||
setSelectedServers([]);
|
||||
} catch (e) {
|
||||
console.error('[ServerManager] batch delete failed:', e);
|
||||
toast.error(t('server.batchDeleteFailed'));
|
||||
console.error("[ServerManager] batch delete failed:", e);
|
||||
toast.error(t("server.batchDeleteFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -274,25 +223,27 @@ export function ServerManager() {
|
||||
const openEditForm = (server: JsonData) => {
|
||||
setEditingId(server.id);
|
||||
setFormData({
|
||||
hostname: server.hostname || '',
|
||||
ip: server.ip || '',
|
||||
public_ip: server.public_ip || '',
|
||||
hostname: server.hostname || "",
|
||||
ip: server.ip || "",
|
||||
public_ip: server.public_ip || "",
|
||||
port: String(server.port || 22),
|
||||
username: server.username || 'root',
|
||||
auth_type: server.auth_type || 'password',
|
||||
os_type: server.os_type || ((server.protocol === 'rdp') ? 'windows' : 'linux'),
|
||||
protocol: server.protocol || ((server.os_type === 'windows') ? 'rdp' : 'ssh'),
|
||||
username: server.username || "root",
|
||||
auth_type: server.auth_type || "password",
|
||||
os_type:
|
||||
server.os_type || (server.protocol === "rdp" ? "windows" : "linux"),
|
||||
protocol:
|
||||
server.protocol || (server.os_type === "windows" ? "rdp" : "ssh"),
|
||||
rdp_port: String(server.rdp_port || 3389),
|
||||
password: '',
|
||||
ssh_key: '',
|
||||
password: "",
|
||||
ssh_key: "",
|
||||
credential_id: null as number | null,
|
||||
os: server.os || '',
|
||||
cpu: String(server.cpu || ''),
|
||||
memory: server.memory ? String(Math.round(server.memory / 1024)) : '', // MB → GB
|
||||
disk: String(server.disk || ''),
|
||||
status: server.status || 'running',
|
||||
tags: server.tags || '',
|
||||
description: server.description || '',
|
||||
os: server.os || "",
|
||||
cpu: String(server.cpu || ""),
|
||||
memory: server.memory ? String(Math.round(server.memory / 1024)) : "", // MB → GB
|
||||
disk: String(server.disk || ""),
|
||||
status: server.status || "running",
|
||||
tags: server.tags || "",
|
||||
description: server.description || "",
|
||||
});
|
||||
setDetailsOpen(false);
|
||||
setFormOpen(true);
|
||||
@@ -308,11 +259,11 @@ export function ServerManager() {
|
||||
ip: formData.ip,
|
||||
public_ip: formData.public_ip || undefined,
|
||||
port: Number(formData.port) || 22,
|
||||
os_type: formData.os_type || 'linux',
|
||||
protocol: formData.protocol || 'ssh',
|
||||
os_type: formData.os_type || "linux",
|
||||
protocol: formData.protocol || "ssh",
|
||||
rdp_port: Number(formData.rdp_port) || 3389,
|
||||
username: formData.username || 'root',
|
||||
auth_type: formData.auth_type || 'password',
|
||||
username: formData.username || "root",
|
||||
auth_type: formData.auth_type || "password",
|
||||
os: formData.os,
|
||||
cpu: Number(formData.cpu) || 0,
|
||||
memory: (Number(formData.memory) || 0) * 1024, // GB → MB
|
||||
@@ -338,11 +289,11 @@ export function ServerManager() {
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
toast.success(editingId ? t('server.updated') : t('server.created'));
|
||||
toast.success(editingId ? t("server.updated") : t("server.created"));
|
||||
setFormOpen(false);
|
||||
} catch (e) {
|
||||
console.error('[ServerManager] save failed:', e);
|
||||
toast.error(t('server.saveFailed'));
|
||||
console.error("[ServerManager] save failed:", e);
|
||||
toast.error(t("server.saveFailed"));
|
||||
} finally {
|
||||
setFormSaving(false);
|
||||
}
|
||||
@@ -351,34 +302,55 @@ export function ServerManager() {
|
||||
// 导出 CSV — 将当前服务器列表导出为 CSV 文件 // Logan.Li
|
||||
const handleExport = () => {
|
||||
if (servers.length === 0) {
|
||||
toast.error(t('server.noExportData'));
|
||||
toast.error(t("server.noExportData"));
|
||||
return;
|
||||
}
|
||||
const headers = [
|
||||
t('server.csv.hostname'), t('server.csv.ip'), t('server.csv.publicIp'), t('server.csv.port'),
|
||||
t('server.csv.os'), t('server.csv.cpu'), t('server.csv.memory'), t('server.csv.disk'),
|
||||
t('server.csv.status'), t('server.csv.tags'),
|
||||
t("server.csv.hostname"),
|
||||
t("server.csv.ip"),
|
||||
t("server.csv.publicIp"),
|
||||
t("server.csv.port"),
|
||||
t("server.csv.os"),
|
||||
t("server.csv.cpu"),
|
||||
t("server.csv.memory"),
|
||||
t("server.csv.disk"),
|
||||
t("server.csv.status"),
|
||||
t("server.csv.tags"),
|
||||
];
|
||||
const rows = servers.map((s) => {
|
||||
const memGB = s.memory ? Math.round(Number(s.memory) / 1024) : 0;
|
||||
const tags = getTags(s).join(';');
|
||||
return [s.hostname || '', s.ip || '', s.public_ip || '', s.port || 22, s.os || '',
|
||||
s.cpu || 0, memGB, s.disk || 0, s.status || '', tags];
|
||||
const tags = getTags(s).join(";");
|
||||
return [
|
||||
s.hostname || "",
|
||||
s.ip || "",
|
||||
s.public_ip || "",
|
||||
s.port || 22,
|
||||
s.os || "",
|
||||
s.cpu || 0,
|
||||
memGB,
|
||||
s.disk || 0,
|
||||
s.status || "",
|
||||
tags,
|
||||
];
|
||||
});
|
||||
const csv = [headers, ...rows]
|
||||
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
.map((row) =>
|
||||
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","),
|
||||
)
|
||||
.join("\n");
|
||||
const blob = new Blob(["\ufeff" + csv], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `servers_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t('server.exported', { count: servers.length }));
|
||||
toast.success(t("server.exported", { count: servers.length }));
|
||||
};
|
||||
|
||||
const colSpanCount = 9;
|
||||
const colSpanCount = 8;
|
||||
|
||||
const tableToolbar = (
|
||||
<>
|
||||
@@ -386,7 +358,7 @@ export function ServerManager() {
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
||||
<Input
|
||||
placeholder={t('server.searchPlaceholder')}
|
||||
placeholder={t("server.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-9 h-9 dg-input"
|
||||
@@ -394,24 +366,33 @@ export function ServerManager() {
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={handleStatusFilter}>
|
||||
<SelectTrigger className="w-32 h-9 dg-input">
|
||||
<SelectValue placeholder={t('common.status')} />
|
||||
<SelectValue placeholder={t("common.status")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('server.allStatus')}</SelectItem>
|
||||
<SelectItem value="running">{t('common.running')}</SelectItem>
|
||||
<SelectItem value="stopped">{t('common.stopped')}</SelectItem>
|
||||
<SelectItem value="maintenance">{t('common.maintenance')}</SelectItem>
|
||||
<SelectItem value="offline">{t('common.offline')}</SelectItem>
|
||||
<SelectItem value="all">{t("server.allStatus")}</SelectItem>
|
||||
<SelectItem value="running">{t("common.running")}</SelectItem>
|
||||
<SelectItem value="stopped">{t("common.stopped")}</SelectItem>
|
||||
<SelectItem value="maintenance">
|
||||
{t("common.maintenance")}
|
||||
</SelectItem>
|
||||
<SelectItem value="offline">{t("common.offline")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{selectedServers.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm dg-text-secondary">{t('server.selected', { count: selectedServers.length })}</span>
|
||||
{hasPermission('server:delete') && (
|
||||
<Button variant="outline" size="sm" className="dg-btn-danger dg-btn-sm" onClick={handleBatchDelete}>
|
||||
<span className="text-sm dg-text-secondary">
|
||||
{t("server.selected", { count: selectedServers.length })}
|
||||
</span>
|
||||
{hasPermission("server:delete") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="dg-btn-danger dg-btn-sm"
|
||||
onClick={handleBatchDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t('server.batchDelete')}
|
||||
{t("server.batchDelete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -422,150 +403,195 @@ export function ServerManager() {
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title={t('server.title')}
|
||||
subtitle={t('server.subtitle')}
|
||||
title={t("server.title")}
|
||||
subtitle={t("server.subtitle")}
|
||||
icon={<Server className="h-5 w-5" />}
|
||||
metrics={<ServerStatsStrip servers={servers} />}
|
||||
actions={(
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{t('common.refresh')}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="dg-btn-secondary"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("h-4 w-4 mr-2", isFetching && "animate-spin")}
|
||||
/>
|
||||
{t("common.refresh")}
|
||||
</Button>
|
||||
<Button variant="outline" className="dg-btn-secondary" onClick={handleExport}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="dg-btn-secondary"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{t('common.export')}
|
||||
{t("common.export")}
|
||||
</Button>
|
||||
{hasPermission('server:create') && (
|
||||
{hasPermission("server:create") && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="dg-btn-secondary">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
{t('server.batchImport')}
|
||||
{t("server.batchImport")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>{t('server.importMethod')}</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{t("server.importMethod")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setCloudImportOpen(true)}>
|
||||
<Cloud className="h-4 w-4 mr-2 dg-text-blue" />
|
||||
{t('server.cloudImport')}
|
||||
<span className="ml-2 text-xs dg-text-muted">{t('server.cloudImportHint')}</span>
|
||||
{t("server.cloudImport")}
|
||||
<span className="ml-2 text-xs dg-text-muted">
|
||||
{t("server.cloudImportHint")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setExcelImportOpen(true)}>
|
||||
<FileSpreadsheet className="h-4 w-4 mr-2 dg-text-green" />
|
||||
{t('server.csvImport')}
|
||||
<span className="ml-2 text-xs dg-text-muted">{t('server.csvImportHint')}</span>
|
||||
{t("server.csvImport")}
|
||||
<span className="ml-2 text-xs dg-text-muted">
|
||||
{t("server.csvImportHint")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{hasPermission('server:create') && (
|
||||
{hasPermission("server:create") && (
|
||||
<Button className="dg-btn-primary" onClick={openCreateForm}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('server.addServer')}
|
||||
{t("server.addServer")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
}
|
||||
>
|
||||
<ContentPanel toolbar={tableToolbar}>
|
||||
<div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedServers.length === servers.length && servers.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.hostname')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.ipAddress')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.publicIp')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">{t('common.status')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.config')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.usage')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.tags')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-right">{t('common.actions')}</TableHead>
|
||||
<div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedServers.length === servers.length &&
|
||||
servers.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">
|
||||
{t("server.hostname")}
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">
|
||||
{t("server.ipAddress")}
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">
|
||||
{t("server.publicIp")}
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">
|
||||
{t("common.status")}
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">
|
||||
{t("server.config")}
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">
|
||||
{t("server.tags")}
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell
|
||||
colSpan={colSpanCount}
|
||||
className="text-center text-[var(--dg-text-muted)] py-12"
|
||||
>
|
||||
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||||
{t("common.loading")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={colSpanCount} className="text-center text-[var(--dg-text-muted)] py-12">
|
||||
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||||
{t('common.loading')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : servers.length === 0 ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={colSpanCount} className="text-center text-[var(--dg-text-muted)] py-12">
|
||||
{t('server.noData')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
servers.map((server) => (
|
||||
<ServerTableRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
selected={selectedServers.includes(String(server.id))}
|
||||
onToggleSelect={toggleSelect}
|
||||
onOpenTerminal={openTerminal}
|
||||
onOpenRdp={openRdp}
|
||||
onOpenFiles={openFileBrowser}
|
||||
onOpenEdit={openEditForm}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell
|
||||
colSpan={colSpanCount}
|
||||
className="text-center text-[var(--dg-text-muted)] py-12"
|
||||
>
|
||||
{t("server.noData")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
servers.map((server) => (
|
||||
<ServerTableRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
selected={selectedServers.includes(String(server.id))}
|
||||
onToggleSelect={toggleSelect}
|
||||
onOpenTerminal={openTerminal}
|
||||
onOpenRdp={openRdp}
|
||||
onOpenFiles={openFileBrowser}
|
||||
onOpenEdit={openEditForm}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
|
||||
<span>{t('server.perPage')}</span>
|
||||
<Select value={String(pageSize)} onValueChange={(v) => { setPageSize(Number(v)); setPage(1); }}>
|
||||
<SelectTrigger className="w-16 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="30">30</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span>{t('server.items')}</span>
|
||||
<span className="ml-4">{t('server.totalItems', { count: total })}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] px-2">
|
||||
{page} / {Math.max(1, Math.ceil(total / pageSize))}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
|
||||
<span>{t("server.perPage")}</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(v) => {
|
||||
setPageSize(Number(v));
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-16 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="30">30</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span>{t("server.items")}</span>
|
||||
<span className="ml-4">
|
||||
{t("server.totalItems", { count: total })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] px-2">
|
||||
{page} / {Math.max(1, Math.ceil(total / pageSize))}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ContentPanel>
|
||||
|
||||
{/* 创建/编辑服务器 Dialog */}
|
||||
@@ -584,8 +610,8 @@ export function ServerManager() {
|
||||
open={rdpOpen}
|
||||
onOpenChange={setRdpOpen}
|
||||
serverId={rdpServer?.id ?? null}
|
||||
serverName={rdpServer?.name || ''}
|
||||
serverIp={rdpServer?.ip || ''}
|
||||
serverName={rdpServer?.name || ""}
|
||||
serverIp={rdpServer?.ip || ""}
|
||||
/>
|
||||
|
||||
{/* Import Dialogs */}
|
||||
@@ -609,8 +635,8 @@ export function ServerManager() {
|
||||
open={terminalOpen}
|
||||
onOpenChange={setTerminalOpen}
|
||||
serverId={terminalServer?.id ?? null}
|
||||
serverName={terminalServer?.name ?? ''}
|
||||
serverIp={terminalServer?.ip ?? ''}
|
||||
serverName={terminalServer?.name ?? ""}
|
||||
serverIp={terminalServer?.ip ?? ""}
|
||||
hasAuth={terminalServer?.hasAuth ?? true}
|
||||
/>
|
||||
|
||||
@@ -619,7 +645,7 @@ export function ServerManager() {
|
||||
open={fileBrowserOpen}
|
||||
onOpenChange={setFileBrowserOpen}
|
||||
serverId={fileBrowserServer?.id ?? null}
|
||||
serverName={fileBrowserServer?.name ?? ''}
|
||||
serverName={fileBrowserServer?.name ?? ""}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// 服务器表格行 — 纯展示子组件(接收 props,不直接调 API) // Logan.Li
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -10,12 +11,20 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive, FolderOpen, Monitor } from 'lucide-react';
|
||||
import { getStatusBadge, getTags, getUsageColor, isRdpServer } from './serverHelpers';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import type { JsonData } from '@/types/common';
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Edit,
|
||||
MoreHorizontal,
|
||||
Server as ServerIcon,
|
||||
Terminal,
|
||||
Trash2,
|
||||
FolderOpen,
|
||||
Monitor,
|
||||
} from "lucide-react";
|
||||
import { getStatusBadge, getTags, isRdpServer } from "./serverHelpers";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import type { JsonData } from "@/types/common";
|
||||
|
||||
interface ServerTableRowProps {
|
||||
server?: JsonData;
|
||||
@@ -38,26 +47,12 @@ export function ServerTableRow({
|
||||
onOpenFiles,
|
||||
onDelete,
|
||||
}: ServerTableRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { hasPermission } = usePermission();
|
||||
const cpuUsage = Number(server.cpu_usage || 0);
|
||||
const memoryUsage = Number(server.memory_usage || 0);
|
||||
const tags = getTags(server);
|
||||
const memGB = server.memory ? Math.round(server.memory / 1024) : 0;
|
||||
const tcpEst = server.tcp_established || 0;
|
||||
const tcpTw = server.tcp_time_wait || 0;
|
||||
const _rxBps = server.net_rx_bps || 0;
|
||||
const _txBps = server.net_tx_bps || 0;
|
||||
const readBps = server.disk_read_bps || 0;
|
||||
const writeBps = server.disk_write_bps || 0;
|
||||
const isRdp = isRdpServer(server);
|
||||
|
||||
// Format bytes/s to human readable
|
||||
const fmtBps = (bps: number) => {
|
||||
if (bps >= 1_000_000) return (bps / 1_000_000).toFixed(1) + 'MB/s';
|
||||
if (bps >= 1_000) return (bps / 1_000).toFixed(1) + 'KB/s';
|
||||
return bps.toFixed(0) + 'B/s';
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<TableCell>
|
||||
@@ -70,7 +65,9 @@ export function ServerTableRow({
|
||||
<div className="flex items-center gap-2">
|
||||
<ServerIcon className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<div>
|
||||
<p className="font-medium text-[var(--dg-text-primary)]">{server.hostname}</p>
|
||||
<p className="font-medium text-[var(--dg-text-primary)]">
|
||||
{server.hostname}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{server.os}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,46 +89,16 @@ export function ServerTableRow({
|
||||
<TableCell>{getStatusBadge(server.status)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-[var(--dg-text-secondary)]">
|
||||
<span className="text-[var(--dg-text-muted)]">{server.cpu}核</span>
|
||||
<span className="text-[var(--dg-text-muted)]">
|
||||
{server.cpu}
|
||||
{t("server.table.cores")}
|
||||
</span>
|
||||
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
|
||||
<span className="text-[var(--dg-text-muted)]">{memGB}GB</span>
|
||||
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
|
||||
<span className="text-[var(--dg-text-muted)]">{server.disk}GB</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--dg-text-muted)] w-8">CPU</span>
|
||||
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full', getUsageColor(cpuUsage))}
|
||||
style={{ width: `${cpuUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">{cpuUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--dg-text-muted)] w-8">内存</span>
|
||||
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full', getUsageColor(memoryUsage))}
|
||||
style={{ width: `${memoryUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">{memoryUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)] pt-0.5">
|
||||
<Network className="h-3 w-3" />
|
||||
<span>{tcpEst}连接</span>
|
||||
{tcpTw > 0 && <span className="text-[var(--dg-orange)]">({tcpTw}TW)</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)]">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
<span>读{fmtBps(readBps)}/写{fmtBps(writeBps)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.slice(0, 3).map((tag) => (
|
||||
@@ -144,7 +111,10 @@ export function ServerTableRow({
|
||||
</Badge>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
|
||||
>
|
||||
+{tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -152,62 +122,95 @@ export function ServerTableRow({
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{hasPermission('server:webshell') && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => isRdp ? onOpenRdp(server) : onOpenTerminal(server)} title={isRdp ? 'RDP登录' : '终端登录'}>
|
||||
{isRdp ? <Monitor className="h-4 w-4" /> : <Terminal className="h-4 w-4" />}
|
||||
</Button>
|
||||
{hasPermission("server:webshell") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]"
|
||||
onClick={() =>
|
||||
isRdp ? onOpenRdp(server) : onOpenTerminal(server)
|
||||
}
|
||||
title={
|
||||
isRdp
|
||||
? t("server.table.rdpLogin")
|
||||
: t("server.table.terminalLogin")
|
||||
}
|
||||
>
|
||||
{isRdp ? (
|
||||
<Monitor className="h-4 w-4" />
|
||||
) : (
|
||||
<Terminal className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{(hasPermission('server:update') || hasPermission('server:delete') || hasPermission('server:webshell')) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
||||
{hasPermission('server:update') && (
|
||||
<DropdownMenuItem
|
||||
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
|
||||
onClick={() => onOpenEdit(server)}
|
||||
{(hasPermission("server:update") ||
|
||||
hasPermission("server:delete") ||
|
||||
hasPermission("server:webshell")) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasPermission('server:webshell') && (
|
||||
<DropdownMenuItem
|
||||
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
|
||||
onClick={() => isRdp ? onOpenRdp(server) : onOpenTerminal(server)}
|
||||
>
|
||||
{isRdp ? <Monitor className="h-4 w-4 mr-2" /> : <Terminal className="h-4 w-4 mr-2" />}
|
||||
{isRdp ? 'RDP' : '终端'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasPermission('server:update') && (
|
||||
<DropdownMenuItem
|
||||
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
|
||||
onClick={() => onOpenFiles?.(server)}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
文件
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasPermission('server:delete') && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
||||
<DropdownMenuItem
|
||||
className="dg-text-red focus:bg-[var(--dg-bg-hover)] focus:dg-text-red cursor-pointer"
|
||||
onClick={() => onDelete(server.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">
|
||||
{t("common.actions")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
||||
{hasPermission("server:update") && (
|
||||
<DropdownMenuItem
|
||||
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
|
||||
onClick={() => onOpenEdit(server)}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasPermission("server:webshell") && (
|
||||
<DropdownMenuItem
|
||||
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
|
||||
onClick={() =>
|
||||
isRdp ? onOpenRdp(server) : onOpenTerminal(server)
|
||||
}
|
||||
>
|
||||
{isRdp ? (
|
||||
<Monitor className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isRdp ? "RDP" : t("server.table.terminal")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasPermission("server:update") && (
|
||||
<DropdownMenuItem
|
||||
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
|
||||
onClick={() => onOpenFiles?.(server)}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
{t("server.table.files")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasPermission("server:delete") && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
||||
<DropdownMenuItem
|
||||
className="dg-text-red focus:bg-[var(--dg-bg-hover)] focus:dg-text-red cursor-pointer"
|
||||
onClick={() => onDelete(server.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { Maximize2, Minimize2, X, Shield} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { attachTerminalResize, fitTerminal, mountXterm } from '@/lib/xterm-utils';
|
||||
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
|
||||
import { useUserStore } from '@/store';
|
||||
import { credentialApi, serverCredentialApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import type { JsonData } from '@/types/common';
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Terminal as XTerm } from "xterm";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
import "xterm/css/xterm.css";
|
||||
import { Maximize2, Minimize2, X, Shield } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
attachTerminalResize,
|
||||
fitTerminal,
|
||||
mountXterm,
|
||||
} from "@/lib/xterm-utils";
|
||||
import { TerminalLoadingOverlay } from "@/components/common/TerminalLoadingOverlay";
|
||||
import { SessionWatermark } from "@/components/common/SessionWatermark";
|
||||
import { useUserStore } from "@/store";
|
||||
import { credentialApi, serverCredentialApi } from "@/api";
|
||||
import { toast } from "sonner";
|
||||
import type { JsonData } from "@/types/common";
|
||||
|
||||
interface TerminalDialogProps {
|
||||
open: boolean;
|
||||
@@ -52,14 +57,17 @@ export function TerminalDialog({
|
||||
|
||||
// 凭据选择状态(hasAuth=false 时显示) // Logan.Li
|
||||
const [credentials, setCredentials] = useState<JsonData[]>([]);
|
||||
const [selectedCredId, setSelectedCredId] = useState<string>('');
|
||||
const [selectedCredId, setSelectedCredId] = useState<string>("");
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [authReady, setAuthReady] = useState(hasAuth); // 认证就绪标志
|
||||
|
||||
// 加载凭据列表
|
||||
useEffect(() => {
|
||||
if (!open || hasAuth) return;
|
||||
credentialApi.list().then((res: JsonData) => setCredentials(res?.list || [])).catch(() => {});
|
||||
credentialApi
|
||||
.list()
|
||||
.then((res: JsonData) => setCredentials(res?.list || []))
|
||||
.catch(() => {});
|
||||
}, [open, hasAuth]);
|
||||
|
||||
// 关联凭据
|
||||
@@ -68,10 +76,10 @@ export function TerminalDialog({
|
||||
try {
|
||||
setAssigning(true);
|
||||
await serverCredentialApi.assign(serverId, Number(selectedCredId));
|
||||
toast.success(t('common.operationSuccess'));
|
||||
toast.success(t("common.operationSuccess"));
|
||||
setAuthReady(true);
|
||||
} catch (e) {
|
||||
toast.error(e?.message || t('common.operationFailed'));
|
||||
toast.error(e?.message || t("common.operationFailed"));
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
@@ -81,7 +89,7 @@ export function TerminalDialog({
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setAuthReady(hasAuth);
|
||||
setSelectedCredId('');
|
||||
setSelectedCredId("");
|
||||
}
|
||||
}, [open, hasAuth]);
|
||||
|
||||
@@ -97,10 +105,10 @@ export function TerminalDialog({
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
allowProposedApi: true,
|
||||
theme: {
|
||||
background: '#0a0e14',
|
||||
foreground: '#d6d6d6',
|
||||
cursor: '#d6d6d6',
|
||||
selectionBackground: 'rgba(255,255,255,0.2)',
|
||||
background: "#0a0e14",
|
||||
foreground: "#d6d6d6",
|
||||
cursor: "#d6d6d6",
|
||||
selectionBackground: "rgba(255,255,255,0.2)",
|
||||
},
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
@@ -109,8 +117,8 @@ export function TerminalDialog({
|
||||
fitRef.current = fit;
|
||||
|
||||
// WebSocket 连接
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const token = useUserStore.getState().token || '';
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const token = useUserStore.getState().token || "";
|
||||
const wsUrl = `${proto}://${location.host}/ws/terminal?id=${serverId}&cols=80&rows=24&token=${encodeURIComponent(token)}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
@@ -124,13 +132,13 @@ export function TerminalDialog({
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.status === 0 && typeof msg.data === 'string') {
|
||||
if (msg.status === 0 && typeof msg.data === "string") {
|
||||
setHasOutput(true);
|
||||
term.write(msg.data);
|
||||
}
|
||||
} catch {
|
||||
setHasOutput(true);
|
||||
term.write(typeof event.data === 'string' ? event.data : '');
|
||||
term.write(typeof event.data === "string" ? event.data : "");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,19 +151,21 @@ export function TerminalDialog({
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
setHasOutput(true);
|
||||
term.write(`\r\n\x1b[31m[${t('server.connectionClosed')}][0m\r\n`);
|
||||
term.write(`\r\n\x1b[31m[${t("server.connectionClosed")}][0m\r\n`);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setHasOutput(true);
|
||||
term.write(`\r\n\x1b[31m[${t('server.connectionError')}][0m\r\n`);
|
||||
term.write(`\r\n\x1b[31m[${t("server.connectionError")}][0m\r\n`);
|
||||
};
|
||||
|
||||
return () => {
|
||||
try {
|
||||
ws.close();
|
||||
term.dispose();
|
||||
} catch { /* noop */ }
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
termRef.current = null;
|
||||
wsRef.current = null;
|
||||
setConnected(false);
|
||||
@@ -197,22 +207,33 @@ export function TerminalDialog({
|
||||
// 禁用 Radix Dialog 默认的 ESC 关闭:终端内 vi/top 等会用到 ESC,关闭只能点头部 X 按钮 // Logan.Li
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
|
||||
"bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0",
|
||||
// 禁用 CSS 过渡:终端窗口应该即时缩放,不需要动画
|
||||
'!duration-0 !transition-none',
|
||||
"!duration-0 !transition-none",
|
||||
fullscreen
|
||||
? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
|
||||
: ''
|
||||
? "!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0"
|
||||
: "",
|
||||
)}
|
||||
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '960px', width: '92vw' }}
|
||||
style={
|
||||
fullscreen
|
||||
? { maxWidth: "100vw", width: "100vw", height: "100vh" }
|
||||
: { maxWidth: "960px", width: "92vw" }
|
||||
}
|
||||
>
|
||||
<DialogTitle className="sr-only">终端 - {serverName}</DialogTitle>
|
||||
<DialogDescription className="sr-only">SSH 终端连接</DialogDescription>
|
||||
{/* 终端头部 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className={cn('w-2 h-2 rounded-full', connected ? 'bg-emerald-400' : 'bg-red-400')} />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">{serverName}</span>
|
||||
<div
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
connected ? "bg-emerald-400" : "bg-red-400",
|
||||
)}
|
||||
/>
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">
|
||||
{serverName}
|
||||
</span>
|
||||
<span className="text-[var(--dg-text-muted)]">({serverIp})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -220,7 +241,11 @@ export function TerminalDialog({
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
>
|
||||
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
{fullscreen ? (
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -230,26 +255,35 @@ export function TerminalDialog({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 凭据选择器(无认证凭据时显示) */}
|
||||
{/* 凭据选择器(无认证凭据时显示) */}
|
||||
{!authReady && (
|
||||
<div className="p-6 flex flex-col items-center justify-center gap-4" style={{ height: '480px' }}>
|
||||
<div
|
||||
className="p-6 flex flex-col items-center justify-center gap-4"
|
||||
style={{ height: "480px" }}
|
||||
>
|
||||
<Shield className="h-12 w-12 dg-text-yellow" />
|
||||
<div className="text-center">
|
||||
<p className="text-[var(--dg-text-primary)] font-medium mb-1">{t('server.noAuthCred')}</p>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">{t('server.selectCredHint')}</p>
|
||||
<p className="text-[var(--dg-text-primary)] font-medium mb-1">
|
||||
{t("server.noAuthCred")}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">
|
||||
{t("server.selectCredHint")}
|
||||
</p>
|
||||
</div>
|
||||
{credentials.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{t('server.noCredAvailable')}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
{t("server.noCredAvailable")}
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
value={selectedCredId}
|
||||
onChange={(e) => setSelectedCredId(e.target.value)}
|
||||
className="w-64 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
|
||||
>
|
||||
<option value="">{t('server.selectCred')}</option>
|
||||
<option value="">{t("server.selectCred")}</option>
|
||||
{credentials.map((c: JsonData) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.type === 'ssh_key' ? '🔑' : '🔒'} {c.name}
|
||||
{c.type === "ssh_key" ? "🔑" : "🔒"} {c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -259,15 +293,22 @@ export function TerminalDialog({
|
||||
disabled={!selectedCredId || assigning}
|
||||
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||
>
|
||||
{assigning ? t('server.linking') : t('server.confirmConnect')}
|
||||
{assigning ? t("server.linking") : t("server.confirmConnect")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* 终端容器 */}
|
||||
{authReady && (
|
||||
<div className="relative bg-[#0a0e14]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '520px' }}>
|
||||
<div
|
||||
className="relative bg-[#0a0e14]"
|
||||
style={{ height: fullscreen ? "calc(100vh - 49px)" : "520px" }}
|
||||
>
|
||||
<div ref={containerRef} className="xterm-terminal-host h-full" />
|
||||
<TerminalLoadingOverlay show={!hasOutput} label={t('server.terminalLoggingIn')} />
|
||||
<SessionWatermark className="opacity-60" />
|
||||
<TerminalLoadingOverlay
|
||||
show={!hasOutput}
|
||||
label={t("server.terminalLoggingIn")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// RDP 虚拟共享盘根路径,映射为 Windows 本机 Download 目录 // Logan.Li
|
||||
export const RDP_VIRTUAL_DOWNLOAD_ROOT = 'C:\\Users\\Public\\AIOpsShare\\Download';
|
||||
|
||||
export function rdpVirtualDownloadPath(fileName: string): string {
|
||||
const safe = fileName.replace(/[\\/:*?"<>|]/g, '_').trim() || 'upload.bin';
|
||||
return `${RDP_VIRTUAL_DOWNLOAD_ROOT}\\${safe}`;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// 服务器管理共享纯函数/常量 // Logan.Li
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import i18n from '@/i18n';
|
||||
import type { Server as ServerType } from '@/types';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
@@ -39,26 +40,26 @@ export function getStatusBadge(status: ServerType['status']) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5 animate-pulse" />
|
||||
运行中
|
||||
{i18n.t('server.statusBadge.running')}
|
||||
</Badge>
|
||||
);
|
||||
case 'stopped':
|
||||
return (
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] bg-[var(--dg-bg-tertiary)]">
|
||||
已停止
|
||||
{i18n.t('server.statusBadge.stopped')}
|
||||
</Badge>
|
||||
);
|
||||
case 'maintenance':
|
||||
return (
|
||||
<Badge variant="outline" className="border-amber-500/30 dg-text-yellow bg-amber-500/10">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 mr-1.5" />
|
||||
维护中
|
||||
{i18n.t('server.statusBadge.maintenance')}
|
||||
</Badge>
|
||||
);
|
||||
case 'offline':
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10">
|
||||
离线
|
||||
{i18n.t('server.statusBadge.offline')}
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
|
||||
Reference in new issue
Block a user