perf: /api/anrufe und /api/verlauf cachen, Dringlichkeit-Farben zentralisiert

Audit auf Optimierungspotenzial ergab:
- /api/anrufe las bei jeder Anfrage ALLE meta.json unter ablage/ neu (auch
  bereits archivierte, erst danach rausgefiltert) - waechst mit der
  Gesamtzahl je empfangener Anrufe, jetzt von bis zu 3 Logins alle 3s
  parallel gepollt.
- /api/verlauf las bei jeder Anfrage bis zu 300 KB aus dem FreeSWITCH-Log
  und scannte 4000 Zeilen per Regex, ebenfalls ungecacht.
Beide bekommen jetzt einen kurzen 2s-Cache (gleiches Muster wie
dienste.status()). Maskierung bei Testzugang arbeitet jetzt auf einer Kopie
(dict(a) je Eintrag) statt die gecachten Objekte in-place zu veraendern -
sonst haetten maskierte Rufnummern in die naechste, nicht maskierte Antwort
durchsickern koennen.

Dringlichkeit-Farben (rot/orange/gruen/grau) und der Sortierrang standen in
drei Dateien dupliziert (dashboard.py, deck.py, server.py-JS) - bei der
letzten Farbaenderung mussten alle drei von Hand synchron gehalten werden.
Jetzt eine Quelle in pipe/kategorien.py (DRINGLICHKEIT_FARBE,
DRINGLICHKEIT_RANG - Rang aus DRINGLICHKEITEN abgeleitet statt separat
gepflegt), server.py injiziert die Farben serverseitig wie schon die
Kategorie-Labels.

Getestet: RANG/FARBE identisch zum alten Stand, /api/anrufe und /api/verlauf
liefern bei zwei Anfragen innerhalb 2s dasselbe (gecachte) Ergebnis, Maskierung
veraendert nachweislich nur die Kopie.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
This commit is contained in:
Jeuner 2026-08-28 19:14:21 +02:00
parent ab2bd949be
commit 5c9f891c7e
4 changed files with 61 additions and 16 deletions

View file

@ -13,8 +13,8 @@ from pathlib import Path
from . import config, kategorien from . import config, kategorien
RANG = {"notfall": 0, "hoch": 1, "normal": 2, "niedrig": 3} RANG = kategorien.DRINGLICHKEIT_RANG
FARBE = {"notfall": "#E9322D", "hoch": "#E0A339", "normal": "#31CC7C", "niedrig": "#8A93A3"} FARBE = {k: f"#{v}" for k, v in kategorien.DRINGLICHKEIT_FARBE.items()}
KAT_LABEL = kategorien.KATEGORIE_LABEL KAT_LABEL = kategorien.KATEGORIE_LABEL

View file

@ -22,13 +22,8 @@ from . import config, kategorien
_API = "/index.php/apps/deck/api/v1.1" _API = "/index.php/apps/deck/api/v1.1"
# Farben (6-stelliges Hex ohne #) je Dringlichkeit. # Farben je Dringlichkeit - siehe pipe/kategorien.py.
DRINGLICHKEIT_FARBE = { DRINGLICHKEIT_FARBE = kategorien.DRINGLICHKEIT_FARBE
"niedrig": "8A93A3", # grau
"normal": "31CC7C", # grün
"hoch": "E0A339", # orange
"notfall": "E9322D", # rot
}
# Lesbare Namen der Kategorien für den Kartentitel - der Stapel sagt jetzt, # Lesbare Namen der Kategorien für den Kartentitel - der Stapel sagt jetzt,
# wie weit die Bearbeitung ist, nicht mehr worum es geht. # wie weit die Bearbeitung ist, nicht mehr worum es geht.
KATEGORIE_LABEL = kategorien.KATEGORIE_LABEL KATEGORIE_LABEL = kategorien.KATEGORIE_LABEL

View file

