Browse Source

feat(health): show tmux deployments in health command

Alexander Korolev 2 months ago
parent
commit
87d5b3e85e
9 changed files with 218 additions and 0 deletions
  1. 6 0
      locales/de/main.ftl
  2. 6 0
      locales/en/main.ftl
  3. 6 0
      locales/es/main.ftl
  4. 6 0
      locales/fr/main.ftl
  5. 6 0
      locales/it/main.ftl
  6. 6 0
      locales/ru/main.ftl
  7. 5 0
      src/main.rs
  8. 176 0
      src/tmux/discover.rs
  9. 1 0
      src/tmux/mod.rs

+ 6 - 0
locales/de/main.ftl

@@ -166,6 +166,12 @@ composed-dir-built = Zusammengestelltes Modellverzeichnis '{ $host }' auf '{ $no
 tmux-mods-unsupported = Recipe '{ $name }' verwendet [mods], was Docker-only ist. tmux-Bereitstellung unterstützt keine mods.
 tmux-deploy-ignored-opts = ⚠️ tmux-Bereitstellung: ignoriere Docker-only CLI-Optionen: { $opts }
 tmux-composed-model-unsupported = Recipe '{ $name }' verwendet [composed_model], was Docker-only ist. tmux-Bereitstellung unterstützt keine zusammengesetzten Modelldateien.
+tmux-health-section = Tmux:
+tmux-health-entry-running = { $recipe } — { $node }: laufend (API: { $api })
+tmux-health-entry-degraded = { $recipe } — { $node }: degradiert (Sitzung existiert, Prozess tot)
+tmux-health-entry-stopped = { $recipe } — { $node }: gestoppt
+tmux-api-ok = 200
+tmux-api-down = aus
 venv-preparing = 🔧 Bereite venv vor: { $venv }
 venv-exists = ✓ venv existiert bereits auf { $name }
 venv-creating = venv nicht gefunden auf { $name } — erstelle...

+ 6 - 0
locales/en/main.ftl

@@ -152,6 +152,12 @@ tmux-following-logs = 📜 Following logs from '{ $sess }' on { $name }...
 tmux-deploy-ignored-opts = ⚠️ tmux deploy: ignoring Docker-only CLI opts: { $opts }
 tmux-mods-unsupported = Recipe '{ $name }' uses [mods], which is Docker-only. tmux deployment does not support mods.
 tmux-composed-model-unsupported = Recipe '{ $name }' uses [composed_model], which is Docker-only. tmux deployment does not support composed model directories.
+tmux-health-section = Tmux:
+tmux-health-entry-running = { $recipe } — { $node }: running (API: { $api })
+tmux-health-entry-degraded = { $recipe } — { $node }: degraded (session exists, process dead)
+tmux-health-entry-stopped = { $recipe } — { $node }: stopped
+tmux-api-ok = 200
+tmux-api-down = down
 venv-preparing = 🔧 Preparing venv: { $venv }
 venv-exists = ✓ venv already exists on { $name }
 venv-creating = venv not found on { $name } — creating...

+ 6 - 0
locales/es/main.ftl

@@ -166,6 +166,12 @@ composed-dir-built = Creado directorio de modelo compuesto '{ $host }' en '{ $no
 tmux-mods-unsupported = Recipe '{ $name }' usa [mods], que es Docker-only. El despliegue tmux no admite mods.
 tmux-deploy-ignored-opts = ⚠️ tmux deploy: ignorando opciones CLI Docker-only: { $opts }
 tmux-composed-model-unsupported = Recipe '{ $name }' usa [composed_model], que es Docker-only. El despliegue tmux no admite directorios de modelos compuestos.
+tmux-health-section = Tmux:
+tmux-health-entry-running = { $recipe } — { $node }: en ejecución (API: { $api })
+tmux-health-entry-degraded = { $recipe } — { $node }: degradado (la sesión existe, proceso muerto)
+tmux-health-entry-stopped = { $recipe } — { $node }: detenido
+tmux-api-ok = 200
+tmux-api-down = caído
 venv-preparing = 🔧 Preparando venv: { $venv }
 venv-exists = ✓ venv ya existe en { $name }
 venv-creating = venv no encontrado en { $name } — creando...

+ 6 - 0
locales/fr/main.ftl

@@ -166,6 +166,12 @@ composed-dir-built = Répertoire de modèle composé créé : '{ $host }' sur '{
 tmux-mods-unsupported = Recipe '{ $name }' utilise [mods], ce qui est Docker-only. Le déploiement tmux ne prend pas en charge les mods.
 tmux-deploy-ignored-opts = ⚠️ tmux deploy : ignore les options CLI Docker-only : { $opts }
 tmux-composed-model-unsupported = Recipe '{ $name }' utilise [composed_model], ce qui est Docker-only. Le déploiement tmux ne prend pas en charge les répertoires de modèles composés.
