Logpy-AgentOne/pipe/server.py
Jeuner 989d3ba85b feat: Leitstand-Fixes, Sicher-Modus, Notfall-Alarm, Audit-Log + Loeschfristen
- Audio-Wiedergabe im Leitstand brach durch Auto-Refresh (setInterval) ab,
  Karten-DOM wurde per innerHTML neu gebaut waehrend ein <audio> lief.
  takt() pausiert jetzt, solange eine Aufnahme abgespielt wird. Zusaetzlich
  HTTP-Range-Unterstuetzung fuer /audio/ nachgeruestet (war vorher immer
  volle Datei statt 206 Partial Content).
- Sicher-Modus: Rufnummern per Klick blurren, fuer Screenshots/Aufnahmen.
- Notfall-Alarm: pulsierender Karten-Rahmen + Banner + minuetlicher Ton,
  solange ein Notfall nicht im letzten Stapel (Erledigt) liegt.
- Favicon.
- Neues unveraenderliches audit.log (append-only, macOS uappnd-Flag) als
  Nachweis fuer den Umgang mit Anrufdaten - protokoll.schreibe() spiegelt
  jetzt jedes Ereignis dorthin, zusaetzlich zum rotierenden verlauf.log.
- pipe.loeschen: loescht erledigte Anrufe nach LOESCHFRIST_TAGE (lokal +
  Nextcloud), unbearbeitete Anrufe (nicht im letzten Stapel) bleiben immer
  unangetastet. Gedacht fuer taeglichen Cron-Lauf.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs
2026-08-24 19:13:09 +02:00

708 lines
32 KiB
Python

