Wartung: Online-Watchdog nach Neustart, Status-Reset, Zugangsdaten-Spalte

- Nach einem ausgelösten SSH-Neustart blieb der Job-Status bisher für
  immer auf "Neustart ausgelöst" stehen -- ergänzt um einen Ping-
  Watchdog (gleiche Invocation wie das bestehende Monitoring in
  poe.sh: "ping -c 1 -W 2"), der nach einer kurzen Schonfrist (15s)
  regelmäßig prüft, bis das Gerät wieder antwortet (Timeout 5min).
  Status wechselt dann sichtbar auf "wieder online" bzw. auf einen
  Fehler bei Zeitüberschreitung.
- Ein abgeschlossener ERFOLGREICHER Job wird bei jedem (erneuten) Laden
  der Wartungsseite zurückgesetzt (wieder "Noch keine Aktion") -- ein
  Erfolg ist nur relevant, solange man ihn live mitverfolgt. Fehler
  bleiben bewusst bis zur nächsten Aktion sichtbar, um sie nicht durch
  einen einfachen Reload zu übersehen.
- Clients-Tabelle zeigt jetzt wie die Switche-Tabelle eine
  "Zugangsdaten"-Spalte (Name + Username oder "— keine —").

Live gegen das dedizierte Testsystem verifiziert: zwei echte Neustarts,
beide per uptime -s bestätigt (kein False-Positive-Ping gegen die noch
nicht heruntergefahrene alte Instanz), Status korrekt von "wartet auf
Online" auf "wieder online" gewechselt; Status-Reset beim Seitenaufruf
ebenfalls live bestätigt (sichtbar vor, leer nach einem GET /maintenance).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 11:35:51 +02:00
co-authored by Claude Sonnet 5
parent 8772f7777b
commit 3b2a5fc76c
3 changed files with 116 additions and 18 deletions
+35 -10
View File
@@ -221,6 +221,11 @@ unten). Windows/PowerShell-Wartung ist als eigener, separater Schritt
vorgesehen (kein Testsystem dafür verfügbar) — die Kategorie existiert vorgesehen (kein Testsystem dafür verfügbar) — die Kategorie existiert
bereits, die Aktion dahinter noch nicht. bereits, die Aktion dahinter noch nicht.
Sowohl die Switche- als auch die Clients-Tabelle zeigen dieselbe
„Zugangsdaten“-Spalte (Name + Username, oder „— keine —“), damit auf einen
Blick erkennbar ist, welches Gerät bereits per SSH verwaltet werden kann,
ohne extra zur Zugangsdaten-Seite wechseln zu müssen.
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
automatisch **Port 22** angenommen (`SWITCH_DEFAULT_SSH_PORT` in `app.py`). automatisch **Port 22** angenommen (`SWITCH_DEFAULT_SSH_PORT` in `app.py`).
@@ -253,11 +258,20 @@ Nur Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie **Linux-Client**
`Dpkg::Options`-Flags verhindern, dass ein Paket-Postinst-Skript auf eine `Dpkg::Options`-Flags verhindern, dass ein Paket-Postinst-Skript auf eine
Config-Datei-Rückfrage wartet, die nie kommt und sonst bis zum Timeout Config-Datei-Rückfrage wartet, die nie kommt und sonst bis zum Timeout
hängen bliebe. hängen bliebe.
- **Neustart** (pro Gerät): `sudo reboot` per SSH. Ein abrupter - **Neustart** (pro Gerät): `sudo reboot` per SSH, danach automatisch
Verbindungsabbruch direkt danach ist erwartet (die Maschine fährt weiterverfolgt bis das Gerät wieder online ist — ohne das bliebe der
herunter, bevor sie noch antworten kann) und zählt als Erfolg, kein Status für immer auf „Neustart ausgelöst“ stehen, obwohl der eigentlich
Fehler — nur ein Verbindungs-/Auth-Fehler **vor** dem eigentlichen interessante Zeitpunkt (kommt das Gerät zurück?) noch gar nicht erfasst
Neustart-Kommando gilt als echter Fehler. wäre. Ablauf: das SSH-Neustart-Kommando selbst gilt als erfolgreich
ausgelöst, sobald die Verbindung abbricht (erwartet — die Maschine fährt
herunter, bevor sie noch antworten kann; nur ein Verbindungs-/Auth-Fehler
**vor** dem Kommando ist ein echter Fehler). Danach wartet eine kurze
Schonfrist (15s, damit ein zu früher Ping nicht noch die absterbende alte
Instanz erreicht) und pingt anschließend alle 5s (`ping -c 1 -W 2` — exakt
dieselbe Invocation wie das bestehende Online/Offline-Monitoring in
`poe.sh`), bis eine Antwort kommt oder ein Timeout von 5 Minuten erreicht
ist; erst dann wechselt der Status auf „wieder online“ (Erfolg) bzw. auf
einen Fehler, falls das Gerät nicht rechtzeitig zurückkommt.
Live-Status pro Gerät (läuft/erfolgreich/fehlgeschlagen inkl. Ausgabe der Live-Status pro Gerät (läuft/erfolgreich/fehlgeschlagen inkl. Ausgabe der
letzten Aktion, per Klick ein-/ausblendbar) wird per Polling (`/maintenance/status`, letzten Aktion, per Klick ein-/ausblendbar) wird per Polling (`/maintenance/status`,
@@ -265,7 +279,13 @@ 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 zu laden. Der Job-Status lebt bewusst nur im Prozessspeicher (wie beim
SSH-Terminal auch keine Sitzung persistiert wird) — ein Neustart von SSH-Terminal auch keine Sitzung persistiert wird) — ein Neustart von
`poe_web.service` verwirft nur die Anzeige, nicht die auf dem Zielgerät `poe_web.service` verwirft nur die Anzeige, nicht die auf dem Zielgerät
bereits laufende Aktion selbst. bereits laufende Aktion selbst. Ein abgeschlossener **erfolgreicher** Job
wird zusätzlich bei jedem (erneuten) Laden der Seite zurückgesetzt (Status
wieder „Noch keine Aktion“) — ein Erfolg ist nur so lange relevant, wie man
ihn noch live mitverfolgt, ein späterer Seitenaufruf soll keine ggf.
stunden-/tagealte Erfolgsmeldung dauerhaft zeigen. Fehlgeschlagene Aktionen
bleiben dagegen bewusst sichtbar, bis eine neue Aktion sie überschreibt, um
nicht durch einen einfachen Reload versehentlich übersehen zu werden.
**Sicherheitsmodell**: dieselbe `known_hosts`-Datei wie beim **Sicherheitsmodell**: dieselbe `known_hosts`-Datei wie beim
Browser-SSH-Terminal (`SSH_KNOWN_HOSTS_PATH`) — ein Host muss vorher Browser-SSH-Terminal (`SSH_KNOWN_HOSTS_PATH`) — ein Host muss vorher
@@ -281,10 +301,15 @@ Live gegen ein dediziertes Testsystem verifiziert (separat vom gemeinsam
genutzten App-Host, um dort kein echtes `apt upgrade` auszulösen): ein genutzten App-Host, um dort kein echtes `apt upgrade` auszulösen): ein
echtes `apt update && apt upgrade` (inkl. Kernel-/systemd-/netplan-Paketen) echtes `apt update && apt upgrade` (inkl. Kernel-/systemd-/netplan-Paketen)
lief nicht-interaktiv vollständig durch (`status: success`, komplette lief nicht-interaktiv vollständig durch (`status: success`, komplette
`apt`-Ausgabe im Job-Verlauf sichtbar), anschließend hat der SSH-Neustart `apt`-Ausgabe im Job-Verlauf sichtbar); der SSH-Neustart samt
das Testsystem tatsächlich neu gestartet — bestätigt über `uptime -s` Online-Watchdog wurde zweimal gegen dasselbe Testsystem verifiziert —
direkt nach dem Job (Boot-Zeitpunkt stimmte mit dem Auslöse-Zeitpunkt jeweils per `uptime -s` bestätigt, dass der Boot-Zeitpunkt exakt zum
überein, nicht nur die vom Code erwartete Verbindungsunterbrechung). Auslöse-Zeitpunkt des jeweiligen Jobs passte (kein False-Positive-Ping
gegen die noch nicht heruntergefahrene alte Instanz), und dass der
Job-Status korrekt von „wartet auf Online“ auf „wieder online“
umgeschaltet hat. Das Zurücksetzen auf „Noch keine Aktion“ bei einem
erneuten Seitenaufruf wurde ebenfalls live bestätigt (Status vor einem
`GET /maintenance` noch sichtbar, danach wieder leer).
Windows-Clients/PowerShell-Wartung ist bewusst **noch nicht** umgesetzt Windows-Clients/PowerShell-Wartung ist bewusst **noch nicht** umgesetzt
(kein Testsystem verfügbar) — die Kategorie „Windows-Client“ existiert in (kein Testsystem verfügbar) — die Kategorie „Windows-Client“ existiert in
+71 -6
View File
@@ -3058,9 +3058,11 @@ def devices():
device_rows = conn.execute(""" device_rows = conn.execute("""
SELECT devices.mac, devices.rpi_ip, devices.port, devices.name, devices.is_active, SELECT devices.mac, devices.rpi_ip, devices.port, devices.name, devices.is_active,
devices.credential_id, devices.ssh_port, devices.credential_id, devices.ssh_port,
switches.hostname AS switch_hostname switches.hostname AS switch_hostname,
credentials.name AS credential_name, credentials.username AS credential_username
FROM devices FROM devices
LEFT JOIN switches ON devices.switch_hostname = switches.hostname LEFT JOIN switches ON devices.switch_hostname = switches.hostname
LEFT JOIN credentials ON credentials.id = devices.credential_id
ORDER BY switches.hostname ASC, devices.name ASC ORDER BY switches.hostname ASC, devices.name ASC
""").fetchall() """).fetchall()
all_credentials = conn.execute("SELECT id, name, username, category FROM credentials ORDER BY name ASC").fetchall() all_credentials = conn.execute("SELECT id, name, username, category FROM credentials ORDER BY name ASC").fetchall()
@@ -3405,6 +3407,16 @@ def maintenance():
devices_rows = _maintenance_devices(conn) devices_rows = _maintenance_devices(conn)
conn.close() conn.close()
with _maintenance_jobs_lock: with _maintenance_jobs_lock:
# Abgeschlossene ERFOLGREICHE Aktionen werden bei jedem (erneuten)
# Laden der Seite zurückgesetzt (Status wieder "Noch keine Aktion")
# -- ein Erfolg ist nur so lange relevant, wie man ihn noch live
# über das Polling mitverfolgt; ein späterer Aufruf der Seite soll
# nicht dauerhaft eine ggf. Stunden alte Erfolgsmeldung zeigen.
# Fehlgeschlagene Aktionen bleiben dagegen bewusst sichtbar, bis
# eine neue Aktion sie überschreibt -- ein Fehler soll nicht durch
# einen einfachen Seiten-Reload versehentlich übersehen werden.
for mac in [m for m, job in _maintenance_jobs.items() if job.get("status") == "success"]:
del _maintenance_jobs[mac]
jobs_snapshot = {mac: dict(job) for mac, job in _maintenance_jobs.items()} jobs_snapshot = {mac: dict(job) for mac, job in _maintenance_jobs.items()}
return render_template( return render_template(
"maintenance.html", "maintenance.html",
@@ -3453,17 +3465,70 @@ def _maintenance_run_update(mac, name, host, port, username, password):
log_action_system("maintenance.update", name, f"fehlgeschlagen: Exit-Code {result['exit_code']}") log_action_system("maintenance.update", name, f"fehlgeschlagen: Exit-Code {result['exit_code']}")
# Nach einem ausgelösten Neustart blieb der Job-Status bisher für immer auf
# "Neustart ausgelöst" stehen -- der eigentlich interessante Zeitpunkt (ist
# das Gerät wieder erreichbar?) wurde nie erfasst. Gleiche Ping-Invocation
# wie das bestehende Online/Offline-Monitoring in poe.sh
# ("ping -c 1 -W 2 <ip>"), für konsistentes Verhalten mit dem Dashboard.
_MAINTENANCE_REBOOT_GRACE_SECONDS = 15
_MAINTENANCE_REBOOT_TIMEOUT_SECONDS = 300
_MAINTENANCE_REBOOT_POLL_INTERVAL = 5
def _ping_once(host, timeout=2):
try:
result = subprocess.run(
["ping", "-c", "1", "-W", str(timeout), host],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout + 2,
)
return result.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def _maintenance_wait_online_after_reboot(mac, name, host):
"""Wartet nach einem erfolgreich ausgelösten Neustart eine kurze
Schonfrist (die Maschine braucht einen Moment zum Herunterfahren, ein
zu früher Ping könnte noch die absterbende alte Instanz erreichen),
pingt danach regelmäßig, bis sie wieder antwortet oder ein Timeout
erreicht ist. Läuft im selben Hintergrund-Thread wie
_maintenance_run_reboot weiter, blockiert also niemanden."""
_maintenance_job_set(mac, status="running", message="Neustart ausgelöst, warte auf Online …")
time.sleep(_MAINTENANCE_REBOOT_GRACE_SECONDS)
deadline = time.time() + _MAINTENANCE_REBOOT_TIMEOUT_SECONDS
while time.time() < deadline:
if _ping_once(host):
_maintenance_job_set(
mac, status="success", message="Neustart erfolgreich — Gerät wieder online.",
finished=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
log_action_system("maintenance.reboot", name, "wieder online")
return
time.sleep(_MAINTENANCE_REBOOT_POLL_INTERVAL)
_maintenance_job_set(
mac, status="error",
message=f"Nach dem Neustart nicht innerhalb von {_MAINTENANCE_REBOOT_TIMEOUT_SECONDS // 60} Minuten wieder online (Ping ohne Antwort).",
finished=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
log_action_system("maintenance.reboot", name, "nach Neustart nicht wieder online")
def _maintenance_run_reboot(mac, name, host, port, username, password): def _maintenance_run_reboot(mac, name, host, port, username, password):
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 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) _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) result = _run_ssh_reboot(host, port, username, password)
finished = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if result["error"]: if result["error"]:
_maintenance_job_set(mac, status="error", message=result["error"], finished=finished) _maintenance_job_set(
mac, status="error", message=result["error"],
finished=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
log_action_system("maintenance.reboot", name, f"fehlgeschlagen: {result['error']}") log_action_system("maintenance.reboot", name, f"fehlgeschlagen: {result['error']}")
else: return
_maintenance_job_set(mac, status="success", message="Neustart ausgelöst.", finished=finished)
log_action_system("maintenance.reboot", name, "ausgelöst") log_action_system("maintenance.reboot", name, "ausgelöst")
_maintenance_wait_online_after_reboot(mac, name, host)
def _maintenance_dispatch(action_name, thread_target): def _maintenance_dispatch(action_name, thread_target):
+10 -2
View File
@@ -8,7 +8,7 @@
{% set can_edit = current_user.has_permission('devices.edit') %} {% set can_edit = current_user.has_permission('devices.edit') %}
{% set can_delete = current_user.has_permission('devices.edit') %} {% set can_delete = current_user.has_permission('devices.edit') %}
{% set show_actions_col = can_edit or can_delete %} {% set show_actions_col = can_edit or can_delete %}
{% set col_count = 5 + (1 if can_toggle else 0) + (1 if show_actions_col else 0) %} {% set col_count = 6 + (1 if can_toggle else 0) + (1 if show_actions_col else 0) %}
{% block page_title %}Clients{% endblock %} {% block page_title %}Clients{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
@@ -43,18 +43,26 @@
<th data-sort-key="mac">MAC-Adresse</th> <th data-sort-key="mac">MAC-Adresse</th>
<th data-sort-key="switch">Switch</th> <th data-sort-key="switch">Switch</th>
<th data-sort-key="port">Switchport</th> <th data-sort-key="port">Switchport</th>
<th data-sort-key="credential">Zugangsdaten</th>
{% if can_toggle %}<th>Status</th>{% endif %} {% if can_toggle %}<th>Status</th>{% endif %}
{% if show_actions_col %}<th style="width:1%;">Aktionen</th>{% endif %} {% if show_actions_col %}<th style="width:1%;">Aktionen</th>{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for d in devices %} {% for d in devices %}
<tr data-sort-hostname="{{ d['name']|lower }}" data-sort-ip="{{ d['rpi_ip']|lower }}" data-sort-mac="{{ d['mac']|lower }}" data-sort-switch="{{ (d['switch_hostname'] or '')|lower }}" data-sort-port="{{ (d['port'] or '')|lower }}"> <tr data-sort-hostname="{{ d['name']|lower }}" data-sort-ip="{{ d['rpi_ip']|lower }}" data-sort-mac="{{ d['mac']|lower }}" data-sort-switch="{{ (d['switch_hostname'] or '')|lower }}" data-sort-port="{{ (d['port'] or '')|lower }}" data-sort-credential="{{ (d['credential_name'] or '')|lower }}">
<td class="cell-name">{{ d['name'] }}</td> <td class="cell-name">{{ d['name'] }}</td>
<td class="mono">{{ d['rpi_ip'] }}</td> <td class="mono">{{ d['rpi_ip'] }}</td>
<td class="mono">{{ d['mac'] }}</td> <td class="mono">{{ d['mac'] }}</td>
<td>{{ d['switch_hostname'] or '—' }}</td> <td>{{ d['switch_hostname'] or '—' }}</td>
<td>{{ d['port'] or '—' }}</td> <td>{{ d['port'] or '—' }}</td>
<td>
{% if d['credential_name'] %}
{{ d['credential_name'] }} <span class="text-faint mono" style="font-size:11.5px;">({{ d['credential_username'] }})</span>
{% else %}
<span class="text-faint">— keine —</span>
{% endif %}
</td>
{% if can_toggle %} {% if can_toggle %}
<td> <td>
<label class="switch-check"> <label class="switch-check">