""" Collects disk usage stats over SSH using df. Designed for dashboard healthcheck loop. """ from app.models.device import Device from app.models.object import Object from app.plugins.base import ActionResult, BaseAction, Emitter from app.services import ssh as ssh_service _CMD = "df -P -k -x tmpfs -x devtmpfs" class DiskUsageAction(BaseAction): """Collects disk usage (df) and returns normalized stats.""" name = "disk_usage" display_name = "Disk Usage" compatible_categories = ["main_server", "vm"] params_schema = [] async def execute(self, device: Device, obj: Object, params: dict, emit: Emitter, db=None) -> ActionResult: await emit(f"[{device.ip}] Checking disk usage...", "info") result = await ssh_service.run_command(device, obj, _CMD, timeout=30, db=db) if result.error: await emit(f"[{device.ip}] disk usage error: {result.error}", "error") return ActionResult(success=False, error=result.error, stdout=result.stdout, stderr=result.stderr) if result.exit_code != 0: return ActionResult( success=False, error=f"df returned non-zero exit code: {result.exit_code}", stdout=result.stdout, stderr=result.stderr, exit_code=result.exit_code, ) disks = _parse_df(result.stdout) if not disks: return ActionResult( success=False, error="No disk rows parsed from df output", stdout=result.stdout, stderr=result.stderr, exit_code=result.exit_code, ) top = max(disks, key=lambda d: d["pct"]) max_pct = top["pct"] max_mount = top["mount"] await emit(f"[{device.ip}] max disk usage: {max_pct}% ({max_mount})", "info") return ActionResult( success=True, stdout=result.stdout, stderr=result.stderr, exit_code=result.exit_code, data={ "disks": disks, "max_pct": max_pct, "max_mount": max_mount, }, ) def _parse_df(output: str) -> list[dict]: rows: list[dict] = [] for line in output.splitlines(): line = line.strip() if not line or line.startswith("Filesystem"): continue parts = line.split() if len(parts) < 6: continue filesystem = parts[0] total_kb = _to_int(parts[1]) used_kb = _to_int(parts[2]) avail_kb = _to_int(parts[3]) pct_raw = parts[4].rstrip("%") mount = parts[5] pct = _to_int(pct_raw) if pct is None: continue rows.append( { "filesystem": filesystem, "total_kb": total_kb, "used_kb": used_kb, "avail_kb": avail_kb, "pct": pct, "mount": mount, } ) return rows def _to_int(value: str): try: return int(value) except Exception: return None