Merge pull request #30 from bunny-lab-io/codex/fix-storage-usage-display-on-device_details-page

Fix storage usage display and align status icon
This commit is contained in:
2025-08-13 03:26:37 -06:00
committed by GitHub
4 changed files with 234 additions and 98 deletions

View File

@@ -17,6 +17,8 @@ import time # Heartbeat timestamps
import subprocess import subprocess
import getpass import getpass
import datetime import datetime
import shutil
import string
import requests import requests
try: try:
@@ -465,63 +467,83 @@ def collect_storage():
"disk_type": "Removable" if "removable" in part.opts.lower() else "Fixed Disk", "disk_type": "Removable" if "removable" in part.opts.lower() else "Fixed Disk",
"usage": usage.percent, "usage": usage.percent,
"total": usage.total, "total": usage.total,
"free": 100 - usage.percent, "free": usage.free,
"used": usage.used,
}) })
elif plat == "windows": elif plat == "windows":
try: found = False
out = subprocess.run( for letter in string.ascii_uppercase:
["wmic", "logicaldisk", "get", "DeviceID,Size,FreeSpace"], drive = f"{letter}:\\"
capture_output=True, if os.path.exists(drive):
text=True, try:
timeout=60, usage = shutil.disk_usage(drive)
) except Exception:
lines = [l for l in out.stdout.splitlines() if l.strip()][1:] continue
for line in lines:
parts = line.split()
if len(parts) >= 3:
drive, free, size = parts[0], parts[1], parts[2]
try:
total = float(size)
free_bytes = float(free)
used = total - free_bytes
usage = (used / total * 100) if total else 0
free_pct = 100 - usage
disks.append({
"drive": drive,
"disk_type": "Fixed Disk",
"usage": usage,
"total": total,
"free": free_pct,
})
except Exception:
pass
except FileNotFoundError:
ps_cmd = (
"Get-PSDrive -PSProvider FileSystem | "
"Select-Object Name,Free,Used,Capacity,Root | ConvertTo-Json"
)
out = subprocess.run(
["powershell", "-NoProfile", "-Command", ps_cmd],
capture_output=True,
text=True,
timeout=60,
)
data = json.loads(out.stdout or "[]")
if isinstance(data, dict):
data = [data]
for d in data:
total = d.get("Capacity") or 0
used = d.get("Used") or 0
usage = (used / total * 100) if total else 0
free = 100 - usage
drive = d.get("Root") or f"{d.get('Name','')}:"
disks.append({ disks.append({
"drive": drive, "drive": drive,
"disk_type": "Fixed Disk", "disk_type": "Fixed Disk",
"usage": usage, "usage": (usage.used / usage.total * 100) if usage.total else 0,
"total": total, "total": usage.total,
"free": free, "free": usage.free,
"used": usage.used,
}) })
found = True
if not found:
try:
out = subprocess.run(
["wmic", "logicaldisk", "get", "DeviceID,Size,FreeSpace"],
capture_output=True,
text=True,
timeout=60,
)
lines = [l for l in out.stdout.splitlines() if l.strip()][1:]
for line in lines:
parts = line.split()
if len(parts) >= 3:
drive, free, size = parts[0], parts[1], parts[2]
try:
total = float(size)
free_bytes = float(free)
used = total - free_bytes
usage = (used / total * 100) if total else 0
disks.append({
"drive": drive,
"disk_type": "Fixed Disk",
"usage": usage,
"total": total,
"free": free_bytes,
"used": used,
})
except Exception:
pass
except FileNotFoundError:
ps_cmd = (
"Get-PSDrive -PSProvider FileSystem | "
"Select-Object Name,Free,Used,Capacity,Root | ConvertTo-Json"
)
out = subprocess.run(
["powershell", "-NoProfile", "-Command", ps_cmd],
capture_output=True,
text=True,
timeout=60,
)
data = json.loads(out.stdout or "[]")
if isinstance(data, dict):
data = [data]
for d in data:
total = d.get("Capacity") or 0
used = d.get("Used") or 0
free_bytes = d.get("Free") or max(total - used, 0)
usage = (used / total * 100) if total else 0
drive = d.get("Root") or f"{d.get('Name','')}:"
disks.append({
"drive": drive,
"disk_type": "Fixed Disk",
"usage": usage,
"total": total,
"free": free_bytes,
"used": used,
})
else: else:
out = subprocess.run( out = subprocess.run(
["df", "-kP"], capture_output=True, text=True, timeout=60 ["df", "-kP"], capture_output=True, text=True, timeout=60
@@ -532,14 +554,15 @@ def collect_storage():
if len(parts) >= 6: if len(parts) >= 6:
total = int(parts[1]) * 1024 total = int(parts[1]) * 1024
used = int(parts[2]) * 1024 used = int(parts[2]) * 1024
free_bytes = int(parts[3]) * 1024
usage = float(parts[4].rstrip("%")) usage = float(parts[4].rstrip("%"))
free = 100 - usage
disks.append({ disks.append({
"drive": parts[5], "drive": parts[5],
"disk_type": "Fixed Disk", "disk_type": "Fixed Disk",
"usage": usage, "usage": usage,
"total": total, "total": total,
"free": free, "free": free_bytes,
"used": used,
}) })
except Exception as e: except Exception as e:
print(f"[WARN] collect_storage failed: {e}") print(f"[WARN] collect_storage failed: {e}")

View File

@@ -283,15 +283,65 @@ export default function DeviceDetails({ device, onBack }) {
}; };
const renderStorage = () => { const renderStorage = () => {
const rows = (details.storage || []).map((d) => ({ const toNum = (val) => {
drive: d.drive, if (val === undefined || val === null) return undefined;
disk_type: d.disk_type, if (typeof val === "number") {
usage: d.usage !== undefined ? Number(d.usage) : undefined, return Number.isNaN(val) ? undefined : val;
total: d.total !== undefined ? Number(d.total) : undefined, }
free: d.free !== undefined ? Number(d.free) : undefined, const n = parseFloat(String(val).replace(/[^0-9.]+/g, ""));
})); return Number.isNaN(n) ? undefined : n;
};
const rows = (details.storage || []).map((d) => {
const total = toNum(d.total);
let usagePct = toNum(d.usage);
let usedBytes = toNum(d.used);
let freeBytes = toNum(d.free);
let freePct;
if (usagePct !== undefined) {
if (usagePct <= 1) usagePct *= 100;
freePct = 100 - usagePct;
}
if (usedBytes === undefined && total !== undefined && usagePct !== undefined) {
usedBytes = (usagePct / 100) * total;
}
if (freeBytes === undefined && total !== undefined && usedBytes !== undefined) {
freeBytes = total - usedBytes;
}
if (freePct === undefined && total !== undefined && freeBytes !== undefined) {
freePct = (freeBytes / total) * 100;
}
if (usagePct === undefined && freePct !== undefined) {
usagePct = 100 - freePct;
}
return {
drive: d.drive,
disk_type: d.disk_type,
used: usedBytes,
freePct,
freeBytes,
total,
usage: usagePct,
};
});
if (!rows.length) if (!rows.length)
return placeholderTable(["Drive Letter", "Disk Type", "Usage", "Total Size", "Free %"]); return placeholderTable([
"Drive Letter",
"Disk Type",
"Used",
"Free %",
"Free GB",
"Total Size",
"Usage",
]);
return ( return (
<Box sx={{ maxHeight: 400, overflowY: "auto" }}> <Box sx={{ maxHeight: 400, overflowY: "auto" }}>
<Table size="small"> <Table size="small">
@@ -299,9 +349,11 @@ export default function DeviceDetails({ device, onBack }) {
<TableRow> <TableRow>
<TableCell>Drive Letter</TableCell> <TableCell>Drive Letter</TableCell>
<TableCell>Disk Type</TableCell> <TableCell>Disk Type</TableCell>
<TableCell>Usage</TableCell> <TableCell>Used</TableCell>
<TableCell>Total Size</TableCell>
<TableCell>Free %</TableCell> <TableCell>Free %</TableCell>
<TableCell>Free GB</TableCell>
<TableCell>Total Size</TableCell>
<TableCell>Usage</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@@ -309,6 +361,26 @@ export default function DeviceDetails({ device, onBack }) {
<TableRow key={`${d.drive}-${i}`}> <TableRow key={`${d.drive}-${i}`}>
<TableCell>{d.drive}</TableCell> <TableCell>{d.drive}</TableCell>
<TableCell>{d.disk_type}</TableCell> <TableCell>{d.disk_type}</TableCell>
<TableCell>
{d.used !== undefined && !Number.isNaN(d.used)
? formatBytes(d.used)
: "unknown"}
</TableCell>
<TableCell>
{d.freePct !== undefined && !Number.isNaN(d.freePct)
? `${d.freePct.toFixed(1)}%`
: "unknown"}
</TableCell>
<TableCell>
{d.freeBytes !== undefined && !Number.isNaN(d.freeBytes)
? formatBytes(d.freeBytes)
: "unknown"}
</TableCell>
<TableCell>
{d.total !== undefined && !Number.isNaN(d.total)
? formatBytes(d.total)
: "unknown"}
</TableCell>
<TableCell> <TableCell>
<Box sx={{ display: "flex", alignItems: "center" }}> <Box sx={{ display: "flex", alignItems: "center" }}>
<Box sx={{ flexGrow: 1, mr: 1 }}> <Box sx={{ flexGrow: 1, mr: 1 }}>
@@ -318,7 +390,7 @@ export default function DeviceDetails({ device, onBack }) {
sx={{ sx={{
height: 10, height: 10,
bgcolor: "#333", bgcolor: "#333",
"& .MuiLinearProgress-bar": { bgcolor: "#58a6ff" } "& .MuiLinearProgress-bar": { bgcolor: "#00d18c" }
}} }}
/> />
</Box> </Box>
@@ -329,16 +401,6 @@ export default function DeviceDetails({ device, onBack }) {
</Typography> </Typography>
</Box> </Box>
</TableCell> </TableCell>
<TableCell>
{d.total !== undefined && !Number.isNaN(d.total)
? formatBytes(d.total)
: "unknown"}
</TableCell>
<TableCell>
{d.free !== undefined && !Number.isNaN(d.free)
? `${d.free.toFixed(1)}%`
: "unknown"}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@@ -165,33 +165,44 @@ export default function DeviceList({ onSelectDevice }) {
</TableHead> </TableHead>
<TableBody> <TableBody>
{sorted.map((r, i) => ( {sorted.map((r, i) => (
<TableRow <TableRow key={r.id || i} hover>
key={r.id || i}
hover
onClick={() => onSelectDevice && onSelectDevice(r)}
sx={{ cursor: onSelectDevice ? "pointer" : "default" }}
>
<TableCell> <TableCell>
<span <Box sx={{ display: "flex", alignItems: "center" }}>
style={{ <Box
display: "inline-block", component="span"
width: 10, sx={{
height: 10, display: "inline-block",
borderRadius: 10, width: 10,
background: statusColor(r.status), height: 10,
marginRight: 8, borderRadius: 10,
verticalAlign: "middle" bgcolor: statusColor(r.status),
}} mr: 1,
/> }}
{r.status} />
{r.status}
</Box>
</TableCell>
<TableCell
onClick={() => onSelectDevice && onSelectDevice(r)}
sx={{
color: "#58a6ff",
"&:hover": {
cursor: onSelectDevice ? "pointer" : "default",
textDecoration: onSelectDevice ? "underline" : "none",
},
}}
>
{r.hostname}
</TableCell> </TableCell>
<TableCell>{r.hostname}</TableCell>
<TableCell>{timeSince(r.lastSeen)}</TableCell> <TableCell>{timeSince(r.lastSeen)}</TableCell>
<TableCell>{r.os}</TableCell> <TableCell>{r.os}</TableCell>
<TableCell align="right"> <TableCell align="right">
<IconButton <IconButton
size="small" size="small"
onClick={(e) => openMenu(e, r)} onClick={(e) => {
e.stopPropagation();
openMenu(e, r);
}}
sx={{ color: "#ccc" }} sx={{ color: "#ccc" }}
> >
<MoreVertIcon fontSize="small" /> <MoreVertIcon fontSize="small" />

View File

@@ -398,6 +398,36 @@ def init_db():
init_db() init_db()
def load_agents_from_db():
"""Populate registered_agents with any devices stored in the database."""
try:
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute("SELECT hostname, details FROM device_details")
for hostname, details_json in cur.fetchall():
try:
details = json.loads(details_json or "{}")
except Exception:
details = {}
summary = details.get("summary", {})
agent_id = summary.get("agent_id") or hostname
registered_agents[agent_id] = {
"agent_id": agent_id,
"hostname": summary.get("hostname") or hostname,
"agent_operating_system": summary.get("operating_system")
or summary.get("agent_operating_system")
or "-",
"last_seen": summary.get("last_seen") or 0,
"status": "Offline",
}
conn.close()
except Exception as e:
print(f"[WARN] Failed to load agents from DB: {e}")
load_agents_from_db()
@app.route("/api/agents") @app.route("/api/agents")
def get_agents(): def get_agents():
""" """
@@ -481,10 +511,20 @@ def set_device_description(hostname: str):
@app.route("/api/agent/<agent_id>", methods=["DELETE"]) @app.route("/api/agent/<agent_id>", methods=["DELETE"])
def delete_agent(agent_id: str): def delete_agent(agent_id: str):
"""Remove an agent from the in-memory registry.""" """Remove an agent from the registry and database."""
if agent_id in registered_agents: info = registered_agents.pop(agent_id, None)
registered_agents.pop(agent_id, None) agent_configurations.pop(agent_id, None)
agent_configurations.pop(agent_id, None) hostname = info.get("hostname") if info else None
if hostname:
try:
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute("DELETE FROM device_details WHERE hostname = ?", (hostname,))
conn.commit()
conn.close()
except Exception as e:
return jsonify({"error": str(e)}), 500
if info:
return jsonify({"status": "removed"}) return jsonify({"status": "removed"})
return jsonify({"error": "agent not found"}), 404 return jsonify({"error": "agent not found"}), 404