Wartung: SSH-Bulk-Update & -Neustart für Linux-Clients
Neue Seite "Wartung" (Geräte-Bereich, eigenes Rechtepaar maintenance.view/maintenance.edit), komplett getrennt vom PoE-Neustart auf dem Dashboard: - Dashboard-Neustart bleibt ausschließlich PoE-Reset über den Switch - Wartung läuft immer per SSH direkt auf dem Gerät, unabhängig von jeder Switch-Zuordnung -- nur Geräte mit SSH-Zugangsdaten der Kategorie "Linux-Client" erscheinen dort (Windows/PowerShell folgt als eigener, separater Schritt -- kein Testsystem dafür verfügbar) Backend: - _run_ssh_command()/_run_ssh_reboot(): nicht-interaktive SSH-Ausführung mit paramiko.RejectPolicy() gegen dieselbe known_hosts-Datei wie das interaktive Browser-Terminal -- ein Host muss vorher einmal per "Verbindung testen" bestätigt worden sein, da hier (anders als im Terminal) niemand eine "yes/no"-Rückfrage beantworten kann - _apt_upgrade_command(): "apt update && apt upgrade -y" nicht- interaktiv mit DEBIAN_FRONTEND=noninteractive und --force-confdef/--force-confold, damit ein Paket-Postinst-Skript nicht auf eine nie kommende Config-Rückfrage wartet - Reboot behandelt einen abrupten Verbindungsabbruch NACH dem Kommando als Erfolg (die Maschine fährt herunter, bevor sie antworten kann); nur ein Fehler VOR dem Reboot-Kommando zählt als echter Fehler - Job-Status lebt im Prozessspeicher (wie das SSH-Terminal), Live- Anzeige per Polling (/maintenance/status) Live gegen ein dediziertes Testsystem (getrennt vom gemeinsam genutzten App-Host, um dort kein echtes apt upgrade auszulösen) verifiziert: echtes "apt update && apt upgrade" inkl. Kernel-/systemd-/ netplan-Paketen lief vollständig durch, anschließender SSH-Neustart hat das System nachweislich neu gestartet (uptime nach dem Job bestätigt). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,8 +25,17 @@ Die App ermöglicht:
|
|||||||
Host-Key zu bestätigen und die Zugangsdaten direkt zu prüfen, bevor der
|
Host-Key zu bestätigen und die Zugangsdaten direkt zu prüfen, bevor der
|
||||||
Switch gespeichert wird — funktioniert auf jedem Betriebssystem, die
|
Switch gespeichert wird — funktioniert auf jedem Betriebssystem, die
|
||||||
SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut
|
SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut
|
||||||
- Manueller PoE-Neustart einzelner Geräte per Klick; bei deaktivierten
|
- Manueller PoE-Neustart einzelner Geräte per Klick — ausschließlich über
|
||||||
Geräten bietet das Detail-Popup stattdessen direkt eine Aktivieren-Option
|
den zugeordneten Switch (`poe.sh`); der Button erscheint im Detail-Popup
|
||||||
|
nur, wenn dem Gerät tatsächlich Switch **und** Port zugeordnet sind (sonst
|
||||||
|
wäre kein PoE-Reset möglich), und ist immer der am weitesten links stehende
|
||||||
|
Aktions-Button — „Schließen“ steht unabhängig davon immer ganz rechts. Bei
|
||||||
|
deaktivierten Geräten bietet das Detail-Popup stattdessen direkt eine
|
||||||
|
Aktivieren-Option
|
||||||
|
- **SSH-basierte Wartung für Linux-Clients** (eigene Seite „Wartung“,
|
||||||
|
getrennt vom PoE-Neustart oben): Bulk-Update (`apt update && apt upgrade`)
|
||||||
|
und Neustart per SSH direkt auf dem Gerät, unabhängig von jeder
|
||||||
|
Switch-Zuordnung (siehe [Wartung](#wartung-ssh-bulk-update--neustart))
|
||||||
- Einstellbares Prüfintervall (steuert den Hintergrund-Check-Dienst) —
|
- Einstellbares Prüfintervall (steuert den Hintergrund-Check-Dienst) —
|
||||||
der Countdown „Nächste Prüfung“ wird aus dem tatsächlichen letzten
|
der Countdown „Nächste Prüfung“ wird aus dem tatsächlichen letzten
|
||||||
Log-Eintrag berechnet, nicht bei jedem Seitenaufruf neu gestartet, und
|
Log-Eintrag berechnet, nicht bei jedem Seitenaufruf neu gestartet, und
|
||||||
@@ -191,15 +200,26 @@ Bereichs-Recht nicht bei jedem Neustart automatisch wieder gesetzt wird.
|
|||||||
|
|
||||||
## Zugangsdaten (wiederverwendbare SSH-Logins)
|
## Zugangsdaten (wiederverwendbare SSH-Logins)
|
||||||
|
|
||||||
Switch-Zugangsdaten (Username + verschlüsseltes Passwort) sind eine eigene
|
Zugangsdaten (Username + verschlüsseltes Passwort) sind eine eigene Entität
|
||||||
Entität (`credentials`-Tabelle) statt direkt am Switch zu hängen — mehrere
|
(`credentials`-Tabelle) statt direkt am Switch/Gerät zu hängen — mehrere
|
||||||
Switche mit demselben Login teilen sich einen Eintrag. Beim Anlegen/Bearbeiten
|
Switche/Geräte mit demselben Login teilen sich einen Eintrag. Beim Anlegen/
|
||||||
eines Switches lässt sich per Dropdown ein bestehender Eintrag auswählen oder
|
Bearbeiten eines Switches **oder Geräts** lässt sich per Dropdown ein
|
||||||
über „+ Neue Zugangsdaten anlegen“ direkt im selben Formular ein neuer
|
bestehender Eintrag auswählen oder über „+ Neue Zugangsdaten anlegen“ direkt
|
||||||
anlegen, ohne vorher zur eigenen **Zugangsdaten**-Seite wechseln zu müssen.
|
im selben Formular ein neuer anlegen, ohne vorher zur eigenen
|
||||||
Löschen ist nur möglich, wenn kein Switch mehr auf den Eintrag verweist.
|
**Zugangsdaten**-Seite wechseln zu müssen. Löschen ist nur möglich, wenn
|
||||||
Bestehende Datenbanken aus einer älteren Version (Username/Passwort direkt
|
weder ein Switch noch ein Gerät mehr auf den Eintrag verweist. Bestehende
|
||||||
am Switch) werden beim ersten Start automatisch migriert.
|
Datenbanken aus einer älteren Version (Username/Passwort direkt am Switch)
|
||||||
|
werden beim ersten Start automatisch migriert.
|
||||||
|
|
||||||
|
Jede Zugangsdaten-Zeile hat außerdem eine explizite **Kategorie** (Switch /
|
||||||
|
Linux-Client / Windows-Client / Router-Sonstiges), vom Admin beim Anlegen
|
||||||
|
gesetzt — bewusst **nicht** automatisch per Ping/TTL-Heuristik erkannt (zu
|
||||||
|
unzuverlässig, siehe Diskussion in der Commit-Historie). Aktuell wertet nur
|
||||||
|
die Kategorie **Linux-Client** etwas aus: nur Geräte mit einer so
|
||||||
|
kategorisierten Zugangsdaten-Zuordnung erscheinen unter **Wartung** (siehe
|
||||||
|
unten). Windows/PowerShell-Wartung ist als eigener, separater Schritt
|
||||||
|
vorgesehen (kein Testsystem dafür verfügbar) — die Kategorie existiert
|
||||||
|
bereits, die Aktion dahinter noch nicht.
|
||||||
|
|
||||||
Jeder Switch kann außerdem einen individuellen **SSH-Port** hinterlegen
|
Jeder Switch kann außerdem einen individuellen **SSH-Port** hinterlegen
|
||||||
(Feld „SSH-Port“ beim Anlegen/Bearbeiten) — bleibt er leer, wird überall
|
(Feld „SSH-Port“ beim Anlegen/Bearbeiten) — bleibt er leer, wird überall
|
||||||
@@ -210,6 +230,67 @@ sich per SSH mit einem Switch verbindet: dem Web-Terminal (Verbindungstest),
|
|||||||
`expect`-Aufrufen in `poe.sh` selbst (`ssh -p <port> ...`) für automatische
|
`expect`-Aufrufen in `poe.sh` selbst (`ssh -p <port> ...`) für automatische
|
||||||
wie manuelle PoE-Neustarts.
|
wie manuelle PoE-Neustarts.
|
||||||
|
|
||||||
|
## Wartung (SSH-Bulk-Update & -Neustart)
|
||||||
|
|
||||||
|
Eigene Seite unter **Geräte → Wartung** (eigenes Rechtepaar
|
||||||
|
`maintenance.view`/`maintenance.edit`), komplett getrennt vom PoE-Neustart
|
||||||
|
auf dem Dashboard:
|
||||||
|
|
||||||
|
- **Dashboard-Neustart** = immer PoE-Reset über den Switch (`poe.sh`),
|
||||||
|
funktioniert unabhängig davon, ob das Gerät SSH-Zugangsdaten hat.
|
||||||
|
- **Wartung** = immer eine direkte SSH-Verbindung zum Gerät selbst,
|
||||||
|
funktioniert unabhängig davon, ob/an welchem Switch es hängt.
|
||||||
|
|
||||||
|
Nur Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie **Linux-Client**
|
||||||
|
(siehe oben) erscheinen in der Liste. Zwei Aktionen:
|
||||||
|
|
||||||
|
- **Update** (Mehrfachauswahl per Checkbox, ein Klick startet alle
|
||||||
|
ausgewählten parallel als eigene Hintergrund-Threads): führt serverseitig
|
||||||
|
`env DEBIAN_FRONTEND=noninteractive apt-get update && env
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::="--force-confdef"
|
||||||
|
-o Dpkg::Options::="--force-confold" upgrade` per SSH aus — nicht
|
||||||
|
interaktiv (kein Terminal, keine Rückfragemöglichkeit), die
|
||||||
|
`Dpkg::Options`-Flags verhindern, dass ein Paket-Postinst-Skript auf eine
|
||||||
|
Config-Datei-Rückfrage wartet, die nie kommt und sonst bis zum Timeout
|
||||||
|
hängen bliebe.
|
||||||
|
- **Neustart** (pro Gerät): `sudo reboot` per SSH. Ein abrupter
|
||||||
|
Verbindungsabbruch direkt danach ist erwartet (die Maschine fährt
|
||||||
|
herunter, bevor sie noch antworten kann) und zählt als Erfolg, kein
|
||||||
|
Fehler — nur ein Verbindungs-/Auth-Fehler **vor** dem eigentlichen
|
||||||
|
Neustart-Kommando gilt als echter Fehler.
|
||||||
|
|
||||||
|
Live-Status pro Gerät (läuft/erfolgreich/fehlgeschlagen inkl. Ausgabe der
|
||||||
|
letzten Aktion, per Klick ein-/ausblendbar) wird per Polling (`/maintenance/status`,
|
||||||
|
alle 2s solange etwas läuft, sonst alle 8s) aktualisiert, ohne die Seite neu
|
||||||
|
zu laden. Der Job-Status lebt bewusst nur im Prozessspeicher (wie beim
|
||||||
|
SSH-Terminal auch keine Sitzung persistiert wird) — ein Neustart von
|
||||||
|
`poe_web.service` verwirft nur die Anzeige, nicht die auf dem Zielgerät
|
||||||
|
bereits laufende Aktion selbst.
|
||||||
|
|
||||||
|
**Sicherheitsmodell**: dieselbe `known_hosts`-Datei wie beim
|
||||||
|
Browser-SSH-Terminal (`SSH_KNOWN_HOSTS_PATH`) — ein Host muss vorher
|
||||||
|
mindestens einmal interaktiv über „Verbindung testen“ bestätigt worden sein.
|
||||||
|
Anders als das interaktive Terminal (dort kann bei einem unbekannten
|
||||||
|
Host-Key wie bei einem echten `ssh`-Client live nachgefragt werden) läuft
|
||||||
|
hier nichts vor einem Menschen ab — ein unbekannter oder nicht vorher
|
||||||
|
bestätigter Host-Key wird deshalb per `paramiko.RejectPolicy` hart
|
||||||
|
abgelehnt statt automatisch akzeptiert, da ohne Rückfragemöglichkeit ein
|
||||||
|
automatisches Akzeptieren ein MITM-Risiko ohne jede Prüfung wäre.
|
||||||
|
|
||||||
|
Live gegen ein dediziertes Testsystem verifiziert (separat vom gemeinsam
|
||||||
|
genutzten App-Host, um dort kein echtes `apt upgrade` auszulösen): ein
|
||||||
|
echtes `apt update && apt upgrade` (inkl. Kernel-/systemd-/netplan-Paketen)
|
||||||
|
lief nicht-interaktiv vollständig durch (`status: success`, komplette
|
||||||
|
`apt`-Ausgabe im Job-Verlauf sichtbar), anschließend hat der SSH-Neustart
|
||||||
|
das Testsystem tatsächlich neu gestartet — bestätigt über `uptime -s`
|
||||||
|
direkt nach dem Job (Boot-Zeitpunkt stimmte mit dem Auslöse-Zeitpunkt
|
||||||
|
überein, nicht nur die vom Code erwartete Verbindungsunterbrechung).
|
||||||
|
|
||||||
|
Windows-Clients/PowerShell-Wartung ist bewusst **noch nicht** umgesetzt
|
||||||
|
(kein Testsystem verfügbar) — die Kategorie „Windows-Client“ existiert in
|
||||||
|
den Zugangsdaten bereits als Vorbereitung für einen späteren, separaten
|
||||||
|
Ausbauschritt.
|
||||||
|
|
||||||
## DHCP-Reservierungen & eigene Options (Systemeinstellungen → DHCP)
|
## DHCP-Reservierungen & eigene Options (Systemeinstellungen → DHCP)
|
||||||
|
|
||||||
Erzeugt aus den vorhandenen Client-Stammdaten (MAC + IP + Name) automatisch
|
Erzeugt aus den vorhandenen Client-Stammdaten (MAC + IP + Name) automatisch
|
||||||
@@ -516,7 +597,8 @@ verursachen, obwohl der Code auf dem Server längst aktuell war.
|
|||||||
|
|
||||||
Die Sidebar ist in drei aufklappbare Gruppen strukturiert:
|
Die Sidebar ist in drei aufklappbare Gruppen strukturiert:
|
||||||
|
|
||||||
- **Geräte** — Clients (die eigentlichen Devices), Switche, Zugangsdaten
|
- **Geräte** — Clients (die eigentlichen Devices), Switche, Zugangsdaten,
|
||||||
|
Wartung (SSH-Bulk-Update/-Neustart)
|
||||||
- **Einstellungen** — Benutzer, Gruppen, Systemeinstellungen (Prüfintervall),
|
- **Einstellungen** — Benutzer, Gruppen, Systemeinstellungen (Prüfintervall),
|
||||||
Im-/Export
|
Im-/Export
|
||||||
- **Logs** — Live, Änderungen (Änderungslog)
|
- **Logs** — Live, Änderungen (Änderungslog)
|
||||||
|
|||||||
+366
-1
@@ -18,7 +18,7 @@ from cryptography.fernet import Fernet
|
|||||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
from cryptography.hazmat.primitives import hashes
|
from cryptography.hazmat.primitives import hashes
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import base64, csv, ipaddress, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
|
import base64, csv, ipaddress, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
|
||||||
import paramiko
|
import paramiko
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -196,6 +196,17 @@ PERMISSIONS = {
|
|||||||
"edit": "credentials.edit",
|
"edit": "credentials.edit",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"maintenance": {
|
||||||
|
"label": "Wartung",
|
||||||
|
# Kein eigenes Löschen — "Ändern" (E) steht hier für das
|
||||||
|
# Ausführen von Wartungsaktionen (SSH-Update, SSH-Neustart),
|
||||||
|
# nicht für Löschen. Getrennt von devices.restart (D oben):
|
||||||
|
# das ist ausschließlich der PoE-Neustart über den Switch.
|
||||||
|
"rows": {
|
||||||
|
"view": "maintenance.view",
|
||||||
|
"edit": "maintenance.edit",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"logs_group": {
|
"logs_group": {
|
||||||
@@ -258,6 +269,8 @@ PERMISSION_LABELS = {
|
|||||||
"switches.edit": "Switche ändern (inkl. Löschen)",
|
"switches.edit": "Switche ändern (inkl. Löschen)",
|
||||||
"credentials.view": "Zugangsdaten lesen", "credentials.create": "Zugangsdaten anlegen",
|
"credentials.view": "Zugangsdaten lesen", "credentials.create": "Zugangsdaten anlegen",
|
||||||
"credentials.edit": "Zugangsdaten ändern (inkl. Löschen)",
|
"credentials.edit": "Zugangsdaten ändern (inkl. Löschen)",
|
||||||
|
"maintenance.view": "Wartungsseite ansehen (Geräte-Status, Job-Verlauf)",
|
||||||
|
"maintenance.edit": "Wartungsaktionen ausführen (SSH-Update, SSH-Neustart)",
|
||||||
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog lesen",
|
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog lesen",
|
||||||
"users.view": "Benutzer lesen", "users.create": "Benutzer anlegen",
|
"users.view": "Benutzer lesen", "users.create": "Benutzer anlegen",
|
||||||
"users.edit": "Benutzer ändern (inkl. Löschen)",
|
"users.edit": "Benutzer ändern (inkl. Löschen)",
|
||||||
@@ -317,6 +330,7 @@ NAV_ITEMS = [
|
|||||||
{"key": "devices", "label": "Clients", "icon": "cpu", "endpoint": "devices"},
|
{"key": "devices", "label": "Clients", "icon": "cpu", "endpoint": "devices"},
|
||||||
{"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"},
|
{"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"},
|
||||||
{"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"},
|
{"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"},
|
||||||
|
{"key": "maintenance", "label": "Wartung", "icon": "wrench", "endpoint": "maintenance"},
|
||||||
]},
|
]},
|
||||||
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
|
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
|
||||||
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
|
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
|
||||||
@@ -343,6 +357,8 @@ def _nav_key_visible(key, user):
|
|||||||
return user.can_manage_switches
|
return user.can_manage_switches
|
||||||
if key == "credentials":
|
if key == "credentials":
|
||||||
return user.can_manage_credentials
|
return user.can_manage_credentials
|
||||||
|
if key == "maintenance":
|
||||||
|
return user.can_view_maintenance
|
||||||
if key == "users":
|
if key == "users":
|
||||||
return user.can_manage_users
|
return user.can_manage_users
|
||||||
if key == "groups":
|
if key == "groups":
|
||||||
@@ -468,6 +484,14 @@ class User(UserMixin):
|
|||||||
["credentials.view", "credentials.create", "credentials.edit"]
|
["credentials.view", "credentials.create", "credentials.edit"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_view_maintenance(self):
|
||||||
|
return self.has_any_permission(["maintenance.view", "maintenance.edit"])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_run_maintenance(self):
|
||||||
|
return self.has_permission("maintenance.edit")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def can_view_live_log(self):
|
def can_view_live_log(self):
|
||||||
return self.has_permission("logs_live.view")
|
return self.has_permission("logs_live.view")
|
||||||
@@ -3321,6 +3345,183 @@ def credentials():
|
|||||||
return render_template("credentials.html", credentials=credential_rows, categories=CREDENTIAL_CATEGORIES)
|
return render_template("credentials.html", credentials=credential_rows, categories=CREDENTIAL_CATEGORIES)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Wartung (Clients) — SSH-Bulk-Update + Neustart für Linux-Clients.
|
||||||
|
#
|
||||||
|
# Bewusst komplett getrennt vom PoE-Neustart auf dem Dashboard: der
|
||||||
|
# Dashboard-Button (devices.restart) löst IMMER einen PoE-Reset über den
|
||||||
|
# zugeordneten Switch aus. Die Aktionen hier laufen IMMER per SSH direkt
|
||||||
|
# auf dem Gerät selbst -- unabhängig davon, ob/wie es an einen Switch
|
||||||
|
# angeschlossen ist. Job-Status lebt bewusst nur im Prozessspeicher (wie
|
||||||
|
# das SSH-Terminal auch keine Sitzung persistiert) -- ein Neustart des
|
||||||
|
# poe_web.service verwirft laufende Job-Anzeigen, nicht aber die
|
||||||
|
# zugrundeliegende SSH-Aktion selbst (die läuft serverseitig auf dem
|
||||||
|
# Zielgerät unabhängig vom Anzeigezustand hier weiter).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_maintenance_jobs_lock = threading.Lock()
|
||||||
|
# mac -> {"action": "update"/"reboot", "status": "running"/"success"/"error",
|
||||||
|
# "message": str, "output": str, "started": str, "finished": str|None}
|
||||||
|
_maintenance_jobs = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _maintenance_job_set(mac, **fields):
|
||||||
|
with _maintenance_jobs_lock:
|
||||||
|
job = _maintenance_jobs.setdefault(mac, {})
|
||||||
|
job.update(fields)
|
||||||
|
|
||||||
|
|
||||||
|
def _maintenance_job_immediate_error(mac, action, message):
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
_maintenance_job_set(mac, action=action, status="error", message=message, output="", started=now, finished=now)
|
||||||
|
|
||||||
|
|
||||||
|
def _maintenance_devices(conn):
|
||||||
|
"""Alle Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie
|
||||||
|
DEVICE_MAINTENANCE_CATEGORY (aktuell nur 'linux') -- nur für diese
|
||||||
|
bietet 'Wartung' überhaupt eine Aktion an (Windows/PowerShell folgt
|
||||||
|
als eigener, separater Schritt, siehe CREDENTIAL_CATEGORIES-Kommentar)."""
|
||||||
|
return conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT devices.mac, devices.name, devices.rpi_ip, devices.ssh_port,
|
||||||
|
devices.credential_id, devices.is_active,
|
||||||
|
credentials.username AS cred_username
|
||||||
|
FROM devices
|
||||||
|
JOIN credentials ON credentials.id = devices.credential_id
|
||||||
|
WHERE credentials.category = ?
|
||||||
|
ORDER BY devices.name ASC
|
||||||
|
""",
|
||||||
|
(DEVICE_MAINTENANCE_CATEGORY,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/maintenance")
|
||||||
|
@login_required
|
||||||
|
def maintenance():
|
||||||
|
if not current_user.can_view_maintenance:
|
||||||
|
flash("Keine Berechtigung für die Wartungsseite.", "danger")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
conn = get_db_connection()
|
||||||
|
devices_rows = _maintenance_devices(conn)
|
||||||
|
conn.close()
|
||||||
|
with _maintenance_jobs_lock:
|
||||||
|
jobs_snapshot = {mac: dict(job) for mac, job in _maintenance_jobs.items()}
|
||||||
|
return render_template(
|
||||||
|
"maintenance.html",
|
||||||
|
devices=devices_rows,
|
||||||
|
jobs=jobs_snapshot,
|
||||||
|
can_run=current_user.can_run_maintenance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/maintenance/status")
|
||||||
|
@login_required
|
||||||
|
def maintenance_status():
|
||||||
|
if not current_user.can_view_maintenance:
|
||||||
|
return jsonify({"error": "Keine Berechtigung."}), 403
|
||||||
|
with _maintenance_jobs_lock:
|
||||||
|
return jsonify({mac: dict(job) for mac, job in _maintenance_jobs.items()})
|
||||||
|
|
||||||
|
|
||||||
|
def _maintenance_run_update(mac, name, host, port, username, password):
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
_maintenance_job_set(
|
||||||
|
mac, action="update", status="running", message="Update läuft …",
|
||||||
|
output="", started=now, finished=None,
|
||||||
|
)
|
||||||
|
# timeout=600: ein echtes "apt upgrade" kann bei vielen ausstehenden
|
||||||
|
# Paketen mehrere Minuten dauern -- der kurze Standard-Timeout der
|
||||||
|
# interaktiven SSH-Aktionen (25s) wäre hier viel zu knapp.
|
||||||
|
result = _run_ssh_command(
|
||||||
|
host, port, username, password, _apt_upgrade_command(),
|
||||||
|
sudo_password=password, timeout=600,
|
||||||
|
)
|
||||||
|
finished = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
combined_output = (result.get("stdout", "") + result.get("stderr", ""))[-6000:]
|
||||||
|
if result["error"]:
|
||||||
|
_maintenance_job_set(mac, status="error", message=result["error"], output=combined_output, finished=finished)
|
||||||
|
log_action_system("maintenance.update", name, f"fehlgeschlagen: {result['error']}")
|
||||||
|
elif result["success"]:
|
||||||
|
_maintenance_job_set(mac, status="success", message="Update erfolgreich abgeschlossen.", output=combined_output, finished=finished)
|
||||||
|
log_action_system("maintenance.update", name, "erfolgreich")
|
||||||
|
else:
|
||||||
|
_maintenance_job_set(
|
||||||
|
mac, status="error",
|
||||||
|
message=f"Update fehlgeschlagen (Exit-Code {result['exit_code']}).",
|
||||||
|
output=combined_output, finished=finished,
|
||||||
|
)
|
||||||
|
log_action_system("maintenance.update", name, f"fehlgeschlagen: Exit-Code {result['exit_code']}")
|
||||||
|
|
||||||
|
|
||||||
|
def _maintenance_run_reboot(mac, name, host, port, username, password):
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
_maintenance_job_set(mac, action="reboot", status="running", message="Neustart wird ausgelöst …", output="", started=now, finished=None)
|
||||||
|
result = _run_ssh_reboot(host, port, username, password)
|
||||||
|
finished = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
if result["error"]:
|
||||||
|
_maintenance_job_set(mac, status="error", message=result["error"], finished=finished)
|
||||||
|
log_action_system("maintenance.reboot", name, f"fehlgeschlagen: {result['error']}")
|
||||||
|
else:
|
||||||
|
_maintenance_job_set(mac, status="success", message="Neustart ausgelöst.", finished=finished)
|
||||||
|
log_action_system("maintenance.reboot", name, "ausgelöst")
|
||||||
|
|
||||||
|
|
||||||
|
def _maintenance_dispatch(action_name, thread_target):
|
||||||
|
"""Gemeinsame Logik für /maintenance/update und /maintenance/reboot:
|
||||||
|
ausgewählte, berechtigte MACs auflösen, bereits laufende überspringen,
|
||||||
|
je Gerät einen Hintergrund-Thread starten."""
|
||||||
|
macs = set(request.form.getlist("macs"))
|
||||||
|
if not macs:
|
||||||
|
flash("Bitte mindestens ein Gerät auswählen.", "danger")
|
||||||
|
return redirect(url_for("maintenance"))
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
eligible = {d["mac"]: d for d in _maintenance_devices(conn) if d["mac"] in macs}
|
||||||
|
|
||||||
|
started = []
|
||||||
|
for mac, device in eligible.items():
|
||||||
|
with _maintenance_jobs_lock:
|
||||||
|
existing = _maintenance_jobs.get(mac)
|
||||||
|
if existing and existing.get("status") == "running":
|
||||||
|
continue
|
||||||
|
host, port, username, password, error = _resolve_device_ssh_target(conn, device)
|
||||||
|
if error:
|
||||||
|
_maintenance_job_immediate_error(mac, action_name, error)
|
||||||
|
continue
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=thread_target,
|
||||||
|
args=(mac, device["name"], host, port, username, password),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
started.append(device["name"])
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if started:
|
||||||
|
flash(f"{action_name.capitalize()} gestartet für: {', '.join(started)}", "success")
|
||||||
|
else:
|
||||||
|
flash("Keine Aktion gestartet (bereits laufend oder Zugangsdaten fehlerhaft).", "danger")
|
||||||
|
return redirect(url_for("maintenance"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/maintenance/update", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def maintenance_update():
|
||||||
|
if not current_user.can_run_maintenance:
|
||||||
|
flash("Keine Berechtigung, Wartungsaktionen auszuführen.", "danger")
|
||||||
|
return redirect(url_for("maintenance"))
|
||||||
|
return _maintenance_dispatch("update", _maintenance_run_update)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/maintenance/reboot", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def maintenance_reboot():
|
||||||
|
if not current_user.can_run_maintenance:
|
||||||
|
flash("Keine Berechtigung, Wartungsaktionen auszuführen.", "danger")
|
||||||
|
return redirect(url_for("maintenance"))
|
||||||
|
return _maintenance_dispatch("reboot", _maintenance_run_reboot)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Browser-SSH-Terminal — Verbindungstest beim Anlegen/Bearbeiten von Switchen.
|
# Browser-SSH-Terminal — Verbindungstest beim Anlegen/Bearbeiten von Switchen.
|
||||||
#
|
#
|
||||||
@@ -3623,6 +3824,170 @@ def _ssh_terminal_inner(ws):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Wartung: nicht-interaktive SSH-Kommandos (Bulk-Update, Neustart)
|
||||||
|
#
|
||||||
|
# Bewusst getrennt von _ssh_terminal_inner() oben: dort kann bei einem
|
||||||
|
# unbekannten Host-Key interaktiv "yes/no" nachgefragt werden (wie ein
|
||||||
|
# echtes ssh-CLI). Hier läuft nichts vor einem Menschen ab (Bulk-Aktion über
|
||||||
|
# mehrere Geräte per Thread) -- ein unbekannter/nicht vorher bestätigter
|
||||||
|
# Host-Key wird deshalb hart abgelehnt (RejectPolicy) statt automatisch
|
||||||
|
# akzeptiert (AutoAddPolicy wäre hier ein MITM-Risiko ohne jede Prüfung).
|
||||||
|
# Ein Host muss also immer zuerst einmal über "Verbindung testen" (das
|
||||||
|
# interaktive Terminal) bestätigt worden sein, bevor Wartungsaktionen für
|
||||||
|
# ihn möglich sind -- beide Wege teilen sich dieselbe SSH_KNOWN_HOSTS_PATH.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _apt_upgrade_command():
|
||||||
|
"""Sicheres, nicht-interaktives 'apt update && apt upgrade -y':
|
||||||
|
DEBIAN_FRONTEND=noninteractive plus --force-confdef/--force-confold
|
||||||
|
verhindern, dass ein Paket-Postinst-Skript auf eine Konfig-Datei-
|
||||||
|
Rückfrage wartet, die nie kommt (das Kommando würde sonst ohne PTY
|
||||||
|
unbeaufsichtigt hängen bleiben, bis der Timeout zuschlägt)."""
|
||||||
|
inner = (
|
||||||
|
"env DEBIAN_FRONTEND=noninteractive apt-get update && "
|
||||||
|
'env DEBIAN_FRONTEND=noninteractive apt-get -y '
|
||||||
|
'-o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade'
|
||||||
|
)
|
||||||
|
return f"sudo -S -p '' bash -c {shlex.quote(inner)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_device_ssh_target(conn, device):
|
||||||
|
"""Löst Host/Port/Zugangsdaten für eine SSH-Wartungsaktion an einem
|
||||||
|
Gerät auf. Gibt (host, port, username, password, error) zurück -- bei
|
||||||
|
einem Fehler ist error gesetzt und die übrigen Werte None."""
|
||||||
|
if not device["credential_id"]:
|
||||||
|
return None, None, None, None, f"{device['name']}: keine SSH-Zugangsdaten hinterlegt."
|
||||||
|
cred = conn.execute(
|
||||||
|
"SELECT username, password FROM credentials WHERE id=?", (device["credential_id"],)
|
||||||
|
).fetchone()
|
||||||
|
if not cred:
|
||||||
|
return None, None, None, None, f"{device['name']}: hinterlegte Zugangsdaten existieren nicht mehr."
|
||||||
|
port = device["ssh_port"] or SWITCH_DEFAULT_SSH_PORT
|
||||||
|
try:
|
||||||
|
password = decrypt_password(cred["password"])
|
||||||
|
except Exception:
|
||||||
|
return None, None, None, None, f"{device['name']}: Zugangsdaten konnten nicht entschlüsselt werden."
|
||||||
|
return device["rpi_ip"], port, cred["username"], password, None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ssh_command(host, port, username, password, command, sudo_password=None, timeout=25):
|
||||||
|
"""Führt EIN nicht-interaktives Kommando per SSH aus (kein Terminal,
|
||||||
|
keine Rückfragemöglichkeit). Gibt ein dict zurück:
|
||||||
|
{"success": bool, "exit_code": int|None, "stdout": str, "stderr": str,
|
||||||
|
"error": str|None} -- "error" ist nur bei Verbindungs-/Auth-Fehlern
|
||||||
|
gesetzt, ein regulär durchgelaufenes aber fehlgeschlagenes Kommando
|
||||||
|
(exit_code != 0) zählt als success=False mit leerem error."""
|
||||||
|
result = {"success": False, "exit_code": None, "stdout": "", "stderr": "", "error": None}
|
||||||
|
|
||||||
|
if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
|
||||||
|
result["error"] = (
|
||||||
|
"Host-Key nicht bekannt — bitte zuerst per 'Verbindung testen' "
|
||||||
|
"einmal interaktiv verbinden und den Host-Key bestätigen."
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.load_host_keys(SSH_KNOWN_HOSTS_PATH)
|
||||||
|
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.connect(
|
||||||
|
host, port=port, username=username, password=password,
|
||||||
|
timeout=timeout, banner_timeout=timeout, auth_timeout=timeout,
|
||||||
|
look_for_keys=False, allow_agent=False,
|
||||||
|
)
|
||||||
|
except paramiko.BadHostKeyException:
|
||||||
|
result["error"] = "Host-Key hat sich geändert — Verbindung abgelehnt (möglicher Man-in-the-Middle)."
|
||||||
|
return result
|
||||||
|
except paramiko.AuthenticationException:
|
||||||
|
result["error"] = "Authentifizierung fehlgeschlagen (falsche Zugangsdaten)."
|
||||||
|
return result
|
||||||
|
except paramiko.SSHException as e:
|
||||||
|
result["error"] = f"Host-Key unbekannt — bitte zuerst per 'Verbindung testen' bestätigen ({e})."
|
||||||
|
return result
|
||||||
|
except (OSError, socket.error) as e:
|
||||||
|
result["error"] = f"Verbindung fehlgeschlagen: {e}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
|
||||||
|
if sudo_password is not None:
|
||||||
|
try:
|
||||||
|
stdin.write(sudo_password + "\n")
|
||||||
|
stdin.flush()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
result["exit_code"] = stdout.channel.recv_exit_status()
|
||||||
|
result["stdout"] = stdout.read().decode("utf-8", errors="replace")
|
||||||
|
result["stderr"] = stderr.read().decode("utf-8", errors="replace")
|
||||||
|
result["success"] = result["exit_code"] == 0
|
||||||
|
except (paramiko.SSHException, OSError) as e:
|
||||||
|
result["error"] = f"Kommando fehlgeschlagen: {e}"
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ssh_reboot(host, port, username, password, timeout=15):
|
||||||
|
"""Startet 'sudo reboot' per SSH. Verbindungs-/Auth-Fehler VOR dem
|
||||||
|
eigentlichen Neustart-Kommando gelten als echter Fehler; ein abrupter
|
||||||
|
Verbindungsabbruch WÄHREND/NACH dem Kommando ist dagegen erwartet (die
|
||||||
|
Maschine fährt herunter, bevor sie überhaupt noch antworten kann) und
|
||||||
|
zählt als Erfolg."""
|
||||||
|
result = {"success": False, "error": None}
|
||||||
|
|
||||||
|
if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
|
||||||
|
result["error"] = (
|
||||||
|
"Host-Key nicht bekannt — bitte zuerst per 'Verbindung testen' "
|
||||||
|
"einmal interaktiv verbinden und den Host-Key bestätigen."
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.load_host_keys(SSH_KNOWN_HOSTS_PATH)
|
||||||
|
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.connect(
|
||||||
|
host, port=port, username=username, password=password,
|
||||||
|
timeout=timeout, banner_timeout=timeout, auth_timeout=timeout,
|
||||||
|
look_for_keys=False, allow_agent=False,
|
||||||
|
)
|
||||||
|
except paramiko.BadHostKeyException:
|
||||||
|
result["error"] = "Host-Key hat sich geändert — Verbindung abgelehnt (möglicher Man-in-the-Middle)."
|
||||||
|
return result
|
||||||
|
except paramiko.AuthenticationException:
|
||||||
|
result["error"] = "Authentifizierung fehlgeschlagen (falsche Zugangsdaten)."
|
||||||
|
return result
|
||||||
|
except paramiko.SSHException as e:
|
||||||
|
result["error"] = f"Host-Key unbekannt — bitte zuerst per 'Verbindung testen' bestätigen ({e})."
|
||||||
|
return result
|
||||||
|
except (OSError, socket.error) as e:
|
||||||
|
result["error"] = f"Verbindung fehlgeschlagen: {e}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdin, stdout, stderr = client.exec_command("sudo -S -p '' reboot", timeout=timeout)
|
||||||
|
stdin.write(password + "\n")
|
||||||
|
stdin.flush()
|
||||||
|
stdout.channel.recv_exit_status()
|
||||||
|
except (paramiko.SSHException, OSError, EOFError):
|
||||||
|
pass # Verbindungsabbruch durch den Neustart selbst ist erwartet
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result["success"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Logs
|
# Logs
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -750,6 +750,21 @@ table.data-table {
|
|||||||
.row-actions { display: flex; align-items: center; gap: 6px; flex-wrap: nowrap; }
|
.row-actions { display: flex; align-items: center; gap: 6px; flex-wrap: nowrap; }
|
||||||
.row-actions form { display: flex; }
|
.row-actions form { display: flex; }
|
||||||
|
|
||||||
|
.job-output-pre {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin: 6px 0;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-row td {
|
.empty-row td {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px 16px;
|
padding: 40px 16px;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
|
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
|
||||||
"transfer": '<path d="M17 3l4 4-4 4"/><path d="M3 7h18"/><path d="M7 21l-4-4 4-4"/><path d="M21 17H3"/>',
|
"transfer": '<path d="M17 3l4 4-4 4"/><path d="M3 7h18"/><path d="M7 21l-4-4 4-4"/><path d="M21 17H3"/>',
|
||||||
"network": '<rect x="9" y="2" width="6" height="6" rx="1.2"/><rect x="2" y="16" width="6" height="6" rx="1.2"/><rect x="16" y="16" width="6" height="6" rx="1.2"/><path d="M12 8v4M12 12H5v4M12 12h7v4"/>',
|
"network": '<rect x="9" y="2" width="6" height="6" rx="1.2"/><rect x="2" y="16" width="6" height="6" rx="1.2"/><rect x="16" y="16" width="6" height="6" rx="1.2"/><path d="M12 8v4M12 12H5v4M12 12h7v4"/>',
|
||||||
|
"wrench": '<path d="M14.7 6.3a4 4 0 11-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 015.4-5.4z"/>',
|
||||||
} %}
|
} %}
|
||||||
|
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "maintenance" %}
|
||||||
|
{% block page_title %}Wartung{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Wartung (SSH)</h2>
|
||||||
|
<div class="hint">
|
||||||
|
Bulk-Update (<code>apt update & upgrade -y</code>) und Neustart per SSH direkt auf dem Gerät —
|
||||||
|
unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten
|
||||||
|
SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not devices %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">
|
||||||
|
Noch keine Geräte für die Wartung konfiguriert. Lege unter
|
||||||
|
<a href="{{ url_for('devices') }}" style="color:var(--accent-strong); font-weight:600;">Clients</a>
|
||||||
|
SSH-Zugangsdaten der Kategorie „Linux-Client“ an einem Gerät an — es erscheint danach automatisch hier.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('maintenance_update') }}" id="updateForm">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="data-table" data-sortable>
|
||||||
|
<thead><tr>
|
||||||
|
{% if can_run %}<th style="width:1%;"><input type="checkbox" id="selectAll" title="Alle auswählen"></th>{% endif %}
|
||||||
|
<th data-sort-key="name">Name</th>
|
||||||
|
<th data-sort-key="ip">IP-Adresse</th>
|
||||||
|
<th data-sort-key="user">SSH-User</th>
|
||||||
|
<th data-sort-key="status">Status</th>
|
||||||
|
<th style="width:1%;">Aktionen</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in devices %}
|
||||||
|
{% set job = jobs.get(d['mac']) %}
|
||||||
|
<tr data-mac="{{ d['mac'] }}" data-sort-name="{{ d['name']|lower }}" data-sort-ip="{{ d['rpi_ip'] }}" data-sort-user="{{ d['cred_username']|lower }}">
|
||||||
|
{% if can_run %}
|
||||||
|
<td><input type="checkbox" name="macs" value="{{ d['mac'] }}" class="maint-check" {{ 'disabled' if not d['is_active'] else '' }}></td>
|
||||||
|
{% endif %}
|
||||||
|
<td class="cell-name">{{ d['name'] }}{% if not d['is_active'] %} <span class="text-faint" style="font-size:11px;">(deaktiviert)</span>{% endif %}</td>
|
||||||
|
<td class="mono">{{ d['rpi_ip'] }}:{{ d['ssh_port'] or 22 }}</td>
|
||||||
|
<td class="mono">{{ d['cred_username'] }}</td>
|
||||||
|
<td class="maint-status">
|
||||||
|
{% if job %}
|
||||||
|
{% if job.status == 'running' %}<span class="pill unknown"><span class="spinner"></span> {{ job.message }}</span>
|
||||||
|
{% elif job.status == 'success' %}<span class="pill online">{{ job.message }}</span>
|
||||||
|
{% else %}<span class="pill offline">{{ job.message }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="pill disabled">Noch keine Aktion</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" class="icon-btn" title="Ausgabe anzeigen/verbergen" onclick="toggleJobOutput('{{ d['mac'] }}')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
</button>
|
||||||
|
{% if can_run %}
|
||||||
|
<form method="post" action="{{ url_for('maintenance_reboot') }}" data-confirm="{{ d['name'] }} ({{ d['rpi_ip'] }}) jetzt per SSH neu starten?">
|
||||||
|
<input type="hidden" name="macs" value="{{ d['mac'] }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-secondary">Neustart</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="job-output-row" id="joboutput-{{ d['mac'] }}" style="display:none;">
|
||||||
|
<td colspan="{{ 6 if can_run else 5 }}">
|
||||||
|
<pre class="job-output-pre">{{ job.output if job and job.output else 'Keine Ausgabe.' }}</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_run %}
|
||||||
|
<div class="section-head" style="margin-top:16px;">
|
||||||
|
<div class="hint">Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet).</div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||||
|
Update starten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.getElementById("selectAll")?.addEventListener("change", function () {
|
||||||
|
document.querySelectorAll(".maint-check").forEach(function (cb) {
|
||||||
|
if (!cb.disabled) cb.checked = this.checked;
|
||||||
|
}, this);
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleJobOutput(mac) {
|
||||||
|
const row = document.getElementById("joboutput-" + mac);
|
||||||
|
if (row) row.style.display = (row.style.display === "none" || !row.style.display) ? "table-row" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPillHtml(job) {
|
||||||
|
if (!job) return '<span class="pill disabled">Noch keine Aktion</span>';
|
||||||
|
const msg = job.message || "";
|
||||||
|
if (job.status === "running") return '<span class="pill unknown"><span class="spinner"></span> ' + msg + '</span>';
|
||||||
|
if (job.status === "success") return '<span class="pill online">' + msg + '</span>';
|
||||||
|
return '<span class="pill offline">' + msg + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
let maintPollTimer = null;
|
||||||
|
function pollMaintenanceStatus() {
|
||||||
|
fetch("{{ url_for('maintenance_status') }}")
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (jobs) {
|
||||||
|
let anyRunning = false;
|
||||||
|
document.querySelectorAll("tr[data-mac]").forEach(function (row) {
|
||||||
|
const mac = row.dataset.mac;
|
||||||
|
const job = jobs[mac];
|
||||||
|
const cell = row.querySelector(".maint-status");
|
||||||
|
if (cell) cell.innerHTML = statusPillHtml(job);
|
||||||
|
const outRow = document.getElementById("joboutput-" + mac);
|
||||||
|
if (outRow && job) {
|
||||||
|
const pre = outRow.querySelector(".job-output-pre");
|
||||||
|
if (pre) pre.textContent = job.output || "Keine Ausgabe.";
|
||||||
|
}
|
||||||
|
if (job && job.status === "running") anyRunning = true;
|
||||||
|
});
|
||||||
|
clearTimeout(maintPollTimer);
|
||||||
|
maintPollTimer = setTimeout(pollMaintenanceStatus, anyRunning ? 2000 : 8000);
|
||||||
|
})
|
||||||
|
.catch(function () { maintPollTimer = setTimeout(pollMaintenanceStatus, 8000); });
|
||||||
|
}
|
||||||
|
pollMaintenanceStatus();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user