tawasuyu

tawasuyu/shuma

Interactive shell with zsh/fish parity, native multiplexing + remote sessions, on Llimphi.

Rust 509Markdown 156TOML 81YAML 312114 Sutichaykuna

⎇ 1e5d3ed2397d6efbb71aa34380f170471508cfd7 02_ruway/shuma/sandbox/shuma-module-matilda/src/lib.rs Rust · 1128 siq'ikuna · 40.6 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat: shuma standalone — shell interactiva sobre Llimphi (front-door, git-dep al monorepo) 1e5d3ed23 · Sergio ·

1//! `shuma-module-matilda` — administración declarativa como módulo.
2//!
3//! Adapta el CLI `matilda` para que viva como tab dentro de `shuma-shell-llimphi`:
4//! visualiza el inventario, calcula el plan de reconciliación contra el
5//! estado actual y previsualiza los pasos en seco (`dry_run`). Apply
6//! real local también; apply remoto vía `matilda-linker` llega cuando
7//! el chasis cablee `Source::Remote` (bloque de conectividad).
8//!
9//! Diseño del tab:
10//!
11//! ```text
12//!  Matilda · local · 1 host · 2 containers · 1 vhost
13//!  ┌──────────────────────────┬──────────────────────────────┐
14//!  │ Inventario               │ Plan (4 acciones)            │
15//!  │                          │  1. crear contenedor «web»   │
16//!  │ HOSTS (1)                │  2. crear contenedor «api»   │
17//!  │   edge-1   10.0.0.1      │  3. crear vhost «sitio.com»  │
18//!  │                          │  …                            │
19//!  │ CONTAINERS (2)           │                              │
20//!  │   web      nginx:1.27    │ Log                          │
21//!  │   api      ejemplo/api   │  $ docker pull nginx:1.27    │
22//!  │                          │  …                            │
23//!  │ VHOSTS (1)               │                              │
24//!  │   sitio.com → web:80     │                              │
25//!  └──────────────────────────┴──────────────────────────────┘
26//! ```
27//!
28//! Contribuciones declarativas:
29//!
30//! - **Monitor "matilda · pasos"**: count del plan vigente (0 cuando el
31//!   inventario actual coincide con el deseado).
32//! - **Shortcuts**: `Discover`, `Plan`, `Dry-run`. El chasis los pinta
33//!   en la toolbar de la app-header.
34
35#![forbid(unsafe_code)]
36
37use llimphi_ui::llimphi_layout::taffy::{
38    prelude::{length, percent, FlexDirection, Size, Style},
39    AlignItems, Rect,
40};
41use llimphi_ui::llimphi_text::Alignment;
42use llimphi_ui::{DragPhase, View};
43use llimphi_theme::Theme;
44use llimphi_widget_splitter::{splitter_two, Direction, PaneSize, SplitterPalette};
45use matilda_apply::plan_to_steps;
46use matilda_core::{Container, Host, Inventory, RestartPolicy, VHost};
47use matilda_discover::{discover_inventory, observed_inventory, ServerState};
48use matilda_ghost::{apply, dry_run, ApplyReport};
49use matilda_linker::{Linker, SshAuth, SshConfig};
50use matilda_plan::{plan, Op, Plan};
51use shuma_module::{ModuleContributions, MonitorSpec, Rgb, Sample, ShortcutSpec, Source};
52use std::path::PathBuf;
53use std::sync::{Arc, Mutex};
54
55pub const ID: &str = "matilda";
56
57/// Estado del módulo. El `desired` se llena con un ejemplo arrancable
58/// hasta que el bloque 5 cablee `--inventory` desde el shumarc. El
59/// `pending_steps` se comparte por `Arc<Mutex<>>` para que el sampler
60/// del monitor lo lea desde el thread de polling sin pelear con el UI.
61#[derive(Debug, Clone)]
62pub struct State {
63    pub source: Source,
64    pub desired: Inventory,
65    pub current: Option<Inventory>,
66    pub plan: Option<Plan>,
67    pub log: Vec<String>,
68    pub split_width: f32,
69    /// Path al inventario JSON, si vino del shumarc. El módulo lo
70    /// expone para que el chasis sepa de dónde recargar al pulsar
71    /// «Reload»; el módulo mismo no hace IO, sólo recibe `SetDesired`.
72    pub inventory_path: Option<PathBuf>,
73    pending_steps: Arc<Mutex<usize>>,
74}
75
76impl State {
77    pub fn new(source: Source) -> Self {
78        Self::with_inventory(source, example_inventory())
79    }
80
81    /// Variante de `new` con inventario explícito — usada por el chasis
82    /// cuando el `shumarc` declara `inventory = "path/to/inv.json"`.
83    pub fn with_inventory(source: Source, desired: Inventory) -> Self {
84        Self {
85            source,
86            desired,
87            current: None,
88            plan: None,
89            log: Vec::new(),
90            split_width: 380.0,
91            inventory_path: None,
92            pending_steps: Arc::new(Mutex::new(0)),
93        }
94    }
95
96    /// Como `with_inventory`, recordando además el path para reloads.
97    pub fn with_inventory_path(source: Source, desired: Inventory, path: PathBuf) -> Self {
98        let mut s = Self::with_inventory(source, desired);
99        s.inventory_path = Some(path);
100        s
101    }
102
103    /// Inventario actual contra el cual reconciliar — si no se ha
104    /// hecho discover, asume "vacío" (todo es creación). Equivale al
105    /// modo CLI `matilda plan inv.json` sin `--discover`.
106    pub fn current_or_empty(&self) -> Inventory {
107        self.current.clone().unwrap_or_default()
108    }
109
110    /// Cuenta de pasos pendientes — alimenta el monitor.
111    pub fn pending_count(&self) -> usize {
112        self.plan.as_ref().map(|p| p.len()).unwrap_or(0)
113    }
114}
115
116#[derive(Debug, Clone)]
117pub enum Msg {
118    /// Descubre el inventario actual del servidor (local; los Remote
119    /// los maneja el chasis vía `discover_remote_blocking` y reenvía
120    /// el resultado como `SetCurrent`).
121    Discover,
122    /// Recalcula el plan deseado-vs-actual.
123    MakePlan,
124    /// Ejecuta `dry_run` sobre los pasos del plan y vuelca al log.
125    DryRun,
126    /// Aplica el plan al servidor (local sincrónico; remoto delega al
127    /// chasis, que spawnea el thread SSH y devuelve `ApplyReport`).
128    Apply,
129    /// Setter directo del inventario actual — usado para inyectar el
130    /// resultado del discover remoto desde el chasis (cuando el SSH
131    /// terminó en un thread aparte).
132    SetCurrent(Inventory),
133    /// Línea informativa para el log — útil para que el chasis avise
134    /// "conectando", "fallo de SSH", etc., sin acoplarse al módulo.
135    LogLine(String),
136    /// Inyecta el reporte de un dry-run remoto que el chasis corrió en
137    /// un thread aparte (cada `String` es una línea del log).
138    DryRunReport(Vec<String>),
139    /// Inyecta el reporte de un apply remoto: líneas para el log y, si
140    /// la aplicación fue completa, el nuevo inventario actual (re-
141    /// descubierto post-apply) para resetear plan + pendientes.
142    ApplyReport {
143        lines: Vec<String>,
144        new_current: Option<Inventory>,
145    },
146    /// Reemplaza el inventario deseado — usado por el chasis tras un
147    /// reload exitoso desde disco. Invalida el plan vigente.
148    SetDesired(Inventory),
149    /// Drag del splitter inventario|plan.
150    ResizeSplit(f32),
151}
152
153/// Mapea el `action_id` de un `ShortcutAction::ModuleAction` al `Msg`
154/// que corresponde. Retorna `None` si el action_id no pertenece a este
155/// módulo — el chasis simplemente lo ignora.
156pub fn dispatch(action_id: &str) -> Option<Msg> {
157    match action_id {
158        "matilda.discover" => Some(Msg::Discover),
159        "matilda.plan" => Some(Msg::MakePlan),
160        "matilda.dry_run" => Some(Msg::DryRun),
161        "matilda.apply" => Some(Msg::Apply),
162        _ => None,
163    }
164}
165
166pub fn update(state: State, msg: Msg) -> State {
167    let mut s = state;
168    match msg {
169        Msg::Discover => match &s.source {
170            Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } => {
171                // Matilda no habla todavía con el daemon de shuma — corre
172                // siempre sobre el FS local cuando no es SSH.
173                let current = discover_inventory(&s.desired);
174                s.log.push(format!(
175                    "✔ discover local: {} containers, {} vhosts",
176                    current.containers().count(),
177                    current.vhosts().count()
178                ));
179                s.current = Some(current);
180            }
181            Source::Remote { host, .. } => {
182                // El discover remoto necesita un runtime tokio y vive
183                // en un thread del chasis (ver `discover_remote_blocking`).
184                // Aquí sólo registramos que el módulo no puede hacerlo
185                // por sí mismo desde el hilo de UI — es informativo.
186                s.log.push(format!(
187                    "→ discover remoto en {host} delegado al chasis"
188                ));
189            }
190        },
191        Msg::MakePlan => {
192            let p = plan(&s.current_or_empty(), &s.desired);
193            s.log.push(format!(
194                "✔ plan: {} acciones ({} crear, {} actualizar, {} eliminar)",
195                p.len(),
196                p.count(Op::Create),
197                p.count(Op::Update),
198                p.count(Op::Remove)
199            ));
200            *s.pending_steps.lock().unwrap() = p.len();
201            s.plan = Some(p);
202        }
203        Msg::DryRun => {
204            // El dry-run para Source::Remote vive en el chasis (necesita
205            // SSH + thread); aquí sólo manejamos Local sincrónicamente.
206            // Para Remote, el chasis interceptó el shortcut y dispatchó
207            // el resultado vía `Msg::DryRunReport`.
208            if s.source.is_remote() {
209                s.log
210                    .push("→ dry-run remoto delegado al chasis".into());
211            } else {
212                let p = match &s.plan {
213                    Some(p) => p.clone(),
214                    None => plan(&s.current_or_empty(), &s.desired),
215                };
216                let steps = plan_to_steps(&p, &s.desired);
217                if steps.is_empty() {
218                    s.log.push("Sin pasos: nada que aplicar.".into());
219                } else {
220                    s.log.push(format!("— dry-run de {} pasos —", steps.len()));
221                    let report: ApplyReport = dry_run(&steps);
222                    for r in &report.results {
223                        s.log.push(format!(
224                            "{} {}",
225                            if r.ok { "✔" } else { "✘" },
226                            r.describe
227                        ));
228                        for line in &r.log {
229                            s.log.push(format!("   {line}"));
230                        }
231                    }
232                }
233            }
234            cap_log(&mut s.log);
235        }
236        Msg::Apply => {
237            if s.source.is_remote() {
238                s.log
239                    .push("→ apply remoto delegado al chasis".into());
240            } else {
241                let p = match &s.plan {
242                    Some(p) => p.clone(),
243                    None => plan(&s.current_or_empty(), &s.desired),
244                };
245                let steps = plan_to_steps(&p, &s.desired);
246                if steps.is_empty() {
247                    s.log.push("Sin pasos: nada que aplicar.".into());
248                } else {
249                    s.log.push(format!("— aplicando {} pasos —", steps.len()));
250                    let report: ApplyReport = apply(&steps);
251                    for r in &report.results {
252                        s.log.push(format!(
253                            "{} {}",
254                            if r.ok { "✔" } else { "✘" },
255                            r.describe
256                        ));
257                        for line in &r.log {
258                            s.log.push(format!("   {line}"));
259                        }
260                    }
261                    s.log.push(format!(
262                        "{} de {} pasos aplicados.",
263                        report.applied(),
264                        report.results.len()
265                    ));
266                    if report.all_ok() {
267                        // Re-discover local para resetear plan + pendientes.
268                        let current = discover_inventory(&s.desired);
269                        let new_plan = plan(&current, &s.desired);
270                        *s.pending_steps.lock().unwrap() = new_plan.len();
271                        s.current = Some(current);
272                        s.plan = Some(new_plan);
273                    } else {
274                        s.log.push("✘ se detuvo en el primer error.".into());
275                    }
276                }
277            }
278            cap_log(&mut s.log);
279        }
280        Msg::DryRunReport(lines) => {
281            for line in lines {
282                s.log.push(line);
283            }
284            cap_log(&mut s.log);
285        }
286        Msg::ApplyReport { lines, new_current } => {
287            for line in lines {
288                s.log.push(line);
289            }
290            if let Some(inv) = new_current {
291                let new_plan = plan(&inv, &s.desired);
292                *s.pending_steps.lock().unwrap() = new_plan.len();
293                s.current = Some(inv);
294                s.plan = Some(new_plan);
295            }
296            cap_log(&mut s.log);
297        }
298        Msg::SetCurrent(inv) => {
299            s.log.push(format!(
300                "✔ current: {} containers, {} vhosts",
301                inv.containers().count(),
302                inv.vhosts().count()
303            ));
304            s.current = Some(inv);
305        }
306        Msg::LogLine(line) => {
307            s.log.push(line);
308            cap_log(&mut s.log);
309        }
310        Msg::SetDesired(inv) => {
311            s.log.push(format!(
312                "✔ inventario recargado: {} hosts, {} containers, {} vhosts",
313                inv.hosts().count(),
314                inv.containers().count(),
315                inv.vhosts().count()
316            ));
317            s.desired = inv;
318            s.plan = None;
319            *s.pending_steps.lock().unwrap() = 0;
320        }
321        Msg::ResizeSplit(dx) => {
322            s.split_width = (s.split_width + dx).clamp(220.0, 720.0);
323        }
324    }
325    s
326}
327
328fn cap_log(log: &mut Vec<String>) {
329    const MAX: usize = 200;
330    let len = log.len();
331    if len > MAX {
332        log.drain(0..len - MAX);
333    }
334}
335
336// ─── Discover y dry-run remotos ─────────────────────────────────────
337
338/// Ruta default de la clave SSH del usuario; coincide con el matilda CLI.
339fn default_ssh_key() -> PathBuf {
340    let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
341    PathBuf::from(format!("{home}/.ssh/id_ed25519"))
342}
343
344/// Descubre el inventario actual del servidor remoto. **Bloqueante**:
345/// crea un runtime tokio efímero, conecta por SSH y corre
346/// `docker ps -a --format '{{.Names}}'` + `ls /etc/nginx/sites-enabled`.
347/// Pensado para que el chasis lo invoque dentro de `Handle::spawn`
348/// (un thread aparte) — no llamar desde el hilo de UI.
349///
350/// Para Source::Local fallback a `discover_inventory` (no necesita
351/// SSH, pero usa el mismo entrypoint para uniformidad).
352pub fn discover_remote_blocking(source: &Source, desired: &Inventory) -> Result<Inventory, String> {
353    match source {
354        Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } => {
355            Ok(discover_inventory(desired))
356        }
357        Source::Remote { .. } => {
358            let config = ssh_config_for(source)?;
359            let rt = blocking_runtime()?;
360            rt.block_on(async move {
361                let linker = Linker::connect(&config)
362                    .await
363                    .map_err(|e| format!("ssh connect: {e}"))?;
364                fetch_remote_inventory(&linker, desired).await
365            })
366        }
367    }
368}
369
370/// Equivalente remoto de `Msg::DryRun`: conecta por SSH, descubre el
371/// inventory actual, calcula el plan deseado-vs-actual y enumera los
372/// pasos que SE EJECUTARÍAN — sin invocar ninguno. Útil para validar
373/// que el `Source::Remote` está bien configurado y previsualizar el
374/// cambio antes de un eventual Apply real (fuera de scope aquí).
375///
376/// Devuelve un `Vec<String>` con líneas listas para insertar al log
377/// (incluyendo el reporte de dry-run de cada paso). El chasis las
378/// envuelve en `Msg::DryRunReport`.
379pub fn dry_run_remote_blocking(
380    source: &Source,
381    desired: &Inventory,
382) -> Result<Vec<String>, String> {
383    let mut lines = Vec::new();
384
385    let current = match source {
386        Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } => {
387            discover_inventory(desired)
388        }
389        Source::Remote { .. } => {
390            let config = ssh_config_for(source)?;
391            let rt = blocking_runtime()?;
392            rt.block_on(async move {
393                let linker = Linker::connect(&config)
394                    .await
395                    .map_err(|e| format!("ssh connect: {e}"))?;
396                fetch_remote_inventory(&linker, desired).await
397            })?
398        }
399    };
400    lines.push(format!(
401        "✔ current: {} containers, {} vhosts",
402        current.containers().count(),
403        current.vhosts().count()
404    ));
405
406    let p = plan(&current, desired);
407    if p.is_empty() {
408        lines.push("Sin cambios: el servidor ya está al día.".into());
409        return Ok(lines);
410    }
411    lines.push(format!(
412        "plan: {} acciones ({} crear, {} actualizar, {} eliminar)",
413        p.len(),
414        p.count(Op::Create),
415        p.count(Op::Update),
416        p.count(Op::Remove)
417    ));
418
419    let steps = plan_to_steps(&p, desired);
420    let report: ApplyReport = dry_run(&steps);
421    for r in &report.results {
422        lines.push(format!(
423            "{} {}",
424            if r.ok { "✔" } else { "✘" },
425            r.describe
426        ));
427        for line in &r.log {
428            lines.push(format!("   {line}"));
429        }
430    }
431    Ok(lines)
432}
433
434/// Aplica el plan deseado-vs-actual en el servidor remoto: conecta por
435/// SSH, descubre el inventario, calcula el plan, ejecuta los pasos en
436/// orden y re-descubre el estado final. **Bloqueante** — pensado para
437/// que el chasis lo invoque dentro de `Handle::spawn` y reenvíe el
438/// resultado por `Msg::ApplyReport`.
439///
440/// Devuelve `(lines, new_current)`: el log textual y, si todos los
441/// pasos completaron, el inventario re-observado (para resetear el
442/// plan/pendientes del módulo). Si algún paso falla, `new_current` es
443/// `None` — la UI conserva el plan vigente para que el operador vea
444/// dónde se rompió.
445pub fn apply_remote_blocking(
446    source: &Source,
447    desired: &Inventory,
448) -> Result<(Vec<String>, Option<Inventory>), String> {
449    let mut lines = Vec::new();
450
451    match source {
452        Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } => {
453            // Local lo maneja `Msg::Apply` sincrónicamente. Para
454            // uniformidad damos un fallback síncrono sin tocar el UI.
455            let current = discover_inventory(desired);
456            let p = plan(&current, desired);
457            if p.is_empty() {
458                lines.push("Sin cambios: nada que aplicar.".into());
459                return Ok((lines, Some(current)));
460            }
461            let steps = plan_to_steps(&p, desired);
462            let report: ApplyReport = apply(&steps);
463            push_apply_log(&mut lines, &report);
464            let new_current = if report.all_ok() {
465                Some(discover_inventory(desired))
466            } else {
467                None
468            };
469            Ok((lines, new_current))
470        }
471        Source::Remote { .. } => {
472            let config = ssh_config_for(source)?;
473            let rt = blocking_runtime()?;
474            rt.block_on(async move {
475                let linker = Linker::connect(&config)
476                    .await
477                    .map_err(|e| format!("ssh connect: {e}"))?;
478                let current = fetch_remote_inventory(&linker, desired).await?;
479                lines.push(format!(
480                    "✔ current: {} containers, {} vhosts",
481                    current.containers().count(),
482                    current.vhosts().count()
483                ));
484                let p = plan(&current, desired);
485                if p.is_empty() {
486                    lines.push("Sin cambios: el servidor ya está al día.".into());
487                    return Ok((lines, Some(current)));
488                }
489                lines.push(format!(
490                    "plan: {} acciones ({} crear, {} actualizar, {} eliminar)",
491                    p.len(),
492                    p.count(Op::Create),
493                    p.count(Op::Update),
494                    p.count(Op::Remove)
495                ));
496                let steps = plan_to_steps(&p, desired);
497                lines.push(format!("— aplicando {} pasos por SSH —", steps.len()));
498                let report = linker.apply(&steps).await;
499                push_apply_log(&mut lines, &report);
500                let new_current = if report.all_ok() {
501                    Some(fetch_remote_inventory(&linker, desired).await?)
502                } else {
503                    None
504                };
505                Ok((lines, new_current))
506            })
507        }
508    }
509}
510
511fn push_apply_log(lines: &mut Vec<String>, report: &ApplyReport) {
512    for r in &report.results {
513        lines.push(format!(
514            "{} {}",
515            if r.ok { "✔" } else { "✘" },
516            r.describe
517        ));
518        for line in &r.log {
519            lines.push(format!("   {line}"));
520        }
521    }
522    lines.push(format!(
523        "{} de {} pasos aplicados.",
524        report.applied(),
525        report.results.len()
526    ));
527    if !report.all_ok() {
528        lines.push("✘ se detuvo en el primer error.".into());
529    }
530}
531
532fn ssh_config_for(source: &Source) -> Result<SshConfig, String> {
533    match source {
534        Source::Remote { host, user, port, .. } => {
535            let auth = SshAuth::Key {
536                path: default_ssh_key(),
537                passphrase: None,
538            };
539            let mut config = SshConfig::new(host.as_str(), user.as_str(), auth);
540            config.port = *port;
541            Ok(config)
542        }
543        Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } => {
544            Err("ssh_config_for esperaba Source::Remote".into())
545        }
546    }
547}
548
549fn blocking_runtime() -> Result<tokio::runtime::Runtime, String> {
550    tokio::runtime::Builder::new_current_thread()
551        .enable_all()
552        .build()
553        .map_err(|e| format!("tokio runtime: {e}"))
554}
555
556async fn fetch_remote_inventory(
557    linker: &Linker,
558    desired: &Inventory,
559) -> Result<Inventory, String> {
560    let containers_text = linker
561        .exec("docker ps -a --format '{{.Names}}' 2>/dev/null || true")
562        .await
563        .map_err(|e| format!("docker ps: {e}"))?;
564    let vhosts_text = linker
565        .exec("ls -1 /etc/nginx/sites-enabled 2>/dev/null || true")
566        .await
567        .map_err(|e| format!("ls sites-enabled: {e}"))?;
568    let state = ServerState {
569        containers: matilda_discover::parse_docker_names(&containers_text),
570        vhosts: matilda_discover::parse_nginx_sites(&vhosts_text),
571    };
572    Ok(observed_inventory(&state, desired))
573}
574
575/// Inventario de ejemplo — equivale al `matilda example`. Permite
576/// arrancar el módulo sin un archivo de inventario y demostrar el
577/// flujo plan/dry-run sin tocar nada del servidor.
578pub fn example_inventory() -> Inventory {
579    let mut inv = Inventory::new();
580    inv.add_host(Host::new("edge-1", "10.0.0.1").with_tag("prod"));
581    inv.add_container(
582        Container::new("web", "nginx:1.27")
583            .with_port(8080, 80)
584            .with_volume("/srv/site", "/usr/share/nginx/html")
585            .with_restart(RestartPolicy::Always),
586    );
587    inv.add_container(
588        Container::new("api", "ghcr.io/ejemplo/api:1.0")
589            .with_port(9000, 9000)
590            .with_env("DATABASE_URL", "postgres://db/app")
591            .with_restart(RestartPolicy::UnlessStopped),
592    );
593    inv.add_vhost(
594        VHost::to_container("sitio.com", "web", 80)
595            .with_alias("www.sitio.com")
596            .with_tls(),
597    );
598    inv
599}
600
601// ─── view ──────────────────────────────────────────────────────────
602
603pub fn view<HostMsg: Clone + Send + Sync + 'static>(
604    state: &State,
605    theme: &Theme,
606    lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static,
607) -> View<HostMsg> {
608    let header = matilda_header(state, theme);
609
610    let inv_pane = inventory_pane(state, theme);
611    let plan_pane = plan_and_log_pane(state, theme);
612
613    let splitter_palette = SplitterPalette::from_theme(theme);
614    let lift_resize = lift.clone();
615    let body = splitter_two(
616        Direction::Row,
617        inv_pane,
618        PaneSize::Fixed(state.split_width),
619        plan_pane,
620        PaneSize::Flex,
621        move |phase, dx| match phase {
622            DragPhase::Move => Some(lift_resize(Msg::ResizeSplit(dx))),
623            DragPhase::End => None,
624        },
625        &splitter_palette,
626    );
627
628    View::new(Style {
629        flex_direction: FlexDirection::Column,
630        size: Size {
631            width: percent(1.0_f32),
632            height: percent(1.0_f32),
633        },
634        ..Default::default()
635    })
636    .fill(theme.bg_app)
637    .children(vec![header, body])
638}
639
640fn matilda_header<HostMsg: Clone + 'static>(state: &State, theme: &Theme) -> View<HostMsg> {
641    let label = format!(
642        "Matilda · {} · {} hosts · {} containers · {} vhosts",
643        state.source.label(),
644        state.desired.hosts().count(),
645        state.desired.containers().count(),
646        state.desired.vhosts().count(),
647    );
648    View::new(Style {
649        size: Size {
650            width: percent(1.0_f32),
651            height: length(28.0_f32),
652        },
653        padding: Rect {
654            left: length(14.0_f32),
655            right: length(14.0_f32),
656            top: length(0.0_f32),
657            bottom: length(0.0_f32),
658        },
659        align_items: Some(AlignItems::Center),
660        ..Default::default()
661    })
662    .fill(theme.bg_panel)
663    .text_aligned(label, 12.0, theme.fg_text, Alignment::Start)
664}
665
666/// Panel izquierdo: el inventario deseado en 3 secciones (hosts /
667/// containers / vhosts). Compuesto como Views planos — el
668/// `llimphi-widget-list` exigiría un `on_click` por fila, y en este
669/// tab las filas son informativas (no se seleccionan todavía).
670fn inventory_pane<HostMsg: Clone + 'static>(state: &State, theme: &Theme) -> View<HostMsg> {
671    let mut children: Vec<View<HostMsg>> = Vec::new();
672
673    children.push(section_label(
674        &format!("HOSTS ({})", state.desired.hosts().count()),
675        theme,
676    ));
677    for h in state.desired.hosts() {
678        children.push(inv_row(&format!("  {}   {}", h.name, h.address), theme));
679    }
680
681    children.push(section_label(
682        &format!("CONTAINERS ({})", state.desired.containers().count()),
683        theme,
684    ));
685    for c in state.desired.containers() {
686        children.push(inv_row(&format!("  {}   {}", c.name, c.image), theme));
687    }
688
689    children.push(section_label(
690        &format!("VHOSTS ({})", state.desired.vhosts().count()),
691        theme,
692    ));
693    for v in state.desired.vhosts() {
694        children.push(inv_row(
695            &format!("  {} → {}", v.domain, describe_upstream(&v.upstream)),
696            theme,
697        ));
698    }
699
700    View::new(Style {
701        flex_direction: FlexDirection::Column,
702        size: Size {
703            width: percent(1.0_f32),
704            height: percent(1.0_f32),
705        },
706        padding: Rect {
707            left: length(10.0_f32),
708            right: length(10.0_f32),
709            top: length(8.0_f32),
710            bottom: length(8.0_f32),
711        },
712        gap: Size {
713            width: length(0.0_f32),
714            height: length(2.0_f32),
715        },
716        ..Default::default()
717    })
718    .fill(theme.bg_panel)
719    .children(children)
720}
721
722fn describe_upstream(u: &matilda_core::Upstream) -> String {
723    use matilda_core::Upstream::*;
724    match u {
725        Container { name, port } => format!("{name}:{port}"),
726        Address(addr) => addr.clone(),
727    }
728}
729
730fn inv_row<HostMsg: Clone + 'static>(text: &str, theme: &Theme) -> View<HostMsg> {
731    text_row(text, theme.fg_text, theme)
732}
733
734fn plan_and_log_pane<HostMsg: Clone + 'static>(state: &State, theme: &Theme) -> View<HostMsg> {
735    let plan_label = match &state.plan {
736        Some(p) if p.is_empty() => "Plan · sin cambios".to_string(),
737        Some(p) => format!("Plan · {} acciones", p.len()),
738        None => "Plan · sin calcular (pulsá «Plan» en la toolbar)".to_string(),
739    };
740
741    let plan_header = section_label(&plan_label, theme);
742
743    let mut plan_children: Vec<View<HostMsg>> = vec![plan_header];
744    if let Some(p) = &state.plan {
745        for (i, action) in p.actions.iter().enumerate() {
746            plan_children.push(text_row(
747                &format!("{:>2}. {}", i + 1, action.describe()),
748                theme.fg_text,
749                theme,
750            ));
751        }
752    }
753
754    plan_children.push(section_label("Log", theme));
755    for line in state.log.iter().rev().take(40).rev() {
756        plan_children.push(text_row(line, theme.fg_muted, theme));
757    }
758
759    View::new(Style {
760        flex_direction: FlexDirection::Column,
761        size: Size {
762            width: percent(1.0_f32),
763            height: percent(1.0_f32),
764        },
765        padding: Rect {
766            left: length(10.0_f32),
767            right: length(10.0_f32),
768            top: length(8.0_f32),
769            bottom: length(8.0_f32),
770        },
771        gap: Size {
772            width: length(0.0_f32),
773            height: length(2.0_f32),
774        },
775        ..Default::default()
776    })
777    .fill(theme.bg_app)
778    .children(plan_children)
779}
780
781fn section_label<HostMsg: Clone + 'static>(text: &str, theme: &Theme) -> View<HostMsg> {
782    View::new(Style {
783        size: Size {
784            width: percent(1.0_f32),
785            height: length(18.0_f32),
786        },
787        margin: Rect {
788            left: length(0.0_f32),
789            right: length(0.0_f32),
790            top: length(6.0_f32),
791            bottom: length(2.0_f32),
792        },
793        ..Default::default()
794    })
795    .text_aligned(text.to_string(), 11.0, theme.accent, Alignment::Start)
796}
797
798fn text_row<HostMsg: Clone + 'static>(
799    text: &str,
800    color: llimphi_ui::llimphi_raster::peniko::Color,
801    _theme: &Theme,
802) -> View<HostMsg> {
803    View::new(Style {
804        size: Size {
805            width: percent(1.0_f32),
806            height: length(16.0_f32),
807        },
808        ..Default::default()
809    })
810    .text_aligned(text.to_string(), 11.0, color, Alignment::Start)
811}
812
813// ─── contributions ──────────────────────────────────────────────────
814
815pub fn contributions(state: &State) -> ModuleContributions {
816    let pending = state.pending_steps.clone();
817    let monitor = MonitorSpec {
818        id: "matilda.pending",
819        label: format!("matilda · {}", state.source.label()),
820        accent: Rgb::new(0xE5, 0xC0, 0x7B),
821        history_capacity: 60,
822        period_secs: 5.0,
823        sampler: Box::new(move || {
824            let n = *pending.lock().unwrap();
825            Sample::new(n as f32, format!("{n} pendientes"))
826        }),
827    };
828
829    ModuleContributions {
830        monitors: vec![monitor],
831        shortcuts: vec![
832            ShortcutSpec::module_action("Discover", "matilda.discover")
833                .with_hint("Lee el estado actual del servidor"),
834            ShortcutSpec::module_action("Plan", "matilda.plan")
835                .with_hint("Calcula la reconciliación deseado-vs-actual"),
836            ShortcutSpec::module_action("Dry-run", "matilda.dry_run")
837                .with_hint("Previsualiza los pasos sin aplicar"),
838            ShortcutSpec::module_action("Apply", "matilda.apply")
839                .with_hint("Reconcilia el servidor con el inventario deseado"),
840            ShortcutSpec::module_action("Reload", "matilda.reload")
841                .with_hint("Relee el inventario JSON desde disco"),
842        ],
843    }
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849
850    #[test]
851    fn id_is_stable() {
852        assert_eq!(ID, "matilda");
853    }
854
855    #[test]
856    fn example_inventory_has_expected_shape() {
857        let inv = example_inventory();
858        assert_eq!(inv.hosts().count(), 1);
859        assert_eq!(inv.containers().count(), 2);
860        assert_eq!(inv.vhosts().count(), 1);
861    }
862
863    #[test]
864    fn with_inventory_uses_provided_desired() {
865        let mut inv = matilda_core::Inventory::new();
866        inv.add_container(matilda_core::Container::new("only", "alpine:3"));
867        let s = State::with_inventory(Source::Local, inv);
868        assert_eq!(s.desired.containers().count(), 1);
869        assert_eq!(s.desired.hosts().count(), 0);
870        assert!(s.plan.is_none());
871    }
872
873    #[test]
874    fn fresh_state_has_no_plan_no_current() {
875        let s = State::new(Source::Local);
876        assert!(s.plan.is_none());
877        assert!(s.current.is_none());
878        assert_eq!(s.pending_count(), 0);
879    }
880
881    #[test]
882    fn make_plan_against_empty_current_creates_all() {
883        let s = State::new(Source::Local);
884        let s = update(s, Msg::MakePlan);
885        let plan = s.plan.as_ref().expect("plan se debe haber calculado");
886        // 2 containers + 1 vhost (los hosts no producen acción si no hay
887        // current, pero el example_inventory tiene 1 → cuenta como create).
888        assert_eq!(plan.count(Op::Create), 4);
889        assert_eq!(s.pending_count(), 4);
890    }
891
892    #[test]
893    fn dry_run_appends_log_lines() {
894        let mut s = State::new(Source::Local);
895        s = update(s, Msg::MakePlan);
896        let log_before = s.log.len();
897        s = update(s, Msg::DryRun);
898        assert!(s.log.len() > log_before, "dry-run debe agregar líneas al log");
899    }
900
901    #[test]
902    fn dry_run_with_empty_plan_says_nothing_to_apply() {
903        let mut s = State::new(Source::Local);
904        // Force plan vacío: igualamos current al desired.
905        s.current = Some(s.desired.clone());
906        s = update(s, Msg::MakePlan);
907        assert_eq!(s.plan.as_ref().unwrap().len(), 0);
908        s = update(s, Msg::DryRun);
909        assert!(s
910            .log
911            .iter()
912            .any(|l| l.contains("nada que aplicar")));
913    }
914
915    #[test]
916    fn remote_discover_is_delegated_to_the_chassis() {
917        // El módulo no abre SSH desde el update — el chasis es quien
918        // spawnea el thread con `discover_remote_blocking`. Aquí
919        // verificamos sólo el log informativo.
920        let s = State::new(Source::Remote {
921            host: "srv".into(),
922            user: "ops".into(),
923            port: 22,
924            label: None,
925        });
926        let s = update(s, Msg::Discover);
927        assert!(s.log.iter().any(|l| l.contains("delegado al chasis")));
928        assert!(s.current.is_none());
929    }
930
931    #[test]
932    fn set_current_updates_state_and_logs() {
933        let mut s = State::new(Source::Local);
934        let mut inv = matilda_core::Inventory::new();
935        inv.add_container(matilda_core::Container::new("web", "nginx"));
936        s = update(s, Msg::SetCurrent(inv));
937        assert!(s.current.is_some());
938        assert_eq!(s.current.as_ref().unwrap().containers().count(), 1);
939        assert!(s.log.iter().any(|l| l.contains("1 containers")));
940    }
941
942    #[test]
943    fn log_line_appends_and_caps_at_200() {
944        let mut s = State::new(Source::Local);
945        for i in 0..250 {
946            s = update(s, Msg::LogLine(format!("line {i}")));
947        }
948        assert_eq!(s.log.len(), 200);
949        // Las primeras 50 líneas deben haberse descartado.
950        assert!(s.log[0].contains("line 50"));
951    }
952
953    #[test]
954    fn discover_remote_blocking_local_falls_back_to_local() {
955        // Para `Source::Local` no abre SSH — `discover_inventory` corre
956        // localmente. En CI sin docker, retorna inventory vacío sin error.
957        let inv = matilda_core::Inventory::new();
958        let res = discover_remote_blocking(&Source::Local, &inv);
959        assert!(res.is_ok());
960    }
961
962    #[test]
963    fn dry_run_report_appends_lines_to_log() {
964        let mut s = State::new(Source::Local);
965        let lines = vec!["línea 1".into(), "línea 2".into(), "línea 3".into()];
966        s = update(s, Msg::DryRunReport(lines));
967        assert!(s.log.iter().any(|l| l == "línea 1"));
968        assert!(s.log.iter().any(|l| l == "línea 3"));
969    }
970
971    #[test]
972    fn dry_run_with_remote_source_defers_to_chassis() {
973        let s = State::new(Source::Remote {
974            host: "srv".into(),
975            user: "ops".into(),
976            port: 22,
977            label: None,
978        });
979        let s = update(s, Msg::DryRun);
980        assert!(s
981            .log
982            .iter()
983            .any(|l| l.contains("delegado al chasis")));
984    }
985
986    #[test]
987    fn dry_run_remote_blocking_local_returns_lines() {
988        let inv = matilda_core::Inventory::new();
989        let res = dry_run_remote_blocking(&Source::Local, &inv);
990        assert!(res.is_ok());
991        let lines = res.unwrap();
992        assert!(!lines.is_empty());
993        // El primer reporte siempre incluye el current.
994        assert!(lines[0].contains("current"));
995    }
996
997    #[test]
998    fn resize_split_clamps_to_range() {
999        let s = State::new(Source::Local);
1000        let s = update(s, Msg::ResizeSplit(-10000.0));
1001        assert!(s.split_width >= 220.0);
1002        let s = update(s, Msg::ResizeSplit(10000.0));
1003        assert!(s.split_width <= 720.0);
1004    }
1005
1006    #[test]
1007    fn dispatch_maps_action_ids() {
1008        assert!(matches!(dispatch("matilda.discover"), Some(Msg::Discover)));
1009        assert!(matches!(dispatch("matilda.plan"), Some(Msg::MakePlan)));
1010        assert!(matches!(dispatch("matilda.dry_run"), Some(Msg::DryRun)));
1011        assert!(matches!(dispatch("matilda.apply"), Some(Msg::Apply)));
1012        assert!(dispatch("desconocido").is_none());
1013    }
1014
1015    #[test]
1016    fn contributions_expose_monitor_and_five_shortcuts() {
1017        let s = State::new(Source::Local);
1018        let c = contributions(&s);
1019        assert_eq!(c.monitors.len(), 1);
1020        assert_eq!(c.shortcuts.len(), 5);
1021        assert_eq!(c.shortcuts[0].label, "Discover");
1022        assert_eq!(c.shortcuts[1].label, "Plan");
1023        assert_eq!(c.shortcuts[2].label, "Dry-run");
1024        assert_eq!(c.shortcuts[3].label, "Apply");
1025        assert_eq!(c.shortcuts[4].label, "Reload");
1026    }
1027
1028    #[test]
1029    fn with_inventory_path_records_the_path() {
1030        let inv = matilda_core::Inventory::new();
1031        let p = PathBuf::from("/etc/matilda/inv.json");
1032        let s = State::with_inventory_path(Source::Local, inv, p.clone());
1033        assert_eq!(s.inventory_path.as_deref(), Some(p.as_path()));
1034    }
1035
1036    #[test]
1037    fn set_desired_replaces_inventory_and_invalidates_plan() {
1038        let mut s = State::new(Source::Local);
1039        s = update(s, Msg::MakePlan);
1040        assert!(s.pending_count() > 0);
1041
1042        let mut new_inv = matilda_core::Inventory::new();
1043        new_inv.add_container(matilda_core::Container::new("alone", "alpine"));
1044        s = update(s, Msg::SetDesired(new_inv));
1045
1046        assert_eq!(s.desired.containers().count(), 1);
1047        assert_eq!(s.desired.hosts().count(), 0);
1048        assert!(s.plan.is_none());
1049        assert_eq!(s.pending_count(), 0);
1050        assert!(s.log.iter().any(|l| l.contains("recargado")));
1051    }
1052
1053    #[test]
1054    fn apply_with_remote_source_defers_to_chassis() {
1055        let s = State::new(Source::Remote {
1056            host: "srv".into(),
1057            user: "ops".into(),
1058            port: 22,
1059            label: None,
1060        });
1061        let s = update(s, Msg::Apply);
1062        assert!(s
1063            .log
1064            .iter()
1065            .any(|l| l.contains("delegado al chasis")));
1066        assert!(s.plan.is_none());
1067    }
1068
1069    #[test]
1070    fn apply_report_with_new_current_resets_plan() {
1071        let mut s = State::new(Source::Local);
1072        // Forzamos un plan vigente con pendientes.
1073        s = update(s, Msg::MakePlan);
1074        assert!(s.pending_count() > 0);
1075
1076        // Ahora simulamos que el chasis aplicó remoto y re-descubrió:
1077        // el nuevo current coincide con el desired → plan vacío.
1078        let new_current = s.desired.clone();
1079        s = update(
1080            s,
1081            Msg::ApplyReport {
1082                lines: vec!["✔ aplicado".into()],
1083                new_current: Some(new_current),
1084            },
1085        );
1086        assert_eq!(s.pending_count(), 0);
1087        assert_eq!(s.plan.as_ref().unwrap().len(), 0);
1088        assert!(s.log.iter().any(|l| l == "✔ aplicado"));
1089    }
1090
1091    #[test]
1092    fn apply_report_without_new_current_keeps_plan() {
1093        let mut s = State::new(Source::Local);
1094        s = update(s, Msg::MakePlan);
1095        let pending_before = s.pending_count();
1096        s = update(
1097            s,
1098            Msg::ApplyReport {
1099                lines: vec!["✘ falló paso 2".into()],
1100                new_current: None,
1101            },
1102        );
1103        // El plan vigente sobrevive — el operador inspecciona dónde se rompió.
1104        assert_eq!(s.pending_count(), pending_before);
1105        assert!(s.log.iter().any(|l| l.contains("falló paso 2")));
1106    }
1107
1108    #[test]
1109    fn apply_remote_blocking_local_returns_lines() {
1110        let inv = matilda_core::Inventory::new();
1111        let res = apply_remote_blocking(&Source::Local, &inv);
1112        assert!(res.is_ok());
1113        let (lines, new_current) = res.unwrap();
1114        // Inventory vacío → "nada que aplicar"; new_current refleja el local.
1115        assert!(!lines.is_empty());
1116        assert!(new_current.is_some());
1117    }
1118
1119    #[test]
1120    fn monitor_sampler_reflects_pending_steps() {
1121        let mut s = State::new(Source::Local);
1122        s = update(s, Msg::MakePlan); // 4 pendientes
1123        let c = contributions(&s);
1124        let sample = (c.monitors[0].sampler)();
1125        assert_eq!(sample.value, 4.0);
1126        assert_eq!(sample.display, "4 pendientes");
1127    }
1128}