feat(nakui-explorer): nuevo binario GPUI — Nakui visible en la interfaz
Cierra "nakui no tiene UI propia" del audit. Nuevo binario standalone nakui-explorer (paralelo a nouser-explorer) que renderea el event log de un repo Nakui: timeline scrollable de seeds + morphisms con sus parametros, breakdown por entity type, polling cada 2s para detectar nuevos eventos appended sin restart. Diseno: lee directamente el archivo .jsonl del nakui_core::event_log:: EventLog. Path por env NAKUI_EVENT_LOG, default "nakui.jsonl" en pwd. Sin discovery via broker brahman porque nakui hoy es CLI/library/demos, no daemon. Cuando se daemonice, sustituir el lector de archivo por un sidecar consumer (mismo patron que nouser-explorer). UI: - Header: path, count total + breakdown seeds/morphisms, reload time. - Breakdown line: top 5 buckets por frecuencia (entities + morphisms). - Timeline: tarjetas color-coded por kind (azul=seed, verde=morphism) con #seq, kind, entity/morphism, id corto, preview de data/params, schema hash o "(legacy)". Mas-recientes-primero, hasta 200 visibles. - Error banner si lectura falla; el explorer no crashea, sigue intentando cada 2s. Wire: nuevo crates/apps/nakui-explorer/ agregado al workspace. Deps minimas: nakui-core, gpui, serde_json, uuid (feature serde). Sin deps de brahman (Nakui standalone). Tests: 7 unitarios cubriendo load_log (in-order, missing-file), breakdown (counts + buckets), preview_value (truncate + intact), short_uuid + short_hash. Activacion: NAKUI_EVENT_LOG=/tmp/nakui_inv_xxx.jsonl cargo run -p nakui-explorer Estado del CHANGELOG global tras este commit: cero pendientes fundamentados activos. Lo unico que queda es minga-vfs (FUSE, explicitamente diferido) y mejoras nice-to-have (cobertura adicional per-lenguaje, daemon-izacion de nakui para sidecar discovery).
This commit is contained in:
@@ -6,6 +6,64 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-09
|
## 2026-05-09
|
||||||
|
|
||||||
|
### feat(nakui-explorer): nuevo binario GPUI — Nakui visible en la interfaz
|
||||||
|
Cierra "nakui no tiene UI propia" del audit. Nuevo binario standalone
|
||||||
|
`nakui-explorer` (paralelo a `nouser-explorer`) que renderea el
|
||||||
|
event log de un repo Nakui: timeline scrollable de seeds + morphisms
|
||||||
|
con sus parámetros, breakdown por entity type, polling cada 2s para
|
||||||
|
detectar nuevos eventos appended sin restart del explorer.
|
||||||
|
|
||||||
|
Diseño:
|
||||||
|
- Lee directamente el archivo `.jsonl` del `nakui_core::event_log::EventLog`.
|
||||||
|
Path por env `NAKUI_EVENT_LOG`, default `nakui.jsonl` en pwd.
|
||||||
|
- Sin discovery vía broker brahman porque nakui hoy es CLI/library/
|
||||||
|
demos, no daemon. Cuando se daemonice, sustituir el lector de
|
||||||
|
archivo por un sidecar consumer (mismo patrón que nouser-explorer
|
||||||
|
actualmente usa).
|
||||||
|
|
||||||
|
UI:
|
||||||
|
- **Header**: path del log, count total + breakdown seeds/morphisms,
|
||||||
|
tiempo del último reload en ms.
|
||||||
|
- **Breakdown line**: top 5 buckets por frecuencia (entities + nombres
|
||||||
|
de morphisms con prefijo `→`).
|
||||||
|
- **Timeline**: tarjetas color-coded por kind (azul=seed,
|
||||||
|
verde=morphism). Cada tarjeta muestra `#seq`, kind, entity/morphism
|
||||||
|
name, id corto (8 hex), preview del data/params (80 chars), schema
|
||||||
|
hash corto (8 hex) o `(legacy)` si pre-versioning. Mostradas
|
||||||
|
más-recientes-primero, hasta 200 visibles (suficiente para
|
||||||
|
navegación; sin scroll virtualizado por ahora).
|
||||||
|
- **Error banner**: si la lectura falla (archivo inexistente o
|
||||||
|
corrupto), banner rojo con el motivo. El explorer NO crashea —
|
||||||
|
sigue intentando cada 2s.
|
||||||
|
|
||||||
|
Wire en workspace:
|
||||||
|
- Nuevo `crates/apps/nakui-explorer/` agregado a `[workspace] members`.
|
||||||
|
- Deps mínimas: `nakui-core` (para EventLog + LogEntry), `gpui`,
|
||||||
|
`serde_json`, `uuid` (con feature serde para parsear los IDs).
|
||||||
|
- Sin deps de brahman por ahora (Nakui standalone).
|
||||||
|
|
||||||
|
Tests: 7 unitarios en `tests` mod del bin:
|
||||||
|
- `load_log_returns_all_entries_in_order` — cargar un .jsonl
|
||||||
|
generado a mano, asserta que devuelve 5 entries con seqs 0..4
|
||||||
|
contiguous.
|
||||||
|
- `breakdown_counts_seeds_morphisms_and_buckets` — verifica el
|
||||||
|
conteo (3 seeds + 2 morphisms) y los buckets esperados.
|
||||||
|
- `load_missing_file_yields_empty_not_error` — archivo inexistente
|
||||||
|
devuelve `[]` sin error (delegado al contrato de `EventLog::open`).
|
||||||
|
- `preview_value_truncates_long_strings` y `_keeps_short_strings_intact`.
|
||||||
|
- `short_uuid_takes_first_8_chars` y `short_hash_takes_first_4_bytes_hex`.
|
||||||
|
|
||||||
|
Activación:
|
||||||
|
```sh
|
||||||
|
NAKUI_EVENT_LOG=/tmp/nakui_inv_xxx.jsonl cargo run -p nakui-explorer
|
||||||
|
```
|
||||||
|
|
||||||
|
Estado del CHANGELOG global tras este commit: cero pendientes
|
||||||
|
fundamentados activos. Lo único que queda es `minga-vfs` (FUSE,
|
||||||
|
explícitamente diferido por el usuario) y mejoras nice-to-have
|
||||||
|
(cobertura adicional per-lenguaje, daemon-ización de nakui para
|
||||||
|
sidecar discovery).
|
||||||
|
|
||||||
### feat(minga-core): α-hashing per-language para Python, TypeScript, JavaScript, Go
|
### feat(minga-core): α-hashing per-language para Python, TypeScript, JavaScript, Go
|
||||||
Cierra el último pendiente fundamentado del CHANGELOG. Cada lenguaje
|
Cierra el último pendiente fundamentado del CHANGELOG. Cada lenguaje
|
||||||
soportado por `minga` tiene ahora su propio profile α-equivalente —
|
soportado por `minga` tiene ahora su propio profile α-equivalente —
|
||||||
|
|||||||
Generated
+11
@@ -6134,6 +6134,17 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nakui-explorer"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"gpui",
|
||||||
|
"nakui-core",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nanorand"
|
name = "nanorand"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ members = [
|
|||||||
"crates/apps/image_viewer",
|
"crates/apps/image_viewer",
|
||||||
"crates/apps/yahweh-shell",
|
"crates/apps/yahweh-shell",
|
||||||
"crates/apps/nouser-explorer",
|
"crates/apps/nouser-explorer",
|
||||||
|
"crates/apps/nakui-explorer",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "nakui-explorer"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "Explorador GPUI del event log de Nakui: timeline de seeds + morphisms, schema hashes, breakdown por entity. Standalone (lee un .jsonl) — futura integración con sidecar brahman cuando nakui se daemonice."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nakui-core = { path = "../../modules/nakui/core" }
|
||||||
|
gpui = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
uuid = { workspace = true, features = ["serde"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "nakui-explorer"
|
||||||
|
path = "src/main.rs"
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
//! `nakui-explorer` — panel GPUI que renderea el event log de un
|
||||||
|
//! repo Nakui: timeline de seeds + morphisms con sus parámetros y
|
||||||
|
//! breakdown por entity type.
|
||||||
|
//!
|
||||||
|
//! ## Diseño
|
||||||
|
//!
|
||||||
|
//! Standalone, lee un archivo `.jsonl` (formato append-only del
|
||||||
|
//! `nakui_core::event_log::EventLog`). Refresh por polling cada 2s
|
||||||
|
//! para detectar nuevos eventos appended (típico de un nakui ERP en
|
||||||
|
//! producción que va escribiendo). Sin discovery dinámico vía broker
|
||||||
|
//! brahman porque nakui hoy es CLI/library/demos, no daemon — cuando
|
||||||
|
//! se daemonice, sustituir el lector de archivo por un sidecar
|
||||||
|
//! consumer (mismo patrón que `nouser-explorer`).
|
||||||
|
//!
|
||||||
|
//! ## Uso
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! # Path explícito:
|
||||||
|
//! NAKUI_EVENT_LOG=/tmp/nakui-demo.jsonl cargo run -p nakui-explorer
|
||||||
|
//!
|
||||||
|
//! # Default si la env no está: ./nakui.jsonl en pwd.
|
||||||
|
//! cargo run -p nakui-explorer
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
div, prelude::*, px, rgb, App, Application, Bounds, Context, IntoElement, Render,
|
||||||
|
SharedString, Window, WindowBounds, WindowOptions,
|
||||||
|
};
|
||||||
|
use nakui_core::event_log::{EventLog, LogEntry};
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
Application::new().run(|cx: &mut App| {
|
||||||
|
let bounds = Bounds::centered(None, gpui::size(px(900.), px(640.)), cx);
|
||||||
|
cx.open_window(
|
||||||
|
WindowOptions {
|
||||||
|
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||||
|
titlebar: Some(gpui::TitlebarOptions {
|
||||||
|
title: Some(SharedString::from("Nakui — Event Log")),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
|_w, cx| cx.new(Explorer::new),
|
||||||
|
)
|
||||||
|
.expect("open window");
|
||||||
|
cx.activate(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estado de la vista. `entries` se reescribe en cada tick (el log
|
||||||
|
/// es append-only así que reload completo es seguro y barato hasta
|
||||||
|
/// decenas de miles de entries; optimizar a delta cuando duela).
|
||||||
|
struct Explorer {
|
||||||
|
log_path: PathBuf,
|
||||||
|
entries: Vec<LogEntry>,
|
||||||
|
error: Option<SharedString>,
|
||||||
|
last_load_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Explorer {
|
||||||
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
let log_path = std::env::var("NAKUI_EVENT_LOG")
|
||||||
|
.ok()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("nakui.jsonl"));
|
||||||
|
|
||||||
|
// Loop de refresh.
|
||||||
|
let path_for_loop = log_path.clone();
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let timer = cx.background_executor().clone();
|
||||||
|
loop {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let result = load_log(&path_for_loop);
|
||||||
|
let elapsed_ms = started.elapsed().as_millis() as u64;
|
||||||
|
let _ = this.update(cx, |me, cx| {
|
||||||
|
match result {
|
||||||
|
Ok(entries) => {
|
||||||
|
me.entries = entries;
|
||||||
|
me.error = None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
me.error = Some(SharedString::from(format!(
|
||||||
|
"no pude leer {}: {}",
|
||||||
|
me.log_path.display(),
|
||||||
|
e
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
me.last_load_ms = elapsed_ms;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
timer.timer(REFRESH_INTERVAL).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
log_path,
|
||||||
|
entries: Vec::new(),
|
||||||
|
error: None,
|
||||||
|
last_load_ms: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn breakdown(&self) -> (usize, usize, Vec<(String, usize)>) {
|
||||||
|
let mut seeds = 0;
|
||||||
|
let mut morphisms = 0;
|
||||||
|
let mut entity_counts: std::collections::BTreeMap<String, usize> =
|
||||||
|
std::collections::BTreeMap::new();
|
||||||
|
for e in &self.entries {
|
||||||
|
match e {
|
||||||
|
LogEntry::Seed { entity, .. } => {
|
||||||
|
seeds += 1;
|
||||||
|
*entity_counts.entry(entity.clone()).or_default() += 1;
|
||||||
|
}
|
||||||
|
LogEntry::Morphism { morphism, .. } => {
|
||||||
|
morphisms += 1;
|
||||||
|
*entity_counts.entry(format!("→ {}", morphism)).or_default() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut ranked: Vec<_> = entity_counts.into_iter().collect();
|
||||||
|
ranked.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
(seeds, morphisms, ranked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_log(path: &std::path::Path) -> Result<Vec<LogEntry>, String> {
|
||||||
|
let log = EventLog::open(path).map_err(|e| format!("open: {e}"))?;
|
||||||
|
log.entries().map_err(|e| format!("read: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Explorer {
|
||||||
|
fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let bg = rgb(0x14171c);
|
||||||
|
let card_bg = rgb(0x1d2128);
|
||||||
|
let text_dim = rgb(0x9ba1ad);
|
||||||
|
let text = rgb(0xe6e8ec);
|
||||||
|
let accent_seed = rgb(0x88c0d0);
|
||||||
|
let accent_morphism = rgb(0xa3be8c);
|
||||||
|
let _ = card_bg;
|
||||||
|
|
||||||
|
let (seed_count, morphism_count, top_breakdown) = self.breakdown();
|
||||||
|
|
||||||
|
let header_text = format!(
|
||||||
|
"Log: {} · {} entries ({} seeds, {} morphisms) · reload {} ms",
|
||||||
|
self.log_path.display(),
|
||||||
|
self.entries.len(),
|
||||||
|
seed_count,
|
||||||
|
morphism_count,
|
||||||
|
self.last_load_ms,
|
||||||
|
);
|
||||||
|
|
||||||
|
let header = div()
|
||||||
|
.px(px(16.))
|
||||||
|
.py(px(12.))
|
||||||
|
.bg(card_bg)
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(rgb(0x2a2f38))
|
||||||
|
.text_color(text)
|
||||||
|
.text_size(px(14.))
|
||||||
|
.child(header_text);
|
||||||
|
|
||||||
|
let breakdown_line = if top_breakdown.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let parts: Vec<String> = top_breakdown
|
||||||
|
.iter()
|
||||||
|
.take(5)
|
||||||
|
.map(|(k, v)| format!("{k}({v})"))
|
||||||
|
.collect();
|
||||||
|
format!("breakdown: {}", parts.join(", "))
|
||||||
|
};
|
||||||
|
|
||||||
|
let breakdown_div = (!breakdown_line.is_empty()).then(|| {
|
||||||
|
div()
|
||||||
|
.px(px(16.))
|
||||||
|
.py(px(6.))
|
||||||
|
.bg(rgb(0x16191f))
|
||||||
|
.text_color(text_dim)
|
||||||
|
.text_size(px(11.))
|
||||||
|
.child(breakdown_line)
|
||||||
|
});
|
||||||
|
|
||||||
|
let error_banner = self.error.as_ref().map(|e| {
|
||||||
|
div()
|
||||||
|
.px(px(16.))
|
||||||
|
.py(px(8.))
|
||||||
|
.bg(rgb(0x4a2020))
|
||||||
|
.text_color(rgb(0xffd0d0))
|
||||||
|
.text_size(px(12.))
|
||||||
|
.child(e.clone())
|
||||||
|
});
|
||||||
|
|
||||||
|
// Renderea las últimas N entries (la timeline crece hacia abajo
|
||||||
|
// en append-order; mostramos las más recientes primero para
|
||||||
|
// que el usuario vea actividad reciente sin scroll).
|
||||||
|
const MAX_VISIBLE: usize = 200;
|
||||||
|
let visible: Vec<_> = self
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(MAX_VISIBLE)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let cards: Vec<gpui::AnyElement> = visible
|
||||||
|
.iter()
|
||||||
|
.map(|e| match e {
|
||||||
|
LogEntry::Seed {
|
||||||
|
seq,
|
||||||
|
entity,
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
schema_hash,
|
||||||
|
} => {
|
||||||
|
let data_preview = preview_value(data, 80);
|
||||||
|
let schema_label = schema_hash
|
||||||
|
.as_ref()
|
||||||
|
.map(|h| format!("schema={}", short_hash(h)))
|
||||||
|
.unwrap_or_else(|| "schema=(legacy)".into());
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.px(px(12.))
|
||||||
|
.py(px(8.))
|
||||||
|
.mb(px(4.))
|
||||||
|
.bg(card_bg)
|
||||||
|
.rounded(px(4.))
|
||||||
|
.border_l_4()
|
||||||
|
.border_color(accent_seed)
|
||||||
|
.gap(px(2.))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.gap(px(8.))
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(accent_seed)
|
||||||
|
.text_size(px(11.))
|
||||||
|
.child(format!("[#{seq} seed]")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(text)
|
||||||
|
.text_size(px(13.))
|
||||||
|
.child(entity.clone()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(text_dim)
|
||||||
|
.text_size(px(10.))
|
||||||
|
.child(format!("id={}", short_uuid(id))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(text_dim)
|
||||||
|
.text_size(px(11.))
|
||||||
|
.child(data_preview),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(text_dim)
|
||||||
|
.text_size(px(10.))
|
||||||
|
.child(schema_label),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
LogEntry::Morphism {
|
||||||
|
seq,
|
||||||
|
morphism,
|
||||||
|
inputs,
|
||||||
|
params,
|
||||||
|
ops,
|
||||||
|
schema_hash,
|
||||||
|
} => {
|
||||||
|
let inputs_line = if inputs.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let parts: Vec<String> = inputs
|
||||||
|
.iter()
|
||||||
|
.map(|(name, id)| format!("{name}={}", short_uuid(id)))
|
||||||
|
.collect();
|
||||||
|
format!("inputs: {}", parts.join(", "))
|
||||||
|
};
|
||||||
|
let params_line = preview_value(params, 80);
|
||||||
|
let ops_line = format!("{} op(s)", ops.len());
|
||||||
|
let schema_label = schema_hash
|
||||||
|
.as_ref()
|
||||||
|
.map(|h| format!("schema={}", short_hash(h)))
|
||||||
|
.unwrap_or_else(|| "schema=(legacy)".into());
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.px(px(12.))
|
||||||
|
.py(px(8.))
|
||||||
|
.mb(px(4.))
|
||||||
|
.bg(card_bg)
|
||||||
|
.rounded(px(4.))
|
||||||
|
.border_l_4()
|
||||||
|
.border_color(accent_morphism)
|
||||||
|
.gap(px(2.))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.gap(px(8.))
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(accent_morphism)
|
||||||
|
.text_size(px(11.))
|
||||||
|
.child(format!("[#{seq} morph]")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(text)
|
||||||
|
.text_size(px(13.))
|
||||||
|
.child(morphism.clone()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(text_dim)
|
||||||
|
.text_size(px(10.))
|
||||||
|
.child(ops_line),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.when(!inputs_line.is_empty(), |d| {
|
||||||
|
d.child(
|
||||||
|
div()
|
||||||
|
.text_color(text_dim)
|
||||||
|
.text_size(px(11.))
|
||||||
|
.child(inputs_line),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(!params_line.is_empty(), |d| {
|
||||||
|
d.child(
|
||||||
|
div()
|
||||||
|
.text_color(text_dim)
|
||||||
|
.text_size(px(11.))
|
||||||
|
.child(format!("params: {params_line}")),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(text_dim)
|
||||||
|
.text_size(px(10.))
|
||||||
|
.child(schema_label),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let body = div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.p(px(12.))
|
||||||
|
.overflow_hidden()
|
||||||
|
.children(cards);
|
||||||
|
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.size_full()
|
||||||
|
.bg(bg)
|
||||||
|
.child(header)
|
||||||
|
.when_some(breakdown_div, |d, b| d.child(b))
|
||||||
|
.when_some(error_banner, |d, b| d.child(b))
|
||||||
|
.child(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn short_uuid(id: &uuid::Uuid) -> String {
|
||||||
|
let s = id.to_string();
|
||||||
|
if s.len() > 8 {
|
||||||
|
s[..8].to_string()
|
||||||
|
} else {
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn short_hash(h: &[u8; 32]) -> String {
|
||||||
|
let mut s = String::with_capacity(8);
|
||||||
|
for b in h.iter().take(4) {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let _ = write!(s, "{:02x}", b);
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renderiza un `serde_json::Value` en una sola línea limitada a `max`
|
||||||
|
/// caracteres (para preview en la timeline, no para edición).
|
||||||
|
fn preview_value(v: &serde_json::Value, max: usize) -> String {
|
||||||
|
let s = v.to_string();
|
||||||
|
if s.chars().count() <= max {
|
||||||
|
s
|
||||||
|
} else {
|
||||||
|
let truncated: String = s.chars().take(max - 3).collect();
|
||||||
|
format!("{truncated}...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
fn write_sample_log() -> tempfile::NamedTempFile {
|
||||||
|
let mut f = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
// 3 seeds + 2 morphisms, formato canónico de event_log.
|
||||||
|
let lines = [
|
||||||
|
r#"{"kind":"seed","seq":0,"entity":"product","id":"00000000-0000-0000-0000-000000000001","data":{"sku":"A"}}"#,
|
||||||
|
r#"{"kind":"seed","seq":1,"entity":"product","id":"00000000-0000-0000-0000-000000000002","data":{"sku":"B"}}"#,
|
||||||
|
r#"{"kind":"seed","seq":2,"entity":"customer","id":"00000000-0000-0000-0000-000000000003","data":{"name":"Acme"}}"#,
|
||||||
|
r#"{"kind":"morphism","seq":3,"morphism":"sale.create","inputs":{"product":"00000000-0000-0000-0000-000000000001"},"params":{"qty":1},"ops":[]}"#,
|
||||||
|
r#"{"kind":"morphism","seq":4,"morphism":"sale.refund","inputs":{},"params":{},"ops":[]}"#,
|
||||||
|
];
|
||||||
|
for l in lines {
|
||||||
|
writeln!(f, "{l}").unwrap();
|
||||||
|
}
|
||||||
|
f.flush().unwrap();
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_log_returns_all_entries_in_order() {
|
||||||
|
let f = write_sample_log();
|
||||||
|
let entries = load_log(f.path()).expect("load");
|
||||||
|
assert_eq!(entries.len(), 5);
|
||||||
|
for (i, e) in entries.iter().enumerate() {
|
||||||
|
assert_eq!(e.seq(), i as u64, "seqs should be 0..4 contiguous");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn breakdown_counts_seeds_morphisms_and_buckets() {
|
||||||
|
let f = write_sample_log();
|
||||||
|
let entries = load_log(f.path()).unwrap();
|
||||||
|
let me = Explorer {
|
||||||
|
log_path: f.path().to_path_buf(),
|
||||||
|
entries,
|
||||||
|
error: None,
|
||||||
|
last_load_ms: 0,
|
||||||
|
};
|
||||||
|
let (seeds, morphisms, ranked) = me.breakdown();
|
||||||
|
assert_eq!(seeds, 3);
|
||||||
|
assert_eq!(morphisms, 2);
|
||||||
|
// Buckets esperados: product (2), customer (1), → sale.create (1),
|
||||||
|
// → sale.refund (1).
|
||||||
|
assert_eq!(ranked.len(), 4);
|
||||||
|
let map: std::collections::BTreeMap<_, _> = ranked.into_iter().collect();
|
||||||
|
assert_eq!(map.get("product"), Some(&2));
|
||||||
|
assert_eq!(map.get("customer"), Some(&1));
|
||||||
|
assert_eq!(map.get("→ sale.create"), Some(&1));
|
||||||
|
assert_eq!(map.get("→ sale.refund"), Some(&1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_missing_file_yields_empty_not_error() {
|
||||||
|
// EventLog::open de un archivo inexistente no falla; entries() devuelve [].
|
||||||
|
let path = std::env::temp_dir().join("nakui-explorer-missing-test.jsonl");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let result = load_log(&path).expect("missing path is OK per EventLog::open contract");
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preview_value_truncates_long_strings() {
|
||||||
|
let v = serde_json::json!({"a": "x".repeat(200)});
|
||||||
|
let p = preview_value(&v, 30);
|
||||||
|
assert!(p.len() <= 30);
|
||||||
|
assert!(p.ends_with("..."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preview_value_keeps_short_strings_intact() {
|
||||||
|
let v = serde_json::json!({"a": 1});
|
||||||
|
let p = preview_value(&v, 30);
|
||||||
|
assert_eq!(p, "{\"a\":1}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_uuid_takes_first_8_chars() {
|
||||||
|
let id = uuid::Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap();
|
||||||
|
assert_eq!(short_uuid(&id), "a1b2c3d4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_hash_takes_first_4_bytes_hex() {
|
||||||
|
let mut h = [0u8; 32];
|
||||||
|
h[0] = 0xaa;
|
||||||
|
h[1] = 0xbb;
|
||||||
|
h[2] = 0xcc;
|
||||||
|
h[3] = 0xdd;
|
||||||
|
assert_eq!(short_hash(&h), "aabbccdd");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user