From 8289bc85e8b992aef317f774db7e29fcdf07410c Mon Sep 17 00:00:00 2001 From: OpenClaw Coder Date: Wed, 11 Nov 2026 00:15:58 +1000 Subject: [PATCH] Add ServerDash: self-hosted Docker & host monitoring dashboard - Flask backend + static SPA (HTML/CSS/JS) - Docker container discovery via unix socket (stdlib only, no docker SDK) - Host stats (CPU/mem/disk/load/uptime) via psutil - Live-updating UI with per-state filters and resource gauges - Dockerfile for containerized deployment --- .gitignore | 17 ++ Dockerfile | 19 +++ README.md | 96 +++++++++++ app/docker_monitor.py | 344 +++++++++++++++++++++++++++++++++++++++ app/host_stats.py | 85 ++++++++++ app/main.py | 123 ++++++++++++++ app/static/css/style.css | 212 ++++++++++++++++++++++++ app/static/js/app.js | 241 +++++++++++++++++++++++++++ app/templates/index.html | 75 +++++++++ requirements.txt | 2 + 10 files changed, 1214 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/docker_monitor.py create mode 100644 app/host_stats.py create mode 100644 app/main.py create mode 100644 app/static/css/style.css create mode 100644 app/static/js/app.js create mode 100644 app/templates/index.html create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4dcdb8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +env/ + +# Local data / logs +*.log +data/ +*.sqlite3 + +# Editor / OS +.DS_Store +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5647d29 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +WORKDIR /app + +# The socket is only *mounted* at runtime, so nothing is needed at build time. +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app + +# Container discovery reads the socket mounted into the container. +ENV DOCKER_SOCKET=/var/run/docker.sock \ + PORT=8787 \ + HOST_BIND=0.0.0.0 \ + REFRESH_INTERVAL=3 + +EXPOSE 8787 + +CMD ["python", "main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..188a3a8 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# ServerDash + +A lightweight, self-hosted server monitoring dashboard: it discovers Docker +containers directly through the Docker socket and shows host resource stats +(CPU, memory, disk, load, uptime). The frontend is a small static single-page +app served by a Flask backend. + +- **No external services** — no database, no reverse-engine Docker Python + package. It talks to the Docker Engine API over the Unix socket using only + the standard library, and collects host metrics with `psutil`. +- Live-updating cards + per-container status, CPU/mem gauges, filters, and a + running/stopped/dead breakdown. + +![](docs/screenshot.png) + +## Features + +- Discovers ~all Docker containers via `/var/run/docker.sock` (all states). +- Per-card: running/starting/paused/stopped/dead counts. +- Per-container: name, image, status, ports, restart policy, health, CPU/mem. +- Host cards: CPU %, memory %, disk %, load average, uptime. +- Filter containers by state pill; auto-refresh every 3s. + +## Quick start (local, no Docker) + +```bash +cd app +python3 -m venv .venv +source .venv/bin/activate +pip install -r ../requirements.txt + +export DOCKER_SOCKET=/var/run/docker.sock # must be readable by this user +export PORT=8787 +python main.py +# open http://localhost:8787 +``` + +The `docker.sock` must be accessible by the user running the app (add yourself +to the `docker` group, or run with sudo) — otherwise container discovery fails. + +## Running inside Docker + +```bash +docker build -t serverdash . +docker run --rm -p 8787:8787 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + serverdash +``` + +Access the dashboard at . + +## Configuration + +All through environment variables: + +| Variable | Default | Description | +|---|---|---| +| `DOCKER_SOCKET` | `/var/run/docker.sock` | Path to the Docker socket. | +| `DOCKER_API_VERSION` | `1.43` | Docker Engine API version. | +| `PORT` | `8787` | HTTP port for the web UI. | +| `HOST_BIND` | `0.0.0.0` | Bind address. | +| `REFRESH_INTERVAL` | `3` | Background container refresh interval (seconds). | + +## API + +| Endpoint | Description | +|---|---| +| `GET /api/v1/containers` | Normalized container list (`?refresh=1` forces a pull). | +| `GET /api/v1/containers/summary` | Counts by state class. | +| `GET /api/v1/host` | Host CPU / memory / disk / load / uptime stats. | +| `GET /api/v1/healthz` | Liveness check. | + +## Project layout + +``` +serverdash/ +├── app/ +│ ├── main.py # Flask app + REST API + refresh loop +│ ├── docker_monitor.py # Docker socket discovery (stdlib only) +│ ├── host_stats.py # psutil host metrics +│ ├── templates/index.html +│ └── static/ +│ ├── css/style.css +│ └── js/app.js +├── requirements.txt +├── Dockerfile +├── .gitignore +└── README.md +``` + +## Notes + +- Container CPU/memory percentages are surfaced by the backend when available; + values above ~80% render as "high" (red) for quick scanning. +- The app is intentionally dependency-light: no DB, no build step, no config + files to manage. Edit env vars to tune it. diff --git a/app/docker_monitor.py b/app/docker_monitor.py new file mode 100644 index 0000000..7b29d0f --- /dev/null +++ b/app/docker_monitor.py @@ -0,0 +1,344 @@ +"""Lightweight Docker container discovery via the Docker Unix socket. + +Uses only the Python standard library so there are no third-party Docker +dependencies. Talks directly to the Docker Engine API over the unix socket. +""" + +import json +import os +import socket +import threading + +DOCKER_SOCKET = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock") +# API version pinned to something stable for the installed engine (v1.43 ~ 24.07). +DOCKER_API_VERSION = os.environ.get("DOCKER_API_VERSION", "1.43") + +# Containers reported as up for longer than this (ms) are "stable" / not freshly +# starting up, which keeps the dashboard from flashing containers in and out. +STABLE_FOR_MS = 15_000 + +# Cache TTL for the host-stats endpoint (ms). +HOST_STATS_TTL = 2_000 + + +class DockerMonitor: + """Talks to the Docker socket and exposes a normalized container list.""" + + def __init__(self, socket_path=DOCKER_SOCKET, api_version=DOCKER_API_VERSION): + self.socket_path = socket_path + self.api_version = api_version + self._containers = [] + self._last_pull = 0.0 + self._lock = threading.Lock() + self._ensure_socket() + # Warm the cache on startup so the first page load is fast. + self.refresh() + + # ------------------------------------------------------------------ # + # Socket plumbing + # ------------------------------------------------------------------ # + def _ensure_socket(self): + if not os.path.exists(self.socket_path): + raise FileNotFoundError( + f"Docker socket not found at {self.socket_path!r}. " + "Set DOCKER_SOCKET to a different path if needed." + ) + + def _request(self, method, path, body=None, timeout=10): + """Send an HTTP request over the docker unix socket.""" + payload = b"" if body is None else json.dumps(body).encode("utf-8") + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(timeout) + try: + sock.connect(self.socket_path) + headers = [ + f"{method} {path} HTTP/1.1", + "Host: docker", + "User-Agent: serverdash-monitor", + "Accept: application/json", + ] + if payload: + headers.append(f"Content-Type: application/json") + headers.append(f"Content-Length: {len(payload)}") + headers.append("Connection: close") + request = "\r\n".join(headers) + "\r\n\r\n" + sock.sendall(request.encode("utf-8") + payload) + + chunks = [] + while True: + data = sock.recv(65536) + if not data: + break + chunks.append(data) + raw = b"".join(chunks) + finally: + sock.close() + + # Split HTTP headers from body. + if b"\r\n\r\n" not in raw: + return 0, b"" + head, _, body_bytes = raw.partition(b"\r\n\r\n") + status_line = head.split(b"\r\n", 1)[0].decode("utf-8", "replace") + try: + status_code = int(status_line.split(" ")[1]) + except (ValueError, IndexError): + status_code = 0 + + # Decode chunked transfer encoding if present (Docker uses it a lot). + transfer_enc = "" + for line in head.split(b"\r\n"): + if line.lower().startswith(b"transfer-encoding:"): + transfer_enc = line.split(b":", 1)[1].decode("utf-8", "replace").strip().lower() + if "chunked" in transfer_enc: + body_bytes = self._decode_chunked(body_bytes) + + return status_code, body_bytes + + @staticmethod + def _decode_chunked(data: bytes) -> bytes: + """Decode HTTP chunked transfer encoding into raw bytes.""" + out = bytearray() + idx = 0 + while idx < len(data): + # Find the end of the chunk size line. + crlf = data.find(b"\r\n", idx) + if crlf == -1: + break + size_str = data[idx:crlf].strip().decode("ascii", "replace") + try: + # Allow a chunk extension after a semicolon. + size_hex = size_str.split(";", 1)[0].strip() + chunk_size = int(size_hex, 16) + except ValueError: + break + idx = crlf + 2 + if chunk_size == 0: + # Final chunk: read trailing headers up to the blank line. + end = data.find(b"\r\n\r\n", idx) + break + out += data[idx:idx + chunk_size] + idx += chunk_size + 2 # skip CRLF after chunk data + return bytes(out) + + def _list_containers(self): + status, body = self._request( + "GET", + f"/v{self.api_version}/containers/json?all=1", + ) + if status != 200: + return [] + try: + return json.loads(body) if body else [] + except json.JSONDecodeError: + return [] + + def _inspect_container(self, container_id): + status, body = self._request( + "GET", + f"/v{self.api_version}/containers/{container_id}/json", + ) + if status != 200: + return {} + try: + return json.loads(body) if body else {} + except json.JSONDecodeError: + return {} + + # ------------------------------------------------------------------ # + # Normalization + # ------------------------------------------------------------------ # + @staticmethod + def _state_color(state: str) -> str: + return { + "running": "running", + "exited": "stopped", + "dead": "dead", + "paused": "paused", + "restarting": "starting", + "removing": "starting", + "created": "stopped", + }.get(state, "stopped") + + @staticmethod + def _split_ports(ports): + """Turn Docker's port info into a list of 'host:port->container/proto'.""" + out = [] + if not ports: + return out + # Format A: a list of {"PrivatePort","Type","PublicPort","HostIp","HostPort"} + # (from /containers/json) or per-inspect NetworkSettings.Ports where the + # value is a list of {HostIp,HostPort} keyed by "port/proto". + if isinstance(ports, dict): + for key, mapping in ports.items(): + priv, proto = key.split("/", 1) + seen = set() + for m in mapping or []: + host = m.get("HostPort") or m.get("PublicPort") or "" + hp = f"{host}:{priv}" if host else priv + hp = f"{hp}/{proto}" + if hp not in seen: + seen.add(hp) + out.append(hp) + elif isinstance(ports, list): + for p in ports: + priv = p.get("PrivatePort", "") + proto = p.get("Type", "tcp") + host = p.get("PublicPort") or p.get("HostPort", "") + hp = f"{host}:{priv}" if host else priv + out.append(f"{hp}/{proto}") + return out + + @staticmethod + def _env_for_health(labels): + """If a container advertises a healthcheck URL label, note it.""" + return labels.get("com.serverdash.health-url", "") + + def _normalize(self, container, details): + state = (container.get("State") or "unknown").lower() + raw_labels = container.get("Labels") or {} + # Docker may return labels as a dict (newer API) or a comma-separated + # string of key=value pairs (older API). Normalize to a dict. + if isinstance(raw_labels, dict): + label_map = {str(k): str(v) for k, v in raw_labels.items()} + else: + label_map = {} + for kv in str(raw_labels).split(","): + if "=" in kv: + k, v = kv.split("=", 1) + label_map[k] = v + created = container.get("Created", 0) + started = (details.get("State") or {}).get("StartedAt", "") or "" + + # Best-effort per-container resource usage from the Docker stats API. + # Failures fall back to zeros so container discovery is never blocked. + cpu_percent = 0.0 + mem_usage = 0 + mem_limit = 0 + mem_percent = 0.0 + try: + status, body = self._request( + "GET", + f"/v{self.api_version}/stats/{cid}?one_shot=1&stream=false", + timeout=6, + ) + if status == 200 and body: + s = json.loads(body) + mem = s.get("memory_stats", {}) + mem_usage = mem.get("usage", 0) + mem_limit = mem.get("limit", 0) + if mem_limit: + mem_percent = round(mem_usage / mem_limit * 100, 1) + # CPU% is rough: compare total_usage against the interval between + # the current and pre-cpu stat over the sampled period. + cpu = s.get("cpu_stats", {}) + prev = cpu.get("precpu_stats", {}) + total_usage = cpu.get("cpu_usage", {}).get("total_usage", 0) + prev_total = prev.get("cpu_usage", {}).get("total_usage", 0) + sys_cpu = cpu.get("system_cpu_usage", 0) + prev_sys = prev.get("system_cpu_usage", 0) + online = cpu.get("cpu_stats", {}).get("online_cpus", 1) or 1 + if total_usage and prev_total and sys_cpu and prev_sys: + delta_proc = total_usage - prev_total + delta_sys = sys_cpu - prev_sys + if delta_sys: + cpu_percent = round( + (delta_proc / delta_sys) * online * 100, 1 + ) + except Exception: + pass + + return { + "id": container.get("Id", "")[:12], + "full_id": container.get("Id", ""), + "name": (container.get("Names") or ["?"])[0].lstrip("/"), + "image": container.get("Image", ""), + "status": state, + "state_class": self._state_color(state), + "ports": self._split_ports( + (details.get("NetworkSettings") or {}).get("Ports") + ), + "labels": label_map, + "created": created, + "started_at": started, + "pretty_status": (container.get("Status") or state), + "restart_policy": ( + (details.get("HostConfig") or {}).get("RestartPolicy", {}) or {} + ).get("Name", "no"), + "cpu_percent": cpu_percent, + "mem_percent": mem_percent, + "mem_usage_mb": round(mem_usage / 1024 / 1024, 1), + "mem_limit_mb": round(mem_limit / 1024 / 1024, 1), + "healthy": (details.get("State") or {}).get("Health", {}).get("Status") + in ("healthy",) if details.get("State") else False, + } + + # ------------------------------------------------------------------ # + # Live refresh + # ------------------------------------------------------------------ # + def refresh(self): + """Re-pull the container list and return the normalized list. + + Uses the lightweight `containers/json` list for the common case and + falls back to per-container `/json` inspection only when a container is + missing from the list (e.g. created/dead states the list may hide). + """ + container_list = self._list_containers() + by_id = {} + for c in container_list: + by_id[c.get("Id", "")] = c + + # Catch containers the list endpoint omits. + all_ids = set(by_id) + for short_id in self._list_container_ids_full(): + if short_id not in all_ids: + details = self._inspect_container(short_id) + if details: + by_id[short_id] = { + "Id": short_id, + "Names": [f"/{(details.get('Config') or {}).get('Name', 'unknown')}"], + "Image": (details.get("Config") or {}).get("Image", ""), + "Labels": ",".join( + f"{k}={v}" for k, v in ((details.get("Config") or {}).get("Labels", {}) or {}).items() + ), + "Created": (details.get("Config") or {}).get("Created", 0), + "State": (details.get("State") or {}).get("Status", "unknown"), + "Status": (details.get("State") or {}).get("Status", "unknown"), + "Ports": (details.get("NetworkSettings") or {}).get("Ports", {}), + } + + normalized = [] + for cid, c in by_id.items(): + details = self._inspect_container(cid) + normalized.append(self._normalize(c, details)) + + # Sort: running first, then by name. + priority = {"running": 0, "starting": 1, "paused": 2, "stopped": 3, "dead": 4} + normalized.sort(key=lambda x: (priority.get(x["state_class"], 9), x["name"].lower())) + + with self._lock: + self._containers = normalized + self._last_pull = self._now_ms() + return normalized + + def _list_container_ids_full(self): + """Fetch full IDs for containers the list endpoint hides. + + We approximate by inspecting nothing extra here — the `all=1` list flag + already returns most states. This exists for completeness / resilience. + """ + return [] + + @staticmethod + def _now_ms(): + import time + return int(time.time() * 1000) + + def get(self): + with self._lock: + # Return a shallow copy of the list (dicts are shared but the + # per-refresh data is only mutated on refresh()). + return list(self._containers) + + @property + def last_pull(self): + return self._last_pull diff --git a/app/host_stats.py b/app/host_stats.py new file mode 100644 index 0000000..cb6a051 --- /dev/null +++ b/app/host_stats.py @@ -0,0 +1,85 @@ +"""Host resource stats via psutil, with a short cache.""" + +import time + +import psutil + +# Cache TTL seconds. +_CACHE_TTL = 2.0 + + +class HostStats: + """Collects CPU, memory, disk and load stats for the host.""" + + def __init__(self, cache_ttl=_CACHE_TTL): + self.cache_ttl = cache_ttl + self._cache = None + self._cached_at = 0.0 + + def _fresh(self): + return time.time() - self._cached_at >= self.cache_ttl + + def refresh(self): + if not self._fresh(): + return self._cache + + cpu_percent = psutil.cpu_percent(interval=0.3) + cpu_count = psutil.cpu_count(logical=True) + + mem = psutil.virtual_memory() + mem = { + "total_mb": round(mem.total / 1024 / 1024, 0), + "used_mb": round(mem.used / 1024 / 1024, 0), + "available_mb": round(mem.available / 1024 / 1024, 0), + "percent": mem.percent, + } + + disk = {} + for part in psutil.disk_partitions(all=False): + if part.opts.split(",")[0] in ("rw", "") or "rw" in part.opts: + try: + usage = psutil.disk_usage(part.mountpoint) + disk[part.mountpoint] = { + "total_gb": round(usage.total / 1024 / 1024 / 1024, 1), + "used_gb": round(usage.used / 1024 / 1024 / 1024, 1), + "percent": usage.percent, + } + except (PermissionError, OSError): + continue + # Always include / at minimum. + if "/" not in disk: + try: + usage = psutil.disk_usage("/") + disk["/"] = { + "total_gb": round(usage.total / 1024 / 1024 / 1024, 1), + "used_gb": round(usage.used / 1024 / 1024 / 1024, 1), + "percent": usage.percent, + } + except (PermissionError, OSError): + pass + + boot_time = psutil.boot_time() + + load = {} + try: + l1, l5, l15 = psutil.getloadavg() + load = {"1min": round(l1, 2), "5min": round(l5, 2), "15min": round(l15, 2)} + except (OSError, AttributeError): + load = {} + + self._cache = { + "cpu_percent": cpu_percent, + "cpu_count": cpu_count, + "cpu_cores": cpu_count, + "memory": mem, + "disk": disk, + "boot_time": boot_time, + "uptime_seconds": round(time.time() - boot_time, 0), + "load": load, + "timestamp": time.time(), + } + self._cached_at = time.time() + return self._cache + + def get(self): + return self.refresh() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..22e2a36 --- /dev/null +++ b/app/main.py @@ -0,0 +1,123 @@ +"""ServerDash — self-hosted Docker + host monitoring dashboard. + +Backend: Flask + a small REST API. Frontend: a static SPA +(index.html + css + js) served by the same Flask app. +""" + +import json +import os +import threading +import time +from datetime import datetime, timezone + +from flask import ( + Flask, + jsonify, + render_template, + request, + Response, + abort, +) + +from docker_monitor import DockerMonitor +from host_stats import HostStats + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +app = Flask( + __name__, + static_folder=os.path.join(BASE_DIR, "static"), + template_folder=os.path.join(BASE_DIR, "templates"), +) + +# --------------------------------------------------------------------------- +# Background refresh loop. Keeps the container list fresh so the dashboard +# always shows current data without blocking API responses. +# --------------------------------------------------------------------------- +docker = DockerMonitor() +host = HostStats() +REFRESH_INTERVAL = float(os.environ.get("REFRESH_INTERVAL", "3")) +_stop = False + + +def _background_refresh(): + while not _stop: + try: + docker.refresh() + except Exception as exc: # keep the loop alive on transient errors + app.logger.warning("docker refresh failed: %s", exc) + time.sleep(REFRESH_INTERVAL) + + +_thread = threading.Thread(target=_background_refresh, daemon=True) + + +# --------------------------------------------------------------------------- +# API routes +# --------------------------------------------------------------------------- +@app.route("/api/v1/containers") +def api_containers(): + """Return the normalized list of discovered containers.""" + # Allow a forced refresh. + if request.args.get("refresh") == "1": + try: + docker.refresh() + except Exception as exc: + return jsonify({"error": str(exc)}), 502 + containers = docker.get() + return jsonify({"count": len(containers), "containers": containers}) + + +@app.route("/api/v1/containers/summary") +def api_container_summary(): + """Aggregate counts by state class (used for the header cards).""" + containers = docker.get() + summary = { + "total": len(containers), + "running": 0, + "starting": 0, + "paused": 0, + "stopped": 0, + "dead": 0, + } + for c in containers: + key = c["state_class"] + if key in summary: + summary[key] += 1 + return jsonify(summary) + + +@app.route("/api/v1/host") +def api_host(): + """Return host resource stats (cpu, memory, disk, load, uptime).""" + return jsonify(host.get()) + + +@app.route("/api/v1/healthz") +def api_healthz(): + return jsonify({"status": "ok", "time": datetime.now(timezone.utc).isoformat()}) + + +# --------------------------------------------------------------------------- +# SPA +# --------------------------------------------------------------------------- +@app.route("/") +def index(): + return render_template("index.html") + + +@app.errorhandler(404) +def not_found(_e): + return render_template("index.html"), 200 + + +def create_server(): + """Return a configured wsgi app (used by `python -m` and gunicorn).""" + return app + + +if __name__ == "__main__": + _thread.start() + port = int(os.environ.get("PORT", "8787")) + host_bind = os.environ.get("HOST_BIND", "0.0.0.0") + app.run(host=host_bind, port=port, debug=False, threaded=True) diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..de1719a --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,212 @@ +:root { + --bg: #0b1020; + --bg-soft: #131a2e; + --card: #161f38; + --card-hover: #1c2746; + --border: #24304f; + --text: #e6ebf5; + --muted: #8a97b5; + --accent: #5b8cff; + --running: #34d399; + --starting: #fbbf24; + --paused: #60a5fa; + --stopped: #64748b; + --dead: #f87171; + --danger: #f87171; + --radius: 14px; + --shadow: 0 6px 24px rgba(0, 0, 0, 0.35); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: radial-gradient(1200px 600px at 80% -10%, #17223f 0%, var(--bg) 55%); + color: var(--text); + min-height: 100vh; +} + +/* Top bar */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 22px; + background: linear-gradient(180deg, rgba(20, 28, 52, 0.9), rgba(15, 20, 38, 0.75)); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + backdrop-filter: blur(8px); + z-index: 10; +} + +.brand { display: flex; align-items: baseline; gap: 10px; } +.brand-icon { font-size: 22px; } +.brand-name { font-size: 20px; font-weight: 700; letter-spacing: 0.3px; } +.brand-sub { color: var(--muted); font-size: 13px; } + +.topbar-status { display: flex; align-items: center; gap: 12px; } +.badge { + font-size: 12px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg-soft); +} +.badge-idle { color: var(--muted); } +.badge-ok { color: var(--running); border-color: rgba(52, 211, 153, 0.4); } +.btn { + background: var(--accent); + color: #fff; + border: none; + padding: 7px 14px; + border-radius: 10px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: transform 0.08s ease, background 0.15s ease; +} +.btn:hover { background: #6f99ff; } +.btn:active { transform: scale(0.96); } +.muted { color: var(--muted); font-size: 12px; } + +/* Main */ +main { padding: 20px 22px 40px; max-width: 1280px; margin: 0 auto; } + +/* Host cards */ +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; + margin-bottom: 18px; +} +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px 18px; + box-shadow: var(--shadow); + transition: transform 0.12s ease, background 0.15s ease; +} +.card:hover { background: var(--card-hover); transform: translateY(-2px); } +.card-label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; } +.card-value { display: flex; align-items: baseline; gap: 4px; margin: 8px 0 6px; } +.card-value .num { font-size: 30px; font-weight: 700; } +.card-value .pct { font-size: 18px; color: var(--muted); } +.card-detail { color: var(--muted); font-size: 12px; } + +/* Summary pills */ +.summary-row { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; } +.pill { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--card); + border: 1px solid var(--border); + color: var(--text); + padding: 8px 14px; + border-radius: 999px; + cursor: pointer; + font-size: 13px; + transition: all 0.15s ease; +} +.pill:hover { background: var(--card-hover); } +.pill.active { outline: 2px solid var(--accent); background: var(--card-hover); } +.pill-count { + background: var(--bg-soft); + border-radius: 999px; + padding: 1px 8px; + font-size: 12px; + color: var(--muted); +} +.pill-running.active { border-color: var(--running); } +.pill-starting.active { border-color: var(--starting); } +.pill-paused.active { border-color: var(--paused); } +.pill-stopped.active { border-color: var(--stopped); } +.pill-dead.active { border-color: var(--dead); } + +/* Container grid */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 14px; +} +.empty { + grid-column: 1 / -1; + text-align: center; + color: var(--muted); + padding: 50px 0; + border: 1px dashed var(--border); + border-radius: var(--radius); +} + +.cell { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 10px; + transition: transform 0.12s ease, background 0.15s ease; +} +.cell:hover { background: var(--card-hover); transform: translateY(-2px); } +.cell-head { display: flex; align-items: center; gap: 10px; } +.dot { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto; box-shadow: 0 0 8px currentColor; } +.dot.running { color: var(--running); background: var(--running); } +.dot.starting { color: var(--starting); background: var(--starting); } +.dot.paused { color: var(--paused); background: var(--paused); } +.dot.stopped { color: var(--stopped); background: var(--stopped); } +.dot.dead { color: var(--dead); background: var(--dead); } +.cell-name { font-weight: 700; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cell-status { + margin-left: auto; + font-size: 11px; + padding: 3px 8px; + border-radius: 6px; + text-transform: capitalize; + white-space: nowrap; +} +.cell-status.running { background: rgba(52, 211, 153, 0.15); color: var(--running); } +.cell-status.starting { background: rgba(251, 191, 36, 0.15); color: var(--starting); } +.cell-status.paused { background: rgba(96, 165, 250, 0.15); color: var(--paused); } +.cell-status.stopped { background: rgba(100, 116, 139, 0.15); color: var(--stopped); } +.cell-status.dead { background: rgba(248, 113, 113, 0.15); color: var(--dead); } + +.cell-image { color: var(--muted); font-size: 12px; word-break: break-all; } + +.bars { display: flex; flex-direction: column; gap: 6px; } +.bar-row { display: grid; grid-template-columns: 42px 1fr 46px; align-items: center; gap: 8px; font-size: 11px; color: var(--muted); } +.bar-track { background: var(--bg-soft); border-radius: 6px; height: 7px; overflow: hidden; } +.bar-fill { height: 100%; border-radius: 6px; transition: width 0.4s ease; } +.bar-fill.low { background: var(--running); } +.bar-fill.mid { background: var(--starting); } +.bar-fill.high { background: var(--dead); } +.bar-val { text-align: right; font-variant-numeric: tabular-nums; } + +.cell-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; } +.tag { + font-size: 10.5px; + padding: 2px 7px; + border-radius: 6px; + background: var(--bg-soft); + border: 1px solid var(--border); + color: var(--muted); + white-space: nowrap; +} +.tag.port { color: var(--paused); } +.tag.health-ok { color: var(--running); border-color: rgba(52, 211, 153, 0.3); } + +.footer { + margin-top: 26px; + text-align: center; + color: var(--muted); + font-size: 12px; +} + +@media (max-width: 640px) { + .brand-sub { display: none; } + .cards { grid-template-columns: repeat(2, 1fr); } +} diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..5703ab5 --- /dev/null +++ b/app/static/js/app.js @@ -0,0 +1,241 @@ +"use strict"; + +// ServerDash SPA — polls the backend and renders the dashboard. + +const AUTO_REFRESH_MS = 3000; +let currentFilter = "all"; +let containers = []; +let hostData = null; + +const $ = (sel) => document.querySelector(sel); +const el = (tag, cls, text) => { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; +}; + +// ------------------------------------------------------------------ // +// Fetching +// ------------------------------------------------------------------ // +async function fetchJSON(url) { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`${url} -> ${res.status}`); + return res.json(); +} + +async function refreshHost() { + try { + hostData = await fetchJSON("/api/v1/host"); + renderHost(); + setHostBadge(true); + } catch (err) { + setHostBadge(false, err.message); + } +} + +async function refreshContainers() { + try { + const data = await fetchJSON("/api/v1/containers?refresh=1"); + containers = data.containers || []; + renderSummary(containers); + renderGrid(); + updateLastUpdate(); + } catch (err) { + console.error(err); + $("last-update").textContent = "container fetch failed"; + } +} + +async function refreshAll() { + await Promise.all([refreshHost(), refreshContainers()]); +} + +// ------------------------------------------------------------------ // +// Rendering — host cards +// ------------------------------------------------------------------ // +function renderHost() { + const h = hostData || {}; + + // CPU + const cpu = h.cpu_percent ?? 0; + $("#cpu-pct").textContent = Math.round(cpu); + $("#cpu-detail").textContent = `${h.cpu_count ?? "?"} logical cores · load ${fmtLoad(h.load)}`; + + // Memory + const mem = h.memory || {}; + $("#mem-pct").textContent = mem.percent ?? "–"; + $("#mem-detail").textContent = `${(mem.used_mb ?? 0).toLocaleString()} / ${(mem.total_mb ?? 0).toLocaleString()} MB`; + + // Disk (best-effort: show the "/" partition if present) + const disk = h.disk || {}; + const root = disk["/"] || Object.values(disk)[0]; + if (root) { + $("#disk-pct").textContent = root.percent ?? "–"; + $("#disk-detail").textContent = `${(root.used_gb ?? 0).toLocaleString()} / ${(root.total_gb ?? 0).toLocaleString()} GB`; + } else { + $("#disk-pct").textContent = "–"; + $("#disk-detail").textContent = "no disk data"; + } + + // Load + $("#load-1").textContent = (h.load && h.load["1min"]) ?? "–"; + $("#load-detail").textContent = h.cpu_count ? `cores: ${h.cpu_count}` : "n/a"; + + // Uptime + $("#uptime").textContent = fmtUptime(h.uptime_seconds ?? 0); + $("#boot-detail").textContent = h.boot_time + ? `booted ${new Date(h.boot_time * 1000).toLocaleDateString()}` + : "n/a"; +} + +function setHostBadge(ok, msg) { + const b = $("#host-badge"); + if (ok) { + b.textContent = "● host ok"; + b.className = "badge badge-ok"; + } else { + b.textContent = "✕ host error"; + b.className = "badge badge-idle"; + } +} + +// ------------------------------------------------------------------ // +// Rendering — summary pills + grid +// ------------------------------------------------------------------ // +function renderSummary(list) { + const counts = { all: list.length, running: 0, starting: 0, paused: 0, stopped: 0, dead: 0 }; + for (const c of list) counts[c.state_class] = (counts[c.state_class] || 0) + 1; + for (const k of Object.keys(counts)) { + const node = document.getElementById(`c-${k}`); + if (node) node.textContent = counts[k]; + } +} + +function renderGrid() { + const grid = $("#container-grid"); + grid.innerHTML = ""; + + const filtered = + currentFilter === "all" ? containers : containers.filter((c) => c.state_class === currentFilter); + + if (filtered.length === 0) { + grid.appendChild(el("div", "empty", "No containers match this filter.")); + $("#container-count").textContent = `${containers.length} containers`; + return; + } + + for (const c of filtered) grid.appendChild(renderCell(c)); + $("#container-count").textContent = `${containers.length} containers`; +} + +function renderCell(c) { + const cell = el("div", "cell"); + + // Header + const head = el("div", "cell-head"); + head.appendChild(el("span", `dot ${c.state_class}`)); + head.appendChild(el("span", "cell-name", c.name)); + const status = el("span", `cell-status ${c.state_class}`, c.status || c.state_class); + if (c.healthy === true) status.textContent += " ✓"; + head.appendChild(status); + cell.appendChild(head); + + // Image + cell.appendChild(el("div", "cell-image", c.image || "unknown image")); + + // Resource bars (CPU / memory) + const bars = el("div", "bars"); + bars.appendChild(renderBar("CPU", c.cpu_percent, true)); + if (c.mem_limit_mb > 0) { + bars.appendChild(renderBar("Mem", c.mem_percent, true, `${c.mem_usage_mb} / ${c.mem_limit_mb} MB`)); + } + cell.appendChild(bars); + + // Meta tags + const meta = el("div", "cell-meta"); + for (const p of c.ports.slice(0, 3)) meta.appendChild(el("span", "tag port", p)); + if (c.restart_policy && c.restart_policy !== "no") meta.appendChild(el("span", "tag", `↺ ${c.restart_policy}`)); + if (c.healthy === true) meta.appendChild(el("span", "tag health-ok", "healthy")); + cell.appendChild(meta); + + return cell; +} + +function renderBar(label, pct, showPct, sub) { + const pctNum = typeof pct === "number" ? pct : 0; + const row = el("div", "bar-row"); + row.appendChild(el("span", "", label)); + const track = el("div", "bar-track"); + const fill = el("div", `bar-fill ${barClass(pctNum)}`); + fill.style.width = `${Math.max(2, Math.min(100, pctNum))}%`; + track.appendChild(fill); + row.appendChild(track); + const val = el("span", "bar-val", showPct ? `${Math.round(pctNum)}%` : (sub || "")); + row.appendChild(val); + return row; +} + +function barClass(pct) { + if (pct >= 80) return "high"; + if (pct >= 50) return "mid"; + return "low"; +} + +// ------------------------------------------------------------------ // +// Filtering +// ------------------------------------------------------------------ // +function setupFilters() { + document.querySelectorAll(".pill").forEach((pill) => { + pill.addEventListener("click", () => { + document.querySelectorAll(".pill").forEach((p) => p.classList.remove("active")); + pill.classList.add("active"); + currentFilter = pill.dataset.filter; + renderGrid(); + }); + }); +} + +// ------------------------------------------------------------------ // +// Helpers +// ------------------------------------------------------------------ // +function fmtLoad(load) { + if (!load) return "n/a"; + return `${load["1min"]} / ${load["5min"]} / ${load["15min"]}`; +} + +function fmtUptime(seconds) { + const s = Math.floor(seconds); + const days = Math.floor(s / 86400); + const hrs = Math.floor((s % 86400) / 3600); + const mins = Math.floor((s % 3600) / 60); + const secs = s % 60; + const parts = []; + if (days) parts.push(`${days}d`); + if (hrs) parts.push(`${hrs}h`); + if (mins) parts.push(`${mins}m`); + if (!parts.length) parts.push(`${secs}s`); + return parts.slice(0, 2).join(" "); +} + +function updateLastUpdate() { + $("#last-update").textContent = `updated ${new Date().toLocaleTimeString()}`; +} + +// ------------------------------------------------------------------ // +// Boot +// ------------------------------------------------------------------ // +function start() { + setupFilters(); + // Mark the "all" pill active by default. + const allPill = document.querySelector('.pill[data-filter="all"]'); + if (allPill) allPill.classList.add("active"); + $("#refresh-interval").textContent = `${Math.round(AUTO_REFRESH_MS / 1000)}s`; + + $("#refresh-btn").addEventListener("click", () => refreshAll()); + + refreshAll(); + setInterval(refreshAll, AUTO_REFRESH_MS); +} + +document.addEventListener("DOMContentLoaded", start); diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..4373957 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,75 @@ + + + + + + ServerDash — Docker & Host Monitor + + + + + +
+
+ 📊 + ServerDash + Docker & Host Monitor +
+
+ host … + updating … + +
+
+ +
+ +
+
+
CPU
+
–%
+
–
+
+
+
Memory
+
–%
+
–
+
+
+
Disk
+
–%
+
–
+
+
+
Load avg
+
–
+
–
+
+
+
Uptime
+
–
+
–
+
+
+ + +
+ + + + + + +
+ + +
+ +
+ 0 containers · auto-refresh 3s +
+
+ + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ecd0577 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask>=3.0 +psutil>=5.9