nornir 0.4.12

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! 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, Color32, ScrollArea};

use crate::warehouse::iceberg::{IcebergWarehouse, TablePreview};

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>,
}

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()
    }

    /// 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,
        }
    }

    /// 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(root).and_then(|wh| wh.table_names())
            }
            WhSource::Remote { endpoint, token } => {
                super::remote::fetch_tables(endpoint, token, &self.workspace)
            }
        };
        match res {
            Ok(names) => {
                self.tables = names;
                self.err = None;
            }
            Err(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(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;
    }

    pub fn draw(&mut self, ui: &mut egui::Ui) {
        if !self.listed {
            self.refresh_tables();
        }

        egui::SidePanel::left("wh_tables").default_width(240.0).show_inside(ui, |ui| {
            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);
                    }
                }
            });
        });

        egui::CentralPanel::default().show_inside(ui, |ui| {
            if let Some(err) = &self.err {
                ui.colored_label(Color32::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);
                        }
                    } 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(Color32::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>) {
    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),
                Color32::from_gray(200),
            );
            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 {
                Color32::from_rgb(200, 90, 80)
            } else {
                Color32::from_rgb(80, 150, 220)
            };
            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),
                Color32::from_gray(170),
            );
        }
    });
}

/// 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()
    }
}