Files
Borealis-Github-Replica/Data/Engine/web-interface/src/Devices/Device_Details.jsx

2143 lines
71 KiB
JavaScript

////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Engine/web-interface/src/Devices/Device_Details.jsx
import React, { useState, useEffect, useMemo, useCallback } from "react";
import {
Box,
Stack,
Tabs,
Tab,
Typography,
Button,
Switch,
Chip,
Divider,
Menu,
MenuItem,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
LinearProgress
} from "@mui/material";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import StorageRoundedIcon from "@mui/icons-material/StorageRounded";
import MemoryRoundedIcon from "@mui/icons-material/MemoryRounded";
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";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import { ClearDeviceActivityDialog } from "../Dialogs.jsx";
import Prism from "prismjs";
import "prismjs/components/prism-yaml";
import "prismjs/components/prism-bash";
import "prismjs/components/prism-powershell";
import "prismjs/components/prism-batch";
import "prismjs/themes/prism-okaidia.css";
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]);
const MAGIC_UI = {
shellBg:
"radial-gradient(120% 120% at 0% 0%, rgba(76, 186, 255, 0.16), transparent 55%), " +
"radial-gradient(120% 120% at 100% 0%, rgba(214, 130, 255, 0.18), transparent 60%), #040711",
panelBg: "rgba(7,11,24,0.92)",
panelBorder: "rgba(148, 163, 184, 0.35)",
glassBorder: "rgba(94, 234, 212, 0.35)",
glow: "0 35px 80px rgba(2, 6, 23, 0.65)",
textMuted: "#94a3b8",
textBright: "#e2e8f0",
accentA: "#7dd3fc",
accentB: "#c084fc",
accentC: "#34d399",
};
const PAGE_ICON = DeveloperBoardRoundedIcon;
const TAB_HOVER_GRADIENT = "linear-gradient(120deg, rgba(125,211,252,0.18), rgba(192,132,252,0.22))";
const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif';
const iconFontFamily = '"Quartz Regular"';
const SECTION_HEIGHTS = {
summary: 360,
storage: 360,
memory: 260,
network: 260,
};
const buildVpnGroups = (shellPort) => {
const normalizedShell = Number(shellPort) || 47002;
return [
{
key: "shell",
label: "Borealis PowerShell",
description: "Web terminal access over the VPN tunnel.",
ports: [normalizedShell],
},
{
key: "rdp",
label: "RDP",
description: "Remote Desktop (TCP 3389).",
ports: [3389],
},
{
key: "winrm",
label: "WinRM",
description: "PowerShell/WinRM management (TCP 5985/5986).",
ports: [5985, 5986],
},
{
key: "vnc",
label: "VNC",
description: "Remote desktop streaming (TCP 5900).",
ports: [5900],
},
{
key: "webrtc",
label: "WebRTC",
description: "Real-time comms (UDP 3478).",
ports: [3478],
},
];
};
const TOP_TABS = [
{ key: "summary", label: "Device Summary", icon: InfoOutlinedIcon },
{ key: "storage", label: "Storage", icon: StorageRoundedIcon },
{ key: "memory", label: "Memory", icon: MemoryRoundedIcon },
{ key: "network", label: "Network", icon: LanRoundedIcon },
{ key: "software", label: "Installed Software", icon: AppsRoundedIcon },
{ 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({
accentColor: "#8b5cf6",
backgroundColor: "#070b1a",
browserColorScheme: "dark",
chromeBackgroundColor: {
ref: "foregroundColor",
mix: 0.07,
onto: "backgroundColor",
},
fontFamily: {
googleFont: "IBM Plex Sans",
},
foregroundColor: "#f4f7ff",
headerFontSize: 14,
});
const gridThemeClass = myTheme.themeName || "ag-theme-quartz";
const GRID_SHELL_BASE_SX = {
width: "100%",
borderRadius: 3,
border: `1px solid ${MAGIC_UI.panelBorder}`,
background: "rgba(5,8,20,0.9)",
boxShadow: "0 18px 45px rgba(2,6,23,0.6)",
position: "relative",
overflow: "hidden",
"& .ag-root-wrapper": {
borderRadius: 3,
minHeight: "100%",
background: "transparent",
},
"& .ag-root, & .ag-header, & .ag-center-cols-container, & .ag-paging-panel": {
fontFamily: gridFontFamily,
background: "transparent",
},
"& .ag-header": {
backgroundColor: "rgba(3,7,18,0.85)",
borderBottom: "1px solid rgba(148,163,184,0.25)",
},
"& .ag-header-cell-label": {
color: "#e2e8f0",
fontWeight: 600,
letterSpacing: 0.3,
},
"& .ag-row": {
borderColor: "rgba(255,255,255,0.04)",
transition: "background 0.2s ease",
},
"& .ag-row:nth-of-type(even)": {
backgroundColor: "rgba(15,23,42,0.35)",
},
"& .ag-row-hover": {
backgroundColor: "rgba(124,58,237,0.12) !important",
},
"& .ag-row-selected": {
backgroundColor: "rgba(56,189,248,0.16) !important",
boxShadow: "inset 0 0 0 1px rgba(56,189,248,0.3)",
},
"& .ag-icon": {
fontFamily: iconFontFamily,
},
"& .ag-paging-panel": {
borderTop: "1px solid rgba(148,163,184,0.2)",
backgroundColor: "rgba(3,7,18,0.8)",
},
};
const GridShell = ({ children, sx }) => (
<Box className={gridThemeClass} sx={{ ...GRID_SHELL_BASE_SX, ...(sx || {}) }}>
{children}
</Box>
);
const HISTORY_STATUS_THEME = {
running: {
text: "#58a6ff",
background: "rgba(88,166,255,0.15)",
border: "1px solid rgba(88,166,255,0.4)",
dot: "#58a6ff",
},
success: {
text: "#00d18c",
background: "rgba(0,209,140,0.16)",
border: "1px solid rgba(0,209,140,0.35)",
dot: "#00d18c",
},
failed: {
text: "#ff7b89",
background: "rgba(255,123,137,0.16)",
border: "1px solid rgba(255,123,137,0.35)",
dot: "#ff7b89",
},
default: {
text: "#e2e8f0",
background: "rgba(226,232,240,0.12)",
border: "1px solid rgba(226,232,240,0.25)",
dot: "#e2e8f0",
},
};
const StatusPillCell = React.memo(function StatusPillCell(props) {
const value = String(props?.value || "");
if (!value) return null;
const theme = HISTORY_STATUS_THEME[value.toLowerCase()] || HISTORY_STATUS_THEME.default;
return (
<Box
component="span"
sx={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minWidth: 76,
px: 1.5,
py: 0.4,
borderRadius: 999,
backgroundColor: theme.background,
border: theme.border,
color: theme.text,
fontWeight: 600,
fontSize: "13px",
lineHeight: 1,
fontFamily: gridFontFamily,
textTransform: "capitalize",
gap: 0.75,
}}
>
<Box
component="span"
sx={{
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: theme.dot,
boxShadow: "0 0 0 2px rgba(0, 0, 0, 0.22)",
}}
/>
{value}
</Box>
);
});
const HistoryActionsCell = React.memo(function HistoryActionsCell(props) {
const row = props.data || {};
const onViewOutput = props.context?.onViewOutput;
const onCancelAnsible = props.context?.onCancelAnsible;
const scriptType = String(row.script_type || "").toLowerCase();
const isRunningAnsible = scriptType === "ansible" && String(row.status || "").toLowerCase() === "running";
return (
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
{isRunningAnsible ? (
<Button
size="small"
sx={{ color: "#ff7b89", textTransform: "none", minWidth: 0, p: 0 }}
onClick={() => onCancelAnsible && onCancelAnsible(row)}
>
Cancel
</Button>
) : null}
{row.has_stdout ? (
<Button
size="small"
sx={{ color: MAGIC_UI.accentA, textTransform: "none", minWidth: 0, p: 0 }}
onClick={() => onViewOutput && onViewOutput(row, "stdout")}
>
StdOut
</Button>
) : null}
{row.has_stderr ? (
<Button
size="small"
sx={{ color: "#ff7b89", textTransform: "none", minWidth: 0, p: 0 }}
onClick={() => onViewOutput && onViewOutput(row, "stderr")}
>
StdErr
</Button>
) : null}
</Box>
);
});
const GRID_COMPONENTS = {
StatusPillCell,
HistoryActionsCell,
};
export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPageMetaChange }) {
const [tab, setTab] = useState(0);
const [agent, setAgent] = useState(device || {});
const [details, setDetails] = useState({});
const [meta, setMeta] = useState({});
const [softwareSearch, setSoftwareSearch] = useState("");
const [description, setDescription] = useState("");
const [connectionType, setConnectionType] = useState("");
const [connectionEndpoint, setConnectionEndpoint] = useState("");
const [connectionDraft, setConnectionDraft] = useState("");
const [connectionSaving, setConnectionSaving] = useState(false);
const [connectionMessage, setConnectionMessage] = useState("");
const [connectionError, setConnectionError] = useState("");
const [historyRows, setHistoryRows] = useState([]);
const [outputOpen, setOutputOpen] = useState(false);
const [outputTitle, setOutputTitle] = useState("");
const [outputContent, setOutputContent] = useState("");
const [outputLang, setOutputLang] = useState("powershell");
const [menuAnchor, setMenuAnchor] = useState(null);
const [clearDialogOpen, setClearDialogOpen] = useState(false);
const [assemblyNameMap, setAssemblyNameMap] = useState({});
const [vpnLoading, setVpnLoading] = useState(false);
const [vpnSaving, setVpnSaving] = useState(false);
const [vpnError, setVpnError] = useState("");
const [vpnSource, setVpnSource] = useState("default");
const [vpnToggles, setVpnToggles] = useState({});
const [vpnCustomPorts, setVpnCustomPorts] = useState([]);
const [vpnDefaultPorts, setVpnDefaultPorts] = useState([]);
const [vpnShellPort, setVpnShellPort] = useState(47002);
const [vpnLoadedFor, setVpnLoadedFor] = useState("");
// Snapshotted status for the lifetime of this page
const [lockedStatus, setLockedStatus] = useState(() => {
// Prefer status provided by the device list row if available
if (device?.status) return device.status;
// Fallback: compute once from the provided lastSeen timestamp
const tsSec = device?.lastSeen;
if (!tsSec) return "Offline";
const now = Date.now() / 1000;
return now - tsSec <= 300 ? "Online" : "Offline";
});
const summary = details.summary || {};
const vpnAgentId = useMemo(() => {
return (
meta.agentId ||
summary.agent_id ||
agent?.agent_id ||
agent?.id ||
device?.agent_id ||
device?.agent_guid ||
device?.id ||
""
);
}, [agent?.agent_id, agent?.id, device?.agent_guid, device?.agent_id, device?.id, meta.agentId, summary.agent_id]);
const vpnPortGroups = useMemo(() => buildVpnGroups(vpnShellPort), [vpnShellPort]);
const tunnelDevice = useMemo(
() => ({
...(device || {}),
...(agent || {}),
summary,
hostname: meta.hostname || summary.hostname || device?.hostname || agent?.hostname,
agent_id: meta.agentId || summary.agent_id || agent?.agent_id || agent?.id || device?.agent_id || device?.agent_guid,
agent_guid: meta.agentGuid || summary.agent_guid || device?.agent_guid || device?.guid || agent?.agent_guid || agent?.guid,
}),
[agent, device, meta.agentGuid, meta.agentId, meta.hostname, summary]
);
const quickJobTargets = useMemo(() => {
const values = [];
const push = (value) => {
const normalized = typeof value === "string" ? value.trim() : "";
if (!normalized) return;
if (!values.includes(normalized)) values.push(normalized);
};
push(agent?.hostname);
push(device?.hostname);
return values;
}, [agent, device]);
const canLaunchQuickJob = quickJobTargets.length > 0 && typeof onQuickJobLaunch === "function";
useEffect(() => {
setConnectionError("");
}, [connectionDraft]);
useEffect(() => {
if (connectionType !== "ssh") {
setConnectionMessage("");
setConnectionError("");
}
}, [connectionType]);
useEffect(() => {
let canceled = false;
const loadAssemblyNames = async () => {
const next = {};
const storeName = (rawPath, rawName) => {
const name = typeof rawName === "string" ? rawName.trim() : "";
if (!name) return;
const normalizedPath = String(rawPath || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.trim();
if (!normalizedPath) return;
if (!next[normalizedPath]) next[normalizedPath] = name;
const base = normalizedPath.split("/").pop() || "";
if (base && !next[base]) next[base] = name;
const dot = base.lastIndexOf(".");
if (dot > 0) {
const baseNoExt = base.slice(0, dot);
if (baseNoExt && !next[baseNoExt]) next[baseNoExt] = name;
}
};
try {
const resp = await fetch("/api/assemblies");
if (!resp.ok) return;
const data = await resp.json();
const items = Array.isArray(data?.items) ? data.items : [];
items.forEach((item) => {
if (!item || typeof item !== "object") return;
const metadata = item.metadata && typeof item.metadata === "object" ? item.metadata : {};
const displayName =
(item.display_name || "").trim() ||
(metadata.display_name ? String(metadata.display_name).trim() : "") ||
item.assembly_guid ||
"";
if (!displayName) return;
storeName(metadata.source_path || metadata.legacy_path || "", displayName);
if (item.assembly_guid && !next[item.assembly_guid]) {
next[item.assembly_guid] = displayName;
}
if (item.payload_guid && !next[item.payload_guid]) {
next[item.payload_guid] = displayName;
}
});
} catch {
// ignore failures; map remains partial
}
if (!canceled) {
setAssemblyNameMap(next);
}
};
loadAssemblyNames();
return () => {
canceled = true;
};
}, []);
const statusFromHeartbeat = (tsSec, offlineAfter = 300) => {
if (!tsSec) return "Offline";
const now = Date.now() / 1000;
return now - tsSec <= offlineAfter ? "Online" : "Offline";
};
const resolveAssemblyName = useCallback((scriptName, scriptPath) => {
const normalized = String(scriptPath || "").replace(/\\/g, "/").trim();
const base = normalized ? normalized.split("/").pop() || "" : "";
const baseNoExt = base && base.includes(".") ? base.slice(0, base.lastIndexOf(".")) : base;
return (
assemblyNameMap[normalized] ||
(base ? assemblyNameMap[base] : "") ||
(baseNoExt ? assemblyNameMap[baseNoExt] : "") ||
scriptName ||
base ||
scriptPath ||
""
);
}, [assemblyNameMap]);
const formatLastSeen = (tsSec, offlineAfter = 120) => {
if (!tsSec) return "unknown";
const now = Date.now() / 1000;
if (now - tsSec <= offlineAfter) return "Currently Online";
const d = new Date(tsSec * 1000);
const date = d.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
});
const time = d.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
});
return `${date} @ ${time}`;
};
useEffect(() => {
if (device) {
setLockedStatus(device.status || statusFromHeartbeat(device.lastSeen));
}
const guid = device?.agent_guid || device?.guid || device?.agentGuid || device?.summary?.agent_guid;
const agentId = device?.agentId || device?.summary?.agent_id || device?.id;
const hostname = device?.hostname || device?.summary?.hostname;
if (!device || (!guid && !hostname)) return;
const load = async () => {
try {
const agentsPromise = fetch("/api/agents").catch(() => null);
let detailResponse = null;
if (guid) {
try {
detailResponse = await fetch(`/api/devices/${encodeURIComponent(guid)}`);
} catch (err) {
detailResponse = null;
}
}
if ((!detailResponse || !detailResponse.ok) && hostname) {
try {
detailResponse = await fetch(`/api/device/details/${encodeURIComponent(hostname)}`);
} catch (err) {
detailResponse = null;
}
}
if (!detailResponse || !detailResponse.ok) {
throw new Error(`Failed to load device record (${detailResponse ? detailResponse.status : 'no response'})`);
}
const [agentsData, detailData] = await Promise.all([
agentsPromise?.then((r) => (r ? r.json() : {})).catch(() => ({})),
detailResponse.json(),
]);
if (agentsData && agentId && agentsData[agentId]) {
setAgent({ id: agentId, ...agentsData[agentId] });
}
const summary =
detailData?.summary && typeof detailData.summary === "object"
? detailData.summary
: (detailData?.details?.summary || {});
const normalizedSummary = { ...(summary || {}) };
if (detailData?.description) {
normalizedSummary.description = detailData.description;
}
const connectionTypeValue =
(normalizedSummary.connection_type ||
normalizedSummary.remote_type ||
"").toLowerCase();
const connectionEndpointValue =
normalizedSummary.connection_endpoint ||
normalizedSummary.connection_address ||
detailData?.connection_endpoint ||
"";
setConnectionType(connectionTypeValue);
setConnectionEndpoint(connectionEndpointValue);
setConnectionDraft(connectionEndpointValue);
setConnectionMessage("");
setConnectionError("");
const normalized = {
summary: normalizedSummary,
memory: Array.isArray(detailData?.memory)
? detailData.memory
: Array.isArray(detailData?.details?.memory)
? detailData.details.memory
: [],
network: Array.isArray(detailData?.network)
? detailData.network
: Array.isArray(detailData?.details?.network)
? detailData.details.network
: [],
software: Array.isArray(detailData?.software)
? detailData.software
: Array.isArray(detailData?.details?.software)
? detailData.details.software
: [],
storage: Array.isArray(detailData?.storage)
? detailData.storage
: Array.isArray(detailData?.details?.storage)
? detailData.details.storage
: [],
cpu: detailData?.cpu || detailData?.details?.cpu || {},
};
setDetails(normalized);
const toYmdHms = (dateObj) => {
if (!dateObj || Number.isNaN(dateObj.getTime())) return '';
const pad = (v) => String(v).padStart(2, '0');
return `${dateObj.getUTCFullYear()}-${pad(dateObj.getUTCMonth() + 1)}-${pad(dateObj.getUTCDate())} ${pad(dateObj.getUTCHours())}:${pad(dateObj.getUTCMinutes())}:${pad(dateObj.getUTCSeconds())}`;
};
let createdDisplay = normalizedSummary.created || '';
if (!createdDisplay) {
if (detailData?.created_at && Number(detailData.created_at)) {
createdDisplay = toYmdHms(new Date(Number(detailData.created_at) * 1000));
} else if (detailData?.created_at_iso) {
createdDisplay = toYmdHms(new Date(detailData.created_at_iso));
}
}
const metaPayload = {
hostname: detailData?.hostname || normalizedSummary.hostname || hostname || "",
lastUser: detailData?.last_user || normalizedSummary.last_user || "",
deviceType: detailData?.device_type || normalizedSummary.device_type || "",
created: createdDisplay,
createdAtIso: detailData?.created_at_iso || "",
lastSeen: detailData?.last_seen || normalizedSummary.last_seen || 0,
lastReboot: detailData?.last_reboot || normalizedSummary.last_reboot || "",
operatingSystem:
detailData?.operating_system || normalizedSummary.operating_system || normalizedSummary.agent_operating_system || "",
agentId: detailData?.agent_id || normalizedSummary.agent_id || agentId || "",
agentGuid: detailData?.agent_guid || normalizedSummary.agent_guid || guid || "",
agentHash: detailData?.agent_hash || normalizedSummary.agent_hash || "",
internalIp: detailData?.internal_ip || normalizedSummary.internal_ip || "",
externalIp: detailData?.external_ip || normalizedSummary.external_ip || "",
siteId: detailData?.site_id,
siteName: detailData?.site_name || "",
siteDescription: detailData?.site_description || "",
status: detailData?.status || "",
connectionType: connectionTypeValue,
connectionEndpoint: connectionEndpointValue,
};
setMeta(metaPayload);
setDescription(normalizedSummary.description || detailData?.description || "");
setAgent((prev) => ({
...(prev || {}),
id: agentId || prev?.id,
hostname: metaPayload.hostname || prev?.hostname,
agent_hash: metaPayload.agentHash || prev?.agent_hash,
agent_operating_system: metaPayload.operatingSystem || prev?.agent_operating_system,
device_type: metaPayload.deviceType || prev?.device_type,
last_seen: metaPayload.lastSeen || prev?.last_seen,
}));
if (metaPayload.status) {
setLockedStatus(metaPayload.status);
} else if (metaPayload.lastSeen) {
setLockedStatus(statusFromHeartbeat(metaPayload.lastSeen));
}
} catch (e) {
console.warn("Failed to load device info", e);
setMeta({});
}
};
load();
}, [device]);
const activityHostname = useMemo(() => {
return (meta?.hostname || agent?.hostname || device?.hostname || "").trim();
}, [meta?.hostname, agent?.hostname, device?.hostname]);
const saveConnectionEndpoint = useCallback(async () => {
if (connectionType !== "ssh") return;
const host = activityHostname;
if (!host) return;
const trimmed = connectionDraft.trim();
if (!trimmed) {
setConnectionError("Address is required.");
return;
}
if (trimmed === connectionEndpoint.trim()) {
setConnectionMessage("No changes to save.");
return;
}
setConnectionSaving(true);
setConnectionError("");
setConnectionMessage("");
try {
const resp = await fetch(`/api/ssh_devices/${encodeURIComponent(host)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address: trimmed })
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
const updated = data?.device?.connection_endpoint || trimmed;
setConnectionEndpoint(updated);
setConnectionDraft(updated);
setMeta((prev) => ({ ...(prev || {}), connectionEndpoint: updated }));
setConnectionMessage("SSH endpoint updated.");
setTimeout(() => setConnectionMessage(""), 3000);
} catch (err) {
setConnectionError(String(err.message || err));
} finally {
setConnectionSaving(false);
}
}, [connectionType, connectionDraft, connectionEndpoint, activityHostname]);
const loadHistory = useCallback(async () => {
if (!activityHostname) return;
try {
const resp = await fetch(`/api/device/activity/${encodeURIComponent(activityHostname)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
setHistoryRows(data.history || []);
} catch (e) {
console.warn("Failed to load activity history", e);
setHistoryRows([]);
}
}, [activityHostname]);
useEffect(() => { loadHistory(); }, [loadHistory]);
useEffect(() => {
const socket = typeof window !== "undefined" ? window.BorealisSocket : null;
if (!socket || !activityHostname) return undefined;
let refreshTimer = null;
const normalizedHost = activityHostname.toLowerCase();
const scheduleRefresh = (delay = 200) => {
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
refreshTimer = null;
loadHistory();
}, delay);
};
const handleActivityChanged = (payload = {}) => {
const payloadHost = String(payload?.hostname || "").trim().toLowerCase();
if (!payloadHost) return;
if (payloadHost === normalizedHost) {
const delay = payload?.change === "updated" ? 150 : 0;
scheduleRefresh(delay);
}
};
socket.on("device_activity_changed", handleActivityChanged);
return () => {
if (refreshTimer) clearTimeout(refreshTimer);
socket.off("device_activity_changed", handleActivityChanged);
};
}, [activityHostname, loadHistory]);
const applyVpnPorts = useCallback((ports, defaults, shellPort, source) => {
const normalized = Array.isArray(ports) ? ports : [];
const normalizedDefaults = Array.isArray(defaults) ? defaults : [];
const numericPorts = normalized
.map((p) => Number(p))
.filter((p) => Number.isFinite(p) && p > 0);
const numericDefaults = normalizedDefaults
.map((p) => Number(p))
.filter((p) => Number.isFinite(p) && p > 0);
const effectiveShell = Number(shellPort) || 47002;
const groups = buildVpnGroups(effectiveShell);
const knownPorts = new Set(groups.flatMap((group) => group.ports));
const allowedSet = new Set(numericPorts);
const nextToggles = {};
groups.forEach((group) => {
nextToggles[group.key] = group.ports.every((port) => allowedSet.has(port));
});
const customPorts = numericPorts.filter((port) => !knownPorts.has(port));
setVpnShellPort(effectiveShell);
setVpnDefaultPorts(numericDefaults);
setVpnCustomPorts(customPorts);
setVpnToggles(nextToggles);
setVpnSource(source || "default");
}, []);
const loadVpnConfig = useCallback(async () => {
if (!vpnAgentId) return;
setVpnLoading(true);
setVpnError("");
setVpnLoadedFor(vpnAgentId);
try {
const resp = await fetch(`/api/device/vpn_config/${encodeURIComponent(vpnAgentId)}`);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
const allowedPorts = Array.isArray(data?.allowed_ports) ? data.allowed_ports : [];
const defaultPorts = Array.isArray(data?.default_ports) ? data.default_ports : [];
const shellPort = data?.shell_port;
applyVpnPorts(allowedPorts.length ? allowedPorts : defaultPorts, defaultPorts, shellPort, data?.source);
setVpnLoadedFor(vpnAgentId);
} catch (err) {
setVpnError(String(err.message || err));
} finally {
setVpnLoading(false);
}
}, [applyVpnPorts, vpnAgentId]);
const saveVpnConfig = useCallback(async () => {
if (!vpnAgentId) return;
const ports = [];
vpnPortGroups.forEach((group) => {
if (vpnToggles[group.key]) {
ports.push(...group.ports);
}
});
vpnCustomPorts.forEach((port) => ports.push(port));
const uniquePorts = Array.from(new Set(ports)).filter((p) => p > 0);
if (!uniquePorts.length) {
setVpnError("Enable at least one port before saving.");
return;
}
setVpnSaving(true);
setVpnError("");
try {
const resp = await fetch(`/api/device/vpn_config/${encodeURIComponent(vpnAgentId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ allowed_ports: uniquePorts }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
const allowedPorts = Array.isArray(data?.allowed_ports) ? data.allowed_ports : uniquePorts;
const defaultPorts = Array.isArray(data?.default_ports) ? data.default_ports : vpnDefaultPorts;
applyVpnPorts(allowedPorts, defaultPorts, data?.shell_port || vpnShellPort, data?.source || "custom");
} catch (err) {
setVpnError(String(err.message || err));
} finally {
setVpnSaving(false);
}
}, [applyVpnPorts, vpnAgentId, vpnCustomPorts, vpnDefaultPorts, vpnPortGroups, vpnShellPort, vpnToggles]);
const resetVpnConfig = useCallback(() => {
if (!vpnDefaultPorts.length) {
setVpnError("No default ports available to reset.");
return;
}
setVpnError("");
applyVpnPorts(vpnDefaultPorts, vpnDefaultPorts, vpnShellPort, "default");
}, [applyVpnPorts, vpnDefaultPorts, vpnShellPort]);
useEffect(() => {
const advancedIndex = TOP_TABS.findIndex((item) => item.key === "advanced");
if (advancedIndex < 0) return;
if (tab !== advancedIndex) return;
if (!vpnAgentId) return;
if (vpnLoadedFor === vpnAgentId) return;
loadVpnConfig();
}, [loadVpnConfig, tab, vpnAgentId, vpnLoadedFor]);
// No explicit live recap tab; recaps are recorded into Activity History
const clearHistory = async () => {
if (!activityHostname) return;
try {
const resp = await fetch(`/api/device/activity/${encodeURIComponent(activityHostname)}`, { method: "DELETE" });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
setHistoryRows([]);
} catch (e) {
console.warn("Failed to clear activity history", e);
}
};
const saveDescription = async () => {
const targetHost = meta.hostname || details.summary?.hostname;
if (!targetHost) return;
try {
await fetch(`/api/device/description/${targetHost}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description })
});
setDetails((d) => ({
...d,
summary: { ...(d.summary || {}), description }
}));
setMeta((m) => ({ ...(m || {}), hostname: targetHost }));
} catch (e) {
console.warn("Failed to save description", e);
}
};
const formatDateTime = (str) => {
if (!str) return "unknown";
try {
const [datePart, timePart] = str.split(" ");
const [y, m, d] = datePart.split("-").map(Number);
let [hh, mm, ss] = timePart.split(":").map(Number);
const ampm = hh >= 12 ? "PM" : "AM";
hh = hh % 12 || 12;
return `${m.toString().padStart(2, "0")}/${d.toString().padStart(2, "0")}/${y} @ ${hh}:${mm
.toString()
.padStart(2, "0")} ${ampm}`;
} catch {
return str;
}
};
const formatMac = (mac) => (mac ? mac.replace(/-/g, ":").toUpperCase() : "unknown");
const formatBytes = (val) => {
if (val === undefined || val === null || val === "unknown") return "unknown";
let num = Number(val);
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
while (num >= 1024 && i < units.length - 1) {
num /= 1024;
i++;
}
return `${num.toFixed(1)} ${units[i]}`;
};
const formatTimestamp = useCallback((epochSec) => {
const ts = Number(epochSec || 0);
if (!ts) return "unknown";
const d = new Date(ts * 1000);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const yyyy = d.getFullYear();
let hh = d.getHours();
const ampm = hh >= 12 ? "PM" : "AM";
hh = hh % 12 || 12;
const min = String(d.getMinutes()).padStart(2, "0");
return `${mm}/${dd}/${yyyy} @ ${hh}:${min} ${ampm}`;
}, []);
const softwareRows = useMemo(() => details.software || [], [details.software]);
const getSoftwareRowId = useCallback(
(params) => `${params.data?.name || "software"}-${params.data?.version || ""}-${params.rowIndex}`,
[]
);
// Build a best-effort CPU display from summary fields
const cpuInfo = useMemo(() => {
const cpu = details.cpu || summary.cpu || {};
const cores = cpu.logical_cores || cpu.cores || cpu.physical_cores;
let ghz = cpu.base_clock_ghz;
if (!ghz && typeof (summary.processor || '') === 'string') {
const m = String(summary.processor).match(/\(([^)]*?)ghz\)/i);
if (m && m[1]) {
const n = parseFloat(m[1]);
if (!Number.isNaN(n)) ghz = n;
}
}
const name = (cpu.name || '').trim();
const fromProcessor = (summary.processor || '').trim();
const display = fromProcessor || [name, ghz ? `(${Number(ghz).toFixed(1)}GHz)` : null, cores ? `@ ${cores} Cores` : null].filter(Boolean).join(' ');
return { cores, ghz, name, display };
}, [summary]);
const summaryItems = useMemo(
() => [
{
label: "Hostname",
value: meta.hostname || summary.hostname || agent.hostname || device?.hostname || "unknown",
},
{ label: "Agent ID", value: meta.agentId || summary.agent_id || "unknown" },
{ label: "Agent GUID", value: meta.agentGuid || summary.agent_guid || "unknown" },
{
label: "Last User",
value: meta.lastUser || summary.last_user || "unknown",
},
{ label: "Device Type", value: meta.deviceType || summary.device_type || "unknown" },
{
label: "Created",
value: meta.created
? formatDateTime(meta.created)
: summary.created
? formatDateTime(summary.created)
: "unknown",
},
{
label: "Last Seen",
value: formatLastSeen(meta.lastSeen || agent.last_seen || device?.lastSeen),
},
{
label: "Last Reboot",
value: meta.lastReboot
? formatDateTime(meta.lastReboot)
: summary.last_reboot
? formatDateTime(summary.last_reboot)
: "unknown",
},
{
label: "Operating System",
value:
meta.operatingSystem || summary.operating_system || agent.agent_operating_system || "unknown",
},
{ label: "Agent Hash", value: meta.agentHash || summary.agent_hash || "unknown" },
],
[meta, summary, agent, device, formatDateTime, formatLastSeen]
);
const summaryFieldWidth = useMemo(() => {
const longest = summaryItems.reduce((max, item) => Math.max(max, item.label.length), 0);
return Math.min(260, Math.max(160, longest * 8));
}, [summaryItems]);
const summaryGridColumns = useMemo(
() => [
{
field: "label",
headerName: "Field",
width: summaryFieldWidth,
flex: 0,
sortable: false,
filter: false,
suppressSizeToFit: true,
},
{ field: "value", headerName: "Value", flex: 1, minWidth: 220, sortable: false, filter: "agTextColumnFilter" },
],
[summaryFieldWidth]
);
const summaryGridRows = useMemo(
() =>
summaryItems.map((item, idx) => ({
id: `${item.label}-${idx}`,
label: item.label,
value: typeof item.value === "string" ? item.value : String(item.value),
})),
[summaryItems]
);
const defaultGridColDef = useMemo(
() => ({
sortable: true,
resizable: true,
filter: "agTextColumnFilter",
flex: 1,
minWidth: 140,
}),
[]
);
const softwareColumnDefs = useMemo(
() => [
{
field: "name",
headerName: "Software Name",
flex: 1.2,
minWidth: 240,
filter: "agTextColumnFilter",
},
{
field: "version",
headerName: "Version",
width: 180,
minWidth: 160,
filter: "agTextColumnFilter",
},
],
[]
);
const formatScriptType = useCallback((raw) => {
const value = String(raw || "").toLowerCase();
if (value === "ansible") return "Ansible Playbook";
if (value === "reverse_tunnel" || value === "vpn_tunnel") return "Reverse VPN Tunnel";
return "Script";
}, []);
const historyColumnDefs = useMemo(
() => [
{
headerName: "Activity",
field: "script_type",
minWidth: 180,
valueGetter: (params) => formatScriptType(params.data?.script_type),
},
{
headerName: "Task",
field: "script_display_name",
flex: 1.2,
minWidth: 240,
filter: "agTextColumnFilter",
},
{
headerName: "Ran On",
field: "ran_at",
width: 210,
valueFormatter: (params) => formatTimestamp(params.value),
sort: "desc",
comparator: (a, b) => (a || 0) - (b || 0),
},
{
headerName: "Job Status",
field: "status",
width: 160,
cellRenderer: "StatusPillCell",
},
{
headerName: "StdOut / StdErr",
colId: "stdout",
width: 220,
sortable: false,
filter: false,
cellRenderer: "HistoryActionsCell",
},
],
[formatScriptType, formatTimestamp]
);
const MetricCard = ({ icon, title, main, sub, compact = false }) => (
<Box
sx={{
px: compact ? 1.5 : 2.4,
py: compact ? 1.4 : 2,
borderRadius: compact ? 2 : 3,
border: 'none',
background: 'transparent',
boxShadow: 'none',
minWidth: compact ? 170 : 220,
minHeight: compact ? 110 : 140,
display: "flex",
flexDirection: "column",
gap: 0.75,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box
sx={{
width: 36,
height: 36,
borderRadius: 2,
background: "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
}}
>
{icon}
</Box>
<Typography
sx={{
fontSize: "0.75rem",
letterSpacing: 0.6,
textTransform: "uppercase",
color: "rgba(255,255,255,0.72)",
fontWeight: 600,
}}
>
{title}
</Typography>
</Box>
<Typography
sx={{
fontSize: compact ? "1.2rem" : { xs: "1.4rem", md: "1.6rem" },
fontWeight: 700,
color: "#f8fafc",
lineHeight: 1.1,
}}
>
{main}
</Typography>
{sub ? (
<Typography sx={{ fontSize: "0.9rem", color: "rgba(226,232,240,0.78)" }}>{sub}</Typography>
) : null}
</Box>
);
const Island = ({ title, children, sx }) => (
<Box
sx={{
p: 2,
borderRadius: 3,
border: "none",
background: 'transparent',
boxShadow: "0 18px 40px rgba(2,6,23,0.55)",
mb: 1.5,
display: "flex",
flexDirection: "column",
minHeight: 0,
...(sx || {}),
}}
>
<Typography
variant="caption"
sx={{
color: MAGIC_UI.accentA,
fontWeight: 500,
fontSize: "0.9rem",
letterSpacing: 0.35,
textTransform: "uppercase",
display: "block",
mb: 1.5,
}}
>
{title}
</Typography>
<Box sx={{ flexGrow: 1, minHeight: 0 }}>{children}</Box>
</Box>
);
const deviceMetricData = useMemo(() => {
const cpuMain = (cpuInfo.name || (summary.processor || "") || "").split("\n")[0] || "Unknown CPU";
const cpuSub =
cpuInfo.ghz || cpuInfo.cores
? `${cpuInfo.ghz ? `${Number(cpuInfo.ghz).toFixed(2)}GHz ` : ""}${
cpuInfo.cores ? `(${cpuInfo.cores}-Cores)` : ""
}`.trim()
: "";
let totalRam = summary.total_ram;
if (!totalRam && Array.isArray(details.memory)) {
totalRam = details.memory.reduce((a, m) => a + (Number(m.capacity || 0) || 0), 0);
}
const memVal = totalRam ? `${formatBytes(totalRam)}` : "Unknown";
let memSpeed = "";
try {
const speeds = (details.memory || [])
.map((m) => parseInt(String(m.speed || "").replace(/[^0-9]/g, ""), 10))
.filter((v) => !Number.isNaN(v) && v > 0);
if (speeds.length) memSpeed = `Speed: ${Math.max(...speeds)} MT/s`;
} catch {}
let osDrive = null;
if (Array.isArray(details.storage)) {
osDrive =
details.storage.find((d) => String(d.drive || "").toUpperCase().startsWith("C:")) ||
details.storage[0] ||
null;
}
const storageMain = osDrive && osDrive.total != null ? `${formatBytes(osDrive.total)}` : "Unknown";
const storageSub =
osDrive && osDrive.used != null && osDrive.total != null
? `${formatBytes(osDrive.used)} of ${formatBytes(osDrive.total)} used`
: osDrive && osDrive.free != null && osDrive.total != null
? `${formatBytes(osDrive.total - osDrive.free)} of ${formatBytes(osDrive.total)} used`
: "";
const primaryIp = (summary.internal_ip || "").trim();
let nic = null;
if (Array.isArray(details.network)) {
nic = details.network.find((n) => (n.ips || []).includes(primaryIp)) || details.network[0] || null;
}
const normalizeSpeed = (val) => {
const s = String(val || "").trim();
if (!s) return "Unknown";
const low = s.toLowerCase();
if (low.includes("gbps") || low.includes("mbps")) return s;
const m = low.match(/(\d+\.?\d*)\s*([gmk]?)(bps)/);
if (!m) return s;
let num = parseFloat(m[1]);
const unit = m[2];
if (unit === "g") return `${num} Gbps`;
if (unit === "m") return `${num} Mbps`;
if (unit === "k") return `${(num / 1000).toFixed(1)} Mbps`;
if (num >= 1e9) return `${(num / 1e9).toFixed(1)} Gbps`;
if (num >= 1e6) return `${(num / 1e6).toFixed(0)} Mbps`;
return s;
};
const netVal = nic ? normalizeSpeed(nic.link_speed || nic.speed) : "Unknown";
return {
cpuMain,
cpuSub,
memVal,
memSpeed,
storageMain,
storageSub,
netVal,
nicLabel: nic?.adapter || " ",
};
}, [summary, details.memory, details.storage, details.network, cpuInfo]);
const renderDeviceSummaryTab = () => {
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 3, minHeight: 0 }}>
<Box
sx={{
borderRadius: 3,
background: "transparent",
p: { xs: 2, md: 3 },
display: "flex",
flexDirection: "column",
gap: 2,
minHeight: 0,
}}
>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", xl: "1fr auto" },
alignItems: "flex-start",
gap: { xs: 2, md: 3 },
}}
>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, minWidth: 0 }}>
<TextField
size="small"
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
onBlur={saveDescription}
placeholder="Add a friendly label"
sx={{
maxWidth: 420,
input: { color: "#fff" },
"& .MuiOutlinedInput-root": {
backgroundColor: "rgba(4,7,17,0.65)",
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
},
label: { color: MAGIC_UI.textMuted },
}}
/>
{connectionType === "ssh" && (
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1.5, alignItems: "center" }}>
<TextField
size="small"
label="SSH Endpoint"
value={connectionDraft}
onChange={(e) => setConnectionDraft(e.target.value)}
placeholder="user@host or host"
sx={{
minWidth: 260,
maxWidth: 360,
input: { color: "#fff" },
"& .MuiOutlinedInput-root": {
backgroundColor: "rgba(4,7,17,0.65)",
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
},
label: { color: MAGIC_UI.textMuted },
}}
/>
<Button
size="small"
variant="outlined"
onClick={saveConnectionEndpoint}
disabled={connectionSaving || connectionDraft.trim() === connectionEndpoint.trim()}
sx={{
textTransform: "none",
borderColor: MAGIC_UI.accentA,
color: MAGIC_UI.accentA,
borderRadius: 999,
px: 2,
}}
>
{connectionSaving ? "Saving..." : "Save"}
</Button>
<Box sx={{ display: "flex", flexDirection: "column" }}>
{connectionMessage && (
<Typography variant="caption" sx={{ color: MAGIC_UI.accentA }}>
{connectionMessage}
</Typography>
)}
{connectionError && (
<Typography variant="caption" sx={{ color: "#ff7b89" }}>
{connectionError}
</Typography>
)}
</Box>
</Box>
)}
</Box>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 1.2,
justifyContent: { xs: "flex-start", xl: "flex-end" },
alignSelf: "flex-start",
mt: { xs: 0, md: -0.5 },
"& > *": {
background: "transparent !important",
border: "none !important",
boxShadow: "none !important",
borderRadius: 0,
},
}}
>
<MetricCard
compact
icon={<DeveloperBoardRoundedIcon sx={{ fontSize: 24 }} />}
title="Processor"
main={deviceMetricData.cpuMain}
sub={deviceMetricData.cpuSub}
/>
<MetricCard
compact
icon={<MemoryRoundedIcon sx={{ fontSize: 24 }} />}
title="RAM"
main={deviceMetricData.memVal}
sub={deviceMetricData.memSpeed || " "}
/>
<MetricCard
compact
icon={<StorageRoundedIcon sx={{ fontSize: 24 }} />}
title="Storage"
main={deviceMetricData.storageMain}
sub={deviceMetricData.storageSub || " "}
/>
<MetricCard
compact
icon={<SpeedRoundedIcon sx={{ fontSize: 24 }} />}
title="Network"
main={deviceMetricData.netVal}
sub={deviceMetricData.nicLabel}
/>
</Box>
</Box>
<GridShell
sx={{
flexGrow: 1,
minHeight: 0,
height: SECTION_HEIGHTS.summary,
border: "none",
boxShadow: "none",
background: "transparent",
}}
>
<AgGridReact
rowData={summaryGridRows}
columnDefs={summaryGridColumns}
defaultColDef={defaultGridColDef}
pagination={false}
suppressCellFocus
getRowId={(params) => params.data?.id ?? params.rowIndex}
theme={myTheme}
style={{
width: "100%",
height: "100%",
fontFamily: gridFontFamily,
}}
/>
</GridShell>
</Box>
</Box>
);
};
const renderStorageTab = () => (
<GridShell sx={{ height: SECTION_HEIGHTS.storage }}>
<AgGridReact
rowData={storageRows}
columnDefs={storageColumnDefs}
defaultColDef={defaultGridColDef}
pagination={false}
suppressCellFocus
getRowId={(params) => params.data?.id ?? params.rowIndex}
theme={myTheme}
style={{
width: "100%",
height: "100%",
fontFamily: gridFontFamily,
}}
/>
</GridShell>
);
const renderMemoryTab = () => (
<GridShell sx={{ height: SECTION_HEIGHTS.memory }}>
<AgGridReact
rowData={memoryRows}
columnDefs={memoryColumnDefs}
defaultColDef={defaultGridColDef}
pagination={false}
suppressCellFocus
getRowId={(params) => params.data?.id ?? params.rowIndex}
theme={myTheme}
style={{
width: "100%",
height: "100%",
fontFamily: gridFontFamily,
}}
/>
</GridShell>
);
const renderNetworkTab = () => (
<GridShell sx={{ height: SECTION_HEIGHTS.network }}>
<AgGridReact
rowData={networkRows}
columnDefs={networkColumnDefs}
defaultColDef={defaultGridColDef}
pagination={false}
suppressCellFocus
getRowId={(params) => params.data?.id ?? params.rowIndex}
theme={myTheme}
style={{
width: "100%",
height: "100%",
fontFamily: gridFontFamily,
}}
/>
</GridShell>
);
const renderSoftware = () => (
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 1.5,
flexGrow: 1,
minHeight: 0,
}}
>
<TextField
size="small"
placeholder="Search software..."
value={softwareSearch}
onChange={(e) => setSoftwareSearch(e.target.value)}
sx={{
maxWidth: 320,
input: { color: "#fff" },
"& .MuiOutlinedInput-root": {
backgroundColor: "rgba(4,7,17,0.65)",
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
},
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
}}
/>
<GridShell sx={{ flexGrow: 1, minHeight: 360 }}>
<AgGridReact
rowData={softwareRows}
columnDefs={softwareColumnDefs}
defaultColDef={defaultGridColDef}
pagination
paginationPageSize={20}
paginationPageSizeSelector={[20, 50, 100]}
animateRows
quickFilterText={softwareSearch}
getRowId={getSoftwareRowId}
components={GRID_COMPONENTS}
theme={myTheme}
style={{
width: "100%",
height: "100%",
fontFamily: gridFontFamily,
"--ag-icon-font-family": iconFontFamily,
}}
/>
</GridShell>
</Box>
);
const renderRemoteShellTab = () => (
<Box
sx={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
minHeight: 0,
}}
>
<ReverseTunnelPowershell device={tunnelDevice} />
</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");
}, []);
const renderAdvancedConfigTab = () => {
const sourceLabel = vpnSource === "custom" ? "Custom overrides" : "Defaults";
const showProgress = vpnLoading || vpnSaving;
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, flexGrow: 1, minHeight: 0 }}>
<Box
sx={{
borderRadius: 3,
border: `1px solid ${MAGIC_UI.panelBorder}`,
background:
"linear-gradient(160deg, rgba(8,12,24,0.94), rgba(10,16,30,0.9)), radial-gradient(circle at 20% 10%, rgba(125,211,252,0.08), transparent 40%)",
boxShadow: "0 25px 80px rgba(2,6,23,0.65)",
p: { xs: 2, md: 3 },
}}
>
{showProgress ? <LinearProgress color="info" sx={{ height: 3, mb: 2 }} /> : null}
<Stack direction={{ xs: "column", md: "row" }} spacing={2} alignItems={{ xs: "flex-start", md: "center" }}>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h6" sx={{ color: MAGIC_UI.textBright, fontWeight: 700 }}>
Reverse VPN Tunnel - Allowed Ports
</Typography>
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted, mt: 0.5 }}>
Toggle which services the Engine can reach over the WireGuard tunnel for this device.
</Typography>
</Box>
<Chip
label={sourceLabel}
sx={{
borderRadius: 999,
fontWeight: 600,
letterSpacing: 0.2,
color: vpnSource === "custom" ? MAGIC_UI.accentA : MAGIC_UI.textMuted,
border: `1px solid ${MAGIC_UI.panelBorder}`,
backgroundColor: "rgba(8,12,24,0.75)",
}}
/>
</Stack>
<Divider sx={{ my: 2, borderColor: "rgba(148,163,184,0.2)" }} />
<Stack spacing={1.5}>
{vpnPortGroups.map((group) => (
<Box
key={group.key}
sx={{
display: "flex",
alignItems: { xs: "flex-start", md: "center" },
justifyContent: "space-between",
gap: 2,
p: 2,
borderRadius: 2,
border: `1px solid ${MAGIC_UI.panelBorder}`,
background: "rgba(6,10,20,0.7)",
}}
>
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ color: MAGIC_UI.textBright, fontWeight: 600 }}>
{group.label}
</Typography>
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted, mt: 0.35 }}>
{group.description}
</Typography>
<Stack direction="row" spacing={0.75} sx={{ mt: 0.8, flexWrap: "wrap" }}>
{group.ports.map((port) => (
<Chip
key={`${group.key}-${port}`}
label={`TCP ${port}`}
size="small"
sx={{
borderRadius: 999,
backgroundColor: "rgba(15,23,42,0.65)",
color: MAGIC_UI.textMuted,
border: `1px solid rgba(148,163,184,0.25)`,
}}
/>
))}
</Stack>
</Box>
<Switch
checked={Boolean(vpnToggles[group.key])}
onChange={(event) => handleVpnToggle(group.key, event.target.checked)}
color="info"
disabled={vpnLoading || vpnSaving}
/>
</Box>
))}
</Stack>
{vpnCustomPorts.length ? (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
Custom ports preserved: {vpnCustomPorts.join(", ")}
</Typography>
</Box>
) : null}
{vpnError ? (
<Typography variant="body2" sx={{ color: "#ff7b89", mt: 1 }}>
{vpnError}
</Typography>
) : null}
<Stack direction="row" spacing={1.25} sx={{ mt: 2 }}>
<Button
size="small"
disabled={!vpnAgentId || vpnSaving || vpnLoading}
onClick={saveVpnConfig}
sx={{
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
color: "#0b1220",
borderRadius: 999,
textTransform: "none",
px: 2.4,
"&:hover": {
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
},
}}
>
Save Config
</Button>
<Button
size="small"
disabled={!vpnDefaultPorts.length || vpnSaving || vpnLoading}
onClick={resetVpnConfig}
sx={{
borderRadius: 999,
textTransform: "none",
px: 2.4,
color: MAGIC_UI.textBright,
border: `1px solid ${MAGIC_UI.panelBorder}`,
backgroundColor: "rgba(8,12,24,0.6)",
"&:hover": {
backgroundColor: "rgba(12,18,35,0.8)",
},
}}
>
Reset Defaults
</Button>
</Stack>
</Box>
</Box>
);
};
const memoryRows = useMemo(
() =>
(details.memory || []).map((m, idx) => ({
id: `${m.slot || idx}`,
slot: m.slot || `BANK ${idx}`,
speed: m.speed || "unknown",
serial: m.serial || "unknown",
capacity: m.capacity,
})),
[details.memory]
);
const memoryColumnDefs = useMemo(
() => [
{ field: "slot", headerName: "Slot", width: 120, flex: 0, sortable: false },
{ field: "speed", headerName: "Speed", width: 130, flex: 0, sortable: false },
{ field: "serial", headerName: "Serial Number", width: 170, flex: 0, sortable: false },
{
field: "capacity",
headerName: "Capacity",
width: 140,
flex: 0,
valueFormatter: (params) => formatBytes(params.value),
},
],
[]
);
const storageRows = useMemo(() => {
const toNum = (val) => {
if (val === undefined || val === null) return undefined;
if (typeof val === "number") {
return Number.isNaN(val) ? undefined : val;
}
const n = parseFloat(String(val).replace(/[^0-9.]+/g, ""));
return Number.isNaN(n) ? undefined : n;
};
return (details.storage || []).map((d, idx) => {
const total = toNum(d.total);
let usagePct = toNum(d.usage);
let usedBytes = toNum(d.used);
let freeBytes = toNum(d.free);
if (usagePct !== undefined && usagePct <= 1) usagePct *= 100;
if (usedBytes === undefined && total !== undefined && usagePct !== undefined) {
usedBytes = (usagePct / 100) * total;
}
if (freeBytes === undefined && total !== undefined && usedBytes !== undefined) {
freeBytes = total - usedBytes;
}
if (usagePct === undefined && total && usedBytes !== undefined) {
usagePct = (usedBytes / total) * 100;
}
return {
id: `${d.drive || idx}`,
driveLabel: `Drive ${String(d.drive || "").replace("\\\\", "")}`,
disk_type: d.disk_type || "Fixed Disk",
total,
used: usedBytes,
freeBytes,
usage: usagePct,
};
});
}, [details.storage]);
const storageColumnDefs = useMemo(
() => [
{
field: "driveLabel",
headerName: "Drive",
width: 130,
flex: 0,
sortable: false,
filter: "agTextColumnFilter",
},
{ field: "disk_type", headerName: "Type", width: 120, flex: 0, sortable: false },
{
field: "total",
headerName: "Capacity",
valueFormatter: (params) => formatBytes(params.value),
width: 140,
flex: 0,
},
{
field: "used",
headerName: "Used",
valueFormatter: (params) => formatBytes(params.value),
width: 130,
flex: 0,
},
{
field: "freeBytes",
headerName: "Free",
valueFormatter: (params) => formatBytes(params.value),
width: 130,
flex: 0,
},
{
field: "usage",
headerName: "Usage",
valueFormatter: (params) =>
params.value === undefined || Number.isNaN(params.value) ? "unknown" : `${Math.round(params.value)}%`,
width: 110,
flex: 0,
},
],
[]
);
const networkRows = useMemo(
() =>
(details.network || []).map((n, idx) => ({
id: `${n.adapter || idx}`,
adapter: n.adapter || "Adapter",
ips: (n.ips || []).join(", "),
mac: formatMac(n.mac),
link_speed: n.link_speed || n.speed || "unknown",
internalIp: meta.internalIp || summary.internal_ip || "unknown",
externalIp: meta.externalIp || summary.external_ip || "unknown",
})),
[details.network, meta.internalIp, meta.externalIp, summary.internal_ip, summary.external_ip]
);
const networkColumnDefs = useMemo(
() => [
{ field: "adapter", headerName: "Adapter", width: 170, flex: 0 },
{ field: "ips", headerName: "IP Address(es)", width: 220, flex: 0 },
{ field: "mac", headerName: "MAC Address", width: 160, flex: 0 },
{ field: "internalIp", headerName: "Internal IP", width: 150, flex: 0 },
{ field: "externalIp", headerName: "External IP", width: 150, flex: 0 },
{ field: "link_speed", headerName: "Link Speed", width: 140, flex: 0 },
],
[]
);
const highlightCode = (code, lang) => {
try {
return Prism.highlight(code ?? "", Prism.languages[lang] || Prism.languages.markup, lang);
} catch {
return String(code || "");
}
};
const handleViewOutput = useCallback(async (row, which) => {
if (!row || !row.id) return;
try {
const resp = await fetch(`/api/device/activity/job/${row.id}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const lang = ((data.script_path || "").toLowerCase().endsWith(".ps1")) ? "powershell"
: ((data.script_path || "").toLowerCase().endsWith(".bat")) ? "batch"
: ((data.script_path || "").toLowerCase().endsWith(".sh")) ? "bash"
: ((data.script_path || "").toLowerCase().endsWith(".yml")) ? "yaml" : "powershell";
setOutputLang(lang);
const friendly = resolveAssemblyName(data.script_name, data.script_path);
setOutputTitle(`${which === 'stderr' ? 'StdErr' : 'StdOut'} - ${friendly}`);
setOutputContent(which === 'stderr' ? (data.stderr || "") : (data.stdout || ""));
setOutputOpen(true);
} catch (e) {
console.warn("Failed to load output", e);
}
}, [resolveAssemblyName]);
const handleCancelAnsibleRun = useCallback(async (row) => {
if (!row?.id) return;
try {
const resp = await fetch(`/api/ansible/run_for_activity/${encodeURIComponent(row.id)}`);
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
const runId = data.run_id;
if (runId) {
try {
const socket = window.BorealisSocket;
socket && socket.emit("ansible_playbook_cancel", { run_id: runId });
} catch {
/* ignore socket errors */
}
} else {
alert("Unable to locate run id for this playbook run.");
}
} catch (e) {
alert(String(e.message || e));
}
}, []);
const historyDisplayRows = useMemo(() => {
return (historyRows || []).map((row) => ({
...row,
script_display_name: resolveAssemblyName(row.script_name, row.script_path),
}));
}, [historyRows, resolveAssemblyName]);
const getHistoryRowId = useCallback((params) => String(params.data?.id || params.rowIndex), []);
const historyGridContext = useMemo(
() => ({
onViewOutput: handleViewOutput,
onCancelAnsible: handleCancelAnsibleRun,
}),
[handleViewOutput, handleCancelAnsibleRun]
);
const renderHistory = () => (
<GridShell sx={{ flexGrow: 1, minHeight: 360 }}>
<AgGridReact
rowData={historyDisplayRows}
columnDefs={historyColumnDefs}
defaultColDef={defaultGridColDef}
pagination
paginationPageSize={20}
paginationPageSizeSelector={[20, 50, 100]}
animateRows
components={GRID_COMPONENTS}
context={historyGridContext}
getRowId={getHistoryRowId}
suppressCellFocus
theme={myTheme}
style={{
width: "100%",
height: "100%",
fontFamily: gridFontFamily,
"--ag-icon-font-family": iconFontFamily,
}}
/>
</GridShell>
);
const status = lockedStatus || statusFromHeartbeat(agent.last_seen || device?.lastSeen);
const displayHostname = meta.hostname || summary.hostname || agent.hostname || device?.hostname || "Device Details";
const pageSubtitle = status ? `Status: ${status}` : "";
useEffect(() => {
onPageMetaChange?.({
page_title: displayHostname,
page_subtitle: pageSubtitle,
page_icon: PAGE_ICON,
});
return () => onPageMetaChange?.(null);
}, [displayHostname, onPageMetaChange, pageSubtitle]);
const topTabRenderers = [
renderDeviceSummaryTab,
renderStorageTab,
renderMemoryTab,
renderNetworkTab,
renderSoftware,
renderHistory,
renderAdvancedConfigTab,
renderRemoteShellTab,
renderRemoteDesktopTab,
];
const tabContent = (topTabRenderers[tab] || renderDeviceSummaryTab)();
return (
<Box
sx={{
m: 0,
p: { xs: 2, md: 3 },
borderRadius: 0,
background: "transparent",
boxShadow: "none",
display: "flex",
flexDirection: "column",
flexGrow: 1,
minWidth: 0,
height: "100%",
}}
>
<Box
sx={{
position: "fixed",
top: { xs: 72, md: 88 },
right: { xs: 12, md: 20 },
zIndex: 1400,
pointerEvents: "none",
}}
>
<Stack direction="row" spacing={1.25} sx={{ pointerEvents: "auto" }}>
<Button
size="small"
startIcon={<MoreHorizIcon />}
disabled={!(agent?.hostname || device?.hostname)}
onClick={(e) => setMenuAnchor(e.currentTarget)}
sx={{
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
color: "#0b1220",
borderRadius: 999,
textTransform: "none",
px: 2.2,
minWidth: 120,
boxShadow: "none",
"&:hover": {
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
boxShadow: "none",
},
}}
>
Actions
</Button>
</Stack>
</Box>
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={() => setMenuAnchor(null)}
PaperProps={{
sx: {
bgcolor: "rgba(8,12,24,0.96)",
color: "#fff",
border: `1px solid ${MAGIC_UI.panelBorder}`,
},
}}
>
<MenuItem
disabled={!canLaunchQuickJob}
onClick={() => {
setMenuAnchor(null);
if (!canLaunchQuickJob) return;
onQuickJobLaunch && onQuickJobLaunch(quickJobTargets);
}}
>
Quick Job
</MenuItem>
<MenuItem
onClick={() => {
setMenuAnchor(null);
setClearDialogOpen(true);
}}
>
Clear Device Activity
</MenuItem>
</Menu>
<Tabs
value={tab}
onChange={(e, v) => setTab(v)}
variant="scrollable"
scrollButtons="auto"
TabIndicatorProps={{
style: {
height: 3,
borderRadius: 3,
background: "linear-gradient(90deg, #7dd3fc, #c084fc)",
},
}}
sx={{
borderBottom: `1px solid ${MAGIC_UI.panelBorder}`,
mb: 2,
"& .MuiTab-root": {
color: MAGIC_UI.textMuted,
textTransform: "none",
fontWeight: 600,
fontFamily: '"IBM Plex Sans","Helvetica Neue",Arial,sans-serif',
fontSize: 15,
minHeight: 44,
opacity: 1,
borderRadius: 1,
transition: "background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease",
"&:hover": {
color: MAGIC_UI.textBright,
backgroundImage: TAB_HOVER_GRADIENT,
boxShadow: "0 0 0 1px rgba(148,163,184,0.25) inset",
},
},
"& .Mui-selected": {
color: MAGIC_UI.textBright,
"&:hover": {
backgroundImage: TAB_HOVER_GRADIENT,
},
},
}}
>
{TOP_TABS.map((tabDef) => (
<Tab
key={tabDef.key || tabDef.label}
label={tabDef.label}
icon={<tabDef.icon sx={{ fontSize: 18 }} />}
iconPosition="start"
/>
))}
</Tabs>
<Box sx={{ mt: 1, flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
{tabContent}
</Box>
<Dialog
open={outputOpen}
onClose={() => setOutputOpen(false)}
fullWidth
maxWidth="md"
PaperProps={{
sx: {
bgcolor: "rgba(8,12,24,0.96)",
color: "#fff",
border: `1px solid ${MAGIC_UI.panelBorder}`,
boxShadow: "0 25px 80px rgba(2,6,23,0.85)",
},
}}
>
<DialogTitle>{outputTitle}</DialogTitle>
<DialogContent>
<Box
sx={{
border: `1px solid ${MAGIC_UI.panelBorder}`,
borderRadius: 2,
bgcolor: "rgba(4,7,17,0.65)",
maxHeight: 500,
overflow: "auto",
}}
>
<Editor
value={outputContent}
onValueChange={() => {}}
highlight={(code) => highlightCode(code, outputLang)}
padding={12}
style={{
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 12,
color: "#e6edf3",
minHeight: 200,
}}
textareaProps={{ readOnly: true }}
/>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={() => setOutputOpen(false)}
sx={{ color: MAGIC_UI.accentA, textTransform: "none" }}
>
Close
</Button>
</DialogActions>
</Dialog>
<ClearDeviceActivityDialog
open={clearDialogOpen}
onCancel={() => setClearDialogOpen(false)}
onConfirm={() => {
clearHistory();
setClearDialogOpen(false);
}}
/>
</Box>
);
}