"""Live-Dashboard: zeigt Anrufe, Systemzustand und das laufende Protokoll.
python3 -m pipe.server [--port 8088]
Rein lokal, stdlib. Liest die Ablage, das Ereignisprotokoll und den Zustand der
beteiligten Dienste. Die Seite fragt alle paar Sekunden nach — kein WebSocket,
kein Framework, nichts, was im Praxisbetrieb kaputtgehen kann.
"""
from __future__ import annotations
import argparse
import base64
import hmac
import json
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlparse
from . import config, dashboard, protokoll, stapel, store
# Statusabfragen sind teuer (Netz, Unterprozesse) - kurz zwischenspeichern,
# damit haeufiges Nachfragen den Rechner nicht belastet.
_CACHE: dict[str, tuple[float, dict]] = {}
CACHE_S = 4.0
def _laeuft(muster: str) -> bool:
try:
return subprocess.run(["pgrep", "-f", muster], capture_output=True).returncode == 0
except Exception:
return False
def _http_ok(url: str, timeout: float = 5.0) -> bool:
"""Erreichbarkeitstest. Mit eigenem User-Agent - ein WAF vor Nextcloud
blockt den Standardnamen von urllib und meldete fälschlich 'nicht
erreichbar'."""
req = urllib.request.Request(url, headers={"User-Agent": "praxis-telefon-agent/1.0"})
try:
with urllib.request.urlopen(req, timeout=timeout, context=store._ssl_kontext()):
return True
except urllib.error.HTTPError:
return True # antwortet - reicht als Lebenszeichen
except Exception:
return False
def _trunk() -> tuple[str, str]:
"""(zustand, text) des SIP-Trunks."""
if not _laeuft("[f]reeswitch"):
return "aus", "FreeSWITCH läuft nicht"
try:
roh = subprocess.run(
["fs_cli", "-P", config.FS_PORT, "-x", "sofia status gateway plusnet"],
capture_output=True, text=True, timeout=8).stdout
for zeile in roh.splitlines():
if zeile.startswith("Status"):
wert = zeile.split()[-1]
return ("gut", "registriert") if wert == "UP" else ("schlecht", f"Trunk {wert}")
return "unklar", "Gateway unbekannt"
except Exception:
return "unklar", "fs_cli nicht erreichbar"
def status() -> dict:
jetzt = time.time()
gepuffert = _CACHE.get("status")
if gepuffert and jetzt - gepuffert[0] < CACHE_S:
return gepuffert[1]
trunk_zustand, trunk_text = _trunk()
watcher = _laeuft("[p]ipe.watch")
ollama = _http_ok(f"{config.OLLAMA_URL}/api/version")
nextcloud = (_http_ok(f"{config.NEXTCLOUD_URL}/status.php")
if config.nextcloud_aktiv() else None)
offen = 0
if config.TELEFON_EINGANG.is_dir():
offen = sum(1 for d in config.TELEFON_EINGANG.iterdir()
if d.is_file() and not d.name.startswith("."))
fehler = 0
if config.TELEFON_FEHLER.is_dir():
fehler = sum(1 for d in config.TELEFON_FEHLER.iterdir() if d.is_file())
daten = {
"dienste": [
{"name": "Telefon", "zustand": trunk_zustand, "text": trunk_text},
{"name": "Verarbeitung", "zustand": "gut" if watcher else "aus",
"text": "beobachtet Eingang" if watcher else "Watcher läuft nicht"},
{"name": "Spracherkennung", "zustand": "gut" if ollama else "schlecht",
"text": "Ollama bereit" if ollama else "Ollama nicht erreichbar"},
{"name": "Nextcloud", "zustand": "unklar" if nextcloud is None
else ("gut" if nextcloud else "schlecht"),
"text": "nicht konfiguriert" if nextcloud is None
else ("erreichbar" if nextcloud else "nicht erreichbar")},
],
"eingang_offen": offen,
"fehler": fehler,
"stand": datetime.now().strftime("%H:%M:%S"),
}
_CACHE["status"] = (jetzt, daten)
return daten
def anrufe() -> list[dict]:
liste = []
for d in dashboard._anrufe():
e = d["auswertung"]
zeit = d["empfangen"]
ordner = None
try:
z = datetime.fromisoformat(zeit)
tag = f"{z:%Y-%m-%d}"
for kandidat in (config.ABLAGE_LOKAL / tag).iterdir():
if kandidat.is_dir() and kandidat.name.startswith(f"{z:%H-%M-%S}"):
ordner = f"{tag}/{kandidat.name}"
break
except Exception:
pass
if ordner and stapel.ist_archiviert(config.ABLAGE_LOKAL / ordner):
continue # archiviert (simuliert) - verschwindet vom Board
audio = None
if ordner:
for endung in (".wav", ".mp3", ".opus", ".m4a"):
if (config.ABLAGE_LOKAL / ordner / f"aufnahme{endung}").is_file():
audio = f"/audio/{ordner}/aufnahme{endung}"
break
liste.append({
"id": ordner or "",
"stapel": stapel.lies(config.ABLAGE_LOKAL / ordner) if ordner else stapel.erlaubt()[0],
"empfangen": zeit,
"kategorie": e["kategorie"],
"dringlichkeit": e["dringlichkeit"],
"name": e.get("anrufer_name"),
"nummer": d.get("anrufer_nummer") or e.get("rueckrufnummer"),
"rueckruf": bool(e.get("rueckruf_gewuenscht")),
"anliegen": e.get("anliegen_kurz") or "",
"stichworte": e.get("stichworte") or [],
"transkript": d.get("transkript") or "",
"audio": audio,
"kontakt": (d.get("bekannter_kontakt") or {}).get("name"),
})
return liste
def archiviere_tag() -> int:
"""Markiert alle 'Erledigt'-Anrufe als archiviert (simuliert - es wird
nichts exportiert oder geloescht, nur vom Board geraeumt). Gibt die Anzahl
zurueck."""
letzter = stapel.erlaubt()[-1]
anzahl = 0
for a in anrufe():
if a["stapel"] != letzter or not a["id"]:
continue
ordner = stapel.ordner_zu(a["id"])
if ordner and stapel.archiviere(ordner):
anzahl += 1
return anzahl
FS_LOG = config.FREESWITCH_LOG
_ZEIT = re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}")
# Was im FreeSWITCH-Log für die Praxis interessant ist - und wie es heißen soll.
_FS_MUSTER = [
("New Channel sofia/external/", "anruf", "Anruf von {nummer}"),
("has been answered", "anruf", "abgenommen, Ansage läuft"),
("Hangup sofia/external/", "anruf", "Anrufer hat aufgelegt"),
("Failed Registration", "fehler", "Telefon: Registrierung fehlgeschlagen"),
("Ping failed", "fehler", "Telefon: Provider antwortet nicht"),
]
def _telefon_ereignisse(anzahl: int = 60) -> list[dict]:
"""Liest Anruf-Ereignisse aus dem FreeSWITCH-Log.
So sieht man den Anruf schon beim Klingeln, nicht erst wenn die fertige
Aufnahme durch die Pipe gelaufen ist.
"""
if not FS_LOG.is_file():
return []
try:
with FS_LOG.open("rb") as f: # nur das Ende lesen
f.seek(0, 2)
f.seek(max(0, f.tell() - 300_000))
zeilen = f.read().decode("utf-8", "replace").splitlines()
except Exception:
return []
ereignisse = []
for zeile in zeilen[-4000:]:
for schnipsel, art, vorlage in _FS_MUSTER:
if schnipsel not in zeile:
continue
# Bei Kanal-Ereignissen steht die Channel-UUID vor dem Zeitstempel,
# die Position ist also nicht verlässlich - daher per Muster suchen.
treffer = _ZEIT.search(zeile)
zeit = treffer.group(0) if treffer else ""
nummer = ""
if "sofia/external/" in zeile:
rest = zeile.split("sofia/external/", 1)[1]
nummer = rest.split("@", 1)[0].strip()
ereignisse.append({
"zeit": zeit.replace(" ", "T"),
"art": art,
"text": vorlage.format(nummer=nummer or "unbekannt"),
})
break
return ereignisse[-anzahl:]
def verlauf(anzahl: int = 150) -> list[dict]:
"""Pipe-Protokoll und Telefonie-Ereignisse, zeitlich zusammengeführt."""
alle = protokoll.lies(anzahl) + _telefon_ereignisse()
alle.sort(key=lambda e: e.get("zeit") or "")
return alle[-anzahl:]
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_): # kein Zugriffslog auf der Konsole
pass
def _angemeldet(self) -> bool:
"""Prüft die Zugangsdaten, sofern eines gesetzt ist.
Ohne Passwort bleibt der Leitstand offen - das ist nur zulässig, wenn er
an 127.0.0.1 hängt; darauf besteht main() beim Start.
"""
if not config.LEITSTAND_PASS:
return True
kopf = self.headers.get("Authorization", "")
if not kopf.startswith("Basic "):
return False
try:
nutzer, _, wort = base64.b64decode(kopf[6:]).decode("utf-8").partition(":")
except Exception:
return False
# compare_digest: Vergleichsdauer verrät nichts über das Passwort.
return (hmac.compare_digest(nutzer, config.LEITSTAND_USER)
and hmac.compare_digest(wort, config.LEITSTAND_PASS))
def _anmeldung_verlangen(self) -> None:
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="Anruf-Leitstand"')
self.send_header("Content-Length", "0")
self.end_headers()
def _sende(self, inhalt: bytes, typ: str, code: int = 200) -> None:
self.send_response(code)
self.send_header("Content-Type", typ)
self.send_header("Content-Length", str(len(inhalt)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(inhalt)
def _json(self, daten) -> None:
self._sende(json.dumps(daten, ensure_ascii=False).encode("utf-8"),
"application/json; charset=utf-8")
def do_GET(self) -> None:
if not self._angemeldet():
self._anmeldung_verlangen()
return
pfad = unquote(urlparse(self.path).path)
if pfad == "/":
self._sende(SEITE.encode("utf-8"), "text/html; charset=utf-8")
elif pfad == "/api/status":
self._json(status())
elif pfad == "/api/anrufe":
self._json({"stapel": stapel.erlaubt(), "anrufe": anrufe()})
elif pfad == "/api/verlauf":
self._json(verlauf(150))
elif pfad.startswith("/audio/"):
self._audio(pfad[len("/audio/"):])
else:
self._sende(b"nicht gefunden", "text/plain; charset=utf-8", 404)
def do_POST(self) -> None:
if not self._angemeldet():
self._anmeldung_verlangen()
return
pfad = unquote(urlparse(self.path).path)
if pfad == "/api/archivieren":
anzahl = archiviere_tag()
protokoll.schreibe("archiv", f"{anzahl} Anruf(e) archiviert (simuliert)", anzahl=anzahl)
self._json({"ok": True, "anzahl": anzahl, "simuliert": True})
return
if pfad != "/api/stapel":
self._sende(b"nicht gefunden", "text/plain; charset=utf-8", 404)
return
try:
laenge = int(self.headers.get("Content-Length") or 0)
if laenge > 10_000:
raise ValueError("Anfrage zu groß")
wunsch = json.loads(self.rfile.read(laenge).decode("utf-8"))
kennung, ziel = wunsch["id"], wunsch["stapel"]
except Exception:
self._json({"ok": False, "fehler": "ungültige Anfrage"})
return
ordner = stapel.ordner_zu(kennung)
if ordner is None:
self._json({"ok": False, "fehler": "Anruf nicht gefunden"})
return
if not stapel.setze(ordner, ziel):
self._json({"ok": False, "fehler": f"unbekannter Stapel: {ziel}"})
return
protokoll.schreibe("stapel", f"{ordner.name}{ziel}")
self._json({"ok": True})
def _audio(self, rest: str) -> None:
"""Liefert eine Aufnahme aus der Ablage - nur von dort.
Muss HTTP-Range unterstuetzen: der <audio>-Player im Leitstand schickt
beim Laden erst eine kleine Range-Anfrage (z.B. die ersten paar KB),
um Dauer/Seekbarkeit zu ermitteln. Antwortet der Server darauf mit
200 + der kompletten Datei statt 206 + dem angefragten Ausschnitt,
haelt der Browser sich an die urspruenglich angefragte (kleine)
Groesse und bricht die Wiedergabe nach ein paar Sekunden ab, obwohl
die Datei komplett uebertragen wurde.
"""
ziel = (config.ABLAGE_LOKAL / rest).resolve()
wurzel = config.ABLAGE_LOKAL.resolve()
if not str(ziel).startswith(str(wurzel) + "/") or not ziel.is_file():
self._sende(b"nicht gefunden", "text/plain; charset=utf-8", 404)
return
typ = {".wav": "audio/wav", ".mp3": "audio/mpeg",
".opus": "audio/ogg", ".m4a": "audio/mp4"}.get(ziel.suffix, "application/octet-stream")
groesse = ziel.stat().st_size
bereich = self.headers.get("Range")
if not bereich:
self.send_response(200)
self.send_header("Content-Type", typ)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(groesse))
self.send_header("Cache-Control", "no-store")
self.end_headers()
with ziel.open("rb") as datei:
self.wfile.write(datei.read())
return
treffer = re.match(r"bytes=(\d*)-(\d*)", bereich)
start = int(treffer.group(1)) if treffer and treffer.group(1) else 0
ende = int(treffer.group(2)) if treffer and treffer.group(2) else groesse - 1
ende = min(ende, groesse - 1)
if not treffer or start > ende or start >= groesse:
self.send_response(416)
self.send_header("Content-Range", f"bytes */{groesse}")
self.send_header("Content-Length", "0")
self.end_headers()
return
laenge = ende - start + 1
self.send_response(206)
self.send_header("Content-Type", typ)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Range", f"bytes {start}-{ende}/{groesse}")
self.send_header("Content-Length", str(laenge))
self.send_header("Cache-Control", "no-store")
self.end_headers()
with ziel.open("rb") as datei:
datei.seek(start)
self.wfile.write(datei.read(laenge))
SEITE = r"""<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Anruf-Leitstand — Praxis Musterhausen</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%E2%98%8E%EF%B8%8F%3C/text%3E%3C/svg%3E">
<style>
:root{--bg:#f4f6f9;--fg:#18202c;--karte:#fff;--spalte:#eceff4;--rand:#dde3ec;--muted:#68748a;
--gut:#1f9d5c;--schlecht:#d93a34;--unklar:#c8901f;--aus:#8a93a3;--log:#f8fafc}
@media(prefers-color-scheme:dark){:root{--bg:#0f1218;--fg:#e6ecf4;--karte:#1b212b;
--spalte:#151a22;--rand:#28303d;--muted:#8d99ad;--log:#10141b}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
.wrap{max-width:1600px;margin:0 auto;padding:18px 16px 30px}
header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:12px}
h1{font-size:19px;margin:0}
.stand{color:var(--muted);font-size:13px;font-variant-numeric:tabular-nums;margin-left:auto}
.dienste{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:9px}
.dienst{background:var(--karte);border:1px solid var(--rand);border-radius:9px;padding:9px 11px;
display:flex;align-items:center;gap:9px}
.punkt{width:9px;height:9px;border-radius:50%;flex:none}
.gut{background:var(--gut)}.schlecht{background:var(--schlecht)}
.unklar{background:var(--unklar)}.aus{background:var(--aus)}
.dienst b{display:block;font-size:13px}.dienst span{color:var(--muted);font-size:12px}
.hinweis{background:#fdf1e7;border:1px solid #f0c9a0;color:#8a4b12;border-radius:8px;
padding:7px 11px;margin:9px 0;font-size:13px}
@media(prefers-color-scheme:dark){.hinweis{background:#2b1e12;border-color:#5a3a18;color:#f0c08a}}
.notfall-banner{display:none;align-items:center;gap:9px;background:var(--schlecht);color:#fff;
font-weight:700;font-size:14px;padding:9px 14px;border-radius:9px;margin:9px 0;
animation:notfallBlinken 1.6s ease-in-out infinite}
.notfall-banner.zeigen{display:flex}
@keyframes notfallBlinken{0%,100%{opacity:1}50%{opacity:.6}}
@keyframes notfallRahmen{0%,100%{box-shadow:0 0 0 0 rgba(233,50,45,.6)}
50%{box-shadow:0 0 0 9px rgba(233,50,45,0)}}
.karte.notfall-offen{animation:notfallRahmen 1.6s ease-in-out infinite}
.archiv-btn{background:var(--karte);border:1px solid var(--rand);color:var(--fg);border-radius:8px;
padding:6px 12px;font-size:13px;cursor:pointer;font-family:inherit}
.archiv-btn:hover{border-color:var(--muted)}
.archiv-btn:disabled{opacity:.5;cursor:default}
.sicher-btn{background:var(--karte);border:1px solid var(--rand);color:var(--fg);border-radius:8px;
padding:6px 12px;font-size:13px;cursor:pointer;font-family:inherit;margin-right:8px}
.sicher-btn:hover{border-color:var(--muted)}
.sicher-btn.aktiv{background:var(--gut);border-color:var(--gut);color:#fff}
/* Sicher-Modus: Patientendaten unscharf, fuer Bildschirmaufnahmen/Screenshots.
Struktur (Kategorie, Dringlichkeit, Zeit, Stapel) bleibt lesbar - nur was
auf eine Person zurueckfuehrt wird geblurrt. */
body.sicher a.waehlen{filter:blur(6px);user-select:none}
.toast{position:fixed;left:50%;bottom:24px;transform:translate(-50%,12px);background:var(--fg);
color:var(--bg);padding:9px 16px;border-radius:8px;font-size:13px;opacity:0;pointer-events:none;
transition:opacity .2s,transform .2s;z-index:10;box-shadow:0 4px 16px rgba(0,0,0,.2)}
.toast.zeigen{opacity:1;transform:translate(-50%,0)}
.board{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:12px;align-items:start}
@media(max-width:1100px){.board{grid-template-columns:repeat(2,1fr)}}
@media(max-width:660px){.board{grid-template-columns:1fr}}
.spalte{background:var(--spalte);border:1px solid var(--rand);border-radius:12px;padding:10px;
min-height:130px;transition:background .12s,border-color .12s}
.spalte.ueber{border-color:var(--gut);background:color-mix(in srgb,var(--gut) 8%,var(--spalte))}
.spalte h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);
margin:0 0 9px;display:flex;gap:7px;align-items:center}
.zahl{background:var(--rand);color:var(--fg);border-radius:999px;padding:0 7px;font-size:11px}
a.waehlen{color:inherit;text-decoration:underline;text-decoration-style:dotted;
text-underline-offset:2px;cursor:pointer}
a.waehlen:hover{color:var(--gut);text-decoration-style:solid}
.karte{background:var(--karte);border:1px solid var(--rand);border-left:4px solid var(--akzent,#888);
border-radius:9px;padding:9px 11px;margin-bottom:9px;cursor:grab}
.karte:active{cursor:grabbing}
.karte.zieht{opacity:.4}
.kopf{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.titel{font-weight:650;font-size:14px}
.dring{color:#fff;font-size:9px;font-weight:700;text-transform:uppercase;padding:2px 6px;
border-radius:999px;letter-spacing:.04em;margin-left:auto}
.meta{color:var(--muted);font-size:12px;margin:3px 0 5px;font-variant-numeric:tabular-nums}
.anliegen{margin:5px 0;font-size:13.5px}
.tag{display:inline-block;background:var(--bg);border:1px solid var(--rand);border-radius:5px;
padding:1px 6px;font-size:11px;color:var(--muted);margin:2px 3px 2px 0}
audio{width:100%;height:32px;margin-top:6px;display:block}
details{margin-top:4px}summary{cursor:pointer;color:var(--muted);font-size:12px}
.tr{color:var(--muted);font-size:12.5px;margin:4px 0 0}
.schieber{display:flex;gap:5px;margin-top:7px;flex-wrap:wrap}
.schieber button{font:inherit;font-size:11px;padding:2px 8px;border-radius:6px;cursor:pointer;
border:1px solid var(--rand);background:var(--bg);color:var(--muted)}
.schieber button:hover{color:var(--fg);border-color:var(--muted)}
.leer{color:var(--muted);font-size:12.5px;padding:5px 2px}
.dienste-kopf{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);
margin:22px 0 7px}
#verlauf{background:var(--log);border:1px solid var(--rand);border-radius:11px;padding:10px;
height:180px;overflow-y:auto;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}
.z{display:flex;gap:9px;padding:1px 0}
.z time{color:var(--muted);flex:none;font-variant-numeric:tabular-nums}
.z .a{flex:none;width:64px;font-weight:600}
.a-anruf{color:#3b86d8}.a-eingang{color:#1f9d5c}.a-fertig{color:#1f9d5c}
.a-fehler{color:var(--schlecht)}.a-notfall{color:var(--schlecht);font-weight:700}
.a-system{color:var(--muted)}.a-stapel{color:#9b6bd8}.a-archiv{color:#9b6bd8}
.a-kontakt{color:var(--gut)}
.kontakt-badge{display:inline-block;background:color-mix(in srgb,var(--gut) 16%,transparent);
color:var(--gut);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:600;margin-left:6px}
</style></head><body><div class="wrap">
<header><h1>Anruf-Leitstand</h1>
<button class="sicher-btn" id="sicherBtn" title="Namen, Nummern, Anliegen und Transkripte unscharf - fuer Bildschirmaufnahmen/Screenshots">🙈 Sicher-Modus</button>
<button class="archiv-btn" id="archivBtn" title="Erledigte Anrufe vom Board raeumen (simuliert - nichts wird geloescht oder exportiert)">🗄️ Tag archivieren</button>
<span class="stand" id="stand">…</span></header>
<div id="verlauf"></div>
<div id="hinweise"></div>
<div class="notfall-banner" id="notfallBanner">🚨 <span id="notfallText"></span></div>
<div class="board" id="board"></div>
<div class="dienste-kopf">Dienste</div>
<div class="dienste" id="dienste"></div>
</div>
<div class="toast" id="toast"></div>
<script>
const FARBE={notfall:'#d93a34',hoch:'#e08a2a',normal:'#c8a227',niedrig:'#1f9d5c'};
const KAT={termin:'Termin',rezept:'Rezept',ueberweisung:'Überweisung',befund:'Befund',
verwaltung:'Verwaltung',beschwerden:'Beschwerden',notfall:'Notfall',rueckruf:'Rückruf',sonstiges:'Sonstiges'};
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
let STAPEL=[], ANRUFE=[], offeneTranskripte=new Set(), pausiert=false;
async function hole(p){try{const r=await fetch(p);return r.ok?await r.json():null}catch(e){return null}}
async function verschiebe(id,ziel){
// sofort anzeigen, dann speichern - fuehlt sich unmittelbar an
const a=ANRUFE.find(x=>x.id===id); if(!a||a.stapel===ziel)return;
a.stapel=ziel; zeichneBoard();
const r=await fetch('/api/stapel',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id:id,stapel:ziel})});
const d=await r.json().catch(()=>null);
if(!d||!d.ok){takt()} // hat nicht geklappt: echten Stand neu holen
}
// Nur Ziffern und ein fuehrendes Plus taugen fuer tel:
const waehlbar=n=>String(n||'').replace(/[^\d+]/g,'').replace(/(?!^)\+/g,'');
function nummerLink(d){
const n=d.nummer, w=waehlbar(n);
if(!w)return '';
return `<a class="waehlen" href="tel:${esc(w)}" data-id="${esc(d.id)}"
title="Rückruf an ${esc(n)} — verschiebt die Karte in ${esc(STAPEL[1]||'')}">${esc(n)}</a>`;
}
function karte(d){
// Notfall zaehlt als Notfall, auch wenn aeltere Datensaetze die Dringlichkeit
// niedriger gesetzt haben.
const f=(d.kategorie==='notfall')?FARBE.notfall:(FARBE[d.dringlichkeit]||'#888');
const i=STAPEL.indexOf(d.stapel);
const knoepfe=STAPEL.map((s,j)=>j===i?'':
`<button data-id="${esc(d.id)}" data-ziel="${esc(s)}">${j<i?'':''} ${esc(s)}</button>`).join('');
const offen=offeneTranskripte.has(d.id)?' open':'';
// Notfall, solange nicht im letzten Stapel (= erledigt) - pulsiert als
// Blickfang, siehe pruefeNotfall() fuer den zugehoerigen Minuten-Ton.
const notfallOffen=d.dringlichkeit==='notfall'&&d.stapel!==STAPEL[STAPEL.length-1];
return `<article class="karte${notfallOffen?' notfall-offen':''}" draggable="true" data-id="${esc(d.id)}" style="--akzent:${f}">
<div class="kopf"><span class="titel">${esc(KAT[d.kategorie]||d.kategorie)} · ${esc(d.name||'unbekannt')}</span>
<span class="dring" style="background:${f}">${esc(d.kategorie==='notfall'?'notfall':d.dringlichkeit)}</span></div>
<div class="meta">${esc(d.empfangen.replace('T',' '))} · ${nummerLink(d)}${d.rueckruf?' · 📞 Rückruf':''}${d.kontakt?`<span class="kontakt-badge">✓ ${esc(d.kontakt)}</span>`:''}</div>
<p class="anliegen">${esc(d.anliegen)}</p>
<div>${(d.stichworte||[]).map(s=>`<span class="tag">${esc(s)}</span>`).join('')}</div>
${d.audio?`<audio controls preload="none" src="${d.audio}"></audio>`:''}
<details data-id="${esc(d.id)}"${offen}><summary>Transkript</summary><p class="tr">${esc(d.transkript)}</p></details>
<div class="schieber">${knoepfe}</div>
</article>`;
}
function zeichneBoard(){
const el=document.getElementById('board');
el.innerHTML=STAPEL.map(s=>{
const drin=ANRUFE.filter(a=>a.stapel===s);
return `<section class="spalte" data-stapel="${esc(s)}">
<h2>${esc(s)}<span class="zahl">${drin.length}</span></h2>
${drin.map(karte).join('')||'<p class="leer">leer</p>'}
</section>`}).join('');
el.querySelectorAll('.schieber button').forEach(b=>
b.onclick=e=>{e.stopPropagation();verschiebe(b.dataset.id,b.dataset.ziel)});
el.querySelectorAll('a.waehlen').forEach(a=>
a.onclick=()=>{ // der Link waehlt, wir ziehen nur nach
const d=ANRUFE.find(x=>x.id===a.dataset.id);
if(d&&STAPEL[1]&&d.stapel===STAPEL[0])verschiebe(d.id,STAPEL[1]);
});
el.querySelectorAll('details').forEach(d=>{
d.ontoggle=()=>{d.open?offeneTranskripte.add(d.dataset.id):offeneTranskripte.delete(d.dataset.id)};
});
el.querySelectorAll('.karte').forEach(k=>{
k.ondragstart=e=>{pausiert=true;k.classList.add('zieht');e.dataTransfer.setData('text/plain',k.dataset.id)};
k.ondragend=()=>{pausiert=false;k.classList.remove('zieht')};
});
el.querySelectorAll('.spalte').forEach(sp=>{
sp.ondragover=e=>{e.preventDefault();sp.classList.add('ueber')};
sp.ondragleave=()=>sp.classList.remove('ueber');
sp.ondrop=e=>{e.preventDefault();sp.classList.remove('ueber');pausiert=false;
verschiebe(e.dataTransfer.getData('text/plain'),sp.dataset.stapel)};
});
}
// Notfall-Alarm: solange mindestens ein Notfall offen ist (nicht im letzten
// Stapel), pulsiert die Karte (CSS) und alle 60s ein doppelter Piepton -
// zusaetzlich sofort einmal, wenn ein Notfall neu auftaucht. Browser
// blockieren Audio ohne vorherigen Klick auf die Seite (Autoplay-Policy) -
// deshalb der einmalige 'click'-Listener unten, der den AudioContext oeffnet.
let audioCtx=null, notfallOffenVorher=false;
function audioFreischalten(){
try{audioCtx=audioCtx||new (window.AudioContext||window.webkitAudioContext)()}catch(e){}
}
document.addEventListener('click',audioFreischalten,{once:true});
function piepton(){
if(!audioCtx)return; // noch nicht freigeschaltet - naechster Klick holt es nach
const einzelton=(zeit,frequenz)=>{
const o=audioCtx.createOscillator(), g=audioCtx.createGain();
o.type='square'; o.frequency.value=frequenz;
o.connect(g); g.connect(audioCtx.destination);
g.gain.setValueAtTime(0.0001,zeit);
g.gain.exponentialRampToValueAtTime(0.28,zeit+0.02);
g.gain.exponentialRampToValueAtTime(0.0001,zeit+0.45);
o.start(zeit); o.stop(zeit+0.46);
};
const jetzt=audioCtx.currentTime;
einzelton(jetzt,880);
einzelton(jetzt+0.3,1100);
}
function pruefeNotfall(){
const letzterStapel=STAPEL[STAPEL.length-1];
const offen=ANRUFE.filter(a=>a.dringlichkeit==='notfall'&&a.stapel!==letzterStapel);
const banner=document.getElementById('notfallBanner');
banner.classList.toggle('zeigen',offen.length>0);
if(offen.length){
document.getElementById('notfallText').textContent=offen.length===1
?'1 offene Notfall-Meldung'
:`${offen.length} offene Notfall-Meldungen`;
if(!notfallOffenVorher)piepton(); // sofort beim erstmaligen Auftreten
}
notfallOffenVorher=offen.length>0;
}
setInterval(()=>{if(notfallOffenVorher)piepton()},60000);
function zeichneStatus(s){
if(!s)return;
document.getElementById('stand').textContent='Stand '+s.stand;
document.getElementById('dienste').innerHTML=s.dienste.map(d=>
`<div class="dienst"><span class="punkt ${d.zustand}"></span>
<span><b>${esc(d.name)}</b><span>${esc(d.text)}</span></span></div>`).join('');
const h=[];
// Die Ampeln stehen unten - eine Stoerung muss trotzdem sofort auffallen.
s.dienste.filter(d=>d.zustand==='schlecht'||d.zustand==='aus')
.forEach(d=>h.push(`${d.name}: ${d.text}`));
if(s.eingang_offen>0)h.push(`${s.eingang_offen} Aufnahme(n) warten auf Verarbeitung.`);
if(s.fehler>0)h.push(`${s.fehler} Aufnahme(n) in telefon/fehler — nicht verarbeitet.`);
document.getElementById('hinweise').innerHTML=h.map(t=>`<div class="hinweis">${esc(t)}</div>`).join('');
}
function zeichneVerlauf(v){
if(!v)return;
const el=document.getElementById('verlauf');
const unten=el.scrollHeight-el.scrollTop-el.clientHeight<40;
el.innerHTML=v.map(e=>`<div class="z"><time>${esc((e.zeit||'').slice(11,19))}</time>
<span class="a a-${esc(e.art)}">${esc(e.art)}</span><span>${esc(e.text)}</span></div>`).join('')
||'<p class="leer">Noch nichts passiert.</p>';
if(unten)el.scrollTop=el.scrollHeight;
}
async function takt(){
if(pausiert)return; // nicht neu zeichnen, waehrend jemand zieht
// Auch nicht neu zeichnen, waehrend eine Aufnahme laeuft - zeichneBoard()
// ersetzt die Karten per innerHTML und reisst damit jedes spielende
// <audio>-Element mitten in der Wiedergabe weg (Abbruch nach <3s).
if(document.querySelector('#board audio:not([paused])'))return;
const [s,a,v]=await Promise.all([hole('/api/status'),hole('/api/anrufe'),hole('/api/verlauf')]);
zeichneStatus(s);
if(a){STAPEL=a.stapel;ANRUFE=a.anrufe;zeichneBoard();pruefeNotfall()}
zeichneVerlauf(v);
}
let toastZeit=null;
function toast(text){
const el=document.getElementById('toast');
el.textContent=text; el.classList.add('zeigen');
clearTimeout(toastZeit);
toastZeit=setTimeout(()=>el.classList.remove('zeigen'),3500);
}
// Sicher-Modus merkt sich den Zustand pro Browser (localStorage) - beim
// naechsten Aufruf (z.B. vor einer Bildschirmaufnahme) nicht vergessen.
const sicherBtn=document.getElementById('sicherBtn');
function sicherSetzen(an){
document.body.classList.toggle('sicher',an);
sicherBtn.classList.toggle('aktiv',an);
try{localStorage.setItem('sicherModus',an?'1':'0')}catch(e){}
}
try{sicherSetzen(localStorage.getItem('sicherModus')==='1')}catch(e){}
sicherBtn.onclick=()=>sicherSetzen(!document.body.classList.contains('sicher'));
document.getElementById('archivBtn').onclick=async()=>{
const btn=document.getElementById('archivBtn');
const letzter=STAPEL[STAPEL.length-1];
const anzahl=ANRUFE.filter(a=>a.stapel===letzter).length;
if(!anzahl){toast(`Nichts zu archivieren — ${letzter||'letzter Stapel'} ist leer.`);return}
if(!confirm(`${anzahl} Anruf(e) aus "${letzter}" archivieren? (simuliert — nichts wird geloescht)`))return;
btn.disabled=true;
const r=await fetch('/api/archivieren',{method:'POST'});
const d=await r.json().catch(()=>null);
btn.disabled=false;
if(d&&d.ok){toast(`✓ ${d.anzahl} Anruf(e) archiviert (simuliert)`);takt()}
else{toast('Archivieren fehlgeschlagen.')}
};
takt();setInterval(takt,3000);
</script></body></html>"""
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Live-Dashboard der Anruf-Pipe.")
p.add_argument("--port", type=int, default=config.LEITSTAND_PORT)
p.add_argument("--host", default=config.LEITSTAND_HOST,
help="0.0.0.0 macht den Leitstand im Netz erreichbar (dann ist "
"LEITSTAND_PASS Pflicht)")
args = p.parse_args(argv)
oeffentlich = args.host not in {"127.0.0.1", "localhost", "::1"}
if oeffentlich and not config.LEITSTAND_PASS:
print("Abbruch: Der Leitstand soll über das Netz erreichbar sein, aber es ist\n"
"kein LEITSTAND_PASS gesetzt. Auf den Seiten stehen Transkripte und\n"
"Aufnahmen von Patienten — ohne Passwort läge das für jeden im Netz offen.\n"
"Passwort in die .env eintragen und erneut starten.", file=sys.stderr)
return 2
server = ThreadingHTTPServer((args.host, args.port), Handler)
schutz = "mit Passwort" if config.LEITSTAND_PASS else "ohne Passwort (nur lokal)"
print(f"Leitstand: http://{args.host}:{args.port} [{schutz}] — Abbruch mit Strg-C")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nLeitstand beendet.")
return 0
if __name__ == "__main__":
raise SystemExit(main())