use funnel_core::plan_dag::{DagStatus, PlanDag};
use facett_plandag::{Metrics, Msg, OpStatus, PlanDagView, PlanEdge, PlanModel, PlanNode, PlanVersion};
fn op_status(s: DagStatus) -> OpStatus {
match s {
DagStatus::Idle => OpStatus::Idle,
DagStatus::Running => OpStatus::Running,
DagStatus::Done => OpStatus::Done,
DagStatus::Failed => OpStatus::Failed,
}
}
#[derive(Default)]
pub struct FunnelPlanDag {
view: Option<PlanDagView>,
built_for: Option<Option<String>>,
node_count: usize,
edge_count: usize,
}
impl FunnelPlanDag {
pub fn node_count(&self) -> usize {
self.node_count
}
pub fn edge_count(&self) -> usize {
self.edge_count
}
pub fn is_built_for(&self, sel_plan: Option<&str>) -> bool {
self.built_for.as_ref().map(|b| b.as_deref()) == Some(sel_plan)
}
pub fn build(&mut self, dag: &PlanDag, sel_plan: Option<&str>) {
let idx: std::collections::HashMap<&str, usize> =
dag.nodes.iter().enumerate().map(|(i, n)| (n.id.as_str(), i)).collect();
let nodes: Vec<PlanNode> = dag
.nodes
.iter()
.map(|n| {
let mut pn = PlanNode::new(n.id.clone(), n.title.clone())
.status(op_status(n.status))
.metrics(Metrics {
time_ms: n.staleness_hours,
peak_mem: n.weight_bytes,
rows: n.fanout,
spill: 0,
});
if let Some(g) = &n.group {
pn = pn.group(g.clone());
}
if n.is_bug {
pn = pn.photon();
}
pn
})
.collect();
let edges: Vec<PlanEdge> = dag
.edges
.iter()
.filter_map(|e| {
let (Some(&from), Some(&to)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) else {
return None;
};
Some(PlanEdge { from, to })
})
.collect();
self.node_count = nodes.len();
self.edge_count = edges.len();
let version = PlanVersion::new("funnel", nodes, edges);
self.view = Some(PlanDagView::new("funnel-plandag", PlanModel::new(version)));
self.built_for = Some(sel_plan.map(str::to_string));
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(
"viz/funnel_plandag (facett-plandag)",
"funnel_plandag_built",
true,
&format!(
"nodes={} edges={} scope={}",
self.node_count,
self.edge_count,
sel_plan.unwrap_or("<all>")
),
);
}
pub fn toggle_group(&mut self, group: &str) {
if let Some(v) = &mut self.view {
let _ = v.update(Msg::ToggleGroup(group.to_string()));
}
}
pub fn ui(&mut self, ui: &mut egui::Ui) {
use facett_core::Facet;
match &mut self.view {
Some(v) => v.ui(ui),
None => {
ui.centered_and_justified(|ui| {
ui.weak("select a plan to render its Plan-DAG");
});
}
}
}
pub fn state_json(&self) -> serde_json::Value {
use facett_core::Facet;
match &self.view {
Some(v) => {
let mut j = v.state_json();
if let Some(obj) = j.as_object_mut() {
obj.insert(
"built_for".into(),
serde_json::json!(self.built_for.clone().flatten()),
);
}
j
}
None => serde_json::json!({
"node_count": 0,
"edge_count": 0,
"nodes": [],
"built_for": null,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use funnel_core::event::{Event, ItemKind, PlanStatus};
use funnel_core::ids::{IdeaId, NodeId, PlanId};
use funnel_core::state::Funnel;
fn funnel_with_plan() -> Funnel {
let mut f = Funnel::default();
let now = chrono::Utc::now();
let idea = IdeaId::seq(1);
let plan = PlanId::seq(1);
f.apply(&Event::IdeaSubmitted {
id: idea.clone(),
source: "human:rickard".into(),
text: "planner".into(),
refs: vec![],
item_kind: ItemKind::Idea,
ts: now,
})
.unwrap();
f.apply(&Event::PlanCreated {
id: plan.clone(),
idea_id: idea.clone(),
summary: "decompose".into(),
planner: "decompose".into(),
ts: now,
})
.unwrap();
f.apply(&Event::PlanStatusChanged { plan_id: plan.clone(), status: PlanStatus::Active, why: None, ts: now })
.unwrap();
for i in 1..=2u64 {
f.apply(&Event::NodeAdded {
plan_id: plan.clone(),
node_id: NodeId::seq(i),
kind: "code:write".into(),
params: Default::default(),
targets: vec![],
prompt_excerpt: Some(format!("task {i}")),
ts: now,
})
.unwrap();
}
f.apply(&Event::EdgeAdded { plan_id: plan.clone(), from_node: NodeId::seq(1), to_node: NodeId::seq(2), ts: now })
.unwrap();
f
}
#[test]
fn build_maps_plan_dag_onto_facett_model() {
let f = funnel_with_plan();
let dag = PlanDag::from_funnel(&f, None);
let mut pane = FunnelPlanDag::default();
pane.build(&dag, None);
assert_eq!(pane.node_count(), 3);
assert_eq!(pane.edge_count(), 3);
assert!(pane.is_built_for(None));
assert!(!pane.is_built_for(Some("p-001")));
let j = pane.state_json();
assert_eq!(j["node_count"], 3);
assert_eq!(j["edge_count"], 3);
for n in j["nodes"].as_array().unwrap() {
let h = n["heat"].as_f64().unwrap();
assert!((0.0..=1.0).contains(&h), "heat in range: {h}");
}
}
#[test]
fn collapsing_a_plan_group_folds_its_task_nodes() {
let f = funnel_with_plan();
let dag = PlanDag::from_funnel(&f, None);
let mut pane = FunnelPlanDag::default();
pane.build(&dag, None);
let before = pane.state_json()["node_count"].as_u64().unwrap();
pane.toggle_group("p-001");
let after = pane.state_json()["node_count"].as_u64().unwrap();
assert!(after < before, "collapsing plan p-001 folds its task nodes into a super-node");
let st = pane.state_json();
assert_eq!(st["collapsed"], serde_json::json!(["p-001"]));
}
#[test]
fn unbuilt_pane_has_a_shaped_empty_state() {
let pane = FunnelPlanDag::default();
let j = pane.state_json();
assert_eq!(j["node_count"], 0);
assert!(j["nodes"].as_array().unwrap().is_empty());
}
}