Explorar o código

web: jobs registry persists across restarts (web-jobs.json next to the global config; in-flight jobs marked failed with a runlock hint on boot); FTL full parity — 209 keys in all 6 locales, 19 health/exec/doctor/messages translated per locale

Alexander Korolev hai 2 semanas
pai
achega
d67d9d8ffa
Modificáronse 9 ficheiros con 305 adicións e 40 borrados
  1. 41 3
      locales/de/main.ftl
  2. 44 6
      locales/es/main.ftl
  3. 42 4
      locales/fr/main.ftl
  4. 44 6
      locales/it/main.ftl
  5. 29 10
      locales/ru/main.ftl
  6. 2 2
      src/web/events.rs
  7. 68 9
      src/web/jobs.rs
  8. 2 0
      src/web/mod.rs
  9. 33 0
      src/web/tests.rs

+ 41 - 3
locales/de/main.ftl

@@ -15,6 +15,7 @@ docker-unavailable = ✗ Docker nicht verfügbar
 ssh-error = { $node } ({ $ip }): ✗ SSH-Fehler: { $error }
 task-join-error = ✗ Fehler beim Aufgaben-Join: { $error }
 doctor-header = 🩺 Doctor: { $name } ({ $engine }) [Backend: { $backend }]
+
 doctor-node-section = --- { $name } ---
 doctor-all-passed = ✅ Alle Prüfungen bestanden!
 doctor-checks-failed = ⚠ Einige Prüfungen fehlgeschlagen. Mit --repair zum Auto-Fix ausführen.
@@ -31,13 +32,16 @@ health-cluster-header = === { $name } === { $nodes } { $nodes ->
         [one] Knoten
        *[other] Knoten
     }
+
 health-switch-off = 🚫 Switch aus
 health-interface-down = 🌐 Schnittstelle down
 health-all-online = ✅ alle online
-health-partial-online =  ({ $online } online, { $offline } offline)
+health-partial-online =   ({ $online } online, { $offline } offline)
+
 health-node-online-cluster-down =   ◍ { $name } ({ $ip }){ $net } — online (Cluster offline)
 health-free-count = — { $free } frei
 health-cluster-down = ⏻ Cluster offline
+
 health-soft-off = ⚙ { $list } aus (nicht blockierend)
 health-net-status =  [Netz: { $details }]
 health-no-containers = (keine Container)
@@ -49,8 +53,10 @@ exec-header = ⚡ exec: { $cmd } ({ $nodes } { $nodes ->
         [one] Knoten
        *[other] Knoten
     })
+
 exec-section = --- { $name } ---
 exec-failed = ✗ { $name } :: { $cmd } :: { $error }
+
 exec-completed-errors = exec mit Fehlern abgeschlossen (einige Knoten meldeten Fehler)
 power-off-dryrun = dry-run: tatsächliches Herunterfahren + HA-Aufruf übersprungen
 power-off-mikrotik-skip = { $entity } ist ein RouterOS-API-Gerät ({ $address }) — HA wird übersprungen
@@ -64,6 +70,7 @@ power-off-mikrotik-relay-failed = { $name }: Steckdose { $relay } konnte nicht a
 power-on-mikrotik-relay-would = würde: Relais { $relay} einschalten (versorgt { $name} mit Strom)
 power-off-would = würde: { $action } { $target }
 power-off-shutdown-failed = ✗ { $name } :: { $cmd } :: { $error }
+
 power-off-waiting = ⏳ warte { $secs}s bis Knoten sich beruhigen
 power-off-no-ha = keine [home_assistant]-Konfig; OS-Herunterfahren oben ausgeführt
 power-off-ssh-failures = einige Knoten meldeten SSH-Fehler beim Herunterfahren
@@ -73,9 +80,11 @@ power-on-would-skip = würde überspringen: { $name } (keine Entity)
 power-on-head = 💡 einschalten: { $nodes } Knoten
 ha-invalid-entity = { $name }: ungültige Entity '{ $entity }': { $error }
 ha-call-failed = HA { $service } { $entity }: { $error }
+
 ha-skip-no-entity = ⚠ { $name }: keine `entity` konfiguriert, HA übersprungen
 ha-extra-invalid = { $label } ({ $extra }): ungültige Entity '{ $extra }'
 ha-extra-failed = HA { $service } { $extra } ({ $label }): { $error }
+
 power-switch-would-off = würde: HA turn_off { $entity }
 power-switch-would-off-extra = würde: HA turn_off { $extra } (von Extras)
 power-switch-would-on = würde: HA turn_on { $entity }
@@ -144,12 +153,15 @@ sync-syncing = { $name } ({ $ip }): synchronisiere...
 sync-synced = { $name } OK
 sync-fabric-fallback = { $name }: 200G-Fabric-Hop fehlgeschlagen ({ $err }) — Fallback auf 10G-Verwaltung; node-to-node-SSH über die Fabric-NICs prüfen (authorized_keys / Host-Keys für 200.x-Adressen)
 health-node-offline =   ✗ { $name } ({ $ip }) — nicht erreichbar
-health-node-idle =   ○ { $name } ({ $ip }){ $net }
-health-node-online =   ● { $name } ({ $ip }){ $net }
+health-node-idle = ○ { $name } ({ $ip }){ $net }
+
+health-node-online = ● { $name } ({ $ip }){ $net }
+
 health-container-count = , { $count } { $count ->
         [one] Container
        *[other] Container
     }
