databuild/databuild/web/templates.rs

659 lines
21 KiB
Rust

//! 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<PartitionRefView>,
pub status: Option<WantStatusView>,
}
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<PartitionRefView>,
pub upstreams: Vec<PartitionRefView>,
pub lifetime: Option<WantLifetimeView>,
/// 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<String>,
pub comment: Option<String>,
pub comment_display: String,
pub status: Option<WantStatusView>,
pub last_updated_timestamp: u64,
// Lineage fields
pub job_run_ids: Vec<String>,
pub derivative_want_ids: Vec<String>,
pub job_runs: Vec<JobRunDetailView>,
pub derivative_wants: Vec<DerivativeWantView>,
/// 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<DerivativeWantView>,
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<WantDetail> 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<PartitionStatusView>,
pub last_updated_timestamp: Option<u64>,
pub job_run_ids: Vec<String>,
pub want_ids: Vec<String>,
pub taint_ids: Vec<String>,
pub uuid: String,
// Lineage fields
pub built_by_job_run_id: Option<String>,
pub downstream_partition_uuids: Vec<String>,
}
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<PartitionDetail> for PartitionDetailView {
fn from(p: PartitionDetail) -> Self {
Self::from(&p)
}
}
pub struct WantAttributedPartitionsView {
pub want_id: String,
pub partitions: Vec<PartitionRefView>,
}
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<PartitionRefView>,
pub read: Vec<PartitionRefView>,
}
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<JobRunStatusView>,
pub last_heartbeat_at: Option<u64>,
pub queued_at: Option<u64>,
pub started_at: Option<u64>,
pub building_partitions: Vec<PartitionRefView>,
pub servicing_wants: Vec<WantAttributedPartitionsView>,
// Lineage fields (populated for Succeeded/DepMiss states)
pub read_deps: Vec<ReadDepsView>,
pub read_partitions: Vec<PartitionRefWithUuidView>,
pub wrote_partitions: Vec<PartitionRefWithUuidView>,
pub derivative_want_ids: Vec<String>,
}
impl From<&JobRunDetail> for JobRunDetailView {
fn from(jr: &JobRunDetail) -> Self {
// Build read_partitions from read_partition_uuids map
let read_partitions: Vec<PartitionRefWithUuidView> = 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<PartitionRefWithUuidView> = 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<JobRunDetail> 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<WantDetailView>,
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<PartitionDetailView>,
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<JobRunDetailView>,
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"
);
}
}
}