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
+71 -6
View File
@@ -3058,9 +3058,11 @@ def devices():
device_rows = conn.execute("""
SELECT devices.mac, devices.rpi_ip, devices.port, devices.name, devices.is_active,
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
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
""").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)
conn.close()
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()}
return render_template(
"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']}")
# 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):
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)
_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']}")
else:
_maintenance_job_set(mac, status="success", message="Neustart ausgelöst.", finished=finished)
log_action_system("maintenance.reboot", name, "ausgelöst")
return
log_action_system("maintenance.reboot", name, "ausgelöst")
_maintenance_wait_online_after_reboot(mac, name, host)
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_delete = current_user.has_permission('devices.edit') %}
{% 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_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="switch">Switch</th>
<th data-sort-key="port">Switchport</th>
<th data-sort-key="credential">Zugangsdaten</th>
{% if can_toggle %}<th>Status</th>{% endif %}
{% if show_actions_col %}<th style="width:1%;">Aktionen</th>{% endif %}
</tr>
</thead>
<tbody>
{% 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="mono">{{ d['rpi_ip'] }}</td>
<td class="mono">{{ d['mac'] }}</td>
<td>{{ d['switch_hostname'] 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 %}
<td>
<label class="switch-check">