From 0a9a626c5665efe4d39c5399325199bd64d0f299 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Thu, 23 Oct 2025 04:36:24 -0600 Subject: [PATCH] Restore device summary fields and assembly data flow --- Data/Engine/domain/devices.py | 86 ++++++++++++------- .../sqlite/device_inventory_repository.py | 56 +++++++++--- .../devices/device_inventory_service.py | 14 ++- Data/Engine/tests/test_http_agent.py | 53 ++++++++++++ 4 files changed, 166 insertions(+), 43 deletions(-) diff --git a/Data/Engine/domain/devices.py b/Data/Engine/domain/devices.py index b369169..0264f9a 100644 --- a/Data/Engine/domain/devices.py +++ b/Data/Engine/domain/devices.py @@ -7,6 +7,8 @@ from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Dict, List, Mapping, Optional, Sequence +from Data.Engine.domain.device_auth import normalize_guid + __all__ = [ "DEVICE_TABLE_COLUMNS", "DEVICE_TABLE", @@ -91,8 +93,13 @@ class DeviceSnapshot: operating_system: str uptime: int agent_id: str + ansible_ee_ver: str connection_type: str connection_endpoint: str + ssl_key_fingerprint: str + token_version: int + status: str + key_added_at: str details: Dict[str, Any] summary: Dict[str, Any] @@ -121,8 +128,13 @@ class DeviceSnapshot: "operating_system": self.operating_system, "uptime": self.uptime, "agent_id": self.agent_id, + "ansible_ee_ver": self.ansible_ee_ver, "connection_type": self.connection_type, "connection_endpoint": self.connection_endpoint, + "ssl_key_fingerprint": self.ssl_key_fingerprint, + "token_version": self.token_version, + "status": self.status, + "key_added_at": self.key_added_at, "details": self.details, "summary": self.summary, } @@ -211,33 +223,16 @@ def row_to_device_dict(row: Sequence[Any], columns: Sequence[str]) -> Dict[str, def assemble_device_snapshot(record: Mapping[str, Any]) -> Dict[str, Any]: - summary = { - "hostname": record.get("hostname") or "", - "description": record.get("description") or "", - "device_type": record.get("device_type") or "", - "domain": record.get("domain") or "", - "external_ip": record.get("external_ip") or "", - "internal_ip": record.get("internal_ip") or "", - "last_reboot": record.get("last_reboot") or "", - "last_seen": record.get("last_seen") or 0, - "last_user": record.get("last_user") or "", - "operating_system": record.get("operating_system") or "", - "uptime": record.get("uptime") or 0, - "agent_id": record.get("agent_id") or "", - "agent_hash": record.get("agent_hash") or "", - "agent_guid": record.get("guid") or record.get("agent_guid") or "", - "connection_type": record.get("connection_type") or "", - "connection_endpoint": record.get("connection_endpoint") or "", - "ssl_key_fingerprint": record.get("ssl_key_fingerprint") or "", - "status": record.get("status") or "", - "token_version": record.get("token_version") or 0, - "key_added_at": record.get("key_added_at") or "", - "created_at": record.get("created_at") or 0, - } + hostname = clean_device_str(record.get("hostname")) or "" + description = clean_device_str(record.get("description")) or "" + agent_hash = clean_device_str(record.get("agent_hash")) or "" + raw_guid = clean_device_str(record.get("guid")) + normalized_guid = normalize_guid(raw_guid) - created_ts = coerce_int(summary.get("created_at")) or 0 - last_seen_ts = coerce_int(summary.get("last_seen")) or 0 - uptime_val = coerce_int(summary.get("uptime")) or 0 + created_ts = coerce_int(record.get("created_at")) or 0 + last_seen_ts = coerce_int(record.get("last_seen")) or 0 + uptime_val = coerce_int(record.get("uptime")) or 0 + token_version = coerce_int(record.get("token_version")) or 0 parsed_lists = { key: _parse_device_json(record.get(key), default) @@ -245,20 +240,48 @@ def assemble_device_snapshot(record: Mapping[str, Any]) -> Dict[str, Any]: } cpu_obj = _parse_device_json(record.get("cpu"), DEVICE_JSON_OBJECT_FIELDS["cpu"]) + summary: Dict[str, Any] = { + "hostname": hostname, + "description": description, + "agent_hash": agent_hash, + "agent_guid": normalized_guid or "", + "agent_id": clean_device_str(record.get("agent_id")) or "", + "device_type": clean_device_str(record.get("device_type")) or "", + "domain": clean_device_str(record.get("domain")) or "", + "external_ip": clean_device_str(record.get("external_ip")) or "", + "internal_ip": clean_device_str(record.get("internal_ip")) or "", + "last_reboot": clean_device_str(record.get("last_reboot")) or "", + "last_seen": last_seen_ts, + "last_user": clean_device_str(record.get("last_user")) or "", + "operating_system": clean_device_str(record.get("operating_system")) or "", + "uptime": uptime_val, + "uptime_sec": uptime_val, + "ansible_ee_ver": clean_device_str(record.get("ansible_ee_ver")) or "", + "connection_type": clean_device_str(record.get("connection_type")) or "", + "connection_endpoint": clean_device_str(record.get("connection_endpoint")) or "", + "ssl_key_fingerprint": clean_device_str(record.get("ssl_key_fingerprint")) or "", + "status": clean_device_str(record.get("status")) or "", + "token_version": token_version, + "key_added_at": clean_device_str(record.get("key_added_at")) or "", + "created_at": created_ts, + "created": ts_to_human(created_ts), + } + details = { "memory": parsed_lists["memory"], "network": parsed_lists["network"], "software": parsed_lists["software"], "storage": parsed_lists["storage"], "cpu": cpu_obj, + "summary": dict(summary), } payload: Dict[str, Any] = { - "hostname": summary["hostname"], - "description": summary.get("description", ""), + "hostname": hostname, + "description": description, "created_at": created_ts, "created_at_iso": ts_to_iso(created_ts), - "agent_hash": summary.get("agent_hash", ""), + "agent_hash": agent_hash, "agent_guid": summary.get("agent_guid", ""), "guid": summary.get("agent_guid", ""), "memory": parsed_lists["memory"], @@ -277,8 +300,13 @@ def assemble_device_snapshot(record: Mapping[str, Any]) -> Dict[str, Any]: "operating_system": summary.get("operating_system", ""), "uptime": uptime_val, "agent_id": summary.get("agent_id", ""), + "ansible_ee_ver": summary.get("ansible_ee_ver", ""), "connection_type": summary.get("connection_type", ""), "connection_endpoint": summary.get("connection_endpoint", ""), + "ssl_key_fingerprint": summary.get("ssl_key_fingerprint", ""), + "token_version": summary.get("token_version", 0), + "status": summary.get("status", ""), + "key_added_at": summary.get("key_added_at", ""), "details": details, "summary": summary, } diff --git a/Data/Engine/repositories/sqlite/device_inventory_repository.py b/Data/Engine/repositories/sqlite/device_inventory_repository.py index 9a50a9e..6aa839d 100644 --- a/Data/Engine/repositories/sqlite/device_inventory_repository.py +++ b/Data/Engine/repositories/sqlite/device_inventory_repository.py @@ -278,28 +278,60 @@ class SQLiteDeviceInventoryRepository: for field in ("memory", "network", "software", "storage"): payload[field] = serialize_device_json(details.get(field), []) payload["cpu"] = serialize_device_json(summary.get("cpu") or details.get("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["device_type"] = clean_device_str( + summary.get("device_type") + or summary.get("type") + or summary.get("device_class") + ) + payload["domain"] = clean_device_str( + summary.get("domain") or summary.get("domain_name") + ) + 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") or summary.get("last_seen_epoch") + ) payload["last_user"] = clean_device_str( summary.get("last_user") or summary.get("last_user_name") or summary.get("logged_in_user") + or summary.get("username") + or summary.get("user") ) payload["operating_system"] = clean_device_str( - summary.get("operating_system") or summary.get("os") + summary.get("operating_system") + or summary.get("agent_operating_system") + or summary.get("os") ) - payload["uptime"] = coerce_int(summary.get("uptime")) + 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")) payload["ansible_ee_ver"] = clean_device_str(summary.get("ansible_ee_ver")) - payload["connection_type"] = clean_device_str(summary.get("connection_type")) - payload["connection_endpoint"] = clean_device_str( - summary.get("connection_endpoint") or summary.get("endpoint") + payload["connection_type"] = clean_device_str( + summary.get("connection_type") or summary.get("remote_type") + ) + payload["connection_endpoint"] = clean_device_str( + summary.get("connection_endpoint") + or summary.get("endpoint") + or summary.get("connection_address") + or summary.get("address") + or summary.get("external_ip") + or summary.get("internal_ip") + ) + payload["ssl_key_fingerprint"] = clean_device_str( + summary.get("ssl_key_fingerprint") ) - payload["ssl_key_fingerprint"] = clean_device_str(summary.get("ssl_key_fingerprint")) payload["token_version"] = coerce_int(summary.get("token_version")) or 0 payload["status"] = clean_device_str(summary.get("status")) payload["key_added_at"] = clean_device_str(summary.get("key_added_at")) diff --git a/Data/Engine/services/devices/device_inventory_service.py b/Data/Engine/services/devices/device_inventory_service.py index 9252494..e06208e 100644 --- a/Data/Engine/services/devices/device_inventory_service.py +++ b/Data/Engine/services/devices/device_inventory_service.py @@ -14,7 +14,7 @@ from Data.Engine.repositories.sqlite.device_inventory_repository import ( SQLiteDeviceInventoryRepository, ) from Data.Engine.domain.device_auth import DeviceAuthContext, normalize_guid -from Data.Engine.domain.devices import clean_device_str, coerce_int +from Data.Engine.domain.devices import clean_device_str, coerce_int, ts_to_human __all__ = [ "DeviceInventoryService", @@ -240,7 +240,7 @@ class DeviceInventoryService: summary["hostname"] = hostname if metrics: - last_user = metrics.get("last_user") + last_user = metrics.get("last_user") or metrics.get("username") or metrics.get("user") if last_user: cleaned_user = clean_device_str(last_user) if cleaned_user: @@ -422,6 +422,8 @@ class DeviceInventoryService: if created_at is None: created_at = int(time.time()) merged_summary["created_at"] = created_at + if not merged_summary.get("created"): + merged_summary["created"] = ts_to_human(created_at) if fingerprint: merged_summary["ssl_key_fingerprint"] = fingerprint @@ -431,6 +433,14 @@ class DeviceInventoryService: merged_summary["token_version"] = 1 if not merged_summary.get("status") and snapshot.get("summary", {}).get("status"): merged_summary["status"] = snapshot.get("summary", {}).get("status") + uptime_val = merged_summary.get("uptime") + if merged_summary.get("uptime_sec") is None and uptime_val is not None: + coerced = coerce_int(uptime_val) + if coerced is not None: + merged_summary["uptime_sec"] = coerced + merged_summary.setdefault("uptime_seconds", coerced) + if merged_summary.get("uptime_seconds") is None and merged_summary.get("uptime_sec") is not None: + merged_summary["uptime_seconds"] = merged_summary.get("uptime_sec") description = clean_device_str(merged_summary.get("description")) existing_description = snapshot.get("description") if snapshot else "" diff --git a/Data/Engine/tests/test_http_agent.py b/Data/Engine/tests/test_http_agent.py index 885ca6c..0d16e9f 100644 --- a/Data/Engine/tests/test_http_agent.py +++ b/Data/Engine/tests/test_http_agent.py @@ -255,10 +255,14 @@ def test_agent_details_persists_inventory(prepared_app, monkeypatch): "last_user": "BUNNY-LAB\\nicole.rappe", "operating_system": "Windows 11", "description": "Primary workstation", + "last_reboot": "2025-10-01 10:00:00", + "uptime": 3600, }, "memory": [{"slot": "DIMM0", "capacity": 17179869184}], "storage": [{"model": "NVMe", "size": 512}], "network": [{"adapter": "Ethernet", "ips": ["192.168.1.50"]}], + "software": [{"name": "Borealis Agent", "version": "2.0"}], + "cpu": {"name": "Intel Core i7", "logical_cores": 8, "base_clock_ghz": 3.4}, }, } @@ -291,6 +295,26 @@ def test_agent_details_persists_inventory(prepared_app, monkeypatch): assert json.loads(storage_json)[0]["model"] == "NVMe" assert json.loads(network_json)[0]["ips"][0] == "192.168.1.50" + resp = client.get("/api/devices") + assert resp.status_code == 200 + listing = resp.get_json() + device = next((dev for dev in listing.get("devices", []) if dev["hostname"] == hostname), None) + assert device is not None + summary = device["summary"] + details = device["details"] + + assert summary["device_type"] == "Laptop" + assert summary["last_user"] == "BUNNY-LAB\\nicole.rappe" + assert summary["created"] + assert summary.get("uptime_sec") == 3600 + assert details["summary"]["device_type"] == "Laptop" + assert details["summary"]["last_reboot"] == "2025-10-01 10:00:00" + assert details["summary"]["created"] == summary["created"] + assert details["software"][0]["name"] == "Borealis Agent" + assert device["storage"][0]["model"] == "NVMe" + assert device["memory"][0]["capacity"] == 17179869184 + assert device["cpu"]["name"] == "Intel Core i7" + def test_heartbeat_preserves_last_user_from_details(prepared_app, monkeypatch): client = prepared_app.test_client() @@ -330,3 +354,32 @@ def test_heartbeat_preserves_last_user_from_details(prepared_app, monkeypatch): assert row is not None assert row[0] == "BUNNY-LAB\\nicole.rappe" + +def test_heartbeat_uses_username_when_last_user_missing(prepared_app, monkeypatch): + client = prepared_app.test_client() + guid = "802A4E5F-1B2C-4D5E-8F90-A1B2C3D4E5F7" + fingerprint = "55:66:77:88" + hostname = "device-username" + _insert_device(prepared_app, guid, fingerprint, hostname) + + services = prepared_app.extensions["engine_services"] + context = _build_context(guid, fingerprint) + monkeypatch.setattr(services.device_auth, "authenticate", lambda request, path: context) + + resp = client.post( + "/api/agent/heartbeat", + json={"hostname": hostname, "metrics": {"username": "BUNNY-LAB\\alice.smith"}}, + headers={"Authorization": "Bearer token"}, + ) + assert resp.status_code == 200 + + db_path = Path(prepared_app.config["ENGINE_DATABASE_PATH"]) + with sqlite3.connect(db_path) as conn: + row = conn.execute( + "SELECT last_user FROM devices WHERE guid = ?", + (guid,), + ).fetchone() + + assert row is not None + assert row[0] == "BUNNY-LAB\\alice.smith" +