use std::path::PathBuf;
use std::sync::OnceLock;
use anyhow::Result;
use eframe::egui::{
self, Align2, Color32, ColorImage, FontId, Pos2, Rect, Rounding, Sense, Stroke,
TextureHandle, TextureOptions, Vec2,
};
use uuid::Uuid;
use super::graph::draw_dep_graph;
use super::model::{Timeline, load_timeline};
use super::timetravel::{TimeTravelState, draw_timetravel};
const SPLASH_DURATION_SECS: f32 = 2.5;
const SPLASH_FADE_SECS: f32 = 1.0;
const WATERMARK_ALPHA: u8 = 22;
static NORNIR_WEBP_BYTES: &[u8] = include_bytes!("../../.nornir/assets/nornir.webp");
fn decode_nornir() -> Option<ColorImage> {
static CACHE: OnceLock<Option<ColorImage>> = OnceLock::new();
CACHE
.get_or_init(|| {
let img = image::load_from_memory(NORNIR_WEBP_BYTES).ok()?;
let rgba = img.to_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
Some(ColorImage::from_rgba_unmultiplied([w, h], rgba.as_raw()))
})
.clone()
}
#[derive(PartialEq, Eq, Clone, Copy)]
enum Tab {
Timeline,
DepGraph,
CallGraph,
Funnel,
TimeTravel,
LiveRun,
Knowledge,
Warehouse,
Search,
Gates,
Bench,
}
pub enum Source {
Local(PathBuf),
Remote { endpoint: String, token: String },
}
impl Source {
fn load(&self, workspace: &str) -> Result<Timeline> {
match self {
Source::Local(root) => load_timeline(root, workspace),
Source::Remote { endpoint, token } => {
super::remote::fetch_timeline(endpoint, token, workspace)
}
}
}
}
pub struct UrdrThreadsApp {
source: Source,
source_label: String,
workspace_name: String,
workspaces: Vec<String>,
timeline: Result<Timeline>,
selected: Option<Uuid>,
last_reload: std::time::Instant,
tab: Tab,
started_at: std::time::Instant,
nornir_tex: Option<TextureHandle>,
timetravel: TimeTravelState,
live: super::live::LiveRunState,
knowledge: super::knowledge::KnowledgeState,
warehouse: super::warehouse_tab::WarehouseBrowser,
callgraph: super::callgraph::CallGraphState,
funnel: super::funnel_tab::FunnelTabState,
search: super::ops_tabs::SearchState,
gates: super::ops_tabs::GatesState,
bench: super::ops_tabs::BenchState,
ws_panel: super::ops_tabs::WorkspacePanel,
repos: Vec<String>,
auto_refresh: bool,
refresh_secs: u64,
}
impl UrdrThreadsApp {
pub fn new(warehouse_root: PathBuf, workspace_name: String) -> Self {
Self::with_repos(warehouse_root, workspace_name, PathBuf::new(), Vec::new())
}
pub fn with_repos(
warehouse_root: PathBuf,
workspace_name: String,
workspace_root: PathBuf,
repos: Vec<String>,
) -> Self {
let log_dir = warehouse_root
.parent()
.map(|p| p.join("logs"))
.unwrap_or_else(|| warehouse_root.join("logs"));
let label = warehouse_root.display().to_string();
Self::build(Source::Local(warehouse_root), label, workspace_name, workspace_root, repos, log_dir)
}
pub fn with_remote(
endpoint: String,
token: String,
workspace_name: String,
workspace_root: PathBuf,
repos: Vec<String>,
) -> Self {
let label = format!("server {endpoint}");
Self::build(
Source::Remote { endpoint, token },
label,
workspace_name,
workspace_root,
repos,
PathBuf::new(),
)
}
fn build(
source: Source,
source_label: String,
mut workspace_name: String,
workspace_root: PathBuf,
repos: Vec<String>,
log_dir: PathBuf,
) -> Self {
let workspaces = match &source {
Source::Remote { endpoint, token } => {
let mut ws = match super::remote::list_workspaces(endpoint, token) {
Ok(ws) => {
eprintln!("nornir-viz: Workspaces.List → {} workspace(s): {ws:?}", ws.len());
ws
}
Err(e) => {
eprintln!("nornir-viz: Workspaces.List FAILED (picker empty): {e:#}");
Vec::new()
}
};
ws.sort();
ws.dedup();
if workspace_name.is_empty() {
if let Some(first) = ws.first() {
workspace_name = first.clone();
eprintln!("nornir-viz: auto-selected workspace `{workspace_name}`");
}
} else if !ws.iter().any(|w| w == &workspace_name) {
ws.push(workspace_name.clone());
}
ws
}
Source::Local(_) => vec![workspace_name.clone()],
};
let timeline = source.load(&workspace_name);
let live = match &source {
Source::Remote { endpoint, token } => {
super::live::LiveRunState::new_remote(endpoint.clone(), token.clone())
}
Source::Local(_) => super::live::LiveRunState::new(log_dir),
};
let warehouse = match &source {
Source::Local(p) => super::warehouse_tab::WarehouseBrowser::local(p.clone()),
Source::Remote { endpoint, token } => super::warehouse_tab::WarehouseBrowser::remote(
endpoint.clone(),
token.clone(),
workspace_name.clone(),
),
};
let callgraph = match &source {
Source::Local(p) => super::callgraph::CallGraphState::local(p.clone()),
Source::Remote { endpoint, token } => super::callgraph::CallGraphState::remote(
endpoint.clone(),
token.clone(),
workspace_name.clone(),
),
};
let funnel = match &source {
Source::Local(p) => super::funnel_tab::FunnelTabState::local(p.clone()),
Source::Remote { endpoint, token } => super::funnel_tab::FunnelTabState::remote(
endpoint.clone(),
token.clone(),
workspace_name.clone(),
),
};
Self {
source,
source_label,
workspace_name,
workspaces,
timeline,
selected: None,
last_reload: std::time::Instant::now(),
tab: Tab::Timeline,
started_at: std::time::Instant::now(),
nornir_tex: None,
timetravel: TimeTravelState::default(),
live,
knowledge: super::knowledge::KnowledgeState::new(workspace_root, repos.clone()),
warehouse,
callgraph,
funnel,
search: super::ops_tabs::SearchState::default(),
gates: super::ops_tabs::GatesState::default(),
bench: super::ops_tabs::BenchState::default(),
ws_panel: super::ops_tabs::WorkspacePanel::default(),
repos,
auto_refresh: true,
refresh_secs: 5,
}
}
fn reload(&mut self) {
self.timeline = self.source.load(&self.workspace_name);
self.last_reload = std::time::Instant::now();
}
fn server(&self) -> Option<super::ops_tabs::Server> {
match &self.source {
Source::Remote { endpoint, token } => {
Some(super::ops_tabs::Server { endpoint: endpoint.clone(), token: token.clone() })
}
Source::Local(_) => None,
}
}
fn switch_workspace(&mut self, ws: String) {
if ws == self.workspace_name {
return;
}
self.workspace_name = ws.clone();
self.selected = None;
self.warehouse.set_workspace(ws.clone());
self.funnel.set_workspace(ws.clone());
self.callgraph.set_workspace(ws);
self.reload();
self.warehouse.refresh_tables();
}
}
const LANE_HEIGHT: f32 = 80.0;
const LANE_PAD: f32 = 24.0;
const NODE_RADIUS: f32 = 14.0;
const LEFT_GUTTER: f32 = 140.0;
fn status_color(status: &str) -> Color32 {
match status {
s if s.starts_with("succeeded_dry_run") => Color32::from_rgb(200, 160, 60),
s if s.starts_with("succeeded") => Color32::from_rgb(80, 180, 120),
"failed_test" => Color32::from_rgb(220, 80, 80),
"failed_bench" => Color32::from_rgb(220, 130, 60),
_ => Color32::from_rgb(140, 140, 160),
}
}
impl eframe::App for UrdrThreadsApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if self.auto_refresh && self.last_reload.elapsed().as_secs() >= self.refresh_secs {
self.reload();
self.warehouse.refresh_tables();
ctx.request_repaint();
}
if self.auto_refresh {
ctx.request_repaint_after(std::time::Duration::from_secs(1));
}
egui::TopBottomPanel::top("top").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.heading("🧵 Urðr Threads");
ui.separator();
let mut chosen = self.workspace_name.clone();
let label = if chosen.is_empty() { "(default)".to_string() } else { chosen.clone() };
egui::ComboBox::from_id_source("ws_picker")
.selected_text(format!("workspace: {label}"))
.show_ui(ui, |ui| {
for ws in &self.workspaces {
ui.selectable_value(&mut chosen, ws.clone(), ws);
}
});
if chosen != self.workspace_name {
self.switch_workspace(chosen);
}
ui.separator();
ui.label(format!("source: {}", self.source_label));
if ui.button("↻ reload").clicked() {
self.reload();
self.warehouse.refresh_tables();
}
ui.checkbox(&mut self.auto_refresh, "live")
.on_hover_text(format!("auto-refresh every {}s", self.refresh_secs));
ui.separator();
ui.selectable_value(&mut self.tab, Tab::Timeline, "🧵 Timeline");
ui.selectable_value(&mut self.tab, Tab::DepGraph, "🔗 Dep Graph");
ui.selectable_value(&mut self.tab, Tab::CallGraph, "🕸 Call Graph");
ui.selectable_value(&mut self.tab, Tab::Funnel, "🗂 Funnel");
ui.selectable_value(&mut self.tab, Tab::TimeTravel, "⏳ Time Travel");
ui.selectable_value(&mut self.tab, Tab::LiveRun, "📡 Live Run");
ui.selectable_value(&mut self.tab, Tab::Knowledge, "🗺 Knowledge");
ui.selectable_value(&mut self.tab, Tab::Warehouse, "🗄 Warehouse");
ui.selectable_value(&mut self.tab, Tab::Search, "🔍 Search");
ui.selectable_value(&mut self.tab, Tab::Gates, "🚦 Gates");
ui.selectable_value(&mut self.tab, Tab::Bench, "📈 Bench");
ui.separator();
let srv = self.server();
self.ws_panel.draw_controls(ui, srv.as_ref(), &self.workspace_name);
ui.label(format!(
"(last reload {}s ago)",
self.last_reload.elapsed().as_secs()
));
});
});
egui::SidePanel::right("detail").default_width(360.0).show(ctx, |ui| {
ui.heading("Time machine");
ui.separator();
self.ws_panel.draw_panel(ui, self.server().as_ref(), &self.workspace_name);
match (self.selected, &self.timeline) {
(Some(rid), Ok(tl)) => {
ui.label(format!("release_id\n {rid}"));
ui.separator();
ui.label("git checkout commands:");
for lane in &tl.lanes {
if let Some(node) = lane.nodes.iter().find(|n| n.release_id == rid) {
let cmd = format!("cd {} && git checkout {}", lane.repo, node.sha);
ui.horizontal(|ui| {
ui.monospace(&cmd);
if ui.button("📋").on_hover_text("copy").clicked() {
ui.output_mut(|o| o.copied_text = cmd.clone());
}
});
let color = status_color(&node.gate_status);
ui.colored_label(
color,
format!(
" {} • status={} • tests {}/{} • branch={} • dirty={}",
lane.repo,
node.gate_status,
node.tests_passed,
node.tests_failed,
node.branch,
node.dirty,
),
);
if !node.published_versions.is_empty() {
ui.label(format!(
" published: {}",
node.published_versions
.iter()
.map(|(c, v)| format!("{c}@{v}"))
.collect::<Vec<_>>()
.join(", ")
));
}
ui.add_space(4.0);
}
}
}
(None, _) => {
ui.label("click a node in the timeline →");
}
(_, Err(e)) => {
ui.colored_label(Color32::RED, format!("load error:\n{e:#}"));
}
}
});
egui::CentralPanel::default().show(ctx, |ui| {
if self.tab == Tab::LiveRun {
self.live.draw(ui);
return;
}
if self.tab == Tab::Knowledge {
self.knowledge.draw(ui);
return;
}
if self.tab == Tab::Warehouse {
self.warehouse.draw(ui);
return;
}
if self.tab == Tab::CallGraph {
self.callgraph.draw(ui);
return;
}
if self.tab == Tab::Funnel {
self.funnel.draw(ui);
return;
}
let srv = self.server();
if self.tab == Tab::Search {
self.search.draw(ui, srv.as_ref(), &self.workspace_name);
return;
}
if self.tab == Tab::Gates {
self.gates.draw(ui, srv.as_ref(), &self.workspace_name, &self.repos);
return;
}
if self.tab == Tab::Bench {
self.bench.draw(ui, srv.as_ref(), &self.workspace_name, &self.repos);
return;
}
match &self.timeline {
Err(e) => {
ui.colored_label(Color32::RED, format!("Failed to load warehouse:\n{e:#}"));
}
Ok(tl) if tl.is_empty() => {
ui.label("No releases recorded yet. Run `release_demo_znippy_holger`.");
}
Ok(tl) => match self.tab {
Tab::Timeline => {
self.selected = draw_timeline(ui, tl, self.selected);
}
Tab::DepGraph => {
if self.selected.is_none() {
ui.label("Showing latest snapshot — click a release in 🧵 Timeline to time-travel here.");
} else if let Some(rid) = self.selected {
if let Some(snap) = tl.snapshot_for(&rid) {
ui.label(format!(
"Pinned to release {} (snapshot {})",
rid, snap.snapshot_id
));
}
}
draw_dep_graph(ui, tl, self.selected);
}
Tab::TimeTravel => {
ctx.input(|i| {
if i.key_pressed(egui::Key::ArrowRight) {
if let Ok(tl) = &self.timeline {
if self.timetravel.idx + 1 < tl.release_order.len() {
self.timetravel.idx += 1;
}
}
}
if i.key_pressed(egui::Key::ArrowLeft) {
self.timetravel.idx = self.timetravel.idx.saturating_sub(1);
}
if i.key_pressed(egui::Key::Space) {
self.timetravel.playing = !self.timetravel.playing;
self.timetravel.last_tick = std::time::Instant::now();
}
});
if let Some(rid) = draw_timetravel(ui, tl, &mut self.timetravel) {
self.selected = Some(rid);
}
}
Tab::Search | Tab::Gates | Tab::Bench => unreachable!("handled above"),
Tab::LiveRun => unreachable!("handled above"),
Tab::Knowledge => unreachable!("handled above"),
Tab::Warehouse => unreachable!("handled above"),
Tab::CallGraph => unreachable!("handled above"),
Tab::Funnel => unreachable!("handled above"),
},
}
});
self.draw_nornir_overlay(ctx);
}
}
impl UrdrThreadsApp {
fn ensure_texture(&mut self, ctx: &egui::Context) -> Option<&TextureHandle> {
if self.nornir_tex.is_none() {
if let Some(img) = decode_nornir() {
self.nornir_tex = Some(ctx.load_texture(
"nornir-splash",
img,
TextureOptions::LINEAR,
));
}
}
self.nornir_tex.as_ref()
}
fn draw_nornir_overlay(&mut self, ctx: &egui::Context) {
let elapsed = self.started_at.elapsed().as_secs_f32();
let splash_alpha = if elapsed < SPLASH_DURATION_SECS - SPLASH_FADE_SECS {
1.0
} else if elapsed < SPLASH_DURATION_SECS {
1.0 - (elapsed - (SPLASH_DURATION_SECS - SPLASH_FADE_SECS)) / SPLASH_FADE_SECS
} else {
0.0
};
if splash_alpha > 0.0 {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
let Some(tex) = self.ensure_texture(ctx) else { return };
let tex_size = tex.size_vec2();
let tex_id = tex.id();
let screen = ctx.screen_rect();
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("nornir-overlay"),
));
let wm_w = 220.0;
let wm_h = wm_w * (tex_size.y / tex_size.x);
let wm_rect = Rect::from_min_size(
Pos2::new(screen.right() - wm_w - 18.0, screen.bottom() - wm_h - 18.0),
Vec2::new(wm_w, wm_h),
);
painter.image(
tex_id,
wm_rect,
Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
Color32::from_white_alpha(WATERMARK_ALPHA),
);
if splash_alpha > 0.0 {
painter.rect_filled(
screen,
Rounding::ZERO,
Color32::from_rgba_unmultiplied(8, 8, 12, (splash_alpha * 220.0) as u8),
);
let max_w = screen.width().min(screen.height()) * 0.6;
let img_w = max_w.min(tex_size.x);
let img_h = img_w * (tex_size.y / tex_size.x);
let center = screen.center();
let img_rect = Rect::from_center_size(center, Vec2::new(img_w, img_h));
let a = (splash_alpha * 255.0) as u8;
painter.image(
tex_id,
img_rect,
Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
Color32::from_white_alpha(a),
);
painter.text(
Pos2::new(center.x, img_rect.bottom() + 24.0),
Align2::CENTER_TOP,
"Urðr Threads",
FontId::proportional(32.0),
Color32::from_white_alpha(a),
);
painter.text(
Pos2::new(center.x, img_rect.bottom() + 64.0),
Align2::CENTER_TOP,
"weaving releases into provenance",
FontId::proportional(16.0),
Color32::from_white_alpha((a as u32 * 200 / 255) as u8),
);
}
}
}
fn draw_timeline(ui: &mut egui::Ui, tl: &Timeline, mut selected: Option<Uuid>) -> Option<Uuid> {
let available = ui.available_size();
let needed_h = LANE_PAD + tl.lanes.len() as f32 * (LANE_HEIGHT + LANE_PAD);
let canvas_size = Vec2::new(available.x.max(800.0), needed_h.max(300.0));
let (rect, response) = ui.allocate_exact_size(canvas_size, Sense::click());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, Rounding::ZERO, Color32::from_rgb(18, 18, 24));
if tl.release_order.is_empty() {
return selected;
}
let n = tl.release_order.len().max(1);
let x_step = (rect.width() - LEFT_GUTTER - 40.0) / (n as f32).max(1.0);
let x_of = |idx: usize| rect.left() + LEFT_GUTTER + 20.0 + idx as f32 * x_step;
let y_of = |lane_idx: usize| {
rect.top() + LANE_PAD + lane_idx as f32 * (LANE_HEIGHT + LANE_PAD) + LANE_HEIGHT / 2.0
};
for (li, lane) in tl.lanes.iter().enumerate() {
let y = y_of(li);
let lane_rect = Rect::from_min_max(
Pos2::new(rect.left() + LEFT_GUTTER, y - LANE_HEIGHT / 2.0),
Pos2::new(rect.right(), y + LANE_HEIGHT / 2.0),
);
let zebra = if li % 2 == 0 {
Color32::from_rgb(28, 28, 36)
} else {
Color32::from_rgb(24, 24, 30)
};
painter.rect_filled(lane_rect, Rounding::same(4.0), zebra);
painter.line_segment(
[
Pos2::new(rect.left() + LEFT_GUTTER, y),
Pos2::new(rect.right() - 20.0, y),
],
Stroke::new(1.0, Color32::from_rgb(60, 60, 80)),
);
painter.text(
Pos2::new(rect.left() + 10.0, y),
Align2::LEFT_CENTER,
&lane.repo,
FontId::proportional(18.0),
Color32::from_rgb(220, 220, 230),
);
}
if let Some(snap) = &tl.latest_snapshot {
for (col_idx, release_id) in tl.release_order.iter().enumerate() {
let x = x_of(col_idx);
for edge in &snap.edges {
let from_lane = tl.lanes.iter().position(|l| l.repo == edge.from);
let to_lane = tl.lanes.iter().position(|l| l.repo == edge.to);
if let (Some(fi), Some(ti)) = (from_lane, to_lane) {
let has_from = tl.lanes[fi].nodes.iter().any(|n| n.release_id == *release_id);
let has_to = tl.lanes[ti].nodes.iter().any(|n| n.release_id == *release_id);
if has_from && has_to {
painter.line_segment(
[Pos2::new(x, y_of(fi)), Pos2::new(x, y_of(ti))],
Stroke::new(
2.0,
Color32::from_rgba_unmultiplied(180, 140, 220, 160),
),
);
}
}
}
}
}
for (li, lane) in tl.lanes.iter().enumerate() {
for node in &lane.nodes {
let col = tl
.release_order
.iter()
.position(|r| *r == node.release_id)
.unwrap_or(0);
let center = Pos2::new(x_of(col), y_of(li));
let color = status_color(&node.gate_status);
let is_selected = Some(node.release_id) == selected;
let r = if is_selected { NODE_RADIUS + 3.0 } else { NODE_RADIUS };
painter.circle_filled(center, r, color);
painter.circle_stroke(
center,
r,
Stroke::new(if is_selected { 3.0 } else { 1.0 }, Color32::WHITE),
);
if node.dirty {
painter.text(
center + Vec2::new(0.0, NODE_RADIUS + 12.0),
Align2::CENTER_TOP,
"✱",
FontId::proportional(14.0),
Color32::YELLOW,
);
}
painter.text(
center + Vec2::new(0.0, -NODE_RADIUS - 4.0),
Align2::CENTER_BOTTOM,
&node.sha[..node.sha.len().min(7)],
FontId::monospace(11.0),
Color32::from_rgb(200, 200, 210),
);
if response.clicked() {
if let Some(pos) = response.interact_pointer_pos() {
if pos.distance(center) <= r + 2.0 {
selected = Some(node.release_id);
}
}
}
}
}
selected
}