@ -6,9 +6,10 @@ Label) dieselbe Quelle nutzen - ein neues Profil ändert nur die JSON-Datei,
kein Python-Code. kein Python-Code.
Dringlichkeitsstufen sind bewusst NICHT Teil des Profils: die vierstufige Dringlichkeitsstufen sind bewusst NICHT Teil des Profils: die vierstufige
Eskalationsskala (niedrig/normal/hoch/notfall) ist einsatzzweck-unabhängig Eskalationsskala (niedrig/normal/hoch/notfall) ist einsatzzweck-unabhängig.
und steckt fest in Farben/Reihenfolge in dashboard.py, deck.py und der Farbe und Sortierrang je Stufe stehen deshalb ebenfalls hier - eine Quelle
Leitstand-Oberfläche - nur die Kategorien selbst unterscheiden sich fachlich. für dashboard.py, deck.py und den Leitstand (server.py), statt an drei
Stellen von Hand synchron zu halten.
""" """
from __future__ import annotations from __future__ import annotations
@ -18,6 +19,17 @@ from functools import lru_cache
from . import config from . import config
DRINGLICHKEITEN = ["niedrig", "normal", "hoch", "notfall"] DRINGLICHKEITEN = ["niedrig", "normal", "hoch", "notfall"]
# Farben (6-stelliges Hex ohne "#" - so will es Nextcloud Deck; dashboard.py
# und server.py stellen sich das "#" selbst voran).
DRINGLICHKEIT_FARBE = {
"niedrig": "8A93A3", # grau - unwichtig
"normal": "31CC7C", # grün
"hoch": "E0A339", # orange
"notfall": "E9322D", # rot
}
# Sortierrang (0 = dringlichst zuerst) - aus DRINGLICHKEITEN abgeleitet statt
# separat gepflegt.
DRINGLICHKEIT_RANG = {d: i for i, d in enumerate(reversed(DRINGLICHKEITEN))}
@lru_cache(maxsize=1) @lru_cache(maxsize=1)

View file

