diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index f53f253..d394130 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.26" +version = "2.8.27" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/devices.py b/apps/tesm/src/tesm/blueprints/devices.py index f85e71f..1c9f7e8 100644 --- a/apps/tesm/src/tesm/blueprints/devices.py +++ b/apps/tesm/src/tesm/blueprints/devices.py @@ -318,12 +318,16 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ # Der Auftrag laeuft im Hintergrund und meldet sein Ergebnis erst, wenn das # Geraet zurueck ist. Der Zustand muss aber sofort umschlagen -- sonst sieht # die Uebersicht aus wie eine, in der nichts passiert ist. + live = extension.registry.get("live_log") with transaction(): - monitor.mark_restarting(conn, int(device["id"])) + monitor.mark_restarting( + conn, int(device["id"]), live=live, trigger="manuell", method=method.key + ) maintenance_service.start_background( maintenance_service.run_reboot, database=extension.database, + live=live, job_id=handle.id, device=dict(device), username=username, diff --git a/apps/tesm/src/tesm/blueprints/logs.py b/apps/tesm/src/tesm/blueprints/logs.py index e70e574..24a835d 100644 --- a/apps/tesm/src/tesm/blueprints/logs.py +++ b/apps/tesm/src/tesm/blueprints/logs.py @@ -66,4 +66,5 @@ def restarts(): method=method, methods=tuple(monitor.METHOD_LABELS.items()), method_labels=monitor.METHOD_LABELS, + trigger_labels=monitor.TRIGGER_LABELS, ) diff --git a/apps/tesm/src/tesm/blueprints/maintenance.py b/apps/tesm/src/tesm/blueprints/maintenance.py index 8b0f0ec..d8960fe 100644 --- a/apps/tesm/src/tesm/blueprints/maintenance.py +++ b/apps/tesm/src/tesm/blueprints/maintenance.py @@ -83,6 +83,7 @@ def overview(): # noqa: C901 - eine Seite, drei Aufgaben "tesm/maintenance.html", devices=_eligible_devices(conn), jobs=service.list_jobs(conn), + auftragslage=service.letzter_auftrag_je_geraet(conn), category_labels=dict(inventory.CREDENTIAL_CATEGORIES), category_effects=inventory.CATEGORY_EFFECTS, update_category=inventory.MAINTENANCE_CATEGORY, @@ -145,7 +146,7 @@ def install_windows_tool(): def status(): """Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher.""" conn = db() - jobs = service.list_jobs(conn, limit=30) + jobs = service.laufende_und_letzte(conn, limit=30) running = any(job["state"] in {"pending", "running"} for job in jobs) return jsonify( { @@ -282,27 +283,35 @@ def _start(kind: str, befehl: str = "") -> None: timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900), ) else: + weg = ( + service.METHOD_RPC + if str(device["category"]) == inventory.NON_SSH_CATEGORY + else service.METHOD_SSH + ) # Sofort sichtbar, nicht erst wenn das Geraet zurueck ist. with transaction() as verbindung: - monitor.mark_restarting(verbindung, int(device["id"])) + monitor.mark_restarting( + verbindung, + int(device["id"]), + live=extension.registry.get("live_log"), + trigger="maintenance", + method=weg, + ) service.start_background( service.run_reboot, database=extension.database, + live=extension.registry.get("live_log"), job_id=handle.id, device=dict(device), username=username, password=password, wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300), actor=current_user.username, - # Hier steht in ``category`` die Kategorie der *Zugangsdaten* - # (siehe _query_devices) -- daraus folgt der Weg. Uebergeben - # statt in run_reboot erraten: dort bedeutet derselbe - # Schluessel je Herkunft etwas anderes. - method=( - service.METHOD_RPC - if str(device["category"]) == inventory.NON_SSH_CATEGORY - else service.METHOD_SSH - ), + # Der Weg steht oben: in ``category`` liegt die Kategorie der + # *Zugangsdaten* (siehe _query_devices), daraus folgt er. + # Uebergeben statt in run_reboot erraten -- dort bedeutet + # derselbe Schluessel je Herkunft etwas anderes. + method=weg, ) started += 1 diff --git a/apps/tesm/src/tesm/navigation.py b/apps/tesm/src/tesm/navigation.py index f44fa31..40167bc 100644 --- a/apps/tesm/src/tesm/navigation.py +++ b/apps/tesm/src/tesm/navigation.py @@ -128,7 +128,7 @@ NAVIGATION = NavTree( children=( NavItem( key="logs_live", - label="Laufend", + label="Live", icon="terminal", endpoint="logs.live", permissions=("logs_live.read",), diff --git a/apps/tesm/src/tesm/services/maintenance.py b/apps/tesm/src/tesm/services/maintenance.py index 81dcd9e..993cb2d 100644 --- a/apps/tesm/src/tesm/services/maintenance.py +++ b/apps/tesm/src/tesm/services/maintenance.py @@ -29,7 +29,7 @@ import sqlite3 import threading import time from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any, Callable from tesm_core import audit @@ -284,11 +284,81 @@ def list_jobs( placeholders = ",".join("?" * len(kinds)) return Database.all( conn, - f"SELECT * FROM jobs WHERE kind IN ({placeholders}) ORDER BY created_at DESC LIMIT ?", + # ``rowid`` als zweites Ordnungsmerkmal: ``created_at`` hat + # Sekundenaufloesung, und ein Massenbefehl legt vierzig Auftraege in + # derselben Sekunde an. Ohne das entschied bei Gleichstand die + # Speicherreihenfolge -- die Liste sah bei jedem Aufruf anders aus. + f"SELECT * FROM jobs WHERE kind IN ({placeholders}) " + "ORDER BY created_at DESC, rowid DESC LIMIT ?", (*kinds, limit), ) +def laufende_und_letzte( + conn: sqlite3.Connection, + *, + kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT, JOB_KIND_COMMAND), + limit: int = 30, +) -> list[dict[str, Any]]: + """Alles, was laeuft oder eben fertig wurde -- und dazu die letzten Auftraege. + + Fuer die Statusabfrage der Wartungsseite. Eine feste Obergrenze allein + reicht dort nicht, und das war ein echter Fehler: ein Massenbefehl auf + vierzig Geraete legt vierzig Auftraege an, ``LIMIT 30`` schnitt zehn davon + ab -- und weil alle denselben Zeitstempel auf die Sekunde tragen, waren es + zehn willkuerliche. Auf der Seite sah das aus, als haetten die letzten + Geraete den Befehl nie bekommen: sie liefen, ihre Ausgabe kam nur nie an. + + Was laeuft, faellt hier deshalb nie heraus, und was gerade fertig wurde, + behaelt seine letzten Zeilen so lange wie ``eben_fertig`` es zusagt. + """ + platzhalter = ",".join("?" * len(kinds)) + schwelle = ( + datetime.now(timezone.utc) - timedelta(seconds=EBEN_FERTIG_SEKUNDEN) + ).isoformat(timespec="seconds") + gesammelt: dict[str, dict[str, Any]] = {} + for sql, params in ( + ( + f"SELECT * FROM jobs WHERE kind IN ({platzhalter}) " + "AND state IN ('pending','running') ORDER BY created_at DESC, rowid DESC", + (*kinds,), + ), + ( + f"SELECT * FROM jobs WHERE kind IN ({platzhalter}) " + "AND COALESCE(finished_at, updated_at) >= ? ORDER BY created_at DESC, rowid DESC", + (*kinds, schwelle), + ), + ( + f"SELECT * FROM jobs WHERE kind IN ({platzhalter}) " + "ORDER BY created_at DESC, rowid DESC LIMIT ?", + (*kinds, limit), + ), + ): + for job in Database.all(conn, sql, params): + gesammelt.setdefault(str(job["id"]), job) + return sorted( + gesammelt.values(), key=lambda job: str(job["created_at"] or ""), reverse=True + ) + + +def letzter_auftrag_je_geraet( + conn: sqlite3.Connection, + *, + kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT, JOB_KIND_COMMAND), + limit: int = 400, +) -> dict[str, dict[str, Any]]: + """Zu jedem Geraetenamen sein neuester Auftrag. + + Damit die Wartungsseite schon beim Aufbau zeigt, was wo laeuft -- und nicht + erst, wenn die erste Statusabfrage zurueck ist. Der Schluessel ist der + Name, denn genau den traegt ein Auftrag als ``subject``. + """ + stand: dict[str, dict[str, Any]] = {} + for job in list_jobs(conn, kinds=kinds, limit=limit): + stand.setdefault(str(job["subject"]), job) + return stand + + def get_job(conn: sqlite3.Connection, job_id: str) -> dict[str, Any] | None: return Database.one(conn, "SELECT * FROM jobs WHERE id=?", (job_id,)) @@ -648,6 +718,7 @@ def _reboot_windows( actor: str, emit: Callable[[str], None], elapsed: Callable[[], int], + live: Any = None, ) -> bool: """Neustart eines Windows-Geraets ueber RPC. ``True`` = weiter warten. @@ -678,6 +749,7 @@ def _reboot_windows( duration_ms=elapsed(), detail=detail, method=METHOD_RPC, + live=live, ) return False @@ -692,6 +764,7 @@ def run_reboot( wait_seconds: int, actor: str = "system", method: str = METHOD_SSH, + live: Any = None, ) -> None: """Loest einen Neustart aus und wartet auf die Rueckkehr. @@ -744,6 +817,7 @@ def run_reboot( actor=actor, emit=emit, elapsed=elapsed, + live=live, ): return else: @@ -767,6 +841,7 @@ def run_reboot( duration_ms=elapsed(), detail=str(exc), method=method, + live=live, ) return @@ -809,6 +884,7 @@ def run_reboot( duration_ms=elapsed(), detail=problem, method=method, + live=live, ) return @@ -849,6 +925,7 @@ def run_reboot( duration_ms=elapsed(), detail="Neustart über SSH, Gerät ist zurückgekehrt.", method=method, + live=live, ) return time.sleep(REBOOT_POLL_SECONDS) @@ -873,7 +950,8 @@ def run_reboot( f"{wait_seconds}s nicht wieder geantwortet." ), method=method, - ) + live=live, + ) def _log_restart( @@ -885,6 +963,7 @@ def _log_restart( duration_ms: int, detail: str, method: str = METHOD_SSH, + live: Any = None, ) -> None: """Traegt den Neustart in die geraeteweite Neustart-Historie ein. @@ -907,6 +986,7 @@ def _log_restart( duration_ms=duration_ms, detail=detail, method=method, + live=live, ) except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen logger.exception("Neustart konnte nicht protokolliert werden (Gerät %s)", device_id) diff --git a/apps/tesm/src/tesm/services/monitor.py b/apps/tesm/src/tesm/services/monitor.py index 72eb404..a7cb98a 100644 --- a/apps/tesm/src/tesm/services/monitor.py +++ b/apps/tesm/src/tesm/services/monitor.py @@ -156,6 +156,16 @@ class LiveLog: def info(self, scope: str, message: str) -> None: self.write("INFO", scope, message) + def notice(self, scope: str, message: str) -> None: + """Etwas ist passiert, und es ist in Ordnung -- aber es gehoert gesehen. + + Zwischen "laeuft" und "stimmt etwas nicht" fehlte eine Stufe. Ein + Geraet, das neu gestartet wird, ist kein Fehler; es einfach als INFO + zwischen zweihundert Erreichbarkeitsmeldungen zu stellen heisst aber, + dass es niemand findet. + """ + self.write("HINWEIS", scope, message) + def warning(self, scope: str, message: str) -> None: self.write("WARNUNG", scope, message) @@ -278,6 +288,39 @@ METHOD_LABELS = { METHOD_RPC: "Neustart über RPC", } +#: Wer den Neustart ausgeloest hat -- in Worten statt in Schluesseln. +#: +#: Die Schluessel stehen so in der Datenbank und bleiben dort; eine Spalte +#: umzuschreiben, um eine Anzeige zu verbessern, waere der falsche Weg. Die +#: Uebersetzung gehoert an genau eine Stelle, sonst heisst derselbe Vorgang im +#: Protokoll "auto" und auf der Seite "automatisch". +TRIGGER_LABELS = { + "auto": "automatisch", + "manuell": "manuell", + "maintenance": "aus der Wartung", +} + + +def trigger_label(trigger: str) -> str: + """Der Ausloeser als Wort -- unbekannte Schluessel bleiben, wie sie sind.""" + return TRIGGER_LABELS.get(str(trigger or ""), str(trigger or "unbekannt")) + + +def _geraet_kurz(conn: sqlite3.Connection, device_id: int) -> str: + """``Name (IP)`` fuer das laufende Protokoll. + + Die Neustart-Funktionen bekommen nur eine Kennung; im Protokoll steht aber + eine Zeile, die ein Mensch liest -- und "Geraet 17 wird neu gestartet" + hilft niemandem. + """ + row = conn.execute("SELECT name, ip FROM devices WHERE id=?", (int(device_id),)).fetchone() + if row is None: + return f"Gerät {device_id}" + name = str(row["name"] or "").strip() or f"Gerät {device_id}" + ip = str(row["ip"] or "").strip() + return f"{name} ({ip})" if ip else name + + #: Zustand zwischen ausgeloestem Neustart und Rueckkehr. #: #: Ohne ihn blieb die Kachel auf "online" stehen: der Zustand wechselt erst @@ -303,6 +346,7 @@ def record_restart( duration_ms: int, detail: str, method: str = METHOD_POE, + live: Any = None, ) -> None: """Schreibt einen Neustart ins Protokoll -- unabhaengig davon, wie er lief. @@ -311,6 +355,12 @@ def record_restart( nur in ``method``. Vorher gab es nur die PoE-Haelfte, und ein per Wartung neu gestartetes Geraet tauchte in der Neustart-Historie ueberhaupt nicht auf. + + Mit ``live`` steht das Ergebnis auch im laufenden Protokoll. Diese + Funktion ist der Trichter, durch den **jeder** Neustart laeuft -- PoE + automatisch wie von Hand, SSH und RPC aus der Wartung. Die Zeile hier zu + schreiben statt bei jeder Quelle einzeln ist der Unterschied zwischen + "alle Wege sind abgedeckt" und "alle Wege, an die jemand gedacht hat". """ conn.execute( "INSERT INTO restart_events " @@ -318,6 +368,17 @@ def record_restart( "VALUES (?,?,?,?,?,?,?,?,?)", (device_id, _now(), trigger, actor, action, result, duration_ms, detail[:8000], method), ) + if live is not None: + wer = _geraet_kurz(conn, device_id) + weg = METHOD_LABELS.get(method, method) + if result == "ok": + live.info("neustart", f"{wer}: Neustart über {weg} erfolgreich") + elif result == "skipped": + # Warnstufe: ein Geraet, das haette neu gestartet werden sollen und + # es nicht wurde, ist kein Hinweis am Rande. + live.warning("neustart", f"{wer}: Neustart nicht ausgeführt – {detail}") + else: + live.error("neustart", f"{wer}: Neustart fehlgeschlagen – {detail}") if result == "ok": # Hier faellt nur noch der Zaehler und der Zeitstempel an. Den Zustand # setzt ``mark_restarting`` schon beim Ausloesen -- dieses Ergebnis @@ -330,7 +391,14 @@ def record_restart( ) -def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None: +def mark_restarting( + conn: sqlite3.Connection, + device_id: int, + *, + live: Any = None, + trigger: str = "", + method: str = METHOD_POE, +) -> None: """Setzt den Zustand auf "startet neu" -- beim **Ausloesen**, nicht am Ende. Ein Neustart ueber SSH oder RPC laeuft als Auftrag: Kommando schicken, dann @@ -342,6 +410,12 @@ def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None: Absicht. Und ohne ``last_restart_at``: das Kulanzfenster laeuft ab dem Zeitpunkt, an dem der Neustart tatsaechlich ausgeloest wurde (``record_restart``). + + Mit ``live`` geht der Beginn ins laufende Protokoll -- als Hinweis, nicht + als Warnung: ein Neustart ist kein Stoerfall, aber er erklaert die Luecke, + die gleich in der Erreichbarkeit steht. Dass die Zeile hier entsteht und + nicht bei den Aufrufern, ist Absicht: dies ist der Punkt, an dem ein + Neustart **ausgeloest** wird, gleich von welcher Quelle. """ conn.execute( """ @@ -355,6 +429,12 @@ def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None: """, (device_id, STATE_RESTARTING, _now(), _now()), ) + if live is not None: + weg = METHOD_LABELS.get(method, method) + woher = f" ({trigger_label(trigger)})" if trigger else "" + live.notice( + "neustart", f"{_geraet_kurz(conn, device_id)} wird neu gestartet – {weg}{woher}" + ) # --------------------------------------------------------------------------- # @@ -402,7 +482,9 @@ def restart_device_poe( # wie fuer den automatischen Neustart, denn beide laufen hier durch. if port and switch_ip: with write(): - mark_restarting(conn, int(device["id"])) + mark_restarting( + conn, int(device["id"]), live=live, trigger=trigger, method=METHOD_POE + ) if not port or not switch_ip: message = ( @@ -418,6 +500,7 @@ def restart_device_poe( actor=actor, duration_ms=0, detail=message, + live=live, ) return {"ok": False, "message": message, "transcript": ""} @@ -462,9 +545,8 @@ def restart_device_poe( actor=actor, duration_ms=duration, detail=str(exc), + live=live, ) - if live: - live.error("poe", f"{name}: {exc}") return {"ok": False, "message": str(exc), "transcript": ""} duration = int((time.monotonic() - started) * 1000) @@ -478,12 +560,8 @@ def restart_device_poe( actor=actor, duration_ms=duration, detail=(outcome.error or "; ".join(outcome.steps))[:8000], + live=live, ) - if live: - if outcome.ok: - live.info("poe", f"{name}: PoE-Neustart auf {device.get('switch_hostname')} {port} erfolgreich") - else: - live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}") return { "ok": outcome.ok, "message": outcome.error or "PoE-Neustart durchgeführt.", @@ -641,10 +719,22 @@ def run_sweep( name = str(device["name"]) if not license_allows_auto_restart: report.skipped_unlicensed += 1 - live.warning( - "poe", - f"{name}: automatischer PoE-Neustart übersprungen – keine gültige Lizenz.", - ) + # Als Zeile in der Neustart-Historie, nicht nur als Protokollsatz: + # wer auf der Seite "Neustarts" nachsieht, warum ein Geraet nicht + # zurueckkam, soll dort auch lesen, dass TESM es versucht haette. + # Ein leerer Verlauf sieht sonst aus wie "es war nichts noetig". + with database.session() as conn, database.transaction(conn): + record_restart( + conn, + device_id=int(device["id"]), + action="cycle", + result="skipped", + trigger="auto", + actor="system", + duration_ms=0, + detail="keine gültige Lizenz für den automatischen PoE-Neustart", + live=live, + ) continue report.restarts_triggered += 1 # Bewusst **keine** umschliessende Transaktion: der Neustart oeffnet eine diff --git a/apps/tesm/src/tesm/templates/tesm/logs_restarts.html b/apps/tesm/src/tesm/templates/tesm/logs_restarts.html index e61ad2f..de9b5e7 100644 --- a/apps/tesm/src/tesm/templates/tesm/logs_restarts.html +++ b/apps/tesm/src/tesm/templates/tesm/logs_restarts.html @@ -6,7 +6,8 @@ {% block content %} {{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch wie manuell, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung. -Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }} +Auch ein Neustart, den die Überwachung auslösen wollte und nicht durfte, steht hier: +als "übersprungen" mit dem Grund. Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
@@ -32,7 +33,7 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }} {% for row in rows %} - + {{ row.ts | dt }} {{ row.device_name or '–' | safe }} @@ -41,10 +42,15 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }} jahrelang als PoE ausgegeben. #} {% if row.method == 'ssh' %}{{ pill('SSH', 'info') }} {% elif row.method == 'rpc' %}{{ pill('RPC', 'accent') }} - {% elif row.method == 'pö' %}{{ pill('PoE-Port', '') }} + {# 'poe', nicht 'pö'. Der Umlaut stand hier durch eine + Ersetzung, die "oe" fuer eine Umschrift hielt -- und traf den + haeufigsten Fall von allen: jeder automatische Neustart laeuft + ueber den PoE-Port. Sie standen damit als orangefarbenes + "poe" da, wo "PoE-Port" stehen sollte. #} + {% elif row.method == 'poe' %}{{ pill('PoE-Port', '') }} {% else %}{{ pill(row.method or 'unbekannt', 'warning') }}{% endif %} - {{ row.trigger }} + {{ trigger_labels.get(row.trigger, row.trigger) }} {{ row.actor }} {% if row.result == 'ok' %}{{ pill('erfolgreich', 'online') }} diff --git a/apps/tesm/src/tesm/templates/tesm/maintenance.html b/apps/tesm/src/tesm/templates/tesm/maintenance.html index 51d7d04..5af5338 100644 --- a/apps/tesm/src/tesm/templates/tesm/maintenance.html +++ b/apps/tesm/src/tesm/templates/tesm/maintenance.html @@ -45,12 +45,22 @@ brauchen dafür weder SSH noch einen Host-Schlüssel, sondern die offene Datei- Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Port.', 'info') }} -
+{# ``stack``: Massenbefehl und Geräteauswahl sind zwei Bereiche und sollen auch + so aussehen. Ohne das stossen die beiden Karten aneinander. #} + {% if current_user.has('servicing_command.read') %} + {# Zugeklappt beim Laden: der Massenbefehl ist das seltenere Werkzeug, die + Geräteliste darunter das tägliche. Aufgeklappt schob er sie aus dem + Bild. #}
+
{{ icon('terminal', size=17) }} Massenbefehl
@@ -62,7 +72,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
-
+