Browse Source

config: non-blocking extra_entities (blocks = false)

extra_entities now accepts two forms:
  "switch.plug"                      — blocking (default, as before)
  { entity = "switch.fan", blocks = false } — non-blocking: still
  toggled by power, tracked + displayed, but 'off' never takes the
  cluster down (new ExtraEntity enum, untagged; blocks defaults to
  true).

- home_assistant::extra_entities_states → EntityStates { blocking_off,
  soft_off }; verdict/health/web all consume the split
- health: soft-off entities print as '⚙ X off (non-blocking)' in both
  the up and down header lines (i18n: health-soft-off, 6 locales)
- web: ClusterSnap.soft_off; ⚙ badge on up clusters, reason tooltip
  includes soft entities
- power: entity() ids everywhere (toggle/validate/dry-run)
- local config: sw7_fan → blocks=false; qwen38 now reports UP with its
  running container again; 4nodes-switch still down via blocking sw6_mt
- ExtraEntity moved to config.rs + power tests split (500-line cap);
  new parse tests (192 total)
Alexander Korolev 2 months ago
parent
commit
6c5f9922b5

+ 13 - 6
AGENTS.md

@@ -60,11 +60,15 @@ BOTH locations aborts with a `huggingface-cli download` hint. Later serves read
 the model locally — no streaming weights over slow shared storage at load time.
 
 **Cluster health is cluster-level** (`health`, web dashboard): a cluster is
-UP only when *all* its nodes are reachable AND its `extra_entities` (HA
-switches) report `on`. One offline node — or a switch off — takes the whole
-cluster down: containers on surviving nodes are not reported (serve/stop/
-jobs views all ignore them; `power on` stays available). Shared rule:
-`cluster_state::verdict` + `home_assistant::extra_entities_off`.
+UP only when *all* its nodes are reachable AND its **blocking**
+`extra_entities` (HA switches) report `on`. One offline node — or a blocking
+switch off — takes the whole cluster down: containers on surviving nodes are
+not reported (serve/stop/jobs views all ignore them; `power on` stays
+available). `extra_entities` entries can opt out of gating with
+`blocks = false` (e.g. a fan that may be off while the cluster serves) —
+non-blocking entities are still toggled by `power` and shown as a soft badge,
+but never take the cluster down. Shared rule:
+`cluster_state::verdict` + `home_assistant::extra_entities_states`.
 
 `serve --clear` deletes the **host-side `extra_mounts` folders** (alias/host
 — repo-based path refs are never touched) on the launch nodes with
@@ -449,7 +453,10 @@ cluster_sources = ["/path/clusters.toml"]   # extra cluster def files: `ssh_user
 name = "c"
 master_ib_ip = "10.0.0.1"
 nodes = [{ name = "n", ssh_ip = "...", ib_ip = "...", rank = 0, entity = "switch.n" }]
-extra_entities = ["switch.network"] # cluster-level HA entities
+extra_entities = [                  # cluster-level HA entities
+    "switch.network",                # bare string = blocking (off → cluster down)
+    { entity = "switch.fan", blocks = false },  # non-blocking: toggled + shown, never gates up/down
+]
 
 [nccl."c"]
 ib_hca = "..."

+ 1 - 0
locales/de/main.ftl

@@ -38,6 +38,7 @@ 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)
 health-no-containers-inline = , keine Container

+ 1 - 0
locales/en/main.ftl

@@ -38,6 +38,7 @@ health-partial-online =  ({ $online } online, { $offline } offline)
 health-node-online-cluster-down =   ◍ { $name } ({ $ip }){ $net } — online (cluster offline)
 health-free-count = — { $free } free
 health-cluster-down = ⏻ cluster offline
+health-soft-off = ⚙ { $list } off (non-blocking)
 health-net-status =  [net: { $details }]
 health-no-containers = (no containers)
 health-no-containers-inline = , no containers

+ 1 - 0
locales/es/main.ftl

@@ -38,6 +38,7 @@ health-partial-online =  ({ $online } en línea, { $offline } desconectados)
 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-soft-off = ⚙ { $list } apagado (no bloqueante)
 health-net-status =  [red: { $details }]
 health-no-containers = (sin contenedores)
 health-no-containers-inline = , sin contenedores

+ 1 - 0
locales/fr/main.ftl

@@ -38,6 +38,7 @@ 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)
 health-no-containers-inline = , aucun conteneur

+ 1 - 0
locales/it/main.ftl

@@ -38,6 +38,7 @@ 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)
 health-no-containers-inline = , nessun container

+ 1 - 0
locales/ru/main.ftl

@@ -40,6 +40,7 @@ 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 = (нет контейнеров)
 health-no-containers-inline = , нет контейнеров

