⎇ df089f0585e52435c1525abdf3a2c728cb5bc77a
crates/apps/nakui-explorer/src/main.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(nakui-explorer): nuevo binario GPUI — Nakui visible en la interfaz
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}