gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Sutichaykuna

⎇ df089f0585e52435c1525abdf3a2c728cb5bc77a crates/apps/nakui-explorer/src/main.rs Rust · 505 siq'ikuna · 17.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(nakui-explorer): nuevo binario GPUI — Nakui visible en la interfaz 5b8f71e0d · Sergio ·

1//! `nakui-explorer` — panel GPUI que renderea el event log de un
2//! repo Nakui: timeline de seeds + morphisms con sus parámetros y
3//! breakdown por entity type.
4//!
5//! ## Diseño
6//!
7//! Standalone, lee un archivo `.jsonl` (formato append-only del
8//! `nakui_core::event_log::EventLog`). Refresh por polling cada 2s
9//! para detectar nuevos eventos appended (típico de un nakui ERP en
10//! producción que va escribiendo). Sin discovery dinámico vía broker
11//! brahman porque nakui hoy es CLI/library/demos, no daemon — cuando
12//! se daemonice, sustituir el lector de archivo por un sidecar
13//! consumer (mismo patrón que `nouser-explorer`).
14//!
15//! ## Uso
16//!
17//! ```sh
18//! # Path explícito:
19//! NAKUI_EVENT_LOG=/tmp/nakui-demo.jsonl cargo run -p nakui-explorer
20//!
21//! # Default si la env no está: ./nakui.jsonl en pwd.
22//! cargo run -p nakui-explorer
23//! ```
24
25use std::path::PathBuf;
26use std::time::Duration;
27
28use gpui::{
29    div, prelude::*, px, rgb, App, Application, Bounds, Context, IntoElement, Render,
30    SharedString, Window, WindowBounds, WindowOptions,
31};
32use nakui_core::event_log::{EventLog, LogEntry};
33
34const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
35
36fn main() {
37    Application::new().run(|cx: &mut App| {
38        let bounds = Bounds::centered(None, gpui::size(px(900.), px(640.)), cx);
39        cx.open_window(
40            WindowOptions {
41                window_bounds: Some(WindowBounds::Windowed(bounds)),
42                titlebar: Some(gpui::TitlebarOptions {
43                    title: Some(SharedString::from("Nakui — Event Log")),
44                    ..Default::default()
45                }),
46                ..Default::default()
47            },
48            |_w, cx| cx.new(Explorer::new),
49        )
50        .expect("open window");
51        cx.activate(true);
52    });
53}
54
55/// Estado de la vista. `entries` se reescribe en cada tick (el log
56/// es append-only así que reload completo es seguro y barato hasta
57/// decenas de miles de entries; optimizar a delta cuando duela).
58struct Explorer {
59    log_path: PathBuf,
60    entries: Vec<LogEntry>,
61    error: Option<SharedString>,
62    last_load_ms: u64,
63}
64
65impl Explorer {
66    fn new(cx: &mut Context<Self>) -> Self {
67        let log_path = std::env::var("NAKUI_EVENT_LOG")
68            .ok()
69            .map(PathBuf::from)
70            .unwrap_or_else(|| PathBuf::from("nakui.jsonl"));
71
72        // Loop de refresh.
73        let path_for_loop = log_path.clone();
74        cx.spawn(async move |this, cx| {
75            let timer = cx.background_executor().clone();
76            loop {
77                let started = std::time::Instant::now();
78                let result = load_log(&path_for_loop);
79                let elapsed_ms = started.elapsed().as_millis() as u64;
80                let _ = this.update(cx, |me, cx| {
81                    match result {
82                        Ok(entries) => {
83                            me.entries = entries;
84                            me.error = None;
85                        }
86                        Err(e) => {
87                            me.error = Some(SharedString::from(format!(
88                                "no pude leer {}: {}",
89                                me.log_path.display(),
90                                e
91                            )));
92                        }
93                    }
94                    me.last_load_ms = elapsed_ms;
95                    cx.notify();
96                });
97                timer.timer(REFRESH_INTERVAL).await;
98            }
99        })
100        .detach();
101
102        Self {
103            log_path,
104            entries: Vec::new(),
105            error: None,
106            last_load_ms: 0,
107        }
108    }
109
110    fn breakdown(&self) -> (usize, usize, Vec<(String, usize)>) {
111        let mut seeds = 0;
112        let mut morphisms = 0;
113        let mut entity_counts: std::collections::BTreeMap<String, usize> =
114            std::collections::BTreeMap::new();
115        for e in &self.entries {
116            match e {
117                LogEntry::Seed { entity, .. } => {
118                    seeds += 1;
119                    *entity_counts.entry(entity.clone()).or_default() += 1;
120                }
121                LogEntry::Morphism { morphism, .. } => {
122                    morphisms += 1;
123                    *entity_counts.entry(format!("→ {}", morphism)).or_default() += 1;
124                }
125            }
126        }
127        let mut ranked: Vec<_> = entity_counts.into_iter().collect();
128        ranked.sort_by(|a, b| b.1.cmp(&a.1));
129        (seeds, morphisms, ranked)
130    }
131}
132
133fn load_log(path: &std::path::Path) -> Result<Vec<LogEntry>, String> {
134    let log = EventLog::open(path).map_err(|e| format!("open: {e}"))?;
135    log.entries().map_err(|e| format!("read: {e}"))
136}
137
138impl Render for Explorer {
139    fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
140        let bg = rgb(0x14171c);
141        let card_bg = rgb(0x1d2128);
142        let text_dim = rgb(0x9ba1ad);
143        let text = rgb(0xe6e8ec);
144        let accent_seed = rgb(0x88c0d0);
145        let accent_morphism = rgb(0xa3be8c);
146        let _ = card_bg;
147
148        let (seed_count, morphism_count, top_breakdown) = self.breakdown();
149
150        let header_text = format!(
151            "Log: {}  ·  {} entries ({} seeds, {} morphisms)  ·  reload {} ms",
152            self.log_path.display(),
153            self.entries.len(),
154            seed_count,
155            morphism_count,
156            self.last_load_ms,
157        );
158
159        let header = div()
160            .px(px(16.))
161            .py(px(12.))
162            .bg(card_bg)
163            .border_b_1()
164            .border_color(rgb(0x2a2f38))
165            .text_color(text)
166            .text_size(px(14.))
167            .child(header_text);
168
169        let breakdown_line = if top_breakdown.is_empty() {
170            String::new()
171        } else {
172            let parts: Vec<String> = top_breakdown
173                .iter()
174                .take(5)
175                .map(|(k, v)| format!("{k}({v})"))
176                .collect();
177            format!("breakdown: {}", parts.join(", "))
178        };
179
180        let breakdown_div = (!breakdown_line.is_empty()).then(|| {
181            div()
182                .px(px(16.))
183                .py(px(6.))
184                .bg(rgb(0x16191f))
185                .text_color(text_dim)
186                .text_size(px(11.))
187                .child(breakdown_line)
188        });
189
190        let error_banner = self.error.as_ref().map(|e| {
191            div()
192                .px(px(16.))
193                .py(px(8.))
194                .bg(rgb(0x4a2020))
195                .text_color(rgb(0xffd0d0))
196                .text_size(px(12.))
197                .child(e.clone())
198        });
199
200        // Renderea las últimas N entries (la timeline crece hacia abajo
201        // en append-order; mostramos las más recientes primero para
202        // que el usuario vea actividad reciente sin scroll).
203        const MAX_VISIBLE: usize = 200;
204        let visible: Vec<_> = self
205            .entries
206            .iter()
207            .rev()
208            .take(MAX_VISIBLE)
209            .collect();
210
211        let cards: Vec<gpui::AnyElement> = visible
212            .iter()
213            .map(|e| match e {
214                LogEntry::Seed {
215                    seq,
216                    entity,
217                    id,
218                    data,
219                    schema_hash,
220                } => {
221                    let data_preview = preview_value(data, 80);
222                    let schema_label = schema_hash
223                        .as_ref()
224                        .map(|h| format!("schema={}", short_hash(h)))
225                        .unwrap_or_else(|| "schema=(legacy)".into());
226                    div()
227                        .flex()
228                        .flex_col()
229                        .px(px(12.))
230                        .py(px(8.))
231                        .mb(px(4.))
232                        .bg(card_bg)
233                        .rounded(px(4.))
234                        .border_l_4()
235                        .border_color(accent_seed)
236                        .gap(px(2.))
237                        .child(
238                            div()
239                                .flex()
240                                .flex_row()
241                                .gap(px(8.))
242                                .items_center()
243                                .child(
244                                    div()
245                                        .text_color(accent_seed)
246                                        .text_size(px(11.))
247                                        .child(format!("[#{seq} seed]")),
248                                )
249                                .child(
250                                    div()
251                                        .text_color(text)
252                                        .text_size(px(13.))
253                                        .child(entity.clone()),
254                                )
255                                .child(
256                                    div()
257                                        .text_color(text_dim)
258                                        .text_size(px(10.))
259                                        .child(format!("id={}", short_uuid(id))),
260                                ),
261                        )
262                        .child(
263                            div()
264                                .text_color(text_dim)
265                                .text_size(px(11.))
266                                .child(data_preview),
267                        )
268                        .child(
269                            div()
270                                .text_color(text_dim)
271                                .text_size(px(10.))
272                                .child(schema_label),
273                        )
274                        .into_any_element()
275                }
276                LogEntry::Morphism {
277                    seq,
278                    morphism,
279                    inputs,
280                    params,
281                    ops,
282                    schema_hash,
283                } => {
284                    let inputs_line = if inputs.is_empty() {
285                        String::new()
286                    } else {
287                        let parts: Vec<String> = inputs
288                            .iter()
289                            .map(|(name, id)| format!("{name}={}", short_uuid(id)))
290                            .collect();
291                        format!("inputs: {}", parts.join(", "))
292                    };
293                    let params_line = preview_value(params, 80);
294                    let ops_line = format!("{} op(s)", ops.len());
295                    let schema_label = schema_hash
296                        .as_ref()
297                        .map(|h| format!("schema={}", short_hash(h)))
298                        .unwrap_or_else(|| "schema=(legacy)".into());
299                    div()
300                        .flex()
301                        .flex_col()
302                        .px(px(12.))
303                        .py(px(8.))
304                        .mb(px(4.))
305                        .bg(card_bg)
306                        .rounded(px(4.))
307                        .border_l_4()
308                        .border_color(accent_morphism)
309                        .gap(px(2.))
310                        .child(
311                            div()
312                                .flex()
313                                .flex_row()
314                                .gap(px(8.))
315                                .items_center()
316                                .child(
317                                    div()
318                                        .text_color(accent_morphism)
319                                        .text_size(px(11.))
320                                        .child(format!("[#{seq} morph]")),
321                                )
322                                .child(
323                                    div()
324                                        .text_color(text)
325                                        .text_size(px(13.))
326                                        .child(morphism.clone()),
327                                )
328                                .child(
329                                    div()
330                                        .text_color(text_dim)
331                                        .text_size(px(10.))
332                                        .child(ops_line),
333                                ),
334                        )
335                        .when(!inputs_line.is_empty(), |d| {
336                            d.child(
337                                div()
338                                    .text_color(text_dim)
339                                    .text_size(px(11.))
340                                    .child(inputs_line),
341                            )
342                        })
343                        .when(!params_line.is_empty(), |d| {
344                            d.child(
345                                div()
346                                    .text_color(text_dim)
347                                    .text_size(px(11.))
348                                    .child(format!("params: {params_line}")),
349                            )
350                        })
351                        .child(
352                            div()
353                                .text_color(text_dim)
354                                .text_size(px(10.))
355                                .child(schema_label),
356                        )
357                        .into_any_element()
358                }
359            })
360            .collect();
361
362        let body = div()
363            .flex()
364            .flex_col()
365            .p(px(12.))
366            .overflow_hidden()
367            .children(cards);
368
369        div()
370            .flex()
371            .flex_col()
372            .size_full()
373            .bg(bg)
374            .child(header)
375            .when_some(breakdown_div, |d, b| d.child(b))
376            .when_some(error_banner, |d, b| d.child(b))
377            .child(body)
378    }
379}
380
381fn short_uuid(id: &uuid::Uuid) -> String {
382    let s = id.to_string();
383    if s.len() > 8 {
384        s[..8].to_string()
385    } else {
386        s
387    }
388}
389
390fn short_hash(h: &[u8; 32]) -> String {
391    let mut s = String::with_capacity(8);
392    for b in h.iter().take(4) {
393        use std::fmt::Write;
394        let _ = write!(s, "{:02x}", b);
395    }
396    s
397}
398
399/// Renderiza un `serde_json::Value` en una sola línea limitada a `max`
400/// caracteres (para preview en la timeline, no para edición).
401fn preview_value(v: &serde_json::Value, max: usize) -> String {
402    let s = v.to_string();
403    if s.chars().count() <= max {
404        s
405    } else {
406        let truncated: String = s.chars().take(max - 3).collect();
407        format!("{truncated}...")
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use std::io::Write;
415
416    fn write_sample_log() -> tempfile::NamedTempFile {
417        let mut f = tempfile::NamedTempFile::new().unwrap();
418        // 3 seeds + 2 morphisms, formato canónico de event_log.
419        let lines = [
420            r#"{"kind":"seed","seq":0,"entity":"product","id":"00000000-0000-0000-0000-000000000001","data":{"sku":"A"}}"#,
421            r#"{"kind":"seed","seq":1,"entity":"product","id":"00000000-0000-0000-0000-000000000002","data":{"sku":"B"}}"#,
422            r#"{"kind":"seed","seq":2,"entity":"customer","id":"00000000-0000-0000-0000-000000000003","data":{"name":"Acme"}}"#,
423            r#"{"kind":"morphism","seq":3,"morphism":"sale.create","inputs":{"product":"00000000-0000-0000-0000-000000000001"},"params":{"qty":1},"ops":[]}"#,
424            r#"{"kind":"morphism","seq":4,"morphism":"sale.refund","inputs":{},"params":{},"ops":[]}"#,
425        ];
426        for l in lines {
427            writeln!(f, "{l}").unwrap();
428        }
429        f.flush().unwrap();
430        f
431    }
432
433    #[test]
434    fn load_log_returns_all_entries_in_order() {
435        let f = write_sample_log();
436        let entries = load_log(f.path()).expect("load");
437        assert_eq!(entries.len(), 5);
438        for (i, e) in entries.iter().enumerate() {
439            assert_eq!(e.seq(), i as u64, "seqs should be 0..4 contiguous");
440        }
441    }
442
443    #[test]
444    fn breakdown_counts_seeds_morphisms_and_buckets() {
445        let f = write_sample_log();
446        let entries = load_log(f.path()).unwrap();
447        let me = Explorer {
448            log_path: f.path().to_path_buf(),
449            entries,
450            error: None,
451            last_load_ms: 0,
452        };
453        let (seeds, morphisms, ranked) = me.breakdown();
454        assert_eq!(seeds, 3);
455        assert_eq!(morphisms, 2);
456        // Buckets esperados: product (2), customer (1), → sale.create (1),
457        // → sale.refund (1).
458        assert_eq!(ranked.len(), 4);
459        let map: std::collections::BTreeMap<_, _> = ranked.into_iter().collect();
460        assert_eq!(map.get("product"), Some(&2));
461        assert_eq!(map.get("customer"), Some(&1));
462        assert_eq!(map.get("→ sale.create"), Some(&1));
463        assert_eq!(map.get("→ sale.refund"), Some(&1));
464    }
465
466    #[test]
467    fn load_missing_file_yields_empty_not_error() {
468        // EventLog::open de un archivo inexistente no falla; entries() devuelve [].
469        let path = std::env::temp_dir().join("nakui-explorer-missing-test.jsonl");
470        let _ = std::fs::remove_file(&path);
471        let result = load_log(&path).expect("missing path is OK per EventLog::open contract");
472        assert!(result.is_empty());
473    }
474
475    #[test]
476    fn preview_value_truncates_long_strings() {
477        let v = serde_json::json!({"a": "x".repeat(200)});
478        let p = preview_value(&v, 30);
479        assert!(p.len() <= 30);
480        assert!(p.ends_with("..."));
481    }
482
483    #[test]
484    fn preview_value_keeps_short_strings_intact() {
485        let v = serde_json::json!({"a": 1});
486        let p = preview_value(&v, 30);
487        assert_eq!(p, "{\"a\":1}");
488    }
489
490    #[test]
491    fn short_uuid_takes_first_8_chars() {
492        let id = uuid::Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap();
493        assert_eq!(short_uuid(&id), "a1b2c3d4");
494    }
495
496    #[test]
497    fn short_hash_takes_first_4_bytes_hex() {
498        let mut h = [0u8; 32];
499        h[0] = 0xaa;
500        h[1] = 0xbb;
501        h[2] = 0xcc;
502        h[3] = 0xdd;
503        assert_eq!(short_hash(&h), "aabbccdd");
504    }
505}