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,
workspace: String,
tables: Vec<String>,
selected: Option<String>,
preview: Option<Result<TablePreview, String>>,
err: Option<String>,
listed: bool,
chart: bool,
chart_val: Option<usize>,
chart_label: Option<usize>,
}
impl WarehouseBrowser {
pub fn local(root: PathBuf) -> Self {
Self::with(WhSource::Local(root), String::new())
}
pub fn table_names(&self) -> Vec<String> {
self.tables.clone()
}
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,
}
}
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:#}")));
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;
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() {
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…");
}
}
});
}
}
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
}
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),
);
}
});
}
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()
}
}