use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Instant;
use eframe::egui::{
self, Align2, Color32, CornerRadius, FontId, Pos2, Rect, RichText, ScrollArea, Sense, Stroke,
Vec2,
};
use crate::warehouse::iceberg::IcebergWarehouse;
use crate::warehouse::release_events::{
query_release_events, status, EventSelector, ReleaseEventRow,
};
use super::facett_theme::{Theme, AMBER, GREEN, RED};
enum Src {
Local(PathBuf),
Remote { endpoint: String, token: String, workspace: String },
}
#[derive(Clone)]
struct RunGroup {
run_id: String,
rows: Vec<ReleaseEventRow>,
latest_ts: i64,
}
pub struct ReleaseTabState {
src: Src,
loaded: bool,
error: Option<String>,
runs: Vec<RunGroup>,
selected_run: Option<String>,
started_at: Instant,
theme: Theme,
run_armed: bool,
run_result: Option<Result<super::remote::OpRunResult, String>>,
}
impl ReleaseTabState {
pub fn local(root: PathBuf) -> Self {
Self::with(Src::Local(root))
}
pub fn remote(endpoint: String, token: String, workspace: String) -> Self {
Self::with(Src::Remote { endpoint, token, workspace })
}
pub fn set_workspace(&mut self, workspace: String) {
if let Src::Remote { workspace: w, .. } = &mut self.src {
*w = workspace;
}
self.reload();
}
fn with(src: Src) -> Self {
Self {
src,
loaded: false,
error: None,
runs: Vec::new(),
selected_run: None,
started_at: Instant::now(),
theme: Theme::default(),
run_armed: false,
run_result: None,
}
}
fn draw_release_op(&mut self, ui: &mut egui::Ui) {
let Src::Remote { endpoint, token, workspace } = &self.src else {
ui.label(
egui::RichText::new("release via `nornir release` CLI (local mode)")
.color(self.theme.text_dim),
);
return;
};
let (endpoint, token, workspace) = (endpoint.clone(), token.clone(), workspace.clone());
if !self.run_armed {
if ui
.button("🚀 Release (heavy gate)")
.on_hover_text("HEAVY / release-grade — runs the full release gate across the build order server-side (Ops.RunRelease). CLI: nornir release gate all")
.clicked()
{
self.run_armed = true;
}
} else {
ui.colored_label(AMBER, "⚠ heavy / release-grade — confirm?");
if ui.button("✓ run release gate").clicked() {
self.run_result = Some(
super::remote::run_release(&endpoint, &token, "", &workspace)
.map_err(|e| format!("{e:#}")),
);
self.run_armed = false;
self.reload();
}
if ui.button("✕ cancel").clicked() {
self.run_armed = false;
}
}
match &self.run_result {
Some(Ok(r)) => { ui.colored_label(if r.ok { GREEN } else { RED }, &r.summary); }
Some(Err(e)) => { ui.colored_label(RED, e); }
None => {}
}
}
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
#[doc(hidden)]
pub fn inject_for_test(&mut self, rows: Vec<ReleaseEventRow>) {
self.runs = group_runs(rows);
self.selected_run = self.runs.first().map(|r| r.run_id.clone());
self.loaded = true;
self.error = None;
}
pub fn reload(&mut self) {
self.loaded = false;
self.error = None;
self.runs.clear();
self.selected_run = None;
}
fn load(&mut self) {
if self.loaded {
return;
}
self.loaded = true;
let rows = match &self.src {
Src::Local(root) => {
match IcebergWarehouse::open_read_only(root)
.and_then(|wh| wh.block_on(query_release_events(&wh, &EventSelector::All)))
{
Ok(rows) => rows,
Err(e) => {
self.error = Some(format!("{e:#}"));
return;
}
}
}
Src::Remote { endpoint, token, workspace } => {
match super::remote::fetch_release_events(endpoint, token, workspace) {
Ok(rows) => rows,
Err(e) => {
self.error = Some(format!("{e:#}"));
return;
}
}
}
};
self.runs = group_runs(rows);
self.selected_run = self.runs.first().map(|r| r.run_id.clone());
}
fn current(&self) -> Option<&RunGroup> {
let sel = self.selected_run.as_deref();
sel.and_then(|id| self.runs.iter().find(|r| r.run_id == id))
.or_else(|| self.runs.first())
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
let theme = self.theme;
self.load();
if let Some(err) = self.error.clone() {
ui.colored_label(RED, format!("release_events read failed:\n{err}"));
return;
}
if self.runs.is_empty() {
ui.vertical_centered(|ui| {
ui.add_space(40.0);
ui.heading("🚀 Release — no release runs recorded yet");
ui.label("Every `nornir release run` records its op DAG here:");
ui.monospace("nornir release run");
ui.monospace("nornir release events <run-id|repo> # CLI twin");
ui.add_space(12.0);
ui.horizontal(|ui| self.draw_release_op(ui));
});
return;
}
let runs: Vec<(String, usize, i64)> = self
.runs
.iter()
.map(|r| (r.run_id.clone(), r.rows.len(), r.latest_ts))
.collect();
egui::TopBottomPanel::top("release_controls").show_inside(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label("run:");
let sel = self
.selected_run
.clone()
.unwrap_or_else(|| runs.first().map(|r| r.0.clone()).unwrap_or_default());
egui::ComboBox::from_id_salt("release_run")
.selected_text(short_run(&sel))
.show_ui(ui, |ui| {
for (id, n, _) in &runs {
ui.selectable_value(
&mut self.selected_run,
Some(id.clone()),
format!("{} · {n} events", short_run(id)),
);
}
});
if ui.button("↻ reload").on_hover_text("re-read release_events").clicked() {
self.reload();
}
ui.separator();
self.draw_release_op(ui);
ui.separator();
legend(ui, &theme);
});
});
let Some(run) = self.current().cloned() else { return };
let comp_status = component_status(&run.rows);
let graph_h = (ui.available_height() * 0.5).max(180.0);
egui::TopBottomPanel::top("release_graph")
.resizable(true)
.default_height(graph_h)
.show_inside(ui, |ui| {
ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
self.draw_lit_graph(ui, &run, &comp_status);
});
});
egui::CentralPanel::default().show_inside(ui, |ui| {
ui.horizontal(|ui| {
ui.strong(format!("run {}", short_run(&run.run_id)));
ui.separator();
let overall = run_outcome(&run.rows);
let (col, txt) = match overall {
RunOutcome::Running => (AMBER, "● running"),
RunOutcome::Ok => (GREEN, "✓ ok"),
RunOutcome::Fail => (RED, "✗ failed"),
};
ui.colored_label(col, txt);
ui.separator();
ui.weak(format!("{} events", run.rows.len()));
});
ui.separator();
ScrollArea::vertical()
.auto_shrink([false, false])
.stick_to_bottom(true)
.show(ui, |ui| {
for r in &run.rows {
op_log_row(ui, &theme, r);
}
});
});
if comp_status.values().any(|s| *s == status::RUNNING) {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(80));
}
}
fn draw_lit_graph(
&self,
ui: &mut egui::Ui,
run: &RunGroup,
comp_status: &BTreeMap<String, String>,
) {
let theme = self.theme;
let order = topo_order(run);
if order.is_empty() {
ui.label("(no components in this run)");
return;
}
const NODE_W: f32 = 150.0;
const NODE_H: f32 = 52.0;
const COL_GAP: f32 = 210.0;
const LEFT_PAD: f32 = 24.0;
const TOP_PAD: f32 = 40.0;
let col_of: BTreeMap<&str, usize> =
order.iter().enumerate().map(|(i, c)| (c.as_str(), i)).collect();
let pos_for = |rect: Rect, comp: &str| -> Pos2 {
let col = *col_of.get(comp).unwrap_or(&0);
Pos2::new(rect.left() + LEFT_PAD + col as f32 * COL_GAP, rect.top() + TOP_PAD)
};
let needed_w = LEFT_PAD + order.len() as f32 * COL_GAP + NODE_W;
let canvas = Vec2::new(ui.available_width().max(needed_w), (NODE_H + TOP_PAD * 2.0).max(160.0));
let (rect, _resp) = ui.allocate_exact_size(canvas, Sense::hover());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::ZERO, theme.bg);
let pulse = {
let t = self.started_at.elapsed().as_secs_f32();
0.5 + 0.5 * (t * 3.0).sin()
};
for comp in &order {
if let Some(deps) = depends_on_of(run, comp) {
let from = pos_for(rect, comp) + Vec2::new(0.0, NODE_H / 2.0);
for dep in deps {
if !col_of.contains_key(dep.as_str()) {
continue;
}
let to = pos_for(rect, dep) + Vec2::new(NODE_W, NODE_H / 2.0);
painter.line_segment(
[from, to],
Stroke::new(2.0, theme.edge),
);
}
}
}
for comp in &order {
let node_pos = pos_for(rect, comp);
let node_rect = Rect::from_min_size(node_pos, Vec2::new(NODE_W, NODE_H));
let st = comp_status.get(comp.as_str()).map(String::as_str).unwrap_or("idle");
let base = lit_color(&theme, st);
let fill = if st == status::RUNNING {
let a = (110.0 + 110.0 * pulse) as u8;
AMBER.linear_multiply(a as f32 / 255.0)
} else {
theme.node_fill
};
painter.rect_filled(node_rect, CornerRadius::same(6), fill);
painter.rect_stroke(
node_rect,
CornerRadius::same(6),
Stroke::new(2.5, base),
egui::StrokeKind::Inside,
);
painter.text(
node_pos + Vec2::new(NODE_W / 2.0, 12.0),
Align2::CENTER_TOP,
comp,
FontId::proportional(14.0),
theme.text,
);
painter.text(
node_pos + Vec2::new(NODE_W / 2.0, 30.0),
Align2::CENTER_TOP,
st,
FontId::monospace(11.0),
base,
);
}
}
pub fn state_json(&self) -> serde_json::Value {
let current = self.current();
let rows: Vec<serde_json::Value> = current
.map(|run| {
run.rows
.iter()
.map(|r| {
serde_json::json!({
"component": r.component,
"op": r.op,
"phase": r.phase,
"status": r.status,
"line": op_log_line(r),
})
})
.collect()
})
.unwrap_or_default();
let comp_status = current
.map(|run| component_status(&run.rows))
.unwrap_or_default();
serde_json::json!({
"source": match &self.src {
Src::Local(p) => format!("local {}", p.display()),
Src::Remote { endpoint, workspace, .. } => {
format!("remote {endpoint} ws={workspace} (Viz.ReleaseEvents)")
}
},
"error": self.error,
"runs": self.runs.len(),
"selected_run": current.map(|r| r.run_id.clone()),
"rows": rows,
"component_status": comp_status,
"palette": self.theme.name,
"ops": {
"buttons": [ { "id": "run_release_gate", "rpc": "Ops.RunRelease", "heavy": true, "confirm": true } ],
"armed": self.run_armed,
},
"run_result": match &self.run_result {
None => serde_json::json!({ "ran": false }),
Some(Ok(r)) => serde_json::json!({
"ran": true, "ok": r.ok, "summary": r.summary, "run_id": r.run_id,
"targets": r.targets.iter().map(|(n, s, m)| serde_json::json!({ "name": n, "status": s, "message": m })).collect::<Vec<_>>(),
}),
Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
},
})
}
}
fn group_runs(rows: Vec<ReleaseEventRow>) -> Vec<RunGroup> {
let mut by_run: BTreeMap<String, Vec<ReleaseEventRow>> = BTreeMap::new();
for r in rows {
by_run.entry(r.run_id.clone()).or_default().push(r);
}
let mut runs: Vec<RunGroup> = by_run
.into_iter()
.map(|(run_id, mut rows)| {
rows.sort_by_key(|r| r.seq);
let latest_ts = rows.iter().map(|r| r.ts_micros).max().unwrap_or(0);
RunGroup { run_id, rows, latest_ts }
})
.collect();
runs.sort_by(|a, b| b.latest_ts.cmp(&a.latest_ts));
runs
}
fn component_status(rows: &[ReleaseEventRow]) -> BTreeMap<String, String> {
let mut out: BTreeMap<String, String> = BTreeMap::new();
for r in rows {
if r.op == "run" {
continue;
}
out.insert(r.component.clone(), r.status.clone());
}
out
}
fn topo_order(run: &RunGroup) -> Vec<String> {
use std::collections::{BTreeSet, VecDeque};
let mut comps: Vec<String> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
for r in &run.rows {
if r.op == "run" {
continue;
}
if seen.insert(r.component.clone()) {
comps.push(r.component.clone());
}
}
let set: BTreeSet<&str> = comps.iter().map(String::as_str).collect();
let mut indeg: BTreeMap<&str, usize> = comps.iter().map(|c| (c.as_str(), 0)).collect();
let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for c in &comps {
if let Some(deps) = depends_on_of(run, c) {
for d in deps {
if set.contains(d.as_str()) {
adj.entry(d.as_str()).or_default().push(c.as_str());
*indeg.entry(c.as_str()).or_insert(0) += 1;
}
}
}
}
let mut q: VecDeque<&str> =
comps.iter().map(String::as_str).filter(|c| indeg[c] == 0).collect();
let mut out: Vec<String> = Vec::new();
while let Some(c) = q.pop_front() {
out.push(c.to_string());
if let Some(children) = adj.get(c) {
for &ch in children {
let Some(d) = indeg.get_mut(ch) else { continue };
*d = d.saturating_sub(1);
if *d == 0 {
q.push_back(ch);
}
}
}
}
if out.len() == comps.len() {
out
} else {
comps
}
}
fn depends_on_of<'a>(run: &'a RunGroup, comp: &str) -> Option<&'a Vec<String>> {
run.rows
.iter()
.find(|r| r.component == comp && r.depends_on.is_some())
.and_then(|r| r.depends_on.as_ref())
}
enum RunOutcome {
Running,
Ok,
Fail,
}
fn run_outcome(rows: &[ReleaseEventRow]) -> RunOutcome {
if rows.iter().any(|r| r.status == status::FAIL) {
return RunOutcome::Fail;
}
let ended_ok = rows
.iter()
.any(|r| r.op == "run" && r.phase == "end" && r.status == status::OK);
if ended_ok {
RunOutcome::Ok
} else if rows.iter().any(|r| r.status == status::RUNNING) {
RunOutcome::Running
} else {
RunOutcome::Ok
}
}
fn op_log_line(r: &ReleaseEventRow) -> String {
let elapsed = r
.elapsed_ms
.map(|ms| format!(" ({:.1}s)", ms as f32 / 1000.0))
.unwrap_or_default();
let detail = if r.detail.is_empty() {
String::new()
} else {
format!(" — {}", r.detail)
};
format!(
"[{}] {} {} {}{}{}",
r.component, r.op, r.phase, r.status, elapsed, detail
)
}
fn op_log_row(ui: &mut egui::Ui, theme: &Theme, r: &ReleaseEventRow) {
let (glyph, col) = match r.status.as_str() {
status::OK => ("✓", GREEN),
status::FAIL => ("✗", RED),
status::WARN => ("⚠", AMBER),
status::RUNNING => ("…", AMBER),
_ => ("·", theme.text_dim),
};
ui.horizontal(|ui| {
ui.colored_label(col, glyph);
ui.label(
RichText::new(format!("[{}]", r.component))
.monospace()
.size(12.0)
.color(theme.text_dim),
);
ui.colored_label(col, op_log_line_suffix(r));
});
}
fn op_log_line_suffix(r: &ReleaseEventRow) -> String {
let elapsed = r
.elapsed_ms
.map(|ms| format!(" ({:.1}s)", ms as f32 / 1000.0))
.unwrap_or_default();
let detail = if r.detail.is_empty() {
String::new()
} else {
format!(" — {}", r.detail)
};
format!("{} {} {}{}{}", r.op, r.phase, r.status, elapsed, detail)
}
fn lit_color(theme: &Theme, st: &str) -> Color32 {
match st {
status::OK => GREEN,
status::FAIL => RED,
status::WARN => AMBER,
status::RUNNING => AMBER,
_ => theme.node_stroke, }
}
fn short_run(id: &str) -> String {
if id.chars().count() > 12 {
let head: String = id.chars().take(12).collect();
format!("{head}…")
} else {
id.to_string()
}
}
fn legend(ui: &mut egui::Ui, theme: &Theme) {
for (st, label) in [
(status::RUNNING, "running"),
(status::OK, "ok"),
(status::FAIL, "fail"),
("idle", "idle"),
] {
ui.colored_label(lit_color(theme, st), "●");
ui.weak(label);
ui.add_space(4.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(
run: &str,
seq: i64,
comp: &str,
op: &str,
phase: &str,
st: &str,
deps: Option<Vec<&str>>,
elapsed: Option<i64>,
) -> ReleaseEventRow {
ReleaseEventRow {
run_id: run.into(),
seq,
ts_micros: seq * 10,
component: comp.into(),
repo: comp.into(),
op: op.into(),
phase: phase.into(),
status: st.into(),
detail: String::new(),
depends_on: deps.map(|v| v.into_iter().map(String::from).collect()),
elapsed_ms: elapsed,
}
}
#[test]
fn group_runs_newest_first_and_seq_ordered() {
let rows = vec![
row("runA", 1, "znippy", "test", "end", status::OK, None, Some(1200)),
row("runA", 0, "znippy", "test", "start", status::RUNNING, None, None),
row("runB", 0, "korp", "test", "start", status::RUNNING, Some(vec![]), None),
];
let runs = group_runs(rows);
assert_eq!(runs.len(), 2);
assert_eq!(runs[0].run_id, "runA", "newest (highest ts) run on top");
assert_eq!(runs[0].rows.iter().map(|r| r.seq).collect::<Vec<_>>(), vec![0, 1]);
}
#[test]
fn component_status_takes_latest() {
let rows = vec![
row("r", 0, "znippy", "test", "start", status::RUNNING, None, None),
row("r", 1, "znippy", "test", "end", status::OK, None, Some(5)),
row("r", 2, "holger", "gate", "start", status::RUNNING, Some(vec!["znippy"]), None),
row("r", 3, "r", "run", "end", status::OK, None, None), ];
let st = component_status(&rows);
assert_eq!(st.get("znippy").map(String::as_str), Some(status::OK));
assert_eq!(st.get("holger").map(String::as_str), Some(status::RUNNING));
assert!(!st.contains_key("r"), "run-envelope component is not a node");
}
#[test]
fn topo_order_is_deps_first() {
let run = RunGroup {
run_id: "r".into(),
latest_ts: 0,
rows: vec![
row("r", 0, "holger", "gate", "start", status::RUNNING, Some(vec!["znippy"]), None),
row("r", 1, "znippy", "test", "start", status::RUNNING, Some(vec![]), None),
],
};
let order = topo_order(&run);
assert_eq!(order, vec!["znippy".to_string(), "holger".to_string()]);
}
#[test]
fn op_log_line_is_component_prefixed() {
let r = row("r", 0, "znippy", "test", "end", status::OK, None, Some(1200));
assert_eq!(op_log_line(&r), "[znippy] test end ok (1.2s)");
}
#[test]
fn run_outcome_fail_wins() {
let rows = vec![
row("r", 0, "znippy", "test", "end", status::OK, None, Some(5)),
row("r", 1, "holger", "test", "end", status::FAIL, None, Some(3)),
];
assert!(matches!(run_outcome(&rows), RunOutcome::Fail));
}
}