src/viz/warehouse_tab.rs
//! Generic warehouse browser — lists every Iceberg table in the namespace and
//! renders any one as an egui grid of stringified rows. The "see all the data"
//! tab.
//!
//! Works in both modes:
//! - **Local**: opens the warehouse directly (open → scan → drop per action, so
//! it never holds the redb lock between clicks). Works when no `nornir-server`
//! owns the warehouse.
//! - **Remote**: goes through the server's `Warehouse.Tables` / `Warehouse.Scan`
//! gRPC — so a viz pointed at a server (e.g. a friend over Tailscale) browses
//! the same data the server owns, lock and all.
use std::path::PathBuf;
use eframe::egui::{self, ScrollArea};
use crate::warehouse::iceberg::{IcebergWarehouse, TablePreview};
use super::facett_theme::{RED, Theme};
const PREVIEW_LIMIT: usize = 500;
const CELL_MAX_CHARS: usize = 80;
enum WhSource {
Local(PathBuf),
Remote { endpoint: String, token: String },
}
pub struct WarehouseBrowser {
source: WhSource,
/// Selected workspace (the `nornir-workspace` gRPC header); empty for local.
workspace: String,
tables: Vec<String>,
selected: Option<String>,
preview: Option<Result<TablePreview, String>>,
err: Option<String>,
listed: bool,
/// 📊 chart mode: render a numeric column as horizontal bars instead of the grid.
chart: bool,
/// Chart value column (numeric) / label column indices into `preview.columns`.
chart_val: Option<usize>,
chart_label: Option<usize>,
theme: Theme,
}
impl WarehouseBrowser {
/// Browse a local warehouse dir directly (the `Source::Local` path).
pub fn local(root: PathBuf) -> Self {
Self::with(WhSource::Local(root), String::new())
}
/// The currently-listed table names — for the external viz state dump.
pub fn table_names(&self) -> Vec<String> {
self.tables.clone()
}
/// 🗄 Warehouse browser's slice of `state_json` (LAW #6): the table list, the
/// **selected table** (the clicked left-panel item), the chart toggle + its
/// chosen value/label columns, and the loaded preview (columns + row count)
/// — every interactive choice plus the rendered grid's shape.
pub fn state_json(&self) -> serde_json::Value {
let preview = match &self.preview {
None => serde_json::json!({ "loaded": false }),
Some(Ok(p)) => serde_json::json!({
"loaded": true, "ok": true,
"columns": p.columns,
"rows": p.rows.len(),
}),
Some(Err(e)) => serde_json::json!({ "loaded": true, "ok": false, "error": e }),
};
serde_json::json!({
"workspace": self.workspace,
"tables": self.tables,
"table_count": self.tables.len(),
"selected": self.selected,
"error": self.err,
"chart": {
"on": self.chart,
"value_col": self.chart_val,
"label_col": self.chart_label,
},
"preview": preview,
"palette": self.theme.name,
})
}
/// Browse a remote server's warehouse over gRPC, scoped to `workspace`.
pub fn remote(endpoint: String, token: String, workspace: String) -> Self {
Self::with(WhSource::Remote { endpoint, token }, workspace)
}
fn with(source: WhSource, workspace: String) -> Self {
Self {
source,
workspace,
tables: Vec::new(),
selected: None,
preview: None,
err: None,
listed: false,
chart: false,
chart_val: None,
chart_label: None,
theme: Theme::default(),
}
}
/// Set the facett palette the pane paints with.
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
/// Re-scope to a different workspace (the picker switched). Clears the view so
/// the next draw re-lists tables for the new workspace.
pub(crate) fn set_workspace(&mut self, workspace: String) {
self.workspace = workspace;
self.selected = None;
self.preview = None;
self.listed = false;
}
pub(crate) fn refresh_tables(&mut self) {
self.listed = true;
let res = match &self.source {
WhSource::Local(root) => {
IcebergWarehouse::open_read_only(root).and_then(|wh| wh.table_names())
}
WhSource::Remote { endpoint, token } => {
super::remote::fetch_tables(endpoint, token, &self.workspace)
}
};
match res {
Ok(names) => {
nornir_testmatrix::functional_status(
"nornir/viz/warehouse",
"list_tables",
true,
&format!("{} table(s)", names.len()),
);
self.tables = names;
self.err = None;
}
Err(e) => {
nornir_testmatrix::functional_status(
"nornir/viz/warehouse",
"list_tables",
false,
&format!("{e:#}"),
);
self.err = Some(match &self.source {
WhSource::Local(_) => format!(
"open warehouse failed: {e:#}\n(a running nornir-server holds the redb \
lock — stop it to browse locally, or point the viz at the server)"
),
WhSource::Remote { .. } => format!("Warehouse.Tables failed: {e:#}"),
});
}
}
}
fn load_preview(&mut self, table: &str) {
let res = match &self.source {
WhSource::Local(root) => IcebergWarehouse::open_read_only(root)
.and_then(|wh| wh.scan_preview(table, PREVIEW_LIMIT)),
WhSource::Remote { endpoint, token } => super::remote::scan_table(
endpoint,
token,
table,
PREVIEW_LIMIT as u32,
&self.workspace,
),
};
self.preview = Some(res.map_err(|e| format!("{e:#}")));
// New table → previous chart column choices no longer apply.
self.chart_val = None;
self.chart_label = None;
}
/// The whole tab in ONE pane — the master/detail composed with egui's own
/// panels. Kept for the callers that render the tab as a single surface (the
/// scene's per-`Tab::id` host slot, the atom-walk); the SHELL renders the two
/// halves as declared scene panes instead ([`Self::draw_tables`] /
/// [`Self::draw_preview`]), which is what gives the divider its drag handle and
/// each half its fold / minimise / maximise chrome.
pub fn draw(&mut self, ui: &mut egui::Ui) {
egui::Panel::left("wh_tables")
.default_size(240.0)
.show_inside(ui, |ui| self.draw_tables(ui));
egui::CentralPanel::default().show_inside(ui, |ui| self.draw_preview(ui));
}
/// **The MASTER half — the table roster.** The scene's `Warehouse_main` slot: the
/// warehouse's table names, refreshable, one selectable row each; clicking one
/// loads its preview into [`Self::draw_preview`]. Draws NO panel of its own — the
/// scene pane it fills owns the geometry (and publishes its rect).
pub fn draw_tables(&mut self, ui: &mut egui::Ui) {
if !self.listed {
self.refresh_tables();
}
ui.horizontal(|ui| {
ui.heading(format!("tables ({})", self.tables.len()));
if ui.button("↻").clicked() {
self.refresh_tables();
}
});
ui.separator();
ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
let tables = self.tables.clone();
for name in &tables {
let selected = self.selected.as_deref() == Some(name.as_str());
if ui.selectable_label(selected, name).clicked() {
self.selected = Some(name.clone());
self.load_preview(name);
}
}
});
}
/// **The DETAIL half — the selected table's rows (or its bar chart).** The
/// scene's `Warehouse_inspector` slot. Draws NO panel of its own, so the shell's
/// draggable divider — not a hard-coded 240pt — decides how much of the tab it
/// gets, and a maximise can hand it the whole body.
pub fn draw_preview(&mut self, ui: &mut egui::Ui) {
let theme = self.theme;
{
if let Some(err) = &self.err {
ui.colored_label(RED, err);
return;
}
let Some(table) = self.selected.clone() else {
ui.label("select a table on the left to view its rows");
return;
};
match &self.preview {
Some(Ok(p)) => {
let mut chart = self.chart;
// Clamp any column index carried over from a *previously*
// selected table: switching to a narrower table leaves stale
// `chart_val` / `chart_label` indices that would index past
// `p.columns` and panic (`p.columns[i]` below). Drop any index
// that no longer addresses a real column.
let ncols = p.columns.len();
let mut chart_val = self.chart_val.filter(|&i| i < ncols);
let mut chart_label = self.chart_label.filter(|&i| i < ncols);
let numeric: Vec<usize> = (0..p.columns.len())
.filter(|&i| col_is_numeric(p, i))
.collect();
ui.horizontal(|ui| {
ui.heading(&table);
ui.label(format!(
"· {} rows shown (cap {PREVIEW_LIMIT}) · {} columns",
p.rows.len(),
p.columns.len()
));
if !numeric.is_empty() {
ui.separator();
ui.checkbox(&mut chart, "📊 chart");
}
});
if chart && !numeric.is_empty() {
// Default columns: first numeric for values; first
// non-numeric for labels.
if chart_val.map(|i| !numeric.contains(&i)).unwrap_or(true) {
chart_val = numeric.first().copied();
}
if chart_label.is_none() {
chart_label = (0..p.columns.len()).find(|i| !numeric.contains(i));
}
ui.horizontal(|ui| {
ui.label("value:");
egui::ComboBox::from_id_salt("wh_chart_val")
.selected_text(
chart_val.map(|i| p.columns[i].clone()).unwrap_or_default(),
)
.show_ui(ui, |ui| {
for &i in &numeric {
ui.selectable_value(&mut chart_val, Some(i), &p.columns[i]);
}
});
ui.label("label:");
egui::ComboBox::from_id_salt("wh_chart_label")
.selected_text(
chart_label
.map(|i| p.columns[i].clone())
.unwrap_or("(row #)".into()),
)
.show_ui(ui, |ui| {
ui.selectable_value(&mut chart_label, None, "(row #)");
for i in 0..p.columns.len() {
ui.selectable_value(
&mut chart_label,
Some(i),
&p.columns[i],
);
}
});
});
ui.separator();
if let Some(v) = chart_val {
draw_bar_chart(ui, p, v, chart_label, &theme);
}
} else {
ui.separator();
ScrollArea::both()
.auto_shrink([false, false])
.show(ui, |ui| {
egui::Grid::new("wh_grid")
.striped(true)
.num_columns(p.columns.len())
.show(ui, |ui| {
for c in &p.columns {
ui.strong(c);
}
ui.end_row();
for row in &p.rows {
for cell in row {
ui.label(truncate(cell));
}
ui.end_row();
}
});
});
}
self.chart = chart;
self.chart_val = chart_val;
self.chart_label = chart_label;
}
Some(Err(e)) => {
ui.colored_label(RED, e);
}
None => {
ui.label("loading…");
}
}
}
}
}
/// True if at least half of the column's non-empty cells parse as f64 (and at
/// least one does) — the test for offering it as a chart value column.
fn col_is_numeric(p: &TablePreview, col: usize) -> bool {
let (mut seen, mut ok) = (0usize, 0usize);
for row in &p.rows {
let cell = row.get(col).map(String::as_str).unwrap_or("");
if cell.is_empty() {
continue;
}
seen += 1;
if cell.trim().parse::<f64>().is_ok() {
ok += 1;
}
}
ok > 0 && ok * 2 >= seen
}
/// Hand-painted horizontal bar chart over the preview rows (no plotting dep —
/// consistent with the rest of the viz). `val` is the numeric column; `label`
/// the optional label column (row index when `None`).
fn draw_bar_chart(
ui: &mut egui::Ui,
p: &TablePreview,
val: usize,
label: Option<usize>,
theme: &Theme,
) {
const ROW_H: f32 = 18.0;
const LABEL_W: f32 = 230.0;
let rows: Vec<(String, f64)> = p
.rows
.iter()
.enumerate()
.filter_map(|(i, row)| {
let v: f64 = row.get(val)?.trim().parse().ok()?;
let l = match label {
Some(li) => truncate(row.get(li).map(String::as_str).unwrap_or("")),
None => format!("#{i}"),
};
Some((l, v))
})
.take(120)
.collect();
if rows.is_empty() {
ui.label("no numeric values to chart in this column");
return;
}
let max = rows
.iter()
.map(|(_, v)| v.abs())
.fold(f64::EPSILON, f64::max);
ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
let h = ROW_H * rows.len() as f32 + 8.0;
let (resp, painter) = ui.allocate_painter(
egui::Vec2::new(ui.available_width(), h),
egui::Sense::hover(),
);
let rect = resp.rect;
let bar_w = (rect.width() - LABEL_W - 90.0).max(40.0);
for (i, (l, v)) in rows.iter().enumerate() {
let y = rect.top() + 4.0 + i as f32 * ROW_H;
painter.text(
egui::Pos2::new(rect.left() + 4.0, y + ROW_H / 2.0),
egui::Align2::LEFT_CENTER,
l,
egui::FontId::monospace(11.0),
theme.text_dim,
);
let w = ((v.abs() / max) as f32 * bar_w).max(1.0);
let bar = egui::Rect::from_min_size(
egui::Pos2::new(rect.left() + LABEL_W, y + 2.0),
egui::Vec2::new(w, ROW_H - 5.0),
);
let color = if *v < 0.0 { RED } else { theme.accent };
painter.rect_filled(bar, 2.0, color);
painter.text(
bar.right_center() + egui::Vec2::new(6.0, 0.0),
egui::Align2::LEFT_CENTER,
format!("{v}"),
egui::FontId::monospace(11.0),
theme.text_dim,
);
}
});
}
/// Char-safe truncation (never splits a multibyte char) for grid cells.
fn truncate(s: &str) -> String {
if s.chars().count() > CELL_MAX_CHARS {
let head: String = s.chars().take(CELL_MAX_CHARS).collect();
format!("{head}…")
} else {
s.to_string()
}
}