use std::sync::Arc;
use eframe::egui::{self};
use facett_core::Facet;
use facett_graphview::{MetroLine, MetroMap, MetroStation, MetroView, StationKind};
use ::dwarves::brokkr::PipelineDescriptor;
use ::dwarves::brokkr::dag::Dag;
use ::dwarves::brokkr::elm::{Brokkr, BrokkrEffect, BrokkrMsg};
use ::dwarves::brokkr::engine::RunRequest;
use ::dwarves::brokkr::facets::{AgentsFacet, DagFacet, LogFacet, TreeFacet};
use ::dwarves::brokkr::model::Pipeline;
use ::dwarves::brokkr::runner::StepRunner;
use super::facett_theme::Theme;
use super::facett_ui;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrokkrView {
Results,
Tree,
Dag,
Agents,
Log,
Build,
}
impl BrokkrView {
fn label(self) -> &'static str {
match self {
BrokkrView::Results => "Results",
BrokkrView::Tree => "Tree",
BrokkrView::Dag => "DAG",
BrokkrView::Agents => "Agents",
BrokkrView::Log => "Log",
BrokkrView::Build => "Build",
}
}
fn id(self) -> &'static str {
self.label()
}
const ALL: [BrokkrView; 6] = [
BrokkrView::Results,
BrokkrView::Tree,
BrokkrView::Dag,
BrokkrView::Agents,
BrokkrView::Log,
BrokkrView::Build,
];
}
const DEMO_PIPELINE: &str = r#"
[pipeline]
name = "nornir-ci"
[[pipeline.trigger]]
kind = "webhook"
events = ["push"]
branch = "main"
[[pipeline.stage]]
name = "build"
[[pipeline.stage.step]]
name = "compile"
run = { variant = "server" }
artifacts = ["target/release/nornir"]
[[pipeline.stage]]
name = "test"
[[pipeline.stage.step]]
name = "unit"
needs = ["compile"]
run = { container = { name = "rust:1.85", cmd = ["cargo", "test"] } }
report = "junit"
[[pipeline.stage.step]]
name = "lint"
needs = ["compile"]
run = { container = { name = "rust:1.85", cmd = ["cargo", "clippy"] } }
[[pipeline.stage]]
name = "ship"
[[pipeline.stage.step]]
name = "release"
needs = ["unit", "lint"]
run = { variant = "server" }
"#;
const FALLBACK: &str = r#"
[pipeline]
name = "brokkr"
[[pipeline.stage]]
name = "build"
[[pipeline.stage.step]]
name = "compile"
run = { variant = "server" }
"#;
pub struct BrokkrTabState {
view: BrokkrView,
results: Brokkr,
tree: TreeFacet,
dag_facet: DagFacet,
metro: MetroView,
agents: AgentsFacet,
log: LogFacet,
pipeline: Pipeline,
dag: Dag,
runner: Arc<dyn StepRunner>,
run_seq: u64,
palette: Theme,
builder: facett_brokkr::PipelineBuilder,
load_status: Option<Result<String, String>>,
}
impl Default for BrokkrTabState {
fn default() -> Self {
Self::from_toml(DEMO_PIPELINE, Arc::new(crate::brokkr::DemoRunner))
}
}
impl BrokkrTabState {
pub fn new() -> Self {
Self::default()
}
pub fn from_toml(toml: &str, runner: Arc<dyn StepRunner>) -> Self {
let desc = PipelineDescriptor::from_toml_str(toml)
.unwrap_or_else(|_| PipelineDescriptor::from_toml_str(FALLBACK).expect("fallback parses"));
let pipeline = desc.pipeline;
let dag = Dag::build(&pipeline).unwrap_or_else(|_| Dag { order: Vec::new() });
let results = Brokkr::new(vec![pipeline.name.clone()], &[]);
let tree = TreeFacet::from_pipeline("nornir", &pipeline);
let dag_facet = DagFacet::from_dag(&dag);
let mut metro = MetroView::new("⚒ Brokkr DAG");
metro.set_map(metro_map(&pipeline, &dag_facet));
Self {
view: BrokkrView::Results,
results,
tree,
dag_facet,
metro,
agents: AgentsFacet::demo(),
log: LogFacet::with_capacity(200),
pipeline,
dag,
runner,
run_seq: 0,
palette: Theme::default(),
builder: facett_brokkr::PipelineBuilder::from_toml_str("brokkr.toml", toml),
load_status: None,
}
}
pub fn apply_toml(&mut self, toml: &str) -> Result<String, String> {
let desc = PipelineDescriptor::from_toml_str(toml).map_err(|e| format!("parse: {e}"))?;
desc.validate().map_err(|e| format!("invalid: {e}"))?;
let pipeline = desc.pipeline;
let dag = Dag::build(&pipeline).map_err(|e| format!("dag: {e}"))?;
self.results = Brokkr::new(vec![pipeline.name.clone()], &[]);
self.tree = TreeFacet::from_pipeline("nornir", &pipeline);
self.dag_facet = DagFacet::from_dag(&dag);
self.metro.set_map(metro_map(&pipeline, &self.dag_facet));
self.log = LogFacet::with_capacity(200);
self.run_seq = 0;
let msg = format!(
"loaded '{}' — {} stage(s), {} step(s)",
pipeline.name,
pipeline.stages.len(),
dag.order.len()
);
self.pipeline = pipeline;
self.dag = dag;
Ok(msg)
}
#[doc(hidden)]
pub fn set_runner_for_test(&mut self, runner: Arc<dyn StepRunner>) {
self.runner = runner;
}
pub fn set_palette(&mut self, palette: Theme) {
self.palette = palette;
}
pub fn run_demo(&mut self) {
let name = self.pipeline.name.clone();
self.dispatch(BrokkrMsg::Run(name));
}
pub fn set_view_by_label(&mut self, label: &str) {
if let Some(v) = BrokkrView::ALL.into_iter().find(|v| v.label() == label) {
self.view = v;
}
}
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"surface": "brokkr",
"pipeline": self.pipeline.name,
"pipelines": self.results.state().pipelines,
"stages": self.pipeline.stages.len(),
"steps": self.dag.order.len(),
"view": self.view.id(),
"green_count": self.results.green_count(),
"model": self.results.state_json(),
"results": self.results.state_json(),
"tree": self.tree.state_json(),
"dag": self.dag_facet.state_json(),
"agents": self.agents.state_json(),
"log": self.log.state_json(),
"build": self.builder.state_json(),
})
}
fn dispatch(&mut self, msg: BrokkrMsg) {
let effects = self.results.update(msg);
for effect in effects {
match effect {
BrokkrEffect::RunPipeline(name) => self.run_pipeline(&name),
}
}
}
fn run_pipeline(&mut self, name: &str) {
if name != self.pipeline.name {
return;
}
self.run_seq += 1;
let run_id = format!("run-{}", self.run_seq);
let agent_name = self
.agents
.pool
.pick(&["cargo".to_string()])
.map(|a| a.name.clone());
if let Some(a) = &agent_name {
self.agents.set_busy(a, &run_id);
}
let req = RunRequest {
project: "nornir".to_string(),
trigger: "manual".to_string(),
sha: None,
matrix: Default::default(),
branch: Some("main".to_string()),
event: Some("push".to_string()),
};
if let Ok(report) =
::dwarves::brokkr::run_pipeline(&self.pipeline, &self.dag, self.runner.as_ref(), &req, &run_id)
{
self.tree.apply_run(&report);
self.dag_facet.apply_run(&report);
self.log.apply_run(&report);
self.metro.set_map(metro_map(&self.pipeline, &self.dag_facet));
self.results.update(BrokkrMsg::RunFinished(report.run));
}
if let Some(a) = &agent_name {
self.agents.set_idle(a);
}
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
ui.push_id("Brokkr", |ui| {
ui.heading("⚒ Brokkr — CI / build pipelines");
ui.label(format!(
"Pipeline '{}' — {} stage(s), {} step(s).",
self.pipeline.name,
self.pipeline.stages.len(),
self.dag.order.len()
));
ui.horizontal(|ui| {
for v in BrokkrView::ALL {
ui.selectable_value(&mut self.view, v, v.label());
}
});
ui.separator();
match self.view {
BrokkrView::Results => self.draw_results(ui),
BrokkrView::Tree => self.draw_tree(ui),
BrokkrView::Dag => self.draw_dag(ui),
BrokkrView::Agents => self.draw_agents(ui),
BrokkrView::Log => self.draw_log(ui),
BrokkrView::Build => self.draw_build(ui),
}
});
}
fn draw_build(&mut self, ui: &mut egui::Ui) {
ui.label("Author a brokkr.toml here — by raw text or by click — then load it as the live pipeline.");
ui.horizontal(|ui| {
if ui.button("✓ Validate & Load").clicked() {
let toml = self.builder.pipeline_toml();
self.load_status = Some(self.apply_toml(&toml));
}
match &self.load_status {
Some(Ok(msg)) => {
ui.label(egui::RichText::new(format!("✔ {msg}")).color(super::facett_theme::GREEN));
}
Some(Err(e)) => {
ui.label(egui::RichText::new(format!("✖ {e}")).color(super::facett_theme::RED));
}
None => {}
}
});
ui.separator();
self.builder.ui(ui);
}
fn draw_results(&mut self, ui: &mut egui::Ui) {
let theme = self.palette;
let pipelines = self.results.state().pipelines.clone();
let mut to_run: Option<String> = None;
ui.horizontal(|ui| {
for name in &pipelines {
if ui.button(format!("Run {name}")).clicked() {
to_run = Some(name.clone());
}
}
});
if let Some(name) = to_run {
self.dispatch(BrokkrMsg::Run(name));
}
ui.add_space(6.0);
ui.label(egui::RichText::new("Runs").strong());
let runs = self.results.state().runs.clone();
if runs.is_empty() {
ui.label("No runs yet — press a Run button to start the pipeline.");
} else {
egui::Grid::new("brokkr_runs").striped(true).num_columns(5).show(ui, |ui| {
for h in ["status", "pipeline", "trigger", "steps", "duration"] {
ui.label(egui::RichText::new(h).strong());
}
ui.end_row();
for r in &runs {
let (chip, color) = facett_ui::status_chip(&theme, r.status.label());
ui.label(egui::RichText::new(chip).color(color));
ui.label(&r.pipeline);
ui.label(&r.trigger);
ui.label(&r.steps);
ui.label(&r.duration);
ui.end_row();
}
});
}
}
fn draw_tree(&mut self, ui: &mut egui::Ui) {
let theme = self.palette;
ui.label(egui::RichText::new(format!("{} / {}", self.tree.project, self.tree.pipeline)).strong());
for stage in &self.tree.stages {
ui.label(egui::RichText::new(format!("▸ {}", stage.name)).strong());
for step in &stage.steps {
let (chip, color) = facett_ui::status_chip(&theme, &step.status);
ui.horizontal(|ui| {
ui.add_space(16.0);
ui.label(egui::RichText::new(chip).color(color));
ui.label(&step.name);
});
}
}
}
fn draw_dag(&mut self, ui: &mut egui::Ui) {
ui.label(format!(
"{} node(s), {} edge(s) — {}",
self.dag_facet.nodes.len(),
self.dag_facet.edges.len(),
if self.dag_facet.all_green() { "all green ✓" } else { "not yet all green" }
));
ui.separator();
Facet::ui(&mut self.metro, ui);
}
fn draw_agents(&mut self, ui: &mut egui::Ui) {
let theme = self.palette;
ui.label(format!("{} idle, {} busy", self.agents.idle_count(), self.agents.busy_count()));
let mut toggle: Option<String> = None;
egui::Grid::new("brokkr_agents").striped(true).num_columns(4).show(ui, |ui| {
for h in ["agent", "tags", "state", ""] {
ui.label(egui::RichText::new(h).strong());
}
ui.end_row();
for a in &self.agents.pool.agents {
let state = if a.busy { "busy" } else { "idle" };
let (chip, color) = facett_ui::status_chip(&theme, if a.busy { "running" } else { "passed" });
ui.label(&a.name);
ui.label(a.tags.iter().cloned().collect::<Vec<_>>().join(", "));
ui.horizontal(|ui| {
ui.label(egui::RichText::new(chip).color(color));
ui.label(state);
});
if ui.button(format!("Toggle {}", a.name)).clicked() {
toggle = Some(a.name.clone());
}
ui.end_row();
}
});
if let Some(name) = toggle {
self.agents.toggle(&name);
}
}
fn draw_log(&mut self, ui: &mut egui::Ui) {
ui.label(format!("{} line(s)", self.log.count()));
ui.separator();
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
for line in self.log.lines() {
let red = line.contains("[failed]");
let mut txt = egui::RichText::new(line).monospace();
if red {
txt = txt.color(super::facett_theme::RED);
}
ui.label(txt);
}
});
}
}
fn metro_map(pipeline: &Pipeline, dag: &DagFacet) -> MetroMap {
let status_of = |step: &str| {
dag.nodes.iter().find(|n| n.step == step).map(|n| n.status.as_str()).unwrap_or("pending")
};
let mut lines = Vec::new();
for stage in &pipeline.stages {
let steps: Vec<_> = stage.steps.iter().collect();
if steps.is_empty() {
continue;
}
let last = steps.len() - 1;
let stations: Vec<MetroStation> = steps
.iter()
.enumerate()
.map(|(i, st)| {
let kind = if i == 0 {
StationKind::Start
} else if i == last {
StationKind::Terminus
} else {
StationKind::Emitter
};
let green = status_of(&st.name) == "passed";
MetroStation::new(
format!("{}::{}", stage.name, st.name),
st.name.clone(),
kind,
green,
)
})
.collect();
lines.push(MetroLine::new(stage.name.clone(), stage.name.clone(), stations));
}
MetroMap::new(lines)
}