@ -26,7 +26,26 @@ from urllib.parse import parse_qs, unquote, urlparse
from . import bewertung, config, dashboard, dienste, export, kategorien, modellwahl, protokoll, stapel, testzugang from . import bewertung, config, dashboard, dienste, export, kategorien, modellwahl, protokoll, stapel, testzugang
# Anrufe() liest alle meta.json unter ablage/ (auch bereits archivierte, die
# erst danach rausgefiltert werden) - waechst mit der Gesamtzahl je
# empfangener Anrufe. Mehrere angemeldete Nutzer pollen das alle 3s parallel
# (siehe takt() im Frontend) - kurz zwischenspeichern, damit das nicht mit
# der Nutzerzahl skaliert.
_ANRUFE_CACHE: dict[str, tuple[float, list[dict]]] = {}
ANRUFE_CACHE_S = 2.0
def anrufe() -> list[dict]: def anrufe() -> list[dict]:
jetzt = time.time()
gepuffert = _ANRUFE_CACHE.get("anrufe")
if gepuffert and jetzt - gepuffert[0] < ANRUFE_CACHE_S:
return gepuffert[1]
liste = _anrufe_berechnen()
_ANRUFE_CACHE["anrufe"] = (jetzt, liste)
return liste
def _anrufe_berechnen() -> list[dict]:
liste = [] liste = []
for d in dashboard._anrufe(): for d in dashboard._anrufe():
e = d["auswertung"] e = d["auswertung"]
@ -138,11 +157,24 @@ def _telefon_ereignisse(anzahl: int = 60) -> list[dict]:
return ereignisse[-anzahl:] return ereignisse[-anzahl:]
# _telefon_ereignisse() liest bis zu 300 KB aus dem FreeSWITCH-Log und scannt
# bis zu 4000 Zeilen per Regex - bei mehreren Nutzern, die alle 3s pollen,
# unnoetig oft. Gleicher kurzer Cache wie bei anrufe().
_VERLAUF_CACHE: dict[str, tuple[float, list[dict]]] = {}
VERLAUF_CACHE_S = 2.0
def verlauf(anzahl: int = 150) -> list[dict]: def verlauf(anzahl: int = 150) -> list[dict]:
"""Pipe-Protokoll und Telefonie-Ereignisse, zeitlich zusammengeführt.""" """Pipe-Protokoll und Telefonie-Ereignisse, zeitlich zusammengeführt."""
jetzt = time.time()
gepuffert = _VERLAUF_CACHE.get("verlauf")
if gepuffert and jetzt - gepuffert[0] < VERLAUF_CACHE_S:
return gepuffert[1]
alle = protokoll.lies(anzahl) + _telefon_ereignisse() alle = protokoll.lies(anzahl) + _telefon_ereignisse()
alle.sort(key=lambda e: e.get("zeit") or "") alle.sort(key=lambda e: e.get("zeit") or "")
return alle[-anzahl:] ergebnis = alle[-anzahl:]
_VERLAUF_CACHE["verlauf"] = (jetzt, ergebnis)
return ergebnis
# Bei Testzugang (kein Passwort, oeffentlicher Link) werden Rufnummern nicht # Bei Testzugang (kein Passwort, oeffentlicher Link) werden Rufnummern nicht
@ -259,14 +291,18 @@ class Handler(BaseHTTPRequestHandler):
daten["nutzer"] = self._nutzer daten["nutzer"] = self._nutzer
self._json(daten) self._json(daten)
elif pfad == "/api/anrufe": elif pfad == "/api/anrufe":
# anrufe() ist gecacht (mehrere Nutzer pollen alle 3s parallel) -
# bei Testzugang auf einer Kopie maskieren, sonst wuerden die
# ersetzten Rufnummern die naechste (nicht maskierte) Antwort
# verseuchen.
liste = anrufe() liste = anrufe()
if getattr(self, "_via_token", False): if getattr(self, "_via_token", False):
liste = _maskiere_anrufe(liste) liste = _maskiere_anrufe([dict(a) for a in liste])
self._json({"stapel": stapel.erlaubt(), "anrufe": liste}) self._json({"stapel": stapel.erlaubt(), "anrufe": liste})
elif pfad == "/api/verlauf": elif pfad == "/api/verlauf":
eintraege = verlauf(150) eintraege = verlauf(150)
if getattr(self, "_via_token", False): if getattr(self, "_via_token", False):
eintraege = _maskiere_verlauf(eintraege) eintraege = _maskiere_verlauf([dict(e) for e in eintraege])
self._json(eintraege) self._json(eintraege)
elif pfad == "/api/modell": elif pfad == "/api/modell":
self._json({"aktuell": modellwahl.aktuelle_id(), "optionen": modellwahl.auswahl()}) self._json({"aktuell": modellwahl.aktuelle_id(), "optionen": modellwahl.auswahl()})
@ -621,7 +657,7 @@ color:var(--gut);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:
</div> </div>
</div> </div>
<script> <script>
const FARBE={notfall:'#d93a34',hoch:'#e08a2a',normal:'#1f9d5c',niedrig:'#8a93a3'}; const FARBE=__FARBE_JSON__;
const KAT=__KAT_JSON__; const KAT=__KAT_JSON__;
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
let STAPEL=[], ANRUFE=[], offeneTranskripte=new Set(), pausiert=false; let STAPEL=[], ANRUFE=[], offeneTranskripte=new Set(), pausiert=false;
@ -1041,6 +1077,8 @@ def _seite() -> str:
zusammengesetzt. zusammengesetzt.
""" """
seite = SEITE.replace("__KAT_JSON__", json.dumps(kategorien.KATEGORIE_LABEL, ensure_ascii=False)) seite = SEITE.replace("__KAT_JSON__", json.dumps(kategorien.KATEGORIE_LABEL, ensure_ascii=False))
farben = {k: f"#{v}" for k, v in kategorien.DRINGLICHKEIT_FARBE.items()}
seite = seite.replace("__FARBE_JSON__", json.dumps(farben, ensure_ascii=False))
return seite.replace("__PROFIL_NAME__", html.escape(kategorien.PROFIL_NAME)) return seite.replace("__PROFIL_NAME__", html.escape(kategorien.PROFIL_NAME))