mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2026-02-04 08:00:30 -07:00
Initial RDP Implementation
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"ag-grid-community": "34.2.0",
|
||||
"ag-grid-react": "34.2.0",
|
||||
"dayjs": "1.11.18",
|
||||
"guacamole-common-js": "1.5.0",
|
||||
"normalize.css": "8.0.1",
|
||||
"prismjs": "1.30.0",
|
||||
"react-simple-code-editor": "0.13.1",
|
||||
|
||||
@@ -27,6 +27,7 @@ import LanRoundedIcon from "@mui/icons-material/LanRounded";
|
||||
import AppsRoundedIcon from "@mui/icons-material/AppsRounded";
|
||||
import ListAltRoundedIcon from "@mui/icons-material/ListAltRounded";
|
||||
import TerminalRoundedIcon from "@mui/icons-material/TerminalRounded";
|
||||
import DesktopWindowsRoundedIcon from "@mui/icons-material/DesktopWindowsRounded";
|
||||
import TuneRoundedIcon from "@mui/icons-material/TuneRounded";
|
||||
import SpeedRoundedIcon from "@mui/icons-material/SpeedRounded";
|
||||
import DeveloperBoardRoundedIcon from "@mui/icons-material/DeveloperBoardRounded";
|
||||
@@ -42,6 +43,7 @@ import Editor from "react-simple-code-editor";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
||||
import ReverseTunnelPowershell from "./ReverseTunnel/Powershell.jsx";
|
||||
import ReverseTunnelRdp from "./ReverseTunnel/RDP.jsx";
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
@@ -119,6 +121,7 @@ const TOP_TABS = [
|
||||
{ key: "activity", label: "Activity History", icon: ListAltRoundedIcon },
|
||||
{ key: "advanced", label: "Advanced Config", icon: TuneRoundedIcon },
|
||||
{ key: "shell", label: "Remote Shell", icon: TerminalRoundedIcon },
|
||||
{ key: "rdp", label: "Remote Desktop", icon: DesktopWindowsRoundedIcon },
|
||||
];
|
||||
|
||||
const myTheme = themeQuartz.withParams({
|
||||
@@ -1530,6 +1533,19 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderRemoteDesktopTab = () => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<ReverseTunnelRdp device={tunnelDevice} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
const handleVpnToggle = useCallback((key, checked) => {
|
||||
setVpnToggles((prev) => ({ ...(prev || {}), [key]: checked }));
|
||||
setVpnSource("custom");
|
||||
@@ -1926,6 +1942,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
|
||||
renderHistory,
|
||||
renderAdvancedConfigTab,
|
||||
renderRemoteShellTab,
|
||||
renderRemoteDesktopTab,
|
||||
];
|
||||
const tabContent = (topTabRenderers[tab] || renderDeviceSummaryTab)();
|
||||
|
||||
|
||||
552
Data/Engine/web-interface/src/Devices/ReverseTunnel/RDP.jsx
Normal file
552
Data/Engine/web-interface/src/Devices/ReverseTunnel/RDP.jsx
Normal file
@@ -0,0 +1,552 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
DesktopWindowsRounded as DesktopIcon,
|
||||
PlayArrowRounded as PlayIcon,
|
||||
StopRounded as StopIcon,
|
||||
LinkRounded as LinkIcon,
|
||||
LanRounded as IpIcon,
|
||||
} from "@mui/icons-material";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
|
||||
const MAGIC_UI = {
|
||||
panelBorder: "rgba(148, 163, 184, 0.35)",
|
||||
textMuted: "#94a3b8",
|
||||
textBright: "#e2e8f0",
|
||||
accentA: "#7dd3fc",
|
||||
accentB: "#c084fc",
|
||||
accentC: "#34d399",
|
||||
};
|
||||
|
||||
const gradientButtonSx = {
|
||||
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
|
||||
color: "#0b1220",
|
||||
borderRadius: 999,
|
||||
textTransform: "none",
|
||||
px: 2.2,
|
||||
minWidth: 120,
|
||||
"&:hover": {
|
||||
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
|
||||
},
|
||||
};
|
||||
|
||||
const PROTOCOLS = [{ value: "rdp", label: "RDP" }];
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function normalizeText(value) {
|
||||
if (value == null) return "";
|
||||
try {
|
||||
return String(value).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export default function ReverseTunnelRdp({ device }) {
|
||||
const [sessionState, setSessionState] = useState("idle");
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tunnel, setTunnel] = useState(null);
|
||||
const [protocol, setProtocol] = useState("rdp");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const containerRef = useRef(null);
|
||||
const displayRef = useRef(null);
|
||||
const clientRef = useRef(null);
|
||||
const tunnelRef = useRef(null);
|
||||
const mouseRef = useRef(null);
|
||||
const agentIdRef = useRef("");
|
||||
const tunnelIdRef = useRef("");
|
||||
const bumpTimerRef = useRef(null);
|
||||
|
||||
const agentId = useMemo(() => {
|
||||
return (
|
||||
normalizeText(device?.agent_id) ||
|
||||
normalizeText(device?.agentId) ||
|
||||
normalizeText(device?.agent_guid) ||
|
||||
normalizeText(device?.agentGuid) ||
|
||||
normalizeText(device?.id) ||
|
||||
normalizeText(device?.guid) ||
|
||||
normalizeText(device?.summary?.agent_id) ||
|
||||
""
|
||||
);
|
||||
}, [device]);
|
||||
|
||||
useEffect(() => {
|
||||
agentIdRef.current = agentId;
|
||||
}, [agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
tunnelIdRef.current = tunnel?.tunnel_id || "";
|
||||
}, [tunnel?.tunnel_id]);
|
||||
|
||||
const notifyAgentOnboarding = useCallback(async () => {
|
||||
try {
|
||||
await fetch("/api/notifications/notify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
title: "Agent Onboarding Underway",
|
||||
message:
|
||||
"Please wait for the agent to finish onboarding into Borealis. It takes about 1 minute to finish the process.",
|
||||
icon: "info",
|
||||
variant: "info",
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
/* ignore notification transport errors */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAgentOnboarding = useCallback(async () => {
|
||||
await notifyAgentOnboarding();
|
||||
setStatusMessage("Agent Onboarding Underway.");
|
||||
setSessionState("idle");
|
||||
setTunnel(null);
|
||||
}, [notifyAgentOnboarding]);
|
||||
|
||||
const teardownDisplay = useCallback(() => {
|
||||
try {
|
||||
const client = clientRef.current;
|
||||
if (client) {
|
||||
client.disconnect();
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
clientRef.current = null;
|
||||
tunnelRef.current = null;
|
||||
mouseRef.current = null;
|
||||
const host = displayRef.current;
|
||||
if (host) {
|
||||
host.innerHTML = "";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopTunnel = useCallback(async (reason = "operator_disconnect") => {
|
||||
const currentAgentId = agentIdRef.current;
|
||||
if (!currentAgentId) return;
|
||||
const currentTunnelId = tunnelIdRef.current;
|
||||
try {
|
||||
await fetch("/api/tunnel/disconnect", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ agent_id: currentAgentId, tunnel_id: currentTunnelId, reason }),
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearBumpTimer = useCallback(() => {
|
||||
if (bumpTimerRef.current) {
|
||||
clearInterval(bumpTimerRef.current);
|
||||
bumpTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startBumpTimer = useCallback(
|
||||
(currentAgentId) => {
|
||||
clearBumpTimer();
|
||||
if (!currentAgentId) return;
|
||||
bumpTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
await fetch(`/api/tunnel/connect/status?agent_id=${encodeURIComponent(currentAgentId)}&bump=1`);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 60000);
|
||||
},
|
||||
[clearBumpTimer]
|
||||
);
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setStatusMessage("");
|
||||
clearBumpTimer();
|
||||
try {
|
||||
teardownDisplay();
|
||||
await stopTunnel("operator_disconnect");
|
||||
} finally {
|
||||
setTunnel(null);
|
||||
setSessionState("idle");
|
||||
setLoading(false);
|
||||
}
|
||||
}, [clearBumpTimer, stopTunnel, teardownDisplay]);
|
||||
|
||||
const scaleToFit = useCallback(() => {
|
||||
const client = clientRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!client || !container) return;
|
||||
const display = client.getDisplay();
|
||||
const displayWidth = display.getWidth();
|
||||
const displayHeight = display.getHeight();
|
||||
const bounds = container.getBoundingClientRect();
|
||||
if (!displayWidth || !displayHeight || !bounds.width || !bounds.height) return;
|
||||
const scale = Math.min(bounds.width / displayWidth, bounds.height / displayHeight);
|
||||
display.scale(scale);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => scaleToFit();
|
||||
window.addEventListener("resize", handleResize);
|
||||
let observer = null;
|
||||
if (typeof ResizeObserver !== "undefined" && containerRef.current) {
|
||||
observer = new ResizeObserver(() => scaleToFit());
|
||||
observer.observe(containerRef.current);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (observer) observer.disconnect();
|
||||
};
|
||||
}, [scaleToFit]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearBumpTimer();
|
||||
teardownDisplay();
|
||||
stopTunnel("component_unmount");
|
||||
};
|
||||
}, [clearBumpTimer, stopTunnel, teardownDisplay]);
|
||||
|
||||
const requestTunnel = useCallback(async () => {
|
||||
if (!agentId) {
|
||||
setStatusMessage("Agent ID is required to connect.");
|
||||
return null;
|
||||
}
|
||||
setLoading(true);
|
||||
setStatusMessage("");
|
||||
try {
|
||||
try {
|
||||
const readinessResp = await fetch(`/api/tunnel/status?agent_id=${encodeURIComponent(agentId)}`);
|
||||
const readinessData = await readinessResp.json().catch(() => ({}));
|
||||
if (readinessResp.ok && readinessData?.agent_socket !== true) {
|
||||
await handleAgentOnboarding();
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// best-effort readiness check
|
||||
}
|
||||
|
||||
setSessionState("connecting");
|
||||
const resp = await fetch("/api/tunnel/connect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ agent_id: agentId }),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
const detail = data?.detail ? `: ${data.detail}` : "";
|
||||
throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`);
|
||||
}
|
||||
tunnelIdRef.current = data?.tunnel_id || "";
|
||||
|
||||
const waitForTunnelReady = async () => {
|
||||
const deadline = Date.now() + 60000;
|
||||
let lastError = "";
|
||||
while (Date.now() < deadline) {
|
||||
const statusResp = await fetch(
|
||||
`/api/tunnel/connect/status?agent_id=${encodeURIComponent(agentId)}&bump=1`
|
||||
);
|
||||
const statusData = await statusResp.json().catch(() => ({}));
|
||||
if (statusData?.error === "agent_socket_missing" || (statusResp.ok && statusData?.agent_socket === false)) {
|
||||
await handleAgentOnboarding();
|
||||
await stopTunnel("agent_onboarding_pending");
|
||||
return null;
|
||||
}
|
||||
if (statusResp.ok && statusData?.status === "up") {
|
||||
const agentSocket = statusData?.agent_socket;
|
||||
const agentReady = agentSocket === undefined ? true : Boolean(agentSocket);
|
||||
if (agentReady) {
|
||||
return statusData;
|
||||
}
|
||||
setStatusMessage("Waiting for agent VPN socket to register...");
|
||||
} else if (statusData?.error) {
|
||||
lastError = statusData.error;
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
throw new Error(lastError || "Tunnel not ready");
|
||||
};
|
||||
|
||||
const statusData = await waitForTunnelReady();
|
||||
if (!statusData) {
|
||||
return null;
|
||||
}
|
||||
setTunnel({ ...data, ...statusData });
|
||||
startBumpTimer(agentId);
|
||||
return statusData;
|
||||
} catch (err) {
|
||||
setSessionState("error");
|
||||
setStatusMessage(String(err.message || err));
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [agentId, handleAgentOnboarding, startBumpTimer, stopTunnel]);
|
||||
|
||||
const openRdpSession = useCallback(
|
||||
async () => {
|
||||
const currentAgentId = agentIdRef.current;
|
||||
if (!currentAgentId) return;
|
||||
const payload = {
|
||||
agent_id: currentAgentId,
|
||||
protocol,
|
||||
username: username.trim(),
|
||||
password,
|
||||
};
|
||||
const resp = await fetch("/api/rdp/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
const detail = data?.detail ? `: ${data.detail}` : "";
|
||||
throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`);
|
||||
}
|
||||
const token = data?.token;
|
||||
const wsUrl = data?.ws_url;
|
||||
if (!token || !wsUrl) {
|
||||
throw new Error("RDP session unavailable.");
|
||||
}
|
||||
const tunnelUrl = `${wsUrl}?token=${encodeURIComponent(token)}`;
|
||||
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
|
||||
const client = new Guacamole.Client(tunnel);
|
||||
const displayHost = displayRef.current;
|
||||
|
||||
tunnel.onerror = (status) => {
|
||||
setStatusMessage(status?.message || "RDP tunnel error.");
|
||||
};
|
||||
client.onerror = (status) => {
|
||||
setStatusMessage(status?.message || "RDP client error.");
|
||||
};
|
||||
client.onstatechange = (state) => {
|
||||
if (state === Guacamole.Client.State.CONNECTED) {
|
||||
setSessionState("connected");
|
||||
setStatusMessage("");
|
||||
} else if (state === Guacamole.Client.State.DISCONNECTED) {
|
||||
setSessionState("idle");
|
||||
}
|
||||
};
|
||||
client.onresize = () => {
|
||||
scaleToFit();
|
||||
};
|
||||
|
||||
if (displayHost) {
|
||||
displayHost.innerHTML = "";
|
||||
displayHost.appendChild(client.getDisplay().getElement());
|
||||
}
|
||||
|
||||
const mouse = new Guacamole.Mouse(client.getDisplay().getElement());
|
||||
mouse.onmousemove = (state) => client.sendMouseState(state);
|
||||
mouse.onmousedown = (state) => client.sendMouseState(state);
|
||||
mouse.onmouseup = (state) => client.sendMouseState(state);
|
||||
|
||||
clientRef.current = client;
|
||||
tunnelRef.current = tunnel;
|
||||
mouseRef.current = mouse;
|
||||
client.connect();
|
||||
scaleToFit();
|
||||
setStatusMessage("Connecting to RDP...");
|
||||
},
|
||||
[password, protocol, scaleToFit, username]
|
||||
);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
if (sessionState === "connected") return;
|
||||
setStatusMessage("");
|
||||
setSessionState("connecting");
|
||||
try {
|
||||
const tunnelReady = await requestTunnel();
|
||||
if (!tunnelReady) {
|
||||
return;
|
||||
}
|
||||
await openRdpSession();
|
||||
} catch (err) {
|
||||
setSessionState("error");
|
||||
setStatusMessage(String(err.message || err));
|
||||
}
|
||||
}, [openRdpSession, requestTunnel, sessionState]);
|
||||
|
||||
const isConnected = sessionState === "connected";
|
||||
const sessionChips = [
|
||||
tunnel?.tunnel_id
|
||||
? {
|
||||
label: `Tunnel ${tunnel.tunnel_id.slice(0, 8)}`,
|
||||
color: MAGIC_UI.accentB,
|
||||
icon: <LinkIcon sx={{ fontSize: 18 }} />,
|
||||
}
|
||||
: null,
|
||||
tunnel?.virtual_ip
|
||||
? {
|
||||
label: `IP ${String(tunnel.virtual_ip).split("/")[0]}`,
|
||||
color: MAGIC_UI.accentA,
|
||||
icon: <IpIcon sx={{ fontSize: 18 }} />,
|
||||
}
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, flexGrow: 1, minHeight: 0 }}>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5} alignItems={{ xs: "flex-start", md: "center" }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={isConnected ? <StopIcon /> : <PlayIcon />}
|
||||
sx={gradientButtonSx}
|
||||
disabled={loading || (!isConnected && !agentId)}
|
||||
onClick={isConnected ? handleDisconnect : handleConnect}
|
||||
>
|
||||
{isConnected ? "Disconnect" : "Connect"}
|
||||
</Button>
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", alignItems: "center" }}>
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: 140,
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "rgba(12,18,35,0.9)",
|
||||
color: MAGIC_UI.textBright,
|
||||
borderRadius: 2,
|
||||
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
|
||||
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
|
||||
},
|
||||
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
|
||||
}}
|
||||
>
|
||||
<InputLabel>Protocol</InputLabel>
|
||||
<Select
|
||||
label="Protocol"
|
||||
value={protocol}
|
||||
onChange={(e) => setProtocol(e.target.value)}
|
||||
disabled={isConnected}
|
||||
>
|
||||
{PROTOCOLS.map((item) => (
|
||||
<MenuItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isConnected}
|
||||
sx={{
|
||||
minWidth: 180,
|
||||
input: { color: MAGIC_UI.textBright },
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "rgba(12,18,35,0.9)",
|
||||
borderRadius: 2,
|
||||
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
|
||||
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
|
||||
},
|
||||
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isConnected}
|
||||
sx={{
|
||||
minWidth: 180,
|
||||
input: { color: MAGIC_UI.textBright },
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "rgba(12,18,35,0.9)",
|
||||
borderRadius: 2,
|
||||
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
|
||||
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
|
||||
},
|
||||
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{sessionChips.map((chip) => (
|
||||
<Chip
|
||||
key={chip.label}
|
||||
icon={chip.icon}
|
||||
label={chip.label}
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
color: chip.color,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
backgroundColor: "rgba(8,12,24,0.65)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
minHeight: 320,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
background:
|
||||
"linear-gradient(145deg, rgba(8,12,24,0.94), rgba(10,16,30,0.9)), radial-gradient(circle at 20% 20%, rgba(125,211,252,0.08), transparent 35%)",
|
||||
boxShadow: "0 25px 80px rgba(2,6,23,0.85)",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{loading ? <LinearProgress color="info" sx={{ height: 3 }} /> : null}
|
||||
<Box
|
||||
ref={containerRef}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
position: "relative",
|
||||
backgroundColor: "rgba(2,6,20,0.9)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box ref={displayRef} sx={{ width: "100%", height: "100%" }} />
|
||||
{!isConnected ? (
|
||||
<Stack spacing={1} sx={{ position: "absolute", alignItems: "center" }}>
|
||||
<DesktopIcon sx={{ color: MAGIC_UI.accentA, fontSize: 40 }} />
|
||||
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
|
||||
Connect to start the remote desktop session.
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={0.3} sx={{ mt: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
|
||||
Session: {isConnected ? "Active" : sessionState}
|
||||
</Typography>
|
||||
{statusMessage ? (
|
||||
<Typography variant="body2" sx={{ color: sessionState === "error" ? "#ff7b89" : MAGIC_UI.textMuted }}>
|
||||
{statusMessage}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user