+ 45 - 0
src/config.rs

@@ -3,6 +3,51 @@ use serde::Deserialize;
 use std::collections::{HashMap, HashSet};
 use tracing::info;
 
+/// A cluster-level HA entity (smart plug, switch, fan, …).
+///
+/// Two TOML forms:
+/// ```toml
+/// # blocking (default) — "off" takes the whole cluster down
+/// extra_entities = ["switch.network"]
+/// # non-blocking — tracked & toggled by `power`, but "off" does NOT
+/// # make the cluster offline (e.g. a fan that may be off while serving)
+/// extra_entities = [{ entity = "switch.fan", blocks = false }]
+/// ```
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+#[serde(untagged)]
+pub enum ExtraEntity {
+    /// Bare string — always blocking.
+    Simple(String),
+    /// Table `{ entity = "...", blocks = <bool> }`; `blocks` defaults to true.
+    Detailed {
+        entity: String,
+        #[serde(default = "default_true")]
+        blocks: bool,
+    },
+}
+
+fn default_true() -> bool {
+    true
+}
+
+impl ExtraEntity {
+    /// The HA entity id (`domain.name`).
+    pub fn entity(&self) -> &str {
+        match self {
+            ExtraEntity::Simple(s) => s,
+            ExtraEntity::Detailed { entity, .. } => entity,
+        }
+    }
+    /// Whether an "off" state counts against the cluster's up/down.
+    /// Bare strings are always blocking; `blocks = false` opts out.
+    pub fn blocks(&self) -> bool {
+        match self {
+            ExtraEntity::Simple(_) => true,
+            ExtraEntity::Detailed { blocks, .. } => *blocks,
+        }
+    }
+}
+
 mod types;
 pub use types::*;
 

+ 2 - 1
src/config/types.rs

@@ -1,3 +1,4 @@
+use super::ExtraEntity;
 use serde::Deserialize;
 use std::collections::HashMap;
 
@@ -50,7 +51,7 @@ pub struct Cluster {
     pub master_ib_ip: Option<String>,
     pub nodes: Vec<Node>,
     #[serde(default)]
-    pub extra_entities: Vec<String>,
+    pub extra_entities: Vec<ExtraEntity>,
 }
 
 #[derive(Clone, Deserialize)]

+ 19 - 6
src/docker/health.rs

@@ -31,8 +31,8 @@ pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) ->
     // Check HA cluster-level entities (switch, etc.) in parallel.
     let config_ha = config.clone();
     let cluster_ha = cluster.clone();
-    let ha_handle = crate::locale::spawn_with_locale(async move {
-        crate::home_assistant::extra_entities_off(&config_ha, &cluster_ha).await
+    let es_handle = crate::locale::spawn_with_locale(async move {
+        crate::home_assistant::extra_entities_states(&config_ha, &cluster_ha).await
     });
 
     // Build the network check command snippet for this cluster's NCCL interfaces.
@@ -136,8 +136,9 @@ pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) ->
         }
     }
 
-    // Resolve HA result + cluster verdict.
-    let switch_off = ha_handle.await.unwrap_or(false);
+    // Resolve HA states + cluster verdict.
+    let es = es_handle.await.unwrap_or_default();
+    let switch_off = es.blocking_off;
     let total = nodes.len();
     let online = node_health.iter().filter(|n| n.online).count();
     let offline = total - online;
@@ -167,6 +168,12 @@ pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) ->
                 fluent_i18n::t!("health-partial-online", { "online" => online, "offline" => offline })
             );
         }
+        if !es.soft_off.is_empty() {
+            print!(
+                " {}",
+                fluent_i18n::t!("health-soft-off", { "list" => &es.soft_off.join(", ") })
+            );
+        }
         println!(" {}", fluent_i18n::t!("health-cluster-down"));
     } else {
         // `v.up` ⇒ every node is reachable.
@@ -178,6 +185,12 @@ pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) ->
             " {}",
             fluent_i18n::t!("health-free-count", { "free" => v.free })
         );
+        if !es.soft_off.is_empty() {
+            print!(
+                " {}",
+                fluent_i18n::t!("health-soft-off", { "list" => &es.soft_off.join(", ") })
+            );
+        }
     }
     // Container count: only for up clusters — the down path already
     // printed its header line (with newline), and an offline cluster
@@ -274,7 +287,7 @@ pub async fn health_summary(config: &Config) -> Result<()> {
             (
                 c.name.clone(),
                 crate::locale::spawn_with_locale(async move {
-                    crate::home_assistant::extra_entities_off(&config, &cluster).await
+                    crate::home_assistant::extra_entities_states(&config, &cluster).await
                 }),
             )
         })
