diff --git a/README.md b/README.md index 4a6cc8a..ee25c19 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # TESM -Verwaltung von PoE-Geraeten und Aruba-Switchen: Inventar, Zustandsueberwachung, -Portsteuerung, Neustart (PoE, SSH, RPC), DHCP ueber Kea, Dateifreigaben, -Wartung ueber SSH, Protokolle, Papierkorb, verschluesselte Ein- und Ausfuhr und +Verwaltung von PoE-Geräten und Aruba-Switchen: Inventar, Zustandsüberwachung, +Portsteuerung, Neustart (PoE, SSH, RPC), DHCP über Kea, Dateifreigaben, +Wartung über SSH, Protokolle, Papierkorb, verschlüsselte Ein- und Ausfuhr und ein SSH-Terminal im Browser. -**Dieses Repository enthaelt genau das, was zum Installieren und Betreiben von -TESM gebraucht wird** -- nichts darueber hinaus: +**Dieses Repository enthält genau das, was zum Installieren und Betreiben von +TESM gebraucht wird** -- nichts darüber hinaus: ``` apps/tesm die Anwendung @@ -16,7 +16,7 @@ deploy/ install.sh, update.sh, release.sh, systemd, privileg docs/ Betrieb und Sicherheit ``` -Die Serverhaelfte des Lizenzprotokolls (`tesm-licensing-server`: ausstellen, +Die Serverhälfte des Lizenzprotokolls (`tesm-licensing-server`: ausstellen, erneuern, Antworten signieren) liegt **nicht** hier. TESM verifiziert Lizenzen und stellt Anfragen; den Code zum Ausstellen soll ein Client nicht einmal mitbringen. @@ -31,7 +31,7 @@ mitbringen. | Zweig | Inhalt | |---|---| | `main` | dieser Neubau (Version 2.0.0) | -| `SONNET5` | der Stand davor, unveraendert aufbewahrt | +| `SONNET5` | der Stand davor, unverändert aufbewahrt | --- @@ -43,9 +43,9 @@ sudo -u tesm tesm-admin create-admin ``` Danach `https:///`. Ohne `--domain` entsteht ein selbstsigniertes -Zertifikat; der Browser warnt einmal, die Verbindung ist verschluesselt. +Zertifikat; der Browser warnt einmal, die Verbindung ist verschlüsselt. -Mit oeffentlichem Namen und Let's Encrypt stattdessen: +Mit öffentlichem Namen und Let's Encrypt stattdessen: ```bash sudo bash deploy/install.sh --app tesm --https --domain tesm.firma.de --acme-email it@firma.de @@ -54,16 +54,16 @@ sudo bash deploy/install.sh --app tesm --https --domain tesm.firma.de --acme-ema Zielzustand: **Port 443 mit TLS, Port 80 leitet dorthin um** -- ausser dem ACME-Pfad, der erreichbar bleiben muss, sonst kann Let's Encrypt nie erneuern. -Sitzt ein eigener Reverse Proxy davor, der TLS beendet, laeuft TESM ohne +Sitzt ein eigener Reverse Proxy davor, der TLS beendet, läuft TESM ohne `--https` schlicht auf Port 80. -Eine zweite Installation zum Testen stoert die laufende nicht: +Eine zweite Installation zum Testen stört die laufende nicht: ```bash sudo bash deploy/install.sh --app tesm --instance test --port 5100 --http-port 8080 ``` -Jede Instanz bekommt eigene Datenbank, eigene Schluessel, eigene nginx-Site und +Jede Instanz bekommt eigene Datenbank, eigene Schlüssel, eigene nginx-Site und ein eigenes Sitzungscookie. ### Aktualisieren @@ -72,9 +72,9 @@ ein eigenes Sitzungscookie. sudo bash deploy/update.sh --app tesm ``` -Das laedt ein Release, entpackt es und ruft dessen `install.sh` auf. Vor jedem -Update entsteht ein vollstaendiges Backup -- unabhaengig davon, ob sich das -Schema geaendert hat -- und danach laeuft ein Health-Check mit automatischem +Das lädt ein Release, entpackt es und ruft dessen `install.sh` auf. Vor jedem +Update entsteht ein vollständiges Backup -- unabhängig davon, ob sich das +Schema geändert hat -- und danach läuft ein Health-Check mit automatischem Rollback. --- @@ -83,15 +83,15 @@ Rollback. ```bash sudo -u tesm tesm-admin status # Zustand der Installation -sudo -u tesm tesm-admin create-admin # Konto anlegen oder zuruecksetzen -sudo -u tesm tesm-admin check-now # Ueberwachungsdurchlauf sofort -sudo -u tesm tesm-admin housekeeping # Sitzungen und Protokolle aufraeumen +sudo -u tesm tesm-admin create-admin # Konto anlegen oder zurücksetzen +sudo -u tesm tesm-admin check-now # Überwachungsdurchlauf sofort +sudo -u tesm tesm-admin housekeeping # Sitzungen und Protokolle aufräumen sudo -u tesm tesm-admin web-setup --https # nginx und TLS einrichten ``` ### Umzug aus dem alten TESM -Ein Export des Vorgaengers laesst sich uebersetzen und dann ueber den normalen +Ein Export des Vorgängers lässt sich übersetzen und dann über den normalen Import einlesen -- Clients, Switche, Zugangsdaten und die DHCP-Konfiguration: ```bash @@ -100,11 +100,11 @@ sudo -u tesm tesm-admin convert-legacy tesm_export_20260813_195455.json --dry-ru `--dry-run` zeigt nur den Bericht. Ohne die Option entsteht eine Datei im neuen Format, die unter *Verwaltung -> Sicherung* eingelesen wird. Der Bericht -nennt ausdruecklich, was **nicht** uebersetzt werden kann (lokale Konten, +nennt ausdrücklich, was **nicht** übersetzt werden kann (lokale Konten, Rechte, Verzeichnisdienst, Webserver) -- das ist von Hand nachzuziehen. -Die Passphrase gehoert an die verdeckte Eingabeaufforderung. `--passphrase` -existiert nur fuer Automatisierung: es schreibt sie in Shell-Historie und +Die Passphrase gehört an die verdeckte Eingabeaufforderung. `--passphrase` +existiert nur für Automatisierung: es schreibt sie in Shell-Historie und Prozessliste. --- @@ -135,20 +135,20 @@ Entwicklungsrepository, ebenso die Tests. ## Was diese Fassung anders macht -Ein Neubau. Die Fehler des Vorgaengers sind bekannt; an den Stellen, an denen -sie entstanden sind, steht jetzt ein Kommentar, der erklaert, warum es hier -anders geloest ist. Die wichtigsten: +Ein Neubau. Die Fehler des Vorgängers sind bekannt; an den Stellen, an denen +sie entstanden sind, steht jetzt ein Kommentar, der erklärt, warum es hier +anders gelöst ist. Die wichtigsten: -* **Kein root fuer die Anwendung.** Ein einziger allowlisted Helfer mit - Argumentpruefung statt eines Webprozesses als `root`. -* **CSRF-Schutz ueberhaupt.** Der Vorgaenger hatte keinen. -* **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet. +* **Kein root für die Anwendung.** Ein einziger allowlisted Helfer mit + Argumentprüfung statt eines Webprozesses als `root`. +* **CSRF-Schutz überhaupt.** Der Vorgänger hatte keinen. +* **Rechte werden serverseitig geprüft**, nicht nur im Template ausgeblendet. * **Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.** Ein - falscher `alias`-Pfad ueberlebte im Vorgaenger jahrelang jedes Update. -* **Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft** -- nginx wie - Kea. Eine fehlerhafte Datei faellt beim Schreiben auf, nicht erst beim - naechsten Start des Dienstes. + falscher `alias`-Pfad überlebte im Vorgänger jahrelang jedes Update. +* **Jede erzeugte Konfiguration wird vor dem Übernehmen geprüft** -- nginx wie + Kea. Eine fehlerhafte Datei fällt beim Schreiben auf, nicht erst beim + nächsten Start des Dienstes. * **Kein Hostname im Lizenz-Fingerabdruck.** Umbenennen brach die Bindung. -* **Zwei Installationen stoeren sich nicht** -- eigener `SITE_KEY` fuer +* **Zwei Installationen stören sich nicht** -- eigener `SITE_KEY` für nginx-Site, netplan, logrotate, Zertifikate und Sitzungscookie. * **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock. diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index b639514..42aa607 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.0.0" +version = "2.0.2" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/__init__.py b/apps/tesm/src/tesm/__init__.py index 51c0693..ea5cd7f 100644 --- a/apps/tesm/src/tesm/__init__.py +++ b/apps/tesm/src/tesm/__init__.py @@ -23,7 +23,7 @@ from .permissions import PERMISSIONS, ROLE_PRESETS from .schema import TESM_MIGRATIONS from .settings_spec import TESM_SETTINGS -__version__ = "2.0.0" +__version__ = "2.0.2" APP_KEY = "tesm" APP_NAME = "TESM" @@ -181,7 +181,31 @@ def _status_pills(user: Any) -> list[dict[str, str]]: extension = core() pills: list[dict[str, str]] = [] - counts = monitor.dashboard_counts(db()) + verbindung = db() + + # Timer bis zum naechsten Durchlauf, mit Knopf fuer "sofort pruefen". Der + # Knopf gehoert in die Pille, weil er genau die Angabe daneben zuruecksetzt. + if user.has_any(("devices.view", "monitoring.view")): + intervall = int(extension.settings.get(verbindung, "monitor_interval_seconds") or 300) + faellig = monitor.next_check_at(verbindung, intervall) + if faellig is not None: + eintrag = { + "label": "Naechste Pruefung in", + "countdown_to": faellig.isoformat(), + "countdown_done": "jetzt", + "css_class": "", + "dot": "1", + "dot_class": "", + "title": f"Der Ueberwachungsdienst prueft alle {intervall} Sekunden.", + } + if user.has("settings_system.execute"): + from flask import url_for + + eintrag["action"] = url_for("settings.run_check_now") + eintrag["action_title"] = "Alle Clients sofort pruefen" + pills.append(eintrag) + + counts = monitor.dashboard_counts(verbindung) if counts["offline"]: pills.append( { @@ -275,12 +299,22 @@ def _startup_tasks(app: Flask) -> None: ) return - from .services import maintenance + from .services import fileshare, maintenance with extension.database.session() as conn, extension.database.transaction(conn): orphaned = maintenance.mark_orphaned_jobs(conn) + # Einbindungen leben im Mount-Namensraum dieses Prozesses und sind mit + # dem vorherigen verschwunden. Die Zeilen dazu muessen weg, sonst zeigt + # die Oberflaeche Freigaben, die es nicht gibt. + vergessen = fileshare.forget_all_mounts(conn) if orphaned: logger.warning("%s unterbrochene Wartungsauftraege als unklar markiert", orphaned) + if vergessen: + logger.info( + "%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen " + "-- sie bestehen mit dessen Namensraum nicht mehr", + vergessen, + ) if extension.license is not None: import socket diff --git a/apps/tesm/src/tesm/blueprints/devices.py b/apps/tesm/src/tesm/blueprints/devices.py index 86a57b5..c16a727 100644 --- a/apps/tesm/src/tesm/blueprints/devices.py +++ b/apps/tesm/src/tesm/blueprints/devices.py @@ -298,6 +298,12 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ severity="notice", ) + # Der Auftrag laeuft im Hintergrund und meldet sein Ergebnis erst, wenn das + # Geraet zurueck ist. Der Zustand muss aber sofort umschlagen -- sonst sieht + # die Uebersicht aus wie eine, in der nichts passiert ist. + with transaction(): + monitor.mark_restarting(conn, int(device["id"])) + maintenance_service.start_background( maintenance_service.run_reboot, database=extension.database, diff --git a/apps/tesm/src/tesm/blueprints/fileshare.py b/apps/tesm/src/tesm/blueprints/fileshare.py index 81d9bf8..076da6e 100644 --- a/apps/tesm/src/tesm/blueprints/fileshare.py +++ b/apps/tesm/src/tesm/blueprints/fileshare.py @@ -119,6 +119,21 @@ def preview(token: str): headers={ "Content-Disposition": f'inline; filename="{path.name}"', "X-Content-Type-Options": "nosniff", + # Die Vorschau laeuft in einem Rahmen auf der Freigabeseite. Die + # anwendungsweite CSP verbietet mit "frame-ancestors 'none'" jedes + # Einrahmen -- auch das durch die eigene Seite. Beides wird hier + # deshalb ausdruecklich auf "gleicher Ursprung" gesetzt; die Header + # der Anwendung werden per setdefault gesetzt und treten damit + # zurueck. + # + # Bewusst nur die Rahmen-Direktiven und nicht die ganze strenge CSP: + # was hier ausgeliefert wird, ist eine Datei mit engem, erlaubtem + # MIME-Typ (kein HTML, kein SVG) und "nosniff" -- Skript kann darin + # nicht ausgefuehrt werden. Eine vollstaendige CSP auf einer + # PDF-Antwort riskiert dagegen, die eingebaute Anzeige des Browsers + # zu blockieren. + "Content-Security-Policy": "frame-ancestors 'self'", + "X-Frame-Options": "SAMEORIGIN", }, ) @@ -255,6 +270,26 @@ def mapping_add(): return redirect(url_for("fileshare.mappings")) +@bp.post("/zuordnungen/") +@permission_required("fileshare.edit") +def mapping_update(mapping_id: int): + try: + with transaction() as conn: + service.update_mapping( + conn, + mapping_id, + ad_group_dn=request.form.get("ad_group_dn", ""), + ad_group_name=request.form.get("ad_group_name", ""), + label=request.form.get("label", ""), + unc=request.form.get("unc", ""), + ) + _audit("fileshare.mapping_updated", request.form.get("label", ""), severity="notice") + flash("Zuordnung geaendert.", "success") + except service.FileshareError as exc: + flash(str(exc), "danger") + return redirect(url_for("fileshare.mappings")) + + @bp.post("/zuordnungen//loeschen") @permission_required("fileshare.edit") def mapping_delete(mapping_id: int): diff --git a/apps/tesm/src/tesm/blueprints/maintenance.py b/apps/tesm/src/tesm/blueprints/maintenance.py index b7be785..56c8d44 100644 --- a/apps/tesm/src/tesm/blueprints/maintenance.py +++ b/apps/tesm/src/tesm/blueprints/maintenance.py @@ -22,7 +22,7 @@ from tesm_core.auth.service import client_ip from tesm_core.db import Database from tesm_core.extension import core, db, transaction -from ..services import inventory, maintenance as service, ssh, windowsops +from ..services import inventory, maintenance as service, ssh, windowsops, monitor bp = Blueprint("maintenance", __name__, url_prefix="/wartung") @@ -246,6 +246,9 @@ def _start(kind: str) -> None: timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900), ) else: + # Sofort sichtbar, nicht erst wenn das Geraet zurueck ist. + with transaction() as verbindung: + monitor.mark_restarting(verbindung, int(device["id"])) service.start_background( service.run_reboot, database=extension.database, diff --git a/apps/tesm/src/tesm/blueprints/switches.py b/apps/tesm/src/tesm/blueprints/switches.py index a7cf8cf..d4f076e 100644 --- a/apps/tesm/src/tesm/blueprints/switches.py +++ b/apps/tesm/src/tesm/blueprints/switches.py @@ -54,7 +54,6 @@ def overview(): "tesm/switches.html", switches=inventory.list_switches(conn), credentials=inventory.list_credentials(conn), - host_keys=Database.all(conn, "SELECT * FROM switch_host_keys ORDER BY host"), terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")), ) diff --git a/apps/tesm/src/tesm/navigation.py b/apps/tesm/src/tesm/navigation.py index bdd61f6..4125f3e 100644 --- a/apps/tesm/src/tesm/navigation.py +++ b/apps/tesm/src/tesm/navigation.py @@ -140,7 +140,7 @@ NAVIGATION = NavTree( ), NavItem( key="logs_audit", - label="Aenderungen", + label="Audit", icon="history", endpoint="logs.audit_view", permissions=("logs_audit.view", "logs_audit.export"), diff --git a/apps/tesm/src/tesm/services/fileshare.py b/apps/tesm/src/tesm/services/fileshare.py index 758db02..79b1834 100644 --- a/apps/tesm/src/tesm/services/fileshare.py +++ b/apps/tesm/src/tesm/services/fileshare.py @@ -157,7 +157,7 @@ def search_suffixes(conn: sqlite3.Connection) -> list[str]: def resolve_unc_host(unc: str, suffixes: Iterable[str]) -> tuple[str, str]: - """Ersetzt den Hostnamen einer UNC durch einen auflösbaren. + """Ersetzt den Hostnamen einer UNC durch einen aufloesbaren. Rueckgabe: ``(unc, hinweis)``. Der Hinweis ist leer, wenn nichts geaendert wurde; laesst sich der Name gar nicht aufloesen, bleibt die UNC unveraendert @@ -176,9 +176,9 @@ def resolve_unc_host(unc: str, suffixes: Iterable[str]) -> tuple[str, str]: neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "") return neu, f"Name {host} ueber {kandidat} aufgeloest" return unc, ( - f"Der Name {host} ist auf diesem Server nicht auflösbar. Bitte den " + f"Der Name {host} ist auf diesem Server nicht aufloesbar. Bitte den " "vollqualifizierten Namen oder die IP-Adresse eintragen -- oder unter " - "Netzwerk eine Suchdomäne setzen." + "Netzwerk eine Suchdomaene setzen." ) @@ -207,6 +207,52 @@ def add_mapping( return int(cursor.lastrowid or 0) +def update_mapping( + conn: sqlite3.Connection, + mapping_id: int, + *, + ad_group_dn: str, + ad_group_name: str, + label: str, + unc: str, +) -> None: + """Aendert eine bestehende Zuordnung. + + Dieselben Pruefungen wie beim Anlegen -- eine Zuordnung, die man nur neu + anlegen statt korrigieren kann, verleitet dazu, den Tippfehler stehen zu + lassen und daneben einen zweiten Eintrag zu machen. + """ + clean_unc = validate_unc(unc) + clean_label = (label or "").strip() + clean_dn = (ad_group_dn or "").strip() + if not clean_label: + raise FileshareError("Der Anzeigename darf nicht leer sein.") + if not clean_dn: + raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.") + + vorhanden = Database.one( + conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,) + ) + if vorhanden is None: + raise FileshareError("Diese Zuordnung existiert nicht mehr.") + + # Die Eindeutigkeit (ad_group_dn, unc) gilt weiterhin -- nur nicht gegen + # sich selbst. + kollision = Database.one( + conn, + "SELECT id FROM fileshare_mappings WHERE ad_group_dn=? AND unc=? AND id<>?", + (clean_dn, clean_unc, mapping_id), + ) + if kollision is not None: + raise FileshareError("Diese Zuordnung existiert bereits.") + + conn.execute( + "UPDATE fileshare_mappings SET ad_group_dn=?, ad_group_name=?, label=?, unc=? " + "WHERE id=?", + (clean_dn, (ad_group_name or "").strip(), clean_label, clean_unc, mapping_id), + ) + + def delete_mapping(conn: sqlite3.Connection, mapping_id: int) -> None: conn.execute("DELETE FROM fileshare_mappings WHERE id=?", (mapping_id,)) @@ -372,6 +418,24 @@ def release_mount(conn: sqlite3.Connection, mount_id: int) -> None: ) +def forget_all_mounts(conn: sqlite3.Connection) -> int: + """Vergisst alle Einbindungen -- beim Start des Webprozesses. + + Eingebunden wird im Mount-Namensraum dieses Dienstes. Mit dem Dienst + verschwindet der Namensraum und mit ihm jede Einbindung; die Zeilen in der + Datenbank ueberleben aber. Ohne dieses Aufraeumen zeigte die Oberflaeche + nach jedem Neustart Freigaben an, die es nicht mehr gibt -- und ein Klick + darauf fuehrte in ein leeres Verzeichnis. + + Bewusst **kein** ``umount``: es gibt nichts zu loesen. Der Aufruf ginge in + einen Namensraum, der nicht mehr existiert. + """ + cursor = conn.execute( + "UPDATE fileshare_mounts SET released_at=? WHERE released_at IS NULL", (_iso(_now()),) + ) + return int(cursor.rowcount or 0) + + def release_all_for_user(conn: sqlite3.Connection, user_id: int) -> int: count = 0 for mount in active_mounts(conn, user_id): @@ -389,10 +453,26 @@ def sweep_expired(database: Any) -> int: """ released = 0 with database.session() as conn: + # Eine Einbindung lebt genau so lange wie die Sitzung, aus der sie + # entstanden ist. Vorher hing sie an einer eigenen Frist von zwoelf + # Stunden: nach dem Abmelden -- oder nach der automatischen Abmeldung + # bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers + # weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen; + # eine Sitzung kann durch Aktivitaet beliebig lange leben, eine + # Einbindung soll trotzdem irgendwann neu entstehen muessen. rows = Database.all( conn, - "SELECT id FROM fileshare_mounts WHERE released_at IS NULL AND expires_at <= ?", - (_iso(_now()),), + """ + SELECT m.id FROM fileshare_mounts m + LEFT JOIN user_sessions s ON s.id = m.session_id + WHERE m.released_at IS NULL + AND (m.expires_at <= ? + OR s.id IS NULL + OR s.revoked_at IS NOT NULL + OR s.idle_expires_at <= ? + OR s.absolute_expires_at <= ?) + """, + (_iso(_now()), _iso(_now()), _iso(_now())), ) if not rows: return 0 diff --git a/apps/tesm/src/tesm/services/monitor.py b/apps/tesm/src/tesm/services/monitor.py index 0fa669b..773c84c 100644 --- a/apps/tesm/src/tesm/services/monitor.py +++ b/apps/tesm/src/tesm/services/monitor.py @@ -33,7 +33,7 @@ import threading import time from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Callable @@ -313,9 +313,10 @@ def record_restart( (device_id, _now(), trigger, actor, action, result, duration_ms, detail[:8000], method), ) if result == "ok": - # Der Zustand wird hier gesetzt, nicht in den einzelnen Neustartwegen: - # PoE, SSH und RPC laufen alle durch diese Funktion. Eine zweite Stelle - # waere eine, die beim naechsten Verfahren vergessen wird. + # Hier faellt nur noch der Zaehler und der Zeitstempel an. Den Zustand + # setzt ``mark_restarting`` schon beim Ausloesen -- dieses Ergebnis + # kommt bei SSH und RPC erst, wenn das Geraet zurueck ist, und bis + # dahin stand die Kachel weiter auf "online". conn.execute( "UPDATE device_status SET last_restart_at=?, restart_count=restart_count+1, " "state=?, last_change_at=?, fail_count=0 WHERE device_id=?", @@ -323,6 +324,33 @@ def record_restart( ) +def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None: + """Setzt den Zustand auf "startet neu" -- beim **Ausloesen**, nicht am Ende. + + Ein Neustart ueber SSH oder RPC laeuft als Auftrag: Kommando schicken, dann + auf die Rueckkehr warten. Das Ergebnis steht erst Minuten spaeter fest. Wer + den Knopf gedrueckt hat, will die Wirkung aber sofort sehen -- sonst sieht + die Uebersicht aus wie eine, in der nichts passiert ist. + + Bewusst ohne ``restart_count``: gezaehlt wird das Ereignis, nicht die + Absicht. Und ohne ``last_restart_at``: das Kulanzfenster laeuft ab dem + Zeitpunkt, an dem der Neustart tatsaechlich ausgeloest wurde + (``record_restart``). + """ + conn.execute( + """ + INSERT INTO device_status (device_id, state, fail_count, last_check_at, last_change_at) + VALUES (?,?,0,?,?) + ON CONFLICT(device_id) DO UPDATE SET + state=excluded.state, + fail_count=0, + last_change_at=CASE WHEN device_status.state <> excluded.state + THEN excluded.last_change_at ELSE device_status.last_change_at END + """, + (device_id, STATE_RESTARTING, _now(), _now()), + ) + + # --------------------------------------------------------------------------- # # PoE-Neustart # --------------------------------------------------------------------------- # @@ -363,6 +391,13 @@ def restart_device_poe( switch_ip = str(device.get("switch_ip") or "") credential_id = device.get("switch_credential_id") + # Sofort sichtbar machen, dass etwas passiert -- der Portzyklus dauert + # Sekunden, das Warten auf die Rueckkehr laenger. Gilt fuer den manuellen + # wie fuer den automatischen Neustart, denn beide laufen hier durch. + if port and switch_ip: + with write(): + mark_restarting(conn, int(device["id"])) + if not port or not switch_ip: message = ( "Kein Switch oder kein Port zugeordnet -- ein PoE-Neustart ist nicht moeglich." @@ -573,6 +608,28 @@ def _in_cooldown(last_restart_at: Any) -> bool: return (datetime.now(timezone.utc) - moment).total_seconds() < RESTART_COOLDOWN_SECONDS +def next_check_at(conn: sqlite3.Connection, interval_seconds: int) -> datetime | None: + """Wann der naechste Ueberwachungsdurchlauf faellig ist. + + Anker ist die letzte tatsaechliche Pruefung, nicht der Prozessstart: nach + einem Neustart des Dienstes soll der Zaehler dort weiterlaufen, wo der + Durchlauf steht, und nicht bei voller Laenge beginnen. + """ + zeile = Database.one( + conn, "SELECT MAX(last_check_at) AS letzte FROM device_status", () + ) + letzte = (zeile or {}).get("letzte") + if not letzte: + return None + try: + stamp = datetime.fromisoformat(str(letzte)) + except ValueError: + return None + if stamp.tzinfo is None: + stamp = stamp.replace(tzinfo=timezone.utc) + return stamp + timedelta(seconds=max(1, int(interval_seconds))) + + def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]: row = Database.one( conn, diff --git a/apps/tesm/src/tesm/services/restart.py b/apps/tesm/src/tesm/services/restart.py index 83a7ec6..d1cd805 100644 --- a/apps/tesm/src/tesm/services/restart.py +++ b/apps/tesm/src/tesm/services/restart.py @@ -45,7 +45,18 @@ class Method: return self.key == METHOD_POE +#: Derselbe Weg wie PoE, nur anders benannt: bei einem Geraet, das nicht +#: antwortet, ist "Neustart" das falsche Wort. Der Schluessel bleibt ``poe``, +#: damit Protokoll und Auswertung unveraendert bleiben. +POWER_ON = "poe-einschalten" + DEFINITIONS: dict[str, Method] = { + POWER_ON: Method( + key=METHOD_POE, + label="Einschalten (PoE)", + hint="Schaltet den Switch-Port aus und wieder ein. Bei einem Geraet, das nicht " + "antwortet, ist das der einzige Weg, der noch etwas bewirkt.", + ), METHOD_POE: Method( key=METHOD_POE, label="PoE-Port", @@ -82,9 +93,17 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]: return None methods: list[Method] = [] - if field("switch_id") and str(field("port") or "").strip(): + hat_poe = bool(field("switch_id")) and bool(str(field("port") or "").strip()) + if hat_poe: methods.append(DEFINITIONS[METHOD_POE]) + # Ein Geraet, das nicht antwortet, laesst sich nicht ueber SSH oder RPC neu + # starten -- dafuer muesste es erreichbar sein. Angeboten wird dann nur noch + # der PoE-Weg, und der heisst dort "Einschalten": er schaltet den Port + # wieder ein, statt ein laufendes System neu zu starten. + if str(field("state") or "") == "offline": + return [DEFINITIONS[POWER_ON]] if hat_poe else [] + category = str(field("credential_category") or "") has_login = bool(field("credential_id")) if has_login and maintenance_active: diff --git a/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html b/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html index 7f9da98..0efecd2 100644 --- a/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html +++ b/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html @@ -1,8 +1,8 @@ -{# Teilbereich der Statusuebersicht -- wird auch einzeln per fetch geladen. #} +{# Teilbereich der Statusübersicht -- wird auch einzeln per fetch geladen. #} {% from "tesm_core/macros.html" import pill, empty_state %} -{# Beide Bloecke in einen Stapel: sonst klebt die erste Gruppe an den - Filterkacheln, waehrend die Gruppen untereinander Abstand haben. #} +{# Beide Blöcke in einen Stapel: sonst klebt die erste Gruppe an den + Filterkacheln, während die Gruppen untereinander Abstand haben. #}
{% else %}
- Noch nicht geprueft + Noch nicht geprüft {{ counts.unknown }}
{% endif %} @@ -49,9 +49,9 @@
{% for tile in section.tiles %} {# Kopf zuerst Kennzeichen, dann Name: das Auge findet die Kachel - ueber Farbe und Symbol, den genauen Namen liest es danach. + über Farbe und Symbol, den genauen Namen liest es danach. Die IP steht gross darunter statt in der Merkmalliste -- sie ist - die Angabe, nach der man auf dieser Seite tatsaechlich sucht. #} + die Angabe, nach der man auf dieser Seite tatsächlich sucht. #}
@@ -73,7 +73,7 @@
Switch
{{ tile.switch }} / {{ tile.port }}
{% endif %} {% if tile.latency_ms %} -
Antwortzeit
{{ '%.1f' | format(tile.latency_ms) }} ms
+
Antwortzeit
{{ tile.latency_ms | millis }}
{% endif %} {% if tile.last_change_at %}
Seit
@@ -92,7 +92,7 @@
diff --git a/apps/tesm/src/tesm/templates/tesm/backup.html b/apps/tesm/src/tesm/templates/tesm/backup.html index 32a6a00..c6a23a1 100644 --- a/apps/tesm/src/tesm/templates/tesm/backup.html +++ b/apps/tesm/src/tesm/templates/tesm/backup.html @@ -1,14 +1,14 @@ {% extends "tesm_core/base.html" %} {% from "tesm_core/macros.html" import field, checkbox, alert, pill %} -{% block page_title %}Sicherung und Uebertragung{% endblock %} -{% block page_sub %}Verschluesselter Export · zweistufiger Import{% endblock %} +{% block page_title %}Sicherung und Übertragung{% endblock %} +{% block page_sub %}Verschlüsselter Export · zweistufiger Import{% endblock %} {% block content %} {% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %} -{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschluesselt (AES-256-GCM). Ohne -diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertuer. Bewahren Sie +{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschlüsselt (AES-256-GCM). Ohne +diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertür. Bewahren Sie sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
@@ -16,7 +16,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
{{ icon('download', size=17) }} Sicherung erstellen
-
Auswahl je Kategorie · verlangt eine frische Passwortbestaetigung
+
Auswahl je Kategorie · verlangt eine frische Passwortbestätigung
@@ -37,7 +37,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }} {% if category.admin_only %}{{ pill('nur Admin', 'accent') }}{% endif %}
{{ category.hint }}
{% if not allowed %} -
Fuer diese Kategorie fehlt Ihnen das Recht.
+
Für diese Kategorie fehlt Ihnen das Recht.
{% endif %} @@ -46,9 +46,9 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }} {% if can_secrets %} - {{ checkbox('include_secrets', 'Passwoerter der Zugangsdaten mit exportieren', - hint_text='Sie liegen dann im Klartext innerhalb der verschluesselten - Datei. Ohne dieses Kennzeichen enthaelt die Sicherung nur Namen und + {{ checkbox('include_secrets', 'Passwörter der Zugangsdaten mit exportieren', + hint_text='Sie liegen dann im Klartext innerhalb der verschlüsselten + Datei. Ohne dieses Kennzeichen enthält die Sicherung nur Namen und Benutzer.') }} {% endif %} @@ -73,9 +73,9 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
-
{{ icon('upload', size=17) }} Geoeffnete Sicherung
+
{{ icon('upload', size=17) }} Geöffnete Sicherung
- Waehlen Sie, was uebernommen werden soll. Gueltig fuer {{ preview_ttl }} Minuten. + Wählen Sie, was übernommen werden soll. Gültig für {{ preview_ttl }} Minuten.
@@ -84,7 +84,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
- + {% for entry in preview.summary %} @@ -96,7 +96,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }} @@ -104,17 +104,17 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
KategorieEintraege
KategorieEinträge
{{ entry.label }} - {% if entry.has_secrets %}{{ pill('mit Passwoertern', 'danger') }}{% endif %} + {% if entry.has_secrets %}{{ pill('mit Passwörtern', 'danger') }}{% endif %} {{ entry.count }}
- {{ alert('Bestehende Eintraege werden nach ihrem natuerlichen Schluessel abgeglichen - (Name, Hostname, MAC, Benutzername) und aktualisiert. Eintraege, die im Papierkorb + {{ alert('Bestehende Einträge werden nach ihrem natürlichen Schlüssel abgeglichen + (Name, Hostname, MAC, Benutzername) und aktualisiert. Einträge, die im Papierkorb liegen, werden nicht wiederbelebt.', 'info') }}
@@ -123,7 +123,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
{{ icon('upload', size=17) }} Sicherung einspielen
-
Erst oeffnen und pruefen, dann uebernehmen
+
Erst öffnen und prüfen, dann übernehmen
@@ -150,8 +150,8 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
  • Verzeichniskonten werden nie exportiert -- sie entstehen am Ziel bei der ersten Anmeldung neu.
  • -
  • Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehoeren zur Maschine, nicht zur Sicherung.
  • -
  • Sitzungen, Ratenbegrenzung und das Aenderungsprotokoll sind nicht Teil der Sicherung.
  • +
  • Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.
  • +
  • Sitzungen, Ratenbegrenzung und das Änderungsprotokoll sind nicht Teil der Sicherung.
  • Der Zwischenstand des Imports liegt in der Datenbank -- ein Dienst-Neustall verliert ihn nicht.
diff --git a/apps/tesm/src/tesm/templates/tesm/console.html b/apps/tesm/src/tesm/templates/tesm/console.html index 760274b..ca4429d 100644 --- a/apps/tesm/src/tesm/templates/tesm/console.html +++ b/apps/tesm/src/tesm/templates/tesm/console.html @@ -18,21 +18,21 @@ {% block content %} {% if not host_key_ready %} - {{ alert('Fuer dieses Ziel ist kein SSH-Host-Schluessel freigegeben. Die Konsole verweigert + {{ alert('Für dieses Ziel ist kein SSH-Host-Schlüssel freigegeben. Die Konsole verweigert den Aufbau, bis das geschehen ist -- die Freigabe erfolgt auf der Detailseite.', - 'danger', 'Host-Schluessel fehlt') }} -

Zur Detailseite und Schluessel freigeben

+ 'danger', 'Host-Schlüssel fehlt') }} +

Zur Detailseite und Schlüssel freigeben

{% endif %} {{ alert('Die Konsole ist zeilenorientiert: ein Kommando pro Zeile, Antwort darunter. Jede -Eingabe und jede Ausgabe wird auf dem Server mitgeschrieben und im Aenderungsprotokoll -vermerkt. Bei Untaetigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekunden.', +Eingabe und jede Ausgabe wird auf dem Server mitgeschrieben und im Änderungsprotokoll +vermerkt. Bei Untätigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekunden.', 'warning', 'Mitschrift') }} {% if not is_switch %} - {{ alert('Jedes Kommando laeuft in einer eigenen Sitzung. Das heisst: ein "cd" wirkt nicht - auf das naechste Kommando, und interaktive Programme (Editoren, Passwortabfragen) laufen - hier nicht. Fuer solche Arbeiten einen richtigen SSH-Client nehmen.', 'info', + {{ alert('Jedes Kommando läuft in einer eigenen Sitzung. Das heisst: ein "cd" wirkt nicht + auf das nächste Kommando, und interaktive Programme (Editoren, Passwortabfragen) laufen + hier nicht. Für solche Arbeiten einen richtigen SSH-Client nehmen.', 'info', 'Ein Kommando, eine Sitzung') }} {% endif %} diff --git a/apps/tesm/src/tesm/templates/tesm/credential_reveal.html b/apps/tesm/src/tesm/templates/tesm/credential_reveal.html index e9d8765..68c74ca 100644 --- a/apps/tesm/src/tesm/templates/tesm/credential_reveal.html +++ b/apps/tesm/src/tesm/templates/tesm/credential_reveal.html @@ -8,8 +8,8 @@ {% endblock %} {% block content %} -{{ alert('Dieser Aufruf steht mit Zeitpunkt, Benutzer und IP im Aenderungsprotokoll. -Schliessen Sie die Seite, sobald Sie den Wert uebernommen haben.', 'warning') }} +{{ alert('Dieser Aufruf steht mit Zeitpunkt, Benutzer und IP im Änderungsprotokoll. +Schliessen Sie die Seite, sobald Sie den Wert übernommen haben.', 'warning') }}
{{ icon('key', size=17) }} {{ entry.name }}
diff --git a/apps/tesm/src/tesm/templates/tesm/credentials.html b/apps/tesm/src/tesm/templates/tesm/credentials.html index 676b6ec..5d8e47a 100644 --- a/apps/tesm/src/tesm/templates/tesm/credentials.html +++ b/apps/tesm/src/tesm/templates/tesm/credentials.html @@ -5,9 +5,9 @@ {% macro credential_form(entry=None) %}
{{ field('name', 'Name', value=entry.name if entry else '', required=True, - hint_text='Frei waehlbar, z. B. "Switch-Admin" oder "Linux-Wartung".') }} + hint_text='Frei wählbar, z. B. "Switch-Admin" oder "Linux-Wartung".') }} {{ field('username', 'Benutzer', value=entry.username if entry else '', autocomplete='off') }} - {{ field('secret', 'Passwort' ~ (' (leer lassen = unveraendert)' if entry else ''), + {{ field('secret', 'Passwort' ~ (' (leer lassen = unverändert)' if entry else ''), type='password', autocomplete='new-password', required=False if entry else True) }}
- {{ checkbox('is_active', 'Geraet wird ueberwacht', checked=device.is_active if device else True) }} + {{ checkbox('is_active', 'Gerät wird überwacht', checked=device.is_active if device else True) }} {{ checkbox('auto_restart', 'Automatischer PoE-Neustart bei Ausfall', checked=device.auto_restart if device else True, - hint_text='Greift nur mit gueltiger Lizenz und zugeordnetem Switch-Port.') }} + hint_text='Greift nur mit gültiger Lizenz und zugeordnetem Switch-Port.') }}
{% endmacro %} {% block page_title %}Clients{% endblock %} {% block page_sub %} - {{ devices | length }} Geraete · {{ counts.online }} erreichbar, {{ counts.offline }} nicht erreichbar + {{ devices | length }} Geräte · {{ counts.online }} erreichbar, {{ counts.offline }} nicht erreichbar {% endblock %} {% block topbar_extra %} {{ search_box('devices-table', 'Name, IP, MAC, Switch ...', count_id='devices-visible') }} {% if current_user.has('devices.create') %} {% endif %} {% endblock %} @@ -155,8 +155,8 @@ MAC Zugangsdaten Switch / Port - Letzte Pruefung - Host-Schluessel + Letzte Prüfung + Host-Schlüssel Aktion @@ -167,13 +167,14 @@ {{ device.name }} {% if not device.is_active %}{{ pill('deaktiviert', '') }}{% endif %} {% if not device.auto_restart %} - ! + ! {% endif %} {% if device.location %}
{{ device.location }}
{% endif %} {% if not device.is_active %}{{ pill('aus', '') }} {% elif device.state == 'online' %}{{ pill('online', 'online') }} + {% elif device.state == 'restarting' %}{{ pill('startet neu', 'warning') }} {% elif device.state == 'offline' %}{{ pill('offline', 'offline') }} {% else %}{{ pill('unbekannt', 'warning') }}{% endif %} {% if device.fail_count %}({{ device.fail_count }}×){% endif %} @@ -200,8 +201,8 @@ {% if device.credential_category == 'windows' %} - - nicht noetig + + nicht nötig {% elif device.host_key_known %}{{ pill('freigegeben', 'online') }} {% else %} @@ -225,9 +226,9 @@
-
{% endif %} @@ -238,19 +239,19 @@
{% if not devices %} - {{ empty_state('Keine Geraete', 'Legen Sie das erste ueberwachte Geraet an.', 'cpu') }} + {{ empty_state('Keine Geräte', 'Legen Sie das erste überwachte Gerät an.', 'cpu') }} {% endif %}
-{{ alert('Ein PoE-Neustart setzt Switch und Port voraus, ein Neustart ueber SSH oder -RPC setzt passende Zugangsdaten voraus. Ist keine Methode moeglich, erscheint der Knopf -nicht -- damit kein Neustart vorgetaeuscht wird, der nichts bewirkt.', 'info') }} +{{ alert('Ein PoE-Neustart setzt Switch und Port voraus, ein Neustart über SSH oder +RPC setzt passende Zugangsdaten voraus. Ist keine Methode möglich, erscheint der Knopf +nicht -- damit kein Neustart vorgetäuscht wird, der nichts bewirkt.', 'info') }} {% endblock %} {% block dialogs %} {% if current_user.has('devices.create') %} - {% call modal('device-new', 'Geraet anlegen', 'MAC und IP sind Pflicht', 'wide') %} + {% call modal('device-new', 'Gerät anlegen', 'MAC und IP sind Pflicht', 'wide') %}
diff --git a/apps/tesm/src/tesm/templates/tesm/dhcp.html b/apps/tesm/src/tesm/templates/tesm/dhcp.html index 585fec2..f624765 100644 --- a/apps/tesm/src/tesm/templates/tesm/dhcp.html +++ b/apps/tesm/src/tesm/templates/tesm/dhcp.html @@ -9,7 +9,7 @@ {% block content %} {% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %} {% if not sysops_available %} - {{ alert('Systemhelfer nicht verfuegbar -- Konfiguration und Dienstzustand koennen hier nur + {{ alert('Systemhelfer nicht verfügbar -- Konfiguration und Dienstzustand können hier nur angezeigt, nicht geschrieben werden.', 'warning') }} {% endif %} @@ -27,13 +27,13 @@
Reservierungen {{ reservations | length }} - automatisch + manuell + automatisch + manüll
Leases {{ leases | rejectattr('is_expired') | list | length }} - gueltig{% if leases | selectattr('is_expired') | list %}, + gültig{% if leases | selectattr('is_expired') | list %}, {{ leases | selectattr('is_expired') | list | length }} abgelaufen{% endif %}
@@ -58,7 +58,7 @@
{% endfor %} @@ -164,8 +164,8 @@
{{ icon('puzzle', size=17) }} DHCP-Optionen
- {{ options | length }} Optionen · Standardoptionen sind nicht loeschbar, ihr Wert - laesst sich leeren + {{ options | length }} Optionen · Standardoptionen sind nicht löschbar, ihr Wert + lässt sich leeren
{{ search_box('options-table', 'Option suchen ...') }} @@ -202,8 +202,8 @@ data-no-guard> {% endif %} @@ -232,16 +232,16 @@ - {# Der Wert einer Option gilt sonst fuer alle. Genau dafuer gibt es - ``dhcp_option_values.device_id``: ein eigener Wert je Geraet, der in + {# Der Wert einer Option gilt sonst für alle. Genau dafür gibt es + ``dhcp_option_values.device_id``: ein eigener Wert je Gerät, der in dessen Reservierung geschrieben wird -- etwa eine Terminal-URL, die je - Geraet anders lautet. Auswertbar war das von Anfang an, eingeben liess + Gerät anders lautet. Auswertbar war das von Anfang an, eingeben liess es sich nicht. #}
-
{{ icon('sliders', size=17) }} Option nur fuer ein Geraet
+
{{ icon('sliders', size=17) }} Option nur für ein Gerät
- Der Wert landet in der Reservierung dieses Geraets und ueberschreibt dort den + Der Wert landet in der Reservierung dieses Geräts und überschreibt dort den globalen Wert. Leeres Feld entfernt ihn wieder.
@@ -251,7 +251,7 @@
{{ field('value', 'Wert', extra_class='field--full', - hint_text='Leer = Wert fuer dieses Geraet entfernen') }} + hint_text='Leer = Wert für dieses Gerät entfernen') }}
{% endif %} @@ -286,16 +286,16 @@
{{ icon('credit-card', size=17) }} Reservierungen
- Automatisch aus dem Geraetebestand; bei MAC-Kollision gewinnt der Geraetebestand. + Automatisch aus dem Gerätebestand; bei MAC-Kollision gewinnt der Gerätebestand. Nur Adressen aus eingerichteten Bereichen.
{% if skipped_reservations %} - {# Nicht verschweigen, sondern benennen: vorher standen diese Eintraege in + {# Nicht verschweigen, sondern benennen: vorher standen diese Einträge in der Liste, obwohl Kea sie nie ausgeliefert hat. #}
- {{ alert(skipped_reservations | length ~ ' Geraet(e) liegen in keinem eingerichteten + {{ alert(skipped_reservations | length ~ ' Gerät(e) liegen in keinem eingerichteten Bereich und bekommen keine Reservierung. Legen Sie den passenden Bereich an, wenn sie eine bekommen sollen.', 'warning') }}
@@ -339,7 +339,7 @@ {% endif %} - {% if reservation.source == 'manuell' and current_user.has('dhcp.edit') %} + {% if reservation.source == 'manüll' and current_user.has('dhcp.edit') %}
@@ -381,20 +381,20 @@
{% if leases and service.get('active') != 'active' %} - {# Sonst sieht man alte Eintraege und haelt sie fuer den aktuellen Stand: + {# Sonst sieht man alte Einträge und hält sie für den aktüllen Stand: ohne laufenden Dienst wird keine Lease erneuert, und die Datei veraltet still. #}
- {{ alert('Der DHCP-Dienst laeuft nicht (' ~ service.get('active', 'unbekannt') ~ '). + {{ alert('Der DHCP-Dienst läuft nicht (' ~ service.get('active', 'unbekannt') ~ '). Es wird keine Lease erneuert -- die Liste zeigt den letzten Stand der Datei, nicht die - aktuelle Lage. Der Grund steht im Kea-Protokoll.', 'warning') }} + aktülle Lage. Der Grund steht im Kea-Protokoll.', 'warning') }}
{% endif %} {% if leases %}
- + {% for lease in leases %} @@ -407,7 +407,7 @@ {% if current_user.has('fileshare.delete') %}{% endif %} - - + + @@ -69,9 +69,9 @@ {% if current_user.has('fileshare.delete') %}{% endif %} @@ -79,7 +79,7 @@ {% for entry in listing.directories %} {% if current_user.has('fileshare.delete') %} - + {% endif %} {% if current_user.has('fileshare.delete') %} - + {% endif %} - + {# Der Name ist die grösste Fläche der Zeile -- er soll auch + das Naheliegende tun. Was sich anzeigen lässt, öffnet die + Vorschau; alles andere lädt herunter. #} + - + @@ -112,7 +112,7 @@
{{ icon('server', size=17) }} Systemzugriff - {% if not sysops_available %}{{ pill('nicht verfuegbar', 'warning') }} + {% if not sysops_available %}{{ pill('nicht verfügbar', 'warning') }} {% elif sysops_selftest and sysops_selftest.ok %}{{ pill('Helfer antwortet', 'online') }} {% else %}{{ pill('Helfer antwortet nicht', 'danger') }}{% endif %}
@@ -121,7 +121,7 @@

- Der Webprozess laeuft unprivilegiert. Alles, was Root-Rechte braucht, geht durch genau + Der Webprozess läuft unprivilegiert. Alles, was Root-Rechte braucht, geht durch genau ein Helferskript mit Positivliste – nicht durch die Anwendung selbst.

{% if not sysops_available %} @@ -129,10 +129,10 @@ Alles andere funktioniert normal -- das ist der erwartete Zustand in einer Entwicklungsumgebung.', 'info') }} {% elif sysops_selftest and not sysops_selftest.ok %} - {# Geprueft wird ein echter Aufruf, nicht die blosse Existenz der Datei. - Genau daran lag es einmal: die Seite meldete "erreichbar", waehrend + {# Geprüft wird ein echter Aufruf, nicht die blosse Existenz der Datei. + Genau daran lag es einmal: die Seite meldete "erreichbar", während jeder Aufruf scheiterte. #} - {{ alert(sysops_selftest.message or 'Der Helfer laesst sich nicht aufrufen.', + {{ alert(sysops_selftest.message or 'Der Helfer lässt sich nicht aufrufen.', 'danger', 'Systemaktionen funktionieren nicht') }} {% endif %}
@@ -144,7 +144,7 @@ {% if current_user.is_admin %} - + {% endif %} @@ -167,14 +167,14 @@ {{ icon('key', size=17) }} Lizenzzustand {% if license_state.valid and license_state.activated %}{{ pill('aktiv', 'online') }} {% elif license_state.valid %}{{ pill('nicht aktiviert', 'warning') }} - {% else %}{{ pill('ungueltig', 'danger') }}{% endif %} + {% else %}{{ pill('ungültig', 'danger') }}{% endif %}
Datei vorhanden
{{ license_state.present | yes_no }}
-
Signatur gueltig
{{ license_state.valid | yes_no }}
+
Signatur gültig
{{ license_state.valid | yes_no }}
Aussteller gepinnt
{{ license_state.issuer_pinned | yes_no }}
Rolle
{{ license_state.role }}
Module
{{ license_state.modules | sort | join(', ') or '–' | safe }}
diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/error.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/error.html index ebca1bb..1d0b9f7 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/error.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/error.html @@ -7,11 +7,11 @@
{{ description }}
{% if request_id %} -

Vorgangsnummer fuer die Fehlersuche: {{ request_id }}

+

Vorgangsnummer für die Fehlersuche: {{ request_id }}

{% endif %} {% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html index aacb4d3..2f1e572 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html @@ -20,8 +20,8 @@ {{ icon('lock', size=18) }}
Systemgruppe
- Name und Rechte sind gesperrt. Anders als im Vorgaenger werden die Rechte - nicht bei jedem Neustart auf die Vorlage zurueckgesetzt – + Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte + nicht bei jedem Neustart auf die Vorlage zurückgesetzt – Anpassungen bleiben erhalten. {% if current_user.has('groups.edit') %} @@ -49,8 +49,8 @@ {% if group.preset_key and current_user.is_admin %} {% endif %} @@ -58,7 +58,7 @@
{{ field('name', 'Name', value=group.name, required=True, disabled=group.is_system, - hint_text='Systemgruppen koennen nicht umbenannt werden.' if group.is_system else '') }} + hint_text='Systemgruppen können nicht umbenannt werden.' if group.is_system else '') }} {{ textarea_field('description', 'Beschreibung', value=group.description, rows=2, extra_class='field--full') }}
diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/groups.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/groups.html index e4204d9..0945367 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/groups.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/groups.html @@ -2,7 +2,7 @@ {% from "tesm_core/macros.html" import field, textarea_field, pill, modal, alert, permission_matrix, empty_state %} {% block page_title %}Gruppen und Rechte{% endblock %} -{% block page_sub %}{{ groups | length }} Gruppen · Rechte addieren sich ueber alle Gruppen eines Benutzers{% endblock %} +{% block page_sub %}{{ groups | length }} Gruppen · Rechte addieren sich über alle Gruppen eines Benutzers{% endblock %} {% block topbar_extra %} {% if current_user.has('groups.create') %} @@ -13,9 +13,9 @@ {% endblock %} {% block content %} -{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt fuer -alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter ueber eine -neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }} +{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt für +alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter über eine +neue Gruppe selbst höherstuft.', 'info', 'Eskalationsschutz') }}
{% for group in groups %} @@ -37,9 +37,9 @@ neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
+ data-confirm="Gruppe {{ group.name }} löschen? Sie landet im Papierkorb; Mitglieder verlieren die zugehörigen Rechte." + data-confirm-title="Gruppe löschen" data-confirm-ok="Löschen" + aria-label="Löschen">{{ icon('trash', size=15) }} {% endif %}
@@ -67,7 +67,7 @@ neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }} {% block dialogs %} {% if current_user.has('groups.create') %} - {% call modal('group-new', 'Gruppe anlegen', 'Rechte koennen anschliessend feiner eingestellt werden', 'wide') %} + {% call modal('group-new', 'Gruppe anlegen', 'Rechte können anschliessend feiner eingestellt werden', 'wide') %}
@@ -77,13 +82,13 @@
IPMACHostnameArtZustandLaeuft ab
Läuft ab
{% if lease.is_expired %}{{ pill('abgelaufen', 'warning') }} - {% elif lease.is_active %}{{ pill('gueltig', 'online') }} + {% elif lease.is_active %}{{ pill('gültig', 'online') }} {% else %}{{ pill('inaktiv', '') }}{% endif %} {{ lease.expires_text }} @@ -429,7 +429,7 @@
{{ icon('file-text', size=17) }} Erzeugte Kea-Konfiguration
-
Vorschau – so wuerde die Datei geschrieben
+
Vorschau – so würde die Datei geschrieben
{{ icon('chevron-down', size=16) }}
diff --git a/apps/tesm/src/tesm/templates/tesm/fileshare.html b/apps/tesm/src/tesm/templates/tesm/fileshare.html index 532c402..faf9463 100644 --- a/apps/tesm/src/tesm/templates/tesm/fileshare.html +++ b/apps/tesm/src/tesm/templates/tesm/fileshare.html @@ -59,8 +59,8 @@
NameGroesseGeaendertGrösseGeändert Aktion
- - {{ icon('chevron-right', size=14) }} eine Ebene hoeher + {{ icon('chevron-right', size=14) }} eine Ebene höher
{{ icon('folder', size=15) }} @@ -93,16 +93,44 @@ {% for entry in listing.files %}
{{ icon('file-text', size=15) }} {{ entry.name }} + {% if entry.previewable %} + + {% else %} + + {{ icon('file-text', size=15) }} {{ entry.name }} + + {% endif %} + {{ entry.size | bytes }} {{ entry.modified | dt }} {% if entry.previewable %} - {{ icon('eye', size=15) }} + + {% endif %} + {% if current_user.has('fileshare.edit') %} + {% endif %} {% endif %} @@ -142,7 +170,7 @@
- +
@@ -165,4 +193,44 @@ {% endcall %} {% endif %} + +{% if listing %} + {# Vorschau im Rahmen statt in einem neuen Tab: der Zusammenhang bleibt + erhalten, und man ist mit Escape wieder in der Dateiliste. Titel, Quelle + und Download-Ziel werden beim Öffnen aus dem Auslöser gefüllt -- ein + Dialog für alle Dateien statt einer je Zeile. #} + {% call modal('file-preview', 'Vorschau', '', 'wide') %} + + +
+ {% endcall %} +{% endif %} + +{% if listing and current_user.has('fileshare.edit') %} + {% call modal('file-rename', 'Umbenennen', '', 'narrow') %} +
+ + + + + +
+ {% endcall %} +{% endif %} {% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/fileshare_mappings.html b/apps/tesm/src/tesm/templates/tesm/fileshare_mappings.html index 28ce10a..2515da9 100644 --- a/apps/tesm/src/tesm/templates/tesm/fileshare_mappings.html +++ b/apps/tesm/src/tesm/templates/tesm/fileshare_mappings.html @@ -1,13 +1,13 @@ {% extends "tesm_core/base.html" %} {% from "tesm_core/_group_picker.html" import group_picker %} -{% from "tesm_core/macros.html" import field, empty_state, alert %} +{% from "tesm_core/macros.html" import field, empty_state, alert, modal %} {% block page_title %}Freigaben einrichten{% endblock %} {% block page_sub %}{{ mappings | length }} Zuordnungen · Wurzel {{ mount_root }}{% endblock %} {% block content %} {{ alert('Eine Freigabe wird bei der Anmeldung eingebunden, wenn der Benutzer Mitglied der zugeordneten Verzeichnisgruppe ist. Eingebunden wird mit dem Konto dieses Benutzers -- deshalb -ist die Verzeichnisgruppe das einzige moegliche Ziel, und lokale Konten bekommen keine +ist die Verzeichnisgruppe das einzige mögliche Ziel, und lokale Konten bekommen keine Einbindungen.', 'info') }} {% if not directory_enabled %} @@ -33,6 +33,11 @@ Einbindungen.', 'info') }}
{{ entry.ad_group_name }}
{{ entry.ad_group_dn }}
+ {% if current_user.has('fileshare.edit') %} + + {% endif %}
+ + + + {% endcall %} + {% endfor %} +{% endif %} +{% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/hostkeys.html b/apps/tesm/src/tesm/templates/tesm/hostkeys.html index 4033373..174515d 100644 --- a/apps/tesm/src/tesm/templates/tesm/hostkeys.html +++ b/apps/tesm/src/tesm/templates/tesm/hostkeys.html @@ -1,40 +1,44 @@ {% extends "tesm_core/base.html" %} {% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %} -{% block page_title %}Host-Schluessel{% endblock %} +{% block page_title %}Host-Schlüssel{% endblock %} {% block page_sub %} {{ targets | length }} Ziele mit SSH-Zugang {%- if open_count %} · {{ open_count }} ohne Freigabe{% endif %} {% endblock %} -{% block topbar_extra %}{{ search_box('hostkey-table', 'Geraet, IP, Fingerprint ...') }}{% endblock %} +{% block topbar_extra %}{{ search_box('hostkey-table', 'Gerät, IP, Fingerprint ...') }}{% endblock %} {% block content %} {% if open_count %} - {{ alert('Fuer ' ~ open_count ~ ' Ziel(e) ist noch kein Schluessel freigegeben. Wartung, - Konsole und PoE-Neustart brechen dort mit "Schluessel unbekannt" ab -- absichtlich: ein - Hintergrundauftrag darf keinen fremden Schluessel annehmen.', 'warning', - 'Freigaben offen') }} + {# Der PoE-Neustart hängt am Schlüssel des *Switches*, nicht am Schlüssel des + Clients: geschaltet wird der Port auf dem Switch. Der alte Satz warf beides + zusammen und liess vermuten, ein Client ohne freigegebenen Schlüssel sei + nicht per PoE neu startbar. #} + {{ alert('Für ' ~ open_count ~ ' Ziel(e) ist noch kein Schlüssel freigegeben. Wartung und + Konsole brechen dort mit "Schlüssel unbekannt" ab -- absichtlich: ein Hintergrundauftrag + darf keinen fremden Schlüssel annehmen. Ein PoE-Neustart braucht dagegen den Schlüssel + des Switches, nicht den des Clients.', 'warning', 'Freigaben offen') }} {% else %} - {{ alert('Fuer jedes Ziel mit SSH-Zugang liegt ein freigegebener Schluessel vor.', - 'success', 'Vollstaendig') }} + {{ alert('Für jedes Ziel mit SSH-Zugang liegt ein freigegebener Schlüssel vor.', + 'success', 'Vollständig') }} {% endif %} -{{ alert('Ein Schluessel wird in zwei Schritten uebernommen: erst auslesen, dann -- nach -Vergleich mit einer unabhaengigen Quelle -- freigeben. Gespeichert wird nach IP und Port; -aendert sich die Adresse eines Geraets, ist erneut freizugeben. Ein *geaenderter* Schluessel -wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff auftritt.', +{{ alert('Ein Schlüssel wird in zwei Schritten übernommen: erst auslesen, dann -- nach +Vergleich mit einer unabhängigen Quelle -- freigeben. Gespeichert wird nach IP und Port; +ändert sich die Adresse eines Geräts, ist erneut freizugeben. Ein *geänderter* Schlüssel +wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff auftritt.', 'info', 'So funktioniert die Freigabe') }} {% if probed_fingerprint %}
{{ icon('alert', size=18) }}
-
Gelesener Schluessel von {{ probed_host }}:{{ probed_port }}
+
Gelesener Schlüssel von {{ probed_host }}:{{ probed_port }}
{{ probed_type }} {{ probed_fingerprint }}

- Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle -- Konsolenzugang am - Geraet, Inventardokumentation --, bevor Sie ihn unten freigeben. + Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle -- Konsolenzugang am + Gerät, Inventardokumentation --, bevor Sie ihn unten freigeben.

@@ -102,8 +106,8 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au @@ -114,7 +118,7 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au action="{{ url_for('hostkeys.forget', kind=row.kind, target_id=row.id) }}"> @@ -141,7 +145,7 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au
{{ icon('alert', size=17) }} Verwaiste Freigaben
- Zu diesen Eintraegen gibt es kein Ziel mehr -- geloescht oder mit neuer IP. + Zu diesen Einträgen gibt es kein Ziel mehr -- gelöscht oder mit neuer IP.
@@ -162,7 +166,7 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au diff --git a/apps/tesm/src/tesm/templates/tesm/license.html b/apps/tesm/src/tesm/templates/tesm/license.html index a85b66e..49622d5 100644 --- a/apps/tesm/src/tesm/templates/tesm/license.html +++ b/apps/tesm/src/tesm/templates/tesm/license.html @@ -4,8 +4,8 @@ {% block page_title %}Lizenz{% endblock %} {% block page_sub %} {% if state and state.valid and state.activated %}aktiv - {% elif state and state.valid %}gueltig, noch nicht aktiviert - {% elif state and state.present %}ungueltig + {% elif state and state.valid %}gültig, noch nicht aktiviert + {% elif state and state.present %}ungültig {% else %}keine Lizenz installiert{% endif %} {% endblock %} @@ -37,7 +37,7 @@
Lizenz-ID
{{ state.status.license_id }}
Revision
{{ state.status.seq }}
Ausgestellt
{{ state.status.issued_at | dt }}
-
Laeuft ab
+
Läuft ab
{% if state.status.lifetime %}{{ pill('unbefristet', 'online') }} {% else %} @@ -46,7 +46,7 @@ {{ pill('abgelaufen seit ' ~ state.status.days_since_expiry ~ ' Tagen', 'danger') }} {% if state.status.grace_active %}
- Kulanzfrist laeuft ({{ grace_days }} Tage) – Funktionen bleiben vorerst nutzbar. + Kulanzfrist läuft ({{ grace_days }} Tage) – Funktionen bleiben vorerst nutzbar.
{% endif %} {% elif state.status.expiring_soon %} @@ -88,7 +88,7 @@ {% else %}
- +
@@ -118,14 +118,14 @@ {% if pending.reason %} {{ alert(pending.reason, 'warning', 'Warum der Online-Weg nicht ging') }} {% endif %} - {{ alert('Beide Codes sind signiert -- sie lassen sich unterwegs nicht veraendern. - Der Anfragecode gehoert zu genau dieser Maschine und genau dieser Anfrage; eine + {{ alert('Beide Codes sind signiert -- sie lassen sich unterwegs nicht verändern. + Der Anfragecode gehört zu genau dieser Maschine und genau dieser Anfrage; eine Antwort auf einen anderen Code wird abgelehnt.', 'info') }} {% if pending.code %}
- Anfragecode -- an den Anbieter uebermitteln + Anfragecode -- an den Anbieter übermitteln
{{ pending.code }}
@@ -135,8 +135,8 @@
{% else %} - {{ alert('Zu dieser Anfrage liegt kein Code vor -- sie stammt aus einer aelteren - Fassung. Loesen Sie die Aktion erneut aus, dann entsteht ein neuer Code.', + {{ alert('Zu dieser Anfrage liegt kein Code vor -- sie stammt aus einer älteren + Fassung. Lösen Sie die Aktion erneut aus, dann entsteht ein neuer Code.', 'warning') }} {% endif %} @@ -145,10 +145,10 @@
- +
@@ -161,15 +161,15 @@
{{ icon('shield', size=17) }} Zertifikat des Lizenzservers
- Nur noetig, wenn der Lizenzserver ein eigenes Zertifikat benutzt + Nur nötig, wenn der Lizenzserver ein eigenes Zertifikat benutzt
{{ alert('Ein Lizenzserver im eigenen Netz hat meist ein selbstsigniertes - Zertifikat. Ohne Anker scheitert die Pruefung, und jeder Versuch faellt auf den - Offline-Weg zurueck. Die Pruefung abzuschalten waere der falsche Weg -- hinterlegen - Sie stattdessen das Zertifikat, dann wird gegen genau dieses geprueft.', 'info') }} + Zertifikat. Ohne Anker scheitert die Prüfung, und jeder Versuch fällt auf den + Offline-Weg zurück. Die Prüfung abzuschalten wäre der falsche Weg -- hinterlegen + Sie stattdessen das Zertifikat, dann wird gegen genau dieses geprüft.', 'info') }}
@@ -196,7 +196,7 @@
{{ icon('upload', size=17) }} Lizenzdatei einspielen
- Die Datei enthaelt das Lizenzdokument und den zugehoerigen Client-Schluessel + Die Datei enthält das Lizenzdokument und den zugehörigen Client-Schlüssel
@@ -206,7 +206,7 @@
{% if trusted_keys and current_user.is_admin %} - {{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdruecklich zulassen', + {{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdrücklich zulassen', hint_text='Nur ankreuzen, wenn die neue Lizenz bewusst von einem anderen Lizenzserver kommt.') }} {% endif %} @@ -233,8 +233,8 @@
Fingerprint
{{ fingerprint }}

- Der Fingerprint stammt aus der stabilen Maschinenidentitaet. Eine Umbenennung des Hosts - aendert ihn nicht mehr -- im Vorgaenger fuehrte genau das zu + Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts + ändert ihn nicht mehr -- im Vorgänger führte genau das zu „bereits anderweitig gebunden“.

@@ -243,14 +243,14 @@
-
{{ icon('shield', size=17) }} Vertrauenswuerdige Aussteller
-
{{ trusted_keys | length }} gepinnte Schluessel
+
{{ icon('shield', size=17) }} Vertrauenswürdige Aussteller
+
{{ trusted_keys | length }} gepinnte Schlüssel
{% if trusted_keys %}
- + {% for key in trusted_keys %} @@ -268,7 +268,7 @@ action="{{ url_for('license.remove_trusted_key', key_id=key.key_id) }}"> @@ -281,7 +281,7 @@ {% else %} {{ empty_state('Kein Aussteller gepinnt', - 'Beim ersten Import wird der Schluessel des Lizenzservers uebernommen und danach + 'Beim ersten Import wird der Schlüssel des Lizenzservers übernommen und danach erwartet.', 'shield') }} {% endif %} @@ -307,7 +307,7 @@

Diese Liste ist die einzige Wahrheit: was hier steht, wird serverseitig durchgesetzt -- - nicht nur in der Oberflaeche ausgegraut. + nicht nur in der Oberfläche ausgegraut.

diff --git a/apps/tesm/src/tesm/templates/tesm/logs_restarts.html b/apps/tesm/src/tesm/templates/tesm/logs_restarts.html index c38330d..6d6f616 100644 --- a/apps/tesm/src/tesm/templates/tesm/logs_restarts.html +++ b/apps/tesm/src/tesm/templates/tesm/logs_restarts.html @@ -2,11 +2,11 @@ {% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %} {% block page_title %}Neustarts{% endblock %} {% block page_sub %}{{ rows | length }} Neustarts (neueste zuerst){% endblock %} -{% block topbar_extra %}{{ search_box('restart-table', 'Geraet, Ergebnis, Methode ...') }}{% endblock %} +{% block topbar_extra %}{{ search_box('restart-table', 'Gerät, Ergebnis, Methode ...') }}{% endblock %} {% block content %} -{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Ausloeser festgehalten -- automatisch -wie manuell, ueber den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung. -Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }} +{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch +wie manüll, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung. +Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
@@ -25,8 +25,8 @@ Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }}
Schluessel-IDBezeichnungQuelle
Schlüssel-IDBezeichnungQuelle
- - + + @@ -41,17 +41,17 @@ Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }} jahrelang als PoE ausgegeben. #} {% if row.method == 'ssh' %}{{ pill('SSH', 'info') }} {% elif row.method == 'rpc' %}{{ pill('RPC', 'accent') }} - {% elif row.method == 'poe' %}{{ pill('PoE-Port', '') }} + {% elif row.method == 'pö' %}{{ pill('PoE-Port', '') }} {% else %}{{ pill(row.method or 'unbekannt', 'warning') }}{% endif %} - + {% endfor %} diff --git a/apps/tesm/src/tesm/templates/tesm/maintenance.html b/apps/tesm/src/tesm/templates/tesm/maintenance.html index 9feac70..56f8c1e 100644 --- a/apps/tesm/src/tesm/templates/tesm/maintenance.html +++ b/apps/tesm/src/tesm/templates/tesm/maintenance.html @@ -3,29 +3,29 @@ {% block page_title %}Wartung{% endblock %} {% block page_sub %} - {{ devices | length }} Geraete mit Zugangsdaten der Kategorie + {{ devices | length }} Geräte mit Zugangsdaten der Kategorie „Linux-Client“ oder „Windows-Client“ {% endblock %} -{% block topbar_extra %}{{ search_box('maintenance-table', 'Geraet suchen ...') }}{% endblock %} +{% block topbar_extra %}{{ search_box('maintenance-table', 'Gerät suchen ...') }}{% endblock %} {% block content %} {% if devices | rejectattr('host_key_ready') | list %} - {{ alert('Fuer mindestens ein Geraet ist der SSH-Host-Schluessel noch nicht freigegeben. - Die Wartung bricht dort mit "Schluessel unbekannt" ab. Die Freigabe erfolgt auf der - Detailseite des Geraets -- erst auslesen, Fingerprint pruefen, dann freigeben.', - 'warning', 'Host-Schluessel offen') }} + {{ alert('Für mindestens ein Gerät ist der SSH-Host-Schlüssel noch nicht freigegeben. + Die Wartung bricht dort mit "Schlüssel unbekannt" ab. Die Freigabe erfolgt auf der + Detailseite des Geräts -- erst auslesen, Fingerprint prüfen, dann freigeben.', + 'warning', 'Host-Schlüssel offen') }} {% endif %} {% if windows_tool_missing %} - {# Die Meldung im Auftrag nennt das fehlende Paket -- ohne diesen Knopf waere sie + {# Die Meldung im Auftrag nennt das fehlende Paket -- ohne diesen Knopf wäre sie eine Sackgasse. #}
{{ icon('alert', size=18) }}
- Windows-Neustart nicht moeglich + Windows-Neustart nicht möglich Auf diesem Server fehlt das Paket {{ windows_package }}. Es bringt - net mit, ueber das der Neustart eines Windows-Geraetes laeuft. + net mit, über das der Neustart eines Windows-Gerätes läuft.
@@ -38,10 +38,10 @@
{% endif %} -{{ alert('Welche Geraete hier erscheinen und was mit ihnen moeglich ist, entscheidet die -Kategorie ihrer Zugangsdaten. Linux-Clients werden ueber SSH aktualisiert und neu gestartet. -Windows-Clients werden nur neu gestartet, und zwar ueber RPC (wie "shutdown /m") -- sie -brauchen dafuer weder SSH noch einen Host-Schluessel, sondern die offene Datei- und +{{ alert('Welche Geräte hier erscheinen und was mit ihnen möglich ist, entscheidet die +Kategorie ihrer Zugangsdaten. Linux-Clients werden über SSH aktualisiert und neu gestartet. +Windows-Clients werden nur neu gestartet, und zwar über RPC (wie "shutdown /m") -- sie +brauchen dafür weder SSH noch einen Host-Schlüssel, sondern die offene Datei- und Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Port.', 'info') }} @@ -51,20 +51,20 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% if current_user.has('maintenance.execute') %} @@ -76,11 +76,11 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
- + - + @@ -92,7 +92,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por - {{ device.name }} auswaehlen + {{ device.name }} auswählen @@ -108,13 +108,13 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
ZeitGeraetMethodeAusloeserZeitGerätMethodeAuslöser BenutzerErgebnis DauerDetail
{{ row.trigger }} {{ row.actor }} {% if row.result == 'ok' %}{{ pill('erfolgreich', 'online') }} - {% elif row.result == 'skipped' %}{{ pill('uebersprungen', 'warning') }} + {% elif row.result == 'skipped' %}{{ pill('übersprungen', 'warning') }} {% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %} {{ row.duration_ms or 0 }} ms{{ row.duration_ms | millis }} {{ row.detail }}
GeraetGerät IP Zustand ZugangsdatenHost-SchluesselHost-Schlüssel
{{ device.name }} {% if not device.ssh_managed %} - - nicht noetig + + nicht nötig {% elif device.host_key_ready %}{{ pill('freigegeben', 'online') }} {% else %} + title="Ohne freigegebenen Schlüssel bricht die Wartung ab"> {{ pill('offen', 'danger') }} {% endif %} @@ -134,9 +134,9 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% if not devices %} - {{ empty_state('Keine passenden Geraete', + {{ empty_state('Keine passenden Geräte', 'Setzen Sie bei den Zugangsdaten die Kategorie "Linux-Client" und ordnen Sie - sie einem Geraet zu.', 'wrench') }} + sie einem Gerät zu.', 'wrench') }} {% endif %}
@@ -144,12 +144,12 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por {# Kein data-refresh-url: die Schleife in app.js schreibt die Antwort in innerHTML. Hier zeigte sie auf die Seite selbst -- die komplette Wartungsseite landete damit in dieser Karte, mitsamt Seitenleiste und - Suchfeld. Aktualisiert wird ueber die Abfrage unten, die bei laufenden - Auftraegen die ganze Seite neu laedt. #} + Suchfeld. Aktualisiert wird über die Abfrage unten, die bei laufenden + Aufträgen die ganze Seite neu lädt. #}
-
{{ icon('clock', size=17) }} Auftraege
+
{{ icon('clock', size=17) }} Aufträge
Zustand liegt in der Datenbank -- ein Dienst-Neustart verliert ihn nicht mehr
@@ -158,7 +158,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por {% if jobs %}
- + {% for job in jobs %} @@ -170,7 +170,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por @@ -186,16 +186,16 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
GestartetGeraetArtZustandFortschrittMeldung
GestartetGerätArtZustandFortschrittMeldung
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }} {% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }} - {% elif job.state == 'running' %}{{ pill('laeuft', 'info') }} + {% elif job.state == 'running' %}{{ pill('läuft', 'info') }} {% elif job.state == 'unknown' %}{{ pill('unklar', 'warning') }} {% else %}{{ pill('wartet', '') }}{% endif %}
{% else %} - {{ empty_state('Keine Auftraege', 'Es wurde noch keine Wartung gestartet.', 'clock') }} + {{ empty_state('Keine Aufträge', 'Es wurde noch keine Wartung gestartet.', 'clock') }} {% endif %}
{% endblock %} {% block scripts %} -{# -- Bestaetigungsdialog (ersetzt window.confirm) ------------------------- #} +{# -- Bestätigungsdialog (ersetzt window.confirm) ------------------------- #}
Komponente Version MigrationPruefsummePrüfsumme Angewendet
{{ row.target }} {{ row.detail }} - {% if row.severity == 'critical' %}{{ pill('kritisch', 'danger') }} - {% elif row.severity == 'warning' %}{{ pill('Warnung', 'warning') }} - {% elif row.severity == 'notice' %}{{ pill('Hinweis', 'info') }} - {% else %}{{ pill('Info', '') }}{% endif %} + {# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info + grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos + ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine + eigene, gefüllte Darstellung, sonst wäre es von "Warnung" + nicht zu unterscheiden. #} + {% if row.severity == 'critical' %}{{ pill('kritisch', 'critical') }} + {% elif row.severity == 'warning' %}{{ pill('Warnung', 'danger') }} + {% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }} + {% else %}{{ pill('Info', 'online') }}{% endif %} {{ row.ip or '–' | safe }}
- {% if not rows %}{{ empty_state('Keine Eintraege', 'Zu diesem Filter gibt es nichts.', 'history') }}{% endif %} + {% if not rows %}{{ empty_state('Keine Einträge', 'Zu diesem Filter gibt es nichts.', 'history') }}{% endif %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_web.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_web.html index 837b1ac..2199d32 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_web.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_web.html @@ -10,14 +10,14 @@ {% block content %} {% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %} {% if not sysops_available %} - {{ alert('Systemhelfer nicht verfuegbar -- die Konfiguration kann hier erzeugt und angesehen, + {{ alert('Systemhelfer nicht verfügbar -- die Konfiguration kann hier erzeugt und angesehen, aber nicht angewandt werden.', 'info') }} {% endif %} -{{ alert('Die Site-Datei wird bei jeder Uebernahme vollstaendig neu geschrieben. -Alles, was ein Administrator sonst per Hand hineinschreiben wuerde, ist hier Einstellung. Der -Vorgaenger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad -blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info', +{{ alert('Die Site-Datei wird bei jeder Übernahme vollständig neu geschrieben. +Alles, was ein Administrator sonst per Hand hineinschreiben würde, ist hier Einstellung. Der +Vorgänger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad +blieb dadurch über Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info', 'Warum die Datei immer neu geschrieben wird') }}
@@ -32,12 +32,12 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
{{ field('web_domain', 'Domain', value=config.domain, placeholder='tesm.firma.de', disabled=bool(license_hint), - hint_text='Leer = Zugriff ueber IP-Adresse.') }} - {{ field('web_acme_email', 'E-Mail fuer Let\'s Encrypt', value=acme_email, type='email', + hint_text='Leer = Zugriff über IP-Adresse.') }} + {{ field('web_acme_email', 'E-Mail für Let\'s Encrypt', value=acme_email, type='email', disabled=bool(license_hint)) }} {{ field('web_cert_path', 'Zertifikat (Pfad)', value=config.cert_path, disabled=bool(license_hint), extra_class='field--full') }} - {{ field('web_key_path', 'Privater Schluessel (Pfad)', value=config.key_path, + {{ field('web_key_path', 'Privater Schlüssel (Pfad)', value=config.key_path, disabled=bool(license_hint), extra_class='field--full') }} {{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot, disabled=bool(license_hint), extra_class='field--full') }} @@ -47,7 +47,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404. checked=config.https_enabled, disabled=bool(license_hint)) }} {{ checkbox('web_redirect_http', 'HTTP (Port 80) auf HTTPS umleiten', checked=config.redirect_http, disabled=bool(license_hint), - hint_text='Der ACME-Pfad bleibt dabei erreichbar -- sonst koennte Let\'s + hint_text='Der ACME-Pfad bleibt dabei erreichbar -- sonst könnte Let\'s Encrypt nie erneuern.') }} {{ checkbox('web_hsts_enabled', 'HSTS-Header senden', checked=config.hsts_enabled, disabled=bool(license_hint), @@ -67,7 +67,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
{{ icon('file-text', size=17) }} Erzeugte nginx-Konfiguration
-
Vorschau der Datei, die geschrieben wuerde
+
Vorschau der Datei, die geschrieben würde
{{ icon('chevron-down', size=16) }}
@@ -89,8 +89,8 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404. {% if certificate.present and not certificate.get('error') %}
Inhaber
{{ certificate.subject }}
-
Aussteller
{{ certificate.issuer }}
-
Gueltig bis
{{ certificate.not_valid_after | dt }}
+
Aussteller
{{ certificate.issür }}
+
Gültig bis
{{ certificate.not_valid_after | dt }}
Seriennummer
{{ certificate.serial }}
{% elif certificate.get('error') %} @@ -107,7 +107,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/standalone.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/standalone.html index 5bd2f55..04ef814 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/standalone.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/standalone.html @@ -1,11 +1,11 @@ {# ========================================================================== - Grundgeruest fuer Seiten ohne Anwendungsrahmen: Anmeldung, zweiter Faktor, + Grundgerüst für Seiten ohne Anwendungsrahmen: Anmeldung, zweiter Faktor, Kundenportal, Self-Service. Wichtiger Unterschied zu base.html: Flash-Meldungen werden hier **sichtbar - serverseitig** gerendert, nicht ueber die JSON-Bruecke. Der Vorgaenger hatte + serverseitig** gerendert, nicht über die JSON-Brücke. Der Vorgänger hatte dieselbe Trennung; sie ist wichtig, weil diese Seiten auch ohne JavaScript - nutzbar bleiben muessen (und weil Tests hier direkt auf Text pruefen). + nutzbar bleiben müssen (und weil Tests hier direkt auf Text prüfen). ========================================================================== #} @@ -23,11 +23,11 @@
{% if auth_tagline %} - {# Wortmarke mit Funktionszeile als tatsaechlicher Seitenhintergrund -- - uebernommen vom Vorgaenger. Bewusst inline und mit Themen-Tokens statt - zweier Dateien fuer hell und dunkel: bei der Einstellung "System" und - beim Umschalten von Hand waere die versteckte Variante die falsche. - ``aria-hidden``, weil der Name schon in der Ueberschrift steht. #} + {# Wortmarke mit Funktionszeile als tatsächlicher Seitenhintergrund -- + übernommen vom Vorgänger. Bewusst inline und mit Themen-Tokens statt + zweier Dateien für hell und dunkel: bei der Einstellung "System" und + beim Umschalten von Hand wäre die versteckte Variante die falsche. + ``aria-hidden``, weil der Name schon in der Überschrift steht. #}
diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/trash.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/trash.html index ecec0f3..67d0c22 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/trash.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/trash.html @@ -2,11 +2,11 @@ {% from "tesm_core/macros.html" import empty_state, alert, pill %} {% block page_title %}Papierkorb{% endblock %} -{% block page_sub %}{{ total }} Eintraege · Aufbewahrung {{ retention_days }} Tage{% endblock %} +{% block page_sub %}{{ total }} Einträge · Aufbewahrung {{ retention_days }} Tage{% endblock %} {% block content %} -{{ alert('Ansehen ist immer moeglich. Wiederherstellen und endgueltiges Loeschen erfordern eine -gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Seite entfernt.', +{{ alert('Ansehen ist immer möglich. Wiederherstellen und endgültiges Löschen erfordern eine +gültige, aktivierte Lizenz. Abgelaufene Einträge werden beim Aufruf dieser Seite entfernt.', 'info') }} {% for section in sections %} @@ -23,8 +23,8 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei @@ -33,7 +33,7 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei {% if section.entries %}
- + {% for entry in section.entries %} @@ -53,9 +53,9 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei action="{{ url_for('trash.purge', kind=section.key, ident=entry.id) }}"> {% endif %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/users.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/users.html index cc6bc6e..422ddb0 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/users.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/users.html @@ -20,7 +20,7 @@ {% if directory_enabled and current_user.has('users.create') %} {% endif %} {% if current_user.is_admin %} @@ -90,9 +90,9 @@ style="display:inline" data-no-guard> + data-confirm="Konto {{ user.username }} wirklich löschen?{% if user.auth_source == 'ldap' %} Verzeichniskonten werden sofort endgültig entfernt.{% else %} Es landet im Papierkorb.{% endif %}" + data-confirm-title="Konto löschen" data-confirm-ok="Löschen" + aria-label="Löschen">{{ icon('trash', size=15) }} {% endif %} @@ -106,8 +106,8 @@ {% endif %} -{{ alert('Rechte werden ausschliesslich ueber Gruppen vergeben. Das Admin-Kennzeichen ist davon -unabhaengig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes +{{ alert('Rechte werden ausschliesslich über Gruppen vergeben. Das Admin-Kennzeichen ist davon +unabhängig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes Administratorkonto muss immer bestehen bleiben.', 'info') }} {% endblock %} @@ -137,7 +137,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }} {% if current_user.is_admin %} - {{ checkbox('is_admin', 'Administrator (darf alles, unabhaengig von Gruppen)') }} + {{ checkbox('is_admin', 'Administrator (darf alles, unabhängig von Gruppen)') }} {% endif %} {{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }} @@ -149,7 +149,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }} {% endcall %} {% if directory_enabled %} - {% call modal('user-directory', 'Aus Verzeichnis uebernehmen', + {% call modal('user-directory', 'Aus Verzeichnis übernehmen', 'Konto vorbereiten, damit Gruppen vor der ersten Anmeldung sitzen') %} @@ -169,7 +169,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }} {{ alert('Name und E-Mail werden bei der ersten echten Anmeldung aus dem Verzeichnis - uebernommen und ueberschrieben.', 'info') }} + übernommen und überschrieben.', 'info') }}
EintragDetailGeloeschtAktion
EintragDetailGelöschtAktion