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
This commit is contained in:
@@ -6,7 +6,11 @@
|
|||||||
# token: REPLACE_ME ← or set ETHICA_TOKEN in .env / environment
|
# 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)
|
# 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
|
interval: 60
|
||||||
|
|
||||||
widgets:
|
widgets:
|
||||||
|
|||||||
+39
-6
@@ -211,19 +211,48 @@ def run_widget(w: dict) -> tuple:
|
|||||||
|
|
||||||
if wtype == "system_cpu":
|
if wtype == "system_cpu":
|
||||||
pct = cpu_percent()
|
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))
|
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":
|
if wtype == "system_memory":
|
||||||
pct = mem_percent()
|
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))
|
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":
|
if wtype == "system_disk":
|
||||||
path = w.get("path", "/")
|
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))
|
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":
|
if wtype == "http_check":
|
||||||
url = w["url"]
|
url = w["url"]
|
||||||
@@ -276,12 +305,14 @@ def run_widget(w: dict) -> tuple:
|
|||||||
# ── Push ───────────────────────────────────────────────────────────────────────
|
# ── Push ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def push(home: str, token: str, name: str, kind: str, status: str,
|
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"
|
url = home.rstrip("/") + "/api/company/metrics"
|
||||||
payload: dict = {"name": name, "kind": kind, "status": status,
|
payload: dict = {"name": name, "kind": kind, "status": status,
|
||||||
"version": version, "note": note}
|
"version": version, "note": note}
|
||||||
if latency_ms is not None:
|
if latency_ms is not None:
|
||||||
payload["latency_ms"] = latency_ms
|
payload["latency_ms"] = latency_ms
|
||||||
|
if group:
|
||||||
|
payload["group"] = group
|
||||||
data = json.dumps(payload).encode()
|
data = json.dumps(payload).encode()
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
url, data=data, method="POST",
|
url, data=data, method="POST",
|
||||||
@@ -313,9 +344,11 @@ def main():
|
|||||||
# ETHICA_TOKEN : company API token (required)
|
# ETHICA_TOKEN : company API token (required)
|
||||||
# ETHICA_HOME : home URL (optional, default https://ethica.no)
|
# ETHICA_HOME : home URL (optional, default https://ethica.no)
|
||||||
# ETHICA_INTERVAL: collection interval in seconds (optional)
|
# ETHICA_INTERVAL: collection interval in seconds (optional)
|
||||||
|
# ETHICA_GROUP : server/group label shown in the dashboard card (default: hostname)
|
||||||
token = os.environ.get("ETHICA_TOKEN") or cfg.get("token", "")
|
token = os.environ.get("ETHICA_TOKEN") or cfg.get("token", "")
|
||||||
home = os.environ.get("ETHICA_HOME") or cfg.get("home", "https://ethica.no")
|
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))
|
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", [])
|
widgets = cfg.get("widgets", [])
|
||||||
|
|
||||||
if not token or token == "REPLACE_ME":
|
if not token or token == "REPLACE_ME":
|
||||||
@@ -334,7 +367,7 @@ def main():
|
|||||||
label = w.get("name", w.get("type", "?"))
|
label = w.get("name", w.get("type", "?"))
|
||||||
try:
|
try:
|
||||||
name, kind, status, lat, ver, note = run_widget(w)
|
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}")
|
print(f" [{status:<9}] {name} → {code}")
|
||||||
except NotImplementedError as e:
|
except NotImplementedError as e:
|
||||||
print(f" [SKIP ] {label}: {e}", file=sys.stderr)
|
print(f" [SKIP ] {label}: {e}", file=sys.stderr)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user