+tmux-health-section = Tmux:
+tmux-health-entry-running = { $recipe } — { $node }: en cours (API : { $api })
+tmux-health-entry-degraded = { $recipe } — { $node }: dégradé (la session existe, processus mort)
+tmux-health-entry-stopped = { $recipe } — { $node }: arrêté
+tmux-api-ok = 200
+tmux-api-down = arrêté
 venv-preparing = 🔧 Préparation du venv : { $venv }
 venv-exists = ✓ venv existe déjà sur { $name }
 venv-creating = venv non trouvé sur { $name } — création...

+ 6 - 0
locales/it/main.ftl

@@ -166,6 +166,12 @@ composed-dir-built = Creata directory del modello composto '{ $host }' su '{ $no
 tmux-mods-unsupported = Recipe '{ $name }' usa [mods], che è Docker-only. Il deployment tmux non supporta i mods.
 tmux-deploy-ignored-opts = ⚠️ tmux deploy: ignoro le opzioni CLI Docker-only: { $opts }
 tmux-composed-model-unsupported = Recipe '{ $name }' usa [composed_model], che è Docker-only. Il deployment tmux non supporta directory di modelli composti.
+tmux-health-section = Tmux:
+tmux-health-entry-running = { $recipe } — { $node }: in esecuzione (API: { $api })
+tmux-health-entry-degraded = { $recipe } — { $node }: degradata (la sessione esiste, processo morto)
+tmux-health-entry-stopped = { $recipe } — { $node }: fermato
+tmux-api-ok = 200
+tmux-api-down = giù
 venv-preparing = 🔧 Preparazione venv: { $venv }
 venv-exists = ✓ venv esiste già su { $name }
 venv-creating = venv non trovato su { $name } — creazione...

+ 6 - 0
locales/ru/main.ftl

@@ -175,6 +175,12 @@ composed-dir-built = Создан каталог составной модели
 tmux-mods-unsupported = Recipe '{ $name }' использует [mods], который является Docker-only. Развертывание tmux не поддерживает mods.
 tmux-deploy-ignored-opts = ⚠️ tmux deploy: игнорируются Docker-only CLI опции: { $opts }
 tmux-composed-model-unsupported = Recipe '{ $name }' использует [composed_model], который является Docker-only. Развертывание tmux не поддерживает составные каталоги моделей.
+tmux-health-section = Tmux:
+tmux-health-entry-running = { $recipe } — { $node }: запущен (API: { $api })
+tmux-health-entry-degraded = { $recipe } — { $node }: деградирован (сессия существует, процесс мёртв)
+tmux-health-entry-stopped = { $recipe } — { $node }: остановлен
+tmux-api-ok = 200
+tmux-api-down = недоступен
 venv-preparing = 🔧 Подготовка venv: { $venv }
 venv-exists = ✓ venv уже существует на { $name }
 venv-creating = venv не найден на { $name } — создаю...

+ 5 - 0
src/main.rs

