Refactor device inventory storage and API

This commit is contained in:
2025-10-08 20:27:26 -06:00
parent 12428d863a
commit d7c991548d
3 changed files with 923 additions and 335 deletions

View File

@@ -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);
}

View File

@@ -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 }) {
<Tooltip title="Refresh Devices to Detect Changes">
<IconButton
size="small"
onClick={() => fetchAgents({ refreshRepo: true })}
onClick={() => fetchDevices({ refreshRepo: true })}
sx={{ color: "#bbb", mr: 1 }}
>
<CachedIcon fontSize="small" />
@@ -830,8 +863,30 @@ export default function DeviceList({ onSelectDevice }) {
return (
<TableCell key={col.id}>{formatLastSeen(r.lastSeen)}</TableCell>
);
case "agentId":
return <TableCell key={col.id}>{r.agentId || ""}</TableCell>;
case "agentHash":
return <TableCell key={col.id}>{r.agentHash || ""}</TableCell>;
case "agentGuid":
return <TableCell key={col.id}>{r.agentGuid || ""}</TableCell>;
case "domain":
return <TableCell key={col.id}>{r.domain || ""}</TableCell>;
case "uptime":
return <TableCell key={col.id}>{r.uptimeDisplay || ''}</TableCell>;
case "memory":
return <TableCell key={col.id}>{r.memory || ""}</TableCell>;
case "network":
return <TableCell key={col.id}>{r.network || ""}</TableCell>;
case "software":
return <TableCell key={col.id}>{r.software || ""}</TableCell>;
case "storage":
return <TableCell key={col.id}>{r.storage || ""}</TableCell>;
case "cpu":
return <TableCell key={col.id}>{r.cpu || ""}</TableCell>;
case "siteDescription":
return <TableCell key={col.id}>{r.siteDescription || ""}</TableCell>;
default:
return <TableCell key={col.id} />;
return <TableCell key={col.id}>{String(r[col.id] || "")}</TableCell>;
}
})}
<TableCell align="right">
@@ -952,44 +1007,30 @@ export default function DeviceList({ onSelectDevice }) {
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: '#fff', p: 1 } }}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, 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) => (
<MenuItem key={opt.id} disableRipple onClick={(e) => e.stopPropagation()} sx={{ gap: 1 }}>
<Checkbox
size="small"
checked={columns.some((c) => 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' }}
/>
<Typography variant="body2" sx={{ color: '#ddd' }}>{opt.label}</Typography>
</MenuItem>
))}
{Object.entries(COL_LABELS)
.filter(([id]) => id !== 'status')
.map(([id, label]) => (
<MenuItem key={id} disableRipple onClick={(e) => e.stopPropagation()} sx={{ gap: 1 }}>
<Checkbox
size="small"
checked={columns.some((c) => 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' }}
/>
<Typography variant="body2" sx={{ color: '#ddd' }}>{label || id}</Typography>
</MenuItem>
))}
<Box sx={{ display: 'flex', gap: 1, pt: 0.5 }}>
<Button
size="small"