diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index f107f40..f94f43a 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.8.24" +version = "2.8.25" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/maintenance.py b/apps/tesm/src/tesm/blueprints/maintenance.py index b6df018..8b0f0ec 100644 --- a/apps/tesm/src/tesm/blueprints/maintenance.py +++ b/apps/tesm/src/tesm/blueprints/maintenance.py @@ -174,7 +174,7 @@ def status(): # Geraete zu uebertragen. "output": ( service.letzte_zeilen(str(job["output"] or "")) - if job["state"] in {"pending", "running"} + if service.eben_fertig(dict(job)) else "" ), } diff --git a/apps/tesm/src/tesm/blueprints/screen.py b/apps/tesm/src/tesm/blueprints/screen.py index ffed11b..533c0d7 100644 --- a/apps/tesm/src/tesm/blueprints/screen.py +++ b/apps/tesm/src/tesm/blueprints/screen.py @@ -70,6 +70,30 @@ def _geraet(conn: Any, device_id: int) -> dict[str, Any] | None: return device +def _erklaerung(fehler: OSError) -> str: + """Die Fehlernummer in einem Satz, der weiterhilft. + + "No route to host" und "Connection refused" sehen im Protokoll gleich + harmlos aus und bedeuten das Gegenteil voneinander: einmal kommt man gar + nicht hin, einmal kommt man hin und niemand hoert zu. Und beides hat mit + einem gelungenen Ping nichts zu tun -- der prueft eine andere Schicht. + """ + import errno as fehlernummern + + nummer = getattr(fehler, "errno", None) + if nummer == fehlernummern.EHOSTUNREACH: + return ( + f"{fehler} – kein Weg dorthin. Eine Firewall oder eine Route steht " + "dazwischen; ein gelungener Ping sagt darüber nichts, der prüft eine " + "andere Schicht" + ) + if nummer == fehlernummern.ECONNREFUSED: + return f"{fehler} – das Gerät ist erreichbar, aber auf diesem Port hört niemand" + if nummer == fehlernummern.ETIMEDOUT: + return f"{fehler} – keine Antwort; Paketfilter verwerfen meist stillschweigend" + return str(fehler) + + def _port(conn: Any) -> int: extension = core() if not extension.settings.get(conn, "vnc_enabled"): @@ -314,10 +338,26 @@ def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def] ws.close(1008, "Für diesen Client ist kein Ziel bekannt.") return - try: - gegenstelle = socket.create_connection((host, port), timeout=VERBINDUNGSLIMIT) - except OSError as exc: - ws.close(1011, f"{host}:{port} antwortet nicht ({exc}).") + gegenstelle = None + ausweich = str(device.get("ip") or "").strip() + for versuch, ziel_host in enumerate([host] + ([ausweich] if ausweich != host else [])): + try: + gegenstelle = socket.create_connection((ziel_host, port), timeout=VERBINDUNGSLIMIT) + if versuch: + # Der Name fuehrte ins Leere, die Adresse nicht. Das ist eine + # Auskunft ueber das Netz, keine Nebensaechlichkeit: irgendwo + # zeigt ein Eintrag woandershin. + if live_log := extension.registry.get("live_log"): + live_log.warning( + "vnc", + f"{host} war nicht erreichbar – Sitzung läuft über {ziel_host}", + ) + host = ziel_host + break + except OSError as exc: + letzter = exc + if gegenstelle is None: + ws.close(1011, f"{host}:{port} antwortet nicht ({_erklaerung(letzter)}).") return # Das Zeitlimit galt dem *Aufbau* -- als Abbruchgrund taugt es nicht. # diff --git a/apps/tesm/src/tesm/services/maintenance.py b/apps/tesm/src/tesm/services/maintenance.py index 601fc5c..81dcd9e 100644 --- a/apps/tesm/src/tesm/services/maintenance.py +++ b/apps/tesm/src/tesm/services/maintenance.py @@ -495,6 +495,11 @@ def run_update( ) +#: So lange gilt ein Auftrag als "eben fertig geworden" und schickt seine +#: letzten Zeilen noch mit. Ohne das verschwaende die Ausgabe genau in dem +#: Augenblick, in dem sie interessant wird: wenn der Befehl durch ist. +EBEN_FERTIG_SEKUNDEN = 120 + #: So viele Zeilen gehen ins Mitlesen. Genug, um zu sehen, was gerade #: passiert; wenig genug, dass eine Abfrage alle zwei Sekunden nichts kostet. MITLESEN_ZEILEN = 40 @@ -506,6 +511,22 @@ def letzte_zeilen(ausgabe: str, anzahl: int = MITLESEN_ZEILEN) -> str: return "\n".join(zeilen[-anzahl:]) +def eben_fertig(job: dict[str, Any], sekunden: int = EBEN_FERTIG_SEKUNDEN) -> bool: + """Ist dieser Auftrag gerade eben fertig geworden?""" + if job.get("state") in {"pending", "running"}: + return True + stempel = str(job.get("finished_at") or job.get("updated_at") or "") + if not stempel: + return False + try: + moment = datetime.fromisoformat(stempel) + except ValueError: + return False + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return (datetime.now(timezone.utc) - moment).total_seconds() < sekunden + + def befehl_pruefen(befehl: str) -> str: """Gibt den bereinigten Befehl zurueck -- oder wirft mit Klartext. diff --git a/apps/tesm/src/tesm/templates/tesm/maintenance.html b/apps/tesm/src/tesm/templates/tesm/maintenance.html index fbcd71d..51d7d04 100644 --- a/apps/tesm/src/tesm/templates/tesm/maintenance.html +++ b/apps/tesm/src/tesm/templates/tesm/maintenance.html @@ -226,13 +226,13 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por GestartetGerätArtZustandFortschrittMeldung {% for job in jobs %} - + {{ job.created_at | dt }} {{ job.subject }} {{ 'Aktualisierung' if job.kind.endswith('update') else 'Neustart' }} - + {% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }} {% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }} {% elif job.state == 'running' %}{{ pill('läuft', 'info') }} @@ -240,11 +240,12 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por {% else %}{{ pill('wartet', '') }}{% endif %} -
+
- {{ job.message }} + {{ job.message }} {% endfor %} @@ -312,7 +313,42 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por if (text && zeile.hidden && !offen.has(name)) zeile.hidden = false; } - let liefEtwas = false; + // Zustand, Fortschritt und Meldung eines Auftrags nachziehen. + // + // Vorher lud die Seite sich dafür komplett neu, sobald der letzte Auftrag + // durch war -- und riss damit genau das weg, was man lesen wollte: die + // Ausgabe stand da, dann sprang die Seite, und danach war sie leer. + const ZUSTAENDE = { + succeeded: ["erfolgreich", "pill--online", "progress--success"], + failed: ["fehlgeschlagen", "pill--danger", "progress--danger"], + running: ["läuft", "pill--info", ""], + unknown: ["unklar", "pill--warning", ""], + pending: ["wartet", "", ""], + }; + + function auftragZeigen(job) { + const zeile = document.querySelector(`[data-auftrag="${CSS.escape(job.id)}"]`); + if (!zeile) return false; + const [text, pillenKlasse, balkenKlasse] = ZUSTAENDE[job.state] || ZUSTAENDE.pending; + const pille = zeile.querySelector("[data-auftrag-zustand] .pill"); + if (pille && pille.textContent.trim() !== text) { + pille.textContent = text; + pille.className = `pill ${pillenKlasse}`.trim(); + } + const balken = zeile.querySelector("[data-auftrag-balken]"); + if (balken) { + balken.className = `progress ${balkenKlasse}`.trim(); + const inner = balken.querySelector(".progress__bar"); + if (inner) inner.style.width = `${job.progress || 0}%`; + } + const meldung = zeile.querySelector("[data-auftrag-meldung]"); + if (meldung && meldung.textContent.trim() !== (job.message || "")) { + meldung.textContent = job.message || ""; + } + return true; + } + + let neueAuftraege = false; async function poll() { let weiter = 10000; try { @@ -320,6 +356,10 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por weiter = state.poll_ms || 10000; state.jobs.forEach((job) => { if (job.output) zeigen(job.subject, job.output); + // Ein Auftrag, der in der Tabelle noch gar nicht steht, ist neu -- + // dafür braucht es die Seite einmal frisch, aber erst, wenn nichts + // mehr läuft. + if (!auftragZeigen(job)) neueAuftraege = true; }); // Erreichbarkeit mitziehen -- sonst steht die Seite mit dem Stand ihres // Aufbaus da, und wer nach einem Neustart auf die Rückkehr wartet, sieht @@ -339,13 +379,13 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por pille.className = `pill ${art}`; } }); - // Erst wenn der letzte Auftrag durch ist, einmal neu laden: dann stimmt - // auch die Auftragsliste daneben. - if (liefEtwas && !state.running) { - location.reload(); - return; + // Neu laden nur, wenn Aufträge dazugekommen sind, die in der Tabelle + // fehlen -- und auch dann erst, wenn nichts mehr läuft. Wer zusieht, + // soll nicht mitten im Lesen weggerissen werden. + if (neueAuftraege && !state.running) { + window.setTimeout(() => location.reload(), 8000); + neueAuftraege = false; } - liefEtwas = state.running; } catch { /* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */ }