Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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);
    }
}