@@ -321,7 +334,7 @@ pub async fn health_summary(config: &Config) -> Result<()> {
     let ha_results: HashMap<String, bool> = futures::future::join_all(
         ha_handles
             .into_iter()
-            .map(|(name, h)| async move { (name, h.await.unwrap_or(false)) }),
+            .map(|(name, h)| async move { (name, h.await.unwrap_or_default().blocking_off) }),
     )
     .await
     .into_iter()

+ 6 - 1
src/ends.rs

@@ -109,6 +109,11 @@ pub fn cluster_entities(cluster: &Cluster) -> Vec<String> {
             entities.push(entity.clone());
         }
     }
-    entities.extend(cluster.extra_entities.iter().cloned());
+    entities.extend(
+        cluster
+            .extra_entities
+            .iter()
+            .map(|e| e.entity().to_string()),
+    );
     entities
 }

+ 34 - 15
src/home_assistant.rs

@@ -130,36 +130,55 @@ impl Client {
     }
 }
 
-/// True when HA is configured and any of the cluster's `extra_entities`
-/// (power switches) reports anything other than "on" — including HA being
-/// unreachable (unknown is treated as off, same as the `health` command
-/// has always done). No HA config, no entities, or an unparseable HA URL
-/// → `false` (nothing to check ≠ off).
-pub async fn extra_entities_off(config: &Config, cluster: &Cluster) -> bool {
+/// HA states for a cluster's `extra_entities` (parallel GETs).
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct EntityStates {
+    /// A *blocking* entity reports anything other than "on" — including
+    /// HA being unreachable (unknown is treated as off, same as the
+    /// `health` command has always done). Blocking-off takes the whole
+    /// cluster down.
+    pub blocking_off: bool,
+    /// Non-blocking entities (`blocks = false`) that are not "on" —
+    /// tracked and displayed, but they never affect up/down.
+    pub soft_off: Vec<String>,
+}
+
+/// Probe the cluster's `extra_entities`. No HA config, no entities, or an
+/// unparseable HA URL → default (nothing to check ≠ off).
+pub async fn extra_entities_states(config: &Config, cluster: &Cluster) -> EntityStates {
     let Some(ha_cfg) = &config.home_assistant else {
-        return false;
+        return EntityStates::default();
     };
     if cluster.extra_entities.is_empty() {
-        return false;
+        return EntityStates::default();
     }
     let Ok(client) = Client::new(ha_cfg) else {
-        return false;
+        return EntityStates::default();
     };
     let mut handles = Vec::new();
     for e in &cluster.extra_entities {
         let client = client.clone();
-        let e = e.clone();
+        let id = e.entity().to_string();
+        let blocks = e.blocks();
         handles.push(crate::locale::spawn_with_locale(async move {
-            client.get_state(&e).await
+            (id.clone(), blocks, client.get_state(&id).await)
         }));
     }
+    let mut st = EntityStates::default();
     for h in futures::future::join_all(handles).await {
-        match h {
-            Ok(Some(s)) if s == "on" => {}
-            _ => return true,
+        let Ok((id, blocks, state)) = h else {
+            continue;
+        };
+        let on = matches!(&state, Some(s) if s == "on");
+        if !on {
+            if blocks {
+                st.blocking_off = true;
+            } else {
+                st.soft_off.push(id);
+            }
         }
     }
-    false
+    st
 }
 
 /// Parse `switch.dgx1` into `("switch", "dgx1")` so callers can build

+ 22 - 132
src/power.rs

@@ -42,9 +42,10 @@ pub async fn power_off(
             }
         }
         for extra in &cluster.extra_entities {
+            let id = extra.entity().to_string();
             println!(
                 "{}",
-                fluent_i18n::t!("power-switch-would-off-extra", { "extra" => extra })
+                fluent_i18n::t!("power-switch-would-off-extra", { "extra" => &id })
             );
         }
         return Ok(());
@@ -98,14 +99,7 @@ pub async fn power_off(
     if let Some(ha_cfg) = &config.home_assistant {
         let ha = HaClient::new(ha_cfg)?;
         crate::home_assistant::warn_if_token_looks_short(&ha_cfg.token);
-        toggle_entities(
-            &ha,
-            "turn_off",
-            &targets,
-            &cluster.extra_entities,
-            "cluster",
-        )
-        .await;
+        toggle_entities(&ha, "turn_off", &targets, &extra_ids(cluster), "cluster").await;
     } else {
         info!("{}", fluent_i18n::t!("power-off-no-ha"));
     }
@@ -152,9 +146,10 @@ pub async fn power_on(
             }
         }
         for extra in &cluster.extra_entities {
+            let id = extra.entity().to_string();
             println!(
                 "{}",
-                fluent_i18n::t!("power-on-would-on", { "entity" => extra })
+                fluent_i18n::t!("power-on-would-on", { "entity" => &id })
             );
         }
         return Ok(());
@@ -164,10 +159,19 @@ pub async fn power_on(
         "{}",
         fluent_i18n::t!("power-on-head", { "nodes" => targets.len() })
     );
-    toggle_entities(&ha, "turn_on", &targets, &cluster.extra_entities, "cluster").await;
+    toggle_entities(&ha, "turn_on", &targets, &extra_ids(cluster), "cluster").await;
     Ok(())
 }
 
