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] }); setAgent({ id: device.id, ...agentsData[device.id] });
} }
const detailData = await detailsRes.json(); const detailData = await detailsRes.json();
setDetails(detailData || {}); const summary = detailData?.summary && typeof detailData.summary === 'object'
setDescription(detailData?.summary?.description || ""); ? 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) { } catch (e) {
console.warn("Failed to load device info", 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"; 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 }) { export default function DeviceList({ onSelectDevice }) {
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
const [orderBy, setOrderBy] = useState("status"); const [orderBy, setOrderBy] = useState("status");
@@ -89,6 +104,17 @@ export default function DeviceList({ onSelectDevice }) {
lastReboot: "Last Reboot", lastReboot: "Last Reboot",
created: "Created", created: "Created",
lastSeen: "Last Seen", 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 [filters, setFilters] = useState({});
const [filterAnchor, setFilterAnchor] = useState(null); // { id, anchorEl } 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 [sites, setSites] = useState([]); // sites list for assignment
const [assignDialogOpen, setAssignDialogOpen] = useState(false); const [assignDialogOpen, setAssignDialogOpen] = useState(false);
const [assignSiteId, setAssignSiteId] = useState(null); const [assignSiteId, setAssignSiteId] = useState(null);
@@ -152,195 +175,182 @@ export default function DeviceList({ onSelectDevice }) {
return agentHash === repo ? "Up-to-Date" : "Needs Updated"; return agentHash === repo ? "Up-to-Date" : "Needs Updated";
}, []); }, []);
const fetchAgents = useCallback(async (options = {}) => { const fetchDevices = useCallback(async (options = {}) => {
const { refreshRepo = false } = options || {}; const { refreshRepo = false } = options || {};
let repoSha = repoHash; let repoSha = repoHash;
if (refreshRepo || !repoSha) { if (refreshRepo || !repoSha) {
const fetched = await fetchLatestRepoHash(); const fetched = await fetchLatestRepoHash();
if (fetched) repoSha = fetched; if (fetched) repoSha = fetched;
} }
const hashById = new Map();
const hashByGuid = new Map();
const hashByHost = new Map();
try { try {
const hashById = new Map(); const hashResp = await fetch('/api/agent/hash_list');
const hashByGuid = new Map(); if (hashResp.ok) {
const hashByHost = new Map(); const hashJson = await hashResp.json();
try { const list = Array.isArray(hashJson?.agents) ? hashJson.agents : [];
const hashResp = await fetch('/api/agent/hash_list'); list.forEach((rec) => {
if (hashResp.ok) { if (!rec || typeof rec !== 'object') return;
const hashJson = await hashResp.json(); const hash = (rec.agent_hash || '').trim();
const list = Array.isArray(hashJson?.agents) ? hashJson.agents : []; if (!hash) return;
list.forEach((rec) => { const agentId = (rec.agent_id || '').trim();
if (!rec || typeof rec !== 'object') return; const guidRaw = (rec.agent_guid || '').trim().toLowerCase();
const hash = (rec.agent_hash || '').trim(); const hostKey = (rec.hostname || '').trim().toLowerCase();
if (!hash) return; const isMemory = (rec.source || '').trim() === 'memory';
const agentId = (rec.agent_id || '').trim(); if (agentId && (!hashById.has(agentId) || isMemory)) {
const guidRaw = (rec.agent_guid || '').trim().toLowerCase(); hashById.set(agentId, hash);
const hostKey = (rec.hostname || '').trim().toLowerCase(); }
const isMemory = (rec.source || '').trim() === 'memory'; if (guidRaw && (!hashByGuid.has(guidRaw) || isMemory)) {
if (agentId && (!hashById.has(agentId) || isMemory)) { hashByGuid.set(guidRaw, hash);
hashById.set(agentId, hash); }
} if (hostKey && (!hashByHost.has(hostKey) || isMemory)) {
if (guidRaw && (!hashByGuid.has(guidRaw) || isMemory)) { hashByHost.set(hostKey, hash);
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);
}); }
} else {
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 { try {
const errPayload = await hashResp.json(); createdDisplay = new Date(device.created_at_iso).toLocaleString();
if (errPayload?.error) {
console.warn('Failed to fetch agent hash list', errPayload.error);
}
} catch {} } catch {}
} }
} catch (err) {
console.warn('Failed to fetch agent hash list', err);
}
const res = await fetch("/api/agents"); const osName =
const data = await res.json(); device.operating_system ||
const arr = Object.entries(data || {}).map(([id, a]) => { summary.operating_system ||
const agentId = (id || '').trim(); summary.agent_operating_system ||
const hostname = a.hostname || agentId || "unknown"; "-";
const normalizedHostKey = (hostname || '').trim().toLowerCase(); const type = (device.device_type || summary.device_type || '').trim();
const details = detailsByHost[hostname] || {}; const lastUser = (device.last_user || summary.last_user || '').trim();
const rawGuid = (a.agent_guid || a.agentGuid || a.guid || '').trim(); const domain = (device.domain || summary.domain || '').trim();
const detailGuid = (details.agentGuid || details.agent_guid || '').trim(); const internalIp = (device.internal_ip || summary.internal_ip || '').trim();
const guidCandidates = [rawGuid, detailGuid].filter((g) => g && g.trim()); const externalIp = (device.external_ip || summary.external_ip || '').trim();
let agentHash = ''; const lastReboot = (device.last_reboot || summary.last_reboot || '').trim();
if (agentId && hashById.has(agentId)) { const uptimeSeconds = Number(
agentHash = hashById.get(agentId) || ''; device.uptime ||
} summary.uptime_sec ||
if (!agentHash) { summary.uptime_seconds ||
for (const candidate of guidCandidates) { summary.uptime ||
const key = (candidate || '').trim().toLowerCase(); 0
if (key && hashByGuid.has(key)) { ) || 0;
agentHash = hashByGuid.get(key) || '';
break; 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 : [];
if (!agentHash && normalizedHostKey && hashByHost.has(normalizedHostKey)) { const cpuObj =
agentHash = hashByHost.get(normalizedHostKey) || ''; (device.cpu && typeof device.cpu === 'object' && device.cpu) ||
} (summary.cpu && typeof summary.cpu === 'object' ? summary.cpu : {});
if (!agentHash) {
agentHash = (a.agent_hash || details.agentHash || '').trim(); const memoryDisplay = memoryList.length ? `${memoryList.length} module(s)` : '';
} const networkDisplay = networkList.length ? networkList.map((n) => n.adapter || n.name || '').filter(Boolean).join(', ') : '';
const agentGuidValue = rawGuid || detailGuid || ''; const softwareDisplay = softwareList.length ? `${softwareList.length} item(s)` : '';
const storageDisplay = storageList.length ? `${storageList.length} volume(s)` : '';
const cpuDisplay = cpuObj.name || summary.processor || '';
return { return {
id, id,
hostname, hostname,
status: statusFromHeartbeat(a.last_seen), status,
lastSeen: a.last_seen || 0, lastSeen,
os: a.agent_operating_system || a.os || "-", lastSeenDisplay: formatLastSeen(lastSeen),
// Enriched fields from details cache os: osName,
lastUser: details.lastUser || "", lastUser,
type: a.device_type || details.type || "", type,
created: details.created || "", site: device.site_name || 'Not Configured',
createdTs: details.createdTs || 0, siteId: device.site_id || null,
internalIp: details.internalIp || "", siteDescription: device.site_description || '',
externalIp: details.externalIp || "", description: (device.description || summary.description || '').trim(),
lastReboot: details.lastReboot || "", created: createdDisplay,
description: details.description || "", createdTs,
agentGuid: agentGuidValue, createdIso: device.created_at_iso || '',
agentGuid: guidRaw,
agentHash, agentHash,
agentVersion: computeAgentVersion(agentHash, repoSha), 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 setRows(normalized);
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
}
})
);
}
}
} catch (e) { } catch (e) {
console.warn("Failed to load agents:", e); console.warn('Failed to load devices:', e);
setRows([]); setRows([]);
} }
}, [detailsByHost, repoHash, fetchLatestRepoHash, computeAgentVersion]); }, [repoHash, fetchLatestRepoHash, computeAgentVersion]);
const fetchViews = useCallback(async () => { const fetchViews = useCallback(async () => {
try { try {
@@ -355,8 +365,8 @@ export default function DeviceList({ onSelectDevice }) {
useEffect(() => { useEffect(() => {
// Initial load only; removed auto-refresh interval // Initial load only; removed auto-refresh interval
fetchAgents({ refreshRepo: true }); fetchDevices({ refreshRepo: true });
}, [fetchAgents]); }, [fetchDevices]);
useEffect(() => { useEffect(() => {
fetchViews(); fetchViews();
@@ -471,6 +481,28 @@ export default function DeviceList({ onSelectDevice }) {
return formatCreated(row.created, row.createdTs); return formatCreated(row.created, row.createdTs);
case "lastSeen": case "lastSeen":
return formatLastSeen(row.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: default:
return ""; return "";
} }
@@ -485,9 +517,10 @@ export default function DeviceList({ onSelectDevice }) {
const sorted = useMemo(() => { const sorted = useMemo(() => {
const dir = order === "asc" ? 1 : -1; const dir = order === "asc" ? 1 : -1;
return [...filtered].sort((a, b) => { 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 === "lastSeen") return ((a.lastSeen || 0) - (b.lastSeen || 0)) * dir;
if (orderBy === "created") return ((a.createdTs || 0) - (b.createdTs || 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 A = a[orderBy];
const B = b[orderBy]; const B = b[orderBy];
return String(A || "").localeCompare(String(B || "")) * dir; return String(A || "").localeCompare(String(B || "")) * dir;
@@ -682,7 +715,7 @@ export default function DeviceList({ onSelectDevice }) {
<Tooltip title="Refresh Devices to Detect Changes"> <Tooltip title="Refresh Devices to Detect Changes">
<IconButton <IconButton
size="small" size="small"
onClick={() => fetchAgents({ refreshRepo: true })} onClick={() => fetchDevices({ refreshRepo: true })}
sx={{ color: "#bbb", mr: 1 }} sx={{ color: "#bbb", mr: 1 }}
> >
<CachedIcon fontSize="small" /> <CachedIcon fontSize="small" />
@@ -830,8 +863,30 @@ export default function DeviceList({ onSelectDevice }) {
return ( return (
<TableCell key={col.id}>{formatLastSeen(r.lastSeen)}</TableCell> <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: default:
return <TableCell key={col.id} />; return <TableCell key={col.id}>{String(r[col.id] || "")}</TableCell>;
} }
})} })}
<TableCell align="right"> <TableCell align="right">
@@ -952,44 +1007,30 @@ export default function DeviceList({ onSelectDevice }) {
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: '#fff', p: 1 } }} PaperProps={{ sx: { bgcolor: "#1e1e1e", color: '#fff', p: 1 } }}
> >
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, p: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, p: 1 }}>
{[ {Object.entries(COL_LABELS)
{ id: 'agentVersion', label: 'Agent Version' }, .filter(([id]) => id !== 'status')
{ id: 'site', label: 'Site' }, .map(([id, label]) => (
{ id: 'hostname', label: 'Hostname' }, <MenuItem key={id} disableRipple onClick={(e) => e.stopPropagation()} sx={{ gap: 1 }}>
{ id: 'os', label: 'Operating System' }, <Checkbox
{ id: 'type', label: 'Device Type' }, size="small"
{ id: 'lastUser', label: 'Last User' }, checked={columns.some((c) => c.id === id)}
{ id: 'internalIp', label: 'Internal IP' }, onChange={(e) => {
{ id: 'externalIp', label: 'External IP' }, const checked = e.target.checked;
{ id: 'lastReboot', label: 'Last Reboot' }, setColumns((prev) => {
{ id: 'created', label: 'Created' }, const exists = prev.some((c) => c.id === id);
{ id: 'lastSeen', label: 'Last Seen' }, if (checked) {
{ id: 'description', label: 'Description' }, if (exists) return prev;
].map((opt) => ( const nextLabel = COL_LABELS[id] || label || id;
<MenuItem key={opt.id} disableRipple onClick={(e) => e.stopPropagation()} sx={{ gap: 1 }}> return [...prev, { id, label: nextLabel }];
<Checkbox }
size="small" return prev.filter((c) => c.id !== id);
checked={columns.some((c) => c.id === opt.id)} });
onChange={(e) => { }}
const checked = e.target.checked; sx={{ p: 0.3, color: '#bbb' }}
setColumns((prev) => { />
// Keep 'status' always present; manage others per toggle <Typography variant="body2" sx={{ color: '#ddd' }}>{label || id}</Typography>
const exists = prev.some((c) => c.id === opt.id); </MenuItem>
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>
))}
<Box sx={{ display: 'flex', gap: 1, pt: 0.5 }}> <Box sx={{ display: 'flex', gap: 1, pt: 0.5 }}>
<Button <Button
size="small" size="small"

View File

@@ -463,7 +463,7 @@ def _lookup_agent_hash_record(agent_id: str) -> Optional[Dict[str, Any]]:
if row_guid: if row_guid:
payload['agent_guid'] = row_guid payload['agent_guid'] = row_guid
return payload return payload
cur.execute('SELECT hostname, agent_hash, details, guid FROM device_details') cur.execute(f'SELECT hostname, agent_hash, details, guid FROM {DEVICE_TABLE}')
for host, db_hash, details_json, row_guid in cur.fetchall(): for host, db_hash, details_json, row_guid in cur.fetchall():
try: try:
data = json.loads(details_json or '{}') data = json.loads(details_json or '{}')
@@ -528,7 +528,7 @@ def _lookup_agent_hash_by_guid(agent_guid: str) -> Optional[Dict[str, Any]]:
conn = _db_conn() conn = _db_conn()
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
'SELECT hostname, agent_hash, details FROM device_details WHERE guid = ?', f'SELECT hostname, agent_hash, details FROM {DEVICE_TABLE} WHERE guid = ?',
(normalized_guid,), (normalized_guid,),
) )
row = cur.fetchone() row = cur.fetchone()
@@ -638,7 +638,7 @@ def _collect_agent_hash_records() -> List[Dict[str, Any]]:
try: try:
conn = _db_conn() conn = _db_conn()
cur = conn.cursor() cur = conn.cursor()
cur.execute('SELECT hostname, agent_hash, details, guid FROM device_details') cur.execute(f'SELECT hostname, agent_hash, details, guid FROM {DEVICE_TABLE}')
for hostname, stored_hash, details_json, row_guid in cur.fetchall(): for hostname, stored_hash, details_json, row_guid in cur.fetchall():
try: try:
details = json.loads(details_json or '{}') details = json.loads(details_json or '{}')
@@ -719,15 +719,16 @@ def _apply_agent_hash_update(agent_id: str, agent_hash: str, agent_guid: Optiona
updated_via_guid = False updated_via_guid = False
if normalized_guid: if normalized_guid:
cur.execute( cur.execute(
'SELECT hostname, details FROM device_details WHERE guid = ?', f'SELECT hostname, description, details, created_at, agent_hash FROM {DEVICE_TABLE} WHERE guid = ?',
(normalized_guid,), (normalized_guid,),
) )
row = cur.fetchone() row = cur.fetchone()
if row: if row:
updated_via_guid = True updated_via_guid = True
hostname = row[0] hostname = row[0]
description = row[1]
try: try:
details = json.loads(row[1] or '{}') details = json.loads(row[2] or '{}')
except Exception: except Exception:
details = {} details = {}
summary = details.setdefault('summary', {}) summary = details.setdefault('summary', {})
@@ -737,9 +738,16 @@ def _apply_agent_hash_update(agent_id: str, agent_hash: str, agent_guid: Optiona
summary['agent_id'] = resolved_agent_id summary['agent_id'] = resolved_agent_id
summary['agent_hash'] = agent_hash summary['agent_hash'] = agent_hash
summary['agent_guid'] = normalized_guid summary['agent_guid'] = normalized_guid
cur.execute( existing_created_at = row[3] if len(row) > 3 else None
'UPDATE device_details SET agent_hash=?, details=? WHERE hostname=?', existing_hash = row[4] if len(row) > 4 else None
(agent_hash, json.dumps(details), hostname), _device_upsert(
cur,
hostname,
description,
details,
existing_created_at,
agent_hash=agent_hash or existing_hash,
guid=normalized_guid,
) )
conn.commit() conn.commit()
elif not agent_id: elif not agent_id:
@@ -774,8 +782,22 @@ def _apply_agent_hash_update(agent_id: str, agent_hash: str, agent_guid: Optiona
if resolved_agent_id: if resolved_agent_id:
summary['agent_id'] = resolved_agent_id summary['agent_id'] = resolved_agent_id
cur.execute( cur.execute(
'UPDATE device_details SET agent_hash=?, details=? WHERE hostname=?', f'SELECT description, created_at, agent_hash FROM {DEVICE_TABLE} WHERE hostname = ?',
(agent_hash, json.dumps(details), hostname), (hostname,),
)
info_row = cur.fetchone()
description = info_row[0] if info_row else None
existing_created_at = info_row[1] if info_row else None
existing_hash = info_row[2] if info_row else None
effective_guid = normalized_guid or target.get('guid')
_device_upsert(
cur,
hostname,
description,
details,
existing_created_at,
agent_hash=agent_hash or existing_hash,
guid=effective_guid,
) )
conn.commit() conn.commit()
if not normalized_guid: if not normalized_guid:
@@ -1054,6 +1076,7 @@ def api_login():
cur.execute("UPDATE users SET last_login=?, updated_at=? WHERE id=?", (now, now, row[0])) cur.execute("UPDATE users SET last_login=?, updated_at=? WHERE id=?", (now, now, row[0]))
conn.commit() conn.commit()
conn.commit() conn.commit()
conn.commit()
conn.close() conn.close()
# set session cookie # set session cookie
session['username'] = row[1] session['username'] = row[1]
@@ -2427,6 +2450,203 @@ latest_images: Dict[str, Dict] = {}
DB_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "database.db")) DB_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "database.db"))
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
DEVICE_TABLE = "devices"
_DEVICE_JSON_LIST_FIELDS = {
"memory": [],
"network": [],
"software": [],
"storage": [],
}
_DEVICE_JSON_OBJECT_FIELDS = {
"cpu": {},
}
def _serialize_device_json(value: Any, default: Any) -> str:
candidate = value
if candidate is None:
candidate = default
if not isinstance(candidate, (list, dict)):
candidate = default
try:
return json.dumps(candidate)
except Exception:
try:
return json.dumps(default)
except Exception:
return "{}" if isinstance(default, dict) else "[]"
def _clean_device_str(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, (int, float)) and not isinstance(value, bool):
text = str(value)
elif isinstance(value, str):
text = value
else:
try:
text = str(value)
except Exception:
return None
text = text.strip()
return text or None
def _coerce_int(value: Any) -> Optional[int]:
if value is None:
return None
try:
if isinstance(value, str) and value.strip() == "":
return None
return int(float(value))
except (ValueError, TypeError):
return None
def _extract_device_columns(details: Dict[str, Any]) -> Dict[str, Any]:
summary = details.get("summary") or {}
payload = {}
for field, default in _DEVICE_JSON_LIST_FIELDS.items():
payload[field] = _serialize_device_json(details.get(field), default)
payload["cpu"] = _serialize_device_json(summary.get("cpu") or details.get("cpu"), _DEVICE_JSON_OBJECT_FIELDS["cpu"])
payload["device_type"] = _clean_device_str(summary.get("device_type") or summary.get("type"))
payload["domain"] = _clean_device_str(summary.get("domain"))
payload["external_ip"] = _clean_device_str(summary.get("external_ip") or summary.get("public_ip"))
payload["internal_ip"] = _clean_device_str(summary.get("internal_ip") or summary.get("private_ip"))
payload["last_reboot"] = _clean_device_str(summary.get("last_reboot") or summary.get("last_boot"))
payload["last_seen"] = _coerce_int(summary.get("last_seen"))
payload["last_user"] = _clean_device_str(
summary.get("last_user")
or summary.get("last_user_name")
or summary.get("username")
)
payload["operating_system"] = _clean_device_str(
summary.get("operating_system")
or summary.get("agent_operating_system")
or summary.get("os")
)
uptime_value = (
summary.get("uptime_sec")
or summary.get("uptime_seconds")
or summary.get("uptime")
)
payload["uptime"] = _coerce_int(uptime_value)
payload["agent_id"] = _clean_device_str(summary.get("agent_id"))
return payload
def _device_upsert(
cur: sqlite3.Cursor,
hostname: str,
description: Optional[str],
merged_details: Dict[str, Any],
created_at: Optional[int],
*,
agent_hash: Optional[str] = None,
guid: Optional[str] = None,
) -> None:
if not hostname:
return
try:
details_json = json.dumps(merged_details or {})
except Exception:
details_json = json.dumps({})
column_values = _extract_device_columns(merged_details or {})
normalized_description = description if description is not None else ""
try:
normalized_description = str(normalized_description)
except Exception:
normalized_description = ""
normalized_hash = _clean_device_str(agent_hash) or None
normalized_guid = _clean_device_str(guid) or None
if normalized_guid:
try:
normalized_guid = _normalize_guid(normalized_guid)
except Exception:
pass
created_ts = _coerce_int(created_at)
if not created_ts:
created_ts = int(time.time())
sql = f"""
INSERT INTO {DEVICE_TABLE}(
hostname,
description,
details,
created_at,
agent_hash,
guid,
memory,
network,
software,
storage,
cpu,
device_type,
domain,
external_ip,
internal_ip,
last_reboot,
last_seen,
last_user,
operating_system,
uptime,
agent_id
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(hostname) DO UPDATE SET
description=excluded.description,
details=excluded.details,
created_at=COALESCE({DEVICE_TABLE}.created_at, excluded.created_at),
agent_hash=COALESCE(NULLIF(excluded.agent_hash, ''), {DEVICE_TABLE}.agent_hash),
guid=COALESCE(NULLIF(excluded.guid, ''), {DEVICE_TABLE}.guid),
memory=excluded.memory,
network=excluded.network,
software=excluded.software,
storage=excluded.storage,
cpu=excluded.cpu,
device_type=COALESCE(NULLIF(excluded.device_type, ''), {DEVICE_TABLE}.device_type),
domain=COALESCE(NULLIF(excluded.domain, ''), {DEVICE_TABLE}.domain),
external_ip=COALESCE(NULLIF(excluded.external_ip, ''), {DEVICE_TABLE}.external_ip),
internal_ip=COALESCE(NULLIF(excluded.internal_ip, ''), {DEVICE_TABLE}.internal_ip),
last_reboot=COALESCE(NULLIF(excluded.last_reboot, ''), {DEVICE_TABLE}.last_reboot),
last_seen=COALESCE(NULLIF(excluded.last_seen, 0), {DEVICE_TABLE}.last_seen),
last_user=COALESCE(NULLIF(excluded.last_user, ''), {DEVICE_TABLE}.last_user),
operating_system=COALESCE(NULLIF(excluded.operating_system, ''), {DEVICE_TABLE}.operating_system),
uptime=COALESCE(NULLIF(excluded.uptime, 0), {DEVICE_TABLE}.uptime),
agent_id=COALESCE(NULLIF(excluded.agent_id, ''), {DEVICE_TABLE}.agent_id)
"""
params: List[Any] = [
hostname,
normalized_description,
details_json,
created_ts,
normalized_hash,
normalized_guid,
column_values.get("memory"),
column_values.get("network"),
column_values.get("software"),
column_values.get("storage"),
column_values.get("cpu"),
column_values.get("device_type"),
column_values.get("domain"),
column_values.get("external_ip"),
column_values.get("internal_ip"),
column_values.get("last_reboot"),
column_values.get("last_seen"),
column_values.get("last_user"),
column_values.get("operating_system"),
column_values.get("uptime"),
column_values.get("agent_id"),
]
cur.execute(sql, params)
# --- Simple at-rest secret handling for service account passwords --- # --- Simple at-rest secret handling for service account passwords ---
_SERVER_SECRET_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), 'server_secret.key')) _SERVER_SECRET_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), 'server_secret.key'))
@@ -2514,20 +2734,101 @@ def init_db():
conn = _db_conn() conn = _db_conn()
cur = conn.cursor() cur = conn.cursor()
# Device details table # Device table (renamed from historical device_details)
cur.execute( cur.execute(
"CREATE TABLE IF NOT EXISTS device_details (hostname TEXT PRIMARY KEY, description TEXT, details TEXT, created_at INTEGER, agent_hash TEXT, guid TEXT)" "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(DEVICE_TABLE,),
) )
# Backfill missing created_at column on existing installs has_devices = cur.fetchone()
if not has_devices:
cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='device_details'"
)
legacy = cur.fetchone()
if legacy:
cur.execute("ALTER TABLE device_details RENAME TO devices")
else:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS devices (
hostname TEXT PRIMARY KEY,
description TEXT,
details TEXT,
created_at INTEGER,
agent_hash TEXT,
guid TEXT,
memory TEXT,
network TEXT,
software TEXT,
storage TEXT,
cpu TEXT,
device_type TEXT,
domain TEXT,
external_ip TEXT,
internal_ip TEXT,
last_reboot TEXT,
last_seen INTEGER,
last_user TEXT,
operating_system TEXT,
uptime INTEGER,
agent_id TEXT
)
"""
)
# Ensure required columns exist on upgraded installs
cur.execute(f"PRAGMA table_info({DEVICE_TABLE})")
existing_cols = [r[1] for r in cur.fetchall()]
def _ensure_column(name: str, decl: str) -> None:
if name not in existing_cols:
cur.execute(f"ALTER TABLE {DEVICE_TABLE} ADD COLUMN {name} {decl}")
existing_cols.append(name)
_ensure_column("description", "TEXT")
_ensure_column("details", "TEXT")
_ensure_column("created_at", "INTEGER")
_ensure_column("agent_hash", "TEXT")
_ensure_column("guid", "TEXT")
_ensure_column("memory", "TEXT")
_ensure_column("network", "TEXT")
_ensure_column("software", "TEXT")
_ensure_column("storage", "TEXT")
_ensure_column("cpu", "TEXT")
_ensure_column("device_type", "TEXT")
_ensure_column("domain", "TEXT")
_ensure_column("external_ip", "TEXT")
_ensure_column("internal_ip", "TEXT")
_ensure_column("last_reboot", "TEXT")
_ensure_column("last_seen", "INTEGER")
_ensure_column("last_user", "TEXT")
_ensure_column("operating_system", "TEXT")
_ensure_column("uptime", "INTEGER")
_ensure_column("agent_id", "TEXT")
# Backfill expanded columns from stored JSON
try: try:
cur.execute("PRAGMA table_info(device_details)") cur.execute(f"SELECT hostname, details FROM {DEVICE_TABLE}")
cols = [r[1] for r in cur.fetchall()] rows = cur.fetchall()
if 'created_at' not in cols: for hostname, details_json in rows:
cur.execute("ALTER TABLE device_details ADD COLUMN created_at INTEGER") try:
if 'agent_hash' not in cols: details = json.loads(details_json or "{}")
cur.execute("ALTER TABLE device_details ADD COLUMN agent_hash TEXT") except Exception:
if 'guid' not in cols: details = {}
cur.execute("ALTER TABLE device_details ADD COLUMN guid TEXT") column_values = _extract_device_columns(details)
update_fields = []
params: List[Any] = []
for key, value in column_values.items():
if key not in existing_cols:
continue
update_fields.append(f"{key}=?")
params.append(value)
if update_fields:
params.append(hostname)
cur.execute(
f"UPDATE {DEVICE_TABLE} SET {', '.join(update_fields)} WHERE hostname=?",
params,
)
except Exception: except Exception:
pass pass
@@ -2986,7 +3287,9 @@ def _load_device_records(limit: int = 0):
try: try:
conn = _db_conn() conn = _db_conn()
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT hostname, description, details FROM device_details") cur.execute(
f"SELECT hostname, description, last_user, internal_ip, external_ip, details FROM {DEVICE_TABLE}"
)
rows = cur.fetchall() rows = cur.fetchall()
# Build device -> site mapping # Build device -> site mapping
@@ -3005,19 +3308,18 @@ def _load_device_records(limit: int = 0):
site_map = {} site_map = {}
out = [] out = []
for hostname, description, details_json in rows: for hostname, description, last_user, internal_ip, external_ip, details_json in rows:
d = {} summary = {}
try: try:
d = json.loads(details_json or "{}") summary = (json.loads(details_json or "{}") or {}).get("summary") or {}
except Exception: except Exception:
d = {} summary = {}
summary = d.get("summary") or {}
rec = { rec = {
"hostname": hostname or summary.get("hostname") or "", "hostname": hostname or summary.get("hostname") or "",
"description": (description or summary.get("description") or ""), "description": (description or summary.get("description") or ""),
"last_user": summary.get("last_user") or summary.get("last_user_name") or "", "last_user": last_user or summary.get("last_user") or summary.get("last_user_name") or "",
"internal_ip": summary.get("internal_ip") or "", "internal_ip": internal_ip or summary.get("internal_ip") or "",
"external_ip": summary.get("external_ip") or "", "external_ip": external_ip or summary.get("external_ip") or "",
} }
site_info = site_map.get(rec["hostname"]) or {} site_info = site_map.get(rec["hostname"]) or {}
rec.update({ rec.update({
@@ -3120,6 +3422,138 @@ def search_suggest():
}) })
@app.route("/api/devices", methods=["GET"])
def list_devices():
"""Return all devices with expanded columns for the WebUI."""
try:
conn = _db_conn()
cur = conn.cursor()
cur.execute(
f"""
SELECT d.hostname, d.description, d.details, d.created_at, d.agent_hash, d.guid,
d.memory, d.network, d.software, d.storage, d.cpu,
d.device_type, d.domain, d.external_ip, d.internal_ip,
d.last_reboot, d.last_seen, d.last_user, d.operating_system,
d.uptime, d.agent_id,
s.id, s.name, s.description
FROM {DEVICE_TABLE} d
LEFT JOIN device_sites ds ON ds.device_hostname = d.hostname
LEFT JOIN sites s ON s.id = ds.site_id
"""
)
rows = cur.fetchall()
conn.close()
except Exception as exc:
return jsonify({"error": str(exc)}), 500
def _parse_json_list(raw: Optional[str]) -> list:
try:
return json.loads(raw or "[]")
except Exception:
return []
def _parse_json_obj(raw: Optional[str]) -> dict:
try:
return json.loads(raw or "{}")
except Exception:
return {}
def _ts_to_iso(ts: Optional[int]) -> str:
if not ts:
return ""
try:
from datetime import datetime, timezone
return datetime.fromtimestamp(int(ts), timezone.utc).isoformat()
except Exception:
return ""
devices = []
now = time.time()
for row in rows:
(
hostname,
description,
details_json,
created_at,
agent_hash,
guid,
memory_json,
network_json,
software_json,
storage_json,
cpu_json,
device_type,
domain,
external_ip,
internal_ip,
last_reboot,
last_seen,
last_user,
operating_system,
uptime,
agent_id,
site_id,
site_name,
site_description,
) = row
try:
details = json.loads(details_json or "{}")
except Exception:
details = {}
summary = details.get("summary") or {}
if description:
summary.setdefault("description", description)
if agent_hash:
summary.setdefault("agent_hash", agent_hash)
normalized_guid = _normalize_guid(guid) if guid else ""
if normalized_guid:
summary.setdefault("agent_guid", normalized_guid)
status = "Offline"
try:
if last_seen and (now - float(last_seen)) <= 300:
status = "Online"
except Exception:
pass
devices.append(
{
"hostname": hostname or summary.get("hostname") or "",
"description": description or summary.get("description") or "",
"details": details,
"summary": summary,
"created_at": int(created_at or 0),
"created_at_iso": _ts_to_iso(created_at),
"agent_hash": (agent_hash or "").strip() if agent_hash else summary.get("agent_hash", ""),
"agent_guid": normalized_guid,
"memory": _parse_json_list(memory_json),
"network": _parse_json_list(network_json),
"software": _parse_json_list(software_json),
"storage": _parse_json_list(storage_json),
"cpu": _parse_json_obj(cpu_json),
"device_type": (device_type or "").strip() or summary.get("device_type") or "",
"domain": (domain or "").strip(),
"external_ip": (external_ip or "").strip() or summary.get("external_ip") or "",
"internal_ip": (internal_ip or "").strip() or summary.get("internal_ip") or "",
"last_reboot": (last_reboot or "").strip() or summary.get("last_reboot") or "",
"last_seen": int(last_seen or 0),
"last_seen_iso": _ts_to_iso(last_seen),
"last_user": (last_user or "").strip() or summary.get("last_user") or "",
"operating_system": (operating_system or "").strip()
or summary.get("operating_system")
or summary.get("agent_operating_system")
or "",
"uptime": int(uptime or 0),
"agent_id": (agent_id or "").strip() or summary.get("agent_id") or "",
"site_id": site_id,
"site_name": site_name or "",
"site_description": site_description or "",
"status": status,
}
)
return jsonify({"devices": devices})
# --------------------------------------------- # ---------------------------------------------
# Device List Views API # Device List Views API
# --------------------------------------------- # ---------------------------------------------
@@ -3277,7 +3711,7 @@ def delete_device_list_view(view_id: int):
def _persist_last_seen(hostname: str, last_seen: int, agent_id: str = None): def _persist_last_seen(hostname: str, last_seen: int, agent_id: str = None):
"""Persist last_seen (and agent_id if provided) into device_details.details JSON. """Persist last_seen (and agent_id if provided) into the stored device record.
Ensures that after a server restart, we can restore last_seen from DB even Ensures that after a server restart, we can restore last_seen from DB even
if the agent is offline, and helps merge entries by keeping track of the if the agent is offline, and helps merge entries by keeping track of the
@@ -3289,7 +3723,7 @@ def _persist_last_seen(hostname: str, last_seen: int, agent_id: str = None):
conn = _db_conn() conn = _db_conn()
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
"SELECT details, description, created_at, guid FROM device_details WHERE hostname = ?", f"SELECT details, description, created_at, guid, agent_hash FROM {DEVICE_TABLE} WHERE hostname = ?",
(hostname,), (hostname,),
) )
row = cur.fetchone() row = cur.fetchone()
@@ -3304,6 +3738,7 @@ def _persist_last_seen(hostname: str, last_seen: int, agent_id: str = None):
details = {} details = {}
description = "" description = ""
created_at = 0 created_at = 0
existing_hash = row[4] if row and len(row) > 4 else None
summary = details.get("summary") or {} summary = details.get("summary") or {}
summary["hostname"] = summary.get("hostname") or hostname summary["hostname"] = summary.get("hostname") or hostname
@@ -3335,16 +3770,16 @@ def _persist_last_seen(hostname: str, last_seen: int, agent_id: str = None):
pass pass
# Single upsert to avoid unique-constraint races # Single upsert to avoid unique-constraint races
cur.execute( effective_hash = summary.get("agent_hash") or existing_hash
""" effective_guid = summary.get("agent_guid") or existing_guid
INSERT INTO device_details(hostname, description, details, created_at) _device_upsert(
VALUES (?, ?, ?, ?) cur,
ON CONFLICT(hostname) DO UPDATE SET hostname,
description=excluded.description, description,
details=excluded.details, details,
created_at=COALESCE(device_details.created_at, excluded.created_at) target_created_at,
""", agent_hash=effective_hash,
(hostname, description, json.dumps(details), target_created_at), guid=effective_guid,
) )
conn.commit() conn.commit()
conn.close() conn.close()
@@ -3357,9 +3792,11 @@ def load_agents_from_db():
try: try:
conn = _db_conn() conn = _db_conn()
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT hostname, details, agent_hash, guid FROM device_details") cur.execute(
f"SELECT hostname, description, details, created_at, agent_hash, guid FROM {DEVICE_TABLE}"
)
rows = cur.fetchall() rows = cur.fetchall()
for hostname, details_json, agent_hash, guid in rows: for hostname, description, details_json, created_at, agent_hash, guid in rows:
try: try:
details = json.loads(details_json or "{}") details = json.loads(details_json or "{}")
except Exception: except Exception:
@@ -3373,11 +3810,17 @@ def load_agents_from_db():
stored_hash = summary.get("agent_hash") or "" stored_hash = summary.get("agent_hash") or ""
agent_guid = (summary.get("agent_guid") or guid or "").strip() agent_guid = (summary.get("agent_guid") or guid or "").strip()
if guid and not summary.get("agent_guid"): if guid and not summary.get("agent_guid"):
summary["agent_guid"] = _normalize_guid(guid) normalized_guid = _normalize_guid(guid)
summary["agent_guid"] = normalized_guid
try: try:
cur.execute( _device_upsert(
"UPDATE device_details SET details=? WHERE hostname=?", cur,
(json.dumps(details), hostname), hostname,
description,
details,
created_at,
agent_hash=agent_hash or stored_hash,
guid=normalized_guid,
) )
except Exception: except Exception:
pass pass
@@ -3428,7 +3871,7 @@ def _device_rows_for_agent(cur, agent_id: str) -> List[Dict[str, Any]]:
return results return results
try: try:
cur.execute( cur.execute(
"SELECT hostname, agent_hash, details, guid FROM device_details WHERE LOWER(hostname) = ?", f"SELECT hostname, agent_hash, details, guid FROM {DEVICE_TABLE} WHERE LOWER(hostname) = ?",
(base_host.lower(),), (base_host.lower(),),
) )
rows = cur.fetchall() rows = cur.fetchall()
@@ -3470,14 +3913,22 @@ def _ensure_agent_guid_for_hostname(cur, hostname: str, agent_id: Optional[str]
if not normalized_host: if not normalized_host:
return None return None
cur.execute( cur.execute(
"SELECT hostname, guid, details FROM device_details WHERE LOWER(hostname) = ?", f"SELECT hostname, guid, details, description, created_at, agent_hash FROM {DEVICE_TABLE} WHERE LOWER(hostname) = ?",
(normalized_host.lower(),), (normalized_host.lower(),),
) )
row = cur.fetchone() row = cur.fetchone()
if row: if row:
actual_host, existing_guid, details_json = row actual_host = row[0]
existing_guid = row[1] or ""
details_json = row[2] or "{}"
description = row[3] if len(row) > 3 else ""
created_at = row[4] if len(row) > 4 else None
agent_hash = row[5] if len(row) > 5 else None
else: else:
actual_host, existing_guid, details_json = normalized_host, "", "{}" actual_host, existing_guid, details_json = normalized_host, "", "{}"
description = ""
created_at = None
agent_hash = None
try: try:
details = json.loads(details_json or "{}") details = json.loads(details_json or "{}")
except Exception: except Exception:
@@ -3494,26 +3945,28 @@ def _ensure_agent_guid_for_hostname(cur, hostname: str, agent_id: Optional[str]
if existing_guid: if existing_guid:
normalized = _normalize_guid(existing_guid) normalized = _normalize_guid(existing_guid)
summary.setdefault("agent_guid", normalized) summary.setdefault("agent_guid", normalized)
cur.execute( _device_upsert(
"UPDATE device_details SET guid=?, details=? WHERE hostname=?", cur,
(normalized, json.dumps(details), actual_host), actual_host,
description,
details,
created_at,
agent_hash=agent_hash,
guid=normalized,
) )
return summary.get("agent_guid") or normalized return summary.get("agent_guid") or normalized
new_guid = str(uuid.uuid4()) new_guid = str(uuid.uuid4())
summary["agent_guid"] = new_guid summary["agent_guid"] = new_guid
now = int(time.time()) now = int(time.time())
cur.execute( _device_upsert(
""" cur,
INSERT INTO device_details(hostname, description, details, created_at, guid) actual_host,
VALUES (?, COALESCE((SELECT description FROM device_details WHERE hostname = ?), ''), ?, COALESCE((SELECT created_at FROM device_details WHERE hostname = ?), ?), ?) description,
ON CONFLICT(hostname) DO UPDATE SET details,
guid=excluded.guid, created_at or now,
details=excluded.details, agent_hash=agent_hash,
created_at=COALESCE(device_details.created_at, excluded.created_at), guid=new_guid,
description=COALESCE(device_details.description, excluded.description)
""",
(actual_host, actual_host, json.dumps(details), actual_host, now, new_guid),
) )
return new_guid return new_guid
@@ -3531,9 +3984,23 @@ def _ensure_agent_guid(agent_id: str, hostname: Optional[str] = None) -> Optiona
if candidate: if candidate:
summary = row.get("details", {}).setdefault("summary", {}) summary = row.get("details", {}).setdefault("summary", {})
summary.setdefault("agent_guid", candidate) summary.setdefault("agent_guid", candidate)
host = row.get("hostname")
cur.execute( cur.execute(
"UPDATE device_details SET guid=?, details=? WHERE hostname=?", f"SELECT description, created_at, agent_hash FROM {DEVICE_TABLE} WHERE hostname = ?",
(candidate, json.dumps(row.get("details", {})), row.get("hostname")), (host,),
)
info = cur.fetchone()
description = info[0] if info else None
created_at = info[1] if info else None
agent_hash = info[2] if info else None
_device_upsert(
cur,
host,
description,
row.get("details", {}),
created_at,
agent_hash=agent_hash,
guid=candidate,
) )
conn.commit() conn.commit()
return candidate return candidate
@@ -3641,7 +4108,7 @@ def save_agent_details():
cur = conn.cursor() cur = conn.cursor()
# Load existing row to preserve description and created_at and merge fields # Load existing row to preserve description and created_at and merge fields
cur.execute( cur.execute(
"SELECT details, description, created_at, guid FROM device_details WHERE hostname = ?", f"SELECT details, description, created_at, guid, agent_hash FROM {DEVICE_TABLE} WHERE hostname = ?",
(hostname,), (hostname,),
) )
row = cur.fetchone() row = cur.fetchone()
@@ -3649,6 +4116,7 @@ def save_agent_details():
description = "" description = ""
created_at = 0 created_at = 0
existing_guid = None existing_guid = None
existing_agent_hash = None
if row: if row:
try: try:
prev_details = json.loads(row[0] or '{}') prev_details = json.loads(row[0] or '{}')
@@ -3663,6 +4131,10 @@ def save_agent_details():
existing_guid = (row[3] or "").strip() existing_guid = (row[3] or "").strip()
except Exception: except Exception:
existing_guid = None existing_guid = None
try:
existing_agent_hash = (row[4] or "").strip()
except Exception:
existing_agent_hash = None
else: else:
existing_guid = None existing_guid = None
@@ -3737,25 +4209,14 @@ def save_agent_details():
pass pass
# Upsert row without destroying created_at; keep previous created_at if exists # Upsert row without destroying created_at; keep previous created_at if exists
cur.execute( _device_upsert(
""" cur,
INSERT INTO device_details(hostname, description, details, created_at, agent_hash, guid) hostname,
VALUES (?,?,?,?,?,?) description,
ON CONFLICT(hostname) DO UPDATE SET merged,
description=excluded.description, created_at,
details=excluded.details, agent_hash=agent_hash or existing_agent_hash,
created_at=COALESCE(device_details.created_at, excluded.created_at), guid=normalized_effective_guid,
agent_hash=COALESCE(NULLIF(excluded.agent_hash, ''), device_details.agent_hash),
guid=COALESCE(NULLIF(excluded.guid, ''), device_details.guid)
""",
(
hostname,
description,
json.dumps(merged),
created_at,
agent_hash or None,
(normalized_effective_guid or None),
),
) )
conn.commit() conn.commit()
conn.close() conn.close()
@@ -3790,7 +4251,15 @@ def get_device_details(hostname: str):
conn = _db_conn() conn = _db_conn()
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
"SELECT details, description, created_at, agent_hash, guid FROM device_details WHERE hostname = ?", f"""
SELECT details, description, created_at, agent_hash, guid,
memory, network, software, storage, cpu,
device_type, domain, external_ip, internal_ip,
last_reboot, last_seen, last_user, operating_system,
uptime, agent_id
FROM {DEVICE_TABLE}
WHERE hostname = ?
""",
(hostname,), (hostname,),
) )
row = cur.fetchone() row = cur.fetchone()
@@ -3819,16 +4288,48 @@ def get_device_details(hostname: str):
details['summary']['agent_hash'] = agent_hash details['summary']['agent_hash'] = agent_hash
except Exception: except Exception:
pass pass
if len(row) > 4: agent_guid = (row[4] or "").strip() if len(row) > 4 else ""
agent_guid = (row[4] or "").strip() normalized_guid = _normalize_guid(agent_guid) if agent_guid else ""
if agent_guid: if normalized_guid:
try: details.setdefault('summary', {})
details.setdefault('summary', {}) details['summary'].setdefault('agent_guid', normalized_guid)
if not details['summary'].get('agent_guid'):
details['summary']['agent_guid'] = _normalize_guid(agent_guid) def _parse_list(raw: Optional[str]) -> list:
except Exception: try:
pass return json.loads(raw or '[]')
return jsonify(details) except Exception:
return []
def _parse_obj(raw: Optional[str]) -> dict:
try:
return json.loads(raw or '{}')
except Exception:
return {}
payload = {
"details": details,
"summary": details.get("summary", {}),
"description": description or "",
"created_at": created_at,
"agent_hash": agent_hash,
"agent_guid": normalized_guid,
"memory": _parse_list(row[5] if len(row) > 5 else None),
"network": _parse_list(row[6] if len(row) > 6 else None),
"software": _parse_list(row[7] if len(row) > 7 else None),
"storage": _parse_list(row[8] if len(row) > 8 else None),
"cpu": _parse_obj(row[9] if len(row) > 9 else None),
"device_type": (row[10] or "").strip() if len(row) > 10 and row[10] else "",
"domain": (row[11] or "").strip() if len(row) > 11 and row[11] else "",
"external_ip": (row[12] or "").strip() if len(row) > 12 and row[12] else "",
"internal_ip": (row[13] or "").strip() if len(row) > 13 and row[13] else "",
"last_reboot": (row[14] or "").strip() if len(row) > 14 and row[14] else "",
"last_seen": int(row[15] or 0) if len(row) > 15 and row[15] is not None else 0,
"last_user": (row[16] or "").strip() if len(row) > 16 and row[16] else "",
"operating_system": (row[17] or "").strip() if len(row) > 17 and row[17] else "",
"uptime": int(row[18] or 0) if len(row) > 18 and row[18] is not None else 0,
"agent_id": (row[19] or "").strip() if len(row) > 19 and row[19] else "",
}
return jsonify(payload)
except Exception: except Exception:
pass pass
return jsonify({}) return jsonify({})
@@ -3841,17 +4342,34 @@ def set_device_description(hostname: str):
try: try:
conn = _db_conn() conn = _db_conn()
cur = conn.cursor() cur = conn.cursor()
now = int(time.time())
# Insert row if missing with created_at; otherwise update description only
cur.execute( cur.execute(
"INSERT INTO device_details(hostname, description, details, created_at) " f"SELECT details, created_at, agent_hash, guid, description FROM {DEVICE_TABLE} WHERE hostname = ?",
"VALUES (?, COALESCE(?, ''), COALESCE((SELECT details FROM device_details WHERE hostname = ?), '{}'), ?) " (hostname,),
"ON CONFLICT(hostname) DO NOTHING",
(hostname, description, hostname, now),
) )
cur.execute( row = cur.fetchone()
"UPDATE device_details SET description=? WHERE hostname=?", if row:
(description, hostname), details_json, created_at, agent_hash, guid, existing_description = row
try:
details = json.loads(details_json or "{}")
except Exception:
details = {}
created_at = created_at or int(time.time())
else:
details = {}
created_at = int(time.time())
agent_hash = None
guid = None
existing_description = ""
summary = details.setdefault("summary", {})
summary["description"] = description
_device_upsert(
cur,
hostname,
description or existing_description or "",
details,
created_at,
agent_hash=agent_hash,
guid=guid,
) )
conn.commit() conn.commit()
conn.close() conn.close()
@@ -4876,7 +5394,7 @@ def handle_collector_status(data):
conn = _db_conn() conn = _db_conn()
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
"SELECT details, description, created_at FROM device_details WHERE hostname = ?", f"SELECT details, description, created_at, agent_hash, guid FROM {DEVICE_TABLE} WHERE hostname = ?",
(host,), (host,),
) )
row = cur.fetchone() row = cur.fetchone()
@@ -4887,25 +5405,27 @@ def handle_collector_status(data):
details = {} details = {}
description = row[1] or "" description = row[1] or ""
created_at = int(row[2] or 0) created_at = int(row[2] or 0)
existing_hash = (row[3] or "").strip() if len(row) > 3 else None
existing_guid = (row[4] or "").strip() if len(row) > 4 else None
else: else:
details = {} details = {}
description = "" description = ""
created_at = 0 created_at = 0
existing_hash = None
existing_guid = None
summary = details.get('summary') or {} summary = details.get('summary') or {}
# Only update last_user if provided; do not clear other fields # Only update last_user if provided; do not clear other fields
summary['last_user'] = last_user summary['last_user'] = last_user
details['summary'] = summary details['summary'] = summary
now = int(time.time()) now = int(time.time())
cur.execute( _device_upsert(
""" cur,
INSERT INTO device_details(hostname, description, details, created_at) host,
VALUES (?,?,?,?) description,
ON CONFLICT(hostname) DO UPDATE SET details,
description=excluded.description, created_at or now,
details=excluded.details, agent_hash=existing_hash,
created_at=COALESCE(device_details.created_at, excluded.created_at) guid=existing_guid,
""",
(host, description, json.dumps(details), created_at or now),
) )
conn.commit() conn.commit()
conn.close() conn.close()
@@ -4918,10 +5438,10 @@ def delete_agent(agent_id: str):
"""Remove an agent from the registry and database.""" """Remove an agent from the registry and database."""
info = registered_agents.pop(agent_id, None) info = registered_agents.pop(agent_id, None)
agent_configurations.pop(agent_id, None) agent_configurations.pop(agent_id, None)
# IMPORTANT: Do NOT delete device_details here. Multiple in-memory agent # IMPORTANT: Do NOT delete devices here. Multiple in-memory agent
# records can refer to the same hostname; removing one should not wipe the # records can refer to the same hostname; removing one should not wipe the
# persisted device inventory for the hostname. A dedicated endpoint can be # persisted device inventory for the hostname. A dedicated endpoint can be
# added later to purge device_details by hostname if needed. # added later to purge devices by hostname if needed.
if info: if info:
return jsonify({"status": "removed"}) return jsonify({"status": "removed"})
return jsonify({"error": "agent not found"}), 404 return jsonify({"error": "agent not found"}), 404