Files
OpenClaw Coder 8289bc85e8 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
2026-11-11 00:15:58 +10:00

124 lines
3.3 KiB
Python

"""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)