From 4db6414da4cf110e025798108fdb0c87948a930f Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Tue, 2 Sep 2025 21:06:39 -0600 Subject: [PATCH] Fixed Device "Last Seen" Logic and Watchdog Behavior --- Data/Agent/borealis-agent.py | 6 +- .../WebUI/src/Devices/Device_Details.jsx | 6 +- Data/Server/WebUI/src/Devices/Device_List.jsx | 4 +- Data/Server/server.py | 80 ++++++++++++++++++- 4 files changed, 88 insertions(+), 8 deletions(-) diff --git a/Data/Agent/borealis-agent.py b/Data/Agent/borealis-agent.py index 6eff17a..8277a7e 100644 --- a/Data/Agent/borealis-agent.py +++ b/Data/Agent/borealis-agent.py @@ -204,6 +204,9 @@ async def send_heartbeat(): Periodically send agent heartbeat to the server so the Devices page can show hostname, OS, and last_seen. """ + # Initial heartbeat is sent in the WebSocket 'connect' handler. + # Delay the loop start so we don't double-send immediately. + await asyncio.sleep(60) while True: try: payload = { @@ -215,7 +218,8 @@ async def send_heartbeat(): await sio.emit("agent_heartbeat", payload) except Exception as e: print(f"[WARN] heartbeat emit failed: {e}") - await asyncio.sleep(5) + # Send periodic heartbeats every 60 seconds + await asyncio.sleep(60) # ---------------- Detailed Agent Data ---------------- diff --git a/Data/Server/WebUI/src/Devices/Device_Details.jsx b/Data/Server/WebUI/src/Devices/Device_Details.jsx index be971cc..59980ce 100644 --- a/Data/Server/WebUI/src/Devices/Device_Details.jsx +++ b/Data/Server/WebUI/src/Devices/Device_Details.jsx @@ -34,10 +34,10 @@ export default function DeviceDetails({ device, onBack }) { const tsSec = device?.lastSeen; if (!tsSec) return "Offline"; const now = Date.now() / 1000; - return now - tsSec <= 15 ? "Online" : "Offline"; + return now - tsSec <= 120 ? "Online" : "Offline"; }); - const statusFromHeartbeat = (tsSec, offlineAfter = 15) => { + const statusFromHeartbeat = (tsSec, offlineAfter = 120) => { if (!tsSec) return "Offline"; const now = Date.now() / 1000; return now - tsSec <= offlineAfter ? "Online" : "Offline"; @@ -45,7 +45,7 @@ export default function DeviceDetails({ device, onBack }) { const statusColor = (s) => (s === "Online" ? "#00d18c" : "#ff4f4f"); - const formatLastSeen = (tsSec, offlineAfter = 15) => { + const formatLastSeen = (tsSec, offlineAfter = 120) => { if (!tsSec) return "unknown"; const now = Date.now() / 1000; if (now - tsSec <= offlineAfter) return "Currently Online"; diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index 74e87c2..ce959c9 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -18,7 +18,7 @@ import { import MoreVertIcon from "@mui/icons-material/MoreVert"; import { DeleteDeviceDialog } from "../Dialogs.jsx"; -function formatLastSeen(tsSec, offlineAfter = 15) { +function formatLastSeen(tsSec, offlineAfter = 120) { if (!tsSec) return "unknown"; const now = Date.now() / 1000; if (now - tsSec <= offlineAfter) return "Currently Online"; @@ -35,7 +35,7 @@ function formatLastSeen(tsSec, offlineAfter = 15) { return `${date} @ ${time}`; } -function statusFromHeartbeat(tsSec, offlineAfter = 15) { +function statusFromHeartbeat(tsSec, offlineAfter = 120) { if (!tsSec) return "Offline"; const now = Date.now() / 1000; return now - tsSec <= offlineAfter ? "Online" : "Offline"; diff --git a/Data/Server/server.py b/Data/Server/server.py index 6ac27c1..f342dc1 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -399,6 +399,48 @@ def init_db(): init_db() +def _persist_last_seen(hostname: str, last_seen: int): + """Persist the last_seen timestamp into the device_details.details JSON. + + Ensures that after a server restart, we can restore last_seen from DB + even if the agent is offline. + """ + if not hostname or str(hostname).strip().lower() == "unknown": + return + try: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute( + "SELECT details, description FROM device_details WHERE hostname = ?", + (hostname,), + ) + row = cur.fetchone() + # Load existing details JSON or create a minimal one + if row and row[0]: + try: + details = json.loads(row[0]) + except Exception: + details = {} + description = row[1] if len(row) > 1 else "" + else: + details = {} + description = "" + + summary = details.get("summary") or {} + summary["hostname"] = summary.get("hostname") or hostname + summary["last_seen"] = int(last_seen or 0) + details["summary"] = summary + + cur.execute( + "REPLACE INTO device_details (hostname, description, details) VALUES (?, ?, ?)", + (hostname, description, json.dumps(details)), + ) + conn.commit() + conn.close() + except Exception as e: + print(f"[WARN] Failed to persist last_seen for {hostname}: {e}") + + def load_agents_from_db(): """Populate registered_agents with any devices stored in the database.""" try: @@ -441,6 +483,7 @@ def save_agent_details(): data = request.get_json(silent=True) or {} hostname = data.get("hostname") details = data.get("details") + agent_id = data.get("agent_id") if not hostname and isinstance(details, dict): hostname = details.get("summary", {}).get("hostname") if not hostname or not isinstance(details, dict): @@ -448,12 +491,34 @@ def save_agent_details(): try: conn = sqlite3.connect(DB_PATH) cur = conn.cursor() + # Load existing details/description so we can preserve description and merge last_seen cur.execute( - "SELECT description FROM device_details WHERE hostname = ?", + "SELECT details, description FROM device_details WHERE hostname = ?", (hostname,), ) row = cur.fetchone() - description = row[0] if row else "" + prev_details = {} + if row and row[0]: + try: + prev_details = json.loads(row[0]) + except Exception: + prev_details = {} + description = row[1] if row and len(row) > 1 else "" + + # Ensure details.summary.last_seen is preserved/merged so it survives restarts + try: + incoming_summary = details.setdefault("summary", {}) + if not incoming_summary.get("last_seen"): + last_seen = None + if agent_id and agent_id in registered_agents: + last_seen = registered_agents[agent_id].get("last_seen") + if not last_seen: + last_seen = (prev_details.get("summary") or {}).get("last_seen") + if last_seen: + incoming_summary["last_seen"] = int(last_seen) + except Exception: + pass + cur.execute( "REPLACE INTO device_details (hostname, description, details) VALUES (?, ?, ?)", (hostname, description, json.dumps(details)), @@ -667,6 +732,12 @@ def connect_agent(data): rec["agent_operating_system"] = rec.get("agent_operating_system", "-") rec["last_seen"] = int(time.time()) rec["status"] = "provisioned" if agent_id in agent_configurations else "orphaned" + # If we already know the hostname for this agent, persist last_seen so it + # can be restored after server restarts. + try: + _persist_last_seen(rec.get("hostname"), rec["last_seen"]) + except Exception: + pass @socketio.on("agent_heartbeat") def on_agent_heartbeat(data): @@ -696,6 +767,11 @@ def on_agent_heartbeat(data): rec["agent_operating_system"] = data.get("agent_operating_system") rec["last_seen"] = int(data.get("last_seen") or time.time()) rec["status"] = "provisioned" if agent_id in agent_configurations else rec.get("status", "orphaned") + # Persist last_seen into DB keyed by hostname so it survives restarts. + try: + _persist_last_seen(rec.get("hostname") or hostname, rec["last_seen"]) + except Exception: + pass @socketio.on("request_config") def send_agent_config(data):