use std::collections::BTreeSet;
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use eframe::egui::{self, RichText, ScrollArea};
use nornir_build_thing::build_info::BuildInfoRow;
use super::facett_theme::{Theme, AMBER, GREEN, RED};
use super::facett_ui;
pub const POLL_EVERY: Duration = Duration::from_millis(1500);
#[derive(Debug, Clone, PartialEq)]
pub struct TailLine {
pub kind: String,
pub level: String,
pub name: String,
pub value: String,
pub ts_micros: i64,
}
impl TailLine {
fn from_row(r: &BuildInfoRow) -> Self {
Self {
kind: r.kind.clone(),
level: r.level.clone(),
name: r.name.clone(),
value: r.value.clone(),
ts_micros: r.ts_micros,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct StepTail {
pub step_id: String,
pub status: String,
pub status_history: Vec<String>,
pub tail: Vec<TailLine>,
}
impl StepTail {
fn new(step_id: &str) -> Self {
Self {
step_id: step_id.to_string(),
status: String::new(),
status_history: Vec::new(),
tail: Vec::new(),
}
}
}
pub struct BuildInfoTail {
build_id: Option<String>,
steps: Vec<StepTail>,
manifest_rows: usize,
endpoint: Option<String>,
error: Option<String>,
theme: Theme,
inbox_tx: Sender<Vec<BuildInfoRow>>,
inbox_rx: Receiver<Vec<BuildInfoRow>>,
tickets: Arc<Mutex<BTreeSet<(String, String)>>>,
poll_count: usize,
#[cfg(feature = "build-info-flight")]
poller: Option<Poller>,
}
#[cfg(feature = "build-info-flight")]
struct Poller {
stop: Arc<std::sync::atomic::AtomicBool>,
}
impl Default for BuildInfoTail {
fn default() -> Self {
let (inbox_tx, inbox_rx) = std::sync::mpsc::channel();
Self {
build_id: None,
steps: Vec::new(),
manifest_rows: 0,
endpoint: None,
error: None,
theme: Theme::default(),
inbox_tx,
inbox_rx,
tickets: Arc::new(Mutex::new(BTreeSet::new())),
poll_count: 0,
#[cfg(feature = "build-info-flight")]
poller: None,
}
}
}
#[cfg(feature = "build-info-flight")]
impl Drop for BuildInfoTail {
fn drop(&mut self) {
if let Some(p) = &self.poller {
p.stop.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
}
impl BuildInfoTail {
pub fn new() -> Self {
Self::default()
}
pub fn set_endpoint(&mut self, endpoint: impl Into<String>) {
self.endpoint = Some(endpoint.into());
}
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
pub fn ingest(&mut self, rows: &[BuildInfoRow]) {
for r in rows {
if self.build_id.is_none() && !r.build_id.is_empty() {
self.build_id = Some(r.build_id.clone());
}
self.register_ticket(&r.build_id, &r.step_id);
match r.kind.as_str() {
"step" => {
let step = self.step_mut(&r.step_id);
if !r.level.is_empty() && step.status != r.level {
step.status = r.level.clone();
step.status_history.push(r.level.clone());
}
step.tail.push(TailLine::from_row(r));
}
"log" | "metric" => {
let step = self.step_mut(&r.step_id);
step.tail.push(TailLine::from_row(r));
}
_ => self.manifest_rows += 1,
}
}
self.emit_trace();
}
#[cfg(feature = "build-info-flight")]
pub async fn pull(
&mut self,
endpoint: &str,
token: Option<&str>,
key: &str,
) -> usize {
self.endpoint = Some(endpoint.to_string());
match crate::build::flight_sink::subscribe(endpoint, token, key).await {
Ok(rows) => {
let n = rows.len();
self.error = None;
self.ingest(&rows);
n
}
Err(e) => {
self.error = Some(format!("{e:#}"));
0
}
}
}
#[doc(hidden)]
pub fn inject_for_test(&mut self, rows: Vec<BuildInfoRow>) {
self.ingest(&rows);
}
pub fn subscribe_ticket(&mut self, build_id: &str, step_id: &str) {
self.register_ticket(build_id, step_id);
}
fn register_ticket(&self, build_id: &str, step_id: &str) {
if build_id.is_empty() {
return;
}
if let Ok(mut t) = self.tickets.lock() {
t.insert((build_id.to_string(), step_id.to_string()));
}
}
fn tickets_snapshot(&self) -> Vec<(String, String)> {
self.tickets
.lock()
.map(|t| t.iter().cloned().collect())
.unwrap_or_default()
}
fn drain_inbox(&mut self) {
while let Ok(rows) = self.inbox_rx.try_recv() {
if !rows.is_empty() {
self.ingest(&rows);
self.poll_count += 1;
}
}
}
#[doc(hidden)]
pub fn poll_sender_for_test(&self) -> Sender<Vec<BuildInfoRow>> {
self.inbox_tx.clone()
}
#[doc(hidden)]
pub fn pump(&mut self) {
self.drain_inbox();
}
#[cfg(feature = "build-info-flight")]
pub fn start_poller(
&mut self,
endpoint: impl Into<String>,
token: Option<String>,
interval: Duration,
) {
use std::sync::atomic::{AtomicBool, Ordering};
let endpoint = endpoint.into();
self.endpoint = Some(endpoint.clone());
if self.poller.is_some() {
return;
}
let tx = self.inbox_tx.clone();
let tickets = self.tickets.clone();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let _ = std::thread::Builder::new()
.name("build-info-live-poll".into())
.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return,
};
rt.block_on(async move {
let mut seen: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
while !stop_thread.load(Ordering::Relaxed) {
let keys = tickets
.lock()
.map(|t| t.iter().cloned().collect::<Vec<(String, String)>>())
.unwrap_or_default();
for (build_id, step_id) in keys {
let key = crate::build::flight_sink::route_key(&build_id, &step_id);
if let Ok(rows) = crate::build::flight_sink::subscribe(
&endpoint,
token.as_deref(),
&key,
)
.await
{
let already = seen.get(&key).copied().unwrap_or(0);
if rows.len() > already {
let fresh: Vec<BuildInfoRow> = rows[already..].to_vec();
seen.insert(key.clone(), rows.len());
if tx.send(fresh).is_err() {
return; }
}
}
}
tokio::time::sleep(interval).await;
}
});
});
self.poller = Some(Poller { stop });
}
fn step_mut(&mut self, step_id: &str) -> &mut StepTail {
if let Some(idx) = self.steps.iter().position(|s| s.step_id == step_id) {
&mut self.steps[idx]
} else {
self.steps.push(StepTail::new(step_id));
self.steps.last_mut().expect("just pushed")
}
}
fn tail_total(&self) -> usize {
self.steps.iter().map(|s| s.tail.len()).sum()
}
fn emit_trace(&self) {
super::trace::emit_end(
"build.info.live",
&serde_json::json!({
"build_id": self.build_id,
"steps": self.steps.len(),
"tail_total": self.tail_total(),
"manifest_rows": self.manifest_rows,
}),
);
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(
"nornir/viz/build-info-live",
"ingest_feed",
true,
&format!(
"build_id={:?} steps={} tail={}",
self.build_id,
self.steps.len(),
self.tail_total()
),
);
}
pub fn state_json(&self) -> serde_json::Value {
#[cfg(feature = "build-info-flight")]
let polling = self.poller.is_some();
#[cfg(not(feature = "build-info-flight"))]
let polling = false;
serde_json::json!({
"palette": self.theme.name,
"endpoint": self.endpoint,
"error": self.error,
"build_id": self.build_id,
"step_count": self.steps.len(),
"manifest_rows": self.manifest_rows,
"tail_total": self.tail_total(),
"polling": polling,
"poll_count": self.poll_count,
"live_tickets": self.tickets_snapshot().iter().map(|(b, s)| serde_json::json!({
"build_id": b,
"step_id": s,
})).collect::<Vec<_>>(),
"steps": self.steps.iter().map(|s| serde_json::json!({
"step_id": s.step_id,
"status": s.status,
"status_history": s.status_history,
"tail_len": s.tail.len(),
"tail": s.tail.iter().map(|l| serde_json::json!({
"kind": l.kind,
"level": l.level,
"name": l.name,
"value": l.value,
"ts_micros": l.ts_micros,
})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
})
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
self.drain_inbox();
ui.ctx().request_repaint_after(POLL_EVERY);
let theme = self.theme;
ui.heading(RichText::new("๐ก Build-info โ live tail").color(theme.text));
ui.horizontal(|ui| {
if let Some(b) = &self.build_id {
ui.label(RichText::new(format!("build {b}")).color(theme.text_dim));
}
if let Some(ep) = &self.endpoint {
ui.label(RichText::new(format!("ยท {ep}")).color(theme.text_dim));
}
ui.label(
RichText::new(format!(
"ยท {} sub-job(s) ยท {} tail line(s)",
self.steps.len(),
self.tail_total()
))
.color(theme.text_dim),
);
});
if let Some(err) = self.error.clone() {
ui.colored_label(RED, format!("subscribe failed: {err}"));
}
if self.steps.is_empty() {
ui.label(
RichText::new("no build-info feed yet โ waiting for a sub-job to emit")
.color(theme.text_dim),
);
return;
}
ui.separator();
ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
for step in &self.steps {
let (chip, color) = facett_ui::status_chip(&theme, &step.status);
let title = if step.step_id.is_empty() {
"(root)".to_string()
} else {
step.step_id.clone()
};
egui::CollapsingHeader::new(
RichText::new(format!("{chip} {title}")).color(color),
)
.default_open(true)
.show(ui, |ui| {
if !step.status_history.is_empty() {
ui.label(
RichText::new(format!(
"status: {}",
step.status_history.join(" โ ")
))
.color(theme.text_dim),
);
}
for line in &step.tail {
let lc = match line.level.as_str() {
"error" | "failed" => RED,
"running" | "warn" => AMBER,
"done" | "info" => GREEN,
_ => theme.text,
};
ui.label(
RichText::new(format!(
"[{}] {} = {}",
line.kind, line.name, line.value
))
.color(lc)
.monospace(),
);
}
});
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use nornir_build_thing::build_info::{BuildInfoKind, BuildInfoRow};
fn step_row(build: &str, step: &str, ts: i64, status: &str) -> BuildInfoRow {
BuildInfoRow::new(build, ts, BuildInfoKind::Step)
.step(step)
.level(status)
.kv("cargo build", "full")
}
fn log_row(build: &str, step: &str, ts: i64, msg: &str) -> BuildInfoRow {
BuildInfoRow::new(build, ts, BuildInfoKind::Log)
.step(step)
.level("info")
.kv("cargo", msg)
}
#[test]
fn ingest_grows_tail_and_tracks_status() {
let build = "b-live";
let mut tail = BuildInfoTail::new();
tail.ingest(&[
step_row(build, "compile", 1, "running"),
log_row(build, "compile", 2, "Compiling holger v0.1"),
]);
let v1 = tail.state_json();
assert_eq!(v1["build_id"], "b-live");
assert_eq!(v1["step_count"], 1);
assert_eq!(v1["tail_total"], 2, "step + log tailed");
let compile1 = &v1["steps"][0];
assert_eq!(compile1["step_id"], "compile");
assert_eq!(compile1["status"], "running", "first status");
assert_eq!(compile1["tail_len"], 2);
tail.ingest(&[
log_row(build, "compile", 3, "Finished release"),
step_row(build, "compile", 4, "done"),
step_row(build, "test", 5, "running"),
]);
let v2 = tail.state_json();
assert_eq!(v2["step_count"], 2, "second sub-job appeared");
assert_eq!(
v2["tail_total"], 5,
"tail GREW to 5 across the two batches, not reset"
);
let compile2 = v2["steps"]
.as_array()
.unwrap()
.iter()
.find(|s| s["step_id"] == "compile")
.unwrap();
assert_eq!(compile2["status"], "done", "latest status");
let hist: Vec<&str> = compile2["status_history"]
.as_array()
.unwrap()
.iter()
.map(|s| s.as_str().unwrap())
.collect();
assert_eq!(hist, vec!["running", "done"], "transitions in order");
assert_eq!(compile2["tail_len"], 4, "compile tail grew to 4");
let test = v2["steps"]
.as_array()
.unwrap()
.iter()
.find(|s| s["step_id"] == "test")
.unwrap();
assert_eq!(test["status"], "running");
assert_eq!(test["tail_len"], 1);
}
#[test]
fn auto_refresh_drains_inbox_and_advances_ticks() {
let build = "b-auto";
let mut tail = BuildInfoTail::new();
let tx = tail.poll_sender_for_test();
let v0 = tail.state_json();
assert_eq!(v0["tail_total"], 0);
assert_eq!(v0["poll_count"], 0);
assert_eq!(v0["live_tickets"].as_array().unwrap().len(), 0);
tx.send(vec![
step_row(build, "compile", 1, "running"),
log_row(build, "compile", 2, "Compiling holger v0.1"),
])
.unwrap();
tail.pump();
let v1 = tail.state_json();
assert_eq!(v1["tail_total"], 2, "first interval pull folded");
assert_eq!(v1["poll_count"], 1, "one tick");
tx.send(vec![
step_row(build, "compile", 3, "done"),
step_row(build, "test", 4, "running"),
])
.unwrap();
tail.pump();
let v2 = tail.state_json();
assert_eq!(v2["tail_total"], 4, "tail GREW after the second interval pull");
assert_eq!(v2["poll_count"], 2, "ticks advanced per interval pull");
let tickets = v2["live_tickets"].as_array().unwrap();
assert!(tickets.iter().any(|t| t["step_id"] == "compile"), "compile ticket");
assert!(tickets.iter().any(|t| t["step_id"] == "test"), "test ticket");
let compile = v2["steps"]
.as_array()
.unwrap()
.iter()
.find(|s| s["step_id"] == "compile")
.unwrap();
assert_eq!(compile["status"], "done", "status advanced with the pulls");
}
#[test]
fn empty_interval_pull_is_not_a_tick() {
let mut tail = BuildInfoTail::new();
let tx = tail.poll_sender_for_test();
tx.send(vec![]).unwrap();
tail.pump();
let v = tail.state_json();
assert_eq!(v["poll_count"], 0, "an empty pull is not a tick");
assert_eq!(v["tail_total"], 0);
}
#[test]
fn manifest_rows_counted_not_tailed() {
let mut tail = BuildInfoTail::new();
tail.ingest(&[
BuildInfoRow::new("b", 1, BuildInfoKind::Provenance).kv("repo", "holger"),
BuildInfoRow::new("b", 2, BuildInfoKind::Dep).kv("feature", "full"),
BuildInfoRow::new("b", 3, BuildInfoKind::Artifact).kv("target/x", "10"),
log_row("b", "compile", 4, "line"),
]);
let v = tail.state_json();
assert_eq!(v["manifest_rows"], 3, "3 manifest rows counted");
assert_eq!(v["step_count"], 1, "only the feed row made a sub-job");
assert_eq!(v["tail_total"], 1, "manifest rows not in the tail");
}
}