Umlaute in der Oberflaeche, Sitzungskopplung, Statusanzeigen

Echte Umlaute in allen Texten der Oberflaeche und in den Markdown-Dateien.
Die Einbindung von Dateifreigaben haengt jetzt an der Sitzung: wird sie
widerrufen oder laeuft sie ab, verschwinden die Freigaben mit ihr. Neu
einstellbar ist die automatische Abmeldung bei Leerlauf (Vorgabe 30 Minuten).
Ein Geraet, das nicht antwortet, bietet nur noch "Einschalten (PoE)" an --
Neustart ueber SSH oder RPC braucht ein erreichbares Geraet. Der Zeitgeber der
naechsten Pruefung und die Restlaufzeit der Lizenz stehen jetzt oben in der
Kopfzeile; der doppelte Knopf darunter ist weg. Millisekunden werden ab einer
Sekunde als Sekunden und ab einer Minute als m:ss dargestellt.

Ausserdem: Release-Tarball nicht mehr im Repository -- er haengt am Release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 23:54:33 +02:00
co-authored by Claude Opus 5
parent 9a760274f8
commit fbbf8eb87b
68 changed files with 1235 additions and 691 deletions
+1 -1
View File
@@ -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 = [
+37 -3
View File
@@ -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
+6
View File
@@ -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,
@@ -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/<int:mapping_id>")
@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/<int:mapping_id>/loeschen")
@permission_required("fileshare.edit")
def mapping_delete(mapping_id: int):
+4 -1
View File
@@ -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,
@@ -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")),
)
+1 -1
View File
@@ -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"),
+85 -5
View File
@@ -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
+61 -4
View File
@@ -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,
+20 -1
View File
@@ -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:
@@ -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. #}
<div class="stack">
<div class="grid grid--4" data-behavior="tile-filter" data-filter-scope="tile-sections">
<button type="button" class="stat" data-filter-value="" aria-pressed="false">
@@ -25,7 +25,7 @@
</button>
{% else %}
<div class="stat stat--warning">
<span class="stat__label">Noch nicht geprueft</span>
<span class="stat__label">Noch nicht geprüft</span>
<span class="stat__value">{{ counts.unknown }}</span>
</div>
{% endif %}
@@ -49,9 +49,9 @@
<div class="grid grid--tiles">
{% 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. #}
<article class="tile tile--{{ tile.state }}" data-filter-item
data-filter-text="{{ tile.name }} {{ tile.get('ip','') }} {{ tile.get('switch','') }} {{ tile.location }}">
<div class="tile__head">
@@ -73,7 +73,7 @@
<dt>Switch</dt><dd class="truncate">{{ tile.switch }} / {{ tile.port }}</dd>
{% endif %}
{% if tile.latency_ms %}
<dt>Antwortzeit</dt><dd>{{ '%.1f' | format(tile.latency_ms) }} ms</dd>
<dt>Antwortzeit</dt><dd>{{ tile.latency_ms | millis }}</dd>
{% endif %}
{% if tile.last_change_at %}
<dt>Seit</dt>
@@ -92,7 +92,7 @@
<input type="hidden" name="method" value="{{ method.key }}">
<button class="btn btn--sm" type="submit" title="{{ method.hint }}"
data-busy-label="Startet ..."
data-confirm="{{ tile.name }} ueber {{ method.label }} neu starten? {{ method.hint }}"
data-confirm="{{ tile.name }} über {{ method.label }} neu starten? {{ method.hint }}"
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
data-confirm-style="primary">
{{ icon('power', size=14) }} {{ method.label }}
@@ -118,8 +118,8 @@
{% endfor %}
{% if not sections | map(attribute='tiles') | select | list %}
{{ empty_state('Keine Geraete erfasst',
'Legen Sie das erste Geraet unter Ueberwachung an.', 'cpu') }}
{{ empty_state('Keine Geräte erfasst',
'Legen Sie das erste Gerät unter Überwachung an.', 'cpu') }}
{% endif %}
</div>
</div>
+23 -23
View File
@@ -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 &middot; zweistufiger Import{% endblock %}
{% block page_title %}Sicherung und Übertragung{% endblock %}
{% block page_sub %}Verschlüsselter Export &middot; 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') }}
<div class="grid grid--split">
@@ -16,7 +16,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('download', size=17) }} Sicherung erstellen</div>
<div class="card__sub">Auswahl je Kategorie &middot; verlangt eine frische Passwortbestaetigung</div>
<div class="card__sub">Auswahl je Kategorie &middot; verlangt eine frische Passwortbestätigung</div>
</div>
</div>
<form method="post" action="{{ url_for('backup.export') }}" data-no-guard>
@@ -37,7 +37,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
{% if category.admin_only %}{{ pill('nur Admin', 'accent') }}{% endif %}
<div class="fs-xs faint">{{ category.hint }}</div>
{% if not allowed %}
<div class="fs-xs danger-text">Fuer diese Kategorie fehlt Ihnen das Recht.</div>
<div class="fs-xs danger-text">Für diese Kategorie fehlt Ihnen das Recht.</div>
{% endif %}
</span>
</label>
@@ -46,9 +46,9 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
</fieldset>
{% 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') }}
<section class="card card--accent">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Geoeffnete Sicherung</div>
<div class="card__title">{{ icon('upload', size=17) }} Geöffnete Sicherung</div>
<div class="card__sub">
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.
</div>
</div>
</div>
@@ -84,7 +84,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
<div class="card__body stack">
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th></th><th>Kategorie</th><th class="numeric">Eintraege</th></tr></thead>
<thead><tr><th></th><th>Kategorie</th><th class="numeric">Einträge</th></tr></thead>
<tbody>
{% for entry in preview.summary %}
<tr>
@@ -96,7 +96,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
</td>
<td>
{{ entry.label }}
{% if entry.has_secrets %}{{ pill('mit Passwoertern', 'danger') }}{% endif %}
{% if entry.has_secrets %}{{ pill('mit Passwörtern', 'danger') }}{% endif %}
</td>
<td class="numeric">{{ entry.count }}</td>
</tr>
@@ -104,17 +104,17 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
</tbody>
</table>
</div>
{{ 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') }}
</div>
<div class="card__footer modal__footer--split">
<button class="btn btn--sm btn--ghost" type="submit"
formaction="{{ url_for('backup.import_discard') }}">Verwerfen</button>
<button class="btn btn--primary" type="submit"
data-confirm="Ausgewaehlte Kategorien jetzt uebernehmen? Bestehende Daten werden aktualisiert."
data-confirm-title="Sicherung uebernehmen" data-confirm-ok="Uebernehmen"
data-confirm-style="primary">Uebernehmen</button>
data-confirm="Ausgewählte Kategorien jetzt übernehmen? Bestehende Daten werden aktualisiert."
data-confirm-title="Sicherung übernehmen" data-confirm-ok="Übernehmen"
data-confirm-style="primary">Übernehmen</button>
</div>
</form>
</section>
@@ -123,7 +123,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Sicherung einspielen</div>
<div class="card__sub">Erst oeffnen und pruefen, dann uebernehmen</div>
<div class="card__sub">Erst öffnen und prüfen, dann übernehmen</div>
</div>
</div>
<form method="post" enctype="multipart/form-data" data-no-guard
@@ -135,8 +135,8 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
autocomplete='off') }}
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit" data-busy-label="Oeffne ...">
Datei oeffnen
<button class="btn btn--primary" type="submit" data-busy-label="Öffne ...">
Datei öffnen
</button>
</div>
</form>
@@ -150,8 +150,8 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
<div class="card__body">
<ul class="stack stack--tight fs-sm muted">
<li>Verzeichniskonten werden nie exportiert -- sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
<li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehoeren zur Maschine, nicht zur Sicherung.</li>
<li>Sitzungen, Ratenbegrenzung und das Aenderungsprotokoll sind nicht Teil der Sicherung.</li>
<li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.</li>
<li>Sitzungen, Ratenbegrenzung und das Änderungsprotokoll sind nicht Teil der Sicherung.</li>
<li>Der Zwischenstand des Imports liegt in der Datenbank -- ein Dienst-Neustall verliert ihn nicht.</li>
</ul>
</div>
@@ -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') }}
<p><a class="btn btn--primary" href="{{ back_url }}">Zur Detailseite und Schluessel freigeben</a></p>
'danger', 'Host-Schlüssel fehlt') }}
<p><a class="btn btn--primary" href="{{ back_url }}">Zur Detailseite und Schlüssel freigeben</a></p>
{% 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 %}
@@ -8,8 +8,8 @@
</nav>
{% 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') }}
<section class="card" style="max-width:560px">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} {{ entry.name }}</div>
@@ -5,9 +5,9 @@
{% macro credential_form(entry=None) %}
<div class="form-grid">
{{ 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) }}
<label class="field">
@@ -24,7 +24,7 @@
{% endmacro %}
{% block page_title %}Zugangsdaten{% endblock %}
{% block page_sub %}{{ credentials | length }} Eintraege &middot; verschluesselt mit AES-256-GCM{% endblock %}
{% block page_sub %}{{ credentials | length }} Einträge &middot; verschlüsselt mit AES-256-GCM{% endblock %}
{% block topbar_extra %}
{{ search_box('credentials-table', 'Name, Benutzer ...') }}
@@ -36,10 +36,10 @@
{% endblock %}
{% block content %}
{{ alert('Passwoerter liegen AES-256-GCM-verschluesselt und an ihren Verwendungszweck gebunden.
Sie im Klartext zu sehen braucht ein eigenes Recht, eine frische Passwortbestaetigung und wird
protokolliert. Die Kategorie setzen Sie ausdruecklich -- sie wird bewusst nicht geraten, weil
davon abhaengt, welche Geraete auf der Wartungsseite erscheinen.', 'info') }}
{{ alert('Passwörter liegen AES-256-GCM-verschlüsselt und an ihren Verwendungszweck gebunden.
Sie im Klartext zu sehen braucht ein eigenes Recht, eine frische Passwortbestätigung und wird
protokolliert. Die Kategorie setzen Sie ausdrücklich -- sie wird bewusst nicht geraten, weil
davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
<section class="card">
<div class="table-wrap">
@@ -89,10 +89,10 @@ davon abhaengt, welche Geraete auf der Wartungsseite erscheinen.', 'info') }}
<form method="post" action="{{ url_for('credentials.delete', credential_id=entry.id) }}"
style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Loeschen"
{% if entry.in_use %}disabled title="Wird noch von {{ entry.in_use }} Eintraegen verwendet"{% endif %}
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Löschen"
{% if entry.in_use %}disabled title="Wird noch von {{ entry.in_use }} Einträgen verwendet"{% endif %}
data-confirm="{{ entry.name }} in den Papierkorb verschieben?"
data-confirm-title="Zugangsdaten loeschen" data-confirm-ok="Loeschen">
data-confirm-title="Zugangsdaten löschen" data-confirm-ok="Löschen">
{{ icon('trash', size=15) }}</button>
</form>
{% endif %}
@@ -1,22 +1,17 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import search_box %}
{% block page_title %}Statusuebersicht{% endblock %}
{% block page_title %}Statusübersicht{% endblock %}
{% block page_sub %}
{{ counts.total }} Geraete &middot; Pruefintervall {{ interval }} s
{% if not detailed %}&middot; oeffentliche Ansicht{% endif %}
{{ counts.total }} Geräte &middot; Prüfintervall {{ interval }} s
{% if not detailed %}&middot; öffentliche Ansicht{% endif %}
{% endblock %}
{% block topbar_extra %}
{{ search_box('tile-sections', 'Geraet suchen ...') }}
{% if current_user.has('settings_system.execute') %}
<form method="post" action="{{ url_for('settings.run_check_now') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit" title="Sofort pruefen">
{{ icon('refresh', size=15) }} Jetzt pruefen
</button>
</form>
{% endif %}
{{ search_box('tile-sections', 'Gerät suchen ...') }}
{# Der Knopf "Jetzt prüfen" sass hier ein zweites Mal: die Timer-Pille in
der Topbar trägt ihn jetzt, und dort gehört er hin -- er setzt genau die
Angabe zurück, die daneben steht. #}
{% endblock %}
{% block content %}
@@ -24,7 +24,7 @@
<input type="hidden" name="method" value="{{ method.key }}">
<button class="btn btn--sm {{ 'btn--primary' if loop.first else 'btn--ghost' }}"
type="submit" title="{{ method.hint }}" data-busy-label="Startet ..."
data-confirm="{{ device.name }} ueber {{ method.label }} neu starten? {{ method.hint }}"
data-confirm="{{ device.name }} über {{ method.label }} neu starten? {{ method.hint }}"
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
data-confirm-style="primary">
{{ icon('power', size=15) }} {{ method.label }}
@@ -36,7 +36,7 @@
{% block content %}
{% if device.deleted_at %}
{{ alert('Dieses Geraet liegt im Papierkorb (geloescht am ' ~ (device.deleted_at | dt) ~ ').',
{{ alert('Dieses Gerät liegt im Papierkorb (gelöscht am ' ~ (device.deleted_at | dt) ~ ').',
'warning') }}
{% endif %}
@@ -58,8 +58,8 @@
<div class="grid grid--4">
<div class="stat">
<span class="stat__label">Antwortzeit</span>
<span class="stat__value">{{ '%.1f' | format(device.latency_ms) if device.latency_ms else '&ndash;' | safe }}</span>
<span class="stat__hint">Millisekunden</span>
<span class="stat__value">{{ device.latency_ms | millis if device.latency_ms else '&ndash;' | safe }}</span>
<span class="stat__hint">letzte Messung</span>
</div>
<div class="stat {{ 'stat--danger' if device.fail_count else '' }}">
<span class="stat__label">Fehlversuche</span>
@@ -88,29 +88,33 @@
<div class="flex-1">
<div class="card__title">{{ icon('power', size=17) }} Neustarts</div>
<div class="card__sub">
Die letzten {{ events | length }} Neustarts -- ueber den PoE-Port wie per SSH
Die letzten {{ events | length }} Neustarts -- über den PoE-Port wie per SSH
</div>
</div>
</div>
{% if events %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Zeit</th><th>Methode</th><th>Ausloeser</th><th>Ergebnis</th><th class="numeric">Dauer</th><th>Detail</th></tr></thead>
<thead><tr><th>Zeit</th><th>Methode</th><th>Auslöser</th><th>Ergebnis</th><th class="numeric">Dauer</th><th>Detail</th></tr></thead>
<tbody>
{% for event in events %}
<tr>
<td class="fs-sm nowrap">{{ event.ts | dt }}</td>
<td class="fs-sm">
{# Drei Wege, drei Anzeigen: alles ausser 'ssh' als "PoE-Port"
zu zeigen hat RPC-Neustarts als PoE ausgegeben. #}
{% if event.method == 'ssh' %}{{ pill('SSH', 'info') }}
{% else %}{{ pill('PoE-Port', '') }}{% endif %}
{% elif event.method == 'rpc' %}{{ pill('RPC', 'accent') }}
{% elif event.method == 'pö' %}{{ pill('PoE-Port', '') }}
{% else %}{{ pill(event.method or 'unbekannt', 'warning') }}{% endif %}
</td>
<td class="fs-sm">{{ event.trigger }} <span class="faint">/ {{ event.actor }}</span></td>
<td>
{% if event.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
{% elif event.result == 'skipped' %}{{ pill('uebersprungen', 'warning') }}
{% elif event.result == 'skipped' %}{{ pill('übersprungen', 'warning') }}
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
</td>
<td class="numeric fs-sm">{{ event.duration_ms or 0 }} ms</td>
<td class="numeric fs-sm nowrap">{{ event.duration_ms | millis }}</td>
<td class="fs-xs muted break-all">{{ event.detail }}</td>
</tr>
{% endfor %}
@@ -118,7 +122,7 @@
</table>
</div>
{% else %}
{{ empty_state('Noch keine Neustarts', 'Dieses Geraet wurde noch nie neu gestartet.', 'power') }}
{{ empty_state('Noch keine Neustarts', 'Dieses Gerät wurde noch nie neu gestartet.', 'power') }}
{% endif %}
</section>
@@ -126,12 +130,12 @@
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('shield', size=17) }} SSH-Host-Schluessel
{{ icon('shield', size=17) }} SSH-Host-Schlüssel
{% if host_keys %}{{ pill('freigegeben', 'online') }}
{% else %}{{ pill('offen', 'warning') }}{% endif %}
</div>
<div class="card__sub">
Ohne freigegebenen Schluessel verweigern Wartung und Konsole die Verbindung.
Ohne freigegebenen Schlüssel verweigern Wartung und Konsole die Verbindung.
</div>
</div>
{% if current_user.has('devices.edit') %}
@@ -151,11 +155,11 @@
<div class="alert alert--warning">
{{ icon('alert', size=18) }}
<div class="alert__body">
<div class="alert__title">Gelesener Schluessel</div>
<div class="alert__title">Gelesener Schlüssel</div>
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
<p class="fs-sm mt-2">
Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle (Konsole am
Geraet, Inventardokumentation), bevor Sie ihn freigeben.
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsole am
Gerät, Inventardokumentation), bevor Sie ihn freigeben.
</p>
<form method="post" class="mt-2"
action="{{ url_for('devices.trust_host_key', device_id=device.id) }}">
@@ -163,8 +167,8 @@
<input type="hidden" name="key_type" value="{{ probed_type }}">
<input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}">
<button class="btn btn--sm btn--primary" type="submit"
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswuerdig hinterlegen?"
data-confirm-title="Host-Schluessel freigeben" data-confirm-ok="Freigeben"
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswürdig hinterlegen?"
data-confirm-title="Host-Schlüssel freigeben" data-confirm-ok="Freigeben"
data-confirm-style="primary">Freigeben</button>
</form>
</div>
@@ -184,15 +188,15 @@
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost" type="submit"
data-confirm="Gespeicherte Host-Schluessel dieses Geraets entfernen? Danach muss der Schluessel erneut freigegeben werden."
data-confirm-title="Schluessel verwerfen" data-confirm-ok="Verwerfen">
data-confirm="Gespeicherte Host-Schlüssel dieses Geräts entfernen? Danach muss der Schlüssel erneut freigegeben werden."
data-confirm-title="Schlüssel verwerfen" data-confirm-ok="Verwerfen">
Verwerfen
</button>
</form>
{% endif %}
{% else %}
<p class="muted fs-sm">
Noch kein Schluessel hinterlegt. Erst lesen, Fingerprint pruefen, dann freigeben.
Noch kein Schlüssel hinterlegt. Erst lesen, Fingerprint prüfen, dann freigeben.
</p>
{% endif %}
</div>
@@ -223,7 +227,7 @@
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd>
<dt>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
<dt>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
<dt>Geaendert</dt>
<dt>Geändert</dt>
<dd>{{ device.updated_at | dt }} <span class="faint">von {{ device.updated_by or '-' }}</span></dd>
</dl>
{% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %}
@@ -234,7 +238,7 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="active" value="{{ '0' if device.is_active else '1' }}">
<button class="btn btn--sm" type="submit">
{{ 'Ueberwachung aussetzen' if device.is_active else 'Ueberwachung aktivieren' }}
{{ 'Überwachung aussetzen' if device.is_active else 'Überwachung aktivieren' }}
</button>
</form>
</div>
+26 -25
View File
@@ -4,10 +4,10 @@
{# Neustart-Bedienelement.
Ein einziger moeglicher Weg: ein Knopf, der ihn benennt. Mehrere: ein Knopf
fuer den bevorzugten Weg (PoE, weil er auch bei haengendem Betriebssystem
Ein einziger möglicher Weg: ein Knopf, der ihn benennt. Mehrere: ein Knopf
für den bevorzugten Weg (PoE, weil er auch bei hängendem Betriebssystem
wirkt) und daneben die Auswahl. Kein Weg: gar nichts -- ein Knopf, der
verlaesslich nichts bewirkt, ist schlimmer als kein Knopf. #}
verlässlich nichts bewirkt, ist schlimmer als kein Knopf. #}
{% macro restart_control(device, methods, compact=False) %}
{% if methods %}
{% set first = methods[0] %}
@@ -17,7 +17,7 @@
<input type="hidden" name="method" value="{{ first.key }}">
<button class="btn btn--sm{{ ' btn--ghost' if compact else '' }}" type="submit"
title="{{ first.hint }}" data-busy-label="Startet ..."
data-confirm="{{ device.name }} ueber {{ first.label }} neu starten? {{ first.hint }}"
data-confirm="{{ device.name }} über {{ first.label }} neu starten? {{ first.hint }}"
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
data-confirm-style="primary">
{{ icon('power', size=15) }}{% if not compact %} {{ first.label }}{% endif %}
@@ -31,7 +31,7 @@
<input type="hidden" name="method" value="{{ other.key }}">
<button class="btn btn--sm btn--ghost" type="submit" title="{{ other.hint }}"
data-busy-label="Startet ..."
data-confirm="{{ device.name }} ueber {{ other.label }} neu starten? {{ other.hint }}"
data-confirm="{{ device.name }} über {{ other.label }} neu starten? {{ other.hint }}"
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
data-confirm-style="primary">
{{ other.label }}
@@ -65,7 +65,7 @@
placeholder='1 oder 1/1/3',
hint_text='Pflicht, sobald ein Switch zugeordnet ist.') }}
<label class="field">
<span class="field__label">Zugangsdaten (fuer Wartung)</span>
<span class="field__label">Zugangsdaten (für Wartung)</span>
<select class="select" name="credential_id" data-behavior="toggle-target"
data-toggle-value="__neu__" data-toggle-target="#new-credential-{{ scope }}">
<option value="">keine</option>
@@ -85,8 +85,8 @@
<legend class="field__label">Neue Zugangsdaten</legend>
<div class="form-grid">
{{ field('new_credential_name', 'Bezeichnung', placeholder='z. B. Wartung Linux',
hint_text='Der Eintrag wird zusaetzlich unter Zugangsdaten gespeichert und
ist danach fuer weitere Geraete verwendbar.') }}
hint_text='Der Eintrag wird zusätzlich unter Zugangsdaten gespeichert und
ist danach für weitere Geräte verwendbar.') }}
{{ field('new_credential_username', 'Benutzer') }}
{{ field('new_credential_secret', 'Passwort', type='password') }}
<label class="field">
@@ -101,7 +101,7 @@
</div>
</fieldset>
{% endif %}
{{ field('ssh_port', 'SSH-Port des Geraets', value=device.ssh_port if device else 22, type='number') }}
{{ field('ssh_port', 'SSH-Port des Geräts', value=device.ssh_port if device else 22, type='number') }}
<label class="field">
<span class="field__label">Kategorie</span>
<select class="select" name="category">
@@ -115,23 +115,23 @@
extra_class='field--full') }}
</div>
<div class="stack stack--tight">
{{ 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.') }}
</div>
{% endmacro %}
{% block page_title %}Clients{% endblock %}
{% block page_sub %}
{{ devices | length }} Geraete &middot; {{ counts.online }} erreichbar, {{ counts.offline }} nicht erreichbar
{{ devices | length }} Geräte &middot; {{ 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') %}
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="device-new">
{{ icon('plus', size=15) }} Geraet anlegen
{{ icon('plus', size=15) }} Gerät anlegen
</button>
{% endif %}
{% endblock %}
@@ -155,8 +155,8 @@
<th class="sortable">MAC</th>
<th class="sortable">Zugangsdaten</th>
<th class="sortable">Switch / Port</th>
<th class="sortable">Letzte Pruefung</th>
<th class="sortable">Host-Schluessel</th>
<th class="sortable">Letzte Prüfung</th>
<th class="sortable">Host-Schlüssel</th>
<th class="actions">Aktion</th>
</tr>
</thead>
@@ -167,13 +167,14 @@
<a class="fw-600" href="{{ url_for('devices.detail', device_id=device.id) }}">{{ device.name }}</a>
{% if not device.is_active %}{{ pill('deaktiviert', '') }}{% endif %}
{% if not device.auto_restart %}
<span class="hint-icon" title="Automatischer PoE-Neustart ist fuer dieses Geraet abgeschaltet">!</span>
<span class="hint-icon" title="Automatischer PoE-Neustart ist für dieses Gerät abgeschaltet">!</span>
{% endif %}
{% if device.location %}<div class="fs-xs faint">{{ device.location }}</div>{% endif %}
</td>
<td data-sort-value="{{ device.state }}">
{% 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 %}<span class="fs-xs faint">({{ device.fail_count }}&times;)</span>{% endif %}
@@ -200,8 +201,8 @@
</td>
<td data-sort-value="{{ '1' if device.host_key_known else '0' }}">
{% if device.credential_category == 'windows' %}
<span class="faint fs-xs" title="Windows wird nicht ueber SSH verwaltet">
nicht noetig
<span class="faint fs-xs" title="Windows wird nicht über SSH verwaltet">
nicht nötig
</span>
{% elif device.host_key_known %}{{ pill('freigegeben', 'online') }}
{% else %}
@@ -225,9 +226,9 @@
<form method="post" action="{{ url_for('devices.delete', device_id=device.id) }}"
style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Loeschen"
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Löschen"
data-confirm="{{ device.name }} in den Papierkorb verschieben? Die MAC bleibt dabei reserviert."
data-confirm-title="Geraet loeschen" data-confirm-ok="Loeschen">
data-confirm-title="Gerät löschen" data-confirm-ok="Löschen">
{{ icon('trash', size=15) }}</button>
</form>
{% endif %}
@@ -238,19 +239,19 @@
</table>
</div>
{% 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 %}
</section>
{{ 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') %}
<form method="post" action="{{ url_for('devices.create') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">{{ device_form(scope='neu') }}</div>
+26 -26
View File
@@ -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 @@
<div class="stat">
<span class="stat__label">Reservierungen</span>
<span class="stat__value">{{ reservations | length }}</span>
<span class="stat__hint">automatisch + manuell</span>
<span class="stat__hint">automatisch + manüll</span>
</div>
<div class="stat">
<span class="stat__label">Leases</span>
<span class="stat__value">{{ leases | rejectattr('is_expired') | list | length }}</span>
<span class="stat__hint">
gueltig{% if leases | selectattr('is_expired') | list %},
gültig{% if leases | selectattr('is_expired') | list %},
{{ leases | selectattr('is_expired') | list | length }} abgelaufen{% endif %}
</span>
</div>
@@ -58,7 +58,7 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm {{ 'btn--' ~ style if style }}" type="submit"
data-busy-label="Arbeitet ..." data-confirm="{{ question }}"
data-confirm-title="{{ label }}" data-confirm-ok="Ausfuehren"
data-confirm-title="{{ label }}" data-confirm-ok="Ausführen"
data-confirm-style="{{ style or 'primary' }}">{{ label }}</button>
</form>
{% endfor %}
@@ -164,8 +164,8 @@
<div class="flex-1">
<div class="card__title">{{ icon('puzzle', size=17) }} DHCP-Optionen</div>
<div class="card__sub">
{{ options | length }} Optionen &middot; Standardoptionen sind nicht loeschbar, ihr Wert
laesst sich leeren
{{ options | length }} Optionen &middot; Standardoptionen sind nicht löschbar, ihr Wert
lässt sich leeren
</div>
</div>
{{ search_box('options-table', 'Option suchen ...') }}
@@ -202,8 +202,8 @@
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Eigene Option {{ option.name }} loeschen?"
data-confirm-title="Option loeschen" data-confirm-ok="Loeschen">
data-confirm="Eigene Option {{ option.name }} löschen?"
data-confirm-title="Option löschen" data-confirm-ok="Löschen">
{{ icon('trash', size=14) }}</button>
</form>
{% endif %}
@@ -232,16 +232,16 @@
<div class="card__footer"><button class="btn" type="submit">Eigene Option anlegen</button></div>
</form>
{# 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. #}
<div class="card__header" style="border-top:1px solid var(--border-subtle)">
<div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Option nur fuer ein Geraet</div>
<div class="card__title">{{ icon('sliders', size=17) }} Option nur für ein Gerät</div>
<div class="card__sub">
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.
</div>
</div>
@@ -251,7 +251,7 @@
<div class="card__body">
<div class="form-grid">
<label class="field">
<span class="field__label">Geraet<span class="req" aria-hidden="true">*</span></span>
<span class="field__label">Gerät<span class="req" aria-hidden="true">*</span></span>
<select class="select" name="device_id" required>
{% for reservation in reservations if reservation.device_id %}
<option value="{{ reservation.device_id }}">
@@ -269,11 +269,11 @@
</select>
</label>
{{ 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') }}
</div>
</div>
<div class="card__footer">
<button class="btn" type="submit">Fuer dieses Geraet speichern</button>
<button class="btn" type="submit">Für dieses Gerät speichern</button>
</div>
</form>
{% endif %}
@@ -286,16 +286,16 @@
<div class="flex-1">
<div class="card__title">{{ icon('credit-card', size=17) }} Reservierungen</div>
<div class="card__sub">
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.
</div>
</div>
</div>
{% 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. #}
<div class="card__body pt-0">
{{ 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') }}
<details class="fs-xs faint">
@@ -339,7 +339,7 @@
{% endif %}
</td>
<td class="actions">
{% if reservation.source == 'manuell' and current_user.has('dhcp.edit') %}
{% if reservation.source == 'manüll' and current_user.has('dhcp.edit') %}
<form method="post" data-no-guard
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -381,20 +381,20 @@
</div>
</div>
{% 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. #}
<div class="card__body pt-0">
{{ 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') }}
</div>
{% endif %}
{% if leases %}
<div class="table-wrap" style="max-height:420px;overflow:auto">
<table class="table table--compact">
<thead><tr><th>IP</th><th>MAC</th><th>Hostname</th><th>Art</th><th>Zustand</th>
<th>Laeuft ab</th></tr></thead>
<th>Läuft ab</th></tr></thead>
<tbody>
{% for lease in leases %}
<tr>
@@ -407,7 +407,7 @@
</td>
<td>
{% 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 %}
</td>
<td class="fs-sm">{{ lease.expires_text }}
@@ -429,7 +429,7 @@
<summary class="card__header" style="cursor:pointer">
<div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte Kea-Konfiguration</div>
<div class="card__sub">Vorschau &ndash; so wuerde die Datei geschrieben</div>
<div class="card__sub">Vorschau &ndash; so würde die Datei geschrieben</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
@@ -59,8 +59,8 @@
<tr>
{% if current_user.has('fileshare.delete') %}<th style="width:40px"></th>{% endif %}
<th>Name</th>
<th class="numeric">Groesse</th>
<th>Geaendert</th>
<th class="numeric">Grösse</th>
<th>Geändert</th>
<th class="actions">Aktion</th>
</tr>
</thead>
@@ -69,9 +69,9 @@
<tr>
{% if current_user.has('fileshare.delete') %}<td></td>{% endif %}
<td colspan="4">
<a href="{{ url_for('fileshare.browse', freigabe=token,
<a class="link-icon" href="{{ url_for('fileshare.browse', freigabe=token,
pfad=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}">
{{ icon('chevron-right', size=14) }} eine Ebene hoeher
{{ icon('chevron-right', size=14) }} eine Ebene höher
</a>
</td>
</tr>
@@ -79,7 +79,7 @@
{% for entry in listing.directories %}
<tr>
{% if current_user.has('fileshare.delete') %}
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswaehlen</span></label></td>
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
{% endif %}
<td>
{{ icon('folder', size=15) }}
@@ -93,16 +93,44 @@
{% for entry in listing.files %}
<tr>
{% if current_user.has('fileshare.delete') %}
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswaehlen</span></label></td>
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
{% endif %}
<td>{{ icon('file-text', size=15) }} {{ entry.name }}</td>
{# 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. #}
<td>
{% if entry.previewable %}
<button class="link-icon file-name" type="button"
data-dialog-open="file-preview"
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
data-fill-Name="{{ entry.name }}"
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
{{ icon('file-text', size=15) }} {{ entry.name }}
</button>
{% else %}
<a class="link-icon"
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
{{ icon('file-text', size=15) }} {{ entry.name }}
</a>
{% endif %}
</td>
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
<td class="fs-sm">{{ entry.modified | dt }}</td>
<td class="actions">
{% if entry.previewable %}
<a class="btn btn--sm btn--ghost" target="_blank" rel="noopener"
href="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
aria-label="Vorschau">{{ icon('eye', size=15) }}</a>
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="file-preview"
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
data-fill-Name="{{ entry.name }}"
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
{% endif %}
{% if current_user.has('fileshare.edit') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="file-rename"
data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}"
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
{% endif %}
<a class="btn btn--sm btn--ghost"
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
@@ -119,9 +147,9 @@
{% if current_user.has('fileshare.delete') %}
<div class="card__footer">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Die ausgewaehlten Eintraege endgueltig loeschen? Auf einer Netzwerkfreigabe gibt es keinen Papierkorb."
data-confirm-title="Loeschen" data-confirm-ok="Loeschen">
Ausgewaehlte loeschen
data-confirm="Die ausgewählten Einträge endgültig löschen? Auf einer Netzwerkfreigabe gibt es keinen Papierkorb."
data-confirm-title="Löschen" data-confirm-ok="Löschen">
Ausgewählte löschen
</button>
</div>
{% endif %}
@@ -142,7 +170,7 @@
<input type="hidden" name="pfad" value="{{ listing.relative }}">
<div class="card__body row">
<input class="input" type="file" name="files" multiple required style="flex:1">
<button class="btn btn--primary" type="submit" data-busy-label="Laedt ...">Hochladen</button>
<button class="btn btn--primary" type="submit" data-busy-label="Lädt ...">Hochladen</button>
</div>
</form>
</section>
@@ -165,4 +193,44 @@
</form>
{% 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') %}
<div class="modal__caption" data-fill-target="Name"></div>
<div class="modal__body modal__body--flush">
<iframe class="file-preview" title="Vorschau" data-fill-target="Src" data-fill-as="src"
src="about:blank" referrerpolicy="same-origin"></iframe>
</div>
<div class="modal__footer">
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
{{ icon('download', size=15) }} Herunterladen
</a>
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
</div>
{% endcall %}
{% endif %}
{% if listing and current_user.has('fileshare.edit') %}
{% call modal('file-rename', 'Umbenennen', '', 'narrow') %}
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="parent" value="{{ listing.relative }}">
<input type="hidden" name="pfad" data-fill-target="Pfad" value="">
<div class="modal__body">
{{ field('name', 'Neuer Name', required=True, attrs='data-fill-target="Name"') }}
<p class="fs-xs faint">
Umbenannt wird auf der Freigabe selbst -- es gibt dort keinen Papierkorb.
</p>
</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Umbenennen</button>
</div>
</form>
{% endcall %}
{% endif %}
{% endblock %}
@@ -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 &middot; 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') }}
<td class="fs-sm">{{ entry.ad_group_name }}
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
<td class="actions">
{% if current_user.has('fileshare.edit') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="mapping-edit-{{ entry.id }}"
aria-label="Bearbeiten">{{ icon('pencil', size=15) }}</button>
{% endif %}
<form method="post" action="{{ url_for('fileshare.mapping_delete', mapping_id=entry.id) }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
@@ -61,7 +66,7 @@ Einbindungen.', 'info') }}
<div class="form-grid">
{{ field('label', 'Anzeigename', required=True, placeholder='Abteilung Technik') }}
{{ field('unc', 'UNC-Pfad', required=True, placeholder='\\\\dc2022\\daten',
hint_text='Schraegstriche gehen auch: //dc2022/daten') }}
hint_text='Schrägstriche gehen auch: //dc2022/daten') }}
{{ group_picker('ad_group_name', 'ad_group_dn', 'share', required=True,
label='Verzeichnisgruppe, die diese Freigabe bekommt') }}
</div>
@@ -70,3 +75,33 @@ Einbindungen.', 'info') }}
</form>
</section>
{% endblock %}
{% block dialogs %}
{# Bearbeiten statt neu anlegen: bei einem Tippfehler im UNC-Pfad oder im
Anzeigenamen soll man ihn korrigieren können. Sonst legt man daneben einen
zweiten Eintrag an und vergisst, den ersten zu löschen. #}
{% if current_user.has('fileshare.edit') %}
{% for entry in mappings %}
{% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %}
<form method="post" action="{{ url_for('fileshare.mapping_update', mapping_id=entry.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">
<div class="form-grid">
{{ field('label', 'Anzeigename', value=entry.label, required=True) }}
{{ field('unc', 'UNC-Pfad', value=entry.unc, required=True,
hint_text='Schrägstriche gehen auch: //dc2022/daten') }}
{{ group_picker('ad_group_name', 'ad_group_dn', 'edit-' ~ entry.id,
name_value=entry.ad_group_name, dn_value=entry.ad_group_dn,
required=True,
label='Verzeichnisgruppe, die diese Freigabe bekommt') }}
</div>
</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
</form>
{% endcall %}
{% endfor %}
{% endif %}
{% endblock %}
+24 -20
View File
@@ -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 %} &middot; {{ 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 %}
<div class="alert alert--warning">
{{ icon('alert', size=18) }}
<div class="alert__body">
<div class="alert__title">Gelesener Schluessel von {{ probed_host }}:{{ probed_port }}</div>
<div class="alert__title">Gelesener Schlüssel von {{ probed_host }}:{{ probed_port }}</div>
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
<p class="fs-sm mt-2">
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.
</p>
</div>
</div>
@@ -102,8 +106,8 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au
<input type="hidden" name="key_type" value="{{ probed_type }}">
<input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}">
<button class="btn btn--sm btn--primary" type="submit"
data-confirm="Fingerprint {{ probed_fingerprint }} fuer {{ row.label }} dauerhaft als vertrauenswuerdig hinterlegen?"
data-confirm-title="Host-Schluessel freigeben"
data-confirm="Fingerprint {{ probed_fingerprint }} für {{ row.label }} dauerhaft als vertrauenswürdig hinterlegen?"
data-confirm-title="Host-Schlüssel freigeben"
data-confirm-ok="Freigeben" data-confirm-style="primary">
{{ icon('check', size=14) }} Freigeben
</button>
@@ -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) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost" type="submit"
data-confirm="Freigabe fuer {{ row.label }} entfernen? Wartung und Konsole verweigern danach die Verbindung, bis erneut freigegeben wurde."
data-confirm="Freigabe für {{ row.label }} entfernen? Wartung und Konsole verweigern danach die Verbindung, bis erneut freigegeben wurde."
data-confirm-title="Freigabe verwerfen" data-confirm-ok="Verwerfen">
Verwerfen
</button>
@@ -141,7 +145,7 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au
<div class="flex-1">
<div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div>
<div class="card__sub">
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.
</div>
</div>
</div>
@@ -162,7 +166,7 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au
<input type="hidden" name="host" value="{{ row.host }}">
<input type="hidden" name="port" value="{{ row.port }}">
<button class="btn btn--sm btn--ghost" type="submit"
data-confirm="Verwaiste Freigabe fuer {{ row.host }}:{{ row.port }} entfernen?"
data-confirm="Verwaiste Freigabe für {{ row.host }}:{{ row.port }} entfernen?"
data-confirm-title="Eintrag entfernen" data-confirm-ok="Entfernen">
Entfernen
</button>
+27 -27
View File
@@ -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 @@
<dt>Lizenz-ID</dt><dd class="mono fs-xs break-all">{{ state.status.license_id }}</dd>
<dt>Revision</dt><dd>{{ state.status.seq }}</dd>
<dt>Ausgestellt</dt><dd>{{ state.status.issued_at | dt }}</dd>
<dt>Laeuft ab</dt>
<dt>Läuft ab</dt>
<dd>
{% 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 %}
<div class="fs-xs warning-text">
Kulanzfrist laeuft ({{ grace_days }} Tage) &ndash; Funktionen bleiben vorerst nutzbar.
Kulanzfrist läuft ({{ grace_days }} Tage) &ndash; Funktionen bleiben vorerst nutzbar.
</div>
{% endif %}
{% elif state.status.expiring_soon %}
@@ -88,7 +88,7 @@
{% else %}
<form method="post" action="{{ url_for('license.heartbeat') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Jetzt pruefen</button>
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Jetzt prüfen</button>
</form>
<form method="post" action="{{ url_for('license.deactivate') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -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 %}
<div class="field">
<span class="field__label">
Anfragecode -- an den Anbieter uebermitteln
Anfragecode -- an den Anbieter übermitteln
</span>
<pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre>
<div class="row row--end mt-2">
@@ -135,8 +135,8 @@
</div>
</div>
{% 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 @@
<label class="field">
<span class="field__label">Antwortcode des Lizenzservers</span>
<textarea class="textarea input--mono" name="code" rows="4" required
placeholder="Code einfuegen ..."></textarea>
placeholder="Code einfügen ..."></textarea>
</label>
<div class="row row--end mt-3">
<button class="btn btn--primary" type="submit">Antwort uebernehmen</button>
<button class="btn btn--primary" type="submit">Antwort übernehmen</button>
</div>
</form>
</div>
@@ -161,15 +161,15 @@
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Zertifikat des Lizenzservers</div>
<div class="card__sub">
Nur noetig, wenn der Lizenzserver ein eigenes Zertifikat benutzt
Nur nötig, wenn der Lizenzserver ein eigenes Zertifikat benutzt
</div>
</div>
</div>
<div class="card__body stack">
{{ 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') }}
<form method="post" action="{{ url_for('license.server_certificate') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<label class="field">
@@ -178,7 +178,7 @@
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
>{{ server_certificate }}</textarea>
<span class="field__hint">
Leer lassen, um wieder gegen die Systemanker zu pruefen. Auf dem Lizenzserver
Leer lassen, um wieder gegen die Systemanker zu prüfen. Auf dem Lizenzserver
liegt es unter <span class="mono">/etc/tesm/certs/&lt;name&gt;/fullchain.pem</span>.
</span>
</label>
@@ -196,7 +196,7 @@
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div>
<div class="card__sub">
Die Datei enthaelt das Lizenzdokument und den zugehoerigen Client-Schluessel
Die Datei enthält das Lizenzdokument und den zugehörigen Client-Schlüssel
</div>
</div>
</div>
@@ -206,7 +206,7 @@
<div class="card__body stack">
<input class="input" type="file" name="license_file" accept=".json,.lic" required>
{% 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 @@
<dt>Fingerprint</dt><dd class="mono fs-xs break-all">{{ fingerprint }}</dd>
</dl>
<p class="fs-xs faint mt-3">
Der Fingerprint stammt aus der stabilen Maschinenidentitaet. Eine Umbenennung des Hosts
aendert ihn <strong>nicht</strong> mehr -- im Vorgaenger fuehrte genau das zu
Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts
ändert ihn <strong>nicht</strong> mehr -- im Vorgänger führte genau das zu
&bdquo;bereits anderweitig gebunden&ldquo;.
</p>
</div>
@@ -243,14 +243,14 @@
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswuerdige Aussteller</div>
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schluessel</div>
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswürdige Aussteller</div>
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schlüssel</div>
</div>
</div>
{% if trusted_keys %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Schluessel-ID</th><th>Bezeichnung</th><th>Quelle</th><th class="actions"></th></tr></thead>
<thead><tr><th>Schlüssel-ID</th><th>Bezeichnung</th><th>Quelle</th><th class="actions"></th></tr></thead>
<tbody>
{% for key in trusted_keys %}
<tr>
@@ -268,7 +268,7 @@
action="{{ url_for('license.remove_trusted_key', key_id=key.key_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Diesen Aussteller-Schluessel entfernen? Danach gilt die von ihm ausgestellte Lizenz als nicht vertrauenswuerdig."
data-confirm="Diesen Aussteller-Schlüssel entfernen? Danach gilt die von ihm ausgestellte Lizenz als nicht vertrauenswürdig."
data-confirm-title="Aussteller entfernen" data-confirm-ok="Entfernen">
{{ icon('trash', size=14) }}</button>
</form>
@@ -281,7 +281,7 @@
</div>
{% 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 %}
</section>
@@ -307,7 +307,7 @@
</ul>
<p class="fs-xs faint mt-3">
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.
</p>
</div>
</section>
@@ -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') }}
<section class="card">
<div class="card__header">
@@ -25,8 +25,8 @@ Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }}
<div class="table-wrap">
<table class="table table--compact" id="restart-table" data-behavior="sortable">
<thead><tr>
<th class="sortable">Zeit</th><th class="sortable">Geraet</th>
<th class="sortable">Methode</th><th class="sortable">Ausloeser</th>
<th class="sortable">Zeit</th><th class="sortable">Gerät</th>
<th class="sortable">Methode</th><th class="sortable">Auslöser</th>
<th class="sortable">Benutzer</th><th class="sortable">Ergebnis</th>
<th class="sortable numeric">Dauer</th><th>Detail</th>
</tr></thead>
@@ -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 %}
</td>
<td class="fs-sm">{{ row.trigger }}</td>
<td class="fs-sm">{{ row.actor }}</td>
<td>
{% 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 %}
</td>
<td class="numeric fs-sm">{{ row.duration_ms or 0 }} ms</td>
<td class="numeric fs-sm nowrap">{{ row.duration_ms | millis }}</td>
<td class="fs-xs muted break-all" style="max-width:420px">{{ row.detail }}</td>
</tr>
{% endfor %}
@@ -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
&bdquo;Linux-Client&ldquo; oder &bdquo;Windows-Client&ldquo;
{% 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. #}
<div class="alert alert--warning">
{{ icon('alert', size=18) }}
<div class="alert__body">
<strong>Windows-Neustart nicht moeglich</strong>
<strong>Windows-Neustart nicht möglich</strong>
Auf diesem Server fehlt das Paket <code>{{ windows_package }}</code>. Es bringt
<code>net</code> mit, ueber das der Neustart eines Windows-Geraetes laeuft.
<code>net</code> mit, über das der Neustart eines Windows-Gerätes läuft.
</div>
<form method="post" action="{{ url_for('maintenance.install_windows_tool') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -38,10 +38,10 @@
</div>
{% 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
<div class="table-toolbar">
<label class="check">
<input type="checkbox" id="select-all">
<span>Alle auswaehlen</span>
<span>Alle auswählen</span>
</label>
<span class="table-toolbar__spacer"></span>
{% if current_user.has('maintenance.execute') %}
<button class="btn btn--sm btn--primary" type="submit"
formaction="{{ url_for('maintenance.start_update') }}"
title="Nur fuer Linux-Clients -- Windows-Geraete werden dabei uebersprungen."
data-confirm="Paketaktualisierung auf den ausgewaehlten Geraeten starten?"
title="Nur für Linux-Clients -- Windows-Geräte werden dabei übersprungen."
data-confirm="Paketaktualisierung auf den ausgewählten Geräten starten?"
data-confirm-title="Aktualisieren" data-confirm-ok="Starten" data-confirm-style="primary">
{{ icon('download', size=15) }} Aktualisieren
</button>
<button class="btn btn--sm btn--danger" type="submit"
formaction="{{ url_for('maintenance.start_reboot') }}"
data-confirm="Die ausgewaehlten Geraete jetzt neu starten? Sie sind dabei kurzzeitig nicht erreichbar."
data-confirm="Die ausgewählten Geräte jetzt neu starten? Sie sind dabei kurzzeitig nicht erreichbar."
data-confirm-title="Neustarten" data-confirm-ok="Neu starten">
{{ icon('refresh', size=15) }} Neustarten
</button>
@@ -76,11 +76,11 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<thead>
<tr>
<th style="width:44px"></th>
<th class="sortable">Geraet</th>
<th class="sortable">Gerät</th>
<th class="sortable">IP</th>
<th class="sortable">Zustand</th>
<th class="sortable">Zugangsdaten</th>
<th class="sortable">Host-Schluessel</th>
<th class="sortable">Host-Schlüssel</th>
<th></th>
</tr>
</thead>
@@ -92,7 +92,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<input type="checkbox" name="device_ids" value="{{ device.id }}"
class="device-select" data-category="{{ device.category }}"
{% if not current_user.has('maintenance.execute') %}disabled{% endif %}>
<span class="sr-only">{{ device.name }} auswaehlen</span>
<span class="sr-only">{{ device.name }} auswählen</span>
</label>
</td>
<td class="fw-600">{{ device.name }}</td>
@@ -108,13 +108,13 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
</td>
<td>
{% if not device.ssh_managed %}
<span class="faint fs-xs" title="Windows wird ueber RPC verwaltet, nicht ueber SSH">
nicht noetig
<span class="faint fs-xs" title="Windows wird über RPC verwaltet, nicht über SSH">
nicht nötig
</span>
{% elif device.host_key_ready %}{{ pill('freigegeben', 'online') }}
{% else %}
<a href="{{ url_for('devices.detail', device_id=device.id) }}"
title="Ohne freigegebenen Schluessel bricht die Wartung ab">
title="Ohne freigegebenen Schlüssel bricht die Wartung ab">
{{ pill('offen', 'danger') }}
</a>
{% endif %}
@@ -134,9 +134,9 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
</table>
</div>
{% 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 %}
</section>
</form>
@@ -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. #}
<section class="card" id="jobs">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('clock', size=17) }} Auftraege</div>
<div class="card__title">{{ icon('clock', size=17) }} Aufträge</div>
<div class="card__sub">
Zustand liegt in der Datenbank -- ein Dienst-Neustart verliert ihn nicht mehr
</div>
@@ -158,7 +158,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% if jobs %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Gestartet</th><th>Geraet</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>
{% for job in jobs %}
<tr>
@@ -170,7 +170,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<td>
{% 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 %}
</td>
@@ -186,16 +186,16 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
</table>
</div>
{% 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 %}
</section>
{% endblock %}
{% block scripts %}
<script type="module" nonce="{{ csp_nonce }}">
// Auswahl beim Laden bewusst zuruecksetzen: 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"
// versehentlich das falsche Geraet erwischt.
// versehentlich das falsche Gerät erwischt.
const boxes = Array.from(document.querySelectorAll(".device-select"));
boxes.forEach((box) => (box.checked = false));
@@ -205,7 +205,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
.forEach((box) => (box.checked = all.checked));
});
// Auftragsliste nachladen, solange etwas laeuft.
// Auftragsliste nachladen, solange etwas läuft.
async function poll() {
try {
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
@@ -15,7 +15,7 @@
{{ icon('wrench', size=17) }} Auftrag
{% 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 %}
</div>
@@ -37,14 +37,14 @@
</div>
</section>
<div class="row row--end">
<a class="btn" href="{{ url_for('maintenance.overview') }}">Zurueck</a>
<a class="btn" href="{{ url_for('maintenance.overview') }}">Zurück</a>
</div>
{% endblock %}
{% block scripts %}
<script type="module" nonce="{{ csp_nonce }}">
// Beim Oeffnen ans Ende springen: bei einem laufenden Auftrag ist die
// juengste Zeile die interessante, nicht die erste. Das Nachfuehren beim
// Beim Öffnen ans Ende springen: bei einem laufenden Auftrag ist die
// jüngste Zeile die interessante, nicht die erste. Das Nachführen beim
// Aktualisieren erledigt die Schleife in app.js.
const view = document.querySelector("#job-output .log-view");
if (view) view.scrollTop = view.scrollHeight;
@@ -2,12 +2,12 @@
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill %}
{% block page_title %}Systemeinstellungen{% endblock %}
{% block page_sub %}Ueberwachung, Protokolle, Host, Navigation{% endblock %}
{% block page_sub %}Überwachung, Protokolle, Host, Navigation{% endblock %}
{% block content %}
{% if not sysops_available %}
{{ alert('Systemhelfer nicht verfuegbar -- Hostname und Zeitzone koennen hier nur angezeigt,
nicht geaendert werden. In einer Entwicklungsumgebung ist das der erwartete Zustand.',
{{ alert('Systemhelfer nicht verfügbar -- Hostname und Zeitzone können hier nur angezeigt,
nicht geändert werden. In einer Entwicklungsumgebung ist das der erwartete Zustand.',
'info') }}
{% endif %}
@@ -16,13 +16,13 @@
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('activity', size=17) }} Ueberwachung</div>
<div class="card__sub">Wie oft geprueft und wann automatisch geschaltet wird</div>
<div class="card__title">{{ icon('activity', size=17) }} Überwachung</div>
<div class="card__sub">Wie oft geprüft und wann automatisch geschaltet wird</div>
</div>
{% if current_user.has('settings_system.execute') %}
<form method="post" action="{{ url_for('settings.run_check_now') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Jetzt pruefen</button>
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Jetzt prüfen</button>
</form>
{% endif %}
</div>
@@ -31,7 +31,7 @@
<div class="card__body stack">
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
<div class="form-grid">
{{ field('monitor_interval_seconds', 'Pruefintervall (Sekunden)',
{{ field('monitor_interval_seconds', 'Prüfintervall (Sekunden)',
value=values.monitor_interval_seconds, type='number',
disabled=bool(license_hint),
hint_text=specs['monitor_interval_seconds'].hint) }}
@@ -41,9 +41,9 @@
</div>
{{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren',
checked=values.monitor_auto_restart,
hint_text='Zusaetzlich lizenzpflichtig. Der manuelle Knopf im Dashboard
funktioniert unabhaengig davon.') }}
{{ checkbox('dashboard_public', 'Statusuebersicht ohne Anmeldung erreichbar',
hint_text='Zusätzlich lizenzpflichtig. Der manülle Knopf im Dashboard
funktioniert unabhängig davon.') }}
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
checked=values.dashboard_public,
hint_text=specs['dashboard_public'].hint) }}
</div>
@@ -56,17 +56,17 @@
<div class="flex-1">
<div class="card__title">{{ icon('terminal', size=17) }} Switch-Konsole</div>
<div class="card__sub">
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestaetigung,
vollstaendige Mitschrift
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestätigung,
vollständige Mitschrift
</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ checkbox('terminal_enabled', 'Browser-Konsole fuer Switche zulassen',
{{ checkbox('terminal_enabled', 'Browser-Konsole für Switche zulassen',
checked=values.terminal_enabled) }}
{{ field('terminal_idle_timeout', 'Trennen bei Untaetigkeit (Sekunden)',
{{ field('terminal_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
value=values.terminal_idle_timeout, type='number') }}
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
@@ -87,6 +87,9 @@
specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }}
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
type='number') }}
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
value=values.session_idle_minutes, type='number',
hint_text=specs['session_idle_minutes'].hint) }}
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
value=values.trash_retention_days, type='number',
hint_text=specs['trash_retention_days'].hint) }}
@@ -134,7 +137,7 @@
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
<div class="card__sub">Gilt fuer alle Benutzer; jeder sieht weiter nur, was er darf</div>
<div class="card__sub">Gilt für alle Benutzer; jeder sieht weiter nur, was er darf</div>
</div>
</div>
<form method="post" action="{{ url_for('admin.nav_order') }}">
@@ -154,8 +157,8 @@
{% endfor %}
</ol>
<p class="fs-xs faint mt-3">
Die Reihenfolge folgt der Liste. Zum Umsortieren die Eintraege in der gewuenschten
Reihenfolge speichern (Drag-and-drop folgt in einer spaeteren Version).
Die Reihenfolge folgt der Liste. Zum Umsortieren die Einträge in der gewünschten
Reihenfolge speichern (Drag-and-drop folgt in einer späteren Version).
</p>
</div>
<div class="card__footer">
@@ -18,6 +18,6 @@
<pre class="log-view" id="cmd-output">{{ output }}</pre>
</section>
<div class="row row--end">
<a class="btn" href="{{ url_for('switches.detail', switch_id=switch.id) }}">Zurueck</a>
<a class="btn" href="{{ url_for('switches.detail', switch_id=switch.id) }}">Zurück</a>
</div>
{% endblock %}
@@ -25,11 +25,11 @@
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('lock', size=17) }} SSH-Host-Schluessel
{{ icon('lock', size=17) }} SSH-Host-Schlüssel
{% if host_keys %}{{ pill('freigegeben', 'online') }}{% else %}{{ pill('offen', 'danger') }}{% endif %}
</div>
<div class="card__sub">
Ohne freigegebenen Schluessel verweigert die Anwendung jeden Zugriff auf diesen Switch.
Ohne freigegebenen Schlüssel verweigert die Anwendung jeden Zugriff auf diesen Switch.
</div>
</div>
{% if current_user.has('switches.edit') %}
@@ -45,12 +45,12 @@
<div class="alert alert--warning">
{{ icon('alert', size=18) }}
<div class="alert__body">
<div class="alert__title">Gelesener Schluessel</div>
<div class="alert__title">Gelesener Schlüssel</div>
<div class="mono fs-sm break-all">
{{ request.args.get('probed_type') }} {{ request.args.get('probed_fingerprint') }}
</div>
<p class="fs-sm mt-2">
Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle (Konsolenzugang am
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsolenzugang am
Switch, Inventardokumentation), bevor Sie ihn freigeben.
</p>
{% if current_user.has('switches.edit') %}
@@ -60,8 +60,8 @@
<input type="hidden" name="key_type" value="{{ request.args.get('probed_type') }}">
<input type="hidden" name="fingerprint" value="{{ request.args.get('probed_fingerprint') }}">
<button class="btn btn--sm btn--primary" type="submit"
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswuerdig hinterlegen?"
data-confirm-title="Host-Schluessel freigeben" data-confirm-ok="Freigeben"
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswürdig hinterlegen?"
data-confirm-title="Host-Schlüssel freigeben" data-confirm-ok="Freigeben"
data-confirm-style="primary">Freigeben</button>
</form>
{% endif %}
@@ -83,14 +83,14 @@
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Gespeicherte Host-Schluessel entfernen? Danach ist kein Zugriff mehr moeglich, bis erneut freigegeben wird."
data-confirm-title="Schluessel verwerfen" data-confirm-ok="Verwerfen">
Schluessel verwerfen
data-confirm="Gespeicherte Host-Schlüssel entfernen? Danach ist kein Zugriff mehr möglich, bis erneut freigegeben wird."
data-confirm-title="Schlüssel verwerfen" data-confirm-ok="Verwerfen">
Schlüssel verwerfen
</button>
</form>
{% endif %}
{% else %}
<p class="muted fs-sm">Noch kein Schluessel hinterlegt. Bitte auslesen und pruefen.</p>
<p class="muted fs-sm">Noch kein Schlüssel hinterlegt. Bitte auslesen und prüfen.</p>
{% endif %}
</div>
</section>
@@ -99,9 +99,9 @@
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('terminal', size=17) }} Kommando ausfuehren</div>
<div class="card__title">{{ icon('terminal', size=17) }} Kommando ausführen</div>
<div class="card__sub">
Positivliste lesender Kommandos &ndash; verlangt eine frische Passwortbestaetigung
Positivliste lesender Kommandos &ndash; verlangt eine frische Passwortbestätigung
</div>
</div>
</div>
@@ -118,8 +118,8 @@
</label>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit" data-busy-label="Fuehre aus ...">
Ausfuehren
<button class="btn btn--primary" type="submit" data-busy-label="Führe aus ...">
Ausführen
</button>
</div>
</form>
@@ -140,7 +140,7 @@
<dt>Zugangsdaten</dt><dd>{{ switch.credential_name or 'keine' }}</dd>
<dt>Standort</dt><dd>{{ switch.location or '&ndash;' | safe }}</dd>
<dt>Zuletzt gesehen</dt><dd>{{ switch.last_seen_at | dt }}</dd>
<dt>Geaendert</dt><dd>{{ switch.updated_at | dt }} <span class="faint">von {{ switch.updated_by or '-' }}</span></dd>
<dt>Geändert</dt><dd>{{ switch.updated_at | dt }} <span class="faint">von {{ switch.updated_by or '-' }}</span></dd>
</dl>
{% if switch.notes %}<p class="fs-sm muted mt-3">{{ switch.notes }}</p>{% endif %}
</div>
@@ -149,14 +149,14 @@
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('cpu', size=17) }} Angeschlossene Geraete</div>
<div class="card__title">{{ icon('cpu', size=17) }} Angeschlossene Geräte</div>
<div class="card__sub">{{ devices | length }} zugeordnet</div>
</div>
</div>
{% if devices %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Port</th><th>Geraet</th><th>Zustand</th></tr></thead>
<thead><tr><th>Port</th><th>Gerät</th><th>Zustand</th></tr></thead>
<tbody>
{% for device in devices %}
<tr>
@@ -176,7 +176,7 @@
</table>
</div>
{% else %}
{{ empty_state('Keine Geraete', 'Diesem Switch ist noch kein Geraet zugeordnet.', 'cpu') }}
{{ empty_state('Keine Geräte', 'Diesem Switch ist noch kein Gerät zugeordnet.', 'cpu') }}
{% endif %}
</section>
</div>
+12 -34
View File
@@ -39,9 +39,9 @@
{% endblock %}
{% block content %}
{{ alert('Vor dem ersten Zugriff muss der SSH-Host-Schluessel eines Switches ausdruecklich
freigegeben werden. Unbekannte oder geaenderte Schluessel werden abgewiesen -- nicht
stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
{{ alert('Vor dem ersten Zugriff muss der SSH-Host-Schlüssel eines Switches ausdrücklich
freigegeben werden. Unbekannte oder geänderte Schlüssel werden abgewiesen -- nicht
stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
<section class="card">
<div class="table-wrap">
@@ -52,8 +52,8 @@ stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
<th class="sortable">IP</th>
<th class="sortable numeric">Port</th>
<th class="sortable">Zugangsdaten</th>
<th class="sortable numeric">Geraete</th>
<th class="sortable">Host-Schluessel</th>
<th class="sortable numeric">Geräte</th>
<th class="sortable">Host-Schlüssel</th>
<th class="actions">Aktion</th>
</tr>
</thead>
@@ -99,9 +99,9 @@ stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
<form method="post" action="{{ url_for('switches.delete', switch_id=switch.id) }}"
style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Loeschen"
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Löschen"
data-confirm="{{ switch.hostname }} in den Papierkorb verschieben?"
data-confirm-title="Switch loeschen" data-confirm-ok="Loeschen">
data-confirm-title="Switch löschen" data-confirm-ok="Löschen">
{{ icon('trash', size=15) }}</button>
</form>
{% endif %}
@@ -116,37 +116,15 @@ stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
{% endif %}
</section>
{% if host_keys %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('lock', size=17) }} Freigegebene Host-Schluessel</div>
<div class="card__sub">{{ host_keys | length }} Eintraege</div>
</div>
</div>
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Host</th><th>Port</th><th>Typ</th><th>Fingerprint</th><th>Freigegeben</th></tr></thead>
<tbody>
{% for key in host_keys %}
<tr>
<td class="mono fs-sm">{{ key.host }}</td>
<td class="fs-sm">{{ key.port }}</td>
<td class="fs-sm">{{ key.key_type }}</td>
<td class="mono fs-xs break-all">{{ key.fingerprint }}</td>
<td class="fs-sm">{{ key.added_at | dt }} <span class="faint">von {{ key.added_by }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
{# Die freigegebenen Host-Schlüssel standen hier ein zweites Mal. Gepflegt
werden sie unter Überwachung -> Host-Schlüssel: dort mit Auslesen,
Fingerprint-Vergleich, Freigeben, Verwerfen und Aufräumen. Eine reine
Zweitanzeige lädt dazu ein, sie an der falschen Stelle zu suchen. #}
{% endblock %}
{% block dialogs %}
{% if current_user.has('switches.create') %}
{% call modal('switch-new', 'Switch anlegen', 'Host-Schluessel wird danach freigegeben') %}
{% call modal('switch-new', 'Switch anlegen', 'Host-Schlüssel wird danach freigegeben') %}
<form method="post" action="{{ url_for('switches.create') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">{{ switch_form() }}</div>