crates/nornir-wire/src/view/timeline.rs
//! The **timeline** view-model — the `Viz.Timeline` RPC payload.
//!
//! Pure serde data (no egui, no warehouse handle): the server's `build_timeline`
//! producer fills it from the warehouse and serializes it; the read-client
//! deserializes it here. Relocated verbatim from `nornir/src/viz/model.rs` (its
//! struct half) — the producer functions (`build_timeline`/`load_timeline`) stay
//! in nornir, importing these types from here via a `pub use` re-export.
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use nornir_depgraph::DepGraphSnapshot;
use uuid::Uuid;
/// One node on a repo's swim lane — a single recorded release of
/// that repo, at a given git SHA, with the gate verdict.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LaneNode {
pub release_id: Uuid,
pub timestamp: DateTime<Utc>,
pub sha: String,
pub branch: String,
pub dirty: bool,
pub gate_status: String,
pub tests_passed: u32,
pub tests_failed: u32,
pub published_versions: Vec<(String, String)>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Lane {
pub repo: String,
pub nodes: Vec<LaneNode>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BenchPoint {
pub timestamp: DateTime<Utc>,
/// Primary scalar plotted on the sparkline (the largest numeric
/// metric on the first result — same heuristic across repos so
/// every lane plots something).
pub primary_metric_name: String,
pub primary_metric_value: f64,
/// All numeric metrics across all results, flattened for display.
pub metrics: Vec<(String, f64)>,
pub version: String,
pub machine: String,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct BenchHistory {
pub repo: String,
pub points: Vec<BenchPoint>,
}
impl BenchHistory {
pub fn min_max(&self, metric: Option<&str>) -> Option<(f64, f64)> {
let vals: Vec<f64> = self
.points
.iter()
.filter_map(|p| match metric {
None => Some(p.primary_metric_value),
Some(name) => p.metrics.iter().find(|(n, _)| n == name).map(|(_, v)| *v),
})
.collect();
if vals.is_empty() {
return None;
}
let mn = vals.iter().cloned().fold(f64::INFINITY, f64::min);
let mx = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
Some((mn, mx))
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct Timeline {
pub workspace_name: String,
pub lanes: Vec<Lane>,
/// All releases keyed by id, in chronological order.
pub release_order: Vec<Uuid>,
/// release_id → snapshot id pinned at release time.
pub release_snapshot: BTreeMap<Uuid, Uuid>,
/// snapshot_id → loaded snapshot.
pub snapshots: BTreeMap<Uuid, DepGraphSnapshot>,
/// Most recent snapshot — used when nothing is selected.
pub latest_snapshot: Option<DepGraphSnapshot>,
/// Per-repo benchmark history, ordered by timestamp.
pub bench_history: BTreeMap<String, BenchHistory>,
}
impl Timeline {
pub fn is_empty(&self) -> bool {
self.lanes.iter().all(|l| l.nodes.is_empty())
&& self.bench_history.values().all(|h| h.points.is_empty())
}
pub fn has_releases(&self) -> bool {
!self.release_order.is_empty()
}
pub fn has_benches(&self) -> bool {
self.bench_history.values().any(|h| !h.points.is_empty())
}
/// Snapshot pinned to `release_id`, falling back to the latest
/// loaded snapshot if the release pre-dates lineage tracking.
pub fn snapshot_for(&self, release_id: &Uuid) -> Option<&DepGraphSnapshot> {
self.release_snapshot
.get(release_id)
.and_then(|sid| self.snapshots.get(sid))
.or(self.latest_snapshot.as_ref())
}
}
#[cfg(test)]
mod tests {
use super::*;
use nornir_depgraph::CrossRepoEdge;
use std::collections::BTreeSet;
/// The viz `Timeline` must survive a JSON round-trip — that's the wire
/// contract for the `Viz.Timeline` RPC (server serializes, client decodes).
/// Covers Uuid, DateTime<Utc>, nested DepGraphSnapshot/CrossRepoEdge. Relocated
/// with the struct from `nornir/src/viz/model.rs` — the wire shape now proves
/// itself in the leaf that owns it.
#[test]
fn timeline_json_roundtrip() {
let sid = Uuid::new_v4();
let rid = Uuid::new_v4();
let now = Utc::now();
let snap = DepGraphSnapshot {
snapshot_id: sid,
workspace_name: "ws".into(),
timestamp: now,
edges: vec![CrossRepoEdge::normal(
"a",
"b",
BTreeSet::from(["shared".to_string()]),
)],
};
let mut snapshots = BTreeMap::new();
snapshots.insert(sid, snap.clone());
let mut release_snapshot = BTreeMap::new();
release_snapshot.insert(rid, sid);
let mut bench_history = BTreeMap::new();
bench_history.insert(
"a".to_string(),
BenchHistory {
repo: "a".into(),
points: vec![BenchPoint {
timestamp: now,
primary_metric_name: "ops_sec".into(),
primary_metric_value: 1234.5,
metrics: vec![("x.ops_sec".into(), 1234.5)],
version: "0.1.0".into(),
machine: "m".into(),
}],
},
);
let tl = Timeline {
workspace_name: "ws".into(),
lanes: vec![Lane {
repo: "a".into(),
nodes: vec![LaneNode {
release_id: rid,
timestamp: now,
sha: "deadbeef".into(),
branch: "main".into(),
dirty: false,
gate_status: "succeeded".into(),
tests_passed: 3,
tests_failed: 0,
published_versions: vec![("a".into(), "0.1.0".into())],
}],
}],
release_order: vec![rid],
release_snapshot,
snapshots,
latest_snapshot: Some(snap),
bench_history,
};
let json = serde_json::to_string(&tl).expect("serialize");
let back: Timeline = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.workspace_name, "ws");
assert_eq!(back.lanes.len(), 1);
assert_eq!(back.lanes[0].nodes[0].release_id, rid);
assert_eq!(back.snapshots[&sid].edges[0].from, "a");
assert_eq!(back.release_snapshot[&rid], sid);
assert_eq!(back.bench_history["a"].points[0].primary_metric_value, 1234.5);
}
}