use std::path::PathBuf;
use eframe::egui::{self};
use super::action_log::{ActionLog, Kind};
use super::facett_theme::{Theme, GREEN, RED};
use super::ops_tabs::Server;
use super::remote;
use crate::warehouse::clone_events::CloneEventRow;
use facett_jobview::{Facet, JobEntry, JobList, JobStatus};
pub struct NornirRootView {
new_name: String,
new_descriptor: String,
new_mode: String, new_poll: String,
kill_target: String,
kill_armed: bool,
last_add: Option<Result<String, String>>,
last_kill: Option<Result<String, String>>,
last_populate: Option<Result<String, String>>,
last_refresh: Option<Result<String, String>>,
server_info: Option<Result<remote::ServerInfo, String>>,
clone_events: Option<Result<Vec<CloneEventRow>, String>>,
clone_events_ws: String,
jobs_view: JobList,
jobs_ws: String,
jobs_last_poll: Option<std::time::Instant>,
jobs_error: Option<String>,
theme: Theme,
}
impl Default for NornirRootView {
fn default() -> Self {
Self {
new_name: String::new(),
new_descriptor: String::new(),
new_mode: "monitored".into(),
new_poll: "60s".into(),
kill_target: String::new(),
kill_armed: false,
last_add: None,
last_kill: None,
last_populate: None,
last_refresh: None,
server_info: None,
clone_events: None,
clone_events_ws: String::new(),
jobs_view: JobList::new("Jobs"),
jobs_ws: String::new(),
jobs_last_poll: None,
jobs_error: None,
theme: Theme::default(),
}
}
}
impl NornirRootView {
pub fn local() -> Self {
Self::default()
}
pub fn remote() -> Self {
Self::default()
}
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
#[must_use]
pub fn draw(
&mut self,
ui: &mut egui::Ui,
srv: Option<&Server>,
workspaces: &[String],
active_ws: &str,
local_warehouse: Option<&PathBuf>,
log: &ActionLog,
) -> bool {
let theme = self.theme;
ui.heading("🧬 nornir — server & workspace operations");
self.draw_populate_status(ui, srv, active_ws, local_warehouse, log);
self.draw_jobs(ui, srv, active_ws, local_warehouse);
let Some(srv) = srv else {
ui.add_space(20.0);
ui.label(
"The nornir root pane's lifecycle ops are server-backed — launch the viz against \
a running nornir-server (NORNIR_SERVER=…) to add/kill/populate workspaces.",
);
return false;
};
let mut mutated = false;
ui.separator();
ui.horizontal(|ui| {
ui.strong("server:");
if ui.button("⟳ status").on_hover_text("Health.Ping — version + repo count").clicked() {
log.push(Kind::Rpc, "Health.Ping".to_string());
self.server_info =
Some(remote::ping(&srv.endpoint, &srv.token).map_err(|e| format!("{e:#}")));
}
match &self.server_info {
Some(Ok(i)) => {
ui.colored_label(
GREEN,
format!("{} · nornir {} · {} repo(s)", i.status, i.version, i.repo_count),
);
}
Some(Err(e)) => { ui.colored_label(RED, e); }
None => { ui.colored_label(theme.text_dim, format!("{}", srv.endpoint)); }
}
});
ui.separator();
ui.strong("âž• add workspace");
egui::Grid::new("nornir_add_ws").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| {
ui.label("name:");
ui.add(egui::TextEdit::singleline(&mut self.new_name).desired_width(220.0).hint_text("workspace name"));
ui.end_row();
ui.label("descriptor:");
ui.add(
egui::TextEdit::singleline(&mut self.new_descriptor)
.desired_width(360.0)
.hint_text("server-readable nornir-workspace.toml path/URL"),
);
ui.end_row();
ui.label("mode:");
egui::ComboBox::from_id_salt("nornir_add_mode")
.selected_text(self.new_mode.clone())
.show_ui(ui, |ui| {
for m in ["monitored", "pushed", "external"] {
ui.selectable_value(&mut self.new_mode, m.to_string(), m);
}
});
ui.end_row();
ui.label("poll:");
ui.add(egui::TextEdit::singleline(&mut self.new_poll).desired_width(100.0).hint_text("60s"));
ui.end_row();
});
if ui
.button("âž• Add workspace")
.on_hover_text("Workspaces.Register (eager populate) — CLI: nornir workspace add")
.clicked()
&& !self.new_name.trim().is_empty()
{
log.push(Kind::Rpc, format!("Workspaces.Register name={}", self.new_name));
self.last_add = Some(
remote::register_workspace(
&srv.endpoint,
&srv.token,
self.new_name.trim(),
self.new_descriptor.trim(),
&self.new_mode,
self.new_poll.trim(),
)
.map(|(name, mode, members)| format!("registered `{name}` ({mode}, {members} member(s))"))
.map_err(|e| format!("{e:#}")),
);
mutated |= matches!(self.last_add, Some(Ok(_)));
}
result_line(ui, theme, &self.last_add);
ui.separator();
ui.strong("🗑 kill workspace");
if self.kill_target.is_empty() {
self.kill_target = workspaces.first().cloned().unwrap_or_default();
}
ui.horizontal(|ui| {
egui::ComboBox::from_id_salt("nornir_kill_target")
.selected_text(if self.kill_target.is_empty() { "—".into() } else { self.kill_target.clone() })
.show_ui(ui, |ui| {
for w in workspaces {
ui.selectable_value(&mut self.kill_target, w.clone(), w);
}
});
if !self.kill_armed {
if ui.button("🗑 Kill…").clicked() && !self.kill_target.trim().is_empty() {
self.kill_armed = true;
}
} else {
ui.colored_label(RED, format!("âš remove `{}`?", self.kill_target));
if ui.button("✓ confirm kill").clicked() {
log.push(Kind::Rpc, format!("Workspaces.Remove name={}", self.kill_target));
self.last_kill = Some(
remote::remove_workspace(&srv.endpoint, &srv.token, self.kill_target.trim())
.map(|()| format!("removed `{}`", self.kill_target))
.map_err(|e| format!("{e:#}")),
);
mutated |= matches!(self.last_kill, Some(Ok(_)));
self.kill_armed = false;
}
if ui.button("✕ cancel").clicked() {
self.kill_armed = false;
}
}
});
result_line(ui, theme, &self.last_kill);
ui.separator();
ui.strong("🔄 populate / refresh");
ui.horizontal(|ui| {
let target = if self.kill_target.is_empty() {
workspaces.first().cloned().unwrap_or_default()
} else {
self.kill_target.clone()
};
if ui
.button("⬇ Populate")
.on_hover_text("Workspaces.Fetch{background} — clone members now, build async (CLI: nornir workspace populate)")
.clicked()
&& !target.trim().is_empty()
{
log.push(Kind::Rpc, format!("Workspaces.Fetch(background) name={target}"));
self.last_populate = Some(
fetch(&srv.endpoint, &srv.token, &target, false, true)
.map_err(|e| format!("{e:#}")),
);
mutated |= matches!(self.last_populate, Some(Ok(_)));
}
if ui
.button("⟳ Refresh / poll-now")
.on_hover_text("Workspaces.Fetch{force} — poll + rebuild this workspace now (CLI: nornir workspace fetch)")
.clicked()
&& !target.trim().is_empty()
{
log.push(Kind::Rpc, format!("Workspaces.Fetch(force) name={target}"));
self.last_refresh = Some(
fetch(&srv.endpoint, &srv.token, &target, true, false)
.map_err(|e| format!("{e:#}")),
);
mutated |= matches!(self.last_refresh, Some(Ok(_)));
}
});
result_line(ui, theme, &self.last_populate);
result_line(ui, theme, &self.last_refresh);
mutated
}
fn draw_jobs(
&mut self,
ui: &mut egui::Ui,
srv: Option<&Server>,
active_ws: &str,
local_warehouse: Option<&PathBuf>,
) {
use std::time::{Duration, Instant};
let due = self.jobs_ws != active_ws
|| self.jobs_last_poll.is_none_or(|t| t.elapsed() >= Duration::from_millis(1500));
if due {
self.jobs_ws = active_ws.to_string();
self.jobs_last_poll = Some(Instant::now());
match load_jobs(srv, active_ws, local_warehouse) {
Ok(recs) => {
self.jobs_error = None;
self.jobs_view.set_jobs(recs.iter().map(job_entry).collect());
}
Err(e) => self.jobs_error = Some(e),
}
}
ui.separator();
egui::CollapsingHeader::new("🧰 Jobs").default_open(true).show(ui, |ui| {
if let Some(e) = &self.jobs_error {
ui.colored_label(RED, format!("jobs unavailable: {e}"));
}
Facet::ui(&mut self.jobs_view, ui);
});
ui.ctx().request_repaint_after(Duration::from_millis(1500));
}
pub fn running_jobs(&self) -> usize {
self.jobs_view.count(JobStatus::Running)
}
fn draw_populate_status(
&mut self,
ui: &mut egui::Ui,
srv: Option<&Server>,
active_ws: &str,
local_warehouse: Option<&PathBuf>,
log: &ActionLog,
) {
let theme = self.theme;
ui.separator();
ui.horizontal(|ui| {
ui.strong("🩺 populate status");
ui.colored_label(theme.text_dim, active_ws);
if ui
.button("⟳ load")
.on_hover_text("clone_events — per-member populate outcomes (Viz.CloneEvents / local warehouse)")
.clicked()
{
log.push(Kind::Rpc, format!("Viz.CloneEvents workspace={active_ws}"));
self.clone_events = Some(load_clone_events(srv, active_ws, local_warehouse));
self.clone_events_ws = active_ws.to_string();
}
});
if self.clone_events.is_none() || self.clone_events_ws != active_ws {
self.clone_events = Some(load_clone_events(srv, active_ws, local_warehouse));
self.clone_events_ws = active_ws.to_string();
}
match &self.clone_events {
Some(Ok(rows)) if rows.is_empty() => {
ui.colored_label(theme.text_dim, "no clone/populate events recorded yet");
}
Some(Ok(rows)) => {
let failed = rows.iter().filter(|r| r.status == "error").count();
if failed == 0 {
ui.colored_label(GREEN, format!("✓ all {} member event(s) ok", rows.len()));
} else {
ui.colored_label(RED, format!("✗ {failed} member(s) failed to populate"));
}
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
egui::Grid::new("nornir_clone_events").num_columns(4).striped(true).spacing([12.0, 2.0]).show(ui, |ui| {
ui.strong("member"); ui.strong("op"); ui.strong("status"); ui.strong("detail");
ui.end_row();
for r in rows.iter().take(50) {
ui.label(&r.member);
ui.label(&r.op);
if r.status == "error" {
ui.colored_label(RED, "✗ error");
} else {
ui.colored_label(GREEN, "✓ ok");
}
ui.label(&r.detail);
ui.end_row();
}
});
});
}
Some(Err(e)) => { ui.colored_label(RED, format!("✗ {e}")); }
None => {}
}
}
pub fn state_json(&self) -> serde_json::Value {
let res = |r: &Option<Result<String, String>>| match r {
None => serde_json::json!({ "ran": false }),
Some(Ok(m)) => serde_json::json!({ "ran": true, "ok": true, "message": m }),
Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
};
serde_json::json!({
"palette": self.theme.name,
"buttons": [
{ "id": "server_status", "rpc": "Health.Ping", "heavy": false },
{ "id": "add_workspace", "rpc": "Workspaces.Register", "heavy": false },
{ "id": "kill_workspace", "rpc": "Workspaces.Remove", "heavy": false, "confirm": true },
{ "id": "populate", "rpc": "Workspaces.Fetch", "heavy": false },
{ "id": "refresh", "rpc": "Workspaces.Fetch", "heavy": false },
{ "id": "populate_status", "rpc": "Viz.CloneEvents", "heavy": false },
{ "id": "jobs", "rpc": "Viz.Jobs", "heavy": false },
],
"jobs": self.jobs_view.state_json(),
"jobs_error": self.jobs_error,
"add_form": {
"name": self.new_name,
"descriptor": self.new_descriptor,
"mode": self.new_mode,
"poll": self.new_poll,
},
"kill": { "target": self.kill_target, "armed": self.kill_armed },
"results": {
"add": res(&self.last_add),
"kill": res(&self.last_kill),
"populate": res(&self.last_populate),
"refresh": res(&self.last_refresh),
},
"server": match &self.server_info {
None => serde_json::json!({ "pinged": false }),
Some(Ok(i)) => serde_json::json!({
"pinged": true, "ok": true,
"status": i.status, "version": i.version, "repo_count": i.repo_count,
}),
Some(Err(e)) => serde_json::json!({ "pinged": true, "ok": false, "error": e }),
},
"populate_status": match &self.clone_events {
None => serde_json::json!({ "loaded": false }),
Some(Err(e)) => serde_json::json!({ "loaded": true, "ok": false, "error": e }),
Some(Ok(rows)) => serde_json::json!({
"loaded": true,
"ok": true,
"workspace": self.clone_events_ws,
"total": rows.len(),
"failed": rows.iter().filter(|r| r.status == "error").count(),
"members": rows.iter().map(|r| serde_json::json!({
"member": r.member,
"op": r.op,
"status": r.status,
"detail": r.detail,
"remote": r.remote,
"ts": crate::warehouse::clone_events::ts_to_rfc3339(r.ts_micros),
"elapsed_ms": r.elapsed_ms,
})).collect::<Vec<_>>(),
}),
},
})
}
pub fn set_clone_events_for_test(&mut self, workspace: &str, rows: Vec<CloneEventRow>) {
self.clone_events_ws = workspace.to_string();
self.clone_events = Some(Ok(rows));
}
}
fn load_clone_events(
srv: Option<&Server>,
active_ws: &str,
local_warehouse: Option<&PathBuf>,
) -> Result<Vec<CloneEventRow>, String> {
if let Some(srv) = srv {
return remote::fetch_clone_events(&srv.endpoint, &srv.token, active_ws)
.map_err(|e| format!("{e:#}"));
}
let Some(root) = local_warehouse else {
return Ok(Vec::new());
};
use crate::warehouse::clone_events::{query_clone_events, CloneSelector};
let wh = crate::warehouse::iceberg::IcebergWarehouse::open_read_only(root)
.map_err(|e| format!("open warehouse: {e:#}"))?;
wh.block_on(query_clone_events(&wh, &CloneSelector::Workspace(active_ws.to_string())))
.map_err(|e| format!("{e:#}"))
}
fn load_jobs(
srv: Option<&Server>,
active_ws: &str,
local_warehouse: Option<&PathBuf>,
) -> Result<Vec<crate::jobs::JobRecord>, String> {
if let Some(srv) = srv {
return remote::fetch_jobs(&srv.endpoint, &srv.token, active_ws, "", 200)
.map_err(|e| format!("{e:#}"));
}
let Some(root) = local_warehouse else {
return Ok(Vec::new());
};
crate::jobs::JobStore::open_read_only(root)
.and_then(|s| s.list(&crate::jobs::JobSelector::All))
.map_err(|e| format!("{e:#}"))
}
fn job_entry(r: &crate::jobs::JobRecord) -> JobEntry {
let meta = if r.result_ref.is_empty() {
Vec::new()
} else {
vec![("result".to_string(), r.result_ref.clone())]
};
JobEntry {
id: r.job_id.clone(),
kind: r.kind.clone(),
target: r.target.clone(),
status: JobStatus::parse(&r.status),
started_micros: r.ts_start_micros,
elapsed_ms: r.elapsed_ms,
detail: r.detail_json.clone(),
meta,
}
}
fn result_line(ui: &mut egui::Ui, theme: Theme, r: &Option<Result<String, String>>) {
let _ = theme;
match r {
Some(Ok(m)) => { ui.colored_label(GREEN, format!("✓ {m}")); }
Some(Err(e)) => { ui.colored_label(RED, format!("✗ {e}")); }
None => {}
}
}
fn fetch(
endpoint: &str,
token: &str,
name: &str,
force: bool,
background: bool,
) -> anyhow::Result<String> {
let (fetched, changed, errors, snapshot) = if background {
remote::populate_workspace(endpoint, token, name)?
} else {
remote::fetch_workspace(endpoint, token, name, force)?
};
let snap = if snapshot.is_empty() { String::new() } else { format!(" → snapshot {}", &snapshot[..12.min(snapshot.len())]) };
let errs = if errors.is_empty() { String::new() } else { format!(", {} error(s)", errors.len()) };
Ok(format!("{} fetched, {} changed{snap}{errs}", fetched, changed.len()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_json_surfaces_failed_member_populate_status() {
let mut view = NornirRootView::remote();
view.set_clone_events_for_test(
"nordisk",
vec![
CloneEventRow {
ts_micros: 2,
workspace: "nordisk".into(),
member: "korp".into(),
remote: "https://github.com/nordisk/korp".into(),
op: "clone-fetch".into(),
status: "error".into(),
detail: "clone-fetch …: Couldn't obtain Username".into(),
elapsed_ms: 7,
},
CloneEventRow {
ts_micros: 1,
workspace: "nordisk".into(),
member: "facett".into(),
remote: "git@github.com:nordisk/facett.git".into(),
op: "clone-fetch".into(),
status: "ok".into(),
detail: "abc123".into(),
elapsed_ms: 42,
},
],
);
let s = view.state_json();
let ps = &s["populate_status"];
assert_eq!(ps["loaded"], true);
assert_eq!(ps["ok"], true);
assert_eq!(ps["workspace"], "nordisk");
assert_eq!(ps["total"], 2);
assert_eq!(ps["failed"], 1, "one member failed to populate");
let members = ps["members"].as_array().expect("members array");
let korp = members
.iter()
.find(|m| m["member"] == "korp")
.expect("failed member korp is surfaced");
assert_eq!(korp["status"], "error");
assert!(
korp["detail"].as_str().unwrap().contains("Couldn't obtain Username"),
"the error detail is readable in state_json, got: {}",
korp["detail"]
);
assert_eq!(korp["op"], "clone-fetch");
assert!(korp["ts"].as_str().unwrap().contains('T'), "ts rendered as RFC3339");
let buttons = s["buttons"].as_array().unwrap();
assert!(
buttons.iter().any(|b| b["id"] == "populate_status" && b["rpc"] == "Viz.CloneEvents"),
"the populate-status surface is wired to Viz.CloneEvents"
);
}
}