diff --git a/backend/app/plugins/builtin/ssh/debian_system_info.py b/backend/app/plugins/builtin/ssh/debian_system_info.py new file mode 100644 index 0000000..a2606bb --- /dev/null +++ b/backend/app/plugins/builtin/ssh/debian_system_info.py @@ -0,0 +1,150 @@ +""" +Collects Debian/Linux system status over SSH: +disk usage, RAM, CPU load, uptime, OS version, top processes. +""" + +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 + +# One-liner that collects all metrics in a single SSH session +_CMD = r""" +echo "=== OS ===" && (cat /etc/os-release 2>/dev/null | grep -E "^PRETTY_NAME|^VERSION_ID" || echo "unknown") && \ +echo "=== UPTIME ===" && uptime -p 2>/dev/null || uptime && \ +echo "=== CPU_LOAD ===" && cat /proc/loadavg && \ +echo "=== CPU_CORES ===" && nproc && \ +echo "=== MEMORY ===" && free -m && \ +echo "=== DISK ===" && df -h --output=target,size,used,avail,pcent -x tmpfs -x devtmpfs 2>/dev/null || df -h && \ +echo "=== TOP_PROCS ===" && ps aux --sort=-%cpu --no-headers 2>/dev/null | head -5 && \ +echo "=== TEMPS ===" && (cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null | awk '{printf "%.1f°C\n", $1/1000}' || echo "n/a") +""".strip() + + +class DebianSystemInfoAction(BaseAction): + """Collects disk, RAM, CPU, uptime and process info from a Debian/Linux host via SSH.""" + + name = "debian_system_info" + display_name = "Debian: System Info" + compatible_categories = ["server", "raspberry", "embedded", "other"] + params_schema = [] + + async def execute(self, device: Device, obj: Object, params: dict, emit: Emitter) -> ActionResult: + await emit(f"[{device.ip}] Collecting system info...", "info") + + result = await ssh_service.run_command(device, obj, _CMD, timeout=30) + + if result.error: + await emit(f"[{device.ip}] Error: {result.error}", "error") + return ActionResult(success=False, error=result.error) + + data = _parse(result.stdout) + + # Emit summary + await emit(f"[{device.ip}] OS: {data.get('os', '?')}", "info") + await emit(f"[{device.ip}] Uptime: {data.get('uptime', '?')}", "info") + await emit(f"[{device.ip}] Load: {data.get('load_1m', '?')} / {data.get('load_5m', '?')} / {data.get('load_15m', '?')} (cores: {data.get('cpu_cores', '?')})", "info") + await emit(f"[{device.ip}] RAM: used {data.get('mem_used_mb', '?')} MB / {data.get('mem_total_mb', '?')} MB ({data.get('mem_percent', '?')}%)", "info") + for disk in data.get("disks", []): + await emit(f"[{device.ip}] Disk {disk['mount']}: {disk['used']} / {disk['size']} ({disk['pct']} used, {disk['avail']} free)", "info") + if data.get("temps"): + await emit(f"[{device.ip}] Temps: {', '.join(data['temps'])}", "info") + + return ActionResult( + success=True, + stdout=result.stdout, + data=data, + ) + + +def _parse(output: str) -> dict: + sections: dict[str, list[str]] = {} + current = None + for line in output.splitlines(): + line = line.strip() + if line.startswith("===") and line.endswith("==="): + current = line.strip("= ").strip() + sections[current] = [] + elif current is not None and line: + sections[current].append(line) + + data: dict = {} + + # OS + os_lines = sections.get("OS", []) + for line in os_lines: + if line.startswith("PRETTY_NAME="): + data["os"] = line.split("=", 1)[1].strip('"') + break + if "os" not in data and os_lines: + data["os"] = os_lines[0] + + # Uptime + uptime_lines = sections.get("UPTIME", []) + data["uptime"] = uptime_lines[0] if uptime_lines else "?" + + # CPU load + load_lines = sections.get("CPU_LOAD", []) + if load_lines: + parts = load_lines[0].split() + data["load_1m"] = parts[0] if len(parts) > 0 else "?" + data["load_5m"] = parts[1] if len(parts) > 1 else "?" + data["load_15m"] = parts[2] if len(parts) > 2 else "?" + + # CPU cores + cores_lines = sections.get("CPU_CORES", []) + data["cpu_cores"] = cores_lines[0] if cores_lines else "?" + + # Memory (free -m output) + # Line format: "Mem: total used free shared buff/cache available" + mem_lines = sections.get("MEMORY", []) + for line in mem_lines: + if line.startswith("Mem:"): + parts = line.split() + if len(parts) >= 3: + total = int(parts[1]) + used = int(parts[2]) + data["mem_total_mb"] = total + data["mem_used_mb"] = used + data["mem_free_mb"] = int(parts[3]) if len(parts) > 3 else total - used + data["mem_percent"] = round(used / total * 100, 1) if total else 0 + break + + # Disk + disk_lines = sections.get("DISK", []) + disks = [] + for line in disk_lines: + # Skip header lines + if line.startswith("Filesystem") or line.startswith("target"): + continue + parts = line.split() + if len(parts) >= 5: + disks.append({ + "mount": parts[0], + "size": parts[1], + "used": parts[2], + "avail": parts[3], + "pct": parts[4], + }) + data["disks"] = disks + + # Top processes + proc_lines = sections.get("TOP_PROCS", []) + procs = [] + for line in proc_lines: + parts = line.split(None, 10) + if len(parts) >= 11: + procs.append({ + "user": parts[0], + "pid": parts[1], + "cpu": parts[2], + "mem": parts[3], + "cmd": parts[10], + }) + data["top_processes"] = procs + + # Temperatures + temp_lines = sections.get("TEMPS", []) + data["temps"] = [t for t in temp_lines if t != "n/a"] + + return data diff --git a/backend/app/plugins/loader.py b/backend/app/plugins/loader.py index 9f527f8..0547728 100644 --- a/backend/app/plugins/loader.py +++ b/backend/app/plugins/loader.py @@ -25,6 +25,7 @@ from app.plugins.builtin.common.ping import PingAction from app.plugins.builtin.mikrotik.get_config import MikrotikGetConfigAction from app.plugins.builtin.mikrotik.get_ntp import MikrotikGetNTPAction from app.plugins.builtin.mikrotik.set_ntp import MikrotikSetNTPAction +from app.plugins.builtin.ssh.debian_system_info import DebianSystemInfoAction from app.plugins.builtin.ssh.get_file import GetFileAction from app.plugins.builtin.ssh.get_timedatectl import GetTimedatectlAction from app.plugins.builtin.ssh.ssh_command import SSHCommandAction @@ -37,6 +38,7 @@ BUILTIN_ACTIONS: dict[str, BaseAction] = { for a in [ PingAction(), SSHCommandAction(), + DebianSystemInfoAction(), GetTimedatectlAction(), GetFileAction(), MikrotikGetConfigAction(),