From e9c196d4b0f4ac15fb04f6160baea870d0fd85a2 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Fri, 7 Nov 2025 00:06:10 -0700 Subject: [PATCH] Updated Device Details Design --- .../src/Devices/Device_Details.jsx | 1762 ++++++++++------- .../web-interface/src/GUI_SYLING_GUIDE.md | 2 + 2 files changed, 1074 insertions(+), 690 deletions(-) diff --git a/Data/Engine/web-interface/src/Devices/Device_Details.jsx b/Data/Engine/web-interface/src/Devices/Device_Details.jsx index cef34176..3bea0f5b 100644 --- a/Data/Engine/web-interface/src/Devices/Device_Details.jsx +++ b/Data/Engine/web-interface/src/Devices/Device_Details.jsx @@ -1,23 +1,15 @@ ////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /Data/WebUI/src/Device_Details.js -import React, { useState, useEffect, useMemo, useCallback } from "react"; +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { - Paper, Box, Tabs, Tab, Typography, - Table, - TableHead, - TableRow, - TableCell, - TableBody, Button, IconButton, Menu, MenuItem, - LinearProgress, - TableSortLabel, TextField, Dialog, DialogTitle, @@ -38,14 +30,229 @@ import "prismjs/components/prism-batch"; import "prismjs/themes/prism-okaidia.css"; import Editor from "react-simple-code-editor"; import QuickJob from "../Scheduling/Quick_Job.jsx"; +import { AgGridReact } from "ag-grid-react"; +import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community"; + +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 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 TOP_TABS = ["Device Summary", "Storage", "Memory", "Network", "Installed Software", "Activity History"]; + +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 }) => ( + + {children} + +); + +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 ( + + + {value} + + ); +}); + +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 ( + + {isRunningAnsible ? ( + + ) : null} + {row.has_stdout ? ( + + ) : null} + {row.has_stderr ? ( + + ) : null} + + ); +}); + +const GRID_COMPONENTS = { + StatusPillCell, + HistoryActionsCell, +}; export default function DeviceDetails({ device, onBack }) { const [tab, setTab] = useState(0); const [agent, setAgent] = useState(device || {}); const [details, setDetails] = useState({}); const [meta, setMeta] = useState({}); - const [softwareOrderBy, setSoftwareOrderBy] = useState("name"); - const [softwareOrder, setSoftwareOrder] = useState("asc"); const [softwareSearch, setSoftwareSearch] = useState(""); const [description, setDescription] = useState(""); const [connectionType, setConnectionType] = useState(""); @@ -55,8 +262,6 @@ export default function DeviceDetails({ device, onBack }) { const [connectionMessage, setConnectionMessage] = useState(""); const [connectionError, setConnectionError] = useState(""); const [historyRows, setHistoryRows] = useState([]); - const [historyOrderBy, setHistoryOrderBy] = useState("ran_at"); - const [historyOrder, setHistoryOrder] = useState("desc"); const [outputOpen, setOutputOpen] = useState(false); const [outputTitle, setOutputTitle] = useState(""); const [outputContent, setOutputContent] = useState(""); @@ -65,6 +270,7 @@ export default function DeviceDetails({ device, onBack }) { const [menuAnchor, setMenuAnchor] = useState(null); const [clearDialogOpen, setClearDialogOpen] = useState(false); const [assemblyNameMap, setAssemblyNameMap] = useState({}); + const softwareGridApiRef = useRef(null); // Snapshotted status for the lifetime of this page const [lockedStatus, setLockedStatus] = useState(() => { // Prefer status provided by the device list row if available @@ -486,7 +692,7 @@ export default function DeviceDetails({ device, onBack }) { return `${num.toFixed(1)} ${units[i]}`; }; - const formatTimestamp = (epochSec) => { + const formatTimestamp = useCallback((epochSec) => { const ts = Number(epochSec || 0); if (!ts) return "unknown"; const d = new Date(ts * 1000); @@ -498,29 +704,27 @@ export default function DeviceDetails({ device, onBack }) { hh = hh % 12 || 12; const min = String(d.getMinutes()).padStart(2, "0"); return `${mm}/${dd}/${yyyy} @ ${hh}:${min} ${ampm}`; - }; + }, []); - const handleSoftwareSort = (col) => { - if (softwareOrderBy === col) { - setSoftwareOrder(softwareOrder === "asc" ? "desc" : "asc"); - } else { - setSoftwareOrderBy(col); - setSoftwareOrder("asc"); + const softwareRows = useMemo(() => details.software || [], [details.software]); + const handleSoftwareGridReady = useCallback( + (params) => { + softwareGridApiRef.current = params.api; + params.api.setQuickFilter(softwareSearch); + }, + [softwareSearch] + ); + + useEffect(() => { + if (softwareGridApiRef.current) { + softwareGridApiRef.current.setQuickFilter(softwareSearch); } - }; + }, [softwareSearch]); - const softwareRows = useMemo(() => { - const rows = details.software || []; - const filtered = rows.filter((s) => - s.name.toLowerCase().includes(softwareSearch.toLowerCase()) - ); - const dir = softwareOrder === "asc" ? 1 : -1; - return [...filtered].sort((a, b) => { - const A = a[softwareOrderBy] || ""; - const B = b[softwareOrderBy] || ""; - return String(A).localeCompare(String(B)) * dir; - }); - }, [details.software, softwareSearch, softwareOrderBy, softwareOrder]); + const getSoftwareRowId = useCallback( + (params) => `${params.data?.name || "software"}-${params.data?.version || ""}-${params.rowIndex}`, + [] + ); const summary = details.summary || {}; // Build a best-effort CPU display from summary fields @@ -541,401 +745,555 @@ export default function DeviceDetails({ device, onBack }) { return { cores, ghz, name, display }; }, [summary]); - const summaryItems = [ - { label: "Hostname", value: meta.hostname || summary.hostname || agent.hostname || device?.hostname || "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 ID", value: meta.agentId || summary.agent_id || 'unknown' }, - { label: "Agent GUID", value: meta.agentGuid || summary.agent_guid || 'unknown' }, - { label: "Agent Hash", value: meta.agentHash || summary.agent_hash || 'unknown' }, - ]; - - const MetricCard = ({ icon, title, main, sub, color }) => { - const edgeColor = color || '#232323'; - const parseHex = (hex) => { - const v = String(hex || '').replace('#', ''); - const n = parseInt(v.length === 3 ? v.split('').map(c => c + c).join('') : v, 16); - return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; - }; - const hexToRgba = (hex, alpha = 1) => { - try { const { r, g, b } = parseHex(hex); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } catch { return `rgba(88,166,255, ${alpha})`; } - }; - const lightenToRgba = (hex, p = 0.5, alpha = 1) => { - try { - const { r, g, b } = parseHex(hex); - const mix = (c) => Math.round(c + (255 - c) * p); - const R = mix(r), G = mix(g), B = mix(b); - return `rgba(${R}, ${G}, ${B}, ${alpha})`; - } catch { return hexToRgba('#58a6ff', alpha); } - }; - return ( - - - {icon} - {title} - - {main} - - - {sub ? {sub} : null} - - ); - }; - - const Island = ({ title, children, sx }) => ( - - {title} - {children} - + const summaryItems = useMemo( + () => [ + { + label: "Hostname", + value: meta.hostname || summary.hostname || agent.hostname || device?.hostname || "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 ID", value: meta.agentId || summary.agent_id || "unknown" }, + { label: "Agent GUID", value: meta.agentGuid || summary.agent_guid || "unknown" }, + { label: "Agent Hash", value: meta.agentHash || summary.agent_hash || "unknown" }, + ], + [meta, summary, agent, device, formatDateTime, formatLastSeen] ); - const renderSummary = () => { - // Derive metric values - // CPU tile: model as main, speed as sub (like screenshot) - 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) : null} - - ) - : ''; + 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]); - // MEMORY: total RAM + 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 historyColumnDefs = useMemo( + () => [ + { + headerName: "Assembly", + field: "script_type", + minWidth: 180, + valueGetter: (params) => + String(params.data?.script_type || "").toLowerCase() === "ansible" + ? "Ansible Playbook" + : "Script", + }, + { + 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", + }, + ], + [formatTimestamp] + ); + + const MetricCard = ({ icon, title, main, sub, compact = false }) => ( + + + + {icon} + + + {title} + + + + {main} + + {sub ? ( + {sub} + ) : null} + + ); + + const Island = ({ title, children, sx }) => ( + + + {title} + + {children} + + ); + + 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)) { - try { totalRam = details.memory.reduce((a, m) => a + (Number(m.capacity || 0) || 0), 0); } catch {} + totalRam = details.memory.reduce((a, m) => a + (Number(m.capacity || 0) || 0), 0); } - const memVal = totalRam ? `${formatBytes(totalRam)}` : 'Unknown'; - // RAM speed best-effort: use max speed among modules - let memSpeed = ''; + 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); + .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 {} - - // STORAGE: OS drive (Windows C: if available) let osDrive = null; if (Array.isArray(details.storage)) { - osDrive = details.storage.find((d) => String(d.drive || '').toUpperCase().startsWith('C:')) || details.storage[0] || null; + 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` - : ''; - - // NETWORK: Speed of adapter with internal IP or first - const primaryIp = (summary.internal_ip || '').trim(); + 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; } - function normalizeSpeed(val) { - const s = String(val || '').trim(); - if (!s) return 'unknown'; + 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; + 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`; - // raw bps - if (num >= 1e9) return `${(num/1e9).toFixed(1)} Gbps`; - if (num >= 1e6) return `${(num/1e6).toFixed(0)} Mbps`; + 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'; + }; + 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 ( - - {/* Metrics row at the very top */} - - } - title="Processor" - main={cpuMain} - sub={cpuSub} - color="#132332" + + + 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 }, + }} /> - } - title="Installed RAM" - main={memVal} - sub={memSpeed || ' '} - color="#291a2e" - /> - } - title="Storage" - main={storageMain} - sub={storageSub || ' '} - color="#142616" - /> - } - title="Network" - main={netVal} - sub={(nic && nic.adapter) ? nic.adapter : ' '} - color="#2b1a18" - /> - - {/* Split pane: three-column layout (Summary | Storage | Memory/Network) */} - - {/* Left column: Summary table */} - - - - - - Description - - setDescription(e.target.value)} - onBlur={saveDescription} - placeholder="Enter description" - sx={{ - input: { color: '#fff' }, - '& .MuiOutlinedInput-root': { - '& fieldset': { borderColor: '#555' }, - '&:hover fieldset': { borderColor: '#888' } - } - }} - /> - - - {connectionType === "ssh" && ( - - SSH Endpoint - - - setConnectionDraft(e.target.value)} - placeholder="user@host or host" - sx={{ - maxWidth: 300, - input: { color: '#fff' }, - '& .MuiOutlinedInput-root': { - '& fieldset': { borderColor: '#555' }, - '&:hover fieldset': { borderColor: '#888' } - } - }} - /> - - - {connectionMessage && ( - {connectionMessage} - )} - {connectionError && ( - {connectionError} - )} - - - )} - {summaryItems.map((item) => ( - - {item.label} - {item.value} - - ))} - -
+ {connectionType === "ssh" && ( + + 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 }, + }} + /> + + + {connectionMessage && ( + + {connectionMessage} + + )} + {connectionError && ( + + {connectionError} + + )} + -
- - {/* Middle column: Storage */} - {renderStorage()} - - {/* Right column: Memory + Network */} - - {renderMemory()} - {renderNetwork()} - + )} + + params.data?.id ?? params.rowIndex} + theme={myTheme} + style={{ + width: "100%", + height: "100%", + fontFamily: gridFontFamily, + }} + /> +
); }; - const placeholderTable = (headers) => ( - - - - - {headers.map((h) => ( - {h} - ))} - - - - - - No data available. - - - -
+ const renderStorageTab = () => ( + + params.data?.id ?? params.rowIndex} + theme={myTheme} + style={{ + width: "100%", + height: "100%", + fontFamily: gridFontFamily, + }} + /> + + ); + + const renderMemoryTab = () => ( + + params.data?.id ?? params.rowIndex} + theme={myTheme} + style={{ + width: "100%", + height: "100%", + fontFamily: gridFontFamily, + }} + /> + + ); + + const renderNetworkTab = () => ( + + params.data?.id ?? params.rowIndex} + theme={myTheme} + style={{ + width: "100%", + height: "100%", + fontFamily: gridFontFamily, + }} + /> + + ); + + const renderSoftware = () => ( + + 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 }, + }} + /> + + + ); - const renderSoftware = () => { - if (!softwareRows.length) - return placeholderTable(["Software Name", "Version", "Action"]); + 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] + ); - return ( - - - setSoftwareSearch(e.target.value)} - sx={{ - input: { color: "#fff" }, - "& .MuiOutlinedInput-root": { - "& fieldset": { borderColor: "#555" }, - "&:hover fieldset": { borderColor: "#888" } - } - }} - /> - - {/* Constrain the table height within the page and enable scrolling */} - - - - - - handleSoftwareSort("name")} - > - Software Name - - - - handleSoftwareSort("version")} - > - Version - - - Action - - - - {softwareRows.map((s, i) => ( - - {s.name} - {s.version} - - - ))} - -
-
-
- ); - }; + 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 renderMemory = () => { - const rows = details.memory || []; - if (!rows.length) return placeholderTable(["Slot", "Speed", "Serial Number", "Capacity"]); - return ( - - - - - Slot - Speed - Serial Number - Capacity - - - - {rows.map((m, i) => ( - - {m.slot} - {m.speed} - {m.serial} - {formatBytes(m.capacity)} - - ))} - -
-
- ); - }; - - const renderStorage = () => { + const storageRows = useMemo(() => { const toNum = (val) => { if (val === undefined || val === null) return undefined; if (typeof val === "number") { @@ -944,152 +1302,102 @@ export default function DeviceDetails({ device, onBack }) { const n = parseFloat(String(val).replace(/[^0-9.]+/g, "")); return Number.isNaN(n) ? undefined : n; }; - - const rows = (details.storage || []).map((d) => { + 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); - let freePct; - - if (usagePct !== undefined) { - if (usagePct <= 1) usagePct *= 100; - freePct = 100 - usagePct; - } - + 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 (freePct === undefined && total !== undefined && freeBytes !== undefined) { - freePct = (freeBytes / total) * 100; + if (usagePct === undefined && total && usedBytes !== undefined) { + usagePct = (usedBytes / total) * 100; } - - if (usagePct === undefined && freePct !== undefined) { - usagePct = 100 - freePct; - } - return { - drive: d.drive, - disk_type: d.disk_type, - used: usedBytes, - freePct, - freeBytes, + 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]); - if (!rows.length) { - return placeholderTable(["Drive", "Type", "Capacity"]); - } + 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 fmtPct = (v) => (v !== undefined && !Number.isNaN(v) ? `${v.toFixed(0)}%` : "unknown"); + 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] + ); - return ( - - {rows.map((d, i) => { - const usage = d.usage ?? (d.total ? ((d.used || 0) / d.total) * 100 : 0); - const used = d.used; - const free = d.freeBytes; - const total = d.total; - return ( - - - - - {`Drive ${String(d.drive || '').replace('\\', '')}`} - {d.disk_type || 'Fixed Disk'} - - {total !== undefined ? formatBytes(total) : 'unknown'} - - - - - - - {used !== undefined ? `${formatBytes(used)} - ${fmtPct(usage)} in use` : 'unknown'} - - - {free !== undefined && total !== undefined ? `${formatBytes(free)} - ${fmtPct(100 - (usage || 0))} remaining` : ''} - - - - ); - })} - - ); - }; - - const renderNetwork = () => { - const rows = details.network || []; - const internalIp = meta.internalIp || summary.internal_ip || "unknown"; - const externalIp = meta.externalIp || summary.external_ip || "unknown"; - const ipHeader = ( - - - Internal IP: {internalIp || 'unknown'} - - - External IP: {externalIp || 'unknown'} - - - ); - if (!rows.length) { - return ( - - {ipHeader} - {placeholderTable(["Adapter", "IP Address", "MAC Address"])} - - ); - } - return ( - - {ipHeader} - - - - Adapter - IP Address - MAC Address - - - - {rows.map((n, i) => ( - - {n.adapter} - {(n.ips || []).join(", ")} - {formatMac(n.mac)} - - ))} - -
-
- ); - }; - - const jobStatusColor = (s) => { - const val = String(s || "").toLowerCase(); - if (val === "running") return "#58a6ff"; // borealis blue - if (val === "success") return "#00d18c"; - if (val === "failed") return "#ff4f4f"; - return "#666"; - }; + 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 { @@ -1119,13 +1427,27 @@ export default function DeviceDetails({ device, onBack }) { } }, [resolveAssemblyName]); - const handleHistorySort = (col) => { - if (historyOrderBy === col) setHistoryOrder(historyOrder === "asc" ? "desc" : "asc"); - else { - setHistoryOrderBy(col); - setHistoryOrder("asc"); + 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) => ({ @@ -1134,161 +1456,171 @@ export default function DeviceDetails({ device, onBack }) { })); }, [historyRows, resolveAssemblyName]); - const sortedHistory = useMemo(() => { - const dir = historyOrder === "asc" ? 1 : -1; - const key = historyOrderBy === "script_name" ? "script_display_name" : historyOrderBy; - return [...historyDisplayRows].sort((a, b) => { - const A = a[key]; - const B = b[key]; - if (key === "ran_at") return ((A || 0) - (B || 0)) * dir; - return String(A ?? "").localeCompare(String(B ?? "")) * dir; - }); - }, [historyDisplayRows, historyOrderBy, historyOrder]); + const getHistoryRowId = useCallback((params) => String(params.data?.id || params.rowIndex), []); + + const historyGridContext = useMemo( + () => ({ + onViewOutput: handleViewOutput, + onCancelAnsible: handleCancelAnsibleRun, + }), + [handleViewOutput, handleCancelAnsibleRun] + ); + const renderHistory = () => ( - - - - - Assembly - - handleHistorySort("script_name")}> - Task - - - - handleHistorySort("ran_at")} - > - Ran On - - - - handleHistorySort("status")} - > - Job Status - - - - StdOut / StdErr - - - - - {sortedHistory.map((r) => ( - - {(r.script_type || '').toLowerCase() === 'ansible' ? 'Ansible Playbook' : 'Script'} - {r.script_display_name || r.script_name} - {formatTimestamp(r.ran_at)} - - - {r.status} - - - - - {(String(r.script_type || '').toLowerCase() === 'ansible' && String(r.status||'') === 'Running') ? ( - - ) : null} - {r.has_stdout ? ( - - ) : null} - {r.has_stderr ? ( - - ) : null} - - - - ))} - {sortedHistory.length === 0 && ( - No activity yet. - )} - -
-
+ + + ); - const tabs = [ - { label: "Summary", content: renderSummary() }, - { label: "Installed Software", content: renderSoftware() }, - { label: "Activity History", content: renderHistory() } - ]; - // Use the snapshotted status so it stays static while on this page const status = lockedStatus || statusFromHeartbeat(agent.last_seen || device?.lastSeen); + const topTabRenderers = [ + renderDeviceSummaryTab, + renderStorageTab, + renderMemoryTab, + renderNetworkTab, + renderSoftware, + renderHistory, + ]; + const tabContent = (topTabRenderers[tab] || renderDeviceSummaryTab)(); + return ( - - - + + + {onBack && ( - )} - - - {agent.hostname || "Device Details"} - + + + + {agent.hostname || "Device Details"} + + + GUID: {meta.agentGuid || summary.agent_guid || "unknown"} + + - + + + } + title="Processor" + main={deviceMetricData.cpuMain} + sub={deviceMetricData.cpuSub} + /> + } + title="RAM" + main={deviceMetricData.memVal} + sub={deviceMetricData.memSpeed || " "} + /> + } + title="Storage" + main={deviceMetricData.storageMain} + sub={deviceMetricData.storageSub || " "} + /> + } + title="Network" + main={deviceMetricData.netVal} + sub={deviceMetricData.nicLabel} + /> + + + setMenuAnchor(e.currentTarget)} sx={{ - color: !(agent?.hostname || device?.hostname) ? "#666" : "#58a6ff", - borderColor: !(agent?.hostname || device?.hostname) ? "#333" : "#58a6ff", - border: "1px solid", - borderRadius: 1, - width: 32, - height: 32 + color: !(agent?.hostname || device?.hostname) ? MAGIC_UI.textMuted : MAGIC_UI.textBright, + border: "1px solid rgba(148,163,184,0.45)", + borderRadius: 2, + width: 38, + height: 38, + backgroundColor: "rgba(4,7,17,0.6)", }} > @@ -1297,6 +1629,13 @@ export default function DeviceDetails({ device, onBack }) { 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}`, + }, + }} > { @@ -1320,58 +1659,101 @@ export default function DeviceDetails({ device, onBack }) { setTab(v)} - sx={{ borderBottom: 1, borderColor: "#333" }} + variant="scrollable" + scrollButtons="auto" + TabIndicatorProps={{ + style: { + height: 3, + borderRadius: 3, + background: "linear-gradient(90deg, #7dd3fc, #c084fc)", + }, + }} + sx={{ + borderBottom: `1px solid rgba(148,163,184,0.35)`, + mb: 2, + "& .MuiTab-root": { + color: MAGIC_UI.textMuted, + textTransform: "none", + fontWeight: 600, + }, + "& .Mui-selected": { color: MAGIC_UI.textBright }, + }} > - {tabs.map((t) => ( - + {TOP_TABS.map((label) => ( + ))} - {tabs[tab].content} + + {tabContent} + - setOutputOpen(false)} fullWidth maxWidth="md" - PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }} + 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)", + }, + }} > {outputTitle} - + {}} highlight={(code) => highlightCode(code, outputLang)} padding={12} style={{ - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', fontSize: 12, color: "#e6edf3", - minHeight: 200 + minHeight: 200, }} textareaProps={{ readOnly: true }} /> - + - {/* Recap dialog removed; recaps flow into Activity History stdout */} + setClearDialogOpen(false)} + onConfirm={() => { + clearHistory(); + setClearDialogOpen(false); + }} + /> - setClearDialogOpen(false)} - onConfirm={() => { - clearHistory(); - setClearDialogOpen(false); - }} + {quickJobOpen && ( + setQuickJobOpen(false)} + hostnames={[agent?.hostname || device?.hostname].filter(Boolean)} /> - - {quickJobOpen && ( - setQuickJobOpen(false)} - hostnames={[agent?.hostname || device?.hostname].filter(Boolean)} - /> - )} - - ); - } + )} +
+ ); +} diff --git a/Data/Engine/web-interface/src/GUI_SYLING_GUIDE.md b/Data/Engine/web-interface/src/GUI_SYLING_GUIDE.md index 648033c7..6d7d57fb 100644 --- a/Data/Engine/web-interface/src/GUI_SYLING_GUIDE.md +++ b/Data/Engine/web-interface/src/GUI_SYLING_GUIDE.md @@ -4,7 +4,9 @@ - **Full-Bleed Canvas**: Let hero shells run edge-to-edge inside the content column (no dark voids); reserve inset padding for interior cards so gradients feel immersive. - **Glass Panels**: Primary panels/cards use glassmorphic layers (`rgba(15,23,42,0.7)`), rounded 16–24px corners, blurred backdrops, and micro borders; add radial light flares via pseudo-elements for motion while keeping content readable. - **Hero Storytelling**: Each view begins with a stat-forward hero—gradient StatTiles (min 160px) and uppercase pills (HERO_BADGE_SX) summarize live signals, active filters, and selections so telemetry feels alive at a glance. +- **Summary Data Grids**: When metadata runs long (device summary, network facts) render it with AG Grid inside a glass wrapper—two columns (Field/Value), matte navy background, and no row striping so it reads like structured cards instead of spreadsheets. - **Tile Palettes**: Online tiles lean cyan→green, stale tiles go orange→red, “needs update” stays violet→cyan, and secondary metrics fade from cyan into desaturated steel so each KPI has a consistent hue family across pages. +- **Hardware Islands**: Storage, memory, and network blocks reuse the Quartz AG Grid theme inside rounded glass shells with flat fills (no gradients); show readable numeric columns (`Capacity`, `Used`, `Free`, `%`) instead of custom bars so panels match the Device Inventory surface. - **Action Surfaces**: Control bars (view selectors, tool strips) live inside translucent glass bands with generous spacing; selectors get filled dark inputs with cyan hover borders, while primary actions are pill-shaped gradients and secondary controls use soft-outline icon buttons. - **Anchored Controls**: Align view selectors/utility buttons directly with grid edges, keeping the controls in a single horizontal row that feels docked to the data surface; reserve glass backdrops for hero sections so the content canvas stays flush. - **Buttons & Chips**: Reserve gradient pills (`linear-gradient(135deg,#34d399,#22d3ee)` for success, `#7dd3fc→#c084fc` for creation) for primary CTAs; neutral actions rely on rounded outlines with `rgba(148,163,184,0.4)` borders and uppercase microcopy for supporting tokens.