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:
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user