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"

View File

@@ -463,7 +463,7 @@ def _lookup_agent_hash_record(agent_id: str) -> Optional[Dict[str, Any]]:
if row_guid:
payload['agent_guid'] = row_guid
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():
try:
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()
cur = conn.cursor()
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,),
)
row = cur.fetchone()
@@ -638,7 +638,7 @@ def _collect_agent_hash_records() -> List[Dict[str, Any]]:
try:
conn = _db_conn()
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():
try:
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
if normalized_guid:
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,),
)
row = cur.fetchone()
if row:
updated_via_guid = True
hostname = row[0]
description = row[1]
try:
details = json.loads(row[1] or '{}')
details = json.loads(row[2] or '{}')
except Exception:
details = {}
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_hash'] = agent_hash
summary['agent_guid'] = normalized_guid
cur.execute(
'UPDATE device_details SET agent_hash=?, details=? WHERE hostname=?',
(agent_hash, json.dumps(details), hostname),
existing_created_at = row[3] if len(row) > 3 else None
existing_hash = row[4] if len(row) > 4 else None
_device_upsert(
cur,
hostname,
description,
details,
existing_created_at,
agent_hash=agent_hash or existing_hash,
guid=normalized_guid,
)
conn.commit()
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:
summary['agent_id'] = resolved_agent_id
cur.execute(
'UPDATE device_details SET agent_hash=?, details=? WHERE hostname=?',
(agent_hash, json.dumps(details), hostname),
f'SELECT description, created_at, agent_hash FROM {DEVICE_TABLE} WHERE 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()
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]))
conn.commit()
conn.commit()
conn.commit()
conn.close()
# set session cookie
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"))
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 ---
_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()
cur = conn.cursor()
# Device details table
# Device table (renamed from historical device_details)
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:
cur.execute("PRAGMA table_info(device_details)")
cols = [r[1] for r in cur.fetchall()]
if 'created_at' not in cols:
cur.execute("ALTER TABLE device_details ADD COLUMN created_at INTEGER")
if 'agent_hash' not in cols:
cur.execute("ALTER TABLE device_details ADD COLUMN agent_hash TEXT")
if 'guid' not in cols:
cur.execute("ALTER TABLE device_details ADD COLUMN guid TEXT")
cur.execute(f"SELECT hostname, details FROM {DEVICE_TABLE}")
rows = cur.fetchall()
for hostname, details_json in rows:
try:
details = json.loads(details_json or "{}")
except Exception:
details = {}
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:
pass
@@ -2986,7 +3287,9 @@ def _load_device_records(limit: int = 0):
try:
conn = _db_conn()
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()
# Build device -> site mapping
@@ -3005,19 +3308,18 @@ def _load_device_records(limit: int = 0):
site_map = {}
out = []
for hostname, description, details_json in rows:
d = {}
for hostname, description, last_user, internal_ip, external_ip, details_json in rows:
summary = {}
try:
d = json.loads(details_json or "{}")
summary = (json.loads(details_json or "{}") or {}).get("summary") or {}
except Exception:
d = {}
summary = d.get("summary") or {}
summary = {}
rec = {
"hostname": hostname or summary.get("hostname") or "",
"description": (description or summary.get("description") or ""),
"last_user": summary.get("last_user") or summary.get("last_user_name") or "",
"internal_ip": summary.get("internal_ip") or "",
"external_ip": summary.get("external_ip") or "",
"last_user": last_user or summary.get("last_user") or summary.get("last_user_name") or "",
"internal_ip": internal_ip or summary.get("internal_ip") or "",
"external_ip": external_ip or summary.get("external_ip") or "",
}
site_info = site_map.get(rec["hostname"]) or {}
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
# ---------------------------------------------
@@ -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):
"""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
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()
cur = conn.cursor()
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,),
)
row = cur.fetchone()
@@ -3304,6 +3738,7 @@ def _persist_last_seen(hostname: str, last_seen: int, agent_id: str = None):
details = {}
description = ""
created_at = 0
existing_hash = row[4] if row and len(row) > 4 else None
summary = details.get("summary") or {}
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
# Single upsert to avoid unique-constraint races
cur.execute(
"""
INSERT INTO device_details(hostname, description, details, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(hostname) DO UPDATE SET
description=excluded.description,
details=excluded.details,
created_at=COALESCE(device_details.created_at, excluded.created_at)
""",
(hostname, description, json.dumps(details), target_created_at),
effective_hash = summary.get("agent_hash") or existing_hash
effective_guid = summary.get("agent_guid") or existing_guid
_device_upsert(
cur,
hostname,
description,
details,
target_created_at,
agent_hash=effective_hash,
guid=effective_guid,
)
conn.commit()
conn.close()
@@ -3357,9 +3792,11 @@ def load_agents_from_db():
try:
conn = _db_conn()
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()
for hostname, details_json, agent_hash, guid in rows:
for hostname, description, details_json, created_at, agent_hash, guid in rows:
try:
details = json.loads(details_json or "{}")
except Exception:
@@ -3373,11 +3810,17 @@ def load_agents_from_db():
stored_hash = summary.get("agent_hash") or ""
agent_guid = (summary.get("agent_guid") or guid or "").strip()
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:
cur.execute(
"UPDATE device_details SET details=? WHERE hostname=?",
(json.dumps(details), hostname),
_device_upsert(
cur,
hostname,
description,
details,
created_at,
agent_hash=agent_hash or stored_hash,
guid=normalized_guid,
)
except Exception:
pass
@@ -3428,7 +3871,7 @@ def _device_rows_for_agent(cur, agent_id: str) -> List[Dict[str, Any]]:
return results
try:
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(),),
)
rows = cur.fetchall()
@@ -3470,14 +3913,22 @@ def _ensure_agent_guid_for_hostname(cur, hostname: str, agent_id: Optional[str]
if not normalized_host:
return None
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(),),
)
row = cur.fetchone()
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:
actual_host, existing_guid, details_json = normalized_host, "", "{}"
description = ""
created_at = None
agent_hash = None
try:
details = json.loads(details_json or "{}")
except Exception:
@@ -3494,26 +3945,28 @@ def _ensure_agent_guid_for_hostname(cur, hostname: str, agent_id: Optional[str]
if existing_guid:
normalized = _normalize_guid(existing_guid)
summary.setdefault("agent_guid", normalized)
cur.execute(
"UPDATE device_details SET guid=?, details=? WHERE hostname=?",
(normalized, json.dumps(details), actual_host),
_device_upsert(
cur,
actual_host,
description,
details,
created_at,
agent_hash=agent_hash,
guid=normalized,
)
return summary.get("agent_guid") or normalized
new_guid = str(uuid.uuid4())
summary["agent_guid"] = new_guid
now = int(time.time())
cur.execute(
"""
INSERT INTO device_details(hostname, description, details, created_at, guid)
VALUES (?, COALESCE((SELECT description FROM device_details WHERE hostname = ?), ''), ?, COALESCE((SELECT created_at FROM device_details WHERE hostname = ?), ?), ?)
ON CONFLICT(hostname) DO UPDATE SET
guid=excluded.guid,
details=excluded.details,
created_at=COALESCE(device_details.created_at, excluded.created_at),
description=COALESCE(device_details.description, excluded.description)
""",
(actual_host, actual_host, json.dumps(details), actual_host, now, new_guid),
_device_upsert(
cur,
actual_host,
description,
details,
created_at or now,
agent_hash=agent_hash,
guid=new_guid,
)
return new_guid
@@ -3531,9 +3984,23 @@ def _ensure_agent_guid(agent_id: str, hostname: Optional[str] = None) -> Optiona
if candidate:
summary = row.get("details", {}).setdefault("summary", {})
summary.setdefault("agent_guid", candidate)
host = row.get("hostname")
cur.execute(
"UPDATE device_details SET guid=?, details=? WHERE hostname=?",
(candidate, json.dumps(row.get("details", {})), row.get("hostname")),
f"SELECT description, created_at, agent_hash FROM {DEVICE_TABLE} WHERE 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()
return candidate
@@ -3641,7 +4108,7 @@ def save_agent_details():
cur = conn.cursor()
# Load existing row to preserve description and created_at and merge fields
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,),
)
row = cur.fetchone()
@@ -3649,6 +4116,7 @@ def save_agent_details():
description = ""
created_at = 0
existing_guid = None
existing_agent_hash = None
if row:
try:
prev_details = json.loads(row[0] or '{}')
@@ -3663,6 +4131,10 @@ def save_agent_details():
existing_guid = (row[3] or "").strip()
except Exception:
existing_guid = None
try:
existing_agent_hash = (row[4] or "").strip()
except Exception:
existing_agent_hash = None
else:
existing_guid = None
@@ -3737,25 +4209,14 @@ def save_agent_details():
pass
# Upsert row without destroying created_at; keep previous created_at if exists
cur.execute(
"""
INSERT INTO device_details(hostname, description, details, created_at, agent_hash, guid)
VALUES (?,?,?,?,?,?)
ON CONFLICT(hostname) DO UPDATE SET
description=excluded.description,
details=excluded.details,
created_at=COALESCE(device_details.created_at, excluded.created_at),
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),
),
_device_upsert(
cur,
hostname,
description,
merged,
created_at,
agent_hash=agent_hash or existing_agent_hash,
guid=normalized_effective_guid,
)
conn.commit()
conn.close()
@@ -3790,7 +4251,15 @@ def get_device_details(hostname: str):
conn = _db_conn()
cur = conn.cursor()
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,),
)
row = cur.fetchone()
@@ -3819,16 +4288,48 @@ def get_device_details(hostname: str):
details['summary']['agent_hash'] = agent_hash
except Exception:
pass
if len(row) > 4:
agent_guid = (row[4] or "").strip()
if agent_guid:
try:
details.setdefault('summary', {})
if not details['summary'].get('agent_guid'):
details['summary']['agent_guid'] = _normalize_guid(agent_guid)
except Exception:
pass
return jsonify(details)
agent_guid = (row[4] or "").strip() if len(row) > 4 else ""
normalized_guid = _normalize_guid(agent_guid) if agent_guid else ""
if normalized_guid:
details.setdefault('summary', {})
details['summary'].setdefault('agent_guid', normalized_guid)
def _parse_list(raw: Optional[str]) -> list:
try:
return json.loads(raw or '[]')
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:
pass
return jsonify({})
@@ -3841,17 +4342,34 @@ def set_device_description(hostname: str):
try:
conn = _db_conn()
cur = conn.cursor()
now = int(time.time())
# Insert row if missing with created_at; otherwise update description only
cur.execute(
"INSERT INTO device_details(hostname, description, details, created_at) "
"VALUES (?, COALESCE(?, ''), COALESCE((SELECT details FROM device_details WHERE hostname = ?), '{}'), ?) "
"ON CONFLICT(hostname) DO NOTHING",
(hostname, description, hostname, now),
f"SELECT details, created_at, agent_hash, guid, description FROM {DEVICE_TABLE} WHERE hostname = ?",
(hostname,),
)
cur.execute(
"UPDATE device_details SET description=? WHERE hostname=?",
(description, hostname),
row = cur.fetchone()
if row:
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.close()
@@ -4876,7 +5394,7 @@ def handle_collector_status(data):
conn = _db_conn()
cur = conn.cursor()
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,),
)
row = cur.fetchone()
@@ -4887,25 +5405,27 @@ def handle_collector_status(data):
details = {}
description = row[1] or ""
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:
details = {}
description = ""
created_at = 0
existing_hash = None
existing_guid = None
summary = details.get('summary') or {}
# Only update last_user if provided; do not clear other fields
summary['last_user'] = last_user
details['summary'] = summary
now = int(time.time())
cur.execute(
"""
INSERT INTO device_details(hostname, description, details, created_at)
VALUES (?,?,?,?)
ON CONFLICT(hostname) DO UPDATE SET
description=excluded.description,
details=excluded.details,
created_at=COALESCE(device_details.created_at, excluded.created_at)
""",
(host, description, json.dumps(details), created_at or now),
_device_upsert(
cur,
host,
description,
details,
created_at or now,
agent_hash=existing_hash,
guid=existing_guid,
)
conn.commit()
conn.close()
@@ -4918,10 +5438,10 @@ def delete_agent(agent_id: str):
"""Remove an agent from the registry and database."""
info = registered_agents.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
# 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:
return jsonify({"status": "removed"})
return jsonify({"error": "agent not found"}), 404