@@ -412,10 +412,15 @@ async fn handle_health(
             Some(ref name) => {
                 let cluster = select_cluster_by_name(config, name)?;
                 docker::health::health_check(config, &cluster, false).await?;
+                let tmux_entries = tmux::discover::tmux_health_for_cluster(config, &cluster).await;
+                tmux::discover::print_tmux_health(&tmux_entries);
             }
             None => {
                 for cluster in &config.clusters {
                     docker::health::health_check(config, cluster, false).await?;
+                    let tmux_entries =
+                        tmux::discover::tmux_health_for_cluster(config, cluster).await;
+                    tmux::discover::print_tmux_health(&tmux_entries);
                 }
                 docker::health::health_summary(config).await?;
             }

+ 176 - 0
src/tmux/discover.rs

@@ -0,0 +1,176 @@
+//! Tmux deployment discovery for the `health` command.
+//!
+//! The `health` command is Docker-only by default. This module adds
+//! tmux awareness: it scans `recipes/` for tmux-deployed recipes,
+//! checks each node for active sessions, and reports status alongside
+//! the Docker container listing.
+
+use tracing::warn;
+
+use crate::config::{Cluster, Config, DeployTarget, Recipe};
+use crate::health;
+use crate::tmux::TmuxManager;
+
+/// Lists all recipe directory names in `recipes/`.
+pub fn list_recipe_dirs() -> Vec<String> {
+    let mut recipes = Vec::new();
+    if let Ok(entries) = std::fs::read_dir("recipes") {
+        for entry in entries.flatten() {
+            if entry.path().is_dir() {
+                let name = entry.file_name();
+                let Some(name) = name.to_str() else {
+                    continue;
+                };
+                recipes.push(name.to_string());
+            }
+        }
+    }
+    recipes.sort();
+    recipes
+}
+
+/// Finds all recipes with `deploy = "tmux"`.
+pub async fn find_tmux_recipes() -> Vec<(String, Recipe)> {
+    let dirs = list_recipe_dirs();
+    let mut recipes = Vec::new();
+    for dir in dirs {
+        let Ok(recipe) = crate::config::load_recipe(&dir).await else {
+            continue;
+        };
+        if recipe.deploy == DeployTarget::Tmux {
+            recipes.push((dir, recipe));
+        }
+    }
+    recipes
+}
+
+/// Health entry for a single tmux deployment on a node.
+pub struct TmuxHealthEntry {
+    pub recipe_display: String,
+    pub node_name: String,
+    pub session_exists: bool,
+    pub process_running: bool,
+    pub api_ok: bool,
+}
+
+/// Checks tmux health for all tmux-deployed recipes on a cluster.
+///
+/// For each recipe with `deploy = "tmux"`, runs a deep health check
+/// (session existence, process liveness, GPU, API) on every node.
+/// Returns one entry per node per recipe.
+pub async fn tmux_health_for_cluster(config: &Config, cluster: &Cluster) -> Vec<TmuxHealthEntry> {
+    let tmux_recipes = find_tmux_recipes().await;
+    let manager = TmuxManager;
+    let mut entries = Vec::new();
+
+    for (_dir, recipe) in tmux_recipes {
+        let report = health::check_cluster_health(&manager, config, cluster, &recipe, false).await;
+        match report {
+            Ok(report) => {
+                for node_health in &report.nodes {
+                    entries.push(TmuxHealthEntry {
+                        recipe_display: recipe.name.clone(),
+                        node_name: node_health.name.clone(),
+                        session_exists: node_health.session_exists,
+                        process_running: node_health.process_running,
+                        api_ok: node_health.port_accessible,
+                    });
+                }
+            }
+            Err(e) => warn!("Tmux health check failed for '{}': {}", recipe.name, e),
+        }
+    }
+
+    entries
+}
+
+/// Prints tmux health entries to stdout.
+///
+/// Shows nothing if there are no tmux-deployed recipes.
+pub fn print_tmux_health(entries: &[TmuxHealthEntry]) {
+    if entries.is_empty() {
+        return;
+    }
+
+    println!();
+    println!("{}", fluent_i18n::t!("tmux-health-section"));
+    for entry in entries {
+        let status = if entry.session_exists && entry.process_running {
+            let api_str = if entry.api_ok {
+                fluent_i18n::t!("tmux-api-ok")
+            } else {
+                fluent_i18n::t!("tmux-api-down")
+            };
+            fluent_i18n::t!("tmux-health-entry-running", {
+                "recipe" => &entry.recipe_display,
+                "node" => &entry.node_name,
+                "api" => &api_str,
+            })
+        } else if entry.session_exists {
+            fluent_i18n::t!("tmux-health-entry-degraded", {
+                "recipe" => &entry.recipe_display,
+                "node" => &entry.node_name,
+            })
+        } else {
+            fluent_i18n::t!("tmux-health-entry-stopped", {
+                "recipe" => &entry.recipe_display,
+                "node" => &entry.node_name,
+            })
+        };
+        println!("  {}", status);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn list_recipe_dirs_finds_directories() {
+        let dirs = list_recipe_dirs();
+        // Should find at least the laguna recipe
+        assert!(dirs.iter().any(|d| d == "laguna-s2.1-nvfp4"));
+        // Should be sorted
+        let sorted = dirs.windows(2).all(|w| w[0] <= w[1]);
+        assert!(sorted);
+    }
+
+    #[tokio::test]
+    async fn find_tmux_recipes_finds_tmux_deployed() {
+        // This test runs in the project directory, so it should find
+        // the laguna recipe which has deploy = "tmux"
+        let recipes = find_tmux_recipes().await;
+        assert!(recipes.iter().any(|(dir, _)| dir == "laguna-s2.1-nvfp4"));
+        for (_dir, recipe) in &recipes {
+            assert_eq!(recipe.deploy, DeployTarget::Tmux);
+        }
+    }
+
+    #[test]
+    fn print_tmux_health_empty_is_noop() {
+        // Should not panic or print anything
+        print_tmux_health(&[]);
+    }
+
+    #[test]
+    fn print_tmux_health_with_entries() {
+        let entries = vec![
+            TmuxHealthEntry {
+                recipe_display: "Test Recipe".to_string(),
+                node_name: "dgx1".to_string(),
+                session_exists: true,
+                process_running: true,
+                api_ok: true,
+            },
+            TmuxHealthEntry {
+                recipe_display: "Test Recipe".to_string(),
+                node_name: "dgx2".to_string(),
+                session_exists: false,
+                process_running: false,
+                api_ok: false,
+            },
+        ];
+        // Should not panic
+        print_tmux_health(&entries);
+    }
+}

+ 1 - 0
src/tmux/mod.rs

@@ -1,4 +1,5 @@
 pub mod commands;
+pub mod discover;
 pub mod ops;
 pub mod serve;
 pub mod session;