mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2026-02-04 03:30:32 -07:00
2143 lines
71 KiB
JavaScript
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>
|
|
);
|
|
}
|