From 887d6c35969b5f6725c83c61624126ba8c17c7cf Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 28 Sep 2025 03:59:24 -0600 Subject: [PATCH] Design Overhaul of Device Details Page --- Data/Agent/Roles/role_DeviceAudit.py | 133 +++++- Data/Server/WebUI/src/App.jsx | 17 +- .../WebUI/src/Devices/Device_Details.jsx | 390 ++++++++++++------ 3 files changed, 418 insertions(+), 122 deletions(-) diff --git a/Data/Agent/Roles/role_DeviceAudit.py b/Data/Agent/Roles/role_DeviceAudit.py index dfb51a8..841fbe5 100644 --- a/Data/Agent/Roles/role_DeviceAudit.py +++ b/Data/Agent/Roles/role_DeviceAudit.py @@ -344,9 +344,9 @@ def collect_network(): "try { " "$ip = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | " "Where-Object { $_.IPAddress -and $_.IPAddress -notmatch '^169\\.254\\.' -and $_.IPAddress -ne '127.0.0.1' }; " - "$ad = Get-NetAdapter | ForEach-Object { $_ | Select-Object -Property InterfaceAlias, MacAddress }; " - "$map = @{}; foreach($a in $ad){ $map[$a.InterfaceAlias] = $a.MacAddress }; " - "$out = @(); foreach($e in $ip){ $mac = $map[$e.InterfaceAlias]; $out += [pscustomobject]@{ InterfaceAlias=$e.InterfaceAlias; IPAddress=$e.IPAddress; MacAddress=$mac } } " + "$ad = Get-NetAdapter | ForEach-Object { $_ | Select-Object -Property InterfaceAlias, MacAddress, LinkSpeed }; " + "$map = @{}; foreach($a in $ad){ $map[$a.InterfaceAlias] = @{ Mac=$a.MacAddress; LinkSpeed=('' + $a.LinkSpeed).Trim() } }; " + "$out = @(); foreach($e in $ip){ $m = $map[$e.InterfaceAlias]; $mac = $m.Mac; $ls = $m.LinkSpeed; $out += [pscustomobject]@{ InterfaceAlias=$e.InterfaceAlias; IPAddress=$e.IPAddress; MacAddress=$mac; LinkSpeed=$ls } } " "$out | ConvertTo-Json -Depth 3 } catch { '' }" ) data = _ps_json(ps_cmd, timeout=60) @@ -357,9 +357,10 @@ def collect_network(): alias = e.get('InterfaceAlias') or 'unknown' ip = e.get('IPAddress') or '' mac = e.get('MacAddress') or 'unknown' + link = e.get('LinkSpeed') or '' if not ip: continue - item = tmp.setdefault(alias, {'adapter': alias, 'ips': [], 'mac': mac}) + item = tmp.setdefault(alias, {'adapter': alias, 'ips': [], 'mac': mac, 'link_speed': link}) if ip not in item['ips']: item['ips'].append(ip) if tmp: @@ -408,6 +409,88 @@ def collect_network(): return adapters +def collect_cpu() -> dict: + """Collect CPU model, cores, and base clock (best-effort cross-platform).""" + out: dict = {} + try: + plat = platform.system().lower() + if plat == 'windows': + try: + ps_cmd = ( + "Get-CimInstance Win32_Processor | " + "Select-Object Name, NumberOfCores, NumberOfLogicalProcessors, MaxClockSpeed | ConvertTo-Json" + ) + data = _ps_json(ps_cmd, timeout=15) + if isinstance(data, dict): + data = [data] + name = '' + phys = 0 + logi = 0 + mhz = 0 + for idx, cpu in enumerate(data or []): + if idx == 0: + name = str(cpu.get('Name') or '') + try: + mhz = int(cpu.get('MaxClockSpeed') or 0) + except Exception: + mhz = 0 + try: + phys += int(cpu.get('NumberOfCores') or 0) + except Exception: + pass + try: + logi += int(cpu.get('NumberOfLogicalProcessors') or 0) + except Exception: + pass + out = { + 'name': name.strip(), + 'physical_cores': phys or None, + 'logical_cores': logi or None, + 'base_clock_ghz': (float(mhz) / 1000.0) if mhz else None, + } + return out + except Exception: + pass + elif plat == 'darwin': + try: + name = subprocess.run(["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True, text=True, timeout=5).stdout.strip() + except Exception: + name = '' + try: + cores = int(subprocess.run(["sysctl", "-n", "hw.ncpu"], capture_output=True, text=True, timeout=5).stdout.strip() or '0') + except Exception: + cores = 0 + out = {'name': name, 'logical_cores': cores or None} + return out + else: + # Linux + try: + brand = '' + cores = 0 + with open('/proc/cpuinfo', 'r', encoding='utf-8', errors='ignore') as fh: + for line in fh: + if not brand and 'model name' in line: + brand = line.split(':', 1)[-1].strip() + if 'processor' in line: + cores += 1 + out = {'name': brand, 'logical_cores': cores or None} + return out + except Exception: + pass + except Exception: + pass + # psutil fallback + try: + if psutil: + return { + 'name': platform.processor() or '', + 'physical_cores': psutil.cpu_count(logical=False) if hasattr(psutil, 'cpu_count') else None, + 'logical_cores': psutil.cpu_count(logical=True) if hasattr(psutil, 'cpu_count') else None, + } + except Exception: + pass + return out or {} + def detect_device_type(): try: plat = platform.system().lower() @@ -645,6 +728,48 @@ def _build_details_fallback() -> dict: except Exception: pass + # CPU information (summary + display string) + try: + cpu = collect_cpu() + if cpu: + summary['cpu'] = cpu + cores = cpu.get('logical_cores') or cpu.get('physical_cores') + ghz = cpu.get('base_clock_ghz') + name = (cpu.get('name') or '').strip() + parts = [] + if name: + parts.append(name) + if ghz: + try: + parts.append(f"({float(ghz):.1f}GHz)") + except Exception: + pass + if cores: + parts.append(f"@ {int(cores)} Cores") + if parts: + summary['processor'] = ' '.join(parts) + except Exception: + pass + + # Total RAM (bytes) for quick UI metrics + try: + total = 0 + mem_list = collect_memory() + for m in (mem_list or []): + try: + total += int(m.get('capacity') or 0) + except Exception: + pass + if not total and psutil: + try: + total = int(psutil.virtual_memory().total) + except Exception: + pass + if total: + summary['total_ram'] = total + except Exception: + pass + details = { 'summary': summary, 'software': collect_software(), diff --git a/Data/Server/WebUI/src/App.jsx b/Data/Server/WebUI/src/App.jsx index 5179868..e466c71 100644 --- a/Data/Server/WebUI/src/App.jsx +++ b/Data/Server/WebUI/src/App.jsx @@ -914,9 +914,22 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; - + - + *': { + alignSelf: 'stretch', + minHeight: 'calc(100% - 32px)' // account for typical m:2 top+bottom margins + } + }} + > {renderMainContent()} diff --git a/Data/Server/WebUI/src/Devices/Device_Details.jsx b/Data/Server/WebUI/src/Devices/Device_Details.jsx index 0e8f957..3bce9c7 100644 --- a/Data/Server/WebUI/src/Devices/Device_Details.jsx +++ b/Data/Server/WebUI/src/Devices/Device_Details.jsx @@ -24,6 +24,10 @@ import { DialogContent, DialogActions } from "@mui/material"; +import StorageRoundedIcon from "@mui/icons-material/StorageRounded"; +import MemoryRoundedIcon from "@mui/icons-material/MemoryRounded"; +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"; @@ -226,9 +230,28 @@ export default function DeviceDetails({ device, onBack }) { }, [details.software, softwareSearch, softwareOrderBy, softwareOrder]); const summary = details.summary || {}; + // Build a best-effort CPU display from summary fields + const cpuInfo = useMemo(() => { + const 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 = [ { label: "Hostname", value: summary.hostname || agent.hostname || device?.hostname || "unknown" }, { label: "Operating System", value: summary.operating_system || agent.agent_operating_system || "unknown" }, + { label: "Processor", value: cpuInfo.display || "unknown" }, { label: "Device Type", value: summary.device_type || "unknown" }, { label: "Last User", value: ( @@ -246,42 +269,206 @@ export default function DeviceDetails({ device, onBack }) { { label: "Last Seen", value: formatLastSeen(agent.last_seen || device?.lastSeen) } ]; - const renderSummary = () => ( - - - - - Description - - setDescription(e.target.value)} - onBlur={saveDescription} - placeholder="Enter description" - sx={{ - input: { color: "#fff" }, - "& .MuiOutlinedInput-root": { - "& fieldset": { borderColor: "#555" }, - "&:hover fieldset": { borderColor: "#888" } - } - }} - /> - - - {summaryItems.map((item) => ( - - {item.label} - {item.value} - - ))} - -
-
+ 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 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} + + ) + : ''; + + // MEMORY: total RAM + 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 {} + } + const memVal = totalRam ? `${formatBytes(totalRam)}` : 'Unknown'; + // RAM speed best-effort: use max speed among modules + 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 {} + + // 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; + } + 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(); + 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 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`; + // raw bps + 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 ( + + {/* Metrics row at the very top */} + + } + title="Processor" + main={cpuMain} + sub={cpuSub} + color="#132332" + /> + } + 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' } + } + }} + /> + + + {summaryItems.map((item) => ( + + {item.label} + {item.value} + + ))} + +
+
+
+ + {/* Middle column: Storage */} + {renderStorage()} + + {/* Right column: Memory + Network */} + + {renderMemory()} + {renderNetwork()} + +
+
+ ); + }; + const placeholderTable = (headers) => ( - + @@ -306,8 +493,8 @@ export default function DeviceDetails({ device, onBack }) { return placeholderTable(["Software Name", "Version", "Action"]); return ( - - + + - -
+ +
@@ -366,7 +553,7 @@ export default function DeviceDetails({ device, onBack }) { const rows = details.memory || []; if (!rows.length) return placeholderTable(["Slot", "Speed", "Serial Number", "Capacity"]); return ( - +
@@ -440,80 +627,54 @@ export default function DeviceDetails({ device, onBack }) { }; }); - if (!rows.length) - return placeholderTable([ - "Drive Letter", - "Disk Type", - "Used", - "Free %", - "Free GB", - "Total Size", - "Usage", - ]); + if (!rows.length) { + return placeholderTable(["Drive", "Type", "Capacity"]); + } + + const fmtPct = (v) => (v !== undefined && !Number.isNaN(v) ? `${v.toFixed(0)}%` : "unknown"); return ( - -
- - - Drive Letter - Disk Type - Used - Free % - Free GB - Total Size - Usage - - - - {rows.map((d, i) => ( - - {d.drive} - {d.disk_type} - - {d.used !== undefined && !Number.isNaN(d.used) - ? formatBytes(d.used) - : "unknown"} - - - {d.freePct !== undefined && !Number.isNaN(d.freePct) - ? `${d.freePct.toFixed(1)}%` - : "unknown"} - - - {d.freeBytes !== undefined && !Number.isNaN(d.freeBytes) - ? formatBytes(d.freeBytes) - : "unknown"} - - - {d.total !== undefined && !Number.isNaN(d.total) - ? formatBytes(d.total) - : "unknown"} - - - - - - - - {d.usage !== undefined && !Number.isNaN(d.usage) - ? `${d.usage.toFixed(1)}%` - : "unknown"} - - - - - ))} - -
+ + {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` : ''} + + + + ); + })} ); }; @@ -522,7 +683,7 @@ export default function DeviceDetails({ device, onBack }) { const rows = details.network || []; if (!rows.length) return placeholderTable(["Adapter", "IP Address", "MAC Address"]); return ( - + @@ -598,7 +759,7 @@ export default function DeviceDetails({ device, onBack }) { }, [historyRows, historyOrderBy, historyOrder]); const renderHistory = () => ( - +
@@ -681,10 +842,7 @@ export default function DeviceDetails({ device, onBack }) { const tabs = [ { label: "Summary", content: renderSummary() }, - { label: "Software", content: renderSoftware() }, - { label: "Memory", content: renderMemory() }, - { label: "Storage", content: renderStorage() }, - { label: "Network", content: renderNetwork() }, + { label: "Installed Software", content: renderSoftware() }, { label: "Activity History", content: renderHistory() } ]; // Use the snapshotted status so it stays static while on this page