+/// Entity ids of a cluster's `extra_entities` (both forms).
+fn extra_ids(cluster: &Cluster) -> Vec<String> {
+    cluster
+        .extra_entities
+        .iter()
+        .map(|e| e.entity().to_string())
+        .collect()
+}
+
 /// Call HA `service` for each per-node entity, then each cluster-level
 /// `extra_entities`.  Validation is the same in both cases (entity IDs
 /// must be `domain.name`).
@@ -245,12 +249,12 @@ pub async fn power_switch(
         .cloned()
         .collect();
     let to_extras: std::collections::HashSet<&str> =
-        to.extra_entities.iter().map(|e| e.as_str()).collect();
+        to.extra_entities.iter().map(|e| e.entity()).collect();
     let disable_extras: Vec<String> = from
         .extra_entities
         .iter()
-        .filter(|e| !to_extras.contains(e.as_str()))
-        .cloned()
+        .filter(|e| !to_extras.contains(e.entity()))
+        .map(|e| e.entity().to_string())
         .collect();
 
     info!(
@@ -286,9 +290,10 @@ pub async fn power_switch(
             }
         }
         for extra in &to.extra_entities {
+            let id = extra.entity().to_string();
             println!(
                 "{}",
-                fluent_i18n::t!("power-switch-would-on-extra", { "extra" => extra })
+                fluent_i18n::t!("power-switch-would-on-extra", { "extra" => &id })
             );
         }
         return Ok(());
@@ -345,7 +350,7 @@ pub async fn power_switch(
             "{}",
             fluent_i18n::t!("power-switch-powering-on", { "nodes" => to.nodes.len(), "entities" => to.extra_entities.len(), "to" => &to.name })
         );
-        toggle_entities(&ha, "turn_on", &to.nodes, &to.extra_entities, "to").await;
+        toggle_entities(&ha, "turn_on", &to.nodes, &extra_ids(to), "to").await;
     } else {
         info!("{}", fluent_i18n::t!("power-switch-no-ha"));
     }
@@ -382,119 +387,4 @@ fn select_targets(cluster: &Cluster, node_names: &[String], op: &str) -> Result<
 }
 
 #[cfg(test)]
