mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-16 09:25:48 -07:00
Major Progress Towards Interactive Remote Powershell
This commit is contained in:
@@ -0,0 +1,704 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Stack,
|
||||
Chip,
|
||||
TextField,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Alert,
|
||||
LinearProgress,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
TerminalRounded as TerminalIcon,
|
||||
PlayArrowRounded as PlayIcon,
|
||||
StopRounded as StopIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
RefreshRounded as RefreshIcon,
|
||||
LanRounded as PortIcon,
|
||||
SensorsRounded as ActivityIcon,
|
||||
LinkRounded as LinkIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { io } from "socket.io-client";
|
||||
import Prism from "prismjs";
|
||||
import "prismjs/components/prism-powershell";
|
||||
import "prismjs/themes/prism-okaidia.css";
|
||||
import Editor from "react-simple-code-editor";
|
||||
|
||||
const MAGIC_UI = {
|
||||
panelBg: "rgba(7,11,24,0.92)",
|
||||
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",
|
||||
boxShadow: "0 10px 26px rgba(124,58,237,0.28)",
|
||||
px: 2.2,
|
||||
minWidth: 120,
|
||||
"&:hover": {
|
||||
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
|
||||
boxShadow: "0 12px 34px rgba(124,58,237,0.38)",
|
||||
},
|
||||
};
|
||||
|
||||
const FRAME_HEADER_BYTES = 12; // version, msg_type, flags, reserved, channel_id(u32), length(u32)
|
||||
const MSG_CLOSE = 0x08;
|
||||
const CLOSE_AGENT_SHUTDOWN = 6;
|
||||
|
||||
const fontFamilyMono =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
|
||||
|
||||
function normalizeText(value) {
|
||||
if (value == null) return "";
|
||||
try {
|
||||
return String(value).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function base64FromBytes(bytes) {
|
||||
let binary = "";
|
||||
bytes.forEach((b) => {
|
||||
binary += String.fromCharCode(b);
|
||||
});
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function buildCloseFrame(channelId = 1, code = CLOSE_AGENT_SHUTDOWN, reason = "operator_close") {
|
||||
const payload = new TextEncoder().encode(JSON.stringify({ code, reason }));
|
||||
const buffer = new ArrayBuffer(FRAME_HEADER_BYTES + payload.length);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, 1); // version
|
||||
view.setUint8(1, MSG_CLOSE);
|
||||
view.setUint8(2, 0); // flags
|
||||
view.setUint8(3, 0); // reserved
|
||||
view.setUint32(4, channelId >>> 0, true);
|
||||
view.setUint32(8, payload.length >>> 0, true);
|
||||
new Uint8Array(buffer, FRAME_HEADER_BYTES).set(payload);
|
||||
return base64FromBytes(new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
function highlightPs(code) {
|
||||
try {
|
||||
return Prism.highlight(code || "", Prism.languages.powershell, "powershell");
|
||||
} catch {
|
||||
return code || "";
|
||||
}
|
||||
}
|
||||
|
||||
export default function ReverseTunnelPowershell({ device }) {
|
||||
const [connectionType, setConnectionType] = useState("ps");
|
||||
const [tunnel, setTunnel] = useState(null);
|
||||
const [sessionState, setSessionState] = useState("idle");
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
const [statusSeverity, setStatusSeverity] = useState("info");
|
||||
const [output, setOutput] = useState("");
|
||||
const [input, setInput] = useState("");
|
||||
const [copyFlash, setCopyFlash] = useState(false);
|
||||
const [, setPolling] = useState(false);
|
||||
const [psStatus, setPsStatus] = useState({});
|
||||
const socketRef = useRef(null);
|
||||
const pollTimerRef = useRef(null);
|
||||
const resizeTimerRef = useRef(null);
|
||||
const terminalRef = useRef(null);
|
||||
const joinRetryRef = useRef(null);
|
||||
const joinAttemptsRef = useRef(0);
|
||||
|
||||
const hostname = useMemo(() => {
|
||||
return (
|
||||
normalizeText(device?.hostname) ||
|
||||
normalizeText(device?.summary?.hostname) ||
|
||||
normalizeText(device?.agent_hostname) ||
|
||||
""
|
||||
);
|
||||
}, [device]);
|
||||
|
||||
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]);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setTunnel(null);
|
||||
setSessionState("idle");
|
||||
setStatusMessage("");
|
||||
setStatusSeverity("info");
|
||||
setOutput("");
|
||||
setInput("");
|
||||
setPsStatus({});
|
||||
}, []);
|
||||
|
||||
const disconnectSocket = useCallback(() => {
|
||||
const socket = socketRef.current;
|
||||
if (socket) {
|
||||
socket.off();
|
||||
socket.disconnect();
|
||||
}
|
||||
socketRef.current = null;
|
||||
}, []);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
setPolling(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
disconnectSocket();
|
||||
if (joinRetryRef.current) {
|
||||
clearTimeout(joinRetryRef.current);
|
||||
joinRetryRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [disconnectSocket, stopPolling]);
|
||||
|
||||
const appendOutput = useCallback((text) => {
|
||||
if (!text) return;
|
||||
setOutput((prev) => {
|
||||
const next = `${prev}${text}`;
|
||||
const limit = 40000;
|
||||
return next.length > limit ? next.slice(next.length - limit) : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = terminalRef.current;
|
||||
if (!el) return;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [output, scrollToBottom]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(output || "");
|
||||
setCopyFlash(true);
|
||||
setTimeout(() => setCopyFlash(false), 1200);
|
||||
} catch {
|
||||
setCopyFlash(false);
|
||||
}
|
||||
};
|
||||
|
||||
const measureTerminal = useCallback(() => {
|
||||
const el = terminalRef.current;
|
||||
if (!el) return { cols: 120, rows: 32 };
|
||||
const width = el.clientWidth || 960;
|
||||
const height = el.clientHeight || 460;
|
||||
const charWidth = 8.2;
|
||||
const charHeight = 18;
|
||||
const cols = Math.max(20, Math.min(Math.floor(width / charWidth), 300));
|
||||
const rows = Math.max(10, Math.min(Math.floor(height / charHeight), 200));
|
||||
return { cols, rows };
|
||||
}, []);
|
||||
|
||||
const emitAsync = useCallback((socket, event, payload, timeoutMs = 4000) => {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({ error: "timeout" });
|
||||
}, timeoutMs);
|
||||
socket.emit(event, payload, (resp) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(resp || {});
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pollLoop = useCallback(
|
||||
(socket, tunnelId) => {
|
||||
if (!socket || !tunnelId) return;
|
||||
setPolling(true);
|
||||
pollTimerRef.current = setTimeout(async () => {
|
||||
const resp = await emitAsync(socket, "ps_poll", {});
|
||||
if (resp?.error) {
|
||||
if (resp.error === "ps_unsupported") {
|
||||
setStatusSeverity("info");
|
||||
setStatusMessage("PowerShell channel warming up...");
|
||||
} else {
|
||||
setStatusSeverity("warning");
|
||||
setStatusMessage(resp.error);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(resp?.output) && resp.output.length) {
|
||||
appendOutput(resp.output.join(""));
|
||||
}
|
||||
if (resp?.status) {
|
||||
setPsStatus(resp.status);
|
||||
if (resp.status.closed) {
|
||||
setSessionState("closed");
|
||||
setStatusSeverity("warning");
|
||||
setStatusMessage(resp.status.close_reason || "Session closed");
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
if (resp.status.ack) {
|
||||
setSessionState("connected");
|
||||
}
|
||||
}
|
||||
pollLoop(socket, tunnelId);
|
||||
}, 520);
|
||||
},
|
||||
[appendOutput, emitAsync, stopPolling]
|
||||
);
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
const socket = socketRef.current;
|
||||
const tunnelId = tunnel?.tunnel_id;
|
||||
if (joinRetryRef.current) {
|
||||
clearTimeout(joinRetryRef.current);
|
||||
joinRetryRef.current = null;
|
||||
}
|
||||
joinAttemptsRef.current = 0;
|
||||
if (socket && tunnelId) {
|
||||
const frame = buildCloseFrame(1, CLOSE_AGENT_SHUTDOWN, "operator_close");
|
||||
socket.emit("send", { frame });
|
||||
}
|
||||
stopPolling();
|
||||
disconnectSocket();
|
||||
setSessionState("closed");
|
||||
setStatusSeverity("info");
|
||||
setStatusMessage("Session closed by operator.");
|
||||
}, [disconnectSocket, stopPolling, tunnel?.tunnel_id]);
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
if (!socketRef.current || sessionState === "idle") return;
|
||||
const dims = measureTerminal();
|
||||
socketRef.current.emit("ps_resize", dims);
|
||||
}, [measureTerminal, sessionState]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer =
|
||||
typeof ResizeObserver !== "undefined"
|
||||
? new ResizeObserver(() => {
|
||||
if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current);
|
||||
resizeTimerRef.current = setTimeout(() => handleResize(), 200);
|
||||
})
|
||||
: null;
|
||||
const el = terminalRef.current;
|
||||
if (observer && el) observer.observe(el);
|
||||
const onWinResize = () => handleResize();
|
||||
window.addEventListener("resize", onWinResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", onWinResize);
|
||||
if (observer && el) observer.unobserve(el);
|
||||
};
|
||||
}, [handleResize]);
|
||||
|
||||
const connectSocket = useCallback(
|
||||
(lease, { isRetry = false } = {}) => {
|
||||
if (!lease?.tunnel_id) return;
|
||||
if (joinRetryRef.current) {
|
||||
clearTimeout(joinRetryRef.current);
|
||||
joinRetryRef.current = null;
|
||||
}
|
||||
if (!isRetry) {
|
||||
joinAttemptsRef.current = 0;
|
||||
}
|
||||
disconnectSocket();
|
||||
stopPolling();
|
||||
setSessionState("waiting");
|
||||
const socket = io(`${window.location.origin}/tunnel`, { transports: ["websocket"] });
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on("connect_error", () => {
|
||||
setStatusSeverity("warning");
|
||||
setStatusMessage("Tunnel namespace unavailable.");
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
stopPolling();
|
||||
if (sessionState !== "closed") {
|
||||
setSessionState("disconnected");
|
||||
setStatusSeverity("warning");
|
||||
setStatusMessage("Socket disconnected.");
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("connect", async () => {
|
||||
setStatusSeverity("info");
|
||||
setStatusMessage("Joining tunnel...");
|
||||
const joinResp = await emitAsync(socket, "join", { tunnel_id: lease.tunnel_id });
|
||||
if (joinResp?.error) {
|
||||
if (joinResp.error === "unknown_tunnel") {
|
||||
setSessionState("waiting_agent");
|
||||
setStatusSeverity("info");
|
||||
setStatusMessage("Waiting for agent to establish tunnel...");
|
||||
joinAttemptsRef.current += 1;
|
||||
const attempt = joinAttemptsRef.current;
|
||||
if (attempt <= 15) {
|
||||
joinRetryRef.current = setTimeout(() => connectSocket(lease, { isRetry: true }), 1000);
|
||||
} else {
|
||||
setSessionState("error");
|
||||
setTunnel(null);
|
||||
setStatusSeverity("warning");
|
||||
setStatusMessage("Agent did not attach to tunnel (timeout). Try Connect again.");
|
||||
}
|
||||
} else {
|
||||
setSessionState("error");
|
||||
setStatusSeverity("error");
|
||||
setStatusMessage(joinResp.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const dims = measureTerminal();
|
||||
const openResp = await emitAsync(socket, "ps_open", dims);
|
||||
if (openResp?.error && openResp.error === "ps_unsupported") {
|
||||
setStatusSeverity("info");
|
||||
setStatusMessage("PowerShell channel warming up...");
|
||||
}
|
||||
appendOutput("");
|
||||
setStatusMessage("Attached — waiting for agent to acknowledge...");
|
||||
setSessionState("waiting_agent");
|
||||
pollLoop(socket, lease.tunnel_id);
|
||||
handleResize();
|
||||
});
|
||||
},
|
||||
[appendOutput, disconnectSocket, emitAsync, handleResize, measureTerminal, pollLoop, sessionState, stopPolling]
|
||||
);
|
||||
|
||||
const requestTunnel = useCallback(async () => {
|
||||
if (tunnel && sessionState !== "closed" && sessionState !== "idle") {
|
||||
setStatusSeverity("info");
|
||||
setStatusMessage("Re-attaching to existing tunnel...");
|
||||
connectSocket(tunnel);
|
||||
return;
|
||||
}
|
||||
if (!agentId) {
|
||||
setStatusSeverity("warning");
|
||||
setStatusMessage("Agent ID is required to request a tunnel.");
|
||||
return;
|
||||
}
|
||||
if (connectionType !== "ps") {
|
||||
setStatusSeverity("warning");
|
||||
setStatusMessage("Only PowerShell is supported right now.");
|
||||
return;
|
||||
}
|
||||
resetState();
|
||||
setSessionState("requesting");
|
||||
setStatusSeverity("info");
|
||||
setStatusMessage("Requesting tunnel lease...");
|
||||
try {
|
||||
const resp = await fetch("/api/tunnel/request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ agent_id: agentId, protocol: "ps", domain: "ps" }),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
const err = data?.error || `HTTP ${resp.status}`;
|
||||
setSessionState("error");
|
||||
setStatusSeverity(err === "domain_limit" ? "warning" : "error");
|
||||
setStatusMessage(
|
||||
err === "domain_limit"
|
||||
? "PowerShell session already active for this agent. Try again after it closes."
|
||||
: err
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTunnel(data);
|
||||
setStatusMessage("Lease issued. Waiting for agent to connect...");
|
||||
setSessionState("lease_issued");
|
||||
connectSocket(data);
|
||||
} catch (e) {
|
||||
setSessionState("error");
|
||||
setStatusSeverity("error");
|
||||
setStatusMessage(e?.message || "Failed to request tunnel");
|
||||
}
|
||||
}, [agentId, connectSocket, connectionType, resetState]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text) => {
|
||||
const socket = socketRef.current;
|
||||
if (!socket) return;
|
||||
const payload = `${text}${text.endsWith("\n") ? "" : "\r\n"}`;
|
||||
appendOutput(`\nPS> ${text}\n`);
|
||||
setInput("");
|
||||
const resp = await emitAsync(socket, "ps_send", { data: payload });
|
||||
if (resp?.error) {
|
||||
setStatusSeverity("warning");
|
||||
setStatusMessage(resp.error);
|
||||
}
|
||||
},
|
||||
[appendOutput, emitAsync]
|
||||
);
|
||||
|
||||
const isConnected = sessionState === "connected" || psStatus?.ack;
|
||||
const isBusy =
|
||||
sessionState === "requesting" ||
|
||||
sessionState === "waiting" ||
|
||||
sessionState === "waiting_agent" ||
|
||||
sessionState === "lease_issued";
|
||||
const canStart = Boolean(agentId) && !isBusy && !isConnected;
|
||||
|
||||
const sessionChips = [
|
||||
{
|
||||
label: isConnected ? "Connected" : sessionState === "idle" ? "Idle" : sessionState.replace(/_/g, " "),
|
||||
color: isConnected ? MAGIC_UI.accentC : MAGIC_UI.accentA,
|
||||
icon: <ActivityIcon sx={{ fontSize: 18 }} />,
|
||||
},
|
||||
tunnel?.tunnel_id
|
||||
? {
|
||||
label: `Tunnel ${tunnel.tunnel_id.slice(0, 8)}`,
|
||||
color: MAGIC_UI.accentB,
|
||||
icon: <LinkIcon sx={{ fontSize: 18 }} />,
|
||||
}
|
||||
: null,
|
||||
tunnel?.port
|
||||
? {
|
||||
label: `Port ${tunnel.port}`,
|
||||
color: MAGIC_UI.accentA,
|
||||
icon: <PortIcon sx={{ fontSize: 18 }} />,
|
||||
}
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, flexGrow: 1, minHeight: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(120deg, rgba(12,18,35,0.9), rgba(8,12,24,0.92))",
|
||||
boxShadow: "0 18px 45px rgba(2,6,23,0.6)",
|
||||
p: { xs: 1.5, md: 2 },
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={1.5}
|
||||
alignItems={{ xs: "flex-start", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
||||
<TerminalIcon sx={{ fontSize: 22, color: MAGIC_UI.accentA }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, letterSpacing: 0.3 }}>
|
||||
Remote Shell
|
||||
</Typography>
|
||||
{hostname ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={hostname}
|
||||
sx={{
|
||||
background: "rgba(12,18,35,0.8)",
|
||||
color: MAGIC_UI.textBright,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{agentId ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Agent ${agentId}`}
|
||||
sx={{
|
||||
background: "rgba(12,18,35,0.8)",
|
||||
color: MAGIC_UI.textMuted,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems="center">
|
||||
<TextField
|
||||
select
|
||||
label="Connection Type"
|
||||
size="small"
|
||||
value={connectionType}
|
||||
onChange={(e) => setConnectionType(e.target.value)}
|
||||
sx={{
|
||||
minWidth: 180,
|
||||
"& .MuiInputBase-root": {
|
||||
backgroundColor: "rgba(12,18,35,0.85)",
|
||||
color: MAGIC_UI.textBright,
|
||||
borderRadius: 1.5,
|
||||
},
|
||||
"& fieldset": { borderColor: MAGIC_UI.panelBorder },
|
||||
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="ps">PowerShell</MenuItem>
|
||||
</TextField>
|
||||
<Tooltip title={isConnected ? "Disconnect session" : "Connect to agent"}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={isConnected ? <StopIcon /> : <PlayIcon />}
|
||||
sx={gradientButtonSx}
|
||||
disabled={!isConnected && !canStart}
|
||||
onClick={isConnected ? handleDisconnect : requestTunnel}
|
||||
>
|
||||
{isConnected ? "Disconnect" : "Connect"}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||
{sessionChips.map((chip) => (
|
||||
<Chip
|
||||
key={chip.label}
|
||||
icon={chip.icon}
|
||||
label={chip.label}
|
||||
sx={{
|
||||
background: "rgba(12,18,35,0.85)",
|
||||
color: chip.color,
|
||||
border: `1px solid ${chip.color}44`,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{statusMessage ? (
|
||||
<Alert
|
||||
severity={statusSeverity}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
backgroundColor: "rgba(8,12,24,0.9)",
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
color: MAGIC_UI.textBright,
|
||||
}}
|
||||
>
|
||||
{statusMessage}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
{isBusy ? <LinearProgress color="info" sx={{ height: 3 }} /> : null}
|
||||
<Box
|
||||
ref={terminalRef}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
minHeight: 240,
|
||||
maxHeight: "100%",
|
||||
overflow: "auto",
|
||||
position: "relative",
|
||||
p: 2,
|
||||
"& pre": {
|
||||
margin: 0,
|
||||
fontFamily: fontFamilyMono,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
color: "#e6edf3",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Editor
|
||||
value={output}
|
||||
onValueChange={() => {}}
|
||||
highlight={highlightPs}
|
||||
padding={12}
|
||||
readOnly
|
||||
style={{
|
||||
minHeight: "100%",
|
||||
background: "transparent",
|
||||
color: "#e6edf3",
|
||||
fontFamily: fontFamilyMono,
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ position: "absolute", top: 8, right: 8, display: "flex", gap: 0.5 }}>
|
||||
<Tooltip title="Copy output">
|
||||
<IconButton size="small" onClick={handleCopy} sx={{ color: copyFlash ? MAGIC_UI.accentC : MAGIC_UI.textMuted }}>
|
||||
<CopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Clear output">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setOutput("")}
|
||||
sx={{ color: MAGIC_UI.textMuted }}
|
||||
>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
borderTop: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
p: 1.5,
|
||||
background: "rgba(6,10,20,0.92)",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={input}
|
||||
disabled={!isConnected}
|
||||
placeholder={
|
||||
isConnected
|
||||
? "Enter PowerShell command and press Enter"
|
||||
: "Connect to start sending commands"
|
||||
}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const text = input.trim();
|
||||
if (text) handleSend(text);
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
sx: {
|
||||
backgroundColor: "rgba(12,18,35,0.9)",
|
||||
color: "#e2e8f0",
|
||||
borderRadius: 2,
|
||||
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
|
||||
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user