⎇ d5ef7144b5a1d2e6604505cb4dc08e7a703ecc72
crates/apps/nakui-explorer/src/main.rs
Historia
Quién tocó qué
crudo
feat(yahweh-meta-runtime): promover short_hash y preview_value desde nakui-explorer
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};
33use yahweh_meta_runtime::{preview_value, short_hash, short_uuid};
34
35const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
36
37fn main() {
38 Application::new().run(|cx: &mut App| {
39 let bounds = Bounds::centered(None, gpui::size(px(900.), px(640.)), cx);
40 cx.open_window(
41 WindowOptions {
42 window_bounds: Some(WindowBounds::Windowed(bounds)),
43 titlebar: Some(gpui::TitlebarOptions {
44 title: Some(SharedString::from("Nakui — Event Log")),
45 ..Default::default()
46 }),
47 ..Default::default()
48 },
49 |_w, cx| cx.new(Explorer::new),
50 )
51 .expect("open window");
52 cx.activate(true);
53 });
54}
55
56/// Estado de la vista. `entries` se reescribe en cada tick (el log
57/// es append-only así que reload completo es seguro y barato hasta
58/// decenas de miles de entries; optimizar a delta cuando duela).
59struct Explorer {
60 log_path: PathBuf,
61 entries: Vec<LogEntry>,
62 error: Option<SharedString>,
63 last_load_ms: u64,
64}
65
66impl Explorer {
67 fn new(cx: &mut Context<Self>) -> Self {
68 let log_path = std::env::var("NAKUI_EVENT_LOG")
69 .ok()
70 .map(PathBuf::from)
71 .unwrap_or_else(|| PathBuf::from("nakui.jsonl"));
72
73 // Loop de refresh.
74 let path_for_loop = log_path.clone();
75 cx.spawn(async move |this, cx| {
76 let timer = cx.background_executor().clone();
77 loop {
78 let started = std::time::Instant::now();
79 let result = load_log(&path_for_loop);
80 let elapsed_ms = started.elapsed().as_millis() as u64;
81 let _ = this.update(cx, |me, cx| {
82 match result {
83 Ok(entries) => {
84 me.entries = entries;
85 me.error = None;
86 }
87 Err(e) => {
88 me.error = Some(SharedString::from(format!(
89 "no pude leer {}: {}",
90 me.log_path.display(),
91 e
92 )));
93 }
94 }
95 me.last_load_ms = elapsed_ms;
96 cx.notify();
97 });
98 timer.timer(REFRESH_INTERVAL).await;
99 }
100 })
101 .detach();
102
103 Self {
104 log_path,
105 entries: Vec::new(),
106 error: None,
107 last_load_ms: 0,
108 }
109 }
110
111 fn breakdown(&self) -> (usize, usize, Vec<(String, usize)>) {
112 let mut seeds = 0;
113 let mut morphisms = 0;
114 let mut entity_counts: std::collections::BTreeMap<String, usize> =
115 std::collections::BTreeMap::new();
116 for e in &self.entries {
117 match e {
118 LogEntry::Seed { entity, .. } => {
119 seeds += 1;
120 *entity_counts.entry(entity.clone()).or_default() += 1;
121 }
122 LogEntry::Morphism { morphism, .. } => {
123 morphisms += 1;
124 *entity_counts.entry(format!("→ {}", morphism)).or_default() += 1;
125 }
126 }
127 }
128 let mut ranked: Vec<_> = entity_counts.into_iter().collect();
129 ranked.sort_by(|a, b| b.1.cmp(&a.1));
130 (seeds, morphisms, ranked)
131 }
132}
133
134fn load_log(path: &std::path::Path) -> Result<Vec<LogEntry>, String> {
135 let log = EventLog::open(path).map_err(|e| format!("open: {e}"))?;
136 log.entries().map_err(|e| format!("read: {e}"))
137}
138
139impl Render for Explorer {
140 fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
141 let bg = rgb(0x14171c);
142 let card_bg = rgb(0x1d2128);
143 let text_dim = rgb(0x9ba1ad);
144 let text = rgb(0xe6e8ec);
145 let accent_seed = rgb(0x88c0d0);
146 let accent_morphism = rgb(0xa3be8c);
147 let _ = card_bg;
148
149 let (seed_count, morphism_count, top_breakdown) = self.breakdown();
150
151 let header_text = format!(
152 "Log: {} · {} entries ({} seeds, {} morphisms) · reload {} ms",
153 self.log_path.display(),
154 self.entries.len(),
155 seed_count,
156 morphism_count,
157 self.last_load_ms,
158 );
159
160 let header = div()
161 .px(px(16.))
162 .py(px(12.))
163 .bg(card_bg)
164 .border_b_1()
165 .border_color(rgb(0x2a2f38))
166 .text_color(text)
167 .text_size(px(14.))
168 .child(header_text);
169
170 let breakdown_line = if top_breakdown.is_empty() {
171 String::new()
172 } else {
173 let parts: Vec<String> = top_breakdown
174 .iter()
175 .take(5)
176 .map(|(k, v)| format!("{k}({v})"))
177 .collect();
178 format!("breakdown: {}", parts.join(", "))
179 };
180
181 let breakdown_div = (!breakdown_line.is_empty()).then(|| {
182 div()
183 .px(px(16.))
184 .py(px(6.))
185 .bg(rgb(0x16191f))
186 .text_color(text_dim)
187 .text_size(px(11.))
188 .child(breakdown_line)
189 });
190
191 let error_banner = self.error.as_ref().map(|e| {
192 div()
193 .px(px(16.))
194 .py(px(8.))
195 .bg(rgb(0x4a2020))
196 .text_color(rgb(0xffd0d0))
197 .text_size(px(12.))
198 .child(e.clone())
199 });
200
201 // Renderea las últimas N entries (la timeline crece hacia abajo
202 // en append-order; mostramos las más recientes primero para
203 // que el usuario vea actividad reciente sin scroll).
204 const MAX_VISIBLE: usize = 200;
205 let visible: Vec<_> = self
206 .entries
207 .iter()
208 .rev()
209 .take(MAX_VISIBLE)
210 .collect();
211
212 let cards: Vec<gpui::AnyElement> = visible
213 .iter()
214 .map(|e| match e {
215 LogEntry::Seed {
216 seq,
217 entity,
218 id,
219 data,
220 schema_hash,
221 } => {
222 let data_preview = preview_value(data, 80);
223 let schema_label = schema_hash
224 .as_ref()
225 .map(|h| format!("schema={}", short_hash(h)))
226 .unwrap_or_else(|| "schema=(legacy)".into());
227 div()
228 .flex()
229 .flex_col()
230 .px(px(12.))
231 .py(px(8.))
232 .mb(px(4.))
233 .bg(card_bg)
234 .rounded(px(4.))
235 .border_l_4()
236 .border_color(accent_seed)
237 .gap(px(2.))
238 .child(
239 div()
240 .flex()
241 .flex_row()
242 .gap(px(8.))
243 .items_center()
244 .child(
245 div()
246 .text_color(accent_seed)
247 .text_size(px(11.))
248 .child(format!("[#{seq} seed]")),
249 )
250 .child(
251 div()
252 .text_color(text)
253 .text_size(px(13.))
254 .child(entity.clone()),
255 )
256 .child(
257 div()
258 .text_color(text_dim)
259 .text_size(px(10.))
260 .child(format!("id={}", short_uuid(id))),
261 ),
262 )
263 .child(
264 div()
265 .text_color(text_dim)
266 .text_size(px(11.))
267 .child(data_preview),
268 )
269 .child(
270 div()
271 .text_color(text_dim)
272 .text_size(px(10.))
273 .child(schema_label),
274 )
275 .into_any_element()
276 }
277 LogEntry::Morphism {
278 seq,
279 morphism,
280 inputs,
281 params,
282 ops,
283 schema_hash,
284 } => {
285 let inputs_line = if inputs.is_empty() {
286 String::new()
287 } else {
288 let parts: Vec<String> = inputs
289 .iter()
290 .map(|(name, id)| format!("{name}={}", short_uuid(id)))
291 .collect();
292 format!("inputs: {}", parts.join(", "))
293 };
294 let params_line = preview_value(params, 80);
295 let ops_line = format!("{} op(s)", ops.len());
296 let schema_label = schema_hash
297 .as_ref()
298 .map(|h| format!("schema={}", short_hash(h)))
299 .unwrap_or_else(|| "schema=(legacy)".into());
300 div()
301 .flex()
302 .flex_col()
303 .px(px(12.))
304 .py(px(8.))
305 .mb(px(4.))
306 .bg(card_bg)
307 .rounded(px(4.))
308 .border_l_4()
309 .border_color(accent_morphism)
310 .gap(px(2.))
311 .child(
312 div()
313 .flex()
314 .flex_row()
315 .gap(px(8.))
316 .items_center()
317 .child(
318 div()
319 .text_color(accent_morphism)
320 .text_size(px(11.))
321 .child(format!("[#{seq} morph]")),
322 )
323 .child(
324 div()
325 .text_color(text)
326 .text_size(px(13.))
327 .child(morphism.clone()),
328 )
329 .child(
330 div()
331 .text_color(text_dim)
332 .text_size(px(10.))
333 .child(ops_line),
334 ),
335 )
336 .when(!inputs_line.is_empty(), |d| {
337 d.child(
338 div()
339 .text_color(text_dim)
340 .text_size(px(11.))
341 .child(inputs_line),
342 )
343 })
344 .when(!params_line.is_empty(), |d| {
345 d.child(
346 div()
347 .text_color(text_dim)
348 .text_size(px(11.))
349 .child(format!("params: {params_line}")),
350 )
351 })
352 .child(
353 div()
354 .text_color(text_dim)
355 .text_size(px(10.))
356 .child(schema_label),
357 )
358 .into_any_element()
359 }
360 })
361 .collect();
362
363 let body = div()
364 .flex()
365 .flex_col()
366 .p(px(12.))
367 .overflow_hidden()
368 .children(cards);
369
370 div()
371 .flex()
372 .flex_col()
373 .size_full()
374 .bg(bg)
375 .child(header)
376 .when_some(breakdown_div, |d, b| d.child(b))
377 .when_some(error_banner, |d, b| d.child(b))
378 .child(body)
379 }
380}
381
382// Helpers `short_uuid`, `short_hash`, `preview_value` viven en
383// `yahweh_meta_runtime::format`. Se usan acá via el `use` de arriba.
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use std::io::Write;
389
390 fn write_sample_log() -> tempfile::NamedTempFile {
391 let mut f = tempfile::NamedTempFile::new().unwrap();
392 // 3 seeds + 2 morphisms, formato canónico de event_log.
393 let lines = [
394 r#"{"kind":"seed","seq":0,"entity":"product","id":"00000000-0000-0000-0000-000000000001","data":{"sku":"A"}}"#,
395 r#"{"kind":"seed","seq":1,"entity":"product","id":"00000000-0000-0000-0000-000000000002","data":{"sku":"B"}}"#,
396 r#"{"kind":"seed","seq":2,"entity":"customer","id":"00000000-0000-0000-0000-000000000003","data":{"name":"Acme"}}"#,
397 r#"{"kind":"morphism","seq":3,"morphism":"sale.create","inputs":{"product":"00000000-0000-0000-0000-000000000001"},"params":{"qty":1},"ops":[]}"#,
398 r#"{"kind":"morphism","seq":4,"morphism":"sale.refund","inputs":{},"params":{},"ops":[]}"#,
399 ];
400 for l in lines {
401 writeln!(f, "{l}").unwrap();
402 }
403 f.flush().unwrap();
404 f
405 }
406
407 #[test]
408 fn load_log_returns_all_entries_in_order() {
409 let f = write_sample_log();
410 let entries = load_log(f.path()).expect("load");
411 assert_eq!(entries.len(), 5);
412 for (i, e) in entries.iter().enumerate() {
413 assert_eq!(e.seq(), i as u64, "seqs should be 0..4 contiguous");
414 }
415 }
416
417 #[test]
418 fn breakdown_counts_seeds_morphisms_and_buckets() {
419 let f = write_sample_log();
420 let entries = load_log(f.path()).unwrap();
421 let me = Explorer {
422 log_path: f.path().to_path_buf(),
423 entries,
424 error: None,
425 last_load_ms: 0,
426 };
427 let (seeds, morphisms, ranked) = me.breakdown();
428 assert_eq!(seeds, 3);
429 assert_eq!(morphisms, 2);
430 // Buckets esperados: product (2), customer (1), → sale.create (1),
431 // → sale.refund (1).
432 assert_eq!(ranked.len(), 4);
433 let map: std::collections::BTreeMap<_, _> = ranked.into_iter().collect();
434 assert_eq!(map.get("product"), Some(&2));
435 assert_eq!(map.get("customer"), Some(&1));
436 assert_eq!(map.get("→ sale.create"), Some(&1));
437 assert_eq!(map.get("→ sale.refund"), Some(&1));
438 }
439
440 #[test]
441 fn load_missing_file_yields_empty_not_error() {
442 // EventLog::open de un archivo inexistente no falla; entries() devuelve [].
443 let path = std::env::temp_dir().join("nakui-explorer-missing-test.jsonl");
444 let _ = std::fs::remove_file(&path);
445 let result = load_log(&path).expect("missing path is OK per EventLog::open contract");
446 assert!(result.is_empty());
447 }
448
449 // Tests de `short_uuid` / `short_hash` / `preview_value` viven
450 // en `yahweh-meta-runtime::format` tras la migración. Si esos se
451 // vuelven a romper, los tests específicos del crate runtime los
452 // capturan; acá no duplicamos.
453}