-mod tests {
-    use super::*;
-
-    fn cfg_with_ha() -> Config {
-        let toml = r#"
-ssh_user = "u"
-ssh_key_path = "/k"
-[[clusters]]
-name = "x"
-master_ib_ip = "1.1.1.1"
-nodes = [
-    { name = "n1", ssh_ip = "10.0.0.1", ib_ip = "10.0.0.1", rank = 0, entity = "switch.n1" },
-    { name = "n2", ssh_ip = "10.0.0.2", ib_ip = "10.0.0.2", rank = 1 },
-]
-extra_entities = ["switch.network_x"]
-[nccl."x"]
-ib_hca = "h"
-socket_ifname = "s"
-ib_gid_index = 1
-ib_disable = 0
-
-[home_assistant]
-url = "http://ha.local:8123"
-token = "abcdef0123456789abcdef0123456789"
-shutdown_delay_seconds = 5
-"#;
-        toml::from_str(toml).unwrap()
-    }
-
-    fn cfg_no_ha() -> Config {
-        let toml = r#"
-ssh_user = "u"
-ssh_key_path = "/k"
-[[clusters]]
-name = "x"
-master_ib_ip = "1.1.1.1"
-nodes = [
-    { name = "n1", ssh_ip = "10.0.0.1", ib_ip = "10.0.0.1", rank = 0, entity = "switch.n1" },
-]
-[nccl."x"]
-ib_hca = "h"
-socket_ifname = "s"
-ib_gid_index = 1
-ib_disable = 0
-"#;
-        toml::from_str(toml).unwrap()
-    }
-
-    #[test]
-    fn select_targets_filters_by_node() {
-        let cfg = cfg_with_ha();
-        let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
-        let t = select_targets(&c, &["n1".to_string()], "test").unwrap();
-        assert_eq!(t.len(), 1);
-        assert_eq!(t[0].name, "n1");
-    }
-
-    #[test]
-    fn select_targets_all() {
-        let cfg = cfg_with_ha();
-        let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
-        let t = select_targets(&c, &[], "test").unwrap();
-        assert_eq!(t.len(), 2);
-    }
-
-    #[test]
-    fn select_targets_multiple_nodes() {
-        // `stop --node a --node b --off` must power off *both* nodes, not just
-        // the first. (Regression for I-5.)
-        let cfg = cfg_with_ha();
-        let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
-        let t = select_targets(&c, &["n1".to_string(), "n2".to_string()], "test").unwrap();
-        assert_eq!(t.len(), 2);
-        let names: Vec<_> = t.iter().map(|n| n.name.as_str()).collect();
-        assert!(names.contains(&"n1"));
-        assert!(names.contains(&"n2"));
-    }
-
-    #[test]
-    fn select_targets_partial_missing_errors() {
-        let cfg = cfg_with_ha();
-        let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
-        // One valid + one missing → error listing the missing one.
-        let r = select_targets(&c, &["n1".to_string(), "nope".to_string()], "test");
-        assert!(r.is_err());
-        assert!(r.unwrap_err().to_string().contains("nope"));
-    }
-
-    #[test]
-    fn select_targets_unknown_node_errors() {
-        let cfg = cfg_with_ha();
-        let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
-        let r = select_targets(&c, &["nope".to_string()], "test");
-        assert!(r.is_err());
-    }
-
-    #[test]
-    fn power_on_requires_ha_config() {
-        let cfg = cfg_no_ha();
-        assert!(cfg.home_assistant.is_none());
-    }
-
-    #[test]
-    fn cluster_extra_entities_parsed() {
-        let cfg = cfg_with_ha();
-        let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
-        assert_eq!(c.extra_entities, vec!["switch.network_x"]);
-    }
-
-    #[test]
-    fn cluster_without_extra_entities_is_empty() {
-        let cfg = cfg_no_ha();
-        let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
-        assert!(c.extra_entities.is_empty());
-    }
-}
+mod tests;

+ 147 - 0
src/power/tests.rs

