Compare commits

..
5 Commits
28 changed files with 1108 additions and 76 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.8.23" version = "2.8.28"
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 = [
+5 -1
View File
@@ -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,
+93 -1
View File
@@ -20,7 +20,7 @@ from tesm_core.auth.service import client_ip
from tesm_core.db import Database from tesm_core.db import Database
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
from ..services import kea from ..services import inventory, kea
from ..permissions import DHCP_PAGE from ..permissions import DHCP_PAGE
bp = Blueprint("dhcp", __name__, url_prefix="/dhcp") bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")
@@ -73,6 +73,12 @@ def overview():
# Zustand aus den laufenden Leases -- wie im Windows-DHCP. # Zustand aus den laufenden Leases -- wie im Windows-DHCP.
reservations = kea.annotate_reservations(reservations, leases) reservations = kea.annotate_reservations(reservations, leases)
reserved_ips = {str(entry["ip"]) for entry in reservations} reserved_ips = {str(entry["ip"]) for entry in reservations}
# Wozu es schon einen Client gibt, ist nicht mehr zu uebernehmen -- auch
# wenn dessen Adresse (noch) keine Reservierung hat.
bekannte_macs = {
inventory.normalize_mac(str(zeile["mac"]))
for zeile in Database.all(conn, "SELECT mac FROM devices WHERE deleted_at IS NULL")
}
return render_template( return render_template(
"tesm/dhcp.html", "tesm/dhcp.html",
@@ -90,6 +96,7 @@ def overview():
manual_reservations=Database.all(conn, "SELECT * FROM dhcp_reservations ORDER BY ip"), manual_reservations=Database.all(conn, "SELECT * FROM dhcp_reservations ORDER BY ip"),
leases=leases, leases=leases,
reserved_ips=reserved_ips, reserved_ips=reserved_ips,
bekannte_macs=bekannte_macs,
networks=networks, networks=networks,
default_gateway=kea.default_gateway(sysops) if sysops else "", default_gateway=kea.default_gateway(sysops) if sysops else "",
service=kea.service_state(sysops) if sysops else {}, service=kea.service_state(sysops) if sysops else {},
@@ -447,6 +454,91 @@ def add_reservation():
return _write_and_redirect() return _write_and_redirect()
@bp.post("/lease/adopt")
# Zwei Rechte, mit Absicht: die Uebernahme ist eine eigene Funktion, das
# Ergebnis aber ein Client. Wer keine Clients anlegen darf, legt auch hier
# keine an -- sonst waere diese Schaltflaeche eine Hintertuer an der
# Geraeteverwaltung vorbei.
@permission_required("dhcp_lease_adopt.execute", "devices.create")
def adopt_lease():
"""Macht aus einer dynamisch vergebenen Adresse einen ueberwachten Client.
Die Angaben kommen **nicht** aus dem Formular, sondern aus der Lease-Datei:
geschickt wird nur die MAC, alles Weitere liest TESM frisch nach. Damit
heisst "Uebernahme" auch Uebernahme -- und eine Lease, die zwischen
Seitenaufbau und Klick abgelaufen oder umgezogen ist, faellt auf, statt
stillschweigend einen falschen Datensatz anzulegen.
"""
extension = core()
conn = db()
sysops = extension.sysops
gesucht = inventory.normalize_mac(request.form.get("mac") or "")
leases = kea.read_leases(sysops) if sysops else []
lease = next((eintrag for eintrag in leases if eintrag.mac == gesucht), None)
if lease is None:
flash(
"Zu dieser MAC liegt keine Lease mehr vor. Die Liste war nicht mehr aktuell.",
"warning",
)
return redirect(url_for("dhcp.overview"))
# Nur dynamische. Eine Adresse, die schon reserviert ist, gehoert bereits
# zu einem gepflegten Datensatz -- da gibt es nichts zu uebernehmen.
networks = kea.detect_networks(sysops) if sysops else []
reserviert = {
str(eintrag["ip"])
for eintrag in kea.collect_reservations(
conn, only_networks=kea.configured_networks(conn, networks)
)
}
if lease.address in reserviert:
flash(
f"{lease.address} ist bereits reserviert – diese Lease ist nicht dynamisch.",
"warning",
)
return redirect(url_for("dhcp.overview"))
# Der Hostname aus der Lease ist der Name, den das Geraet selbst nennt.
# Fehlt er, steht die Adresse da: ein Name wie "Gerät 7" sagt weniger.
name = (lease.hostname or "").strip() or f"Client {lease.address}"
try:
with transaction():
device_id = inventory.create_device(
conn,
name=name,
mac=lease.mac,
ip=lease.address,
switch_id=None,
port="",
credential_id=None,
ssh_port=None,
# Ohne Switch und Port gibt es keinen PoE-Neustart. Mit
# eingeschaltetem Automatismus schriebe die Ueberwachung bei
# jedem Ausfall einen Fehlschlag ins Protokoll, den niemand
# abstellen kann. Einschalten kann man ihn, sobald der Port
# eingetragen ist.
auto_restart=False,
actor=current_user.username,
)
except inventory.ValidationError as exc:
flash(str(exc), "danger")
return redirect(url_for("dhcp.overview"))
_audit(
"device.created",
name,
f"aus dynamischer Lease {lease.address} ({lease.mac}) übernommen",
severity="notice",
)
flash(
f"{name} wurde als Client übernommen. Switch, Port und Zugangsdaten fehlen noch – "
"ohne sie gibt es keinen PoE-Neustart und keine Wartung. Die feste Adresse entsteht "
"beim nächsten Schreiben der DHCP-Konfiguration.",
"success",
)
return redirect(url_for("devices.detail", device_id=device_id))
@bp.post("/reservation/<int:reservation_id>/delete") @bp.post("/reservation/<int:reservation_id>/delete")
@permission_required("dhcp_reservations.delete") @permission_required("dhcp_reservations.delete")
def delete_reservation(reservation_id: int): def delete_reservation(reservation_id: int):
+1
View File
@@ -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,
) )
+78 -17
View File
@@ -18,7 +18,7 @@ from __future__ import annotations
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
from tesm_core.security.sessions import fragment from tesm_core.security.sessions import fragment
from tesm_core import audit from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required from tesm_core.auth.models import current_user, permission_required, reauth_required
from tesm_core.auth.service import client_ip from tesm_core.auth.service import client_ip
from tesm_core.db import Database from tesm_core.db import Database
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
@@ -77,18 +77,20 @@ def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
@bp.get("/") @bp.get("/")
@permission_required(*SERVICING_PAGE, mode="any") @permission_required(*SERVICING_PAGE, mode="any")
def overview(): def overview(): # noqa: C901 - eine Seite, drei Aufgaben
conn = db() conn = db()
return render_template( return render_template(
"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,
terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")), terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")),
windows_tool_missing=_windows_tool_missing(conn), windows_tool_missing=_windows_tool_missing(conn),
windows_package=windowsops.REQUIRED_PACKAGE, windows_package=windowsops.REQUIRED_PACKAGE,
befehlsvorlagen=service.BEFEHLSVORLAGEN,
) )
@@ -144,12 +146,19 @@ 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(
{ {
"running": running, "running": running,
"poll_ms": 2000 if running else 10000, "poll_ms": 2000 if running else 10000,
# Der Zustand der Geraete gehoert dazu: die Seite steht sonst mit
# dem Stand ihres Aufbaus da, und wer nach einem Neustart auf die
# Rueckkehr wartet, sieht "unbekannt", bis er von Hand neu laedt.
"devices": [
{"id": int(row["id"]), "state": str(row["state"] or "unknown")}
for row in _eligible_devices(conn)
],
"jobs": [ "jobs": [
{ {
"id": job["id"], "id": job["id"],
@@ -159,6 +168,16 @@ def status():
"progress": job["progress"], "progress": job["progress"],
"message": job["message"], "message": job["message"],
"updated_at": job["updated_at"], "updated_at": job["updated_at"],
# Die letzten Zeilen -- nur von dem, was gerade laeuft.
# Mitlesen heisst mitlesen: wer nach der Wartung wissen
# will, was war, oeffnet den Auftrag. Alles mitzuschicken
# hiesse, alle zwei Sekunden das gesamte Protokoll aller
# Geraete zu uebertragen.
"output": (
service.letzte_zeilen(str(job["output"] or ""))
if service.eben_fertig(dict(job))
else ""
),
} }
for job in jobs for job in jobs
], ],
@@ -187,7 +206,7 @@ def job_output(job_id: str):
return render_template("tesm/_maintenance_output.html", job=job) return render_template("tesm/_maintenance_output.html", job=job)
def _start(kind: str) -> None: def _start(kind: str, befehl: str = "") -> None:
extension = core() extension = core()
conn = db() conn = db()
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()] device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
@@ -233,12 +252,27 @@ def _start(kind: str) -> None:
actor_id=current_user.id, actor_id=current_user.id,
action=kind, action=kind,
target=str(device["name"]), target=str(device["name"]),
detail=f"Auftrag {handle.id}", # Beim Befehl gehoert sein Wortlaut hierher und nicht nur die
# Auftragsnummer: was gelaufen ist, muss aus dem Protokoll
# hervorgehen, auch wenn der Auftrag laengst aus dem Verlauf
# gerollt ist.
detail=f"Auftrag {handle.id}" + (f": {befehl}" if befehl else ""),
ip=client_ip(), ip=client_ip(),
severity="notice", severity="warning" if befehl else "notice",
) )
if kind == service.JOB_KIND_UPDATE: if kind == service.JOB_KIND_COMMAND:
service.start_background(
service.run_command,
database=extension.database,
job_id=handle.id,
device=dict(device),
username=username,
password=password,
befehl=befehl,
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
)
elif kind == service.JOB_KIND_UPDATE:
service.start_background( service.start_background(
service.run_update, service.run_update,
database=extension.database, database=extension.database,
@@ -249,27 +283,35 @@ def _start(kind: 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
@@ -289,3 +331,22 @@ def start_update():
def start_reboot(): def start_reboot():
_start(service.JOB_KIND_REBOOT) _start(service.JOB_KIND_REBOOT)
return redirect(url_for("maintenance.overview")) return redirect(url_for("maintenance.overview"))
@bp.post("/command")
@permission_required("servicing_command.execute")
@reauth_required
def start_command():
"""Eine Befehlszeile auf allen ausgewaehlten Clients.
Dieselbe Schwelle wie die Konsole: eigenes Recht **und** eine frische
Passwortbestaetigung. Wer hier etwas eintippt, tut es auf einem Dutzend
Maschinen gleichzeitig, und niemand sieht dabei zu.
"""
try:
befehl = service.befehl_pruefen(request.form.get("befehl", ""))
except ValueError as exc:
flash(str(exc), "danger")
return redirect(url_for("maintenance.overview"))
_start(service.JOB_KIND_COMMAND, befehl=befehl)
return redirect(url_for("maintenance.overview"))
+42 -2
View File
@@ -70,6 +70,30 @@ def _geraet(conn: Any, device_id: int) -> dict[str, Any] | None:
return device 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: def _port(conn: Any) -> int:
extension = core() extension = core()
if not extension.settings.get(conn, "vnc_enabled"): 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.") ws.close(1008, "Für diesen Client ist kein Ziel bekannt.")
return return
gegenstelle = None
ausweich = str(device.get("ip") or "").strip()
for versuch, ziel_host in enumerate([host] + ([ausweich] if ausweich != host else [])):
try: try:
gegenstelle = socket.create_connection((host, port), timeout=VERBINDUNGSLIMIT) 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: except OSError as exc:
ws.close(1011, f"{host}:{port} antwortet nicht ({exc}).") letzter = exc
if gegenstelle is None:
ws.close(1011, f"{host}:{port} antwortet nicht ({_erklaerung(letzter)}).")
return return
# Das Zeitlimit galt dem *Aufbau* -- als Abbruchgrund taugt es nicht. # Das Zeitlimit galt dem *Aufbau* -- als Abbruchgrund taugt es nicht.
# #
+10
View File
@@ -76,6 +76,16 @@ CLIENT_COMMANDS: tuple[tuple[str, str], ...] = (
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"), ("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"), ("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"),
("uname -a", "Kernel und Architektur"), ("uname -a", "Kernel und Architektur"),
# Die beiden schreiben -- deshalb stehen sie am Ende und sagen es im Namen.
# Eine Vorlage fuellt nur das Eingabefeld; abgeschickt wird von Hand.
(
'echo "Encryption=AlwaysOff" | sudo tee -a /etc/vnc/config.d/common.custom',
"VNC: Streckenverschlüsselung abschalten (schreibt)",
),
(
"sudo systemctl restart vncserver-x11-serviced",
"VNC-Dienst neu starten (schreibt)",
),
) )
+1 -1
View File
@@ -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",),
+53 -2
View File
@@ -187,6 +187,24 @@ SERVICING_TOOL = Resource(
action_hints={A.EXECUTE: "Installation auslösen."}, action_hints={A.EXECUTE: "Installation auslösen."},
) )
#: Ein freier Befehl auf vielen Geraeten ist das Maechtigste, was diese
#: Anwendung kann -- und das Einzige, dessen Wirkung sie nicht kennt. Deshalb
#: ein eigenes Recht, das niemand nebenbei bekommt: wer Pakete aktualisieren
#: oder neu starten darf, tut etwas Umrissenes; wer hier etwas eintippt, tut
#: alles, was ein Konto auf dem Geraet tun kann. Die Vorlagen nehmen dem den
#: Alltag ab, nicht die Verantwortung.
SERVICING_COMMAND = Resource(
key="servicing_command",
label="Befehl auf mehreren Geräten",
actions=(A.READ, A.EXECUTE),
hint="Eine Befehlszeile über SSH, auf allen ausgewählten Clients",
action_hints={
A.READ: "Die Karte sehen – und damit, was zuletzt lief.",
A.EXECUTE: "Befehl absenden. Verlangt eine frische Passwortbestätigung; "
"jeder Auftrag steht mit seinem Wortlaut im Änderungsprotokoll.",
},
)
#: Die Wartungsseite zeigt alle drei Aufgaben. Sie oeffnet, wer **eine** davon #: Die Wartungsseite zeigt alle drei Aufgaben. Sie oeffnet, wer **eine** davon
#: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen #: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen
#: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander. #: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander.
@@ -194,6 +212,7 @@ SERVICING_PAGE: tuple[str, ...] = (
"servicing_update.read", "servicing_update.read",
"servicing_reboot.read", "servicing_reboot.read",
"servicing_tool.read", "servicing_tool.read",
"servicing_command.read",
) )
#: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze, #: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze,
@@ -245,6 +264,31 @@ DHCP_SERVICE = Resource(
action_hints={A.EXECUTE: "Dienstzustand ändern."}, action_hints={A.EXECUTE: "Dienstzustand ändern."},
) )
#: Eine dynamische Lease in einen Client verwandeln.
#:
#: Eigene Zeile, weil es eine eigene Entscheidung ist: hier wird aus einer
#: Beobachtung ("dieses Geraet hat sich eine Adresse geholt") ein gepflegter
#: Datensatz, den die Ueberwachung ab sofort anpingt und der beim naechsten
#: Schreiben der Konfiguration eine feste Adresse bekommt. Wer Leases sehen
#: darf, soll deshalb nicht automatisch welche uebernehmen duerfen.
#:
#: **Und es reicht allein nicht.** Die Uebernahme legt einen Client an, also
#: verlangt die Route zusaetzlich "Clients: Anlegen". Sonst waere dies eine
#: Hintertuer an der Geraeteverwaltung vorbei -- dieselbe Wirkung, anderes
#: Formular. Beide Haken zusammen, oder die Schaltflaeche erscheint nicht.
DHCP_LEASE_ADOPT = Resource(
key="dhcp_lease_adopt",
parent="dhcp_subnets",
label="DHCP: Lease als Client übernehmen",
actions=(A.READ, A.EXECUTE),
hint="Aus einer dynamisch vergebenen Adresse einen überwachten Client machen",
action_hints={
A.READ: "Die Schaltfläche an dynamischen Leases sehen.",
A.EXECUTE: "Client aus der Lease anlegen. Verlangt zusätzlich "
"'Clients: Anlegen' – es entsteht ein Gerätedatensatz.",
},
)
#: Die DHCP-Seite zeigt alle Abschnitte; jeder Abschnitt haengt an seiner #: Die DHCP-Seite zeigt alle Abschnitte; jeder Abschnitt haengt an seiner
#: eigenen Zeile. Geoeffnet wird sie, wer einen davon sehen darf. #: eigenen Zeile. Geoeffnet wird sie, wer einen davon sehen darf.
DHCP_PAGE: tuple[str, ...] = ( DHCP_PAGE: tuple[str, ...] = (
@@ -354,14 +398,21 @@ MAINTENANCE_AREA = Area(
icon="wrench", icon="wrench",
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich " hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.", "verborgen – auch wenn einzelne Rechte darunter gesetzt sind.",
resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_TOOL), resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_COMMAND, SERVICING_TOOL),
) )
NETWORK_AREA = Area( NETWORK_AREA = Area(
key="network", key="network",
label="Netzwerkdienste", label="Netzwerkdienste",
icon="network", icon="network",
resources=(DHCP_SUBNETS, DHCP_OPTIONS, DHCP_RESERVATIONS, DHCP_CONFIG, DHCP_SERVICE), resources=(
DHCP_SUBNETS,
DHCP_OPTIONS,
DHCP_RESERVATIONS,
DHCP_LEASE_ADOPT,
DHCP_CONFIG,
DHCP_SERVICE,
),
) )
FILES_AREA = Area( FILES_AREA = Area(
+274 -3
View File
@@ -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
@@ -44,6 +44,51 @@ logger = logging.getLogger(__name__)
JOB_KIND_UPDATE = "maintenance.update" JOB_KIND_UPDATE = "maintenance.update"
JOB_KIND_REBOOT = "maintenance.reboot" JOB_KIND_REBOOT = "maintenance.reboot"
JOB_KIND_COMMAND = "maintenance.command"
#: Vorlagen fuer den Massenbefehl: Name, Befehl, Erklaerung.
#:
#: ``{sudo}`` wird zur Laufzeit ersetzt -- mit ``sudo -n``, wo es ohne Passwort
#: geht, sonst mit ``sudo -S`` und dem hinterlegten Passwort ueber stdin. Wer
#: eine Vorlage waehlt, sieht den Befehl im Feld und kann ihn aendern: eine
#: Vorlage ist ein Vorschlag, kein Automat.
BEFEHLSVORLAGEN: tuple[tuple[str, str, str], ...] = (
(
"VNC: Streckenverschlüsselung abschalten",
'echo "Encryption=AlwaysOff" | {sudo} tee -a /etc/vnc/config.d/common.custom',
"Danach bietet RealVNC auch RA2ne und „ohne Authentifizierung“ an – nur damit "
"kommt eine Sitzung im Browser zustande. Die Strecke ist dann unverschlüsselt; "
"im Netz zwischen Server und Gerät ist sie es ohnehin.",
),
(
"VNC-Dienst neu starten",
"{sudo} systemctl restart vncserver-x11-serviced",
"Räumt auch eine Sperre ab, die der Server nach zu vielen abgebrochenen "
"Verbindungsversuchen gesetzt hat.",
),
(
"VNC: aktuelle Einstellungen zeigen",
"cat /etc/vnc/config.d/common.custom 2>/dev/null || echo '(keine eigene Konfiguration)'",
"Liest nur – gut, um vor dem Ändern zu sehen, was schon dasteht.",
),
(
"Zeit und Zeitzone",
"timedatectl",
"Liest nur.",
),
(
"Belegung der Dateisysteme",
"df -h",
"Liest nur.",
),
)
#: Was in einer Befehlszeile nichts zu suchen hat. Nicht als Schutz vor einem
#: Menschen mit diesem Recht -- er darf ohnehin alles, was sein Konto auf dem
#: Geraet darf --, sondern gegen Unfaelle: ein Zeilenumbruch aus der
#: Zwischenablage macht aus einem Befehl unbemerkt zwei.
VERBOTENE_ZEICHEN = ("\n", "\r", "\x00")
MAX_BEFEHLSLAENGE = 500
MAX_OUTPUT_LINES = 400 MAX_OUTPUT_LINES = 400
CONNECT_TIMEOUT = 25 CONNECT_TIMEOUT = 25
@@ -231,16 +276,89 @@ def update_job(
def list_jobs( def list_jobs(
conn: sqlite3.Connection, *, kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT), limit: int = 60 conn: sqlite3.Connection,
*,
kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT, JOB_KIND_COMMAND),
limit: int = 60,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
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,))
@@ -447,6 +565,149 @@ 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
def letzte_zeilen(ausgabe: str, anzahl: int = MITLESEN_ZEILEN) -> str:
"""Das Ende einer Ausgabe -- das ist der Teil, der gerade entsteht."""
zeilen = [zeile for zeile in ausgabe.splitlines() if zeile.strip()]
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.
Eine eigene Funktion, weil genau diese Frage sich pruefen lassen muss,
ohne ein Geraet zu brauchen.
"""
sauber = (befehl or "").strip()
if not sauber:
raise ValueError("Es wurde kein Befehl eingegeben.")
if len(sauber) > MAX_BEFEHLSLAENGE:
raise ValueError(
f"Der Befehl ist zu lang (mehr als {MAX_BEFEHLSLAENGE} Zeichen). "
"Für längere Abläufe ist ein Skript auf dem Gerät der bessere Weg."
)
for zeichen in VERBOTENE_ZEICHEN:
if zeichen in sauber:
raise ValueError(
"Der Befehl enthält einen Zeilenumbruch. Hier läuft genau eine "
"Befehlszeile – alles danach würde stillschweigend mitlaufen."
)
return sauber
def run_command(
database: Any,
*,
job_id: str,
device: dict[str, Any],
username: str,
password: str,
befehl: str,
timeout: int,
) -> None:
"""Fuehrt **eine** Befehlszeile auf einem Geraet aus. Eigener Thread.
Bewusst ohne Deutung des Ergebnisses: was der Befehl tut, weiss der
Mensch, der ihn geschrieben hat. Festgehalten wird, was zurueckkam und mit
welchem Rueckgabewert -- mehr kann ein Werkzeug hier nicht beitragen, und
weniger waere zu wenig.
"""
name = str(device.get("name") or device.get("ip"))
def emit(line: str) -> None:
stamp = datetime.now().strftime("%H:%M:%S")
update_job(database, job_id, append_output=f"{stamp} {line}\n")
update_job(
database, job_id, state="running", progress=5, message="Verbindung wird aufgebaut"
)
try:
with database.session() as conn:
transport = open_transport(
conn,
host=str(device["ip"]),
port=int(device.get("ssh_port") or 22),
username=username,
password=password,
)
except SshError as exc:
emit(f"FEHLER: {exc}")
update_job(database, job_id, state="failed", message=str(exc), finished=True)
return
status = 1
try:
sudo, sudo_stdin = _sudo_mode(transport, password, emit)
# Nur wer {sudo} benutzt, bekommt es auch: ein Befehl ohne diese Marke
# laeuft als der angemeldete Benutzer, und das ist die richtige
# Vorgabe -- Rechte holt man sich hier ausdruecklich.
ausgefuehrt = befehl.replace("{sudo}", sudo) if "{sudo}" in befehl else befehl
emit(f"$ {ausgefuehrt}")
update_job(database, job_id, progress=40, message="Befehl läuft")
status = _run_remote_command(
transport,
ausgefuehrt,
timeout=timeout,
on_line=emit,
stdin_text=sudo_stdin if "{sudo}" in befehl else "",
)
except Exception as exc: # noqa: BLE001 - der Auftrag soll das Ergebnis tragen
emit(f"FEHLER: {exc}")
finally:
try:
transport.close()
except Exception: # noqa: BLE001
pass
emit(COMPLETION_MARKER)
update_job(
database,
job_id,
state="succeeded" if status == 0 else "failed",
progress=100,
message=(
"Befehl ausgeführt."
if status == 0
else f"Der Befehl endete mit Rückgabewert {status}."
),
finished=True,
)
with database.session() as conn, database.transaction(conn):
audit.log(
conn,
actor="system",
action="maintenance.command_finished",
target=name,
detail=f"Rückgabewert {status}",
severity="notice" if status == 0 else "warning",
)
def _reboot_windows( def _reboot_windows(
database: Any, database: Any,
*, *,
@@ -457,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.
@@ -487,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
@@ -501,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.
@@ -553,6 +817,7 @@ def run_reboot(
actor=actor, actor=actor,
emit=emit, emit=emit,
elapsed=elapsed, elapsed=elapsed,
live=live,
): ):
return return
else: else:
@@ -576,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
@@ -618,6 +884,7 @@ def run_reboot(
duration_ms=elapsed(), duration_ms=elapsed(),
detail=problem, detail=problem,
method=method, method=method,
live=live,
) )
return return
@@ -658,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)
@@ -682,6 +950,7 @@ def run_reboot(
f"{wait_seconds}s nicht wieder geantwortet." f"{wait_seconds}s nicht wieder geantwortet."
), ),
method=method, method=method,
live=live,
) )
@@ -694,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.
@@ -716,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)
+111 -12
View File
@@ -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.",
@@ -594,7 +672,16 @@ def run_sweep(
# bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll # bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll
# seinen Knopf nicht verlieren. # seinen Knopf nicht verlieren.
return device, ergebnis, None return device, ergebnis, None
try:
return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port) return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port)
except Exception: # noqa: BLE001
# Die Ueberwachung ist die Kernaufgabe, der Bildschirmzugriff eine
# Zugabe. Was hier schiefgeht -- ein Name, den der Resolver nicht
# verdaut, ein Netz, das seltsam antwortet --, darf niemals den
# ganzen Durchlauf kosten: sonst stuenden alle Geraete auf
# "unbekannt", und niemand brächte das mit VNC in Verbindung.
logger.warning("VNC-Pruefung fuer %s uebersprungen", device.get("name"), exc_info=True)
return device, ergebnis, None
with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool: with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
results = list(pool.map(pruefen, devices)) results = list(pool.map(pruefen, devices))
@@ -632,9 +719,21 @@ 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
+3 -1
View File
@@ -132,7 +132,9 @@ def aufloesbar(name: str) -> bool:
try: try:
socket.getaddrinfo(name, None) socket.getaddrinfo(name, None)
ergebnis = True ergebnis = True
except OSError: except Exception: # noqa: BLE001
# Nicht nur OSError: ein Name mit Sonderzeichen wirft UnicodeError,
# und der ist kein OSError. Aufloesbar ist er so oder so nicht.
ergebnis = False ergebnis = False
_AUFLOESUNG[name] = (jetzt, ergebnis) _AUFLOESUNG[name] = (jetzt, ergebnis)
return ergebnis return ergebnis
@@ -36,5 +36,7 @@
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button> title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
<button class="btn btn--sm" type="button" data-rolle="taste-altf4" hidden <button class="btn btn--sm" type="button" data-rolle="taste-altf4" hidden
title="Alt+F4 an das Gerät senden – schließt dort das oberste Fenster">Alt+F4</button> title="Alt+F4 an das Gerät senden – schließt dort das oberste Fenster">Alt+F4</button>
<button class="btn btn--sm" type="button" data-rolle="taste-f5" hidden
title="F5 an das Gerät senden – der Browser des Arbeitsplatzes würde sonst diese Seite neu laden">F5</button>
<button class="btn btn--sm" type="button" data-rolle="trennen" hidden>Trennen</button> <button class="btn btn--sm" type="button" data-rolle="trennen" hidden>Trennen</button>
{% endmacro %} {% endmacro %}
+30 -1
View File
@@ -377,6 +377,10 @@
<div class="card__title">{{ icon('clock', size=17) }} Leases</div> <div class="card__title">{{ icon('clock', size=17) }} Leases</div>
<div class="card__sub"> <div class="card__sub">
Direkt aus {{ lease_file }} gelesen &ndash; je Adresse gilt der letzte Eintrag. Direkt aus {{ lease_file }} gelesen &ndash; je Adresse gilt der letzte Eintrag.
{% if current_user.has('dhcp_lease_adopt.read') and current_user.has('devices.create') %}
Eine <strong>dynamische</strong> Lease lässt sich als Client übernehmen; sie bekommt
damit eine feste Adresse und wird überwacht.
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -394,7 +398,7 @@
<div class="table-wrap" style="max-height:420px;overflow:auto"> <div class="table-wrap" style="max-height:420px;overflow:auto">
<table class="table table--compact"> <table class="table table--compact">
<thead><tr><th>IP</th><th>MAC</th><th>Hostname</th><th>Art</th><th>Zustand</th> <thead><tr><th>IP</th><th>MAC</th><th>Hostname</th><th>Art</th><th>Zustand</th>
<th>Läuft ab</th></tr></thead> <th>Läuft ab</th><th></th></tr></thead>
<tbody> <tbody>
{% for lease in leases %} {% for lease in leases %}
<tr> <tr>
@@ -413,6 +417,31 @@
<td class="fs-sm">{{ lease.expires_text }} <td class="fs-sm">{{ lease.expires_text }}
<div class="fs-xs faint">{{ lease.remaining_text }}</div> <div class="fs-xs faint">{{ lease.remaining_text }}</div>
</td> </td>
<td class="nowrap">
{# Übernehmen gibt es nur für dynamische Leases: was reserviert
ist, gehört schon zu einem gepflegten Datensatz. Und nur,
wenn beide Rechte stehen -- hier entsteht ein Client. #}
{% if lease.address not in reserved_ips
and current_user.has('dhcp_lease_adopt.read')
and current_user.has('devices.create') %}
{% if lease.mac in bekannte_macs %}
<span class="faint fs-xs" title="Zu dieser MAC gibt es bereits einen Client">
bereits Client
</span>
{% else %}
<form method="post" action="{{ url_for('dhcp.adopt_lease') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="mac" value="{{ lease.mac }}">
<button class="btn btn--sm btn--ghost" type="submit"
data-confirm="{{ lease.hostname or lease.address }} als überwachten Client anlegen? Switch, Port und Zugangsdaten tragen Sie danach nach."
data-confirm-title="Als Client übernehmen"
data-confirm-ok="Übernehmen">
{{ icon('plus', size=14) }} Als Client
</button>
</form>
{% endif %}
{% endif %}
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -242,8 +242,7 @@
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}" data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"
data-viewer-konto="{{ current_user.username }}" data-viewer-konto="{{ current_user.username }}"
data-viewer-zeuge="{{ '1' if signature_witness else '0' }}" data-viewer-zeuge="{{ '1' if signature_witness else '0' }}"
data-viewer-zeit="{{ serverzeit }}" data-viewer-zeit="{{ serverzeit }}">
data-viewer-ordnerliste="{{ verzeichnisse | tojson }}">
<div class="viewer"> <div class="viewer">
<div class="viewer__stage" data-viewer-bildbuehne hidden> <div class="viewer__stage" data-viewer-bildbuehne hidden>
<img class="viewer__img" alt="" data-viewer-bild> <img class="viewer__img" alt="" data-viewer-bild>
@@ -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 '&ndash;' | safe }}</td> <td class="fw-600 fs-sm">{{ row.device_name or '&ndash;' | 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,15 +45,78 @@ 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') %}
{# 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">
<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="card__title">{{ icon('terminal', size=17) }} Massenbefehl</div>
<div class="card__sub">
Eine Befehlszeile über SSH, auf allen unten ausgewählten Clients – jeder als
eigener Auftrag mit eigener Ausgabe. <strong>{{ '{sudo}' }}</strong> im Befehl
wird ersetzt: mit <code>sudo -n</code>, wo es ohne Passwort geht, sonst mit dem
hinterlegten Passwort über stdin. Ohne diese Marke läuft der Befehl als das
angemeldete Konto.
</div>
</div>
</div>
<div class="card__body stack" data-befehl-feld hidden>
<label class="field">
<span class="field__label">Vorlage</span>
<select class="select" id="befehlsvorlage">
<option value="">– eigene Eingabe –</option>
{% for name, befehl, erklaerung in befehlsvorlagen %}
<option value="{{ befehl }}" data-erklaerung="{{ erklaerung }}">{{ name }}</option>
{% endfor %}
</select>
<span class="field__hint" id="befehlserklaerung">
Eine Vorlage füllt nur das Feld darunter – abgeschickt wird von Hand.
</span>
</label>
<label class="field">
<span class="field__label">Befehl</span>
<input class="input mono" type="text" name="befehl" id="befehl"
maxlength="500" autocomplete="off"
placeholder='echo "Encryption=AlwaysOff" | {sudo} tee -a /etc/vnc/config.d/common.custom'
{% if not current_user.has('servicing_command.execute') %}disabled{% endif %}>
<span class="field__hint">
Genau eine Zeile. Was der Befehl bewirkt, weiß der, der ihn schreibt – TESM
führt ihn aus und hält fest, was zurückkam.
</span>
</label>
</div>
</section>
{% endif %}
<section class="card"> <section class="card">
<div class="table-toolbar"> <div class="table-toolbar">
<label class="check"> <label class="check">
<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') %}
<button class="btn btn--sm" type="submit"
formaction="{{ url_for('maintenance.start_command') }}"
data-confirm="Den eingetragenen Befehl auf allen ausgewählten Geräten ausführen? Was er bewirkt, entscheidet er selbst – TESM führt ihn nur aus."
data-confirm-title="Befehl senden" data-confirm-ok="Senden" data-confirm-style="danger">
{{ icon('terminal', size=15) }} Befehl senden
</button>
{% endif %}
{% if current_user.has('servicing_update.execute') %} {% if current_user.has('servicing_update.execute') %}
<button class="btn btn--sm btn--primary" type="submit" <button class="btn btn--sm btn--primary" type="submit"
formaction="{{ url_for('maintenance.start_update') }}" formaction="{{ url_for('maintenance.start_update') }}"
@@ -79,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>
@@ -95,13 +159,36 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<span class="sr-only">{{ device.name }} auswählen</span> <span class="sr-only">{{ device.name }} auswählen</span>
</label> </label>
</td> </td>
<td class="fw-600">{{ device.name }}</td> <td class="fw-600">
{# Mitlesen, ohne die Seite zu verlassen -- so war es in der
Fassung 1.2.8, und es fehlte seither. Die Zeile darunter
klappt auf, sobald für dieses Gerät etwas läuft. #}
<button class="btn btn--ghost btn--sm" type="button"
data-ausgabe-schalter="{{ device.name }}"
title="Ausgabe dieses Geräts mitlesen">
{{ icon('chevron-right', size=13) }}
</button>
{{ device.name }}
</td>
<td class="mono fs-sm">{{ device.ip }}</td> <td class="mono fs-sm">{{ device.ip }}</td>
<td> <td data-zustand="{{ device.id }}">
{% if device.state == 'online' %}{{ pill('online', 'online') }} {% if device.state == 'online' %}{{ pill('online', 'online') }}
{% 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>
@@ -129,6 +216,11 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr data-ausgabe-zeile="{{ device.name }}" hidden>
<td colspan="8" class="p-0">
<pre class="job-output" data-ausgabe="{{ device.name }}">Noch keine Ausgabe.</pre>
</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -161,13 +253,13 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<thead><tr><th>Gestartet</th><th>Gerät</th><th>Art</th><th>Zustand</th><th>Fortschritt</th><th>Meldung</th></tr></thead> <thead><tr><th>Gestartet</th><th>Gerät</th><th>Art</th><th>Zustand</th><th>Fortschritt</th><th>Meldung</th></tr></thead>
<tbody> <tbody>
{% for job in jobs %} {% for job in jobs %}
<tr> <tr data-auftrag="{{ job.id }}">
<td class="fs-sm nowrap">{{ job.created_at | dt }}</td> <td class="fs-sm nowrap">{{ job.created_at | dt }}</td>
<td class="fs-sm fw-600"> <td class="fs-sm fw-600">
<a href="{{ url_for('maintenance.job_detail', job_id=job.id) }}">{{ job.subject }}</a> <a href="{{ url_for('maintenance.job_detail', job_id=job.id) }}">{{ job.subject }}</a>
</td> </td>
<td class="fs-sm">{{ 'Aktualisierung' if job.kind.endswith('update') else 'Neustart' }}</td> <td class="fs-sm">{{ 'Aktualisierung' if job.kind.endswith('update') else 'Neustart' }}</td>
<td> <td data-auftrag-zustand>
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }} {% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }} {% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
{% elif job.state == 'running' %}{{ pill('läuft', 'info') }} {% elif job.state == 'running' %}{{ pill('läuft', 'info') }}
@@ -175,11 +267,12 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% else %}{{ pill('wartet', '') }}{% endif %} {% else %}{{ pill('wartet', '') }}{% endif %}
</td> </td>
<td style="min-width:120px"> <td style="min-width:120px">
<div class="progress {{ 'progress--success' if job.state == 'succeeded' else ('progress--danger' if job.state == 'failed' else '') }}"> <div class="progress {{ 'progress--success' if job.state == 'succeeded' else ('progress--danger' if job.state == 'failed' else '') }}"
data-auftrag-balken>
<div class="progress__bar" style="width: {{ job.progress }}%"></div> <div class="progress__bar" style="width: {{ job.progress }}%"></div>
</div> </div>
</td> </td>
<td class="fs-xs muted">{{ job.message }}</td> <td class="fs-xs muted" data-auftrag-meldung>{{ job.message }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -193,6 +286,18 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% block scripts %} {% block scripts %}
<script type="module" nonce="{{ csp_nonce }}"> <script type="module" nonce="{{ csp_nonce }}">
// Eine Vorlage füllt das Feld und erklärt sich -- abgeschickt wird von Hand.
const vorlage = document.getElementById("befehlsvorlage");
const befehl = document.getElementById("befehl");
const erklaerung = document.getElementById("befehlserklaerung");
vorlage?.addEventListener("change", () => {
const gewaehlt = vorlage.selectedOptions[0];
if (vorlage.value) befehl.value = vorlage.value;
erklaerung.textContent =
gewaehlt?.dataset.erklaerung ||
"Eine Vorlage füllt nur das Feld darunter – abgeschickt wird von Hand.";
});
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand // Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
// sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten" // sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten"
// versehentlich das falsche Gerät erwischt. // versehentlich das falsche Gerät erwischt.
@@ -205,14 +310,172 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
.forEach((box) => (box.checked = all.checked)); .forEach((box) => (box.checked = all.checked));
}); });
// Auftragsliste nachladen, solange etwas läuft. // Mitlesen: die Zeile unter dem Gerät zeigt, was dort gerade passiert.
//
// Vorher lud diese Seite sich alle zwei Sekunden komplett neu, solange etwas
// lief -- das verwarf jede Auswahl, jeden Tastendruck im Befehlsfeld und
// sprang nach oben. Jetzt kommen nur die letzten Zeilen, und die Seite
// 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]"));
// 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) => {
knopf.addEventListener("click", () => {
const name = knopf.dataset.ausgabeSchalter;
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
if (zeile) klappen(name, !zeile.hidden);
});
});
document.querySelector("[data-alle-einklappen]")?.addEventListener("click", () => {
schalter.forEach((knopf) => klappen(knopf.dataset.ausgabeSchalter, true));
});
function zeigen(name, text) {
const feld = document.querySelector(`[data-ausgabe="${CSS.escape(name)}"]`);
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
if (!feld || !zeile) return;
const erstmals = !gefuellt.has(name);
const unten = feld.scrollTop + feld.clientHeight >= feld.scrollHeight - 24;
feld.textContent = text || "Noch keine Ausgabe.";
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.
if (text && zeile.hidden && !zugeklappt.has(name)) zeile.hidden = 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;
}
// 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;
async function poll() { async function poll() {
let weiter = 10000;
try { try {
const state = await window.tesm.api("{{ url_for('maintenance.status') }}"); const state = await window.tesm.api("{{ url_for('maintenance.status') }}", { auto: true });
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms); 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) => {
if (!gesehen.has(job.subject)) {
gesehen.add(job.subject);
lageZeigen(job.subject, job.state);
}
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
// "unbekannt", bis er von Hand neu lädt.
(state.devices || []).forEach((geraet) => {
const zelle = document.querySelector(`[data-zustand="${geraet.id}"]`);
if (!zelle) return;
const [text, art] =
geraet.state === "online"
? ["online", "pill--online"]
: geraet.state === "offline"
? ["offline", "pill--offline"]
: ["unbekannt", "pill--warning"];
const pille = zelle.querySelector(".pill");
if (pille && pille.textContent.trim() !== text) {
pille.textContent = text;
pille.className = `pill ${art}`;
}
});
// 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;
}
} catch { } catch {
/* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */ /* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
} }
window.setTimeout(poll, weiter);
} }
poll(); poll();
</script> </script>
+5 -1
View File
@@ -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
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.8.13" 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; }
@@ -1275,6 +1285,24 @@
color: var(--text-faint); color: var(--text-faint);
} }
/* Mitlesen auf der Wartungsseite: die Ausgabe eines laufenden Auftrags,
direkt unter seinem Geraet. Dunkler Grund wie in jeder Konsole -- was
dort steht, kommt von einer fremden Maschine und nicht aus dieser
Oberflaeche. */
.job-output {
margin: 0;
max-height: 220px;
overflow: auto;
padding: var(--sp-3) var(--sp-4);
background: #101216;
color: #d7dbe0;
font-family: var(--font-mono);
font-size: var(--fs-xs);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
/* ---------------------------------------------------------------------- # /* ---------------------------------------------------------------------- #
Bildschirmsitzung (VNC) Bildschirmsitzung (VNC)
@@ -88,6 +88,7 @@ const RA2NE = 6;
//: einen Knopf brauchen. X11-Keysyms. //: einen Knopf brauchen. X11-Keysyms.
const ALT_LINKS = 0xffe9; const ALT_LINKS = 0xffe9;
const F4 = 0xffc1; const F4 = 0xffc1;
const F5 = 0xffc2;
const schema = window.location.protocol === "https:" ? "wss:" : "ws:"; const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
@@ -97,7 +98,7 @@ const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
* ``quelle`` ist die Adresse der JSON-Auskunft; alles Weitere steht dort. * ``quelle`` ist die Adresse der JSON-Auskunft; alles Weitere steht dort.
* Rueckgabe: eine Funktion, die die Sitzung wieder abbaut. * Rueckgabe: eine Funktion, die die Sitzung wieder abbaut.
*/ */
export function starteSitzung(wurzel, quelle) { export function starteSitzung(wurzel, quelle, { beiEnde = null } = {}) {
const teil = (rolle) => wurzel.querySelector(`[data-rolle="${rolle}"]`); const teil = (rolle) => wurzel.querySelector(`[data-rolle="${rolle}"]`);
const leinwand = teil("leinwand"); const leinwand = teil("leinwand");
const zeile = teil("status"); const zeile = teil("status");
@@ -295,6 +296,10 @@ export function starteSitzung(wurzel, quelle) {
// Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa, // Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa,
// dass TESM wegen Untätigkeit getrennt hat. // dass TESM wegen Untätigkeit getrennt hat.
melden(letzterGrund ? `Sitzung beendet – ${letzterGrund}` : "Sitzung beendet."); melden(letzterGrund ? `Sitzung beendet – ${letzterGrund}` : "Sitzung beendet.");
// Im Fenster gibt es nach dem Trennen nichts mehr zu sehen: ein leerer
// Rahmen, den man wegklicken muss. Bei einem **Abbruch** bleibt es
// stehen -- dort steht der Grund, und der ist der ganze Zweck.
beiEnde?.();
} else if (!stand && naechsterVersuch()) { } else if (!stand && naechsterVersuch()) {
return; return;
} else { } else {
@@ -374,6 +379,13 @@ export function starteSitzung(wurzel, quelle) {
rfb.sendKey(F4, "F4", false); rfb.sendKey(F4, "F4", false);
rfb.sendKey(ALT_LINKS, "AltLeft", false); rfb.sendKey(ALT_LINKS, "AltLeft", false);
}); });
// F5 faengt der Browser des Arbeitsplatzes ab -- er wuerde diese Seite neu
// laden statt das Bild auf dem Geraet.
teil("taste-f5")?.addEventListener("click", () => {
if (!rfb) return;
rfb.sendKey(F5, "F5", true);
rfb.sendKey(F5, "F5", false);
});
sitzungsknoepfe(false); sitzungsknoepfe(false);
melden("Wird vorbereitet …"); melden("Wird vorbereitet …");
@@ -442,7 +454,9 @@ if (fenster) {
titel.textContent = knopf.dataset.vncName || "Bildschirm"; titel.textContent = knopf.dataset.vncName || "Bildschirm";
if (typeof fenster.showModal === "function") fenster.showModal(); if (typeof fenster.showModal === "function") fenster.showModal();
else fenster.setAttribute("open", ""); else fenster.setAttribute("open", "");
beenden = starteSitzung(behaelter, knopf.dataset.vncOeffnen); beenden = starteSitzung(behaelter, knopf.dataset.vncOeffnen, {
beiEnde: () => fenster.close(),
});
}); });
// Zu heisst zu: eine Sitzung, die im Hintergrund weiterlaeuft, haelt die // Zu heisst zu: eine Sitzung, die im Hintergrund weiterlaeuft, haelt die
@@ -960,15 +960,22 @@ export function initFilePreview(wurzel = document) {
const ersetzenFeld = stueck("ersetzen"); const ersetzenFeld = stueck("ersetzen");
const ersetzenKasten = stueck("ersetzenfeld"); const ersetzenKasten = stueck("ersetzenfeld");
const pruefzeile = stueck("pruefung"); const pruefzeile = stueck("pruefung");
// Die Ordner der geoeffneten Freigabe kommen mit der Seite; die einer // Die Ordner der geoeffneten Freigabe stehen bereits in der Auswahl -- der
// anderen holt das Auswahlfeld bei Bedarf nach. // Server hat sie dort hineingeschrieben. Gemerkt werden sie trotzdem, denn
const eigeneOrdner = () => { // wer zwischendurch eine andere Freigabe waehlt, ersetzt die Liste; die
try { // einer anderen Freigabe holt das Auswahlfeld dann bei Bedarf nach.
return JSON.parse(rumpf.dataset.viewerOrdnerliste || "[]"); //
} catch { // Vorher stand dieselbe Liste ein zweites Mal daneben, als JSON in einem
return []; // Attribut -- und kam dort nie an: ``tojson`` laesst doppelte
} // Anfuehrungszeichen stehen, und in einem doppelt gequoteten Attribut endet
}; // der Wert damit nach der oeffnenden Klammer. Die Auswahl zeigte danach nur
// noch das Wurzelverzeichnis, und in einen Unterordner liess sich nichts
// mehr speichern. Zwei Fassungen derselben Liste waren die eigentliche
// Ursache; jetzt gibt es nur noch eine.
const gelieferteOrdner = ordnerFeld
? [...ordnerFeld.options].map((eintrag) => eintrag.value).filter(Boolean)
: [];
const eigeneOrdner = () => gelieferteOrdner;
const jetzt = stueck("jetzt"); const jetzt = stueck("jetzt");
const abbrechen = stueck("abbrechen"); const abbrechen = stueck("abbrechen");
const modusFeld = stueck("modus"); const modusFeld = stueck("modus");
@@ -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 }} &middot; letzte {{ line_count }} Zeilen{% endblock %} {% block page_sub %}{{ path }} &middot; letzte {{ line_count }} Zeilen{% endblock %}
{% block topbar_extra %} {% block topbar_extra %}
+41
View File
@@ -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,