gioser

tawasuyu/brahman

Rust 326TOML 123JSON 14Markdown 114554 Definiciones

⎇ 99838b849b577dde5cdac7171a3923d6874a74e3 crates/apps/minga-explorer/src/main.rs Rust · 428 líneas · 13.6 KB Historia Quién tocó qué crudo

feat(minga-explorer): listings de items recientes en cada stat card 99838b849 · Sergio ·

1//! `minga-explorer` — dashboard GPUI del repo Minga (VCS semántico
2//! P2P).
3//!
4//! Polling cada 2s contra `MINGA_REPO` (env, default `./.minga`),
5//! abre el `PersistentRepo` (sled, sin passphrase porque los counts
6//! son lectura pública) y muestra:
7//! - Cantidad de nodos AST almacenados.
8//! - Cantidad de atestaciones firmadas.
9//! - Cantidad de claves del MST (Merkle Search Tree).
10//!
11//! No requiere keypair descifrado — eso se queda para el CLI
12//! (`minga status`) cuando hace falta el DID. El explorer foco es
13//! observabilidad rápida.
14//!
15//! Stack visual: yahweh-theme + banner_themed + card_themed +
16//! theme_switcher. Mismo patrón que `nakui-explorer` /
17//! `nouser-explorer`.
18//!
19//! Uso:
20//! ```sh
21//! cargo run -p minga-explorer
22//! # con repo custom:
23//! MINGA_REPO=/path/to/.minga cargo run -p minga-explorer
24//! ```
25
26use std::path::PathBuf;
27use std::time::Duration;
28
29use gpui::{
30    div, prelude::*, px, App, Application, Bounds, Context, IntoElement, Render, SharedString,
31    Window, WindowBounds, WindowOptions,
32};
33use minga_store::PersistentRepo;
34use yahweh_theme::Theme;
35use yahweh_widget_banner::{banner_themed, Banner};
36use yahweh_widget_card::card_themed;
37use yahweh_widget_theme_switcher::theme_switcher;
38
39const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
40const REPO_DIRNAME: &str = "repo";
41
42fn main() {
43    Application::new().run(|cx: &mut App| {
44        Theme::install_default(cx);
45        let bounds = Bounds::centered(None, gpui::size(px(800.), px(560.)), cx);
46        cx.open_window(
47            WindowOptions {
48                window_bounds: Some(WindowBounds::Windowed(bounds)),
49                titlebar: Some(gpui::TitlebarOptions {
50                    title: Some(SharedString::from("Minga — Repo")),
51                    ..Default::default()
52                }),
53                ..Default::default()
54            },
55            |_w, cx| cx.new(Explorer::new),
56        )
57        .expect("open window");
58        cx.activate(true);
59    });
60}
61
62/// Cuántos items recientes mostrar por sección. Los stores no
63/// tienen orden cronológico (sled ordena lexicográfico por hash);
64/// los "recent" acá son simplemente los primeros del iter — sirve
65/// como sample, no como log temporal. Para timeline real haría
66/// falta agregar timestamp al schema.
67const RECENT_LIMIT: usize = 5;
68
69/// Snapshot de counts + sample de items recientes. Reemplaza el
70/// completo en cada refresh — los stores no diff fácilmente y los
71/// counts son baratos (sled tracks size).
72#[derive(Clone, Default, Debug)]
73struct RepoSnapshot {
74    nodes: usize,
75    attestations: usize,
76    mst_keys: usize,
77    /// Sample de nodos: `(hash_short, kind)`.
78    recent_nodes: Vec<(String, String)>,
79    /// Sample de atestaciones: `(content_hash_short, did_short)`.
80    recent_attestations: Vec<(String, String)>,
81    /// Sample de claves MST: `hash_short`.
82    recent_mst_keys: Vec<String>,
83}
84
85struct Explorer {
86    repo_path: PathBuf,
87    snapshot: Option<RepoSnapshot>,
88    error: Option<SharedString>,
89    last_load_ms: u64,
90}
91
92impl Explorer {
93    fn new(cx: &mut Context<Self>) -> Self {
94        let repo_path = std::env::var("MINGA_REPO")
95            .ok()
96            .map(PathBuf::from)
97            .unwrap_or_else(|| PathBuf::from(".minga"));
98        let path_for_loop = repo_path.clone();
99        cx.spawn(async move |this, cx| {
100            let timer = cx.background_executor().clone();
101            loop {
102                let started = std::time::Instant::now();
103                let result = load_snapshot(&path_for_loop);
104                let elapsed = started.elapsed().as_millis() as u64;
105                let _ = this.update(cx, |me, cx| {
106                    match result {
107                        Ok(snap) => {
108                            me.snapshot = Some(snap);
109                            me.error = None;
110                        }
111                        Err(e) => {
112                            me.error = Some(SharedString::from(format!(
113                                "no pude leer repo {}: {}",
114                                me.repo_path.display(),
115                                e
116                            )));
117                        }
118                    }
119                    me.last_load_ms = elapsed;
120                    cx.notify();
121                });
122                timer.timer(REFRESH_INTERVAL).await;
123            }
124        })
125        .detach();
126
127        Self {
128            repo_path,
129            snapshot: None,
130            error: None,
131            last_load_ms: 0,
132        }
133    }
134}
135
136/// Lee el repo sled `<repo_path>/repo` y devuelve los 3 counts.
137/// Falla si: el dir no existe, sled rebota al abrir, o cualquier
138/// store falla a `len()`. Ningún error es fatal — la UI muestra el
139/// banner y mantiene el último snapshot bueno.
140fn load_snapshot(repo_path: &std::path::Path) -> Result<RepoSnapshot, String> {
141    let inner = repo_path.join(REPO_DIRNAME);
142    if !inner.exists() {
143        return Err(format!(
144            "directorio del repo sled no existe: {}",
145            inner.display()
146        ));
147    }
148    let repo = PersistentRepo::open(&inner).map_err(|e| format!("open: {e}"))?;
149
150    // Counts: cheap (sled tracks size).
151    let nodes = repo.nodes.len();
152    let attestations = repo.attestations.len();
153    let mst_keys = repo.mst.len();
154
155    // Samples: tomar los primeros RECENT_LIMIT items del iter.
156    // Errores per-item se silencian (filter_map) porque el dashboard
157    // muestra lo que pueda; un par de items corruptos no debería
158    // tirar el panel entero.
159    let recent_nodes: Vec<(String, String)> = repo
160        .nodes
161        .iter()
162        .filter_map(|r| r.ok())
163        .take(RECENT_LIMIT)
164        .map(|(hash, stored)| (short_hash(&hash.to_string()), stored.kind))
165        .collect();
166
167    let recent_attestations: Vec<(String, String)> = repo
168        .attestations
169        .iter()
170        .filter_map(|r| r.ok())
171        .take(RECENT_LIMIT)
172        .map(|att| {
173            (
174                short_hash(&att.content.to_string()),
175                short_hash(&att.author.to_string()),
176            )
177        })
178        .collect();
179
180    let recent_mst_keys: Vec<String> = repo
181        .mst
182        .iter()
183        .filter_map(|r| r.ok())
184        .take(RECENT_LIMIT)
185        .map(|h| short_hash(&h.to_string()))
186        .collect();
187
188    Ok(RepoSnapshot {
189        nodes,
190        attestations,
191        mst_keys,
192        recent_nodes,
193        recent_attestations,
194        recent_mst_keys,
195    })
196}
197
198/// Trunca un hex string a sus primeros 12 chars. Convención cross-app
199/// para mostrar hashes/dids/contenthash compactos sin perder
200/// distintividad práctica (12 hex = 48 bits, colisión improbable
201/// dentro de un repo single-machine).
202fn short_hash(s: &str) -> String {
203    s.chars().take(12).collect()
204}
205
206impl Render for Explorer {
207    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
208        let theme = Theme::global(cx).clone();
209        let bg = theme.bg_app.clone();
210        let text = theme.fg_text;
211        let text_dim = theme.fg_muted;
212        // Acentos por kind del dashboard: nodos azul, atestaciones
213        // verde, MST purple. Señales semánticas del dominio Minga.
214        let accent_nodes = gpui::rgb(0x88c0d0);
215        let accent_attestations = gpui::rgb(0xa3be8c);
216        let accent_mst = gpui::rgb(0xb48ead);
217
218        let header_text = match &self.snapshot {
219            Some(_) => format!(
220                "Repo: {}  ·  reload {} ms",
221                self.repo_path.display(),
222                self.last_load_ms
223            ),
224            None => format!("Buscando repo en {}…", self.repo_path.display()),
225        };
226
227        let header = div()
228            .flex()
229            .flex_row()
230            .items_center()
231            .px(px(16.))
232            .py(px(12.))
233            .bg(theme.bg_panel.clone())
234            .border_b_1()
235            .border_color(theme.border)
236            .text_color(text)
237            .text_size(px(14.))
238            .child(div().flex_grow().child(header_text))
239            .child(theme_switcher(cx));
240
241        let error_banner = self.error.as_ref().map(|e| {
242            banner_themed(cx, Banner::Error, e.clone())
243                .px(px(16.))
244                .py(px(8.))
245                .text_size(px(12.))
246        });
247
248        let body = match &self.snapshot {
249            None => div()
250                .px(px(16.))
251                .py(px(20.))
252                .text_color(text_dim)
253                .text_size(px(13.))
254                .child("Esperando primer refresh…"),
255            Some(snap) => {
256                let node_items: Vec<String> = snap
257                    .recent_nodes
258                    .iter()
259                    .map(|(h, k)| format!("{h}  {k}"))
260                    .collect();
261                let attestation_items: Vec<String> = snap
262                    .recent_attestations
263                    .iter()
264                    .map(|(h, did)| format!("{h}  ←  {did}"))
265                    .collect();
266                let mst_items: Vec<String> = snap.recent_mst_keys.clone();
267
268                div()
269                    .flex()
270                    .flex_col()
271                    .gap(px(8.))
272                    .px(px(16.))
273                    .py(px(16.))
274                    .child(stat_card(
275                        cx,
276                        "Nodos AST",
277                        snap.nodes,
278                        "fragments parseados del código",
279                        accent_nodes,
280                        text,
281                        text_dim,
282                        &node_items,
283                    ))
284                    .child(stat_card(
285                        cx,
286                        "Atestaciones",
287                        snap.attestations,
288                        "firmas Ed25519 sobre los nodos",
289                        accent_attestations,
290                        text,
291                        text_dim,
292                        &attestation_items,
293                    ))
294                    .child(stat_card(
295                        cx,
296                        "Claves MST",
297                        snap.mst_keys,
298                        "entradas del Merkle Search Tree",
299                        accent_mst,
300                        text,
301                        text_dim,
302                        &mst_items,
303                    ))
304            }
305        };
306
307        div()
308            .flex()
309            .flex_col()
310            .size_full()
311            .bg(bg)
312            .child(header)
313            .when_some(error_banner, |d, b| d.child(b))
314            .child(body)
315    }
316}
317
318/// Card visual para una estadística del dashboard. Border-l por
319/// kind, label arriba + número grande + descripción + listing de
320/// items recientes (puede estar vacío). Items se renderean en
321/// `monospace`-look (text_size chico) — útil para hashes/dids.
322fn stat_card(
323    cx: &mut Context<Explorer>,
324    label: &str,
325    value: usize,
326    description: &str,
327    accent: gpui::Rgba,
328    text: gpui::Hsla,
329    text_dim: gpui::Hsla,
330    recent_items: &[String],
331) -> impl IntoElement {
332    let mut card = card_themed(cx)
333        .border_l_4()
334        .border_color(accent)
335        .child(
336            div()
337                .text_color(accent)
338                .text_size(px(11.))
339                .child(SharedString::from(label.to_string())),
340        )
341        .child(
342            div()
343                .text_color(text)
344                .text_size(px(28.))
345                .child(SharedString::from(value.to_string())),
346        )
347        .child(
348            div()
349                .text_color(text_dim)
350                .text_size(px(11.))
351                .child(SharedString::from(description.to_string())),
352        );
353
354    if !recent_items.is_empty() {
355        // Header de la sub-section.
356        card = card.child(
357            div()
358                .mt(px(6.))
359                .text_color(text_dim)
360                .text_size(px(10.))
361                .child(SharedString::from(format!(
362                    "recent ({} de {}):",
363                    recent_items.len(),
364                    value
365                ))),
366        );
367        // Una linea por item.
368        for it in recent_items {
369            card = card.child(
370                div()
371                    .text_color(text)
372                    .text_size(px(11.))
373                    .child(SharedString::from(it.clone())),
374            );
375        }
376    }
377
378    card
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    /// Sanity: load_snapshot rebota si el dir no existe (mensaje
386    /// claro). Es el path típico para "no inicializaste el repo".
387    #[test]
388    fn load_snapshot_errors_on_missing_dir() {
389        let p = std::env::temp_dir().join(format!(
390            "minga-explorer-missing-{}-{}",
391            std::process::id(),
392            std::time::SystemTime::now()
393                .duration_since(std::time::UNIX_EPOCH)
394                .map(|d| d.as_nanos())
395                .unwrap_or(0)
396        ));
397        // p NO existe.
398        let err = load_snapshot(&p).unwrap_err();
399        assert!(
400            err.contains("no existe"),
401            "msg debe explicar el missing: {err}"
402        );
403    }
404
405    #[test]
406    fn snapshot_default_is_zeros_and_empty_lists() {
407        let s = RepoSnapshot::default();
408        assert_eq!(s.nodes, 0);
409        assert_eq!(s.attestations, 0);
410        assert_eq!(s.mst_keys, 0);
411        assert!(s.recent_nodes.is_empty());
412        assert!(s.recent_attestations.is_empty());
413        assert!(s.recent_mst_keys.is_empty());
414    }
415
416    #[test]
417    fn short_hash_takes_first_12_chars() {
418        let s = "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd";
419        assert_eq!(short_hash(s), "a1b2c3d4e5f6");
420        assert_eq!(short_hash(s).len(), 12);
421    }
422
423    #[test]
424    fn short_hash_handles_empty_or_shorter() {
425        assert_eq!(short_hash(""), "");
426        assert_eq!(short_hash("abc"), "abc");
427    }
428}