Version 2.8.27: Live-Protokoll mit Stufen, Auftraege fallen nicht mehr heraus
This commit is contained in:
1 parent
db06ddc054
commit
76a73017db
18 files changed
+390
-51
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.8.26"
|
version = "2.8.27"
|
||||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -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
|
# Der Auftrag laeuft im Hintergrund und meldet sein Ergebnis erst, wenn das
|
||||||
# Geraet zurueck ist. Der Zustand muss aber sofort umschlagen -- sonst sieht
|
# Geraet zurueck ist. Der Zustand muss aber sofort umschlagen -- sonst sieht
|
||||||
# die Uebersicht aus wie eine, in der nichts passiert ist.
|
# die Uebersicht aus wie eine, in der nichts passiert ist.
|
||||||
|
live = extension.registry.get("live_log")
|
||||||
with transaction():
|
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.start_background(
|
||||||
maintenance_service.run_reboot,
|
maintenance_service.run_reboot,
|
||||||
database=extension.database,
|
database=extension.database,
|
||||||
|
live=live,
|
||||||
job_id=handle.id,
|
job_id=handle.id,
|
||||||
device=dict(device),
|
device=dict(device),
|
||||||
username=username,
|
username=username,
|
||||||
|
|||||||
@@ -66,4 +66,5 @@ def restarts():
|
|||||||
method=method,
|
method=method,
|
||||||
methods=tuple(monitor.METHOD_LABELS.items()),
|
methods=tuple(monitor.METHOD_LABELS.items()),
|
||||||
method_labels=monitor.METHOD_LABELS,
|
method_labels=monitor.METHOD_LABELS,
|
||||||
|
trigger_labels=monitor.TRIGGER_LABELS,
|
||||||
)
|
)
|
||||||
@@ -83,6 +83,7 @@ def overview(): # noqa: C901 - eine Seite, drei Aufgaben
|
|||||||
"tesm/maintenance.html",
|
"tesm/maintenance.html",
|
||||||
devices=_eligible_devices(conn),
|
devices=_eligible_devices(conn),
|
||||||
jobs=service.list_jobs(conn),
|
jobs=service.list_jobs(conn),
|
||||||
|
auftragslage=service.letzter_auftrag_je_geraet(conn),
|
||||||
category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
|
category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
|
||||||
category_effects=inventory.CATEGORY_EFFECTS,
|
category_effects=inventory.CATEGORY_EFFECTS,
|
||||||
update_category=inventory.MAINTENANCE_CATEGORY,
|
update_category=inventory.MAINTENANCE_CATEGORY,
|
||||||
@@ -145,7 +146,7 @@ def install_windows_tool():
|
|||||||
def status():
|
def status():
|
||||||
"""Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
|
"""Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
|
||||||
conn = db()
|
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)
|
running = any(job["state"] in {"pending", "running"} for job in jobs)
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
@@ -282,27 +283,35 @@ def _start(kind: str, befehl: str = "") -> None:
|
|||||||
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
|
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
|
||||||
)
|
)
|
||||||
else:
|
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.
|
# Sofort sichtbar, nicht erst wenn das Geraet zurueck ist.
|
||||||
with transaction() as verbindung:
|
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.start_background(
|
||||||
service.run_reboot,
|
service.run_reboot,
|
||||||
database=extension.database,
|
database=extension.database,
|
||||||
|
live=extension.registry.get("live_log"),
|
||||||
job_id=handle.id,
|
job_id=handle.id,
|
||||||
device=dict(device),
|
device=dict(device),
|
||||||
username=username,
|
username=username,
|
||||||
password=password,
|
password=password,
|
||||||
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
|
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
|
||||||
actor=current_user.username,
|
actor=current_user.username,
|
||||||
# Hier steht in ``category`` die Kategorie der *Zugangsdaten*
|
# Der Weg steht oben: in ``category`` liegt die Kategorie der
|
||||||
# (siehe _query_devices) -- daraus folgt der Weg. Uebergeben
|
# *Zugangsdaten* (siehe _query_devices), daraus folgt er.
|
||||||
# statt in run_reboot erraten: dort bedeutet derselbe
|
# Uebergeben statt in run_reboot erraten -- dort bedeutet
|
||||||
# Schluessel je Herkunft etwas anderes.
|
# derselbe Schluessel je Herkunft etwas anderes.
|
||||||
method=(
|
method=weg,
|
||||||
service.METHOD_RPC
|
|
||||||
if str(device["category"]) == inventory.NON_SSH_CATEGORY
|
|
||||||
else service.METHOD_SSH
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
started += 1
|
started += 1
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ NAVIGATION = NavTree(
|
|||||||
children=(
|
children=(
|
||||||
NavItem(
|
NavItem(
|
||||||
key="logs_live",
|
key="logs_live",
|
||||||
label="Laufend",
|
label="Live",
|
||||||
icon="terminal",
|
icon="terminal",
|
||||||
endpoint="logs.live",
|
endpoint="logs.live",
|
||||||
permissions=("logs_live.read",),
|
permissions=("logs_live.read",),
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import sqlite3
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from tesm_core import audit
|
from tesm_core import audit
|
||||||
@@ -284,11 +284,81 @@ def list_jobs(
|
|||||||
placeholders = ",".join("?" * len(kinds))
|
placeholders = ",".join("?" * len(kinds))
|
||||||
return Database.all(
|
return Database.all(
|
||||||
conn,
|
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),
|
(*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:
|
def get_job(conn: sqlite3.Connection, job_id: str) -> dict[str, Any] | None:
|
||||||
return Database.one(conn, "SELECT * FROM jobs WHERE id=?", (job_id,))
|
return Database.one(conn, "SELECT * FROM jobs WHERE id=?", (job_id,))
|
||||||
|
|
||||||
@@ -648,6 +718,7 @@ def _reboot_windows(
|
|||||||
actor: str,
|
actor: str,
|
||||||
emit: Callable[[str], None],
|
emit: Callable[[str], None],
|
||||||
elapsed: Callable[[], int],
|
elapsed: Callable[[], int],
|
||||||
|
live: Any = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Neustart eines Windows-Geraets ueber RPC. ``True`` = weiter warten.
|
"""Neustart eines Windows-Geraets ueber RPC. ``True`` = weiter warten.
|
||||||
|
|
||||||
@@ -678,6 +749,7 @@ def _reboot_windows(
|
|||||||
duration_ms=elapsed(),
|
duration_ms=elapsed(),
|
||||||
detail=detail,
|
detail=detail,
|
||||||
method=METHOD_RPC,
|
method=METHOD_RPC,
|
||||||
|
live=live,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -692,6 +764,7 @@ def run_reboot(
|
|||||||
wait_seconds: int,
|
wait_seconds: int,
|
||||||
actor: str = "system",
|
actor: str = "system",
|
||||||
method: str = METHOD_SSH,
|
method: str = METHOD_SSH,
|
||||||
|
live: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Loest einen Neustart aus und wartet auf die Rueckkehr.
|
"""Loest einen Neustart aus und wartet auf die Rueckkehr.
|
||||||
|
|
||||||
@@ -744,6 +817,7 @@ def run_reboot(
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
emit=emit,
|
emit=emit,
|
||||||
elapsed=elapsed,
|
elapsed=elapsed,
|
||||||
|
live=live,
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
@@ -767,6 +841,7 @@ def run_reboot(
|
|||||||
duration_ms=elapsed(),
|
duration_ms=elapsed(),
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
method=method,
|
method=method,
|
||||||
|
live=live,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -809,6 +884,7 @@ def run_reboot(
|
|||||||
duration_ms=elapsed(),
|
duration_ms=elapsed(),
|
||||||
detail=problem,
|
detail=problem,
|
||||||
method=method,
|
method=method,
|
||||||
|
live=live,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -849,6 +925,7 @@ def run_reboot(
|
|||||||
duration_ms=elapsed(),
|
duration_ms=elapsed(),
|
||||||
detail="Neustart über SSH, Gerät ist zurückgekehrt.",
|
detail="Neustart über SSH, Gerät ist zurückgekehrt.",
|
||||||
method=method,
|
method=method,
|
||||||
|
live=live,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
time.sleep(REBOOT_POLL_SECONDS)
|
time.sleep(REBOOT_POLL_SECONDS)
|
||||||
@@ -873,7 +950,8 @@ def run_reboot(
|
|||||||
f"{wait_seconds}s nicht wieder geantwortet."
|
f"{wait_seconds}s nicht wieder geantwortet."
|
||||||
),
|
),
|
||||||
method=method,
|
method=method,
|
||||||
)
|
live=live,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _log_restart(
|
def _log_restart(
|
||||||
@@ -885,6 +963,7 @@ def _log_restart(
|
|||||||
duration_ms: int,
|
duration_ms: int,
|
||||||
detail: str,
|
detail: str,
|
||||||
method: str = METHOD_SSH,
|
method: str = METHOD_SSH,
|
||||||
|
live: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Traegt den Neustart in die geraeteweite Neustart-Historie ein.
|
"""Traegt den Neustart in die geraeteweite Neustart-Historie ein.
|
||||||
|
|
||||||
@@ -907,6 +986,7 @@ def _log_restart(
|
|||||||
duration_ms=duration_ms,
|
duration_ms=duration_ms,
|
||||||
detail=detail,
|
detail=detail,
|
||||||
method=method,
|
method=method,
|
||||||
|
live=live,
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen
|
except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen
|
||||||
logger.exception("Neustart konnte nicht protokolliert werden (Gerät %s)", device_id)
|
logger.exception("Neustart konnte nicht protokolliert werden (Gerät %s)", device_id)
|
||||||
|
|||||||
@@ -156,6 +156,16 @@ class LiveLog:
|
|||||||
def info(self, scope: str, message: str) -> None:
|
def info(self, scope: str, message: str) -> None:
|
||||||
self.write("INFO", scope, message)
|
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:
|
def warning(self, scope: str, message: str) -> None:
|
||||||
self.write("WARNUNG", scope, message)
|
self.write("WARNUNG", scope, message)
|
||||||
|
|
||||||
@@ -278,6 +288,39 @@ METHOD_LABELS = {
|
|||||||
METHOD_RPC: "Neustart über RPC",
|
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.
|
#: Zustand zwischen ausgeloestem Neustart und Rueckkehr.
|
||||||
#:
|
#:
|
||||||
#: Ohne ihn blieb die Kachel auf "online" stehen: der Zustand wechselt erst
|
#: Ohne ihn blieb die Kachel auf "online" stehen: der Zustand wechselt erst
|
||||||
@@ -303,6 +346,7 @@ def record_restart(
|
|||||||
duration_ms: int,
|
duration_ms: int,
|
||||||
detail: str,
|
detail: str,
|
||||||
method: str = METHOD_POE,
|
method: str = METHOD_POE,
|
||||||
|
live: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Schreibt einen Neustart ins Protokoll -- unabhaengig davon, wie er lief.
|
"""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
|
nur in ``method``. Vorher gab es nur die PoE-Haelfte, und ein per Wartung
|
||||||
neu gestartetes Geraet tauchte in der Neustart-Historie ueberhaupt nicht
|
neu gestartetes Geraet tauchte in der Neustart-Historie ueberhaupt nicht
|
||||||
auf.
|
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(
|
conn.execute(
|
||||||
"INSERT INTO restart_events "
|
"INSERT INTO restart_events "
|
||||||
@@ -318,6 +368,17 @@ def record_restart(
|
|||||||
"VALUES (?,?,?,?,?,?,?,?,?)",
|
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
(device_id, _now(), trigger, actor, action, result, duration_ms, detail[:8000], method),
|
(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":
|
if result == "ok":
|
||||||
# Hier faellt nur noch der Zaehler und der Zeitstempel an. Den Zustand
|
# Hier faellt nur noch der Zaehler und der Zeitstempel an. Den Zustand
|
||||||
# setzt ``mark_restarting`` schon beim Ausloesen -- dieses Ergebnis
|
# 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.
|
"""Setzt den Zustand auf "startet neu" -- beim **Ausloesen**, nicht am Ende.
|
||||||
|
|
||||||
Ein Neustart ueber SSH oder RPC laeuft als Auftrag: Kommando schicken, dann
|
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
|
Absicht. Und ohne ``last_restart_at``: das Kulanzfenster laeuft ab dem
|
||||||
Zeitpunkt, an dem der Neustart tatsaechlich ausgeloest wurde
|
Zeitpunkt, an dem der Neustart tatsaechlich ausgeloest wurde
|
||||||
(``record_restart``).
|
(``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(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -355,6 +429,12 @@ def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None:
|
|||||||
""",
|
""",
|
||||||
(device_id, STATE_RESTARTING, _now(), _now()),
|
(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.
|
# wie fuer den automatischen Neustart, denn beide laufen hier durch.
|
||||||
if port and switch_ip:
|
if port and switch_ip:
|
||||||
with write():
|
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:
|
if not port or not switch_ip:
|
||||||
message = (
|
message = (
|
||||||
@@ -418,6 +500,7 @@ def restart_device_poe(
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
duration_ms=0,
|
duration_ms=0,
|
||||||
detail=message,
|
detail=message,
|
||||||
|
live=live,
|
||||||
)
|
)
|
||||||
return {"ok": False, "message": message, "transcript": ""}
|
return {"ok": False, "message": message, "transcript": ""}
|
||||||
|
|
||||||
@@ -462,9 +545,8 @@ def restart_device_poe(
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
duration_ms=duration,
|
duration_ms=duration,
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
|
live=live,
|
||||||
)
|
)
|
||||||
if live:
|
|
||||||
live.error("poe", f"{name}: {exc}")
|
|
||||||
return {"ok": False, "message": str(exc), "transcript": ""}
|
return {"ok": False, "message": str(exc), "transcript": ""}
|
||||||
|
|
||||||
duration = int((time.monotonic() - started) * 1000)
|
duration = int((time.monotonic() - started) * 1000)
|
||||||
@@ -478,12 +560,8 @@ def restart_device_poe(
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
duration_ms=duration,
|
duration_ms=duration,
|
||||||
detail=(outcome.error or "; ".join(outcome.steps))[:8000],
|
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 {
|
return {
|
||||||
"ok": outcome.ok,
|
"ok": outcome.ok,
|
||||||
"message": outcome.error or "PoE-Neustart durchgeführt.",
|
"message": outcome.error or "PoE-Neustart durchgeführt.",
|
||||||
@@ -641,10 +719,22 @@ def run_sweep(
|
|||||||
name = str(device["name"])
|
name = str(device["name"])
|
||||||
if not license_allows_auto_restart:
|
if not license_allows_auto_restart:
|
||||||
report.skipped_unlicensed += 1
|
report.skipped_unlicensed += 1
|
||||||
live.warning(
|
# Als Zeile in der Neustart-Historie, nicht nur als Protokollsatz:
|
||||||
"poe",
|
# wer auf der Seite "Neustarts" nachsieht, warum ein Geraet nicht
|
||||||
f"{name}: automatischer PoE-Neustart übersprungen – keine gültige Lizenz.",
|
# 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
|
continue
|
||||||
report.restarts_triggered += 1
|
report.restarts_triggered += 1
|
||||||
# Bewusst **keine** umschliessende Transaktion: der Neustart oeffnet eine
|
# Bewusst **keine** umschliessende Transaktion: der Neustart oeffnet eine
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch
|
{{ 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.
|
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') }}
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
@@ -32,7 +33,7 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
|||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
<tr data-filter-text="{{ row.device_name }} {{ row.result }} {{ row.actor }} {{ method_labels.get(row.method, row.method) }} {{ row.detail }}">
|
<tr data-filter-text="{{ row.device_name }} {{ row.result }} {{ row.actor }} {{ method_labels.get(row.method, row.method) }} {{ trigger_labels.get(row.trigger, row.trigger) }} {{ row.detail }}">
|
||||||
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td>
|
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td>
|
||||||
<td class="fw-600 fs-sm">{{ row.device_name or '–' | safe }}</td>
|
<td class="fw-600 fs-sm">{{ row.device_name or '–' | safe }}</td>
|
||||||
<td class="fs-sm">
|
<td class="fs-sm">
|
||||||
@@ -41,10 +42,15 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
|||||||
jahrelang als PoE ausgegeben. #}
|
jahrelang als PoE ausgegeben. #}
|
||||||
{% if row.method == 'ssh' %}{{ pill('SSH', 'info') }}
|
{% if row.method == 'ssh' %}{{ pill('SSH', 'info') }}
|
||||||
{% elif row.method == 'rpc' %}{{ pill('RPC', 'accent') }}
|
{% 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 %}
|
{% else %}{{ pill(row.method or 'unbekannt', 'warning') }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="fs-sm">{{ row.trigger }}</td>
|
<td class="fs-sm">{{ trigger_labels.get(row.trigger, row.trigger) }}</td>
|
||||||
<td class="fs-sm">{{ row.actor }}</td>
|
<td class="fs-sm">{{ row.actor }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if row.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
|
{% if row.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
|
||||||
|
|||||||
@@ -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.',
|
Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Port.',
|
||||||
'info') }}
|
'info') }}
|
||||||
|
|
||||||
<form method="post" id="maintenance-form" action="{{ url_for('maintenance.start_update') }}">
|
{# ``stack``: Massenbefehl und Geräteauswahl sind zwei Bereiche und sollen auch
|
||||||
|
so aussehen. Ohne das stossen die beiden Karten aneinander. #}
|
||||||
|
<form method="post" id="maintenance-form" class="stack"
|
||||||
|
action="{{ url_for('maintenance.start_update') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
{% if current_user.has('servicing_command.read') %}
|
{% 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. #}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-befehl-schalter
|
||||||
|
aria-expanded="false" title="Massenbefehl ein- und ausklappen">
|
||||||
|
{{ icon('chevron-right', size=14) }}
|
||||||
|
</button>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('terminal', size=17) }} Massenbefehl</div>
|
<div class="card__title">{{ icon('terminal', size=17) }} Massenbefehl</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
@@ -62,7 +72,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body stack">
|
<div class="card__body stack" data-befehl-feld hidden>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">Vorlage</span>
|
<span class="field__label">Vorlage</span>
|
||||||
<select class="select" id="befehlsvorlage">
|
<select class="select" id="befehlsvorlage">
|
||||||
@@ -95,6 +105,9 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
<input type="checkbox" id="select-all">
|
<input type="checkbox" id="select-all">
|
||||||
<span>Alle auswählen</span>
|
<span>Alle auswählen</span>
|
||||||
</label>
|
</label>
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button" data-alle-einklappen>
|
||||||
|
{{ icon('chevron-up', size=14) }} Alle einklappen
|
||||||
|
</button>
|
||||||
<span class="table-toolbar__spacer"></span>
|
<span class="table-toolbar__spacer"></span>
|
||||||
{% if current_user.has('servicing_command.execute') %}
|
{% if current_user.has('servicing_command.execute') %}
|
||||||
<button class="btn btn--sm" type="submit"
|
<button class="btn btn--sm" type="submit"
|
||||||
@@ -129,6 +142,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
<th class="sortable">Gerät</th>
|
<th class="sortable">Gerät</th>
|
||||||
<th class="sortable">IP</th>
|
<th class="sortable">IP</th>
|
||||||
<th class="sortable">Zustand</th>
|
<th class="sortable">Zustand</th>
|
||||||
|
<th class="sortable">Auftrag</th>
|
||||||
<th class="sortable">Zugangsdaten</th>
|
<th class="sortable">Zugangsdaten</th>
|
||||||
<th class="sortable">Host-Schlüssel</th>
|
<th class="sortable">Host-Schlüssel</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -162,6 +176,19 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
|
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
|
||||||
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
|
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
{# Wie weit ist der Auftrag dieses Geräts? Ohne diese Spalte
|
||||||
|
blieb nur die Auftragsliste weiter unten -- und dort steht
|
||||||
|
nicht, welche Zeile zu welchem Gerät gehört, wenn vierzig
|
||||||
|
Aufträge in derselben Sekunde starten. #}
|
||||||
|
<td data-auftragslage="{{ device.name }}">
|
||||||
|
{% set lage = auftragslage.get(device.name) %}
|
||||||
|
{% if not lage %}<span class="faint fs-xs">–</span>
|
||||||
|
{% elif lage.state == 'running' %}{{ pill('läuft', 'info') }}
|
||||||
|
{% elif lage.state == 'pending' %}{{ pill('wartet', '') }}
|
||||||
|
{% elif lage.state == 'succeeded' %}{{ pill('fertig', 'online') }}
|
||||||
|
{% elif lage.state == 'failed' %}{{ pill('Fehler', 'danger') }}
|
||||||
|
{% else %}{{ pill('unklar', 'warning') }}{% endif %}
|
||||||
|
</td>
|
||||||
<td class="fs-sm">
|
<td class="fs-sm">
|
||||||
{{ device.credential_name }}
|
{{ device.credential_name }}
|
||||||
<div class="fs-xs faint">{{ category_labels.get(device.category, device.category) }}</div>
|
<div class="fs-xs faint">{{ category_labels.get(device.category, device.category) }}</div>
|
||||||
@@ -190,7 +217,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr data-ausgabe-zeile="{{ device.name }}" hidden>
|
<tr data-ausgabe-zeile="{{ device.name }}" hidden>
|
||||||
<td colspan="7" class="p-0">
|
<td colspan="8" class="p-0">
|
||||||
<pre class="job-output" data-ausgabe="{{ device.name }}">Noch keine Ausgabe.</pre>
|
<pre class="job-output" data-ausgabe="{{ device.name }}">Noch keine Ausgabe.</pre>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -289,28 +316,59 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
// lief -- das verwarf jede Auswahl, jeden Tastendruck im Befehlsfeld und
|
// lief -- das verwarf jede Auswahl, jeden Tastendruck im Befehlsfeld und
|
||||||
// sprang nach oben. Jetzt kommen nur die letzten Zeilen, und die Seite
|
// sprang nach oben. Jetzt kommen nur die letzten Zeilen, und die Seite
|
||||||
// bleibt stehen.
|
// bleibt stehen.
|
||||||
|
// Der Massenbefehl: zugeklappt geladen, per Kopfzeile aufgeklappt.
|
||||||
|
const befehlSchalter = document.querySelector("[data-befehl-schalter]");
|
||||||
|
const befehlFeld = document.querySelector("[data-befehl-feld]");
|
||||||
|
befehlSchalter?.addEventListener("click", () => {
|
||||||
|
const zu = befehlFeld.hidden;
|
||||||
|
befehlFeld.hidden = !zu;
|
||||||
|
befehlSchalter.setAttribute("aria-expanded", zu ? "true" : "false");
|
||||||
|
befehlSchalter.style.transform = zu ? "rotate(90deg)" : "";
|
||||||
|
if (zu) document.getElementById("befehl")?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
const schalter = Array.from(document.querySelectorAll("[data-ausgabe-schalter]"));
|
const schalter = Array.from(document.querySelectorAll("[data-ausgabe-schalter]"));
|
||||||
const offen = new Set();
|
// Wer von Hand zuklappt, will es zu haben. Ohne dieses Gedaechtnis riss die
|
||||||
|
// naechste Statusabfrage die Zeile wieder auf, weil dort noch Ausgabe stand
|
||||||
|
// -- zwei Sekunden spaeter, jedes Mal.
|
||||||
|
const zugeklappt = new Set();
|
||||||
|
// Beim ersten Fuellen ans Ende springen, danach nur, wenn man ohnehin unten
|
||||||
|
// steht. So steht nach dem Neuladen der Seite das Aktuelle im Bild und nicht
|
||||||
|
// der Anfang von vorgestern -- und wer hochscrollt, um etwas zu lesen, wird
|
||||||
|
// nicht wieder heruntergezogen.
|
||||||
|
const gefuellt = new Set();
|
||||||
|
|
||||||
|
function klappen(name, zu) {
|
||||||
|
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
|
||||||
|
if (!zeile) return;
|
||||||
|
zeile.hidden = zu;
|
||||||
|
if (zu) zugeklappt.add(name);
|
||||||
|
else zugeklappt.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
schalter.forEach((knopf) => {
|
schalter.forEach((knopf) => {
|
||||||
knopf.addEventListener("click", () => {
|
knopf.addEventListener("click", () => {
|
||||||
const name = knopf.dataset.ausgabeSchalter;
|
const name = knopf.dataset.ausgabeSchalter;
|
||||||
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
|
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
|
||||||
if (!zeile) return;
|
if (zeile) klappen(name, !zeile.hidden);
|
||||||
zeile.hidden = !zeile.hidden;
|
|
||||||
if (zeile.hidden) offen.delete(name);
|
|
||||||
else offen.add(name);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-alle-einklappen]")?.addEventListener("click", () => {
|
||||||
|
schalter.forEach((knopf) => klappen(knopf.dataset.ausgabeSchalter, true));
|
||||||
|
});
|
||||||
|
|
||||||
function zeigen(name, text) {
|
function zeigen(name, text) {
|
||||||
const feld = document.querySelector(`[data-ausgabe="${CSS.escape(name)}"]`);
|
const feld = document.querySelector(`[data-ausgabe="${CSS.escape(name)}"]`);
|
||||||
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
|
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
|
||||||
if (!feld || !zeile) return;
|
if (!feld || !zeile) return;
|
||||||
|
const erstmals = !gefuellt.has(name);
|
||||||
const unten = feld.scrollTop + feld.clientHeight >= feld.scrollHeight - 24;
|
const unten = feld.scrollTop + feld.clientHeight >= feld.scrollHeight - 24;
|
||||||
feld.textContent = text || "Noch keine Ausgabe.";
|
feld.textContent = text || "Noch keine Ausgabe.";
|
||||||
if (unten) feld.scrollTop = feld.scrollHeight;
|
if (text) gefuellt.add(name);
|
||||||
|
if (erstmals || unten) feld.scrollTop = feld.scrollHeight;
|
||||||
// Was läuft, klappt von selbst auf -- wer zusieht, will nicht erst suchen.
|
// Was läuft, klappt von selbst auf -- wer zusieht, will nicht erst suchen.
|
||||||
if (text && zeile.hidden && !offen.has(name)) zeile.hidden = false;
|
if (text && zeile.hidden && !zugeklappt.has(name)) zeile.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zustand, Fortschritt und Meldung eines Auftrags nachziehen.
|
// Zustand, Fortschritt und Meldung eines Auftrags nachziehen.
|
||||||
@@ -348,13 +406,41 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dieselben fünf Zustände wie in der Auftragsliste -- hier aber neben dem
|
||||||
|
// Gerät, wo man sie sucht.
|
||||||
|
const LAGE = {
|
||||||
|
pending: ["wartet", ""],
|
||||||
|
running: ["läuft", "pill--info"],
|
||||||
|
succeeded: ["fertig", "pill--online"],
|
||||||
|
failed: ["Fehler", "pill--danger"],
|
||||||
|
unknown: ["unklar", "pill--warning"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function lageZeigen(name, zustand) {
|
||||||
|
const zelle = document.querySelector(`[data-auftragslage="${CSS.escape(name)}"]`);
|
||||||
|
if (!zelle) return;
|
||||||
|
const [text, klasse] = LAGE[zustand] || LAGE.pending;
|
||||||
|
const vorhanden = zelle.querySelector(".pill");
|
||||||
|
if (vorhanden && vorhanden.textContent.trim() === text) return;
|
||||||
|
const pille = document.createElement("span");
|
||||||
|
pille.className = `pill ${klasse}`.trim();
|
||||||
|
pille.textContent = text;
|
||||||
|
zelle.replaceChildren(pille);
|
||||||
|
}
|
||||||
|
|
||||||
let neueAuftraege = false;
|
let neueAuftraege = false;
|
||||||
async function poll() {
|
async function poll() {
|
||||||
let weiter = 10000;
|
let weiter = 10000;
|
||||||
try {
|
try {
|
||||||
const state = await window.tesm.api("{{ url_for('maintenance.status') }}", { auto: true });
|
const state = await window.tesm.api("{{ url_for('maintenance.status') }}", { auto: true });
|
||||||
weiter = state.poll_ms || 10000;
|
weiter = state.poll_ms || 10000;
|
||||||
|
// Der neueste Auftrag je Gerät gewinnt; die Liste kommt neueste zuerst.
|
||||||
|
const gesehen = new Set();
|
||||||
state.jobs.forEach((job) => {
|
state.jobs.forEach((job) => {
|
||||||
|
if (!gesehen.has(job.subject)) {
|
||||||
|
gesehen.add(job.subject);
|
||||||
|
lageZeigen(job.subject, job.state);
|
||||||
|
}
|
||||||
if (job.output) zeigen(job.subject, job.output);
|
if (job.output) zeigen(job.subject, job.output);
|
||||||
// Ein Auftrag, der in der Tabelle noch gar nicht steht, ist neu --
|
// Ein Auftrag, der in der Tabelle noch gar nicht steht, ist neu --
|
||||||
// dafür braucht es die Seite einmal frisch, aber erst, wenn nichts
|
// dafür braucht es die Seite einmal frisch, aber erst, wenn nichts
|
||||||
|
|||||||
@@ -113,7 +113,11 @@ class MonitorDaemon:
|
|||||||
time.sleep(min(1.0, remaining))
|
time.sleep(min(1.0, remaining))
|
||||||
remaining -= 1.0
|
remaining -= 1.0
|
||||||
|
|
||||||
live.info("monitor", "Überwachungsprozess beendet")
|
# Warnstufe, nicht INFO: dass die Ueberwachung *laeuft*, ist der
|
||||||
|
# Normalfall und darf gruen sein. Dass sie **steht**, ist der Zustand,
|
||||||
|
# in dem niemand mehr erfaehrt, dass ein Geraet weg ist -- und genau
|
||||||
|
# diese Zeile sucht man hinterher im Protokoll.
|
||||||
|
live.warning("monitor", "Überwachungsprozess beendet")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.8.15"
|
version = "2.8.16"
|
||||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ DIAGNOSTICS = Resource(
|
|||||||
|
|
||||||
LOGS_LIVE = Resource(
|
LOGS_LIVE = Resource(
|
||||||
key="logs_live",
|
key="logs_live",
|
||||||
label="Laufendes Protokoll",
|
label="Live-Protokoll",
|
||||||
actions=(A.READ,),
|
actions=(A.READ,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1133,6 +1133,16 @@
|
|||||||
}
|
}
|
||||||
.log-view mark { background: var(--warning-soft); color: var(--warning); border-radius: 3px; }
|
.log-view mark { background: var(--warning-soft); color: var(--warning); border-radius: 3px; }
|
||||||
|
|
||||||
|
/* Stufen im laufenden Protokoll. Die Zuordnung der Farbrollen ist Absicht:
|
||||||
|
--warning ist bernsteinfarben und traegt den HINWEIS, --danger ist rot und
|
||||||
|
traegt die WARNUNG. Wer das nach den Namen "aufraeumt", dreht die Bedeutung
|
||||||
|
um: ein Geraet, das nicht antwortet, waere dann orange und eines, das neu
|
||||||
|
startet, rot. */
|
||||||
|
.log-view__zeile--info { color: var(--success); }
|
||||||
|
.log-view__zeile--hinweis { color: var(--warning); }
|
||||||
|
.log-view__zeile--warnung { color: var(--danger); }
|
||||||
|
.log-view__zeile--fehler { color: var(--danger); font-weight: 600; }
|
||||||
|
|
||||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--sp-2) var(--sp-4); font-size: var(--fs-base); }
|
.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--sp-2) var(--sp-4); font-size: var(--fs-base); }
|
||||||
.kv dt { color: var(--text-faint); white-space: nowrap; }
|
.kv dt { color: var(--text-faint); white-space: nowrap; }
|
||||||
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{% if lines %}
|
{% if lines %}
|
||||||
<pre class="log-view" data-tail>{% for line in lines %}{{ line }}
|
{# Jede Zeile in einer Huelle, damit die Stufe eine Farbe tragen kann. Der
|
||||||
|
Zeilenumbruch steht ausserhalb: im <pre> ist er der Umbruch selbst, und
|
||||||
|
eine Huelle mit display:block wuerde daraus zwei machen. #}
|
||||||
|
<pre class="log-view" data-tail>{% for line in lines %}{% set z = line | protokollzeile %}<span class="log-view__zeile{% if z.stufe %} log-view__zeile--{{ z.stufe }}{% endif %}">{{ z.text }}</span>
|
||||||
{% endfor %}</pre>
|
{% endfor %}</pre>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty"><div class="empty__title">Keine Zeilen</div>
|
<div class="empty"><div class="empty__title">Keine Zeilen</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url_for('logs.history_file', name=entry.name) }}">{{ entry.name }}</a></td>
|
<td><a href="{{ url_for('logs.history_file', name=entry.name) }}">{{ entry.name }}</a></td>
|
||||||
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
|
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
|
||||||
<td class="fs-sm">{{ entry.modified | int | string }}</td>
|
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% block page_title %}Laufendes Protokoll{% endblock %}
|
{% block page_title %}Live-Protokoll{% endblock %}
|
||||||
{% block page_sub %}{{ path }} · letzte {{ line_count }} Zeilen{% endblock %}
|
{% block page_sub %}{{ path }} · letzte {{ line_count }} Zeilen{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ def format_datetime(value: Any, *, with_time: bool = True) -> str:
|
|||||||
return "-"
|
return "-"
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
moment = value
|
moment = value
|
||||||
|
elif isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||||
|
# Sekunden seit 1970, wie sie aus ``stat()`` kommen. Ohne diesen Zweig
|
||||||
|
# stand im Protokollverlauf die nackte Zahl (1788472810) in der Spalte
|
||||||
|
# "Geändert" -- fuer die Datei sichtbar, fuer den Menschen nutzlos.
|
||||||
|
try:
|
||||||
|
moment = datetime.fromtimestamp(float(value), timezone.utc)
|
||||||
|
except (OSError, OverflowError, ValueError):
|
||||||
|
return str(value)
|
||||||
else:
|
else:
|
||||||
text = str(value).strip()
|
text = str(value).strip()
|
||||||
if text.endswith("Z"):
|
if text.endswith("Z"):
|
||||||
@@ -351,6 +359,38 @@ def audit_detail(text: Any) -> Markup:
|
|||||||
teile.append(str(escape(roh[ende:])))
|
teile.append(str(escape(roh[ende:])))
|
||||||
return Markup("".join(teile))
|
return Markup("".join(teile))
|
||||||
|
|
||||||
|
#: Stufen im laufenden Protokoll und ihre Farbrolle in der Oberflaeche.
|
||||||
|
#:
|
||||||
|
#: Die Zuordnung ist Absicht und keine Verwechslung: die Farbrolle "warning"
|
||||||
|
#: ist bernsteinfarben und traegt hier den **Hinweis**, "danger" ist rot und
|
||||||
|
#: traegt die **Warnung**. Ein Geraet, das nicht antwortet, ist rot; ein
|
||||||
|
#: Geraet, das neu gestartet wird, ist orange; alles Gelungene ist gruen.
|
||||||
|
PROTOKOLLSTUFEN = {
|
||||||
|
"INFO": "info",
|
||||||
|
"HINWEIS": "hinweis",
|
||||||
|
"WARNUNG": "warnung",
|
||||||
|
"FEHLER": "fehler",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: ``2026-09-16 09:54:03 INFO ping HAP02 ist wieder erreichbar``
|
||||||
|
_PROTOKOLLZEILE = re.compile(
|
||||||
|
r"^\d{4}-\d\d-\d\d[ T]\d\d:\d\d:\d\d\s+([A-ZÄÖÜ]+)\s"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def protokollzeile(zeile: str) -> dict[str, str]:
|
||||||
|
"""Zerlegt eine Protokollzeile so weit, dass die Seite sie faerben kann.
|
||||||
|
|
||||||
|
Bewusst nur so weit: der Text bleibt, wie er ist -- eine Protokollansicht,
|
||||||
|
die ihre Zeilen umbaut, ist keine mehr. Gebraucht wird allein die Stufe.
|
||||||
|
Was nicht ins Muster passt (fremde Protokolle wie das von Kea), bekommt
|
||||||
|
keine Farbe statt einer geratenen.
|
||||||
|
"""
|
||||||
|
treffer = _PROTOKOLLZEILE.match(str(zeile or ""))
|
||||||
|
stufe = PROTOKOLLSTUFEN.get(treffer.group(1), "") if treffer else ""
|
||||||
|
return {"text": str(zeile or ""), "stufe": stufe}
|
||||||
|
|
||||||
|
|
||||||
def init_app(app: Flask) -> None:
|
def init_app(app: Flask) -> None:
|
||||||
app.jinja_env.globals.update(
|
app.jinja_env.globals.update(
|
||||||
icon=icon,
|
icon=icon,
|
||||||
@@ -371,6 +411,7 @@ def init_app(app: Flask) -> None:
|
|||||||
enumerate=enumerate,
|
enumerate=enumerate,
|
||||||
)
|
)
|
||||||
app.jinja_env.filters.update(
|
app.jinja_env.filters.update(
|
||||||
|
protokollzeile=protokollzeile,
|
||||||
bytes=format_bytes,
|
bytes=format_bytes,
|
||||||
dt=format_datetime,
|
dt=format_datetime,
|
||||||
date=format_date,
|
date=format_date,
|
||||||
|
|||||||
@@ -68,12 +68,17 @@ def live_fragment():
|
|||||||
def history():
|
def history():
|
||||||
extension = core()
|
extension = core()
|
||||||
rotated = []
|
rotated = []
|
||||||
for path in sorted(extension.config.log_dir.glob("*.log.*"), reverse=True):
|
for path in extension.config.log_dir.glob("*.log.*"):
|
||||||
try:
|
try:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
rotated.append({"name": path.name, "size": stat.st_size, "modified": stat.st_mtime})
|
rotated.append({"name": path.name, "size": stat.st_size, "modified": stat.st_mtime})
|
||||||
|
# Nach Alter, nicht nach Namen: ``sorted(..., reverse=True)`` ueber die
|
||||||
|
# Dateinamen stellte "live.log.9" vor "live.log.27", weil "9" hinter "2"
|
||||||
|
# kommt. Gefragt ist aber die juengste Kopie zuerst, und das steht im
|
||||||
|
# Zeitstempel.
|
||||||
|
rotated.sort(key=lambda eintrag: eintrag["modified"], reverse=True)
|
||||||
return render_template(
|
return render_template(
|
||||||
"tesm_core/logs_history.html",
|
"tesm_core/logs_history.html",
|
||||||
rotated=rotated,
|
rotated=rotated,
|
||||||
|
|||||||
Reference in new issue
Block a user