From d7c991548d30b9479085240f807309d4d859ebb9 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 8 Oct 2025 20:27:26 -0600 Subject: [PATCH 1/4] Refactor device inventory storage and API --- .../WebUI/src/Devices/Device_Details.jsx | 31 +- Data/Server/WebUI/src/Devices/Device_List.jsx | 467 ++++++----- Data/Server/server.py | 760 +++++++++++++++--- 3 files changed, 923 insertions(+), 335 deletions(-) diff --git a/Data/Server/WebUI/src/Devices/Device_Details.jsx b/Data/Server/WebUI/src/Devices/Device_Details.jsx index 47bcbe59..5225bb5d 100644 --- a/Data/Server/WebUI/src/Devices/Device_Details.jsx +++ b/Data/Server/WebUI/src/Devices/Device_Details.jsx @@ -111,8 +111,35 @@ export default function DeviceDetails({ device, onBack }) { setAgent({ id: device.id, ...agentsData[device.id] }); } const detailData = await detailsRes.json(); - setDetails(detailData || {}); - setDescription(detailData?.summary?.description || ""); + const summary = detailData?.summary && typeof detailData.summary === 'object' + ? detailData.summary + : (detailData?.details?.summary || {}); + const normalized = { + ...(detailData?.details || {}), + summary: summary || {}, + memory: Array.isArray(detailData?.memory) ? detailData.memory : (detailData?.details?.memory || []), + network: Array.isArray(detailData?.network) ? detailData.network : (detailData?.details?.network || []), + software: Array.isArray(detailData?.software) ? detailData.software : (detailData?.details?.software || []), + storage: Array.isArray(detailData?.storage) ? detailData.storage : (detailData?.details?.storage || []), + cpu: detailData?.cpu || detailData?.details?.cpu || {}, + }; + if (detailData?.description) { + normalized.summary = { ...normalized.summary, description: detailData.description }; + } + setDetails(normalized); + setDescription(normalized.summary?.description || detailData?.description || ""); + setAgent((prev) => ({ + ...(prev || {}), + id: device?.id || prev?.id, + hostname: device?.hostname || normalized.summary?.hostname || prev?.hostname, + agent_hash: detailData?.agent_hash || normalized.summary?.agent_hash || prev?.agent_hash, + agent_operating_system: + detailData?.operating_system || + normalized.summary?.operating_system || + prev?.agent_operating_system, + device_type: detailData?.device_type || normalized.summary?.device_type || prev?.device_type, + last_seen: detailData?.last_seen || normalized.summary?.last_seen || prev?.last_seen, + })); } catch (e) { console.warn("Failed to load device info", e); } diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index fdbe35ae..8e90c3e3 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -51,6 +51,21 @@ function statusFromHeartbeat(tsSec, offlineAfter = 300) { return now - tsSec <= offlineAfter ? "Online" : "Offline"; } +function formatUptime(seconds) { + const total = Number(seconds); + if (!Number.isFinite(total) || total <= 0) return ""; + const parts = []; + const days = Math.floor(total / 86400); + if (days) parts.push(`${days}d`); + const hours = Math.floor((total % 86400) / 3600); + if (hours) parts.push(`${hours}h`); + const minutes = Math.floor((total % 3600) / 60); + if (minutes) parts.push(`${minutes}m`); + const secondsPart = Math.floor(total % 60); + if (!parts.length && secondsPart) parts.push(`${secondsPart}s`); + return parts.join(' '); +} + export default function DeviceList({ onSelectDevice }) { const [rows, setRows] = useState([]); const [orderBy, setOrderBy] = useState("status"); @@ -89,6 +104,17 @@ export default function DeviceList({ onSelectDevice }) { lastReboot: "Last Reboot", created: "Created", lastSeen: "Last Seen", + agentId: "Agent ID", + agentHash: "Agent Hash", + agentGuid: "Agent GUID", + domain: "Domain", + uptime: "Uptime", + memory: "Memory", + network: "Network", + software: "Software", + storage: "Storage", + cpu: "CPU", + siteDescription: "Site Description", }), [] ); @@ -114,9 +140,6 @@ export default function DeviceList({ onSelectDevice }) { const [filters, setFilters] = useState({}); const [filterAnchor, setFilterAnchor] = useState(null); // { id, anchorEl } - // Cache device details to avoid re-fetching every refresh - const [detailsByHost, setDetailsByHost] = useState({}); // hostname -> cached fields - const [siteMapping, setSiteMapping] = useState({}); // hostname -> { site_id, site_name } const [sites, setSites] = useState([]); // sites list for assignment const [assignDialogOpen, setAssignDialogOpen] = useState(false); const [assignSiteId, setAssignSiteId] = useState(null); @@ -152,195 +175,182 @@ export default function DeviceList({ onSelectDevice }) { return agentHash === repo ? "Up-to-Date" : "Needs Updated"; }, []); - const fetchAgents = useCallback(async (options = {}) => { + const fetchDevices = useCallback(async (options = {}) => { const { refreshRepo = false } = options || {}; let repoSha = repoHash; if (refreshRepo || !repoSha) { const fetched = await fetchLatestRepoHash(); if (fetched) repoSha = fetched; } + + const hashById = new Map(); + const hashByGuid = new Map(); + const hashByHost = new Map(); try { - const hashById = new Map(); - const hashByGuid = new Map(); - const hashByHost = new Map(); - try { - const hashResp = await fetch('/api/agent/hash_list'); - if (hashResp.ok) { - const hashJson = await hashResp.json(); - const list = Array.isArray(hashJson?.agents) ? hashJson.agents : []; - list.forEach((rec) => { - if (!rec || typeof rec !== 'object') return; - const hash = (rec.agent_hash || '').trim(); - if (!hash) return; - const agentId = (rec.agent_id || '').trim(); - const guidRaw = (rec.agent_guid || '').trim().toLowerCase(); - const hostKey = (rec.hostname || '').trim().toLowerCase(); - const isMemory = (rec.source || '').trim() === 'memory'; - if (agentId && (!hashById.has(agentId) || isMemory)) { - hashById.set(agentId, hash); - } - if (guidRaw && (!hashByGuid.has(guidRaw) || isMemory)) { - hashByGuid.set(guidRaw, hash); - } - if (hostKey && (!hashByHost.has(hostKey) || isMemory)) { - hashByHost.set(hostKey, hash); - } - }); - } else { + const hashResp = await fetch('/api/agent/hash_list'); + if (hashResp.ok) { + const hashJson = await hashResp.json(); + const list = Array.isArray(hashJson?.agents) ? hashJson.agents : []; + list.forEach((rec) => { + if (!rec || typeof rec !== 'object') return; + const hash = (rec.agent_hash || '').trim(); + if (!hash) return; + const agentId = (rec.agent_id || '').trim(); + const guidRaw = (rec.agent_guid || '').trim().toLowerCase(); + const hostKey = (rec.hostname || '').trim().toLowerCase(); + const isMemory = (rec.source || '').trim() === 'memory'; + if (agentId && (!hashById.has(agentId) || isMemory)) { + hashById.set(agentId, hash); + } + if (guidRaw && (!hashByGuid.has(guidRaw) || isMemory)) { + hashByGuid.set(guidRaw, hash); + } + if (hostKey && (!hashByHost.has(hostKey) || isMemory)) { + hashByHost.set(hostKey, hash); + } + }); + } + } catch (err) { + console.warn('Failed to fetch agent hash list', err); + } + + try { + const res = await fetch('/api/devices'); + if (!res.ok) { + const err = new Error(`Failed to fetch devices (${res.status})`); + try { + err.response = await res.json(); + } catch {} + throw err; + } + const payload = await res.json(); + const list = Array.isArray(payload?.devices) ? payload.devices : []; + + const normalizeJson = (value) => { + if (!value) return ''; + try { + return JSON.stringify(value); + } catch { + return ''; + } + }; + + const normalized = list.map((device, index) => { + const summary = device && typeof device.summary === 'object' ? { ...device.summary } : {}; + const rawHostname = (device.hostname || summary.hostname || '').trim(); + const hostname = rawHostname || `device-${index + 1}`; + const agentId = (device.agent_id || summary.agent_id || '').trim(); + const id = agentId || hostname || `device-${index + 1}`; + const guidRaw = (device.agent_guid || summary.agent_guid || '').trim(); + const guidLookupKey = guidRaw.toLowerCase(); + let agentHash = (device.agent_hash || summary.agent_hash || '').trim(); + if (agentId && hashById.has(agentId)) agentHash = hashById.get(agentId) || agentHash; + if (!agentHash && guidLookupKey && hashByGuid.has(guidLookupKey)) { + agentHash = hashByGuid.get(guidLookupKey) || agentHash; + } + const hostKey = hostname.trim().toLowerCase(); + if (!agentHash && hostKey && hashByHost.has(hostKey)) { + agentHash = hashByHost.get(hostKey) || agentHash; + } + const lastSeen = Number(device.last_seen || summary.last_seen || 0) || 0; + const status = device.status || statusFromHeartbeat(lastSeen); + + if (guidRaw && !summary.agent_guid) { + summary.agent_guid = guidRaw; + } + + let createdTs = Number(device.created_at || 0) || 0; + let createdDisplay = summary.created || ''; + if (!createdTs && createdDisplay) { + const parsed = Date.parse(createdDisplay.replace(' ', 'T')); + if (!Number.isNaN(parsed)) createdTs = Math.floor(parsed / 1000); + } + if (!createdDisplay && device.created_at_iso) { try { - const errPayload = await hashResp.json(); - if (errPayload?.error) { - console.warn('Failed to fetch agent hash list', errPayload.error); - } + createdDisplay = new Date(device.created_at_iso).toLocaleString(); } catch {} } - } catch (err) { - console.warn('Failed to fetch agent hash list', err); - } - const res = await fetch("/api/agents"); - const data = await res.json(); - const arr = Object.entries(data || {}).map(([id, a]) => { - const agentId = (id || '').trim(); - const hostname = a.hostname || agentId || "unknown"; - const normalizedHostKey = (hostname || '').trim().toLowerCase(); - const details = detailsByHost[hostname] || {}; - const rawGuid = (a.agent_guid || a.agentGuid || a.guid || '').trim(); - const detailGuid = (details.agentGuid || details.agent_guid || '').trim(); - const guidCandidates = [rawGuid, detailGuid].filter((g) => g && g.trim()); - let agentHash = ''; - if (agentId && hashById.has(agentId)) { - agentHash = hashById.get(agentId) || ''; - } - if (!agentHash) { - for (const candidate of guidCandidates) { - const key = (candidate || '').trim().toLowerCase(); - if (key && hashByGuid.has(key)) { - agentHash = hashByGuid.get(key) || ''; - break; - } - } - } - if (!agentHash && normalizedHostKey && hashByHost.has(normalizedHostKey)) { - agentHash = hashByHost.get(normalizedHostKey) || ''; - } - if (!agentHash) { - agentHash = (a.agent_hash || details.agentHash || '').trim(); - } - const agentGuidValue = rawGuid || detailGuid || ''; + const osName = + device.operating_system || + summary.operating_system || + summary.agent_operating_system || + "-"; + const type = (device.device_type || summary.device_type || '').trim(); + const lastUser = (device.last_user || summary.last_user || '').trim(); + const domain = (device.domain || summary.domain || '').trim(); + const internalIp = (device.internal_ip || summary.internal_ip || '').trim(); + const externalIp = (device.external_ip || summary.external_ip || '').trim(); + const lastReboot = (device.last_reboot || summary.last_reboot || '').trim(); + const uptimeSeconds = Number( + device.uptime || + summary.uptime_sec || + summary.uptime_seconds || + summary.uptime || + 0 + ) || 0; + + const memoryList = Array.isArray(device.memory) ? device.memory : []; + const networkList = Array.isArray(device.network) ? device.network : []; + const softwareList = Array.isArray(device.software) ? device.software : []; + const storageList = Array.isArray(device.storage) ? device.storage : []; + const cpuObj = + (device.cpu && typeof device.cpu === 'object' && device.cpu) || + (summary.cpu && typeof summary.cpu === 'object' ? summary.cpu : {}); + + const memoryDisplay = memoryList.length ? `${memoryList.length} module(s)` : ''; + const networkDisplay = networkList.length ? networkList.map((n) => n.adapter || n.name || '').filter(Boolean).join(', ') : ''; + const softwareDisplay = softwareList.length ? `${softwareList.length} item(s)` : ''; + const storageDisplay = storageList.length ? `${storageList.length} volume(s)` : ''; + const cpuDisplay = cpuObj.name || summary.processor || ''; + return { id, hostname, - status: statusFromHeartbeat(a.last_seen), - lastSeen: a.last_seen || 0, - os: a.agent_operating_system || a.os || "-", - // Enriched fields from details cache - lastUser: details.lastUser || "", - type: a.device_type || details.type || "", - created: details.created || "", - createdTs: details.createdTs || 0, - internalIp: details.internalIp || "", - externalIp: details.externalIp || "", - lastReboot: details.lastReboot || "", - description: details.description || "", - agentGuid: agentGuidValue, + status, + lastSeen, + lastSeenDisplay: formatLastSeen(lastSeen), + os: osName, + lastUser, + type, + site: device.site_name || 'Not Configured', + siteId: device.site_id || null, + siteDescription: device.site_description || '', + description: (device.description || summary.description || '').trim(), + created: createdDisplay, + createdTs, + createdIso: device.created_at_iso || '', + agentGuid: guidRaw, agentHash, agentVersion: computeAgentVersion(agentHash, repoSha), + agentId, + domain, + internalIp, + externalIp, + lastReboot, + uptime: uptimeSeconds, + uptimeDisplay: formatUptime(uptimeSeconds), + memory: memoryDisplay, + memoryRaw: normalizeJson(memoryList), + network: networkDisplay, + networkRaw: normalizeJson(networkList), + software: softwareDisplay, + softwareRaw: normalizeJson(softwareList), + storage: storageDisplay, + storageRaw: normalizeJson(storageList), + cpu: cpuDisplay, + cpuRaw: normalizeJson(cpuObj), + summary, + details: device.details || {}, }; }); - setRows(arr); - // Fetch site mapping for these hostnames - try { - const hostCsv = arr.map((r) => r.hostname).filter(Boolean).map(encodeURIComponent).join(','); - const resp = await fetch(`/api/sites/device_map?hostnames=${hostCsv}`); - const mapData = await resp.json(); - const mapping = mapData?.mapping || {}; - setSiteMapping(mapping); - setRows((prev) => prev.map((r) => ({ ...r, site: mapping[r.hostname]?.site_name || "Not Configured" }))); - } catch {} - - // Fetch missing details (last_user, created) for hosts not cached yet - const hostsToFetch = arr - .map((r) => r.hostname) - .filter((h) => h && !detailsByHost[h]); - if (hostsToFetch.length) { - // Limit concurrency a bit - const chunks = []; - const size = 6; - for (let i = 0; i < hostsToFetch.length; i += size) { - chunks.push(hostsToFetch.slice(i, i + size)); - } - for (const chunk of chunks) { - await Promise.all( - chunk.map(async (h) => { - try { - const resp = await fetch(`/api/device/details/${encodeURIComponent(h)}`); - const det = await resp.json(); - const summary = det?.summary || {}; - const lastUser = summary.last_user || ""; - const createdRaw = summary.created || ""; - // Try to parse created to epoch seconds for sorting; fallback to 0 - let createdTs = 0; - if (createdRaw) { - const parsed = Date.parse(createdRaw.replace(" ", "T")); - createdTs = isNaN(parsed) ? 0 : Math.floor(parsed / 1000); - } - const deviceType = (summary.device_type || "").trim(); - const internalIp = summary.internal_ip || ""; - const externalIp = summary.external_ip || ""; - const lastReboot = summary.last_reboot || ""; - const description = summary.description || ""; - const agentHashValue = (summary.agent_hash || "").trim(); - const agentGuidValue = (summary.agent_guid || summary.agentGuid || "").trim(); - const enriched = { - lastUser, - created: createdRaw, - createdTs, - type: deviceType, - internalIp, - externalIp, - lastReboot, - description, - agentHash: agentHashValue, - agentGuid: agentGuidValue, - }; - setDetailsByHost((prev) => ({ - ...prev, - [h]: enriched, - })); - setRows((prev) => - prev.map((r) => { - if (r.hostname !== h) return r; - const nextHash = agentHashValue || r.agentHash; - return { - ...r, - lastUser: enriched.lastUser || r.lastUser, - type: enriched.type || r.type, - created: enriched.created || r.created, - createdTs: enriched.createdTs || r.createdTs, - internalIp: enriched.internalIp || r.internalIp, - externalIp: enriched.externalIp || r.externalIp, - lastReboot: enriched.lastReboot || r.lastReboot, - description: enriched.description || r.description, - agentGuid: agentGuidValue || r.agentGuid || "", - agentHash: nextHash, - agentVersion: computeAgentVersion(nextHash, repoSha), - }; - }) - ); - } catch { - // ignore per-host failure - } - }) - ); - } - } + setRows(normalized); } catch (e) { - console.warn("Failed to load agents:", e); + console.warn('Failed to load devices:', e); setRows([]); } - }, [detailsByHost, repoHash, fetchLatestRepoHash, computeAgentVersion]); + }, [repoHash, fetchLatestRepoHash, computeAgentVersion]); const fetchViews = useCallback(async () => { try { @@ -355,8 +365,8 @@ export default function DeviceList({ onSelectDevice }) { useEffect(() => { // Initial load only; removed auto-refresh interval - fetchAgents({ refreshRepo: true }); - }, [fetchAgents]); + fetchDevices({ refreshRepo: true }); + }, [fetchDevices]); useEffect(() => { fetchViews(); @@ -471,6 +481,28 @@ export default function DeviceList({ onSelectDevice }) { return formatCreated(row.created, row.createdTs); case "lastSeen": return formatLastSeen(row.lastSeen); + case "agentId": + return row.agentId || ""; + case "agentHash": + return row.agentHash || ""; + case "agentGuid": + return row.agentGuid || ""; + case "domain": + return row.domain || ""; + case "uptime": + return row.uptimeDisplay || (row.uptime ? String(row.uptime) : ""); + case "memory": + return row.memoryRaw || row.memory || ""; + case "network": + return row.networkRaw || row.network || ""; + case "software": + return row.softwareRaw || row.software || ""; + case "storage": + return row.storageRaw || row.storage || ""; + case "cpu": + return row.cpuRaw || row.cpu || ""; + case "siteDescription": + return row.siteDescription || ""; default: return ""; } @@ -485,9 +517,10 @@ export default function DeviceList({ onSelectDevice }) { const sorted = useMemo(() => { const dir = order === "asc" ? 1 : -1; return [...filtered].sort((a, b) => { - // Support numeric sort for created/lastSeen + // Support numeric sort for created/lastSeen/uptime if (orderBy === "lastSeen") return ((a.lastSeen || 0) - (b.lastSeen || 0)) * dir; if (orderBy === "created") return ((a.createdTs || 0) - (b.createdTs || 0)) * dir; + if (orderBy === "uptime") return ((a.uptime || 0) - (b.uptime || 0)) * dir; const A = a[orderBy]; const B = b[orderBy]; return String(A || "").localeCompare(String(B || "")) * dir; @@ -682,7 +715,7 @@ export default function DeviceList({ onSelectDevice }) { fetchAgents({ refreshRepo: true })} + onClick={() => fetchDevices({ refreshRepo: true })} sx={{ color: "#bbb", mr: 1 }} > @@ -830,8 +863,30 @@ export default function DeviceList({ onSelectDevice }) { return ( {formatLastSeen(r.lastSeen)} ); + case "agentId": + return {r.agentId || ""}; + case "agentHash": + return {r.agentHash || ""}; + case "agentGuid": + return {r.agentGuid || ""}; + case "domain": + return {r.domain || ""}; + case "uptime": + return {r.uptimeDisplay || ''}; + case "memory": + return {r.memory || ""}; + case "network": + return {r.network || ""}; + case "software": + return {r.software || ""}; + case "storage": + return {r.storage || ""}; + case "cpu": + return {r.cpu || ""}; + case "siteDescription": + return {r.siteDescription || ""}; default: - return ; + return {String(r[col.id] || "")}; } })} @@ -952,44 +1007,30 @@ export default function DeviceList({ onSelectDevice }) { PaperProps={{ sx: { bgcolor: "#1e1e1e", color: '#fff', p: 1 } }} > - {[ - { id: 'agentVersion', label: 'Agent Version' }, - { id: 'site', label: 'Site' }, - { id: 'hostname', label: 'Hostname' }, - { id: 'os', label: 'Operating System' }, - { id: 'type', label: 'Device Type' }, - { id: 'lastUser', label: 'Last User' }, - { id: 'internalIp', label: 'Internal IP' }, - { id: 'externalIp', label: 'External IP' }, - { id: 'lastReboot', label: 'Last Reboot' }, - { id: 'created', label: 'Created' }, - { id: 'lastSeen', label: 'Last Seen' }, - { id: 'description', label: 'Description' }, - ].map((opt) => ( - e.stopPropagation()} sx={{ gap: 1 }}> - c.id === opt.id)} - onChange={(e) => { - const checked = e.target.checked; - setColumns((prev) => { - // Keep 'status' always present; manage others per toggle - const exists = prev.some((c) => c.id === opt.id); - if (checked) { - if (exists) return prev; - // Append new column at the end with canonical label - const label = COL_LABELS[opt.id] || opt.label || opt.id; - return [...prev, { id: opt.id, label }]; - } - // Remove column - return prev.filter((c) => c.id !== opt.id); - }); - }} - sx={{ p: 0.3, color: '#bbb' }} - /> - {opt.label} - - ))} + {Object.entries(COL_LABELS) + .filter(([id]) => id !== 'status') + .map(([id, label]) => ( + e.stopPropagation()} sx={{ gap: 1 }}> + c.id === id)} + onChange={(e) => { + const checked = e.target.checked; + setColumns((prev) => { + const exists = prev.some((c) => c.id === id); + if (checked) { + if (exists) return prev; + const nextLabel = COL_LABELS[id] || label || id; + return [...prev, { id, label: nextLabel }]; + } + return prev.filter((c) => c.id !== id); + }); + }} + sx={{ p: 0.3, color: '#bbb' }} + /> + {label || id} + + ))}