mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 21:21:57 -06:00
Merge pull request #69 from bunny-lab-io:codex/add-agent-version-column-to-device-list
Add agent version hash tracking to device list
This commit is contained in:
@@ -134,6 +134,32 @@ def _project_root():
|
|||||||
return os.getcwd()
|
return os.getcwd()
|
||||||
|
|
||||||
|
|
||||||
|
_AGENT_HASH_CACHE = {"path": None, "mtime": None, "value": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_agent_hash():
|
||||||
|
try:
|
||||||
|
root = _project_root()
|
||||||
|
path = os.path.join(root, 'github_repo_hash.txt')
|
||||||
|
cache = _AGENT_HASH_CACHE
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
cache.update({"path": path, "mtime": None, "value": None})
|
||||||
|
return None
|
||||||
|
mtime = os.path.getmtime(path)
|
||||||
|
if cache.get("path") == path and cache.get("mtime") == mtime:
|
||||||
|
return cache.get("value")
|
||||||
|
with open(path, 'r', encoding='utf-8') as fh:
|
||||||
|
value = fh.read().strip()
|
||||||
|
cache.update({"path": path, "mtime": mtime, "value": value or None})
|
||||||
|
return cache.get("value")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
_AGENT_HASH_CACHE.update({"value": None})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Removed Ansible-based audit path; Python collectors provide details directly.
|
# Removed Ansible-based audit path; Python collectors provide details directly.
|
||||||
|
|
||||||
|
|
||||||
@@ -828,6 +854,12 @@ class Role:
|
|||||||
|
|
||||||
# Always post the latest available details (possibly cached)
|
# Always post the latest available details (possibly cached)
|
||||||
details_to_send = self._last_details or {'summary': collect_summary(self.ctx.config)}
|
details_to_send = self._last_details or {'summary': collect_summary(self.ctx.config)}
|
||||||
|
agent_hash_value = _read_agent_hash()
|
||||||
|
if agent_hash_value:
|
||||||
|
try:
|
||||||
|
details_to_send.setdefault('summary', {})['agent_hash'] = agent_hash_value
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
get_url = (self.ctx.hooks.get('get_server_url') if isinstance(self.ctx.hooks, dict) else None) or (lambda: 'http://localhost:5000')
|
get_url = (self.ctx.hooks.get('get_server_url') if isinstance(self.ctx.hooks, dict) else None) or (lambda: 'http://localhost:5000')
|
||||||
url = (get_url() or '').rstrip('/') + '/api/agent/details'
|
url = (get_url() or '').rstrip('/') + '/api/agent/details'
|
||||||
payload = {
|
payload = {
|
||||||
@@ -835,6 +867,8 @@ class Role:
|
|||||||
'hostname': details_to_send.get('summary', {}).get('hostname', socket.gethostname()),
|
'hostname': details_to_send.get('summary', {}).get('hostname', socket.gethostname()),
|
||||||
'details': details_to_send,
|
'details': details_to_send,
|
||||||
}
|
}
|
||||||
|
if agent_hash_value:
|
||||||
|
payload['agent_hash'] = agent_hash_value
|
||||||
if aiohttp is not None:
|
if aiohttp is not None:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
await session.post(url, json=payload, timeout=10)
|
await session.post(url, json=payload, timeout=10)
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
const COL_LABELS = useMemo(
|
const COL_LABELS = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
status: "Status",
|
status: "Status",
|
||||||
|
agentVersion: "Agent Version",
|
||||||
site: "Site",
|
site: "Site",
|
||||||
hostname: "Hostname",
|
hostname: "Hostname",
|
||||||
description: "Description",
|
description: "Description",
|
||||||
@@ -95,6 +96,7 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
const defaultColumns = useMemo(
|
const defaultColumns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ id: "status", label: COL_LABELS.status },
|
{ id: "status", label: COL_LABELS.status },
|
||||||
|
{ id: "agentVersion", label: COL_LABELS.agentVersion },
|
||||||
{ id: "site", label: COL_LABELS.site },
|
{ id: "site", label: COL_LABELS.site },
|
||||||
{ id: "hostname", label: COL_LABELS.hostname },
|
{ id: "hostname", label: COL_LABELS.hostname },
|
||||||
{ id: "description", label: COL_LABELS.description },
|
{ id: "description", label: COL_LABELS.description },
|
||||||
@@ -120,13 +122,52 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
const [assignSiteId, setAssignSiteId] = useState(null);
|
const [assignSiteId, setAssignSiteId] = useState(null);
|
||||||
const [assignTargets, setAssignTargets] = useState([]); // hostnames
|
const [assignTargets, setAssignTargets] = useState([]); // hostnames
|
||||||
|
|
||||||
const fetchAgents = useCallback(async () => {
|
const [repoHash, setRepoHash] = useState(null);
|
||||||
|
|
||||||
|
const fetchLatestRepoHash = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
"https://api.github.com/repos/bunny-lab-io/Borealis/branches/main",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) throw new Error(`GitHub status ${resp.status}`);
|
||||||
|
const json = await resp.json();
|
||||||
|
const sha = (json?.commit?.sha || "").trim();
|
||||||
|
setRepoHash((prev) => sha || prev || null);
|
||||||
|
return sha || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to fetch repository hash", err);
|
||||||
|
setRepoHash((prev) => prev || null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const computeAgentVersion = useCallback((agentHashValue, repoHashValue) => {
|
||||||
|
const agentHash = (agentHashValue || "").trim();
|
||||||
|
const repo = (repoHashValue || "").trim();
|
||||||
|
if (!repo) return agentHash ? "Unknown" : "Unknown";
|
||||||
|
if (!agentHash) return "Needs Updated";
|
||||||
|
return agentHash === repo ? "Up-to-Date" : "Needs Updated";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAgents = useCallback(async (options = {}) => {
|
||||||
|
const { refreshRepo = false } = options || {};
|
||||||
|
let repoSha = repoHash;
|
||||||
|
if (refreshRepo || !repoSha) {
|
||||||
|
const fetched = await fetchLatestRepoHash();
|
||||||
|
if (fetched) repoSha = fetched;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/agents");
|
const res = await fetch("/api/agents");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const arr = Object.entries(data || {}).map(([id, a]) => {
|
const arr = Object.entries(data || {}).map(([id, a]) => {
|
||||||
const hostname = a.hostname || id || "unknown";
|
const hostname = a.hostname || id || "unknown";
|
||||||
const details = detailsByHost[hostname] || {};
|
const details = detailsByHost[hostname] || {};
|
||||||
|
const agentHash = (a.agent_hash || "").trim();
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
hostname,
|
hostname,
|
||||||
@@ -142,6 +183,8 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
externalIp: details.externalIp || "",
|
externalIp: details.externalIp || "",
|
||||||
lastReboot: details.lastReboot || "",
|
lastReboot: details.lastReboot || "",
|
||||||
description: details.description || "",
|
description: details.description || "",
|
||||||
|
agentHash,
|
||||||
|
agentVersion: computeAgentVersion(agentHash, repoSha),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
setRows(arr);
|
setRows(arr);
|
||||||
@@ -229,7 +272,7 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
console.warn("Failed to load agents:", e);
|
console.warn("Failed to load agents:", e);
|
||||||
setRows([]);
|
setRows([]);
|
||||||
}
|
}
|
||||||
}, [detailsByHost]);
|
}, [detailsByHost, repoHash, fetchLatestRepoHash, computeAgentVersion]);
|
||||||
|
|
||||||
const fetchViews = useCallback(async () => {
|
const fetchViews = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -244,7 +287,7 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initial load only; removed auto-refresh interval
|
// Initial load only; removed auto-refresh interval
|
||||||
fetchAgents();
|
fetchAgents({ refreshRepo: true });
|
||||||
}, [fetchAgents]);
|
}, [fetchAgents]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -271,7 +314,20 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
setFilters((prev) => ({ ...prev, ...obj }));
|
setFilters((prev) => ({ ...prev, ...obj }));
|
||||||
// Optionally ensure Site column exists when site filter is present
|
// Optionally ensure Site column exists when site filter is present
|
||||||
if (obj.site) {
|
if (obj.site) {
|
||||||
setColumns((prev) => (prev.some((c) => c.id === 'site') ? prev : [{ id: 'status', label: COL_LABELS.status }, { id: 'site', label: COL_LABELS.site }, ...prev.filter((c) => c.id !== 'status') ]));
|
setColumns((prev) => {
|
||||||
|
if (prev.some((c) => c.id === 'site')) return prev;
|
||||||
|
const hasAgentVersion = prev.some((c) => c.id === 'agentVersion');
|
||||||
|
const remainder = prev.filter((c) => !['status', 'agentVersion'].includes(c.id));
|
||||||
|
const base = [
|
||||||
|
{ id: 'status', label: COL_LABELS.status },
|
||||||
|
...(hasAgentVersion ? [{ id: 'agentVersion', label: COL_LABELS.agentVersion }] : []),
|
||||||
|
{ id: 'site', label: COL_LABELS.site },
|
||||||
|
];
|
||||||
|
if (!hasAgentVersion) {
|
||||||
|
return base.concat(prev.filter((c) => c.id !== 'status'));
|
||||||
|
}
|
||||||
|
return [...base, ...remainder];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
localStorage.removeItem('device_list_initial_filters');
|
localStorage.removeItem('device_list_initial_filters');
|
||||||
@@ -283,7 +339,9 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
const hasSite = prev.some((c) => c.id === 'site');
|
const hasSite = prev.some((c) => c.id === 'site');
|
||||||
if (hasSite) return prev;
|
if (hasSite) return prev;
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next.splice(1, 0, { id: 'site', label: COL_LABELS.site });
|
const agentIndex = next.findIndex((c) => c.id === 'agentVersion');
|
||||||
|
const insertAt = agentIndex >= 0 ? agentIndex + 1 : 1;
|
||||||
|
next.splice(insertAt, 0, { id: 'site', label: COL_LABELS.site });
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setFilters((f) => ({ ...f, site }));
|
setFilters((f) => ({ ...f, site }));
|
||||||
@@ -333,6 +391,8 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
return row.type || "";
|
return row.type || "";
|
||||||
case "os":
|
case "os":
|
||||||
return row.os || "";
|
return row.os || "";
|
||||||
|
case "agentVersion":
|
||||||
|
return row.agentVersion || "";
|
||||||
case "internalIp":
|
case "internalIp":
|
||||||
return row.internalIp || "";
|
return row.internalIp || "";
|
||||||
case "externalIp":
|
case "externalIp":
|
||||||
@@ -554,7 +614,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}
|
onClick={() => fetchAgents({ refreshRepo: true })}
|
||||||
sx={{ color: "#bbb", mr: 1 }}
|
sx={{ color: "#bbb", mr: 1 }}
|
||||||
>
|
>
|
||||||
<CachedIcon fontSize="small" />
|
<CachedIcon fontSize="small" />
|
||||||
@@ -660,6 +720,8 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
</Box>
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
|
case "agentVersion":
|
||||||
|
return <TableCell key={col.id}>{r.agentVersion || ""}</TableCell>;
|
||||||
case "site":
|
case "site":
|
||||||
return <TableCell key={col.id}>{r.site || "Not Configured"}</TableCell>;
|
return <TableCell key={col.id}>{r.site || "Not Configured"}</TableCell>;
|
||||||
case "hostname":
|
case "hostname":
|
||||||
@@ -822,7 +884,8 @@ 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 }}>
|
||||||
{[
|
{[
|
||||||
|
{ id: 'agentVersion', label: 'Agent Version' },
|
||||||
{ id: 'site', label: 'Site' },
|
{ id: 'site', label: 'Site' },
|
||||||
{ id: 'hostname', label: 'Hostname' },
|
{ id: 'hostname', label: 'Hostname' },
|
||||||
{ id: 'os', label: 'Operating System' },
|
{ id: 'os', label: 'Operating System' },
|
||||||
|
|||||||
@@ -1776,7 +1776,7 @@ def init_db():
|
|||||||
|
|
||||||
# Device details table
|
# Device details table
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS device_details (hostname TEXT PRIMARY KEY, description TEXT, details TEXT, created_at INTEGER)"
|
"CREATE TABLE IF NOT EXISTS device_details (hostname TEXT PRIMARY KEY, description TEXT, details TEXT, created_at INTEGER, agent_hash TEXT)"
|
||||||
)
|
)
|
||||||
# Backfill missing created_at column on existing installs
|
# Backfill missing created_at column on existing installs
|
||||||
try:
|
try:
|
||||||
@@ -1784,6 +1784,8 @@ def init_db():
|
|||||||
cols = [r[1] for r in cur.fetchall()]
|
cols = [r[1] for r in cur.fetchall()]
|
||||||
if 'created_at' not in cols:
|
if 'created_at' not in cols:
|
||||||
cur.execute("ALTER TABLE device_details ADD COLUMN created_at INTEGER")
|
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")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -2607,14 +2609,19 @@ def load_agents_from_db():
|
|||||||
try:
|
try:
|
||||||
conn = _db_conn()
|
conn = _db_conn()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT hostname, details FROM device_details")
|
cur.execute("SELECT hostname, details, agent_hash FROM device_details")
|
||||||
for hostname, details_json in cur.fetchall():
|
for hostname, details_json, agent_hash in cur.fetchall():
|
||||||
try:
|
try:
|
||||||
details = json.loads(details_json or "{}")
|
details = json.loads(details_json or "{}")
|
||||||
except Exception:
|
except Exception:
|
||||||
details = {}
|
details = {}
|
||||||
summary = details.get("summary", {})
|
summary = details.get("summary", {})
|
||||||
agent_id = summary.get("agent_id") or hostname
|
agent_id = summary.get("agent_id") or hostname
|
||||||
|
stored_hash = None
|
||||||
|
try:
|
||||||
|
stored_hash = (agent_hash or summary.get("agent_hash") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
stored_hash = summary.get("agent_hash") or ""
|
||||||
registered_agents[agent_id] = {
|
registered_agents[agent_id] = {
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"hostname": summary.get("hostname") or hostname,
|
"hostname": summary.get("hostname") or hostname,
|
||||||
@@ -2625,6 +2632,8 @@ def load_agents_from_db():
|
|||||||
"last_seen": summary.get("last_seen") or 0,
|
"last_seen": summary.get("last_seen") or 0,
|
||||||
"status": "Offline",
|
"status": "Offline",
|
||||||
}
|
}
|
||||||
|
if stored_hash:
|
||||||
|
registered_agents[agent_id]["agent_hash"] = stored_hash
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WARN] Failed to load agents from DB: {e}")
|
print(f"[WARN] Failed to load agents from DB: {e}")
|
||||||
@@ -2690,6 +2699,11 @@ def save_agent_details():
|
|||||||
hostname = data.get("hostname")
|
hostname = data.get("hostname")
|
||||||
details = data.get("details")
|
details = data.get("details")
|
||||||
agent_id = data.get("agent_id")
|
agent_id = data.get("agent_id")
|
||||||
|
agent_hash = data.get("agent_hash")
|
||||||
|
if isinstance(agent_hash, str):
|
||||||
|
agent_hash = agent_hash.strip() or None
|
||||||
|
else:
|
||||||
|
agent_hash = None
|
||||||
if not hostname and isinstance(details, dict):
|
if not hostname and isinstance(details, dict):
|
||||||
hostname = (details.get("summary") or {}).get("hostname")
|
hostname = (details.get("summary") or {}).get("hostname")
|
||||||
if not hostname or not isinstance(details, dict):
|
if not hostname or not isinstance(details, dict):
|
||||||
@@ -2726,6 +2740,11 @@ def save_agent_details():
|
|||||||
pass
|
pass
|
||||||
if hostname and not incoming_summary.get("hostname"):
|
if hostname and not incoming_summary.get("hostname"):
|
||||||
incoming_summary["hostname"] = hostname
|
incoming_summary["hostname"] = hostname
|
||||||
|
if agent_hash:
|
||||||
|
try:
|
||||||
|
incoming_summary["agent_hash"] = agent_hash
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Preserve last_seen if incoming omitted it
|
# Preserve last_seen if incoming omitted it
|
||||||
if not incoming_summary.get("last_seen"):
|
if not incoming_summary.get("last_seen"):
|
||||||
@@ -2781,17 +2800,34 @@ def save_agent_details():
|
|||||||
# 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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO device_details(hostname, description, details, created_at)
|
INSERT INTO device_details(hostname, description, details, created_at, agent_hash)
|
||||||
VALUES (?,?,?,?)
|
VALUES (?,?,?,?,?)
|
||||||
ON CONFLICT(hostname) DO UPDATE SET
|
ON CONFLICT(hostname) DO UPDATE SET
|
||||||
description=excluded.description,
|
description=excluded.description,
|
||||||
details=excluded.details,
|
details=excluded.details,
|
||||||
created_at=COALESCE(device_details.created_at, excluded.created_at)
|
created_at=COALESCE(device_details.created_at, excluded.created_at),
|
||||||
|
agent_hash=COALESCE(NULLIF(excluded.agent_hash, ''), device_details.agent_hash)
|
||||||
""",
|
""",
|
||||||
(hostname, description, json.dumps(merged), created_at),
|
(hostname, description, json.dumps(merged), created_at, agent_hash or None),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
normalized_hash = None
|
||||||
|
try:
|
||||||
|
normalized_hash = (agent_hash or (merged.get("summary") or {}).get("agent_hash") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
normalized_hash = agent_hash
|
||||||
|
if normalized_hash:
|
||||||
|
if agent_id and agent_id in registered_agents:
|
||||||
|
registered_agents[agent_id]["agent_hash"] = normalized_hash
|
||||||
|
# Also update any entries keyed by hostname (duplicate agents)
|
||||||
|
try:
|
||||||
|
for aid, rec in registered_agents.items():
|
||||||
|
if rec.get("hostname") == hostname and normalized_hash:
|
||||||
|
rec["agent_hash"] = normalized_hash
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|||||||
Reference in New Issue
Block a user