//! Askama template structs for the DataBuild dashboard. use askama::Template; use crate::{ JobRunDetail, JobRunStatus, PartitionDetail, PartitionRef, PartitionStatus, ReadDeps, WantAttributedPartitions, WantDetail, WantStatus, }; // ============================================================================= // View Types - Template-friendly wrappers for proto types // ============================================================================= pub struct PartitionRefView { pub partition_ref: String, pub partition_ref_encoded: String, } impl From<&PartitionRef> for PartitionRefView { fn from(p: &PartitionRef) -> Self { Self { partition_ref: p.r#ref.clone(), partition_ref_encoded: urlencoding::encode(&p.r#ref).into_owned(), } } } pub struct WantStatusView { pub code: i32, pub name: String, pub name_lowercase: String, } impl From<&WantStatus> for WantStatusView { fn from(s: &WantStatus) -> Self { Self { code: s.code, name: s.name.clone(), name_lowercase: s.name.to_lowercase(), } } } pub struct PartitionStatusView { pub code: i32, pub name: String, pub name_lowercase: String, } impl From<&PartitionStatus> for PartitionStatusView { fn from(s: &PartitionStatus) -> Self { Self { code: s.code, name: s.name.clone(), name_lowercase: s.name.to_lowercase(), } } } pub struct JobRunStatusView { pub code: i32, pub name: String, pub name_lowercase: String, } impl From<&JobRunStatus> for JobRunStatusView { fn from(s: &JobRunStatus) -> Self { Self { code: s.code, name: s.name.clone(), name_lowercase: s.name.to_lowercase(), } } } /// Simple view for derivative wants in the want detail page pub struct DerivativeWantView { pub want_id: String, pub partitions: Vec, pub status: Option, } impl From<&WantDetail> for DerivativeWantView { fn from(w: &WantDetail) -> Self { Self { want_id: w.want_id.clone(), partitions: w.partitions.iter().map(PartitionRefView::from).collect(), status: w.status.as_ref().map(WantStatusView::from), } } } /// Enum representing the want lifetime type for templates pub enum WantLifetimeView { Originating { data_timestamp: u64, ttl_seconds: u64, sla_seconds: u64, }, Ephemeral { job_run_id: String, }, } pub struct WantDetailView { pub want_id: String, pub partitions: Vec, pub upstreams: Vec, pub lifetime: Option, /// Convenience accessor for originating wants - returns data_timestamp or 0 pub data_timestamp: u64, /// Convenience accessor for originating wants - returns ttl_seconds or 0 pub ttl_seconds: u64, /// Convenience accessor for originating wants - returns sla_seconds or 0 pub sla_seconds: u64, /// True if this is an ephemeral (derivative) want pub is_ephemeral: bool, /// Job run that created this ephemeral want (if ephemeral) pub source_job_run_id: Option, pub comment: Option, pub comment_display: String, pub status: Option, pub last_updated_timestamp: u64, // Lineage fields pub job_run_ids: Vec, pub derivative_want_ids: Vec, pub job_runs: Vec, pub derivative_wants: Vec, /// Mermaid flowchart showing want -> partition -> job run -> derivative want lineage pub lineage_mermaid: String, } impl WantDetailView { /// Create a WantDetailView with derivative wants and lineage graph populated. /// Use this for the detail page where full lineage information is shown. pub fn new( w: &WantDetail, derivative_wants: Vec, lineage_mermaid: String, ) -> Self { use crate::want_detail::Lifetime; let (lifetime, data_timestamp, ttl_seconds, sla_seconds, is_ephemeral, source_job_run_id) = match &w.lifetime { Some(Lifetime::Originating(orig)) => ( Some(WantLifetimeView::Originating { data_timestamp: orig.data_timestamp, ttl_seconds: orig.ttl_seconds, sla_seconds: orig.sla_seconds, }), orig.data_timestamp, orig.ttl_seconds, orig.sla_seconds, false, None, ), Some(Lifetime::Ephemeral(eph)) => ( Some(WantLifetimeView::Ephemeral { job_run_id: eph.job_run_id.clone(), }), 0, 0, 0, true, Some(eph.job_run_id.clone()), ), None => (None, 0, 0, 0, false, None), }; Self { want_id: w.want_id.clone(), partitions: w.partitions.iter().map(PartitionRefView::from).collect(), upstreams: w.upstreams.iter().map(PartitionRefView::from).collect(), lifetime, data_timestamp, ttl_seconds, sla_seconds, is_ephemeral, source_job_run_id, comment: w.comment.clone(), comment_display: w.comment.as_deref().unwrap_or("-").to_string(), status: w.status.as_ref().map(WantStatusView::from), last_updated_timestamp: w.last_updated_timestamp, job_run_ids: w.job_run_ids.clone(), derivative_want_ids: w.derivative_want_ids.clone(), job_runs: w.job_runs.iter().map(JobRunDetailView::from).collect(), derivative_wants, lineage_mermaid, } } } /// For list pages where derivative wants and lineage aren't needed impl From for WantDetailView { fn from(w: WantDetail) -> Self { Self::new(&w, vec![], String::new()) } } pub struct PartitionDetailView { pub partition_ref: String, pub partition_ref_encoded: String, pub has_partition_ref: bool, pub status: Option, pub last_updated_timestamp: Option, pub job_run_ids: Vec, pub want_ids: Vec, pub taint_ids: Vec, pub uuid: String, // Lineage fields pub built_by_job_run_id: Option, pub downstream_partition_uuids: Vec, } impl From<&PartitionDetail> for PartitionDetailView { fn from(p: &PartitionDetail) -> Self { let (partition_ref, partition_ref_encoded, has_partition_ref) = match &p.r#ref { Some(pr) => ( pr.r#ref.clone(), urlencoding::encode(&pr.r#ref).into_owned(), true, ), None => (String::new(), String::new(), false), }; Self { partition_ref, partition_ref_encoded, has_partition_ref, status: p.status.as_ref().map(PartitionStatusView::from), last_updated_timestamp: p.last_updated_timestamp, job_run_ids: p.job_run_ids.clone(), want_ids: p.want_ids.clone(), taint_ids: p.taint_ids.clone(), uuid: p.uuid.clone(), built_by_job_run_id: p.built_by_job_run_id.clone(), downstream_partition_uuids: p.downstream_partition_uuids.clone(), } } } impl From for PartitionDetailView { fn from(p: PartitionDetail) -> Self { Self::from(&p) } } pub struct WantAttributedPartitionsView { pub want_id: String, pub partitions: Vec, } impl From<&WantAttributedPartitions> for WantAttributedPartitionsView { fn from(w: &WantAttributedPartitions) -> Self { Self { want_id: w.want_id.clone(), partitions: w.partitions.iter().map(PartitionRefView::from).collect(), } } } /// View for read dependency entries (impacted → read relationships) pub struct ReadDepsView { pub impacted: Vec, pub read: Vec, } impl From<&ReadDeps> for ReadDepsView { fn from(rd: &ReadDeps) -> Self { Self { impacted: rd.impacted.iter().map(PartitionRefView::from).collect(), read: rd.read.iter().map(PartitionRefView::from).collect(), } } } /// View for partition ref with its resolved UUID (for lineage display) pub struct PartitionRefWithUuidView { pub partition_ref: String, pub partition_ref_encoded: String, pub uuid: String, } pub struct JobRunDetailView { pub id: String, pub job_label: String, pub status: Option, pub last_heartbeat_at: Option, pub queued_at: Option, pub started_at: Option, pub building_partitions: Vec, pub servicing_wants: Vec, // Lineage fields (populated for Succeeded/DepMiss states) pub read_deps: Vec, pub read_partitions: Vec, pub wrote_partitions: Vec, pub derivative_want_ids: Vec, } impl From<&JobRunDetail> for JobRunDetailView { fn from(jr: &JobRunDetail) -> Self { // Build read_partitions from read_partition_uuids map let read_partitions: Vec = jr .read_partition_uuids .iter() .map(|(partition_ref, uuid)| PartitionRefWithUuidView { partition_ref: partition_ref.clone(), partition_ref_encoded: urlencoding::encode(partition_ref).into_owned(), uuid: uuid.clone(), }) .collect(); // Build wrote_partitions from wrote_partition_uuids map let wrote_partitions: Vec = jr .wrote_partition_uuids .iter() .map(|(partition_ref, uuid)| PartitionRefWithUuidView { partition_ref: partition_ref.clone(), partition_ref_encoded: urlencoding::encode(partition_ref).into_owned(), uuid: uuid.clone(), }) .collect(); Self { id: jr.id.clone(), job_label: jr.job_label.clone(), status: jr.status.as_ref().map(JobRunStatusView::from), last_heartbeat_at: jr.last_heartbeat_at, queued_at: jr.queued_at, started_at: jr.started_at, building_partitions: jr .building_partitions .iter() .map(PartitionRefView::from) .collect(), servicing_wants: jr .servicing_wants .iter() .map(WantAttributedPartitionsView::from) .collect(), read_deps: jr.read_deps.iter().map(ReadDepsView::from).collect(), read_partitions, wrote_partitions, derivative_want_ids: jr.derivative_want_ids.clone(), } } } impl From for JobRunDetailView { fn from(jr: JobRunDetail) -> Self { Self::from(&jr) } } pub struct BaseContext { pub graph_label: String, } impl Default for BaseContext { fn default() -> Self { Self { graph_label: "DataBuild".to_string(), } } } // ============================================================================= // Template Structure // ============================================================================= // // Templates are file-based and located in databuild/web/templates/. // Common components (head, nav, footer) are defined as macros in base.html // and imported by each page template with: {% import "base.html" as base %} // ============================================================================= // Home Page // ============================================================================= #[derive(Template)] #[template(ext = "html", path = "home.html")] pub struct HomePage { pub base: BaseContext, pub active_wants_count: u64, pub active_job_runs_count: u64, pub live_partitions_count: u64, } // ============================================================================= // Wants List Page // ============================================================================= #[derive(Template)] #[template(ext = "html", path = "wants/list.html")] pub struct WantsListPage { pub base: BaseContext, pub wants: Vec, pub page: u64, pub page_size: u64, pub total_count: u64, } impl WantsListPage { pub fn has_prev(&self) -> bool { self.page > 0 } pub fn has_next(&self) -> bool { (self.page + 1) * self.page_size < self.total_count } pub fn prev_page(&self) -> u64 { self.page.saturating_sub(1) } pub fn next_page(&self) -> u64 { self.page + 1 } } // ============================================================================= // Want Detail Page // ============================================================================= #[derive(Template)] #[template(ext = "html", path = "wants/detail.html")] pub struct WantDetailPage { pub base: BaseContext, pub want: WantDetailView, } // ============================================================================= // Partitions List Page // ============================================================================= #[derive(Template)] #[template(ext = "html", path = "partitions/list.html")] pub struct PartitionsListPage { pub base: BaseContext, pub partitions: Vec, pub page: u64, pub page_size: u64, pub total_count: u64, } impl PartitionsListPage { pub fn has_prev(&self) -> bool { self.page > 0 } pub fn has_next(&self) -> bool { (self.page + 1) * self.page_size < self.total_count } pub fn prev_page(&self) -> u64 { self.page.saturating_sub(1) } pub fn next_page(&self) -> u64 { self.page + 1 } } // ============================================================================= // Partition Detail Page // ============================================================================= #[derive(Template)] #[template(ext = "html", path = "partitions/detail.html")] pub struct PartitionDetailPage { pub base: BaseContext, pub partition: PartitionDetailView, } // ============================================================================= // Job Runs List Page // ============================================================================= #[derive(Template)] #[template(ext = "html", path = "job_runs/list.html")] pub struct JobRunsListPage { pub base: BaseContext, pub job_runs: Vec, pub page: u64, pub page_size: u64, pub total_count: u64, } impl JobRunsListPage { pub fn has_prev(&self) -> bool { self.page > 0 } pub fn has_next(&self) -> bool { (self.page + 1) * self.page_size < self.total_count } pub fn prev_page(&self) -> u64 { self.page.saturating_sub(1) } pub fn next_page(&self) -> u64 { self.page + 1 } } // ============================================================================= // Job Run Detail Page // ============================================================================= #[derive(Template)] #[template(ext = "html", path = "job_runs/detail.html")] pub struct JobRunDetailPage { pub base: BaseContext, pub job_run: JobRunDetailView, } // ============================================================================= // Want Create Page // ============================================================================= #[derive(Template)] #[template(ext = "html", path = "wants/create.html")] pub struct WantCreatePage { pub base: BaseContext, } // ============================================================================= // Tests // ============================================================================= #[cfg(test)] mod tests { use super::*; use crate::build_state::BuildState; use crate::util::test_scenarios::multihop_scenario; use askama::Template; /// Helper to replay events into a fresh BuildState fn build_state_from_events(events: &[crate::data_build_event::Event]) -> BuildState { let mut state = BuildState::default(); for event in events { state.handle_event(event); } state } mod want_detail_page { use super::*; /// Tests that the want detail page shows the job runs that serviced this want. /// This is the "Fulfillment - Job Runs" section in the UI. /// /// Given: The multihop scenario completes (beta job dep-misses, alpha built, beta retries) /// When: We render the beta want detail page /// Then: It should show both beta job runs (beta-job-1 and beta-job-2) #[test] fn test_shows_servicing_job_runs() { let (events, ids) = multihop_scenario(); let state = build_state_from_events(&events); // Get beta want and render its detail page let want_detail = state .get_want(&ids.beta_want_id) .expect("beta want should exist"); let template = WantDetailPage { base: BaseContext::default(), want: WantDetailView::from(want_detail), }; let html = template.render().expect("template should render"); // Verify the Fulfillment section exists and contains both job run IDs assert!( html.contains(&ids.beta_job_1_id), "Should show beta-job-1 in fulfillment section. HTML:\n{}", html ); assert!( html.contains(&ids.beta_job_2_id), "Should show beta-job-2 in fulfillment section. HTML:\n{}", html ); } /// Tests that the want detail page shows derivative wants spawned by dep-miss. /// This is the "Fulfillment - Derivative Wants" section in the UI. /// /// Given: The multihop scenario completes (beta job dep-misses, spawning alpha want) /// When: We render the beta want detail page /// Then: It should show the alpha want as a derivative #[test] fn test_shows_derivative_wants() { let (events, ids) = multihop_scenario(); let state = build_state_from_events(&events); // Get beta want and render its detail page let want_detail = state .get_want(&ids.beta_want_id) .expect("beta want should exist"); // Fetch derivative wants (like the http_server does) let derivative_wants: Vec<_> = want_detail .derivative_want_ids .iter() .filter_map(|id| state.get_want(id)) .map(|w| DerivativeWantView::from(&w)) .collect(); let template = WantDetailPage { base: BaseContext::default(), want: WantDetailView::new(&want_detail, derivative_wants, String::new()), }; let html = template.render().expect("template should render"); // Verify the Fulfillment section exists and contains the derivative want assert!( html.contains(&ids.alpha_want_id), "Should show alpha-want as derivative want. HTML:\n{}", html ); } /// Given: A want detail view with lineage mermaid content /// When: We render the template /// Then: The HTML should contain the mermaid diagram #[test] fn test_want_detail_page_shows_lineage_mermaid() { let (events, ids) = multihop_scenario(); let state = build_state_from_events(&events); let want_detail = state .get_want(&ids.beta_want_id) .expect("beta want should exist"); let derivative_wants: Vec<_> = want_detail .derivative_want_ids .iter() .filter_map(|id| state.get_want(id)) .map(|w| DerivativeWantView::from(&w)) .collect(); // Create a simple lineage mermaid string let lineage_mermaid = "flowchart TD\n W[Want]\n P[Partition]".to_string(); let template = WantDetailPage { base: BaseContext::default(), want: WantDetailView::new(&want_detail, derivative_wants, lineage_mermaid), }; let html = template.render().expect("template should render"); // Verify lineage graph section is present assert!( html.contains("Lineage Graph"), "Should have Lineage Graph section header" ); assert!( html.contains("class=\"mermaid\""), "Should have mermaid pre element" ); assert!( html.contains("flowchart TD"), "Should contain the mermaid content" ); assert!( html.contains("mermaid.esm.min.mjs"), "Should include mermaid script" ); } } }