# Playbook: Live-Dashboard auf den WSM-Server bringen

**Zweck.** Diese Datei ist ein tragbares Werkzeug. Kopiere sie in ein anderes Claude-Projekt
(z. B. „Dashboard Firma A"). Claude in diesem Projekt kann damit ein **live aktualisierendes
Dashboard** (KPIs, Diagramme) auf den bereits laufenden Hetzner-Server deployen – isoliert,
passwortgeschützt, unter eigener Subdomain. Der bestehende WSM-Kampagnen-Dienst läuft ungestört
weiter.

> **Sicherheit zuerst:** In diese Datei kommen **niemals** Passwörter, API-Tokens oder Keys.
> Alle Geheimnisse stehen ausschließlich in der `.env` **auf dem Server**. Diese MD darf geteilt
> werden; die `.env` niemals.

**Version 1.0 · Stand 2026-07-05 · MASTER (Single Source of Truth).** Diese Datei wird zentral
gepflegt und ist immer aktuell abrufbar unter **https://playbook.wsm-surf-snow.de/playbook.md**.
In den einzelnen Dashboard-Projekten liegt nur ein kurzer Verweis (`DASHBOARD_POINTER.md`) – dort
steht: **immer diese Online-Fassung abrufen**, lokale Kopien können veralten. Bei einer
Strukturänderung wird **nur diese Master-Datei** angepasst und neu auf den Server geladen; alle
Projekte lesen dann automatisch die aktuelle Fassung.

---

## 1. Der Server (Fakten)

- Anbieter: **Hetzner Cloud**, Typ CX23, Standort Helsinki (EU/DSGVO), Ubuntu 24.04.
- **IP:** `204.168.171.176`
- **Login:** `ssh root@204.168.171.176` (SSH-Key liegt auf Kais PC unter `~/.ssh/id_ed25519`,
  ohne Passphrase). Erstverbindung: Fingerprint mit `yes` bestätigen.
- Docker + Docker Compose sind installiert.
- Es läuft bereits ein **Caddy** (Auto-HTTPS) für `freigabe.wsm-surf-snow.de` im Stack
  `/root/server`. Dieser Caddy ist der **zentrale Verteiler** für alle Subdomains.
- Domain **wsm-surf-snow.de** liegt bei **IONOS**; Subdomains legt man dort als **A-Record**
  auf die Server-IP an.

Alles läuft auf **einem** Server: ein gemeinsamer Caddy vorne, dahinter **pro Dashboard ein
kleiner Container**. Caddy leitet je Subdomain an den richtigen Container weiter.

---

## 2. Einmal-Setup des zentralen Verteilers (nur beim allerersten Dashboard)

Damit Caddy Container aus anderen Stacks erreichen kann, brauchen alle ein **gemeinsames Docker-
Netzwerk**. Einmalig auf dem Server:

```bash
docker network create web 2>/dev/null || true
```

Dann den bestehenden Caddy an dieses Netz hängen. In `/root/server/docker-compose.yml` beim
Dienst `caddy` ergänzen (falls noch nicht vorhanden):

```yaml
  caddy:
    image: caddy:2
    depends_on: [app]
    ports: ["80:80", "443:443"]
    environment:
      - DOMAIN=${DOMAIN}
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    networks: [default, web]        # <— NEU
    restart: unless-stopped

networks:                            # <— NEU (am Dateiende)
  web:
    external: true
```

Und in `/root/server/Caddyfile` aus dem Ein-Zeiler eine **Mehr-Seiten-Konfiguration** machen.
Jede Subdomain ist ein eigener Block:

```
freigabe.wsm-surf-snow.de {
    reverse_proxy app:8000
}

# Weitere Dashboards hier anhängen, z. B.:
# firma-a.wsm-surf-snow.de {
#     reverse_proxy dash-firma-a:8000
# }
```

Danach neu laden:
```bash
cd /root/server && docker compose up -d
```

> Ab jetzt ist das Muster für jedes weitere Dashboard immer gleich (Abschnitt 3).

---

## 3. Neues Dashboard hinzufügen – Schritt für Schritt

Beispiel: Firma A → Subdomain `firma-a.wsm-surf-snow.de`, Containername `dash-firma-a`.

> **Selbstschutz – vor jedem Deploy den echten Server-Ist-Zustand lesen** (die Struktur kann
> sich seit dieser Anleitung geändert haben):
> `cat /root/server/docker-compose.yml` · `cat /root/server/Caddyfile` · `docker network ls`.
> Richte dich nach dem, was **wirklich** auf dem Server läuft – der Server ist die Wahrheit.

### 3.1 Ordner auf dem Server anlegen und Dateien hochladen
Vom PC (lokales PowerShell/Terminal), Projektordner mit den vier Dateien aus 3.2–3.5:
```bash
scp -r "PFAD\ZUM\dash-firma-a" root@204.168.171.176:/root/
```

### 3.2 Datendienst `app.py` (Vorlage, nur Python-Standardbibliothek)
Zieht die Zahlen periodisch aus einer Quelle, cached sie als JSON und liefert Dashboard + Daten.

```python
#!/usr/bin/env python3
import os, json, time, base64, threading, urllib.request, urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

BASE=os.path.dirname(os.path.abspath(__file__))
def env(k,d=None): return os.environ.get(k,d)
ADMIN_USER=env("ADMIN_USER","admin"); ADMIN_PW=env("ADMIN_PASSWORD","")
SOURCE_URL=env("DATA_SOURCE_URL","")          # z.B. veröffentlichte Google-Sheet-CSV oder API
SOURCE_TOKEN=env("DATA_SOURCE_TOKEN","")      # optional, für APIs mit Auth
REFRESH_MIN=int(env("REFRESH_MINUTES","15"))
CACHE=os.path.join(env("DATA_DIR",BASE),"data_cache.json")
HTML=open(os.path.join(BASE,"dashboard.html"),encoding="utf-8").read()

def fetch():
    """Quelle abrufen -> Liste von Zeilen (Dicts). Hier an deine Quelle anpassen."""
    if not SOURCE_URL: return {"rows":[], "note":"keine Quelle konfiguriert"}
    req=urllib.request.Request(SOURCE_URL)
    if SOURCE_TOKEN: req.add_header("Authorization","Bearer "+SOURCE_TOKEN)
    raw=urllib.request.urlopen(req,timeout=60).read().decode("utf-8","replace")
    # --- CSV (z.B. Google Sheet "Im Web veröffentlichen -> CSV") ---
    if SOURCE_URL.lower().endswith("csv") or "output=csv" in SOURCE_URL.lower():
        lines=[l for l in raw.splitlines() if l.strip()]
        hdr=[h.strip() for h in lines[0].split(",")]
        rows=[dict(zip(hdr,[c.strip() for c in l.split(",")])) for l in lines[1:]]
        return {"rows":rows}
    # --- JSON-API ---
    return json.loads(raw)

def refresh_loop():
    while True:
        try:
            data=fetch(); data["updated"]=time.strftime("%Y-%m-%d %H:%M")
            json.dump(data,open(CACHE,"w",encoding="utf-8"),ensure_ascii=False)
            print("[data] aktualisiert",flush=True)
        except Exception as e: print("[data] Fehler:",e,flush=True)
        time.sleep(REFRESH_MIN*60)

def authed(h):
    if not ADMIN_PW: return True
    hdr=h.headers.get("Authorization","")
    if hdr.startswith("Basic "):
        try:
            u,p=base64.b64decode(hdr[6:]).decode().split(":",1)
            return u==ADMIN_USER and p==ADMIN_PW
        except Exception: return False
    return False

class H(BaseHTTPRequestHandler):
    def _s(self,c,b,ct="application/json"):
        self.send_response(c); self.send_header("Content-Type",ct)
        self.send_header("Content-Length",str(len(b))); self.end_headers(); self.wfile.write(b)
    def do_GET(self):
        p=self.path.split("?")[0]
        if p=="/health": return self._s(200,b'{"ok":true}')
        if not authed(self):
            self.send_response(401); self.send_header("WWW-Authenticate",'Basic realm="dash"'); self.end_headers(); return
        if p in ("/","/index.html"): return self._s(200,HTML.encode(),"text/html; charset=utf-8")
        if p=="/api/data":
            b=open(CACHE,"rb").read() if os.path.exists(CACHE) else b'{"rows":[]}'
            return self._s(200,b)
        return self._s(404,b'{"error":"not found"}')
    def log_message(self,*a): pass

if __name__=="__main__":
    threading.Thread(target=refresh_loop,daemon=True).start()
    ThreadingHTTPServer(("0.0.0.0",int(env("PORT","8000"))),H).serve_forever()
```

### 3.3 `dashboard.html` (Vorlage mit Chart.js)
Lädt `/api/data` und rendert Kacheln + Diagramm. Chart.js per CDN.

```html
<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body{margin:0;background:#0c1424;color:#eef2f8;font-family:-apple-system,Segoe UI,Roboto,Arial}
header{background:#06152F;padding:16px 22px;border-bottom:3px solid #F7B500}
.kpis{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px;padding:20px}
.kpi{background:#131f34;border:1px solid #23324e;border-radius:12px;padding:16px}
.kpi .n{font-size:30px;font-weight:800} .kpi .l{color:#b9c3d6;font-size:13px}
.wrap{padding:0 20px 24px} canvas{background:#131f34;border-radius:12px;padding:10px}
small{color:#8fa0bd}
</style></head><body>
<header><h1 style="margin:0;font-size:20px">Kennzahlen</h1><small id="upd">–</small></header>
<div class="kpis" id="kpis"></div>
<div class="wrap"><canvas id="chart" height="120"></canvas></div>
<script>
async function load(){
  const r=await fetch('/api/data'); const d=await r.json();
  document.getElementById('upd').textContent='Stand: '+(d.updated||'–');
  const rows=d.rows||[];
  // KPIs: erste Zahlenspalten der letzten Zeile als Kacheln (anpassen an deine Daten)
  const last=rows[rows.length-1]||{}; const k=document.getElementById('kpis'); k.innerHTML='';
  Object.entries(last).forEach(([key,val])=>{
    if(isNaN(parseFloat(val)))return;
    k.insertAdjacentHTML('beforeend',`<div class="kpi"><div class="n">${val}</div><div class="l">${key}</div></div>`);
  });
  // Chart: erste numerische Spalte über alle Zeilen
  const keys=Object.keys(rows[0]||{}); const label=keys[0];
  const num=keys.find(x=>rows.some(r=>!isNaN(parseFloat(r[x]))&&x!==label));
  if(num){ new Chart(document.getElementById('chart'),{type:'line',
    data:{labels:rows.map(r=>r[label]),datasets:[{label:num,data:rows.map(r=>parseFloat(r[num])||0),
    borderColor:'#F7B500',backgroundColor:'rgba(247,181,0,.15)',tension:.3,fill:true}]},
    options:{plugins:{legend:{labels:{color:'#eef2f8'}}},scales:{x:{ticks:{color:'#b9c3d6'}},y:{ticks:{color:'#b9c3d6'}}}}});}
}
load(); setInterval(load,300000); // alle 5 Min neu laden
</script></body></html>
```

### 3.4 `Dockerfile`
```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY . /app
ENV PORT=8000 DATA_DIR=/data
RUN mkdir -p /data
EXPOSE 8000
CMD ["python","app.py"]
```

### 3.5 `docker-compose.yml`
Kein Port nach außen (Caddy erreicht den Container übers Netz `web`). Der Servicename **muss**
zum Caddy-Block passen (`dash-firma-a`).
```yaml
services:
  dash-firma-a:
    build: .
    container_name: dash-firma-a
    environment:
      - DATA_DIR=/data
      - ADMIN_USER=${ADMIN_USER}
      - ADMIN_PASSWORD=${ADMIN_PASSWORD}
      - DATA_SOURCE_URL=${DATA_SOURCE_URL}
      - DATA_SOURCE_TOKEN=${DATA_SOURCE_TOKEN}
      - REFRESH_MINUTES=${REFRESH_MINUTES}
    volumes:
      - ./data:/data
    networks: [web]
    restart: unless-stopped
networks:
  web:
    external: true
```

### 3.6 `.env` (Geheimnisse – nur auf dem Server, nie in Git/MD)
```
ADMIN_USER=firma-a
ADMIN_PASSWORD=EinStarkesPasswort
DATA_SOURCE_URL=https://docs.google.com/.../pub?output=csv
DATA_SOURCE_TOKEN=
REFRESH_MINUTES=15
```

### 3.7 Caddy-Route ergänzen + DNS
Auf dem Server in `/root/server/Caddyfile` einen Block anhängen:
```
firma-a.wsm-surf-snow.de {
    reverse_proxy dash-firma-a:8000
}
```
Bei **IONOS** einen **A-Record** setzen: Hostname `firma-a` → `204.168.171.176`.

### 3.8 Starten
```bash
cd /root/dash-firma-a && cp .env.example .env && nano .env   # Werte eintragen
docker compose up -d --build
cd /root/server && docker compose up -d                       # Caddy lädt neue Route
```
Test: `https://firma-a.wsm-surf-snow.de/health` muss `{"ok":true}` zeigen; die Seite fragt
Login (User/Passwort aus `.env`).

---

## 4. Datenquellen anbinden (die eine Stelle, die pro Firma variiert)

Die Funktion `fetch()` in `app.py` ist der einzige Ort, den du an die reale Quelle anpasst:

- **Google Sheet:** im Sheet „Datei → Freigeben → Im Web veröffentlichen → CSV". Diese URL als
  `DATA_SOURCE_URL`. Die CSV-Logik in der Vorlage funktioniert dann direkt.
- **REST-API (JSON):** `DATA_SOURCE_URL` = Endpoint, optional `DATA_SOURCE_TOKEN`. `fetch()`
  gibt das JSON zurück; ggf. auf `{"rows":[...]}` umformen.
- **Datenbank (Postgres/MySQL):** Treiber ins `requirements.txt` + `pip install` im Dockerfile;
  in `fetch()` per SQL abfragen. Zugangsdaten nur via `.env`.

Wichtig: `fetch()` soll **eine Liste gleichartiger Zeilen** (`rows`) liefern, dann greifen
Kacheln + Diagramm der HTML-Vorlage automatisch. Für spezielle KPIs die HTML-Vorlage anpassen.

---

## 5. Sicherheit & Isolation (Pflicht bei Fremdfirmen-Daten)

- **Pro Firma eigenes Passwort** (`ADMIN_PASSWORD` je `.env`) und **eigene Subdomain**.
- **Container getrennt:** jede Firma ihr eigener Ordner/Container/`data`-Volume – keine
  Datenvermischung.
- **Secrets nur in `.env`** (dazu `.gitignore` mit `.env`), niemals in HTML/MD/Repo.
- Nur `/health` ist offen; Dashboard + Daten sind hinter Basic-Auth.
- HTTPS erzwingt Caddy automatisch (Let's Encrypt).
- Bei sensiblen Daten zusätzlich überlegen: IP-Beschränkung, stärkere Auth (später).

---

## 6. Checkliste für ein neues Dashboard

1. [ ] Ordner `dash-<firma>` mit `app.py`, `dashboard.html`, `Dockerfile`,
       `docker-compose.yml`, `.env.example` erstellt.
2. [ ] `fetch()` an die reale Datenquelle angepasst.
3. [ ] Per `scp` auf den Server nach `/root/dash-<firma>` geladen.
4. [ ] `.env` auf dem Server mit Passwort + Quelle ausgefüllt.
5. [ ] IONOS A-Record `firma → 204.168.171.176` gesetzt.
6. [ ] Caddy-Block in `/root/server/Caddyfile` ergänzt.
7. [ ] `docker compose up -d --build` (Dashboard) + `docker compose up -d` (Caddy neu laden).
8. [ ] `https://<firma>.wsm-surf-snow.de/health` = `{"ok":true}`, Login funktioniert, Zahlen
       erscheinen und aktualisieren sich.

---

## 7. Anleitung an Claude (im anderen Projekt)

Wenn du (Claude) diese Datei in einem Dashboard-Projekt siehst und der Nutzer ein Dashboard
online stellen will:
1. Frag nach der **Datenquelle** (Google Sheet / API / DB) und den **KPIs**, die gezeigt werden
   sollen, sowie dem gewünschten **Firmen-Kürzel** (für Subdomain + Containernamen).
2. Erzeuge die fünf Dateien aus Abschnitt 3 im Projektordner, `fetch()` + `dashboard.html` an
   die echten KPIs angepasst.
3. Führe den Nutzer **per Screenshot** durch: `scp`-Upload, IONOS-A-Record, `.env` ausfüllen,
   `docker compose up`. Genau wie in diesem Playbook.
4. Trage **keine** Geheimnisse in Dateien ein, die im Projekt/Repo liegen – nur in die `.env`
   auf dem Server.
5. Prüfe am Ende `/health` und den Login, und dass die Zahlen live erscheinen.
