tawasuyu

tawasuyu/brahman

Rust 334TOML 129Markdown 39JSON 174745 Definiciones

⎇ 2790b6dc8a00189b948dff7d95c5ad0a47af068c crates/apps/minga-explorer/src/main.rs Rust · 303 líneas · 9.4 KB Historia Quién tocó qué crudo

feat(minga-explorer): nueva app dashboard del repo Minga sobre stack yahweh 2790b6dc8 · 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/// Snapshot de counts del repo. Se reemplaza completo en cada
63/// refresh — los stores no diff fácilmente, y los counts son
64/// baratos (sled tracks size).
65#[derive(Clone, Default, Debug)]
66struct RepoSnapshot {
67    nodes: usize,
68    attestations: usize,
69    mst_keys: usize,
70}
71
72struct Explorer {
73    repo_path: PathBuf,
74    snapshot: Option<RepoSnapshot>,
75    error: Option<SharedString>,
76    last_load_ms: u64,
77}
78
79impl Explorer {
80    fn new(cx: &mut Context<Self>) -> Self {
81        let repo_path = std::env::var("MINGA_REPO")
82            .ok()
83            .map(PathBuf::from)
84            .unwrap_or_else(|| PathBuf::from(".minga"));
85        let path_for_loop = repo_path.clone();
86        cx.spawn(async move |this, cx| {
87            let timer = cx.background_executor().clone();
88            loop {
89                let started = std::time::Instant::now();
90                let result = load_snapshot(&path_for_loop);
91                let elapsed = started.elapsed().as_millis() as u64;
92                let _ = this.update(cx, |me, cx| {
93                    match result {
94                        Ok(snap) => {
95                            me.snapshot = Some(snap);
96                            me.error = None;
97                        }
98                        Err(e) => {
99                            me.error = Some(SharedString::from(format!(
100                                "no pude leer repo {}: {}",
101                                me.repo_path.display(),
102                                e
103                            )));
104                        }
105                    }
106                    me.last_load_ms = elapsed;
107                    cx.notify();
108                });
109                timer.timer(REFRESH_INTERVAL).await;
110            }
111        })
112        .detach();
113
114        Self {
115            repo_path,
116            snapshot: None,
117            error: None,
118            last_load_ms: 0,
119        }
120    }
121}
122
123/// Lee el repo sled `<repo_path>/repo` y devuelve los 3 counts.
124/// Falla si: el dir no existe, sled rebota al abrir, o cualquier
125/// store falla a `len()`. Ningún error es fatal — la UI muestra el
126/// banner y mantiene el último snapshot bueno.
127fn load_snapshot(repo_path: &std::path::Path) -> Result<RepoSnapshot, String> {
128    let inner = repo_path.join(REPO_DIRNAME);
129    if !inner.exists() {
130        return Err(format!(
131            "directorio del repo sled no existe: {}",
132            inner.display()
133        ));
134    }
135    let repo = PersistentRepo::open(&inner).map_err(|e| format!("open: {e}"))?;
136    Ok(RepoSnapshot {
137        nodes: repo.nodes.len(),
138        attestations: repo.attestations.len(),
139        mst_keys: repo.mst.len(),
140    })
141}
142
143impl Render for Explorer {
144    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
145        let theme = Theme::global(cx).clone();
146        let bg = theme.bg_app.clone();
147        let text = theme.fg_text;
148        let text_dim = theme.fg_muted;
149        // Acentos por kind del dashboard: nodos azul, atestaciones
150        // verde, MST purple. Señales semánticas del dominio Minga.
151        let accent_nodes = gpui::rgb(0x88c0d0);
152        let accent_attestations = gpui::rgb(0xa3be8c);
153        let accent_mst = gpui::rgb(0xb48ead);
154
155        let header_text = match &self.snapshot {
156            Some(_) => format!(
157                "Repo: {}  ·  reload {} ms",
158                self.repo_path.display(),
159                self.last_load_ms
160            ),
161            None => format!("Buscando repo en {}…", self.repo_path.display()),
162        };
163
164        let header = div()
165            .flex()
166            .flex_row()
167            .items_center()
168            .px(px(16.))
169            .py(px(12.))
170            .bg(theme.bg_panel.clone())
171            .border_b_1()
172            .border_color(theme.border)
173            .text_color(text)
174            .text_size(px(14.))
175            .child(div().flex_grow().child(header_text))
176            .child(theme_switcher(cx));
177
178        let error_banner = self.error.as_ref().map(|e| {
179            banner_themed(cx, Banner::Error, e.clone())
180                .px(px(16.))
181                .py(px(8.))
182                .text_size(px(12.))
183        });
184
185        let body = match &self.snapshot {
186            None => div()
187                .px(px(16.))
188                .py(px(20.))
189                .text_color(text_dim)
190                .text_size(px(13.))
191                .child("Esperando primer refresh…"),
192            Some(snap) => div()
193                .flex()
194                .flex_col()
195                .gap(px(8.))
196                .px(px(16.))
197                .py(px(16.))
198                .child(stat_card(
199                    cx,
200                    "Nodos AST",
201                    snap.nodes,
202                    "fragments parseados del código",
203                    accent_nodes,
204                    text,
205                    text_dim,
206                ))
207                .child(stat_card(
208                    cx,
209                    "Atestaciones",
210                    snap.attestations,
211                    "firmas Ed25519 sobre los nodos",
212                    accent_attestations,
213                    text,
214                    text_dim,
215                ))
216                .child(stat_card(
217                    cx,
218                    "Claves MST",
219                    snap.mst_keys,
220                    "entradas del Merkle Search Tree",
221                    accent_mst,
222                    text,
223                    text_dim,
224                )),
225        };
226
227        div()
228            .flex()
229            .flex_col()
230            .size_full()
231            .bg(bg)
232            .child(header)
233            .when_some(error_banner, |d, b| d.child(b))
234            .child(body)
235    }
236}
237
238/// Card visual para una estadística del dashboard. Border-l por
239/// kind, label arriba + número grande + descripción abajo.
240fn stat_card(
241    cx: &mut Context<Explorer>,
242    label: &str,
243    value: usize,
244    description: &str,
245    accent: gpui::Rgba,
246    text: gpui::Hsla,
247    text_dim: gpui::Hsla,
248) -> impl IntoElement {
249    card_themed(cx)
250        .border_l_4()
251        .border_color(accent)
252        .child(
253            div()
254                .text_color(accent)
255                .text_size(px(11.))
256                .child(SharedString::from(label.to_string())),
257        )
258        .child(
259            div()
260                .text_color(text)
261                .text_size(px(28.))
262                .child(SharedString::from(value.to_string())),
263        )
264        .child(
265            div()
266                .text_color(text_dim)
267                .text_size(px(11.))
268                .child(SharedString::from(description.to_string())),
269        )
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    /// Sanity: load_snapshot rebota si el dir no existe (mensaje
277    /// claro). Es el path típico para "no inicializaste el repo".
278    #[test]
279    fn load_snapshot_errors_on_missing_dir() {
280        let p = std::env::temp_dir().join(format!(
281            "minga-explorer-missing-{}-{}",
282            std::process::id(),
283            std::time::SystemTime::now()
284                .duration_since(std::time::UNIX_EPOCH)
285                .map(|d| d.as_nanos())
286                .unwrap_or(0)
287        ));
288        // p NO existe.
289        let err = load_snapshot(&p).unwrap_err();
290        assert!(
291            err.contains("no existe"),
292            "msg debe explicar el missing: {err}"
293        );
294    }
295
296    #[test]
297    fn snapshot_default_is_zeros() {
298        let s = RepoSnapshot::default();
299        assert_eq!(s.nodes, 0);
300        assert_eq!(s.attestations, 0);
301        assert_eq!(s.mst_keys, 0);
302    }
303}