659 lines
21 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|
|
}
|