+
 power-off-head = 🛑 ausschalten: { $nodes } Knoten — sudo shutdown now
 power-switch-head = 🔸 umschalten: { $from } → { $to } ({ $nodes } Knoten + { $entities } Entities zu deaktivieren)
 power-switch-powering-on = 💡 umschalten: { $nodes } Knoten + { $entities } Entities einschalten nach { $to }
@@ -160,6 +172,7 @@ health-recipe-line =   { $recipe } — { $nodes } { $nodes ->
         [one] Knoten
        *[other] Knoten
     }: { $list }
+
 tmux-ray-backend = 🐍 Ray-Backend erkannt — Head-Knoten wird zuerst gestartet...
 tmux-mp-backend = 🔗 MP-Backend erkannt — alle Knoten werden parallel gestartet...
 tmux-following-logs = 📜 Logs von '{ $sess }' auf { $name } werden verfolgt...
@@ -211,14 +224,20 @@ health-nodes-count = { $nodes } { $nodes ->
 health-col-node = Knoten
 health-col-address = Adresse
 health-col-status = Status
+
 health-col-containers = Container
 health-col-container = Container
+
 health-col-image = Image
+
 health-col-recipe = Rezept
 health-col-cluster = Cluster
+
 health-col-nodes = Knoten
 health-status-online = ● online
+
 health-status-offline = ✗ offline
+
 health-container-line =     { $name }  { $status }  { $image }
 
 dry-run-would-push = [dry-run] würde nach dem Build { $ref} pushen (und { $latest} aktualisieren)
@@ -237,3 +256,22 @@ power-on-skip = { $name }: keine HA-Entität — übersprungen
 power-on-failed = { $name }: HA-Fehler: { $error }
 power-on-cycling = ⟳ { $name }: Steckdose ist on, aber der Knoten antwortet nicht — Stromzyklus { $entity }
 power-on-off-wait-timeout = { $name }: Steckdose meldete nicht off binnen 10 s (Relay klemmt evtl.)
+
+report-col-launches = Starts
+report-col-up = Läuft
+report-col-engine = Engine
+report-col-last = Letzter Bench
+report-col-model = Modell
+report-col-nodes = Knoten×TP
+report-col-points = Punkte
+report-col-prov = Herkunft
+report-col-quant = Quant.
+stop-not-verified = Container nicht als gestoppt verifiziert auf: { $nodes }
+
+report-col-recipe = Rezept
+report-col-reports = Reports
+report-col-score = Score /100
+report-col-tps1 = Tok/s c1
+report-col-tps2 = Tok/s c2
+report-serving = Interaktiver Rezept-Report: { $url }  (Live-Updates; Ctrl-C zum Stoppen)
+report-title = Getestete Rezepte ({ $count })

+ 44 - 6
locales/es/main.ftl

@@ -15,6 +15,7 @@ docker-unavailable = ✗ Docker no disponible
 ssh-error = { $node } ({ $ip }): ✗ Error SSH: { $error }
 task-join-error = ✗ Error de unión de tarea: { $error }
 doctor-header = 🩺 Doctor: { $name } ({ $engine }) [backend: { $backend }]
+
 doctor-node-section = --- { $name } ---
 doctor-all-passed = ✅ ¡Todas las comprobaciones superadas!
 doctor-checks-failed = ⚠ Algunas comprobaciones fallaron. Ejecute con --repair para auto-corregir.
@@ -31,13 +32,16 @@ health-cluster-header = === { $name } === { $nodes } { $nodes ->
         [one] nodo
        *[other] nodos
     }
+
 health-switch-off = 🚫 switch apagado
 health-interface-down = 🌐 interfaz caída
 health-all-online = ✅ todos en línea
-health-partial-online =  ({ $online } en línea, { $offline } desconectados)
+health-partial-online =   ({ $online } en línea, { $offline } sin conexión)
+
 health-node-online-cluster-down =   ◍ { $name } ({ $ip }){ $net } — en línea (cluster fuera de línea)
 health-free-count = — { $free } libres
-health-cluster-down = ⏻ cluster fuera de línea
+health-cluster-down = ⏻ clúster sin conexión
+
 health-soft-off = ⚙ { $list } apagado (no bloqueante)
 health-net-status =  [red: { $details }]
 health-no-containers = (sin contenedores)
@@ -49,8 +53,10 @@ exec-header = ⚡ exec: { $cmd } ({ $nodes } { $nodes ->
         [one] nodo
        *[other] nodos
     })
+
 exec-section = --- { $name } ---
 exec-failed = ✗ { $name } :: { $cmd } :: { $error }
+
 exec-completed-errors = exec completó con errores (algunos nodos reportaron fallos)
 power-off-dryrun = dry-run: omitiendo apagado real + llamada HA
 power-off-mikrotik-skip = { $entity } es un dispositivo de la API de RouterOS ({ $address }) — HA se omite
@@ -64,6 +70,7 @@ power-off-mikrotik-relay-failed = { $name }: no se pudo apagar el enchufe { $rel
 power-on-mikrotik-relay-would = haría: activar el relé { $relay} (enciende { $name})
 power-off-would = sería: { $action } { $target }
 power-off-shutdown-failed = ✗ { $name } :: { $cmd } :: { $error }
+
 power-off-waiting = ⏳ esperando { $secs}s para que los nodos se asienten
 power-off-no-ha = sin config [home_assistant]; el apagado de SO se realizó arriba
 power-off-ssh-failures = algunos nodos reportaron fallos SSH durante el apagado
@@ -73,9 +80,11 @@ power-on-would-skip = se omitiría: { $name } (sin entity)
 power-on-head = 💡 encender: { $nodes } nodo(s)
 ha-invalid-entity = { $name }: entity inválido '{ $entity }': { $error }
 ha-call-failed = HA { $service } { $entity }: { $error }
+
 ha-skip-no-entity = ⚠ { $name }: sin `entity` configurado, omitiendo HA
 ha-extra-invalid = { $label } ({ $extra }): entity inválido '{ $extra }'
 ha-extra-failed = HA { $service } { $extra } ({ $label }): { $error }
+
 power-switch-would-off = sería: HA turn_off { $entity }
 power-switch-would-off-extra = sería: HA turn_off { $extra } (de extras)
 power-switch-would-on = sería: HA turn_on { $entity }
@@ -144,12 +153,15 @@ sync-syncing = { $name } ({ $ip }): sincronizando...
 sync-synced = { $name } OK
 sync-fabric-fallback = { $name }: el salto por 200G falló ({ $err }) — fallback a mgmt 10G; arreglar ssh nodo-a-nodo por la fabrica (authorized_keys / host keys para las direcciones 200.x)
 health-node-offline =   ✗ { $name } ({ $ip }) — desconectado
-health-node-idle =   ○ { $name } ({ $ip }){ $net }
-health-node-online =   ● { $name } ({ $ip }){ $net }
+health-node-idle = ○ { $name } ({ $ip }){ $net }
+
+health-node-online = ● { $name } ({ $ip }){ $net }
+
 health-container-count = , { $count } { $count ->
         [one] contenedor
        *[other] contenedores
     }
+
 power-off-head = 🛑 apagar: { $nodes } nodo(s) — sudo shutdown now
 power-switch-head = 🔌 conmutar: { $from } → { $to } ({ $nodes } nodo(s) + { $entities } entidade(s) a desactivar)
 power-switch-powering-on = 💡 conmutar: encendiendo { $nodes } nodo(s) + { $entities } entidade(s) a { $to }
@@ -160,6 +172,7 @@ health-recipe-line =   { $recipe } — { $nodes } { $nodes ->
         [one] nodo
        *[other] nodos
     }: { $list }
+
 tmux-ray-backend = 🐍 Backend Ray detectado — iniciando nodo principal primero...
 tmux-mp-backend = 🔗 Backend MP detectado — iniciando todos los nodos en paralelo...
 tmux-following-logs = 📜 Siguiendo logs de '{ $sess }' en { $name }...
@@ -211,14 +224,20 @@ health-nodes-count = { $nodes } { $nodes ->
 health-col-node = Nodo
 health-col-address = Dirección
 health-col-status = Estado
+
 health-col-containers = Contenedores
 health-col-container = Contenedor
+
 health-col-image = Imagen
+
 health-col-recipe = Receta
-health-col-cluster = Cluster
+health-col-cluster = Clúster
+
 health-col-nodes = Nodos
 health-status-online = ● en línea
-health-status-offline = ✗ fuera de línea
+
+health-status-offline = ✗ sin conexión
+
 health-container-line =     { $name }  { $status }  { $image }
 
 dry-run-would-push = [dry-run] haría push de { $ref} (y actualizaría { $latest }) tras el build
@@ -237,3 +256,22 @@ power-on-skip = { $name }: sin entidad de HA — omitido
 power-on-failed = { $name }: fallo de HA: { $error }
 power-on-cycling = ⟳ { $name }: el enchufe está on pero el nodo no responde — ciclando { $entity }
 power-on-off-wait-timeout = { $name }: el enchufe no reportó off en 10 s (relé posiblemente atascado)
+
+report-col-launches = Ejecuciones
+report-col-up = Activo
+report-col-engine = Motor
+report-col-last = Último bench
+report-col-model = Modelo
+report-col-nodes = Nodos×TP
+report-col-points = Puntos
+report-col-prov = Procedencia
+report-col-quant = Cuant.
+stop-not-verified = Contenedores no verificados como detenidos en: { $nodes }
+
+report-col-recipe = Receta
+report-col-reports = Informes
+report-col-score = Puntuación /100
+report-col-tps1 = Tok/s c1
+report-col-tps2 = Tok/s c2
+report-serving = Informe interactivo de recetas: { $url }  (actualizaciones en vivo; Ctrl-C para parar)
+report-title = Recetas probadas ({ $count })

+ 42 - 4
locales/fr/main.ftl

@@ -14,7 +14,8 @@ no-sm121-containers = (aucun conteneur sm121)
 docker-unavailable = ✗ Docker indisponible
 ssh-error = { $node } ({ $ip }) : ✗ Erreur SSH : { $error }
 task-join-error = ✗ Erreur de jointure de tâche : { $error }
-doctor-header = 🩺 Doctor : { $name } ({ $engine }) [backend : { $backend }]
+doctor-header = 🩺 Doctor: { $name } ({ $engine }) [backend : { $backend }]
+
 doctor-node-section = --- { $name } ---
 doctor-all-passed = ✅ Toutes les vérifications réussies !
 doctor-checks-failed = ⚠ Certaines vérifications ont échoué. Lancez avec --repair pour la correction automatique.
@@ -31,13 +32,16 @@ health-cluster-header = === { $name } === { $nodes } { $nodes ->
         [one] nœud
        *[other] nœuds
     }
+
 health-switch-off = 🚫 switch éteint
 health-interface-down = 🌐 interface indisponible
 health-all-online = ✅ tous en ligne
-health-partial-online =  ({ $online } en ligne, { $offline } hors ligne)
+health-partial-online =   ({ $online } en ligne, { $offline } hors ligne)
+
 health-node-online-cluster-down =   ◍ { $name } ({ $ip }){ $net } — en ligne (cluster hors ligne)
 health-free-count = — { $free } libres
 health-cluster-down = ⏻ cluster hors ligne
+
 health-soft-off = ⚙ { $list } éteint (non bloquant)
 health-net-status =  [réseau : { $details }]
 health-no-containers = (aucun conteneur)
@@ -49,8 +53,10 @@ exec-header = ⚡ exec : { $cmd } ({ $nodes } { $nodes ->
         [one] nœud
        *[other] nœuds
     })
+
 exec-section = --- { $name } ---
 exec-failed = ✗ { $name } :: { $cmd } :: { $error }
+
 exec-completed-errors = exec terminé avec des erreurs (certains nœuds ont signalé des échecs)
 power-off-dryrun = dry-run : extinction réelle + appel HA ignorés
 power-off-mikrotik-skip = { $entity } est un appareil API RouterOS ({ $address }) — HA ignoré
@@ -64,6 +70,7 @@ power-off-mikrotik-relay-failed = { $name }: impossible de couper la prise { $re
 power-on-mikrotik-relay-would = ferait : activer le relais { $relay} (alimente { $name})
 power-off-would = serait : { $action } { $target }
 power-off-shutdown-failed = ✗ { $name } :: { $cmd } :: { $error }
+
 power-off-waiting = ⏳ attente { $secs}s pour stabilisation des nœuds
 power-off-no-ha = pas de config [home_assistant] ; l'extinction OS a été effectuée ci-dessus
 power-off-ssh-failures = certains nœuds ont signalé des erreurs SSH pendant l'extinction
@@ -73,9 +80,11 @@ power-on-would-skip = serait ignoré : { $name } (pas d'entity)
 power-on-head = 💡 allumage : { $nodes } nœud(s)
 ha-invalid-entity = { $name } : entity invalide '{ $entity }' : { $error }
 ha-call-failed = HA { $service } { $entity } : { $error }
+
 ha-skip-no-entity = ⚠ { $name } : pas d'`entity` configurée, HA ignoré
 ha-extra-invalid = { $label } ({ $extra }) : entity invalide '{ $extra }'
 ha-extra-failed = HA { $service } { $extra } ({ $label }) : { $error }
+
 power-switch-would-off = serait : HA turn_off { $entity }
 power-switch-would-off-extra = serait : HA turn_off { $extra } (des extras)
 power-switch-would-on = serait : HA turn_on { $entity }
@@ -144,12 +153,15 @@ sync-syncing = { $name } ({ $ip }) : synchronisation...
 sync-synced = { $name } OK
 sync-fabric-fallback = { $name }: saut 200G échoué ({ $err }) — repli sur mgmt 10G ; corriger le ssh inter-nœuds sur le fabric (authorized_keys / host keys pour les adresses 200.x)
 health-node-offline =   ✗ { $name } ({ $ip }) — hors ligne
-health-node-idle =   ○ { $name } ({ $ip }){ $net }
-health-node-online =   ● { $name } ({ $ip }){ $net }
+health-node-idle = ○ { $name } ({ $ip }){ $net }
+
+health-node-online = ● { $name } ({ $ip }){ $net }
+
 health-container-count = , { $count } { $count ->
         [one] conteneur
        *[other] conteneurs
     }
+
 power-off-head = 🛑 extinction : { $nodes } nœud(s) — sudo shutdown now
 power-switch-head = 🔌 commutation : { $from } → { $to } ({ $nodes } nœud(s) + { $entities } entité(s) à désactiver)
 power-switch-powering-on = 💡 commutation : allumage de { $nodes } nœud(s) + { $entities } entité(s) vers { $to }
@@ -160,6 +172,7 @@ health-recipe-line =   { $recipe } — { $nodes } { $nodes ->
         [one] nœud
        *[other] nœuds
     } : { $list }
+
 tmux-ray-backend = 🐍 Backend Ray détecté — démarrage du nœud head en premier...
 tmux-mp-backend = 🔗 Backend MP détecté — démarrage de tous les nœuds en parallèle...
 tmux-following-logs = 📜 Suivi des logs de '{ $sess }' sur { $name }...
@@ -211,14 +224,20 @@ health-nodes-count = { $nodes } { $nodes ->
 health-col-node = Nœud
 health-col-address = Adresse
 health-col-status = Statut
+
 health-col-containers = Conteneurs
 health-col-container = Conteneur
+
 health-col-image = Image
+
 health-col-recipe = Recette
 health-col-cluster = Cluster
+
 health-col-nodes = Nœuds
 health-status-online = ● en ligne
+
 health-status-offline = ✗ hors ligne
+
 health-container-line =     { $name }  { $status }  { $image }
 
 dry-run-would-push = [dry-run] pousserait { $ref} (et mettrait à jour { $latest }) après le build
@@ -237,3 +256,22 @@ power-on-skip = { $name }: pas d'entité HA — ignoré
 power-on-failed = { $name }: échec HA : { $error }
 power-on-cycling = ⟳ { $name }: la prise est on mais le nœud ne répond pas — cycle d'alimentation { $entity }
 power-on-off-wait-timeout = { $name }: la prise n'a pas signalé off en 10 s (relais possiblement bloqué)
+
+report-col-launches = Lancements
+report-col-up = Actif
+report-col-engine = Moteur
+report-col-last = Dernier bench
+report-col-model = Modèle
+report-col-nodes = Nœuds×TP
+report-col-points = Points
+report-col-prov = Provenance
+report-col-quant = Quant.
+stop-not-verified = Conteneurs non vérifiés comme arrêtés sur : { $nodes }
+
+report-col-recipe = Recette
+report-col-reports = Rapports
+report-col-score = Score /100
+report-col-tps1 = Tok/s c1
+report-col-tps2 = Tok/s c2
+report-serving = Rapport interactif des recettes : { $url }  (mises à jour en direct ; Ctrl-C pour arrêter)
+report-title = Recettes testées ({ $count })

+ 44 - 6
locales/it/main.ftl

@@ -15,6 +15,7 @@ docker-unavailable = ✗ Docker non disponibile
 ssh-error = { $node } ({ $ip }): ✗ Errore SSH: { $error }
 task-join-error = ✗ Errore di join dell'attività: { $error }
 doctor-header = 🩺 Doctor: { $name } ({ $engine }) [backend: { $backend }]
+
 doctor-node-section = --- { $name } ---
 doctor-all-passed = ✅ Tutti i controlli superati!
 doctor-checks-failed = ⚠ Alcuni controlli falliti. Eseguire con --repair per l'auto-fix.
@@ -31,13 +32,16 @@ health-cluster-header = === { $name } === { $nodes } { $nodes ->
         [one] nodo
        *[other] nodi
     }
+
 health-switch-off = 🚫 switch spento
 health-interface-down = 🌐 interfaccia down
 health-all-online = ✅ tutti online
-health-partial-online =  ({ $online } online, { $offline } offline)
+health-partial-online =   ({ $online } online, { $offline } offline)
+
 health-node-online-cluster-down =   ◍ { $name } ({ $ip }){ $net } — online (cluster offline)
 health-free-count = — { $free } liberi
 health-cluster-down = ⏻ cluster offline
+
 health-soft-off = ⚙ { $list } spento (non bloccante)
 health-net-status =  [rete: { $details }]
 health-no-containers = (nessun container)
@@ -49,8 +53,10 @@ exec-header = ⚡ exec: { $cmd } ({ $nodes } { $nodes ->
         [one] nodo
        *[other] nodi
     })
+
 exec-section = --- { $name } ---
 exec-failed = ✗ { $name } :: { $cmd } :: { $error }
+
 exec-completed-errors = exec completato con errori (alcuni nodi hanno riportato fallimenti)
 power-off-dryrun = dry-run: saltando spegnimento reale + chiamata HA
 power-off-mikrotik-skip = { $entity } è un dispositivo API RouterOS ({ $address }) — HA saltato
@@ -64,6 +70,7 @@ power-off-mikrotik-relay-failed = { $name }: impossibile spegnere la presa { $re
 power-on-mikrotik-relay-would = eseguirebbe: attivare il relé { $relay} (alimenta { $name})
 power-off-would = sarebbe: { $action } { $target }
 power-off-shutdown-failed = ✗ { $name } :: { $cmd } :: { $error }
+
 power-off-waiting = ⏳ attesa { $secs}s per assestamento dei nodi
 power-off-no-ha = nessuna config [home_assistant]; spegnimento OS eseguito sopra
 power-off-ssh-failures = alcuni nodi hanno riportato errori SSH durante lo spegnimento
@@ -73,9 +80,11 @@ power-on-would-skip = saltato: { $name } (nessuna entity)
 power-on-head = 💡 accensione: { $nodes } nodo/i
 ha-invalid-entity = { $name }: entity non valida '{ $entity }': { $error }
 ha-call-failed = HA { $service } { $entity }: { $error }
+
 ha-skip-no-entity = ⚠ { $name }: nessuna `entity` configurata, salto HA
 ha-extra-invalid = { $label } ({ $extra }): entity non valida '{ $extra }'
 ha-extra-failed = HA { $service } { $extra } ({ $label }): { $error }
+
 power-switch-would-off = sarebbe: HA turn_off { $entity }
 power-switch-would-off-extra = sarebbe: HA turn_off { $extra } (da extras)
 power-switch-would-on = sarebbe: HA turn_on { $entity }
@@ -144,12 +153,15 @@ sync-syncing = { $name } ({ $ip }): sincronizzazione...
 sync-synced = { $name } OK
 sync-fabric-fallback = { $name }: hop 200G non riuscito ({ $err }) — fallback su mgmt 10G; correggere ssh nodo-nodo sulla fabric (authorized_keys / host key per gli indirizzi 200.x)
 health-node-offline =   ✗ { $name } ({ $ip }) — offline
-health-node-idle =   ○ { $name } ({ $ip }){ $net }
-health-node-online =   ● { $name } ({ $ip }){ $net }
+health-node-idle = ○ { $name } ({ $ip }){ $net }
+
+health-node-online = ● { $name } ({ $ip }){ $net }
+
 health-container-count = , { $count } { $count ->
-        [one] container
-       *[other] container
+        [one] contenitore
+       *[other] contenitori
     }
+
 power-off-head = 🛑 spegnimento: { $nodes } nodo/i — sudo shutdown now
 power-switch-head = 🔌 commuta: { $from } → { $to } ({ $nodes } nodo/i + { $entities } entità da disattivare)
 power-switch-powering-on = 💡 commuta: accensione { $nodes } nodo/i + { $entities } entità verso { $to }
@@ -160,6 +172,7 @@ health-recipe-line =   { $recipe } — { $nodes } { $nodes ->
         [one] nodo
        *[other] nodi
     }: { $list }
+
 tmux-ray-backend = 🐍 Backend Ray rilevato — avvio del nodo head per primo...
 tmux-mp-backend = 🔗 Backend MP rilevato — avvio di tutti i nodi in parallelo...
 tmux-following-logs = 📜 Seguendo i log da '{ $sess }' su { $name }...
@@ -211,14 +224,20 @@ health-nodes-count = { $nodes } { $nodes ->
 health-col-node = Nodo
 health-col-address = Indirizzo
 health-col-status = Stato
+
 health-col-containers = Container
-health-col-container = Container
+health-col-container = Contenitore
+
 health-col-image = Immagine
+
 health-col-recipe = Ricetta
 health-col-cluster = Cluster
+
 health-col-nodes = Nodi
 health-status-online = ● online
+
 health-status-offline = ✗ offline
+
 health-container-line =     { $name }  { $status }  { $image }
 
 dry-run-would-push = [dry-run] farebbe push di { $ref} (e aggiornerebbe { $latest }) dopo il build
@@ -237,3 +256,22 @@ power-on-skip = { $name }: nessuna entità HA — saltato
 power-on-failed = { $name }: errore HA: { $error }
 power-on-cycling = ⟳ { $name }: la presa è on ma il nodo non risponde — ciclo di alimentazione { $entity }
 power-on-off-wait-timeout = { $name }: la presa non ha riportato off entro 10 s (relè forse bloccato)
+
+report-col-launches = Esecuzioni
+report-col-up = Attivo
+report-col-engine = Motore
+report-col-last = Ultimo bench
+report-col-model = Modello
+report-col-nodes = Nodi×TP
+report-col-points = Punti
+report-col-prov = Provenienza
+report-col-quant = Quant.
+stop-not-verified = Contenitori non verificati come fermati su: { $nodes }
+
+report-col-recipe = Ricetta
+report-col-reports = Report
+report-col-score = Punteggio /100
+report-col-tps1 = Tok/s c1
+report-col-tps2 = Tok/s c2
+report-serving = Report interattivo delle ricette: { $url }  (aggiornamenti live; Ctrl-C per fermare)
+report-title = Ricette testate ({ $count })

+ 29 - 10
locales/ru/main.ftl

@@ -15,7 +15,8 @@ no-sm121-containers = (нет контейнеров sm121)
 docker-unavailable = ✗ Docker недоступен
 ssh-error = { $node } ({ $ip }): ✗ SSH ошибка: { $error }
 task-join-error = ✗ Ошибка присоединения задачи: { $error }
-doctor-header = 🩺 Диагностика: { $name } ({ $engine }) [backend: { $backend }]
+doctor-header = 🩺 Doctor: { $name } ({ $engine }) [бэкенд: { $backend }]
+
 doctor-node-section = --- { $name } ---
 doctor-all-passed = ✅ Все проверки пройдены!
 doctor-checks-failed = ⚠ Некоторые проверки не пройдены. Запустите с --repair для авто-исправления.
@@ -30,16 +31,18 @@ failed-to = Не удалось { $action } на { $name }: { $error }
 health-section = === Кластер: { $name } ===
 health-cluster-header = === { $name } === { $nodes } { $nodes ->
         [one] узел
-        [few] узла
-       *[many] узлов
+       *[other] узлов
     }
+
 health-switch-off = 🚫 переключатель выключен
 health-interface-down = 🌐 интерфейс недоступен
 health-all-online = ✅ все онлайн
-health-partial-online =  ({ $online } онлайн, { $offline } офлайн)
+health-partial-online =   ({ $online } онлайн, { $offline } офлайн)
+
 health-node-online-cluster-down = ◍ { $name } ({ $ip }){ $net } — онлайн (кластер офлайн)
 health-free-count = — { $free } свободно
 health-cluster-down = ⏻ кластер офлайн
+
 health-soft-off = ⚙ { $list } выкл. (не блокирует)
 health-net-status =  [сеть: { $details }]
 health-no-containers = (нет контейнеров)
@@ -49,11 +52,12 @@ health-docker-unavailable = ✗ Docker недоступен
 docker-error-line = { $err }
 exec-header = ⚡ exec: { $cmd } ({ $nodes } { $nodes ->
         [one] узел
-        [few] узла
-       *[many] узлов
+       *[other] узлов
     })
+
 exec-section = --- { $name } ---
 exec-failed = ✗ { $name } :: { $cmd } :: { $error }
+
 exec-completed-errors = exec завершен с ошибками (некоторые узлы не ответили)
 power-off-dryrun = dry-run: пропускаем реальное выключение + HA вызов
 power-off-mikrotik-skip = { $entity } — устройство RouterOS API ({ $address }), HA пропускается
@@ -67,6 +71,7 @@ power-off-mikrotik-relay-failed = { $name }: не удалось обесточ
 power-on-mikrotik-relay-would = бы: включить реле { $relay} (запитывает { $name})
 power-off-would = будет: { $action } { $target }
 power-off-shutdown-failed = ✗ { $name } :: { $cmd } :: { $error }
+
 power-off-waiting = ⏳ ждем { $secs }с для завершения остановки
 power-off-no-ha = нет [home_assistant] конфига; OS shutdown выполнен
 power-off-ssh-failures = некоторые узлы сообщили об ошибках SSH при остановке
@@ -76,9 +81,11 @@ power-on-would-skip = будет пропущено: { $name } (нет entity)
 power-on-head = 💡 включение питания: { $nodes } узлов
 ha-invalid-entity = { $name }: неверный entity '{ $entity }': { $error }
 ha-call-failed = HA { $service } { $entity }: { $error }
+
 ha-skip-no-entity = ⚠ { $name }: нет `entity`, пропускаем HA
 ha-extra-invalid = { $label } ({ $extra }): неверный entity '{ $extra }'
 ha-extra-failed = HA { $service } { $extra } ({ $label }): { $error }
+
 power-switch-would-off = будет: HA turn_off { $entity }
 power-switch-would-off-extra = будет: HA turn_off { $extra } (из extras)
 power-switch-would-on = будет: HA turn_on { $entity }
@@ -148,12 +155,14 @@ sync-synced = { $name } OK
 sync-fabric-fallback = { $name }: 200G-хоп не удался ({ $err }) — фоллбэк на 10G mgmt; поправьте node-to-node ssh по фабрике (authorized_keys / host keys на 200.x-адресах)
 health-node-offline = ✗ { $name } ({ $ip }) — офлайн
 health-node-idle = ○ { $name } ({ $ip }){ $net }
+
 health-node-online = ● { $name } ({ $ip }){ $net }
+
 health-container-count = , { $count } { $count ->
         [one] контейнер
-        [few] контейнера
-       *[many] контейнеров
+       *[other] контейнеров
     }
+
 power-off-head = 🛑 выключение: { $nodes } узлов — sudo shutdown now
 power-switch-head = 🔌 переключение: { $from } → { $to } ({ $nodes } узлов + { $entities } сущностей для отключения)
 power-switch-powering-on = 💡 переключение: включение { $nodes } узлов + { $entities } сущностей в { $to }
@@ -162,9 +171,9 @@ health-no-recipes = Нет запущенных контейнеров sm121.
 health-running-recipes = Запущенные рецепты:
 health-recipe-line =   { $recipe } — { $nodes } { $nodes ->
         [one] узел
-        [few] узла
-       *[many] узлов
+       *[other] узлов
     }: { $list }
+
 tmux-ray-backend = 🐍 Обнаружен Ray backend — запуск главного узла первым...
 tmux-mp-backend = 🔗 Обнаружен MP backend — запуск всех узлов параллельно...
 tmux-following-logs = 📜 Наблюдаю за логами '{ $sess }' на { $name }...
@@ -224,14 +233,20 @@ health-nodes-count = { $nodes } { $nodes ->
 health-col-node = Нода
 health-col-address = Адрес
 health-col-status = Статус
+
 health-col-containers = Контейнеры
 health-col-container = Контейнер
+
 health-col-image = Образ
+
 health-col-recipe = Рецепт
 health-col-cluster = Кластер
+
 health-col-nodes = Ноды
 health-status-online = ● онлайн
+
 health-status-offline = ✗ офлайн
+
 health-container-line = { $name }  { $status }  { $image }
 
 # sm121 report
@@ -266,3 +281,7 @@ power-on-skip = { $name }: нет HA-сущности — пропущено
 power-on-failed = { $name }: ошибка вызова HA: { $error }
 power-on-cycling = ⟳ { $name }: вилка on, но узел недоступен — перецикливаю { $entity }
 power-on-off-wait-timeout = { $name }: вилка не ушла в off за 10 с (возможно, реле залипло)
+
+report-col-launches = Запуски
+report-col-up = Аптайм
+stop-not-verified = Контейнеры не подтверждены как остановленные на: { $nodes }

+ 2 - 2
src/web/events.rs

@@ -21,7 +21,7 @@ pub enum Event {
     Job {
         id: String,
         /// "serve" | "stop" | "power"
-        kind: &'static str,
+        kind: String,
         event: String,
         cluster: String,
         recipe: Option<String>,
@@ -395,7 +395,7 @@ mod tests {
     fn job_event_serializes_with_type_tag() {
         let ev = Event::Job {
             id: "j1".into(),
-            kind: "serve",
+            kind: "serve".to_string(),
             event: "started".into(),
             cluster: "c".into(),
             recipe: Some("r".into()),

+ 68 - 9
src/web/jobs.rs

@@ -5,11 +5,11 @@
 
 use super::AppState;
 use super::events::Event;
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
 use std::sync::atomic::{AtomicU64, Ordering};
 use std::time::{SystemTime, UNIX_EPOCH};
 
-#[derive(Serialize, Clone, Debug, PartialEq)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
 #[serde(rename_all = "snake_case")]
 pub enum JobStatus {
     Running,
@@ -17,11 +17,11 @@ pub enum JobStatus {
     Failed,
 }
 
-#[derive(Serialize, Clone, Debug)]
+#[derive(Serialize, Deserialize, Clone, Debug)]
 pub struct Job {
     pub id: String,
     /// "serve" | "stop" | "power"
-    pub kind: &'static str,
+    pub kind: String,
     pub cluster: String,
     pub recipe: Option<String>,
     pub status: JobStatus,
@@ -46,7 +46,7 @@ fn next_id() -> String {
 fn emit(
     app: &AppState,
     id: &str,
-    kind: &'static str,
+    kind: &str,
     event: &str,
     cluster: &str,
     recipe: Option<&str>,
@@ -54,7 +54,7 @@ fn emit(
 ) {
     let _ = app.bus.send(Event::Job {
         id: id.to_string(),
-        kind,
+        kind: kind.to_string(),
         event: event.to_string(),
         cluster: cluster.to_string(),
         recipe: recipe.map(String::from),
@@ -88,7 +88,7 @@ pub fn new_job(
         }
         jobs.push(Job {
             id: id.clone(),
-            kind,
+            kind: kind.to_string(),
             cluster: cluster.to_string(),
             recipe: recipe.map(String::from),
             status: JobStatus::Running,
@@ -96,6 +96,7 @@ pub fn new_job(
             started: now_ts(),
         });
         trim_finished(&mut jobs);
+        save_locked(&jobs);
     }
     emit(app, &id, kind, "started", cluster, recipe, "started");
     Ok(id)
@@ -111,15 +112,16 @@ pub fn finish_job(app: &AppState, id: &str, ok: bool, message: String) {
             job.message = message.clone();
         }
         trim_finished(&mut jobs);
+        save_locked(&jobs);
         jobs.iter()
             .find(|j| j.id == id)
-            .map(|j| (j.kind, j.cluster.clone(), j.recipe.clone()))
+            .map(|j| (j.kind.clone(), j.cluster.clone(), j.recipe.clone()))
     };
     if let Some((kind, cluster, recipe)) = target {
         emit(
             app,
             id,
-            kind,
+            kind.as_str(),
             if ok { "ok" } else { "failed" },
             &cluster,
             recipe.as_deref(),
@@ -188,3 +190,60 @@ pub fn spawn_report(
         finish_job(&app, &id, ok, message);
     });
 }
+
+/// The jobs registry file (next to the global config): the dashboard's
+/// history survives restarts, and jobs that were running when the process
+/// died are marked failed with a ledger hint instead of vanishing.
+fn jobs_path() -> Option<std::path::PathBuf> {
+    crate::config::default_config_dir().map(|d| d.join("web-jobs.json"))
+}
+
+pub fn load(app: &AppState) {
+    load_from(jobs_path().as_deref(), app);
+}
+
+/// Path-parameterized for tests (the real path is the user's config dir).
+pub(crate) fn load_from(path: Option<&std::path::Path>, app: &AppState) {
+    let Some(path) = path else { return };
+    let Ok(text) = std::fs::read_to_string(path) else {
+        return;
+    };
+    let loaded: Vec<Job> = match serde_json::from_str(text.as_str()) {
+        Ok(v) => v,
+        Err(e) => {
+            tracing::warn!("web jobs: corrupt {}, starting empty: {e}", path.display());
+            return;
+        }
+    };
+    let mut jobs = app.jobs.lock().unwrap();
+    *jobs = loaded
+        .into_iter()
+        .map(|mut j| {
+            if j.status == JobStatus::Running {
+                // the task itself died with the process: the run ledger may
+                // be open — `sm121 runlock close <recipe>` is the repair
+                j.status = JobStatus::Failed;
+                j.message =
+                    "interrupted: web restarted while running (check `sm121 runlock list`)".into();
+            }
+            j
+        })
+        .collect();
+    trim_finished(&mut jobs);
+    save_locked(&jobs);
+}
+
+fn save_locked(jobs: &[Job]) {
+    let Some(path) = jobs_path() else { return };
+    let json = match serde_json::to_string_pretty(jobs) {
+        Ok(j) => j,
+        Err(_) => return,
+    };
+    if let Some(dir) = path.parent() {
+        let _ = std::fs::create_dir_all(dir);
+    }
+    let tmp = path.with_extension("json.tmp");
+    if std::fs::write(&tmp, json).is_ok() {
+        let _ = std::fs::rename(&tmp, &path);
+    }
+}

+ 2 - 0
src/web/mod.rs

@@ -239,6 +239,8 @@ pub async fn run(
         jobs: Arc::new(Mutex::new(Vec::new())),
         log_attaches: Arc::new(Mutex::new(Vec::new())),
     };
+    jobs::load(&app); // history survives restarts; in-flight jobs -> failed
+
     tokio::spawn(supervisor(app.clone()));
     let listener = tokio::net::TcpListener::bind(addr).await?;
     if open {

+ 33 - 0
src/web/tests.rs

@@ -161,6 +161,39 @@ async fn token_gate_enforces_when_set() {
     );
 }
 
+#[test]
+fn jobs_persist_and_mark_interrupted() {
+    use crate::web::jobs;
+    let app = app_state(None);
+    let dir = std::env::temp_dir().join(format!("sm121-jobs-test-{}", std::process::id()));
+    std::fs::create_dir_all(&dir).unwrap();
+    let path = dir.join("jobs.json");
+    // a registry snapshot with a running job: the process died mid-flight
+    std::fs::write(
+        &path,
+        r#"[{"id":"j9","kind":"serve","cluster":"c","recipe":"r","status":"running","message":"started","started":1},
+            {"id":"j8","kind":"power","cluster":"c","recipe":null,"status":"ok","message":"on","started":2}]"#,
+    )
+    .unwrap();
+    jobs::load_from(Some(&path), &app);
+    let jobs = app.jobs.lock().unwrap().clone();
+    std::fs::remove_dir_all(&dir).unwrap();
+    assert_eq!(jobs.len(), 2);
+    let running = jobs.iter().find(|j| j.id == "j9").unwrap();
+    assert_eq!(
+        running.status,
+        jobs::JobStatus::Failed,
+        "running -> failed on boot"
+    );
+    assert!(
+        running.message.contains("runlock"),
+        "the ledger hint: {}",
+        running.message
+    );
+    let done = jobs.iter().find(|j| j.id == "j8").unwrap();
+    assert_eq!(done.status, jobs::JobStatus::Ok);
+}
+
 #[test]
 fn stale_from_snapshot_picks_unreachable_nodes() {
     let app = app_state(None);