@@ -0,0 +1,147 @@
+use super::*;
+
+fn cfg_with_ha() -> Config {
+    let toml = r#"
+ssh_user = "u"
+ssh_key_path = "/k"
+[[clusters]]
+name = "x"
+master_ib_ip = "1.1.1.1"
+nodes = [
+    { name = "n1", ssh_ip = "10.0.0.1", ib_ip = "10.0.0.1", rank = 0, entity = "switch.n1" },
+    { name = "n2", ssh_ip = "10.0.0.2", ib_ip = "10.0.0.2", rank = 1 },
+]
+extra_entities = ["switch.network_x"]
+[nccl."x"]
+ib_hca = "h"
+socket_ifname = "s"
+ib_gid_index = 1
+ib_disable = 0
+
+[home_assistant]
+url = "http://ha.local:8123"
+token = "abcdef0123456789abcdef0123456789"
+shutdown_delay_seconds = 5
+"#;
+    toml::from_str(toml).unwrap()
+}
+
+fn cfg_no_ha() -> Config {
+    let toml = r#"
+ssh_user = "u"
+ssh_key_path = "/k"
+[[clusters]]
+name = "x"
+master_ib_ip = "1.1.1.1"
+nodes = [
+    { name = "n1", ssh_ip = "10.0.0.1", ib_ip = "10.0.0.1", rank = 0, entity = "switch.n1" },
+]
+[nccl."x"]
+ib_hca = "h"
+socket_ifname = "s"
+ib_gid_index = 1
+ib_disable = 0
+"#;
+    toml::from_str(toml).unwrap()
+}
+
+#[test]
+fn select_targets_filters_by_node() {
+    let cfg = cfg_with_ha();
+    let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
+    let t = select_targets(&c, &["n1".to_string()], "test").unwrap();
+    assert_eq!(t.len(), 1);
+    assert_eq!(t[0].name, "n1");
+}
+
+#[test]
+fn select_targets_all() {
+    let cfg = cfg_with_ha();
+    let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
+    let t = select_targets(&c, &[], "test").unwrap();
+    assert_eq!(t.len(), 2);
+}
+
+#[test]
+fn select_targets_multiple_nodes() {
+    // `stop --node a --node b --off` must power off *both* nodes, not just
+    // the first. (Regression for I-5.)
+    let cfg = cfg_with_ha();
+    let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
+    let t = select_targets(&c, &["n1".to_string(), "n2".to_string()], "test").unwrap();
+    assert_eq!(t.len(), 2);
+    let names: Vec<_> = t.iter().map(|n| n.name.as_str()).collect();
+    assert!(names.contains(&"n1"));
+    assert!(names.contains(&"n2"));
+}
+
+#[test]
+fn select_targets_partial_missing_errors() {
+    let cfg = cfg_with_ha();
+    let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
+    // One valid + one missing → error listing the missing one.
+    let r = select_targets(&c, &["n1".to_string(), "nope".to_string()], "test");
+    assert!(r.is_err());
+    assert!(r.unwrap_err().to_string().contains("nope"));
+}
+
+#[test]
+fn select_targets_unknown_node_errors() {
+    let cfg = cfg_with_ha();
+    let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
+    let r = select_targets(&c, &["nope".to_string()], "test");
+    assert!(r.is_err());
+}
+
+#[test]
+fn power_on_requires_ha_config() {
+    let cfg = cfg_no_ha();
+    assert!(cfg.home_assistant.is_none());
+}
+
+#[test]
+fn cluster_extra_entities_parsed() {
+    use crate::config::ExtraEntity;
+    let cfg = cfg_with_ha();
+    let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
+    assert_eq!(
+        c.extra_entities,
+        vec![ExtraEntity::Simple("switch.network_x".into())]
+    );
+}
+
+#[test]
+fn extra_entity_forms_blocking_and_soft() {
+    use crate::config::ExtraEntity;
+    let toml = r#"
+ssh_user = "u"
+ssh_key_path = "/k"
+[[clusters]]
+name = "x"
+nodes = [{ name = "n1", ssh_ip = "10.0.0.1", rank = 0 }]
+extra_entities = ["switch.plug", { entity = "switch.fan", blocks = false }, { entity = "switch.other" }]
+"#;
+    let cfg: crate::config::Config = toml::from_str(toml).unwrap();
+    let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
+    let e = &c.extra_entities;
+    assert_eq!(e.len(), 3);
+    assert!(matches!(&e[0], ExtraEntity::Simple(s) if s == "switch.plug"));
+    assert!(e[0].blocks());
+    assert!(
+        matches!(&e[1], ExtraEntity::Detailed { entity, blocks } if entity == "switch.fan" && !blocks)
+    );
+    assert!(!e[1].blocks());
+    // table form without `blocks` defaults to blocking
+    assert!(
+        matches!(&e[2], ExtraEntity::Detailed { entity, blocks } if entity == "switch.other" && *blocks)
+    );
+    assert!(e[2].blocks());
+    assert_eq!(e[1].entity(), "switch.fan");
+}
+
+#[test]
+fn cluster_without_extra_entities_is_empty() {
+    let cfg = cfg_no_ha();
+    let c = crate::config::select_cluster_by_name(&cfg, "x").unwrap();
+    assert!(c.extra_entities.is_empty());
+}

+ 61 - 8
src/web/events.rs

@@ -7,7 +7,6 @@
 use crate::config::Config;
 use crate::web::probe::ContainerSnap;
 use serde::Serialize;
-use std::collections::HashMap;
 use std::time::{SystemTime, UNIX_EPOCH};
 
 /// One SSE event (serialized as `{"type":"state"|"job", ...}` on the wire).
