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:
commit
8289bc85e8
10 files changed
+1214
No files matched your search
+17
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 & 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 & 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>
|
||||
@@ -0,0 +1,2 @@
|
||||
Flask>=3.0
|
||||
psutil>=5.9
|
||||
Reference in new issue
Block a user