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
This commit is contained in:
OpenClaw Coder committed 2026-11-11 00:15:58 +10:00
commit 8289bc85e8
10 files changed
+1214

No files matched your search

+17
View File
@@ -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/
+19
View File
@@ -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"]
+96
View File
@@ -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 <http://localhost:8787>.
## 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.
+344
View File
@@ -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
+85
View File
@@ -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()
+123
View File
@@ -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)
+212
View File
@@ -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); }
}
+241
View File
@@ -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);
+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ServerDash — Docker &amp; Host Monitor</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='14' font-size='14'>📊</text></svg>" />
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-icon">📊</span>
<span class="brand-name">ServerDash</span>
<span class="brand-sub">Docker &amp; Host Monitor</span>
</div>
<div class="topbar-status">
<span id="host-badge" class="badge badge-idle">host …</span>
<span id="last-update" class="muted">updating …</span>
<button id="refresh-btn" class="btn" title="Refresh now">⟳ Refresh</button>
</div>
</header>
<main>
<!-- Host summary cards -->
<section class="cards" id="host-cards" aria-label="Host stats">
<div class="card" data-metric="cpu">
<div class="card-label">CPU</div>
<div class="card-value"><span class="num" id="cpu-pct">–</span><span class="pct">%</span></div>
<div class="card-detail" id="cpu-detail">–</div>
</div>
<div class="card" data-metric="memory">
<div class="card-label">Memory</div>
<div class="card-value"><span class="num" id="mem-pct">–</span><span class="pct">%</span></div>
<div class="card-detail" id="mem-detail">–</div>
</div>
<div class="card" data-metric="disk">
<div class="card-label">Disk</div>
<div class="card-value"><span class="num" id="disk-pct">–</span><span class="pct">%</span></div>
<div class="card-detail" id="disk-detail">–</div>
</div>
<div class="card" data-metric="load">
<div class="card-label">Load avg</div>
<div class="card-value"><span class="num" id="load-1">–</span></div>
<div class="card-detail" id="load-detail">–</div>
</div>
<div class="card" data-metric="uptime">
<div class="card-label">Uptime</div>
<div class="card-value"><span class="num" id="uptime">–</span></div>
<div class="card-detail" id="boot-detail">–</div>
</div>
</section>
<!-- Container summary pills -->
<section class="summary-row">
<button class="pill" data-filter="all">All <span class="pill-count" id="c-all">0</span></button>
<button class="pill pill-running" data-filter="running">Running <span class="pill-count" id="c-running">0</span></button>
<button class="pill pill-starting" data-filter="starting">Starting <span class="pill-count" id="c-starting">0</span></button>
<button class="pill pill-paused" data-filter="paused">Paused <span class="pill-count" id="c-paused">0</span></button>
<button class="pill pill-stopped" data-filter="stopped">Stopped <span class="pill-count" id="c-stopped">0</span></button>
<button class="pill pill-dead" data-filter="dead">Dead <span class="pill-count" id="c-dead">0</span></button>
</section>
<!-- Container grid -->
<section class="grid" id="container-grid" aria-label="Containers"></section>
<footer class="footer">
<span id="container-count">0 containers</span> · auto-refresh <span id="refresh-interval">3s</span>
</footer>
</main>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
Flask>=3.0
psutil>=5.9