tawasuyu/shuma
Interactive shell with zsh/fish parity, native multiplexing + remote sessions, on Llimphi.
⎇ 1e5d3ed2397d6efbb71aa34380f170471508cfd7
02_ruway/shuma/sandbox/shuma-module-matilda/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat: shuma standalone — shell interactiva sobre Llimphi (front-door, git-dep al monorepo)
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(¤t, &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(¤t, 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(¤t, 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(¤t, 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}