Compare commits

...

2 commits

Author SHA1 Message Date
5c720ebc62 factor out common html macros
Some checks are pending
/ setup (push) Waiting to run
2025-11-26 10:18:19 +08:00
4a1ff75ea9 fix askama template shenanigans 2025-11-26 10:05:28 +08:00
12 changed files with 604 additions and 974 deletions

1
.gitignore vendored
View file

@ -11,6 +11,7 @@ node_modules
**/node_modules
Cargo.toml
Cargo.lock
/askama.toml
databuild/databuild.rs
generated_number
target

View file

@ -24,8 +24,11 @@ rust_library(
srcs = glob(["**/*.rs"]) + [
":generate_databuild_rust",
],
compile_data = glob(["web/templates/**"]) + ["askama.toml"],
crate_root = "lib.rs",
edition = "2021",
# This is required to point to the `askama.toml`, which then points to the appropriate place for templates
rustc_env = {"CARGO_MANIFEST_DIR": "$(BINDIR)/" + package_name()},
visibility = ["//visibility:public"],
deps = [
"@crates//:askama",

2
databuild/askama.toml Normal file
View file

@ -0,0 +1,2 @@
[general]
dirs = ["web/templates"]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,198 @@
{#
Base template macros for DataBuild dashboard.
Import these in page templates with: {% import "base.html" as base %}
Then call: {% call base::head("Page Title") %}, {% call base::nav(...) %}, {% call base::footer() %}
#}
{% macro head(title) %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@600&display=swap" rel="stylesheet">
<title>{{ title }}</title>
<style>
/* View Transitions */
@view-transition { navigation: auto }
/* CSS Variables */
:root {
--color-brand: #F2994A;
--color-brand-light: #fef3e2;
--color-text: #333;
--color-text-muted: #6b7280;
--color-bg: #f9fafb;
--color-surface: #fff;
--color-border: #e5e7eb;
}
/* Reset */
* { box-sizing: border-box; margin: 0; padding: 0 }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.5;
}
/* Navigation */
nav {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: .75rem 1.5rem;
display: flex;
align-items: center;
gap: 2rem;
view-transition-name: nav;
}
.logo {
display: flex;
align-items: center;
gap: .5rem;
text-decoration: none;
color: var(--color-text);
}
.logo svg { width: 28px; height: 25px }
.logo span { font-family: 'Roboto Slab', serif; font-weight: 600; font-size: 1.125rem }
nav .links { display: flex; gap: 1.5rem }
nav .links a {
color: var(--color-text-muted);
text-decoration: none;
font-size: .875rem;
padding: .25rem 0;
border-bottom: 2px solid transparent;
}
nav .links a:hover { color: var(--color-text) }
nav .links a.active { color: var(--color-brand); border-bottom-color: var(--color-brand) }
nav .graph-label { margin-left: auto; color: var(--color-text-muted); font-size: .875rem }
/* Main Content */
main { max-width: 1200px; margin: 0 auto; padding: 1.5rem }
h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 1rem }
/* Dashboard Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: .5rem;
padding: 1rem;
text-decoration: none;
color: inherit;
}
.stat-card:hover { border-color: var(--color-brand) }
.stat-card .value { font-size: 1.5rem; font-weight: 600 }
.stat-card .label { font-size: .75rem; color: var(--color-text-muted) }
/* Tables */
table {
width: 100%;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: .5rem;
border-collapse: collapse;
font-size: .875rem;
}
th, td { padding: .75rem 1rem; text-align: left; border-bottom: 1px solid var(--color-border) }
th { background: var(--color-bg); font-weight: 500; color: var(--color-text-muted) }
tr:last-child td { border-bottom: none }
tr:hover { background: var(--color-bg) }
td a { color: var(--color-brand); text-decoration: none }
td a:hover { text-decoration: underline }
/* Pagination */
.pagination { display: flex; gap: .5rem; margin-top: 1rem; align-items: center; justify-content: center }
.pagination a, .pagination span {
padding: .5rem .75rem;
border: 1px solid var(--color-border);
border-radius: .25rem;
text-decoration: none;
color: var(--color-text);
font-size: .875rem;
}
.pagination a:hover { background: var(--color-bg) }
.pagination .disabled { color: var(--color-text-muted) }
/* Status Badges - Want */
.status { display: inline-block; padding: .25rem .5rem; border-radius: .25rem; font-size: .75rem; font-weight: 500 }
.status-successful, .status-wantsuccessful { background: #dcfce7; color: #166534 }
.status-building, .status-wantbuilding { background: #ede9fe; color: #5b21b6 }
.status-failed, .status-wantfailed { background: #fee2e2; color: #991b1b }
.status-canceled, .status-wantcanceled { background: #f1f5f9; color: #475569 }
.status-new, .status-wantnew, .status-queued, .status-wantqueued { background: var(--color-brand-light); color: #92400e }
.status-upstreambuilding, .status-wantupstreambuilding { background: #fae8ff; color: #86198f }
.status-upstreamfailed, .status-wantupstreamfailed { background: #ffe4e6; color: #be123c }
/* Status Badges - Partition */
.status-live, .status-partitionlive { background: #dcfce7; color: #166534 }
.status-partitionbuilding { background: #ede9fe; color: #5b21b6 }
.status-partitionfailed { background: #fee2e2; color: #991b1b }
.status-idle, .status-partitionidle { background: #e0f2fe; color: #075985 }
.status-tainted, .status-partitiontainted { background: #fef3c7; color: #92400e }
.status-partitionupstreambuilding { background: #fae8ff; color: #86198f }
.status-partitionupstreamfailed { background: #ffe4e6; color: #be123c }
/* Status Badges - Job Run */
.status-succeeded, .status-jobrunsucceeded { background: #dcfce7; color: #166534 }
.status-running, .status-jobrunrunning { background: #ede9fe; color: #5b21b6 }
.status-jobrunfailed, .status-depmiss, .status-jobrundepmiss { background: #fee2e2; color: #991b1b }
.status-jobrunnew { background: var(--color-brand-light); color: #92400e }
/* Detail Pages */
.detail-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem }
.detail-header h1 { margin-bottom: 0 }
.detail-section {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: .5rem;
padding: 1rem;
margin-bottom: 1rem;
}
.detail-section h2 { font-size: .875rem; font-weight: 500; color: var(--color-text-muted); margin-bottom: .75rem }
.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem }
.detail-item label { display: block; font-size: .75rem; color: var(--color-text-muted); margin-bottom: .25rem }
/* Partition Lists */
.partition-list { list-style: none; font-family: monospace; font-size: .8125rem }
.partition-list li { padding: .25rem 0 }
.partition-list a { color: var(--color-brand); text-decoration: none }
.partition-list a:hover { text-decoration: underline }
</style>
</head>
<body>
{% endmacro %}
{% macro nav(active, graph_label) %}
<nav>
<a href="/" class="logo">
<svg viewBox="0 0 243 215" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M123.5 77L149.048 121.25H97.9523L123.5 77Z" fill="#F2994A"/>
<path d="M224.772 125.035L155.772 125.035L109.52 45.3147L86.7722 45.3147L40.2722 124.463L16.7725 124.463" stroke="#333" stroke-width="20"/>
<path d="M86.6196 5.18886L121.12 64.9444L75.2062 144.86L86.58 164.56L178.375 165.256L190.125 185.608" stroke="#333" stroke-width="20"/>
<path d="M51.966 184.847L86.4659 125.092L178.632 124.896L190.006 105.196L144.711 25.3514L156.461 5.00002" stroke="#333" stroke-width="20"/>
</svg>
<span>DataBuild</span>
</a>
<div class="links">
<a href="/wants"{% if active == "wants" %} class="active"{% endif %}>Wants</a>
<a href="/partitions"{% if active == "partitions" %} class="active"{% endif %}>Partitions</a>
<a href="/job_runs"{% if active == "job_runs" %} class="active"{% endif %}>Job Runs</a>
</div>
<span class="graph-label">{{ graph_label }}</span>
</nav>
<main>
{% endmacro %}
{% macro footer() %}
</main>
</body>
</html>
{% endmacro %}

View file

@ -0,0 +1,23 @@
{% import "base.html" as base %}
{% call base::head("Home - DataBuild") %}
{% call base::nav("", base.graph_label) %}
<h1>Dashboard</h1>
<div class="stats-grid">
<a href="/wants" class="stat-card">
<div class="value">{{ active_wants_count }}</div>
<div class="label">Active Wants</div>
</a>
<a href="/job_runs" class="stat-card">
<div class="value">{{ active_job_runs_count }}</div>
<div class="label">Active Job Runs</div>
</a>
<a href="/partitions" class="stat-card">
<div class="value">{{ live_partitions_count }}</div>
<div class="label">Live Partitions</div>
</a>
</div>
{% call base::footer() %}

View file

@ -0,0 +1,57 @@
{% import "base.html" as base %}
{% call base::head("Job Run - DataBuild") %}
{% call base::nav("job_runs", base.graph_label) %}
<div class="detail-header" style="view-transition-name:job-run-header">
<h1>Job Run: {{ job_run.id }}</h1>
{% match job_run.status %}
{% when Some with (s) %}<span class="status status-{{ s.name_lowercase }}">{{ s.name }}</span>
{% when None %}
{% endmatch %}
</div>
<div class="detail-section">
<h2>Details</h2>
<div class="detail-grid">
<div class="detail-item">
<label>Last Heartbeat</label>
<span>
{% match job_run.last_heartbeat_at %}
{% when Some with (ts) %}{{ ts }}
{% when None %}-
{% endmatch %}
</span>
</div>
</div>
</div>
<div class="detail-section">
<h2>Building Partitions ({{ job_run.building_partitions.len() }})</h2>
<ul class="partition-list">
{% for p in job_run.building_partitions %}
<li><a href="/partitions/{{ p.partition_ref_encoded }}">{{ p.partition_ref }}</a></li>
{% endfor %}
{% if job_run.building_partitions.is_empty() %}
<li style="color:var(--color-text-muted)">No partitions</li>
{% endif %}
</ul>
</div>
{% if !job_run.servicing_wants.is_empty() %}
<div class="detail-section">
<h2>Servicing Wants ({{ job_run.servicing_wants.len() }})</h2>
{% for sw in job_run.servicing_wants %}
<div style="margin-bottom:.75rem">
<div><a href="/wants/{{ sw.want_id }}">{{ sw.want_id }}</a></div>
<ul class="partition-list" style="margin-left:1rem">
{% for p in sw.partitions %}
<li>{{ p.partition_ref }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
{% endif %}
{% call base::footer() %}

View file

@ -0,0 +1,58 @@
{% import "base.html" as base %}
{% call base::head("Job Runs - DataBuild") %}
{% call base::nav("job_runs", base.graph_label) %}
<h1>Job Runs</h1>
<table>
<thead>
<tr>
<th>Job Run ID</th>
<th>Status</th>
<th>Building Partitions</th>
<th>Last Heartbeat</th>
</tr>
</thead>
<tbody>
{% for jr in job_runs %}
<tr style="view-transition-name: job-run-{{ loop.index }}">
<td><a href="/job_runs/{{ jr.id }}">{{ jr.id }}</a></td>
<td>
{% match jr.status %}
{% when Some with (s) %}<span class="status status-{{ s.name_lowercase }}">{{ s.name }}</span>
{% when None %}<span class="status">Unknown</span>
{% endmatch %}
</td>
<td>{{ jr.building_partitions.len() }}</td>
<td>
{% match jr.last_heartbeat_at %}
{% when Some with (ts) %}{{ ts }}
{% when None %}-
{% endmatch %}
</td>
</tr>
{% endfor %}
{% if job_runs.is_empty() %}
<tr>
<td colspan="4" style="text-align:center;color:var(--color-text-muted)">No job runs found</td>
</tr>
{% endif %}
</tbody>
</table>
<div class="pagination">
{% if self.has_prev() %}
<a href="?page={{ self.prev_page() }}">Previous</a>
{% else %}
<span class="disabled">Previous</span>
{% endif %}
<span>Page {{ page + 1 }} of {{ (total_count + page_size - 1) / page_size }}</span>
{% if self.has_next() %}
<a href="?page={{ self.next_page() }}">Next</a>
{% else %}
<span class="disabled">Next</span>
{% endif %}
</div>
{% call base::footer() %}

View file

@ -0,0 +1,68 @@
{% import "base.html" as base %}
{% call base::head("Partition - DataBuild") %}
{% call base::nav("partitions", base.graph_label) %}
<div class="detail-header" style="view-transition-name:partition-header">
<h1 style="font-family:monospace;font-size:1.25rem">
{% if partition.has_partition_ref %}{{ partition.partition_ref }}{% else %}Unknown{% endif %}
</h1>
{% match partition.status %}
{% when Some with (s) %}<span class="status status-{{ s.name_lowercase }}">{{ s.name }}</span>
{% when None %}
{% endmatch %}
</div>
<div class="detail-section">
<h2>Details</h2>
<div class="detail-grid">
<div class="detail-item">
<label>UUID</label>
<span style="font-family:monospace;font-size:.8125rem">{{ partition.uuid }}</span>
</div>
<div class="detail-item">
<label>Last Updated</label>
<span>
{% match partition.last_updated_timestamp %}
{% when Some with (ts) %}{{ ts }}
{% when None %}-
{% endmatch %}
</span>
</div>
</div>
</div>
{% if !partition.job_run_ids.is_empty() %}
<div class="detail-section">
<h2>Job Runs ({{ partition.job_run_ids.len() }})</h2>
<ul class="partition-list">
{% for id in partition.job_run_ids %}
<li><a href="/job_runs/{{ id }}">{{ id }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if !partition.want_ids.is_empty() %}
<div class="detail-section">
<h2>Referenced by Wants ({{ partition.want_ids.len() }})</h2>
<ul class="partition-list">
{% for id in partition.want_ids %}
<li><a href="/wants/{{ id }}">{{ id }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if !partition.taint_ids.is_empty() %}
<div class="detail-section">
<h2>Taints ({{ partition.taint_ids.len() }})</h2>
<ul class="partition-list">
{% for id in partition.taint_ids %}
<li>{{ id }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% call base::footer() %}

View file

@ -0,0 +1,62 @@
{% import "base.html" as base %}
{% call base::head("Partitions - DataBuild") %}
{% call base::nav("partitions", base.graph_label) %}
<h1>Partitions</h1>
<table>
<thead>
<tr>
<th>Partition Ref</th>
<th>Status</th>
<th>Last Updated</th>
</tr>
</thead>
<tbody>
{% for p in partitions %}
<tr style="view-transition-name: partition-{{ loop.index }}">
<td>
{% if p.has_partition_ref %}
<a href="/partitions/{{ p.partition_ref_encoded }}">{{ p.partition_ref }}</a>
{% else %}
<span style="color:var(--color-text-muted)">-</span>
{% endif %}
</td>
<td>
{% match p.status %}
{% when Some with (s) %}<span class="status status-{{ s.name_lowercase }}">{{ s.name }}</span>
{% when None %}<span class="status">Unknown</span>
{% endmatch %}
</td>
<td>
{% match p.last_updated_timestamp %}
{% when Some with (ts) %}{{ ts }}
{% when None %}-
{% endmatch %}
</td>
</tr>
{% endfor %}
{% if partitions.is_empty() %}
<tr>
<td colspan="3" style="text-align:center;color:var(--color-text-muted)">No partitions found</td>
</tr>
{% endif %}
</tbody>
</table>
<div class="pagination">
{% if self.has_prev() %}
<a href="?page={{ self.prev_page() }}">Previous</a>
{% else %}
<span class="disabled">Previous</span>
{% endif %}
<span>Page {{ page + 1 }} of {{ (total_count + page_size - 1) / page_size }}</span>
{% if self.has_next() %}
<a href="?page={{ self.next_page() }}">Next</a>
{% else %}
<span class="disabled">Next</span>
{% endif %}
</div>
{% call base::footer() %}

View file

@ -0,0 +1,68 @@
{% import "base.html" as base %}
{% call base::head("Want - DataBuild") %}
{% call base::nav("wants", base.graph_label) %}
<div class="detail-header" style="view-transition-name:want-header">
<h1>Want: {{ want.want_id }}</h1>
{% match want.status %}
{% when Some with (s) %}<span class="status status-{{ s.name_lowercase }}">{{ s.name }}</span>
{% when None %}
{% endmatch %}
</div>
<div class="detail-section">
<h2>Details</h2>
<div class="detail-grid">
<div class="detail-item">
<label>Data Timestamp</label>
<span>{{ want.data_timestamp }}</span>
</div>
<div class="detail-item">
<label>TTL (seconds)</label>
<span>{{ want.ttl_seconds }}</span>
</div>
<div class="detail-item">
<label>SLA (seconds)</label>
<span>{{ want.sla_seconds }}</span>
</div>
<div class="detail-item">
<label>Last Updated</label>
<span>{{ want.last_updated_timestamp }}</span>
</div>
</div>
</div>
{% match want.comment %}
{% when Some with (c) %}
<div class="detail-section">
<h2>Comment</h2>
<p>{{ c }}</p>
</div>
{% when None %}
{% endmatch %}
<div class="detail-section">
<h2>Requested Partitions ({{ want.partitions.len() }})</h2>
<ul class="partition-list">
{% for p in want.partitions %}
<li><a href="/partitions/{{ p.partition_ref_encoded }}">{{ p.partition_ref }}</a></li>
{% endfor %}
{% if want.partitions.is_empty() %}
<li style="color:var(--color-text-muted)">No partitions</li>
{% endif %}
</ul>
</div>
{% if !want.upstreams.is_empty() %}
<div class="detail-section">
<h2>Upstream Dependencies ({{ want.upstreams.len() }})</h2>
<ul class="partition-list">
{% for p in want.upstreams %}
<li><a href="/partitions/{{ p.partition_ref_encoded }}">{{ p.partition_ref }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% call base::footer() %}

View file

@ -0,0 +1,53 @@
{% import "base.html" as base %}
{% call base::head("Wants - DataBuild") %}
{% call base::nav("wants", base.graph_label) %}
<h1>Wants</h1>
<table>
<thead>
<tr>
<th>Want ID</th>
<th>Status</th>
<th>Partitions</th>
<th>Comment</th>
</tr>
</thead>
<tbody>
{% for want in wants %}
<tr style="view-transition-name: want-{{ loop.index }}">
<td><a href="/wants/{{ want.want_id }}">{{ want.want_id }}</a></td>
<td>
{% match want.status %}
{% when Some with (s) %}<span class="status status-{{ s.name_lowercase }}">{{ s.name }}</span>
{% when None %}<span class="status">Unknown</span>
{% endmatch %}
</td>
<td>{{ want.partitions.len() }}</td>
<td>{{ want.comment_display }}</td>
</tr>
{% endfor %}
{% if wants.is_empty() %}
<tr>
<td colspan="4" style="text-align:center;color:var(--color-text-muted)">No wants found</td>
</tr>
{% endif %}
</tbody>
</table>
<div class="pagination">
{% if self.has_prev() %}
<a href="?page={{ self.prev_page() }}">Previous</a>
{% else %}
<span class="disabled">Previous</span>
{% endif %}
<span>Page {{ page + 1 }} of {{ (total_count + page_size - 1) / page_size }}</span>
{% if self.has_next() %}
<a href="?page={{ self.next_page() }}">Next</a>
{% else %}
<span class="disabled">Next</span>
{% endif %}
</div>
{% call base::footer() %}