@@ -44,10 +43,12 @@ pub struct NodeSnap {
 pub struct ClusterSnap {
     pub name: String,
     pub nodes: Vec<NodeSnap>,
-    /// All nodes reachable AND HA switches on (cluster-level verdict).
+    /// All nodes reachable AND blocking HA switches on (cluster verdict).
     pub up: bool,
-    /// HA `extra_entities` report anything but "on".
+    /// A blocking `extra_entity` reports anything but "on".
     pub switch_off: bool,
+    /// Non-blocking entities that are off (display only, e.g. a fan).
+    pub soft_off: Vec<String>,
     /// Free nodes; 0 when the cluster is down.
     pub free: u32,
 }
@@ -74,10 +75,12 @@ fn now_ts() -> u64 {
 /// cluster reports no containers (even on nodes that are alive).
 pub fn build_snapshot(
     config: &Config,
-    nodes: &HashMap<String, crate::web::probe::WebNodeState>,
-    switch_off: &HashMap<String, bool>,
+    fleet: &crate::web::probe::Fleet,
     jobs: &[crate::web::jobs::Job],
 ) -> Snapshot {
+    let nodes = &fleet.nodes;
+    let switch_off = &fleet.switch_off;
+    let soft_off = &fleet.soft_off;
     let mut clusters = Vec::with_capacity(config.clusters.len());
     for c in &config.clusters {
         let mut node_snaps = Vec::with_capacity(c.nodes.len());
@@ -114,6 +117,7 @@ pub fn build_snapshot(
             nodes: node_snaps,
             up: v.up,
             switch_off: so,
+            soft_off: soft_off.get(&c.name).cloned().unwrap_or_default(),
             free: v.free,
         });
     }
@@ -127,6 +131,7 @@ pub fn build_snapshot(
 #[cfg(test)]
 mod tests {
     use super::*;
+    use std::collections::HashMap;
 
     fn config() -> Config {
         Config {
@@ -210,7 +215,12 @@ mod tests {
             },
         );
         // n2 absent → unreachable.
-        let snap = build_snapshot(&config, &nodes, &std::collections::HashMap::new(), &[]);
+        let fleet = crate::web::probe::Fleet {
+            nodes,
+            switch_off: std::collections::HashMap::new(),
+            soft_off: std::collections::HashMap::new(),
+        };
+        let snap = build_snapshot(&config, &fleet, &[]);
         assert_eq!(snap.clusters.len(), 2);
         let a = &snap.clusters[0];
         assert!(a.up);
@@ -247,7 +257,12 @@ mod tests {
                 }],
             },
         );
-        let snap = build_snapshot(&config, &nodes, &std::collections::HashMap::new(), &[]);
+        let fleet = crate::web::probe::Fleet {
+            nodes,
+            switch_off: std::collections::HashMap::new(),
+            soft_off: std::collections::HashMap::new(),
+        };
+        let snap = build_snapshot(&config, &fleet, &[]);
         let a = &snap.clusters[0];
         assert!(!a.up, "one offline node takes the cluster down");
         assert_eq!(a.free, 0);
@@ -274,7 +289,12 @@ mod tests {
         }
         let mut so = HashMap::new();
         so.insert("a".into(), true);
-        let snap = build_snapshot(&config, &nodes, &so, &[]);
+        let fleet = crate::web::probe::Fleet {
+            nodes,
+            switch_off: so,
+            soft_off: std::collections::HashMap::new(),
+        };
+        let snap = build_snapshot(&config, &fleet, &[]);
         assert!(!snap.clusters[0].up);
         assert!(snap.clusters[0].switch_off);
         assert_eq!(snap.clusters[0].free, 0);
@@ -282,6 +302,39 @@ mod tests {
         assert_eq!(snap.clusters[1].free, 1);
     }
 
+    #[test]
+    fn soft_off_does_not_take_cluster_down() {
+        // A non-blocking entity (fan) is off, all nodes live → cluster
+        // stays up; the off entity is surfaced for display only.
+        let config = config();
+        let mut nodes = HashMap::new();
+        for n in ["n0", "n1", "n2"] {
+            nodes.insert(
+                n.into(),
+                crate::web::probe::WebNodeState {
+                    reachable: true,
+                    occupied: false,
+                    containers: vec![],
+                },
+            );
+        }
+        let mut soft = HashMap::new();
+        soft.insert("a".into(), vec!["switch.fan".to_string()]);
+        let fleet = crate::web::probe::Fleet {
+            nodes,
+            switch_off: HashMap::new(),
+            soft_off: soft,
+        };
+        let snap = build_snapshot(&config, &fleet, &[]);
+        assert!(
+            snap.clusters[0].up,
+            "non-blocking off must not down the cluster"
+        );
+        assert_eq!(snap.clusters[0].free, 2);
+        assert_eq!(snap.clusters[0].soft_off, vec!["switch.fan".to_string()]);
+        assert!(snap.clusters[1].soft_off.is_empty());
+    }
+
     #[test]
     fn job_event_serializes_with_type_tag() {
         let ev = Event::Job {

+ 1 - 1
src/web/mod.rs

@@ -50,7 +50,7 @@ pub struct AppState {
 pub(crate) async fn push_state(app: &AppState) {
     let fleet = probe::probe(&app.config).await;
     let jobs = app.jobs.lock().unwrap().clone();
-    let snap = build_snapshot(&app.config, &fleet.nodes, &fleet.switch_off, &jobs);
+    let snap = build_snapshot(&app.config, &fleet, &jobs);
     let _ = app.bus.send(Event::State {
         ts: snap.ts,
         snapshot: snap.clone(),

+ 14 - 7
src/web/probe.rs

@@ -25,8 +25,10 @@ pub struct WebNodeState {
 /// Fleet-wide probe result: per-node state + per-cluster HA switch state.
 pub struct Fleet {
     pub nodes: HashMap<String, WebNodeState>,
-    /// cluster name → its `extra_entities` report anything but "on".
+    /// cluster name → a blocking `extra_entity` reports anything but "on".
     pub switch_off: HashMap<String, bool>,
+    /// cluster name → non-blocking entities that are off (display only).
+    pub soft_off: HashMap<String, Vec<String>>,
 }
 
 /// One `docker ps` over SSH per unique node, parallel + one HA check per
@@ -61,7 +63,7 @@ pub async fn probe(config: &Config) -> Fleet {
         ha_handles.push((
             name,
             crate::locale::spawn_with_locale(async move {
-                crate::home_assistant::extra_entities_off(&config, &cluster).await
+                crate::home_assistant::extra_entities_states(&config, &cluster).await
             }),
         ));
     }
@@ -100,16 +102,21 @@ pub async fn probe(config: &Config) -> Fleet {
             }
         }
     }
-    let switch_off: HashMap<String, bool> = futures::future::join_all(
+    let states: Vec<(String, crate::home_assistant::EntityStates)> = futures::future::join_all(
         ha_handles
             .into_iter()
-            .map(|(name, h)| async move { (name, h.await.unwrap_or(false)) }),
+            .map(|(name, h)| async move { (name, h.await.unwrap_or_default()) }),
     )
-    .await
-    .into_iter()
-    .collect();
+    .await;
+    let mut switch_off = HashMap::new();
+    let mut soft_off = HashMap::new();
+    for (name, es) in states {
+        switch_off.insert(name.clone(), es.blocking_off);
+        soft_off.insert(name, es.soft_off);
+    }
     Fleet {
         nodes: out,
         switch_off,
+        soft_off,
     }
 }

+ 5 - 2
src/web/tests.rs

@@ -144,8 +144,11 @@ async fn snapshot_serializes_with_type_tag() {
         ts: 7,
         snapshot: build_snapshot(
             &fake_config(),
-            &std::collections::HashMap::new(),
-            &std::collections::HashMap::new(),
+            &crate::web::probe::Fleet {
+                nodes: std::collections::HashMap::new(),
+                switch_off: std::collections::HashMap::new(),
+                soft_off: std::collections::HashMap::new(),
+            },
             &[],
         ),
     };

+ 13 - 6
src/web/ui.html

@@ -53,6 +53,7 @@
   .cluster-head b { font-size: 13px; }
   .cluster-head .free { color: var(--muted); font-size: 12px; }
   .cluster-head .down { color: var(--led-off); font-size: 12px; }
+  .cluster-head .soft { color: var(--led-busy); font-size: 12px; }
   .node { display: flex; align-items: center; gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--border); }
   .node:last-child { border-bottom: none; }
   .led {
@@ -192,10 +193,15 @@ function meta(n, c) {
   return "free";
 }
 function clusterReason(c) {
-  if (c.up) return "";
-  if (c.switch_off) return "switch off";
-  const off = c.nodes.filter((n) => !n.reachable).map((n) => n.name);
-  return off.length + " node" + (off.length === 1 ? "" : "s") + " offline" + (off.length ? " (" + off.join(", ") + ")" : "");
+  let r = "";
+  if (c.up) r = "";
+  else if (c.switch_off) r = "switch off";
+  else {
+    const off = c.nodes.filter((n) => !n.reachable).map((n) => n.name);
+    r = off.length + " node" + (off.length === 1 ? "" : "s") + " offline" + (off.length ? " (" + off.join(", ") + ")" : "");
+  }
+  if (c.soft_off && c.soft_off.length) r = (r ? r + " · " : "") + "⚙ " + c.soft_off.join(", ") + " off";
+  return r;
 }
 function renderCluster(c, prev) {
   const el = document.createElement("div");
@@ -203,7 +209,8 @@ function renderCluster(c, prev) {
   const head = document.createElement("div");
   head.className = "cluster-head";
   if (c.up) {
-    head.innerHTML = `<b>${esc(c.name)}</b><span class="free">${c.free} free</span>`;
+    const soft = c.soft_off && c.soft_off.length ? `<span class="soft" title="non-blocking entity off">⚙ ${esc(c.soft_off.join(", "))} off</span>` : "";
+    head.innerHTML = `<b>${esc(c.name)}</b><span class="free">${c.free} free</span>${soft}`;
   } else {
     head.innerHTML = `<b>${esc(c.name)}</b><span class="down">⏻ offline — ${esc(clusterReason(c))}</span>`;
   }
@@ -293,7 +300,7 @@ function onState(snap) {
     chip.className = "chip" + (c.name === state.active ? " active" : "");
     const dot = c.up ? (c.free > 0 ? "on" : "b") : "off";
     const m = c.up ? c.free + " free" : "⏻ OFF";
-    chip.title = c.up ? "" : clusterReason(c);
+    chip.title = clusterReason(c);
     chip.innerHTML = `<span class="led ${dot}"></span>${esc(c.name)}<span class="m">${m}</span>`;
     chip.onclick = () => { state.active = c.name; localStorage.setItem("sm121-active", c.name); onState(state.snap); };
     strip.appendChild(chip);