use std::collections::{HashMap, HashSet};
use std::sync::mpsc::{Receiver, Sender};
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Ecosystem {
Maven,
Pypi,
Rust,
Npm,
Nuget,
Go,
Gem,
Deb,
Rpm,
Helm,
Docker,
Conda,
Composer,
Other,
}
impl Ecosystem {
pub fn classify(repo: &str, artifact: &str) -> Ecosystem {
let r = repo.to_ascii_lowercase();
let has = |needles: &[&str]| needles.iter().any(|n| r.contains(n));
if has(&["maven", "mvn", "jar", "gradle"]) {
Ecosystem::Maven
} else if has(&["pypi", "python", "wheel", "pip"]) {
Ecosystem::Pypi
} else if has(&["rust", "crate", "cargo"]) {
Ecosystem::Rust
} else if has(&["npm", "node"]) {
Ecosystem::Npm
} else if has(&["nuget", "dotnet"]) {
Ecosystem::Nuget
} else if has(&["golang", "goproxy", "go-"]) || r == "go" {
Ecosystem::Go
} else if has(&["gem", "ruby"]) {
Ecosystem::Gem
} else if has(&["deb", "apt", "ubuntu", "debian"]) {
Ecosystem::Deb
} else if has(&["rpm", "yum", "dnf", "rocky", "fedora"]) {
Ecosystem::Rpm
} else if has(&["helm", "chart"]) {
Ecosystem::Helm
} else if has(&["docker", "oci", "image", "registry"]) {
Ecosystem::Docker
} else if has(&["conda", "anaconda"]) {
Ecosystem::Conda
} else if has(&["composer", "php", "packagist"]) {
Ecosystem::Composer
} else if artifact.contains(".crate") {
Ecosystem::Rust
} else if artifact.ends_with(".whl") || artifact.ends_with(".tar.gz") {
Ecosystem::Pypi
} else if artifact.ends_with(".jar") {
Ecosystem::Maven
} else {
Ecosystem::Other
}
}
pub fn label(self) -> &'static str {
match self {
Ecosystem::Maven => "maven",
Ecosystem::Pypi => "pypi",
Ecosystem::Rust => "rust",
Ecosystem::Npm => "npm",
Ecosystem::Nuget => "nuget",
Ecosystem::Go => "go",
Ecosystem::Gem => "gem",
Ecosystem::Deb => "deb",
Ecosystem::Rpm => "rpm",
Ecosystem::Helm => "helm",
Ecosystem::Docker => "docker",
Ecosystem::Conda => "conda",
Ecosystem::Composer => "composer",
Ecosystem::Other => "other",
}
}
pub fn upstream_host(self) -> Option<&'static str> {
Some(match self {
Ecosystem::Maven => "repo1.maven.org",
Ecosystem::Pypi => "pypi.org",
Ecosystem::Rust => "crates.io",
Ecosystem::Npm => "registry.npmjs.org",
Ecosystem::Nuget => "nuget.org",
Ecosystem::Go => "proxy.golang.org",
Ecosystem::Gem => "rubygems.org",
Ecosystem::Conda => "conda.anaconda.org",
Ecosystem::Composer => "packagist.org",
Ecosystem::Deb | Ecosystem::Rpm | Ecosystem::Helm | Ecosystem::Docker | Ecosystem::Other => {
return None
}
})
}
pub fn color(self) -> [u8; 3] {
match self {
Ecosystem::Maven => [224, 122, 95],
Ecosystem::Pypi => [75, 150, 220],
Ecosystem::Rust => [222, 165, 132],
Ecosystem::Npm => [203, 86, 85],
Ecosystem::Nuget => [120, 100, 210],
Ecosystem::Go => [80, 200, 220],
Ecosystem::Gem => [220, 70, 70],
Ecosystem::Deb => [200, 70, 110],
Ecosystem::Rpm => [90, 130, 200],
Ecosystem::Helm => [70, 170, 200],
Ecosystem::Docker => [70, 150, 225],
Ecosystem::Conda => [90, 180, 165],
Ecosystem::Composer => [150, 165, 205],
Ecosystem::Other => [160, 160, 172],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum LineageAction {
Download,
Upload,
Delete,
List,
Promote,
Other,
}
impl LineageAction {
pub fn as_str(self) -> &'static str {
match self {
LineageAction::Download => "download",
LineageAction::Upload => "upload",
LineageAction::Delete => "delete",
LineageAction::List => "list",
LineageAction::Promote => "promote",
LineageAction::Other => "other",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LineageEvent {
pub ts_nanos: i64,
pub ident: String,
pub action: LineageAction,
pub repo: String,
pub artifact: String,
pub source_ip: String,
pub status: u16,
pub bytes: u64,
pub upstream: Option<String>,
}
impl LineageEvent {
#[allow(clippy::too_many_arguments)]
pub fn new(
ts_nanos: i64,
ident: impl Into<String>,
action: LineageAction,
repo: impl Into<String>,
artifact: impl Into<String>,
source_ip: impl Into<String>,
status: u16,
bytes: u64,
) -> Self {
Self {
ts_nanos,
ident: ident.into(),
action,
repo: repo.into(),
artifact: artifact.into(),
source_ip: source_ip.into(),
status,
bytes,
upstream: None,
}
}
pub fn with_upstream(mut self, upstream: impl Into<String>) -> Self {
self.upstream = Some(upstream.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum NodeKind {
Artifact,
Repo,
Upstream,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EdgeKind {
RequestedBy,
ProxiedFrom,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LineageNode {
pub id: String,
pub label: String,
pub kind: NodeKind,
pub ecosystem: Ecosystem,
pub repo: String,
pub cached_at_nanos: i64,
pub size: u64,
pub upstream: Option<String>,
pub hits: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LineageEdge {
pub from: String,
pub to: String,
pub kind: EdgeKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TimeEvent {
pub ts_nanos: i64,
pub artifact: String,
pub ecosystem: Ecosystem,
pub repo: String,
pub action: LineageAction,
}
#[derive(Default, Serialize)]
pub struct LineageView {
pub nodes: Vec<LineageNode>,
pub edges: Vec<LineageEdge>,
pub events: Vec<TimeEvent>,
pub total_events: u64,
pub total_bytes: u64,
#[serde(skip)]
node_index: HashMap<String, usize>,
#[serde(skip)]
edge_seen: HashSet<(String, String)>,
#[serde(skip)]
rx: Option<Receiver<LineageEvent>>,
}
pub fn channel() -> (Sender<LineageEvent>, Receiver<LineageEvent>) {
std::sync::mpsc::channel()
}
impl LineageView {
pub fn new() -> Self {
Self::default()
}
pub fn connect(&mut self, rx: Receiver<LineageEvent>) {
self.rx = Some(rx);
}
pub fn is_live(&self) -> bool {
self.rx.is_some()
}
pub fn pump(&mut self) -> usize {
let drained: Vec<LineageEvent> = match &self.rx {
Some(rx) => rx.try_iter().collect(),
None => return 0,
};
let n = drained.len();
for ev in drained {
self.ingest(ev);
}
n
}
pub fn replay(&mut self, events: impl IntoIterator<Item = LineageEvent>) {
for ev in events {
self.ingest(ev);
}
}
pub fn artifact_count(&self) -> usize {
self.nodes.iter().filter(|n| n.kind == NodeKind::Artifact).count()
}
pub fn ingest(&mut self, ev: LineageEvent) {
self.total_events += 1;
self.total_bytes = self.total_bytes.saturating_add(ev.bytes);
if ev.artifact.is_empty() {
return; }
let eco = Ecosystem::classify(&ev.repo, &ev.artifact);
let upstream = ev
.upstream
.clone()
.or_else(|| eco.upstream_host().map(|h| h.to_string()));
let repo_id = format!("repo:{}", ev.repo);
self.upsert(&repo_id, || LineageNode {
id: repo_id.clone(),
label: ev.repo.clone(),
kind: NodeKind::Repo,
ecosystem: eco,
repo: ev.repo.clone(),
cached_at_nanos: ev.ts_nanos,
size: 0,
upstream: None,
hits: 0,
});
let art_id = format!("art:{}/{}", ev.repo, ev.artifact);
self.upsert(&art_id, || LineageNode {
id: art_id.clone(),
label: ev.artifact.clone(),
kind: NodeKind::Artifact,
ecosystem: eco,
repo: ev.repo.clone(),
cached_at_nanos: ev.ts_nanos,
size: ev.bytes,
upstream: upstream.clone(),
hits: 0,
});
if let Some(&i) = self.node_index.get(&art_id) {
let n = &mut self.nodes[i];
n.size = n.size.max(ev.bytes);
if n.upstream.is_none() {
n.upstream = upstream.clone();
}
}
self.add_edge(&art_id, &repo_id, EdgeKind::RequestedBy);
if let Some(host) = &upstream {
let up_id = format!("up:{host}");
self.upsert(&up_id, || LineageNode {
id: up_id.clone(),
label: host.clone(),
kind: NodeKind::Upstream,
ecosystem: eco,
repo: ev.repo.clone(),
cached_at_nanos: ev.ts_nanos,
size: 0,
upstream: None,
hits: 0,
});
self.add_edge(&art_id, &up_id, EdgeKind::ProxiedFrom);
}
self.events.push(TimeEvent {
ts_nanos: ev.ts_nanos,
artifact: ev.artifact,
ecosystem: eco,
repo: ev.repo,
action: ev.action,
});
}
fn upsert(&mut self, id: &str, make: impl FnOnce() -> LineageNode) -> bool {
if let Some(&i) = self.node_index.get(id) {
self.nodes[i].hits = self.nodes[i].hits.saturating_add(1);
false
} else {
let idx = self.nodes.len();
let mut node = make();
node.hits = 1;
self.nodes.push(node);
self.node_index.insert(id.to_string(), idx);
true
}
}
fn add_edge(&mut self, from: &str, to: &str, kind: EdgeKind) {
let key = (from.to_string(), to.to_string());
if self.edge_seen.insert(key) {
self.edges.push(LineageEdge {
from: from.to_string(),
to: to.to_string(),
kind,
});
}
}
pub fn state_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
pub fn demo_events() -> Vec<LineageEvent> {
const S: i64 = 1_000_000_000; let base = 1_700_000_000 * S;
let mk = |i: i64, action, repo: &str, artifact: &str, bytes| {
LineageEvent::new(base + i * S, "anonymous", action, repo, artifact, "10.0.0.7:5555", 200, bytes)
};
use LineageAction::{Download, Upload};
vec![
mk(0, Download, "rust-proxy", "serde@1.0.203", 92_160),
mk(1, Download, "pypi-proxy", "numpy-1.26.4.whl", 5_242_880),
mk(2, Download, "rust-proxy", "tokio@1.37.0", 720_896),
mk(3, Download, "maven-central", "org.slf4j/slf4j-api@2.0.12", 61_440),
mk(4, Download, "npm-proxy", "react@18.2.0", 311_296),
mk(5, Download, "rust-proxy", "serde@1.0.203", 92_160), mk(6, Download, "pypi-proxy", "requests-2.31.0.whl", 143_360),
mk(7, Download, "go-proxy", "golang.org/x/text@v0.14.0", 1_048_576),
mk(8, Download, "maven-central", "com.google.guava/guava@33.1.0", 2_883_584),
mk(9, Upload, "rust-dev", "holger-ui@0.1.2", 204_800),
mk(10, Download, "npm-proxy", "lodash@4.17.21", 552_960),
mk(11, Download, "rust-proxy", "tokio@1.37.0", 720_896), mk(12, Download, "pypi-proxy", "pandas-2.2.1.whl", 12_582_912),
mk(13, Download, "maven-central", "org.junit/junit-jupiter@5.10.2", 122_880),
mk(14, Download, "go-proxy", "github.com/gin-gonic/gin@v1.9.1", 819_200),
mk(15, Download, "rust-proxy", "anyhow@1.0.81", 45_056),
]
}
#[cfg(any(feature = "gui", test))]
mod audit_tap {
use super::*;
use server_lib::audit::{AuditAction, AuditEvent, AuditError, AuditLog, ArrowIpcAuditLog};
use std::path::Path;
impl From<AuditAction> for LineageAction {
fn from(a: AuditAction) -> Self {
match a {
AuditAction::Download => LineageAction::Download,
AuditAction::Upload => LineageAction::Upload,
AuditAction::Delete => LineageAction::Delete,
AuditAction::List => LineageAction::List,
AuditAction::Promote => LineageAction::Promote,
AuditAction::Other => LineageAction::Other,
}
}
}
impl From<&AuditEvent> for LineageEvent {
fn from(e: &AuditEvent) -> Self {
LineageEvent {
ts_nanos: e.ts_nanos,
ident: e.ident.clone(),
action: e.action.into(),
repo: e.repo.clone(),
artifact: e.artifact.clone(),
source_ip: e.source_ip.clone(),
status: e.status,
bytes: e.bytes,
upstream: None,
}
}
}
impl LineageView {
pub fn replay_audit_dir(&mut self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
let events = ArrowIpcAuditLog::read_dir(dir)
.map_err(|e: AuditError| anyhow::anyhow!("audit replay failed: {e}"))?;
let n = events.len();
for ae in &events {
self.ingest(LineageEvent::from(ae));
}
Ok(n)
}
}
pub struct ChannelAuditLog {
tx: Sender<LineageEvent>,
}
impl ChannelAuditLog {
pub fn new(tx: Sender<LineageEvent>) -> Self {
Self { tx }
}
}
impl AuditLog for ChannelAuditLog {
fn record(&self, event: AuditEvent) -> Result<(), AuditError> {
let _ = self.tx.send(LineageEvent::from(&event));
Ok(())
}
}
}
#[cfg(any(feature = "gui", test))]
pub use audit_tap::ChannelAuditLog;
#[cfg(test)]
mod tests {
use super::*;
use server_lib::audit::{AuditAction, AuditEvent, AuditLog};
#[cfg(feature = "testmatrix")]
fn fstatus(component: &str, check: &str, ok: bool, detail: &str) {
nornir_testmatrix::functional_status(component, check, ok, detail);
}
#[test]
fn audit_event_maps_and_derives_ecosystem_and_upstream() {
let ae = AuditEvent::new(
"anonymous",
AuditAction::Download,
"rust-proxy",
"serde@1.0.203",
"10.0.0.9:4444",
200,
92_160,
);
let mut view = LineageView::new();
view.ingest(LineageEvent::from(&ae));
let art = view
.nodes
.iter()
.find(|n| n.kind == NodeKind::Artifact)
.expect("artifact node");
let eco_ok = art.ecosystem == Ecosystem::Rust;
let up_ok = art.upstream.as_deref() == Some("crates.io");
assert!(eco_ok, "repo 'rust-proxy' derives the rust ecosystem: {:?}", art.ecosystem);
assert!(up_ok, "rust upstream derived as crates.io: {:?}", art.upstream);
assert_eq!(view.edges.len(), 2, "two lineage edges wired");
assert!(view
.edges
.iter()
.any(|e| e.kind == EdgeKind::RequestedBy && e.to == "repo:rust-proxy"));
assert!(view
.edges
.iter()
.any(|e| e.kind == EdgeKind::ProxiedFrom && e.to == "up:crates.io"));
#[cfg(feature = "testmatrix")]
fstatus(
"holger-ui",
"lineage_audit_map_derive",
eco_ok && up_ok && view.edges.len() == 2,
&format!(
"AuditEvent→LineageEvent: eco={} upstream={:?} edges={}",
art.ecosystem.label(),
art.upstream,
view.edges.len()
),
);
}
#[test]
fn channel_audit_tap_streams_live_into_view() {
let (tx, rx) = channel();
let mut view = LineageView::new();
view.connect(rx);
assert!(view.is_live());
let sink: std::sync::Arc<dyn AuditLog> = std::sync::Arc::new(ChannelAuditLog::new(tx));
sink.record(AuditEvent::new("anon", AuditAction::Download, "rust-proxy", "serde@1.0.203", "ip:1", 200, 92_160))
.unwrap();
sink.record(AuditEvent::new("anon", AuditAction::Download, "pypi-proxy", "numpy-1.26.4.whl", "ip:2", 200, 5_242_880))
.unwrap();
sink.record(AuditEvent::new("anon", AuditAction::Download, "rust-proxy", "serde@1.0.203", "ip:3", 200, 92_160))
.unwrap();
let drained = view.pump();
assert_eq!(drained, 3, "pump drained all three recorded events");
let s = view.state_json();
assert_eq!(s["total_events"].as_u64(), Some(3), "state_json event tally: {s}");
assert_eq!(view.artifact_count(), 2, "serde deduped across two hits");
let serde = view
.nodes
.iter()
.find(|n| n.label == "serde@1.0.203")
.expect("serde artifact node");
assert!(serde.hits >= 2, "serde touched twice (fill + cache hit): {}", serde.hits);
let ecos: Vec<&str> = view
.nodes
.iter()
.filter(|n| n.kind == NodeKind::Artifact)
.map(|n| n.ecosystem.label())
.collect();
assert!(ecos.contains(&"rust") && ecos.contains(&"pypi"), "both clusters: {ecos:?}");
#[cfg(feature = "testmatrix")]
fstatus(
"holger-ui",
"lineage_channel_tap_live",
drained == 3 && view.artifact_count() == 2 && serde.hits >= 2,
&format!(
"ChannelAuditLog tap → pump: {drained} events, {} artifacts, serde hits {}",
view.artifact_count(),
serde.hits
),
);
}
#[test]
fn replay_from_audit_dir_round_trips() {
use server_lib::audit::ArrowIpcAuditLog;
let dir = std::env::temp_dir().join(format!("holger-lineage-replay-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
{
let log = ArrowIpcAuditLog::new(&dir).expect("open audit segment");
log.record(AuditEvent::new("anon", AuditAction::Download, "rust-proxy", "serde@1.0.203", "ip:1", 200, 92_160))
.unwrap();
log.record(AuditEvent::new("anon", AuditAction::Upload, "maven-central", "org.slf4j/slf4j-api@2.0.12", "ip:2", 200, 61_440))
.unwrap();
}
let mut view = LineageView::new();
let n = view.replay_audit_dir(&dir).expect("replay");
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(n, 2, "replayed both recorded events");
assert_eq!(view.artifact_count(), 2, "two cached artifacts after replay");
assert_eq!(view.events.len(), 2, "two time events after replay");
let ecos: HashSet<&str> = view
.nodes
.iter()
.filter(|n| n.kind == NodeKind::Artifact)
.map(|n| n.ecosystem.label())
.collect();
assert!(ecos.contains(&"rust") && ecos.contains(&"maven"), "replayed clusters: {ecos:?}");
#[cfg(feature = "testmatrix")]
fstatus(
"holger-ui",
"lineage_replay_audit_dir",
n == 2 && view.artifact_count() == 2 && view.events.len() == 2,
&format!("replay_audit_dir: {n} events → {} artifacts", view.artifact_count()),
);
}
#[test]
fn demo_events_fill_and_dedup() {
let mut view = LineageView::new();
view.replay(demo_events());
assert_eq!(view.total_events as usize, demo_events().len(), "every demo event counted");
assert!(view.artifact_count() < demo_events().len(), "repeat hits deduped");
let clusters: HashSet<&str> = view
.nodes
.iter()
.filter(|n| n.kind == NodeKind::Artifact)
.map(|n| n.ecosystem.label())
.collect();
assert!(clusters.len() >= 4, "multi-ecosystem fill: {clusters:?}");
let s = view.state_json();
assert!(s["nodes"].as_array().map(|a| !a.is_empty()).unwrap_or(false));
assert!(s["edges"].as_array().map(|a| !a.is_empty()).unwrap_or(false));
#[cfg(feature = "testmatrix")]
fstatus(
"holger-ui",
"lineage_demo_fill",
view.artifact_count() < demo_events().len() && clusters.len() >= 4,
&format!("demo fill: {} events → {} artifacts, {} clusters", view.total_events, view.artifact_count(), clusters.len()),
);
}
#[test]
fn classify_covers_ecosystems_and_falls_back() {
let by_repo = [
("maven-central", Ecosystem::Maven),
("pypi-proxy", Ecosystem::Pypi),
("rust-crates", Ecosystem::Rust),
("npm-proxy", Ecosystem::Npm),
("nuget-feed", Ecosystem::Nuget),
("go-proxy", Ecosystem::Go),
("ruby-gems", Ecosystem::Gem),
("debian-apt", Ecosystem::Deb),
("rpm-yum", Ecosystem::Rpm),
("helm-charts", Ecosystem::Helm),
("docker-registry", Ecosystem::Docker),
("conda-forge", Ecosystem::Conda),
("composer-packagist", Ecosystem::Composer),
];
for (repo, want) in by_repo {
assert_eq!(Ecosystem::classify(repo, "whatever"), want, "repo {repo:?}");
}
assert_eq!(Ecosystem::classify("mirror", "foo-0.1.0.crate"), Ecosystem::Rust);
assert_eq!(Ecosystem::classify("mirror", "numpy-1.26.4.whl"), Ecosystem::Pypi);
assert_eq!(Ecosystem::classify("mirror", "slf4j-api-2.0.12.jar"), Ecosystem::Maven);
let other = Ecosystem::classify("mirror", "readme.txt");
assert_eq!(other, Ecosystem::Other);
assert_eq!(other.label(), "other");
#[cfg(feature = "testmatrix")]
fstatus(
"holger-ui",
"lineage_classify_matrix",
by_repo.iter().all(|(r, w)| Ecosystem::classify(r, "x") == *w)
&& Ecosystem::classify("mirror", "readme.txt") == Ecosystem::Other,
"classify: 13 ecosystems by repo + artifact-shape fallback + Other",
);
}
#[test]
fn explicit_upstream_overrides_derived() {
let mut view = LineageView::new();
view.ingest(
LineageEvent::new(1, "anon", LineageAction::Download, "rust-proxy", "serde@1.0", "ip:1", 200, 10)
.with_upstream("mirror.corp.internal"),
);
let art = view.nodes.iter().find(|n| n.kind == NodeKind::Artifact).expect("artifact");
assert_eq!(art.upstream.as_deref(), Some("mirror.corp.internal"), "explicit upstream wins");
assert!(
view.edges.iter().any(|e| e.kind == EdgeKind::ProxiedFrom && e.to == "up:mirror.corp.internal"),
"proxied-from edge targets the explicit mirror, not crates.io: {:?}",
view.edges
);
assert!(
!view.edges.iter().any(|e| e.to == "up:crates.io"),
"the derived crates.io upstream is not used when one is supplied",
);
#[cfg(feature = "testmatrix")]
fstatus(
"holger-ui",
"lineage_explicit_upstream",
art.upstream.as_deref() == Some("mirror.corp.internal"),
"explicit LineageEvent.upstream overrides the ecosystem-derived registry",
);
}
#[test]
fn no_upstream_ecosystem_wires_only_requested_by() {
let mut view = LineageView::new();
view.ingest(LineageEvent::new(1, "anon", LineageAction::Download, "helm-charts", "njord-0.1.0.tgz", "ip:1", 200, 4096));
let art = view.nodes.iter().find(|n| n.kind == NodeKind::Artifact).expect("artifact");
assert_eq!(art.ecosystem, Ecosystem::Helm);
assert!(art.upstream.is_none(), "helm has no canonical upstream host");
assert!(Ecosystem::Helm.upstream_host().is_none());
assert_eq!(view.edges.len(), 1, "only the requested-by edge: {:?}", view.edges);
assert_eq!(view.edges[0].kind, EdgeKind::RequestedBy);
assert!(!view.nodes.iter().any(|n| n.kind == NodeKind::Upstream), "no phantom upstream node");
#[cfg(feature = "testmatrix")]
fstatus(
"holger-ui",
"lineage_no_upstream",
art.upstream.is_none() && view.edges.len() == 1 && !view.nodes.iter().any(|n| n.kind == NodeKind::Upstream),
"helm arrival: requested-by only, no derived proxied-from",
);
}
#[test]
fn bare_list_event_counts_but_adds_no_node() {
let mut view = LineageView::new();
view.ingest(LineageEvent::new(1, "anon", LineageAction::List, "rust-proxy", "", "ip:1", 200, 512));
assert_eq!(view.total_events, 1, "the list is tallied");
assert_eq!(view.total_bytes, 512, "its bytes are tallied");
assert!(view.nodes.is_empty(), "no node for a bare listing");
assert!(view.edges.is_empty(), "no edge for a bare listing");
assert!(view.events.is_empty(), "no time-event for a bare listing");
#[cfg(feature = "testmatrix")]
fstatus(
"holger-ui",
"lineage_bare_list",
view.total_events == 1 && view.nodes.is_empty() && view.events.is_empty(),
"bare list arrival counted but adds no cache node",
);
}
#[test]
fn incremental_pump_pops_nodes_in_across_frames() {
let (tx, rx) = channel();
let mut view = LineageView::new();
view.connect(rx);
tx.send(LineageEvent::new(1, "a", LineageAction::Download, "rust-proxy", "serde@1.0", "ip:1", 200, 10)).unwrap();
assert_eq!(view.pump(), 1, "first pump drains one");
assert_eq!(view.artifact_count(), 1, "one artifact popped in");
assert_eq!(view.pump(), 0, "empty pump");
assert_eq!(view.artifact_count(), 1);
tx.send(LineageEvent::new(2, "a", LineageAction::Download, "pypi-proxy", "numpy.whl", "ip:1", 200, 20)).unwrap();
tx.send(LineageEvent::new(3, "a", LineageAction::Download, "npm-proxy", "react", "ip:1", 200, 30)).unwrap();
assert_eq!(view.pump(), 2, "third pump drains the two");
assert_eq!(view.artifact_count(), 3, "graph grew to three artifacts");
drop(tx);
assert_eq!(view.pump(), 0, "disconnected sender pumps empty, no panic");
let art_ids: Vec<&str> = view.nodes.iter().filter(|n| n.kind == NodeKind::Artifact).map(|n| n.label.as_str()).collect();
assert_eq!(art_ids, ["serde@1.0", "numpy.whl", "react"], "first-seen pop-in order");
#[cfg(feature = "testmatrix")]
fstatus(
"holger-ui",
"lineage_incremental_pump",
view.artifact_count() == 3 && art_ids == ["serde@1.0", "numpy.whl", "react"],
"live feed grows the graph incrementally in first-seen order",
);
}
}