From 94e79becd833b164dba1b34faaad3c662668fda6 Mon Sep 17 00:00:00 2001 From: steffen Date: Mon, 15 Jun 2026 20:07:55 +0200 Subject: [PATCH] feat: grouped server cards with progress bars and sparklines - Agent sends group name (defaults to hostname) and richer metric notes (memory/disk include absolute GB values; CPU note includes system uptime) - push() accepts group param; server stores it as agent_group on deployments - Upsert key is now (company_id, agent_group, name) so two agents on the same company but different servers don't overwrite each other's CPU/Memory - Add widget config packs: linux-base, web-nginx, database-postgres/redis, docker-host, custom-cmd examples --- ethica-agent.example.yml | 6 +++- ethica-agent.py | 53 ++++++++++++++++++++++++++++------- widgets/custom-cmd.yml | 33 ++++++++++++++++++++++ widgets/database-postgres.yml | 21 ++++++++++++++ widgets/database-redis.yml | 17 +++++++++++ widgets/docker-host.yml | 20 +++++++++++++ widgets/linux-base.yml | 19 +++++++++++++ widgets/web-nginx.yml | 22 +++++++++++++++ 8 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 widgets/custom-cmd.yml create mode 100644 widgets/database-postgres.yml create mode 100644 widgets/database-redis.yml create mode 100644 widgets/docker-host.yml create mode 100644 widgets/linux-base.yml create mode 100644 widgets/web-nginx.yml diff --git a/ethica-agent.example.yml b/ethica-agent.example.yml index 6fedd3e..3073a94 100644 --- a/ethica-agent.example.yml +++ b/ethica-agent.example.yml @@ -4,9 +4,13 @@ # Get it from: admin panel → Company → API tab → Company token # # token: REPLACE_ME ← or set ETHICA_TOKEN in .env / environment -# home: https://ethica.no ← or set ETHICA_HOME (default: https://ethica.no) +# home: https://ethica.no ← or set ETHICA_HOME (default: https://ethica.no) # interval: 60 ← or set ETHICA_INTERVAL (seconds, default: 60) +# group: my-server ← or set ETHICA_GROUP (default: system hostname) +# All metrics from this agent are grouped under this +# name in the Ethica dashboard (shows as a server card). +group: my-server interval: 60 widgets: diff --git a/ethica-agent.py b/ethica-agent.py index 823a75c..34dcba1 100644 --- a/ethica-agent.py +++ b/ethica-agent.py @@ -211,19 +211,48 @@ def run_widget(w: dict) -> tuple: if wtype == "system_cpu": pct = cpu_percent() + uptime_note = "" + if SYSTEM == "Linux": + try: + with open("/proc/uptime") as f: + secs = float(f.readline().split()[0]) + days = int(secs // 86400) + hours = int((secs % 86400) // 3600) + uptime_note = f" up {days}d{hours}h" + except Exception: + pass status = _pct_status(pct, w.get("degrade_threshold", 80), w.get("offline_threshold", 95)) - return name, kind, status, None, "", f"cpu {pct}%" + return name, kind, status, None, "", f"cpu {pct}%{uptime_note}" if wtype == "system_memory": pct = mem_percent() + abs_note = "" + if SYSTEM == "Linux": + try: + info: dict = {} + with open("/proc/meminfo") as f: + for line in f: + k, _, v = line.partition(":") + info[k.strip()] = int(v.split()[0]) + total_kb = info.get("MemTotal", 0) + avail_kb = info.get("MemAvailable", 0) + used_gb = round((total_kb - avail_kb) / 1048576, 0) + total_gb = round(total_kb / 1048576, 0) + abs_note = f" {int(used_gb)}/{int(total_gb)}GB" + except Exception: + pass status = _pct_status(pct, w.get("degrade_threshold", 85), w.get("offline_threshold", 97)) - return name, kind, status, None, "", f"mem {pct}%" + return name, kind, status, None, "", f"mem {pct}%{abs_note}" if wtype == "system_disk": path = w.get("path", "/") - pct = disk_percent(path) + u = shutil.disk_usage(path) + pct = round(100.0 * u.used / u.total, 1) + total_gb = round(u.total / 1e9, 0) + used_gb = round(u.used / 1e9, 0) + abs_note = f" {int(used_gb)}/{int(total_gb)}GB" status = _pct_status(pct, w.get("degrade_threshold", 80), w.get("offline_threshold", 95)) - return name, "system", status, None, "", f"disk {pct}% ({path})" + return name, "system", status, None, "", f"disk {pct}%{abs_note} ({path})" if wtype == "http_check": url = w["url"] @@ -276,12 +305,14 @@ def run_widget(w: dict) -> tuple: # ── Push ─────────────────────────────────────────────────────────────────────── def push(home: str, token: str, name: str, kind: str, status: str, - latency_ms, version: str, note: str) -> int: + latency_ms, version: str, note: str, group: str = "") -> int: url = home.rstrip("/") + "/api/company/metrics" payload: dict = {"name": name, "kind": kind, "status": status, "version": version, "note": note} if latency_ms is not None: payload["latency_ms"] = latency_ms + if group: + payload["group"] = group data = json.dumps(payload).encode() req = urllib.request.Request( url, data=data, method="POST", @@ -313,10 +344,12 @@ def main(): # ETHICA_TOKEN : company API token (required) # ETHICA_HOME : home URL (optional, default https://ethica.no) # ETHICA_INTERVAL: collection interval in seconds (optional) - token = os.environ.get("ETHICA_TOKEN") or cfg.get("token", "") - home = os.environ.get("ETHICA_HOME") or cfg.get("home", "https://ethica.no") - interval = int(os.environ.get("ETHICA_INTERVAL") or cfg.get("interval", 60)) - widgets = cfg.get("widgets", []) + # ETHICA_GROUP : server/group label shown in the dashboard card (default: hostname) + token = os.environ.get("ETHICA_TOKEN") or cfg.get("token", "") + home = os.environ.get("ETHICA_HOME") or cfg.get("home", "https://ethica.no") + interval = int(os.environ.get("ETHICA_INTERVAL") or cfg.get("interval", 60)) + agent_group = os.environ.get("ETHICA_GROUP") or cfg.get("group", platform.node()) + widgets = cfg.get("widgets", []) if not token or token == "REPLACE_ME": print("Set ETHICA_TOKEN env var or 'token' in config " @@ -334,7 +367,7 @@ def main(): label = w.get("name", w.get("type", "?")) try: name, kind, status, lat, ver, note = run_widget(w) - code = push(home, token, name, kind, status, lat, ver, note) + code = push(home, token, name, kind, status, lat, ver, note, agent_group) print(f" [{status:<9}] {name} → {code}") except NotImplementedError as e: print(f" [SKIP ] {label}: {e}", file=sys.stderr) diff --git a/widgets/custom-cmd.yml b/widgets/custom-cmd.yml new file mode 100644 index 0000000..50a9417 --- /dev/null +++ b/widgets/custom-cmd.yml @@ -0,0 +1,33 @@ +# Custom command examples. +# Exit code convention: 0 = online, 1 = degraded, anything else = offline. + +widgets: + # Health endpoint that returns HTTP 200 + - type: custom_cmd + name: App health + cmd: curl -sf https://myapp.example.com/health > /dev/null && exit 0 || exit 2 + + # Disk space on a non-root mount + - type: custom_cmd + name: Disk /backup + cmd: | + PCT=$(df /backup | awk 'NR==2{gsub(/%/,"",$5); print $5}') + [ "$PCT" -gt 90 ] && exit 2 + [ "$PCT" -gt 80 ] && exit 1 || exit 0 + + # SSL certificate expiry (degraded < 14 days, offline < 3 days) + - type: custom_cmd + name: SSL expiry + cmd: | + DAYS=$(echo | openssl s_client -connect myapp.example.com:443 2>/dev/null \ + | openssl x509 -noout -enddate 2>/dev/null \ + | sed 's/notAfter=//' \ + | xargs -I{} python3 -c "import datetime; e=datetime.datetime.strptime('{}','%b %d %H:%M:%S %Y %Z'); print((e-datetime.datetime.utcnow()).days)" 2>/dev/null) + [ -z "$DAYS" ] && exit 2 + [ "$DAYS" -lt 3 ] && exit 2 + [ "$DAYS" -lt 14 ] && exit 1 || exit 0 + + # Systemd service check + - type: custom_cmd + name: my-service.service + cmd: systemctl is-active --quiet my-service && exit 0 || exit 2 diff --git a/widgets/database-postgres.yml b/widgets/database-postgres.yml new file mode 100644 index 0000000..7769f24 --- /dev/null +++ b/widgets/database-postgres.yml @@ -0,0 +1,21 @@ +# PostgreSQL — process check + port + optional replication lag custom check. + +widgets: + - type: process_check + name: PostgreSQL + process: postgres + + - type: port_check + name: PG Port + host: localhost + port: 5432 + timeout: 5 + + # Optional: check replication lag (requires psql access). + # Exit 0 = ok, exit 1 = lag > threshold, exit 2 = cannot connect. + # - type: custom_cmd + # name: PG Replication lag + # cmd: | + # LAG=$(psql -U postgres -tAc "SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))::int" 2>/dev/null) + # [ -z "$LAG" ] && exit 2 + # [ "$LAG" -gt 300 ] && exit 1 || exit 0 diff --git a/widgets/database-redis.yml b/widgets/database-redis.yml new file mode 100644 index 0000000..7fa016e --- /dev/null +++ b/widgets/database-redis.yml @@ -0,0 +1,17 @@ +# Redis — process check + port + optional ping. + +widgets: + - type: process_check + name: Redis + process: redis-server + + - type: port_check + name: Redis Port + host: localhost + port: 6379 + timeout: 3 + + # Optional: PING check (requires redis-cli). + # - type: custom_cmd + # name: Redis PING + # cmd: redis-cli ping | grep -q PONG && exit 0 || exit 2 diff --git a/widgets/docker-host.yml b/widgets/docker-host.yml new file mode 100644 index 0000000..4caec18 --- /dev/null +++ b/widgets/docker-host.yml @@ -0,0 +1,20 @@ +# Docker host — disk for docker data dir + Docker daemon check. + +widgets: + - type: system_disk + name: Docker disk + path: /var/lib/docker + degrade_threshold: 75 + offline_threshold: 90 + + - type: custom_cmd + name: Docker daemon + cmd: docker info > /dev/null 2>&1 && exit 0 || exit 2 + + # Optional: count running containers (degraded if 0, offline if docker is down). + # - type: custom_cmd + # name: Containers running + # cmd: | + # COUNT=$(docker ps -q 2>/dev/null | wc -l) + # [ $? -ne 0 ] && exit 2 + # [ "$COUNT" -eq 0 ] && exit 1 || exit 0 diff --git a/widgets/linux-base.yml b/widgets/linux-base.yml new file mode 100644 index 0000000..d828e6b --- /dev/null +++ b/widgets/linux-base.yml @@ -0,0 +1,19 @@ +# Linux base — CPU, memory and disk. +# Paste into your ethica-agent.yml widgets section. + +widgets: + - type: system_cpu + name: CPU + degrade_threshold: 80 + offline_threshold: 95 + + - type: system_memory + name: Memory + degrade_threshold: 85 + offline_threshold: 97 + + - type: system_disk + name: Disk / + path: / + degrade_threshold: 80 + offline_threshold: 95 diff --git a/widgets/web-nginx.yml b/widgets/web-nginx.yml new file mode 100644 index 0000000..3892619 --- /dev/null +++ b/widgets/web-nginx.yml @@ -0,0 +1,22 @@ +# Nginx web server — process check + HTTP/HTTPS endpoint + ports. +# Replace MY_DOMAIN with the actual domain. + +widgets: + - type: process_check + name: nginx + process: nginx + + - type: http_check + name: HTTPS + url: https://MY_DOMAIN + timeout: 10 + + - type: port_check + name: Port 80 + host: localhost + port: 80 + + - type: port_check + name: Port 443 + host: localhost + port: 443