Add public status dashboard at cloud.ladkau.de
Shows live service health (green/red links) and server CPU, RAM, and disk usage with progress bars. Reads host stats from /host/proc, checks service endpoints every 30 s, and serves a frosted-glass UI over a configurable background image (ansible/roles/dashboard/files/bg.jpg).
This commit is contained in:
@@ -3,14 +3,14 @@ Port 22
|
||||
Protocol 2
|
||||
|
||||
# Authentication
|
||||
PermitRootLogin no
|
||||
PermitRootLogin prohibit-password
|
||||
PasswordAuthentication no
|
||||
ChallengeResponseAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
AuthorizedKeysFile .ssh/authorized_keys
|
||||
|
||||
# Only allow the deploy user over SSH
|
||||
AllowUsers {{ deploy_user }}
|
||||
# Only allow the deploy user and root over SSH
|
||||
AllowUsers {{ deploy_user }} root
|
||||
|
||||
# Misc hardening
|
||||
X11Forwarding no
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,7 @@
|
||||
---
|
||||
- name: Restart dashboard
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ dashboard_data_dir }}"
|
||||
state: present
|
||||
pull: missing
|
||||
recreate: always
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
- name: Create dashboard data directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ dashboard_data_dir }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
tags: dashboard
|
||||
|
||||
- name: Deploy background image
|
||||
ansible.builtin.copy:
|
||||
src: bg.jpg
|
||||
dest: "{{ dashboard_data_dir }}/bg.jpg"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Restart dashboard
|
||||
tags: dashboard
|
||||
|
||||
- name: Deploy dashboard app
|
||||
ansible.builtin.template:
|
||||
src: app.py.j2
|
||||
dest: "{{ dashboard_data_dir }}/app.py"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Restart dashboard
|
||||
tags: dashboard
|
||||
|
||||
- name: Deploy Docker Compose file
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ dashboard_data_dir }}/docker-compose.yml"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Restart dashboard
|
||||
tags: dashboard
|
||||
|
||||
- name: Start dashboard
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ dashboard_data_dir }}"
|
||||
state: present
|
||||
pull: missing
|
||||
tags: dashboard
|
||||
@@ -0,0 +1,251 @@
|
||||
{% raw %}#!/usr/bin/env python3
|
||||
# Managed by Ansible — do not edit manually
|
||||
# Status dashboard for cloud.ladkau.de
|
||||
# Reads host system stats from /host/proc (CPU, RAM) and /host (disk),
|
||||
# checks service endpoints, and serves a status page on port 8080.
|
||||
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
PROC = "/host/proc"
|
||||
HOST = "/host"
|
||||
BG_IMAGE = "/app/bg.jpg"
|
||||
|
||||
{% endraw %}
|
||||
SERVICES = [
|
||||
("Gitea", "https://{{ domain_gitea }}", 200, "https://{{ domain_gitea }}"),
|
||||
("Nextcloud", "https://{{ domain_nextcloud }}", 200, "https://{{ domain_nextcloud }}"),
|
||||
("Keycloak", "https://{{ domain_sso }}/realms/master", 200, "https://{{ domain_sso }}"),
|
||||
("Roundcube", "https://{{ domain_mail }}", 200, "https://{{ domain_mail }}"),
|
||||
("Registry", "https://{{ domain_registry }}/v2/", 401, "https://{{ domain_registry }}"),
|
||||
("k8s", "https://{{ domain_k8s }}", 200, "https://{{ domain_k8s }}"),
|
||||
]
|
||||
{% raw %}
|
||||
|
||||
# ── Colour palette ───────────────────────────────────────────────────────────
|
||||
|
||||
BG = "#0f172a"
|
||||
CARD = "#1e293b"
|
||||
MUTED = "#64748b"
|
||||
TEXT = "#e2e8f0"
|
||||
SUB = "#94a3b8"
|
||||
GREEN = "#22c55e"
|
||||
AMBER = "#f59e0b"
|
||||
RED = "#ef4444"
|
||||
|
||||
# ── System stats ─────────────────────────────────────────────────────────────
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache = {"cpu": 0.0, "ram_pct": 0.0, "ram_used_gb": 0.0, "ram_total_gb": 0.0,
|
||||
"disk_pct": 0.0, "disk_used_gb": 0.0, "disk_total_gb": 0.0,
|
||||
"services": [], "updated": 0}
|
||||
|
||||
|
||||
def _cpu_sample():
|
||||
with open(PROC + "/stat") as f:
|
||||
parts = list(map(int, f.readline().split()[1:8]))
|
||||
# user nice system idle iowait irq softirq
|
||||
idle = parts[3] + parts[4]
|
||||
total = sum(parts)
|
||||
return idle, total
|
||||
|
||||
|
||||
def read_cpu():
|
||||
a = _cpu_sample()
|
||||
time.sleep(1)
|
||||
b = _cpu_sample()
|
||||
dt = b[1] - a[1]
|
||||
di = b[0] - a[0]
|
||||
return round(100.0 * (1.0 - di / dt), 1) if dt else 0.0
|
||||
|
||||
|
||||
def read_ram():
|
||||
mem = {}
|
||||
with open(PROC + "/meminfo") as f:
|
||||
for line in f:
|
||||
k, v = line.split(":")
|
||||
mem[k.strip()] = int(v.split()[0]) # kB
|
||||
total_kb = mem["MemTotal"]
|
||||
avail_kb = mem.get("MemAvailable", mem.get("MemFree", 0))
|
||||
used_kb = total_kb - avail_kb
|
||||
pct = round(100.0 * used_kb / total_kb, 1) if total_kb else 0.0
|
||||
return pct, round(used_kb / 1048576, 1), round(total_kb / 1048576, 1)
|
||||
|
||||
|
||||
def read_disk():
|
||||
st = os.statvfs(HOST)
|
||||
total = st.f_blocks * st.f_frsize
|
||||
free = st.f_bfree * st.f_frsize
|
||||
used = total - free
|
||||
pct = round(100.0 * used / total, 1) if total else 0.0
|
||||
gb = 1024 ** 3
|
||||
return pct, round(used / gb, 1), round(total / gb, 1)
|
||||
|
||||
|
||||
def check_services():
|
||||
results = []
|
||||
for name, url, expected, link in SERVICES:
|
||||
ok = False
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "status-dashboard/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
ok = r.status == expected
|
||||
except urllib.error.HTTPError as e:
|
||||
ok = e.code == expected
|
||||
except Exception:
|
||||
ok = False
|
||||
results.append({"name": name, "ok": ok, "link": link})
|
||||
return results
|
||||
|
||||
|
||||
def collect_loop():
|
||||
while True:
|
||||
try:
|
||||
cpu = read_cpu() # includes 1 s sleep
|
||||
ram_pct, ram_used, ram_total = read_ram()
|
||||
disk_pct, disk_used, disk_total = read_disk()
|
||||
services = check_services()
|
||||
with _lock:
|
||||
_cache.update({
|
||||
"cpu": cpu,
|
||||
"ram_pct": ram_pct, "ram_used_gb": ram_used, "ram_total_gb": ram_total,
|
||||
"disk_pct": disk_pct, "disk_used_gb": disk_used, "disk_total_gb": disk_total,
|
||||
"services": services,
|
||||
"updated": time.time(),
|
||||
})
|
||||
except Exception as e:
|
||||
print("collect error:", e, flush=True)
|
||||
time.sleep(29) # 29 s rest + 1 s CPU sample = ~30 s cycle
|
||||
|
||||
|
||||
# ── HTML rendering ───────────────────────────────────────────────────────────
|
||||
|
||||
def _bar_color(pct):
|
||||
if pct < 60: return GREEN
|
||||
if pct < 85: return AMBER
|
||||
return RED
|
||||
|
||||
|
||||
def _service_dot(svc):
|
||||
c = GREEN if svc["ok"] else RED
|
||||
return (
|
||||
'<div style="display:flex;align-items:center;gap:.6rem;margin-bottom:.75rem">'
|
||||
'<div style="width:10px;height:10px;border-radius:50%;flex-shrink:0;background:' + c + ';'
|
||||
'box-shadow:0 0 6px ' + c + '88"></div>'
|
||||
'<a href="' + svc["link"] + '" target="_blank" rel="noopener" '
|
||||
'style="color:' + TEXT + ';text-decoration:none;border-bottom:1px solid rgba(255,255,255,.2)">'
|
||||
+ svc["name"] + '</a>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def _stat_bar(label, pct, detail):
|
||||
bc = _bar_color(pct)
|
||||
pct_str = str(pct)
|
||||
detail_html = (' <small style="color:' + MUTED + '">' + detail + '</small>') if detail else ''
|
||||
return (
|
||||
'<div style="margin-bottom:1.2rem">'
|
||||
'<div style="display:flex;justify-content:space-between;margin-bottom:.4rem;font-size:.85rem">'
|
||||
'<span style="color:' + SUB + '">' + label + '</span>'
|
||||
'<span>' + pct_str + '%' + detail_html + '</span>'
|
||||
'</div>'
|
||||
'<div style="background:' + BG + ';border-radius:999px;height:8px;overflow:hidden">'
|
||||
'<div style="width:' + pct_str + '%;height:100%;border-radius:999px;background:' + bc + '"></div>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
CSS = (
|
||||
'*{box-sizing:border-box;margin:0;padding:0}'
|
||||
'body{font-family:system-ui,sans-serif;background:' + BG + ' url(/bg.jpg) center/cover no-repeat fixed;'
|
||||
'color:' + TEXT + ';min-height:100vh;padding:2rem}'
|
||||
'h1{font-size:1.1rem;font-weight:600;color:' + TEXT + ';margin-bottom:2rem;letter-spacing:.06em;'
|
||||
'text-transform:uppercase;text-shadow:0 1px 4px rgba(0,0,0,.6)}'
|
||||
'.grid{display:grid;gap:1.5rem;grid-template-columns:1fr 1fr;max-width:820px}'
|
||||
'@media(max-width:600px){.grid{grid-template-columns:1fr}}'
|
||||
'.card{background:rgba(15,23,42,.75);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);'
|
||||
'border-radius:.75rem;padding:1.5rem;border:1px solid rgba(255,255,255,.08)}'
|
||||
'.card h2{font-size:.7rem;font-weight:600;color:' + MUTED + ';text-transform:uppercase;letter-spacing:.1em;margin-bottom:1.25rem}'
|
||||
'.footer{margin-top:2rem;font-size:.75rem;color:rgba(148,163,184,.7);max-width:820px;text-align:right;'
|
||||
'text-shadow:0 1px 2px rgba(0,0,0,.5)}'
|
||||
)
|
||||
|
||||
|
||||
def render():
|
||||
with _lock:
|
||||
s = dict(_cache)
|
||||
|
||||
svc_html = "".join(_service_dot(svc) for svc in s["services"]) \
|
||||
or '<span style="color:' + MUTED + '">Checking…</span>'
|
||||
|
||||
stats_html = (
|
||||
_stat_bar("CPU", s["cpu"], "")
|
||||
+ _stat_bar("RAM", s["ram_pct"],
|
||||
str(s["ram_used_gb"]) + " / " + str(s["ram_total_gb"]) + " GB")
|
||||
+ _stat_bar("Disk", s["disk_pct"],
|
||||
str(int(s["disk_used_gb"])) + " / " + str(int(s["disk_total_gb"])) + " GB")
|
||||
)
|
||||
|
||||
updated = time.strftime("%H:%M:%S", time.localtime(s["updated"])) if s["updated"] else "—"
|
||||
|
||||
return (
|
||||
'<!DOCTYPE html>'
|
||||
'<html lang="en">'
|
||||
'<head>'
|
||||
'<meta charset="utf-8">'
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
'<title>cloud.ladkau.de</title>'
|
||||
'<meta http-equiv="refresh" content="30">'
|
||||
'<style>' + CSS + '</style>'
|
||||
'</head>'
|
||||
'<body>'
|
||||
'<h1>cloud.ladkau.de</h1>'
|
||||
'<div class="grid">'
|
||||
'<div class="card"><h2>Services</h2>' + svc_html + '</div>'
|
||||
'<div class="card"><h2>System</h2>' + stats_html + '</div>'
|
||||
'</div>'
|
||||
'<div class="footer">Updated ' + updated + ' · refreshes every 30 s</div>'
|
||||
'</body>'
|
||||
'</html>'
|
||||
)
|
||||
|
||||
|
||||
# ── HTTP server ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/bg.jpg":
|
||||
try:
|
||||
with open(BG_IMAGE, "rb") as f:
|
||||
body = f.read()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/jpeg")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "max-age=3600")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
except OSError:
|
||||
self.send_error(404)
|
||||
return
|
||||
|
||||
body = render().encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass # suppress per-request log noise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
threading.Thread(target=collect_loop, daemon=True).start()
|
||||
print("Dashboard listening on :8080", flush=True)
|
||||
HTTPServer(("", 8080), _Handler).serve_forever()
|
||||
{% endraw %}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Managed by Ansible — do not edit manually
|
||||
services:
|
||||
dashboard:
|
||||
image: python:3.12-alpine
|
||||
container_name: dashboard
|
||||
command: python /app/app.py
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- {{ dashboard_data_dir }}/app.py:/app/app.py:ro
|
||||
- {{ dashboard_data_dir }}/bg.jpg:/app/bg.jpg:ro
|
||||
# Host proc/sys for CPU and RAM stats
|
||||
- /proc:/host/proc:ro
|
||||
# Host root filesystem (read-only) for disk stats
|
||||
- /:/host:ro
|
||||
networks:
|
||||
- traefik_public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Catch all traffic to cloud.ladkau.de not matched by the more specific
|
||||
# Traefik dashboard router (which has PathPrefix /dashboard and /api)
|
||||
- "traefik.http.routers.status.rule=Host(`{{ domain_cloud }}`)"
|
||||
- "traefik.http.routers.status.entrypoints=websecure"
|
||||
- "traefik.http.routers.status.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.status.loadbalancer.server.port=8080"
|
||||
- "traefik.http.routers.status.middlewares=rate-limit@docker"
|
||||
|
||||
networks:
|
||||
traefik_public:
|
||||
external: true
|
||||
Reference in New Issue
Block a user