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;
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>>,
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,
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],
log: &ActionLog,
) -> bool {
let theme = self.theme;
ui.heading("🧬 nornir — server & workspace operations");
let Some(srv) = srv else {
ui.add_space(20.0);
ui.label(
"The nornir root pane is 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
}
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 },
],
"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 }),
},
})
}
}
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()))
}