mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-09-11 01:48:42 -06:00
Fixed Device "Last Seen" Logic and Watchdog Behavior
This commit is contained in:
@@ -204,6 +204,9 @@ async def send_heartbeat():
|
|||||||
Periodically send agent heartbeat to the server so the Devices page can
|
Periodically send agent heartbeat to the server so the Devices page can
|
||||||
show hostname, OS, and last_seen.
|
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:
|
while True:
|
||||||
try:
|
try:
|
||||||
payload = {
|
payload = {
|
||||||
@@ -215,7 +218,8 @@ async def send_heartbeat():
|
|||||||
await sio.emit("agent_heartbeat", payload)
|
await sio.emit("agent_heartbeat", payload)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WARN] heartbeat emit failed: {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 ----------------
|
# ---------------- Detailed Agent Data ----------------
|
||||||
|
|
||||||
|
@@ -34,10 +34,10 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
const tsSec = device?.lastSeen;
|
const tsSec = device?.lastSeen;
|
||||||
if (!tsSec) return "Offline";
|
if (!tsSec) return "Offline";
|
||||||
const now = Date.now() / 1000;
|
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";
|
if (!tsSec) return "Offline";
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
return now - tsSec <= offlineAfter ? "Online" : "Offline";
|
return now - tsSec <= offlineAfter ? "Online" : "Offline";
|
||||||
@@ -45,7 +45,7 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
|
|
||||||
const statusColor = (s) => (s === "Online" ? "#00d18c" : "#ff4f4f");
|
const statusColor = (s) => (s === "Online" ? "#00d18c" : "#ff4f4f");
|
||||||
|
|
||||||
const formatLastSeen = (tsSec, offlineAfter = 15) => {
|
const formatLastSeen = (tsSec, offlineAfter = 120) => {
|
||||||
if (!tsSec) return "unknown";
|
if (!tsSec) return "unknown";
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
if (now - tsSec <= offlineAfter) return "Currently Online";
|
if (now - tsSec <= offlineAfter) return "Currently Online";
|
||||||
|
@@ -18,7 +18,7 @@ import {
|
|||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
import { DeleteDeviceDialog } from "../Dialogs.jsx";
|
import { DeleteDeviceDialog } from "../Dialogs.jsx";
|
||||||
|
|
||||||
function formatLastSeen(tsSec, offlineAfter = 15) {
|
function formatLastSeen(tsSec, offlineAfter = 120) {
|
||||||
if (!tsSec) return "unknown";
|
if (!tsSec) return "unknown";
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
if (now - tsSec <= offlineAfter) return "Currently Online";
|
if (now - tsSec <= offlineAfter) return "Currently Online";
|
||||||
@@ -35,7 +35,7 @@ function formatLastSeen(tsSec, offlineAfter = 15) {
|
|||||||
return `${date} @ ${time}`;
|
return `${date} @ ${time}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusFromHeartbeat(tsSec, offlineAfter = 15) {
|
function statusFromHeartbeat(tsSec, offlineAfter = 120) {
|
||||||
if (!tsSec) return "Offline";
|
if (!tsSec) return "Offline";
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
return now - tsSec <= offlineAfter ? "Online" : "Offline";
|
return now - tsSec <= offlineAfter ? "Online" : "Offline";
|
||||||
|
@@ -399,6 +399,48 @@ def init_db():
|
|||||||
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():
|
def load_agents_from_db():
|
||||||
"""Populate registered_agents with any devices stored in the database."""
|
"""Populate registered_agents with any devices stored in the database."""
|
||||||
try:
|
try:
|
||||||
@@ -441,6 +483,7 @@ def save_agent_details():
|
|||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
hostname = data.get("hostname")
|
hostname = data.get("hostname")
|
||||||
details = data.get("details")
|
details = data.get("details")
|
||||||
|
agent_id = data.get("agent_id")
|
||||||
if not hostname and isinstance(details, dict):
|
if not hostname and isinstance(details, dict):
|
||||||
hostname = details.get("summary", {}).get("hostname")
|
hostname = details.get("summary", {}).get("hostname")
|
||||||
if not hostname or not isinstance(details, dict):
|
if not hostname or not isinstance(details, dict):
|
||||||
@@ -448,12 +491,34 @@ def save_agent_details():
|
|||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
# Load existing details/description so we can preserve description and merge last_seen
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT description FROM device_details WHERE hostname = ?",
|
"SELECT details, description FROM device_details WHERE hostname = ?",
|
||||||
(hostname,),
|
(hostname,),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
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(
|
cur.execute(
|
||||||
"REPLACE INTO device_details (hostname, description, details) VALUES (?, ?, ?)",
|
"REPLACE INTO device_details (hostname, description, details) VALUES (?, ?, ?)",
|
||||||
(hostname, description, json.dumps(details)),
|
(hostname, description, json.dumps(details)),
|
||||||
@@ -667,6 +732,12 @@ def connect_agent(data):
|
|||||||
rec["agent_operating_system"] = rec.get("agent_operating_system", "-")
|
rec["agent_operating_system"] = rec.get("agent_operating_system", "-")
|
||||||
rec["last_seen"] = int(time.time())
|
rec["last_seen"] = int(time.time())
|
||||||
rec["status"] = "provisioned" if agent_id in agent_configurations else "orphaned"
|
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")
|
@socketio.on("agent_heartbeat")
|
||||||
def on_agent_heartbeat(data):
|
def on_agent_heartbeat(data):
|
||||||
@@ -696,6 +767,11 @@ def on_agent_heartbeat(data):
|
|||||||
rec["agent_operating_system"] = data.get("agent_operating_system")
|
rec["agent_operating_system"] = data.get("agent_operating_system")
|
||||||
rec["last_seen"] = int(data.get("last_seen") or time.time())
|
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")
|
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")
|
@socketio.on("request_config")
|
||||||
def send_agent_config(data):
|
def send_agent_config(data):
|
||||||
|
Reference in New Issue
Block a user