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:
2026-08-12 11:12:07 +02:00
co-authored by Claude Sonnet 5
parent 63ca7782ba
commit 8772f7777b
5 changed files with 620 additions and 13 deletions
+366 -1
View File
@@ -18,7 +18,7 @@ from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
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 yaml
@@ -196,6 +196,17 @@ PERMISSIONS = {
"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": {
@@ -258,6 +269,8 @@ PERMISSION_LABELS = {
"switches.edit": "Switche ändern (inkl. Löschen)",
"credentials.view": "Zugangsdaten lesen", "credentials.create": "Zugangsdaten anlegen",
"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",
"users.view": "Benutzer lesen", "users.create": "Benutzer anlegen",
"users.edit": "Benutzer ändern (inkl. Löschen)",
@@ -317,6 +330,7 @@ NAV_ITEMS = [
{"key": "devices", "label": "Clients", "icon": "cpu", "endpoint": "devices"},
{"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"},
{"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": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
@@ -343,6 +357,8 @@ def _nav_key_visible(key, user):
return user.can_manage_switches
if key == "credentials":
return user.can_manage_credentials
if key == "maintenance":
return user.can_view_maintenance
if key == "users":
return user.can_manage_users
if key == "groups":
@@ -468,6 +484,14 @@ class User(UserMixin):
["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
def can_view_live_log(self):
return self.has_permission("logs_live.view")
@@ -3321,6 +3345,183 @@ def credentials():
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.
#
@@ -3623,6 +3824,170 @@ def _ssh_terminal_inner(ws):
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
# ---------------------------------------------------------------------------
+15
View File
@@ -750,6 +750,21 @@ table.data-table {
.row-actions { display: flex; align-items: center; gap: 6px; flex-wrap: nowrap; }
.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 {
text-align: center;
padding: 40px 16px;
+1
View File
@@ -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"/>',
"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"/>',
"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">
+144
View File
@@ -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 &amp; 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 %}