use autumn_web::flash::FlashMessage;
use autumn_web::job::{
JobAdminPage, JobAdminRecord, JobAdminSnapshot, JobAdminStatus, JobScheduleSummary,
};
use maud::{DOCTYPE, Markup, PreEscaped, html};
use serde_json::Value;
use crate::registry::AdminRegistry;
use crate::routes::ADMIN_JS_PATH;
use crate::traits::{
AdminAction, AdminField, AdminFieldKind, ListResult, SortDirection, record_id,
};
const HTMX_JS_PATH: &str = "/static/js/htmx.min.js";
const HTMX_CSRF_JS_PATH: &str = "/static/js/autumn-htmx-csrf.js";
const TOKENS_CSS: &str = include_str!("tokens.css");
const JOBS_NAV_SLUG: &str = "__admin_jobs";
const FLASH_CSS: &str = "\
.flash {
padding: 0.75rem 1rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.flash-success { background: var(--success-light); color: var(--success); border: 1px solid var(--success); }
.flash-error { background: var(--danger-light); color: var(--danger); border: 1px solid var(--danger); }
.flash-warning { background: var(--warning-light); color: var(--warning); border: 1px solid var(--warning); }
.flash-info { background: var(--primary-light); color: var(--primary); border: 1px solid var(--primary); }
";
const ADMIN_CSS: &str = "
/* Skip-to-content link: visually hidden at rest, revealed on keyboard focus. */
.admin-skip-link {
position: absolute;
top: -9999px;
left: 0;
z-index: 9999;
padding: 0.5rem 1rem;
background: var(--primary);
color: #fff;
border-radius: 0 0 0.25rem 0.25rem;
font-size: 0.875rem;
text-decoration: none;
}
.admin-skip-link:focus {
top: 0;
outline: 3px solid var(--primary);
outline-offset: 2px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-family);
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
/* Layout */
.admin-layout { display: flex; min-height: 100vh; }
.admin-sidebar {
width: 240px;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 1.5rem 0;
position: fixed;
top: 0;
left: 0;
bottom: 0;
overflow-y: auto;
}
.admin-main {
margin-left: 240px;
flex: 1;
padding: 2rem;
min-width: 0;
}
.admin-logo {
font-size: 1.125rem;
font-weight: 700;
padding: 0 1.5rem 1rem;
border-bottom: 1px solid var(--border);
margin-bottom: 1rem;
color: var(--text);
}
.admin-nav { list-style: none; }
.admin-nav li a {
display: block;
padding: 0.5rem 1.5rem;
color: var(--text-muted);
font-size: 0.875rem;
font-weight: 500;
border-left: 3px solid transparent;
transition: all 0.15s;
}
.admin-nav li a:hover {
background: var(--bg);
color: var(--text);
text-decoration: none;
}
.admin-nav li a.active {
background: var(--primary-light);
color: var(--primary);
border-left-color: var(--primary);
}
.admin-nav-section {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
padding: 1rem 1.5rem 0.375rem;
font-weight: 600;
}
/* Cards */
.card {
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.card-title {
font-size: 1.125rem;
font-weight: 600;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
transition: all 0.15s;
}
.btn:hover { background: var(--bg); text-decoration: none; }
.btn-primary {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.btn-primary:hover { background: var(--primary-hover); }
.btn-danger {
background: var(--danger);
color: white;
border-color: var(--danger);
}
.btn-danger:hover { background: var(--danger-hover); }
.btn-sm { padding: 0.25rem 0.625rem; font-size: 0.8125rem; }
/* Tables */
.table-wrap { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th {
text-align: left;
padding: 0.75rem;
font-weight: 600;
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 2px solid var(--border);
white-space: nowrap;
user-select: none;
}
th a { cursor: pointer; }
th a:hover { color: var(--text); }
th .sort-icon { font-size: 0.625rem; margin-left: 0.25rem; }
td {
padding: 0.75rem;
border-bottom: 1px solid var(--border);
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
tr:hover td { background: var(--bg); }
.checkbox-cell { width: 40px; text-align: center; }
/* Forms */
.form-group { margin-bottom: 1rem; }
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.375rem;
color: var(--text);
}
.form-label .required { color: var(--danger); margin-left: 0.125rem; }
.form-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 0.875rem;
line-height: 1.5;
background: var(--surface);
color: var(--text);
transition: border-color 0.15s;
}
.form-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
textarea.form-input { min-height: 100px; resize: vertical; }
select.form-input { appearance: auto; }
/* Action bar (bulk actions) */
.action-bar {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
font-size: 0.875rem;
color: var(--text-muted);
}
/* Search bar */
.search-bar {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
align-items: center;
}
.search-bar input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 0.875rem;
}
.search-bar input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
/* Pagination */
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
font-size: 0.875rem;
color: var(--text-muted);
}
.pagination-links {
display: flex;
gap: 0.25rem;
}
.pagination-links a, .pagination-links span {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 0.8125rem;
color: var(--text);
}
.pagination-links a:hover { background: var(--bg); text-decoration: none; }
.pagination-links .active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
/* Dashboard stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.25rem;
}
.stat-label { font-size: 0.8125rem; color: var(--text-muted); font-weight: 500; }
.stat-value { font-size: 1.75rem; font-weight: 700; margin-top: 0.25rem; }
.stat-link { font-size: 0.8125rem; margin-top: 0.375rem; }
.jobs-counter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.jobs-counter {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.875rem;
background: var(--bg);
}
.jobs-counter strong {
display: block;
font-size: 1.35rem;
line-height: 1.1;
margin-top: 0.2rem;
}
.job-error summary {
cursor: pointer;
color: var(--danger);
}
.job-error pre {
margin-top: 0.5rem;
white-space: pre-wrap;
word-break: break-word;
background: var(--danger-light);
border-radius: 0.375rem;
padding: 0.5rem;
max-width: 32rem;
}
.job-actions {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.job-actions form { display: inline; }
/* Breadcrumbs */
.breadcrumbs {
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.breadcrumbs a { color: var(--text-muted); }
.breadcrumbs a:hover { color: var(--primary); }
.breadcrumbs .sep { margin: 0 0.5rem; }
/* Detail view */
.detail-grid {
display: grid;
grid-template-columns: 160px 1fr;
gap: 0;
}
.detail-label {
padding: 0.75rem;
font-weight: 500;
color: var(--text-muted);
font-size: 0.875rem;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.detail-value {
padding: 0.75rem;
font-size: 0.875rem;
border-bottom: 1px solid var(--border);
word-break: break-word;
}
/* Responsive */
@media (max-width: 768px) {
.admin-sidebar { display: none; }
.admin-main { margin-left: 0; }
.stats-grid { grid-template-columns: 1fr 1fr; }
}
";
#[allow(clippy::too_many_arguments)]
pub fn admin_layout(
registry: &AdminRegistry,
active_slug: Option<&str>,
title: &str,
prefix: &str,
actuator_prefix: &str,
csrf_token: &str,
messages: &[FlashMessage],
content: &Markup,
) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
meta name="csrf-token" content=(csrf_token);
title { (title) " — Autumn Admin" }
script src=(HTMX_JS_PATH) {}
script src=(HTMX_CSRF_JS_PATH) {}
script src={ (prefix) (&**ADMIN_JS_PATH) } {}
style {
(PreEscaped(TOKENS_CSS))
(PreEscaped(FLASH_CSS))
(PreEscaped(ADMIN_CSS))
}
}
body {
a href="#admin-main" class="admin-skip-link" { "Skip to main content" }
div class="admin-layout" {
header role="banner" {
nav class="admin-sidebar" aria-label="Admin navigation" {
div class="admin-logo" { "🍂 Autumn Admin" }
ul class="admin-nav" {
li {
a href=(prefix) class=[active_slug.is_none().then_some("active")] {
"Dashboard"
}
}
@if registry.model_count() > 0 {
li { div class="admin-nav-section" { "Models" } }
@for (slug, model) in registry.iter() {
li {
a href={ (prefix) "/" (slug) }
class=[(active_slug == Some(slug)).then_some("active")] {
(model.display_name_plural())
}
}
}
}
li { div class="admin-nav-section" { "System" } }
li {
a href={ (prefix) "/jobs" }
class=[(active_slug == Some(JOBS_NAV_SLUG)).then_some("active")] {
"Jobs"
}
}
li { a href={ (actuator_prefix) "/ui" } { "Actuator" } }
}
}
}
main id="admin-main" class="admin-main" {
@for msg in messages {
div class={ "flash flash-" (msg.level.as_str()) } role="alert" {
(msg.message)
}
}
(content)
}
}
}
}
}
}
fn csrf_hidden_input(csrf_token: &str, csrf_form_field: &str) -> Markup {
html! {
input type="hidden" name=(csrf_form_field) value=(csrf_token);
}
}
pub fn jobs_page(
registry: &AdminRegistry,
snapshot: &JobAdminSnapshot,
messages: &[FlashMessage],
csrf_token: &str,
csrf_form_field: &str,
prefix: &str,
actuator_prefix: &str,
) -> Markup {
let content = html! {
div class="breadcrumbs" {
a href=(prefix) { "Admin" }
span class="sep" { "›" }
span { "Jobs" }
}
h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 1rem;" {
"Jobs"
}
(jobs_counters(snapshot, prefix))
(job_list_card(
"Enqueued",
"Work waiting for a worker.",
&snapshot.enqueued,
"enqueued_page",
csrf_token,
csrf_form_field,
prefix,
))
(job_list_card(
"Running",
"Work currently executing in this runtime.",
&snapshot.running,
"running_page",
csrf_token,
csrf_form_field,
prefix,
))
(job_list_card(
"Completed (last 24h)",
"Recently completed work retained by the bounded dashboard history.",
&snapshot.completed,
"completed_page",
csrf_token,
csrf_form_field,
prefix,
))
(job_list_card(
"Failed (last 7d)",
"Terminal failures available for retry or discard.",
&snapshot.failed,
"failed_page",
csrf_token,
csrf_form_field,
prefix,
))
(job_schedules_card(&snapshot.schedules))
p style="font-size: 0.8125rem; color: var(--text-muted); margin-top: 1rem;" {
"Default backend history is bounded to " (snapshot.bounded_history_limit)
" lifecycle entries; counter refreshes use bounded in-memory reads."
}
};
admin_layout(
registry,
Some(JOBS_NAV_SLUG),
"Jobs",
prefix,
actuator_prefix,
csrf_token,
messages,
&content,
)
}
pub fn jobs_counters(snapshot: &JobAdminSnapshot, prefix: &str) -> Markup {
html! {
div id="jobs-counters"
class="jobs-counter-grid"
hx-get={ (prefix) "/jobs/counters" }
hx-trigger="load, every 2s"
hx-swap="outerHTML" {
(job_counter("Enqueued", snapshot.enqueued.total))
(job_counter("Running", snapshot.running.total))
(job_counter("Completed 24h", snapshot.completed.total))
(job_counter("Failed 7d", snapshot.failed.total))
}
}
}
fn job_counter(label: &str, value: u64) -> Markup {
html! {
div class="jobs-counter" {
span class="stat-label" { (label) }
strong { (value) }
}
}
}
fn job_list_card(
title: &str,
description: &str,
page: &JobAdminPage,
page_param: &str,
csrf_token: &str,
csrf_form_field: &str,
prefix: &str,
) -> Markup {
html! {
div class="card" {
div class="card-header" {
div {
span class="card-title" { (title) }
div style="font-size: 0.8125rem; color: var(--text-muted); margin-top: 0.25rem;" {
(description)
}
}
span style="font-size: 0.875rem; color: var(--text-muted);" {
(page.total) " total"
}
}
div class="table-wrap" {
table {
thead {
tr {
th { "Job" }
th { "Enqueued At" }
th { "Started At" }
th { "Finished At" }
th { "Attempts" }
th { "Principal" }
th { "Correlation" }
th { "Last Error" }
th { "Actions" }
}
}
tbody {
@if page.records.is_empty() {
tr {
td colspan="9" style="text-align: center; padding: 1.5rem; color: var(--text-muted);" {
"No jobs."
}
}
}
@for record in &page.records {
(job_row(record, csrf_token, csrf_form_field, prefix))
}
}
}
}
(jobs_pagination(page, page_param, prefix))
}
}
}
fn job_row(
record: &JobAdminRecord,
csrf_token: &str,
csrf_form_field: &str,
prefix: &str,
) -> Markup {
html! {
tr {
td {
strong { (record.name) }
div style="font-size: 0.75rem; color: var(--text-muted);" {
(record.status.label()) " · " (record.id)
}
}
td { (optional_text(record.enqueued_at.as_deref())) }
td { (optional_text(record.started_at.as_deref())) }
td { (optional_text(record.finished_at.as_deref())) }
td { (record.attempt) "/" (record.max_attempts) }
td { (optional_text(record.principal_id.as_deref())) }
td { (optional_text(record.correlation_id.as_deref())) }
td { (job_error(record)) }
td { (job_actions(record, csrf_token, csrf_form_field, prefix)) }
}
}
}
fn job_error(record: &JobAdminRecord) -> Markup {
let Some(error) = record.last_error.as_deref() else {
return html! { span style="color: var(--text-muted);" { "—" } };
};
if record.status == JobAdminStatus::Failed {
html! {
details class="job-error" {
summary { (truncate_display(error, 80)) }
pre { (error) }
}
}
} else {
html! { (truncate_display(error, 80)) }
}
}
fn job_actions(
record: &JobAdminRecord,
csrf_token: &str,
csrf_form_field: &str,
prefix: &str,
) -> Markup {
html! {
div class="job-actions" {
@if record.status == JobAdminStatus::Failed {
(job_action_form(prefix, &record.id, "retry", "Retry", "btn btn-sm btn-primary", csrf_token, csrf_form_field))
(job_action_form(prefix, &record.id, "discard", "Discard", "btn btn-sm btn-danger", csrf_token, csrf_form_field))
} @else if record.status == JobAdminStatus::Enqueued {
(job_action_form(prefix, &record.id, "cancel", "Cancel", "btn btn-sm btn-danger", csrf_token, csrf_form_field))
} @else {
span style="color: var(--text-muted);" { "—" }
}
}
}
}
fn job_action_form(
prefix: &str,
id: &str,
action: &str,
label: &str,
class_name: &str,
csrf_token: &str,
csrf_form_field: &str,
) -> Markup {
html! {
form method="post" action={ (prefix) "/jobs/" (id) "/" (action) } {
(csrf_hidden_input(csrf_token, csrf_form_field))
button type="submit" class=(class_name) {
(label)
}
}
}
}
fn jobs_pagination(page: &JobAdminPage, page_param: &str, prefix: &str) -> Markup {
if page.total_pages() <= 1 {
return html! {};
}
html! {
div class="pagination" {
div {
"Page " (page.page) " of " (page.total_pages())
}
div class="pagination-links" {
@if page.page > 1 {
a href={ (prefix) "/jobs?" (page_param) "=" (page.page - 1) } { "Previous" }
}
span class="active" { (page.page) }
@if page.page < page.total_pages() {
a href={ (prefix) "/jobs?" (page_param) "=" (page.page + 1) } { "Next" }
}
}
}
}
}
fn job_schedules_card(schedules: &[JobScheduleSummary]) -> Markup {
html! {
div class="card" {
div class="card-header" {
span class="card-title" { "Recurring Schedules" }
}
div class="table-wrap" {
table {
thead {
tr {
th { "Name" }
th { "Schedule" }
th { "Next Run At" }
th { "Last Run Status" }
}
}
tbody {
@if schedules.is_empty() {
tr {
td colspan="4" style="text-align: center; padding: 1.5rem; color: var(--text-muted);" {
"No scheduled tasks registered."
}
}
}
@for schedule in schedules {
tr {
td { (schedule.name) }
td { (schedule.schedule) }
td { (optional_text(schedule.next_run_at.as_deref())) }
td { (optional_text(schedule.last_run_status.as_deref())) }
}
}
}
}
}
}
}
}
fn optional_text(value: Option<&str>) -> Markup {
value.filter(|value| !value.is_empty()).map_or_else(
|| html! { span style="color: var(--text-muted);" { "—" } },
|value| html! { (value) },
)
}
pub fn dashboard_page(
registry: &AdminRegistry,
model_counts: &[(&str, &str, u64)], messages: &[FlashMessage],
csrf_token: &str,
prefix: &str,
actuator_prefix: &str,
) -> Markup {
let content = html! {
h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 1.5rem;" {
"Dashboard"
}
div class="stats-grid" {
@for (slug, name, count) in model_counts {
div class="stat-card" {
div class="stat-label" { (name) }
div class="stat-value" { (count) }
div class="stat-link" {
a href={ (prefix) "/" (slug) } { "View all →" }
}
}
}
}
div class="card" {
div class="card-header" {
span class="card-title" { "System Health" }
a href={ (actuator_prefix) "/ui" } class="btn btn-sm" { "Full Dashboard →" }
}
div hx-get={ (actuator_prefix) "/ui/metrics" } hx-trigger="load, every 5s" {
"Loading metrics…"
}
}
};
admin_layout(
registry,
None,
"Dashboard",
prefix,
actuator_prefix,
csrf_token,
messages,
&content,
)
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub fn model_list_page(
registry: &AdminRegistry,
model_slug: &str,
model_name_plural: &str,
fields: &[AdminField],
actions: &[AdminAction],
result: &ListResult,
search_query: &str,
sort_by: Option<&str>,
sort_dir: SortDirection,
filters: &[(String, String)],
messages: &[FlashMessage],
csrf_token: &str,
csrf_form_field: &str,
prefix: &str,
actuator_prefix: &str,
) -> Markup {
let list_fields: Vec<_> = fields
.iter()
.filter(|f| {
f.list_display && !matches!(f.kind, AdminFieldKind::Password | AdminFieldKind::Hidden)
})
.collect();
let search_enc = url_encode(search_query);
let filters_enc = encode_filter_suffix(filters);
let content = html! {
div class="breadcrumbs" {
a href=(prefix) { "Admin" }
span class="sep" { "›" }
span { (model_name_plural) }
}
div class="card" {
div class="card-header" {
span class="card-title" {
(model_name_plural)
span style="font-weight: 400; color: var(--text-muted); margin-left: 0.5rem;" {
"(" (result.total) ")"
}
}
a href={ (prefix) "/" (model_slug) "/new" } class="btn btn-primary" {
"+ Add " (model_slug.trim_end_matches('s'))
}
}
form class="search-bar" method="get" {
input type="search" name="q" placeholder="Search…"
value=(search_query)
hx-get={ (prefix) "/" (model_slug) }
hx-trigger="input changed delay:300ms"
hx-include="closest form"
hx-target="closest .card"
hx-select=".card > *"
hx-push-url="true" {}
@for (k, v) in filters {
input type="hidden" name={ "filter." (k) } value=(v);
}
}
form method="post" action={ (prefix) "/" (model_slug) "/actions" } {
(csrf_hidden_input(csrf_token, csrf_form_field))
div class="table-wrap" {
table {
thead {
tr {
th class="checkbox-cell" {
input type="checkbox" id="select-all";
}
@for field in &list_fields {
@let is_sorted = sort_by == Some(field.name);
@let next_dir = if is_sorted { sort_dir.flipped() } else { SortDirection::Asc };
th {
@if field.sortable {
a href={ (prefix) "/" (model_slug) "?sort=" (field.name) "&dir=" (next_dir.as_str())
@if !search_enc.is_empty() { "&q=" (search_enc) }
(filters_enc)
}
style="color: inherit; text-decoration: none;" {
(field.label)
@if is_sorted {
span class="sort-icon" {
@if matches!(sort_dir, SortDirection::Asc) { "▲" } @else { "▼" }
}
}
}
} @else {
(field.label)
}
}
}
th { "Actions" }
}
}
tbody {
@if result.records.is_empty() {
tr {
td colspan=(list_fields.len() + 2)
style="text-align: center; padding: 2rem; color: var(--text-muted);" {
"No records found."
}
}
}
@for record in &result.records {
@let row_id = record_id(record);
tr {
td class="checkbox-cell" {
@if let Some(id) = row_id {
input type="checkbox" class="row-check"
name="ids" value=(id);
}
}
@for field in &list_fields {
td { (render_cell_value(record, field)) }
}
td {
@if let Some(id) = row_id {
a href={ (prefix) "/" (model_slug) "/" (id) }
class="btn btn-sm" { "View" }
" "
a href={ (prefix) "/" (model_slug) "/" (id) "/edit" }
class="btn btn-sm" { "Edit" }
} @else {
span style="color: var(--text-muted); font-size: 0.75rem;" {
"no id"
}
}
}
}
}
}
}
}
@if !actions.is_empty() {
div class="action-bar" {
label for="bulk-action" { "With selected:" }
select name="action" id="bulk-action" class="form-input"
style="width: auto; display: inline-block;" {
@for a in actions {
option value=(a.name) data-confirm=[a.confirm.then_some("1")] {
(a.label)
}
}
}
button type="submit" class="btn" data-bulk-submit="1" {
"Apply"
}
}
}
}
@if result.total_pages() > 1 {
(render_pagination(result, model_slug, &search_enc, sort_by, sort_dir, &filters_enc, prefix))
}
}
};
admin_layout(
registry,
Some(model_slug),
model_name_plural,
prefix,
actuator_prefix,
csrf_token,
messages,
&content,
)
}
#[allow(clippy::too_many_arguments)]
pub fn model_detail_page(
registry: &AdminRegistry,
model_slug: &str,
model_name: &str,
model_name_plural: &str,
fields: &[AdminField],
record: &Value,
record_display: &str,
id: i64,
messages: &[FlashMessage],
csrf_token: &str,
prefix: &str,
actuator_prefix: &str,
) -> Markup {
let content = html! {
div class="breadcrumbs" {
a href=(prefix) { "Admin" }
span class="sep" { "›" }
a href={ (prefix) "/" (model_slug) } { (model_name_plural) }
span class="sep" { "›" }
span { (record_display) }
}
div class="card" {
div class="card-header" {
span class="card-title" { (record_display) }
div {
a href={ (prefix) "/" (model_slug) "/" (id) "/edit" }
class="btn btn-primary" { "Edit" }
" "
button class="btn btn-danger"
hx-delete={ (prefix) "/" (model_slug) "/" (id) }
hx-confirm={ "Are you sure you want to delete this " (model_name) "?" }
hx-target="body" {
"Delete"
}
}
}
div class="detail-grid" {
@for field in fields {
div class="detail-label" { (field.label) }
div class="detail-value" {
(render_detail_value(record, field))
}
}
}
}
};
admin_layout(
registry,
Some(model_slug),
record_display,
prefix,
actuator_prefix,
csrf_token,
messages,
&content,
)
}
#[allow(clippy::too_many_arguments)]
pub fn model_form_page(
registry: &AdminRegistry,
model_slug: &str,
model_name: &str,
model_name_plural: &str,
fields: &[AdminField],
record: Option<&Value>,
id: Option<i64>,
messages: &[FlashMessage],
csrf_token: &str,
csrf_form_field: &str,
prefix: &str,
actuator_prefix: &str,
) -> Markup {
let is_edit = id.is_some();
let title = if is_edit {
format!("Edit {model_name}")
} else {
format!("New {model_name}")
};
let editable_fields: Vec<_> = fields.iter().filter(|f| f.editable).collect();
let content = html! {
div class="breadcrumbs" {
a href=(prefix) { "Admin" }
span class="sep" { "›" }
a href={ (prefix) "/" (model_slug) } { (model_name_plural) }
span class="sep" { "›" }
span { (title) }
}
div class="card" {
div class="card-header" {
span class="card-title" { (title) }
}
form method="post"
action={
@if let Some(id) = id {
(prefix) "/" (model_slug) "/" (id)
} @else {
(prefix) "/" (model_slug)
}
} {
(csrf_hidden_input(csrf_token, csrf_form_field))
@for field in &editable_fields {
div class="form-group" {
label class="form-label" for=(field.name) {
(field.label)
@if field.required {
span class="required" { "*" }
}
}
(render_form_widget(field, record))
}
}
div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;" {
button type="submit" class="btn btn-primary" {
@if is_edit { "Save Changes" } @else { "Create" }
}
a href={ (prefix) "/" (model_slug) } class="btn" {
"Cancel"
}
}
}
}
};
admin_layout(
registry,
Some(model_slug),
&title,
prefix,
actuator_prefix,
csrf_token,
messages,
&content,
)
}
fn render_cell_value(record: &Value, field: &AdminField) -> Markup {
if matches!(field.kind, AdminFieldKind::Password) {
return html! { "••••••••" };
}
let val = record.get(field.name);
match val {
None | Some(Value::Null) => html! {
span style="color: var(--text-muted);" { "—" }
},
Some(Value::Bool(b)) => html! {
@if *b {
span style="color: var(--success);" { "✓" }
} @else {
span style="color: var(--text-muted);" { "✗" }
}
},
Some(Value::String(s)) => html! { (truncate_display(s, 80)) },
Some(v) => html! { (v) },
}
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match *b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(*b as char);
}
other => {
use std::fmt::Write;
let _ = write!(out, "%{other:02X}");
}
}
}
out
}
fn encode_filter_suffix(filters: &[(String, String)]) -> String {
let mut out = String::new();
for (k, v) in filters {
out.push_str("&filter.");
out.push_str(&url_encode(k));
out.push('=');
out.push_str(&url_encode(v));
}
out
}
fn truncate_display(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_owned();
}
let keep = max_chars.saturating_sub(1);
let mut out: String = s.chars().take(keep).collect();
out.push('…');
out
}
fn normalize_date_input(s: &str) -> String {
if s.is_empty() {
return String::new();
}
if chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok() {
return s.to_owned();
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
return dt.format("%Y-%m-%d").to_string();
}
if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
return ndt.format("%Y-%m-%d").to_string();
}
s.to_owned()
}
fn normalize_datetime_local_input(s: &str) -> String {
if s.is_empty() {
return String::new();
}
if chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M").is_ok() {
return s.to_owned();
}
if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
return ndt.format("%Y-%m-%dT%H:%M").to_string();
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
return dt.naive_local().format("%Y-%m-%dT%H:%M").to_string();
}
s.to_owned()
}
fn render_detail_value(record: &Value, field: &AdminField) -> Markup {
let val = record.get(field.name);
match val {
None | Some(Value::Null) => html! {
span style="color: var(--text-muted);" { "—" }
},
Some(Value::Bool(b)) => html! {
@if *b { "Yes" } @else { "No" }
},
Some(Value::String(s)) => {
if matches!(field.kind, AdminFieldKind::Password) {
html! { "••••••••" }
} else if matches!(field.kind, AdminFieldKind::TextArea | AdminFieldKind::Json) {
html! {
pre style="white-space: pre-wrap; font-size: 0.8125rem; background: var(--bg); padding: 0.75rem; border-radius: 0.375rem;" {
(s)
}
}
} else {
html! { (s) }
}
}
Some(v) => html! {
pre style="white-space: pre-wrap; font-size: 0.8125rem; background: var(--bg); padding: 0.75rem; border-radius: 0.375rem;" {
(serde_json::to_string_pretty(v).unwrap_or_default())
}
},
}
}
fn render_form_widget(field: &AdminField, record: Option<&Value>) -> Markup {
let current_value = record
.and_then(|r| r.get(field.name))
.cloned()
.unwrap_or(Value::Null);
let str_val = match ¤t_value {
Value::String(s) => s.clone(),
Value::Null => String::new(),
v => v.to_string(),
};
match &field.kind {
AdminFieldKind::Text => html! {
input type="text" class="form-input" name=(field.name) id=(field.name)
value=(str_val)
required[field.required];
},
AdminFieldKind::TextArea => html! {
textarea class="form-input" name=(field.name) id=(field.name)
required[field.required] {
(str_val)
}
},
AdminFieldKind::Integer => html! {
input type="number" class="form-input" name=(field.name) id=(field.name)
value=(str_val) step="1"
required[field.required];
},
AdminFieldKind::Float => html! {
input type="number" class="form-input" name=(field.name) id=(field.name)
value=(str_val) step="any"
required[field.required];
},
AdminFieldKind::Boolean => {
let checked = matches!(current_value, Value::Bool(true));
html! {
input type="hidden" name=(field.name) value="false";
input type="checkbox" name=(field.name) id=(field.name)
value="true" checked[checked]
style="width: auto;";
}
}
AdminFieldKind::Date => {
let v = normalize_date_input(&str_val);
html! {
input type="date" class="form-input" name=(field.name) id=(field.name)
value=(v)
required[field.required];
}
}
AdminFieldKind::DateTime => {
let v = normalize_datetime_local_input(&str_val);
html! {
input type="datetime-local" class="form-input" name=(field.name) id=(field.name)
value=(v)
required[field.required];
}
}
AdminFieldKind::Select(options) => html! {
select class="form-input" name=(field.name) id=(field.name)
required[field.required] {
option value="" { "— Select —" }
@for opt in options {
option value=(opt.value)
selected[str_val == opt.value] {
(opt.label)
}
}
}
},
AdminFieldKind::Hidden => html! {
input type="hidden" name=(field.name) value=(str_val);
},
AdminFieldKind::Password => html! {
input type="password" class="form-input" name=(field.name) id=(field.name)
placeholder="Leave blank to keep current"
autocomplete="new-password";
},
AdminFieldKind::Json => html! {
textarea class="form-input" name=(field.name) id=(field.name)
style="font-family: monospace; min-height: 150px;"
required[field.required] {
(str_val)
}
},
}
}
#[allow(clippy::too_many_arguments)]
fn render_pagination(
result: &ListResult,
model_slug: &str,
search_enc: &str,
sort_by: Option<&str>,
sort_dir: SortDirection,
filters_enc: &str,
prefix: &str,
) -> Markup {
let total_pages = result.total_pages();
let current = result.page.max(1);
let suffix = {
let mut s = String::new();
if !search_enc.is_empty() {
s.push_str("&q=");
s.push_str(search_enc);
}
if let Some(sort) = sort_by {
s.push_str("&sort=");
s.push_str(&url_encode(sort));
s.push_str("&dir=");
s.push_str(sort_dir.as_str());
}
s.push_str(filters_enc);
s
};
let base_qs = |page: u64| -> String { format!("{prefix}/{model_slug}?page={page}{suffix}") };
let start = if result.total == 0 {
0
} else {
result
.per_page
.saturating_mul(current.saturating_sub(1))
.saturating_add(1)
};
let end = start
.saturating_add(result.per_page)
.saturating_sub(1)
.min(result.total);
html! {
div class="pagination" {
span {
"Showing " (start) "–" (end) " of " (result.total)
}
div class="pagination-links" {
@if current > 1 {
a href=(base_qs(current - 1)) { "← Prev" }
}
@for page in pagination_range(current, total_pages) {
@if page == 0 {
span style="border: none; color: var(--text-muted);" { "…" }
} @else if page == current {
span class="active" { (page) }
} @else {
a href=(base_qs(page)) { (page) }
}
}
@if current < total_pages {
a href=(base_qs(current + 1)) { "Next →" }
}
}
}
}
}
fn pagination_range(current: u64, total: u64) -> Vec<u64> {
if total <= 7 {
return (1..=total).collect();
}
let mut pages = Vec::new();
pages.push(1);
if current > 3 {
pages.push(0); }
let start = current.saturating_sub(1).max(2);
let end = current.saturating_add(1).min(total.saturating_sub(1));
for p in start..=end {
pages.push(p);
}
if current < total - 2 {
pages.push(0); }
if *pages.last().unwrap_or(&0) != total {
pages.push(total);
}
pages
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pagination_range_small() {
assert_eq!(pagination_range(1, 5), vec![1, 2, 3, 4, 5]);
}
#[test]
fn pagination_range_middle() {
let result = pagination_range(5, 10);
assert!(result.contains(&1));
assert!(result.contains(&5));
assert!(result.contains(&10));
assert!(result.contains(&0)); }
#[test]
fn pagination_range_start() {
let result = pagination_range(1, 10);
assert_eq!(result[0], 1);
assert_eq!(result[1], 2);
}
#[test]
fn pagination_range_end() {
let result = pagination_range(10, 10);
assert_eq!(*result.last().unwrap(), 10);
}
#[test]
fn truncate_display_ascii() {
assert_eq!(truncate_display("hello", 10), "hello");
assert_eq!(truncate_display("hello world!", 6), "hello…");
}
#[test]
fn truncate_display_utf8_boundary_safe() {
let s = "日本語日";
assert_eq!(truncate_display(s, 3), "日本…");
assert_eq!(truncate_display(s, 10), s);
}
#[test]
fn url_encode_handles_reserved_chars() {
assert_eq!(url_encode("hello world"), "hello%20world");
assert_eq!(url_encode("a&b=c"), "a%26b%3Dc");
assert_eq!(url_encode("safe-._~"), "safe-._~");
}
#[test]
fn url_encode_handles_utf8() {
assert_eq!(url_encode("é"), "%C3%A9");
}
#[test]
fn normalize_datetime_local_accepts_expected_shape() {
assert_eq!(
normalize_datetime_local_input("2026-04-24T12:34"),
"2026-04-24T12:34"
);
}
#[test]
fn normalize_datetime_local_strips_seconds() {
assert_eq!(
normalize_datetime_local_input("2026-04-24T12:34:56"),
"2026-04-24T12:34"
);
}
#[test]
fn normalize_datetime_local_strips_rfc3339_zulu() {
assert_eq!(
normalize_datetime_local_input("2026-04-24T12:34:56Z"),
"2026-04-24T12:34"
);
}
#[test]
fn normalize_datetime_local_preserves_wall_time_across_offsets() {
assert_eq!(
normalize_datetime_local_input("2026-04-24T12:34:56+05:30"),
"2026-04-24T12:34"
);
assert_eq!(
normalize_datetime_local_input("2026-04-24T23:30:00-04:00"),
"2026-04-24T23:30"
);
}
#[test]
fn normalize_datetime_local_empty_stays_empty() {
assert_eq!(normalize_datetime_local_input(""), "");
}
#[test]
fn normalize_datetime_local_leaves_garbage_untouched() {
assert_eq!(normalize_datetime_local_input("not-a-date"), "not-a-date");
}
#[test]
fn normalize_date_accepts_expected_shape() {
assert_eq!(normalize_date_input("2026-04-24"), "2026-04-24");
}
#[test]
fn normalize_date_extracts_from_rfc3339() {
assert_eq!(normalize_date_input("2026-04-24T12:34:56Z"), "2026-04-24");
}
fn dummy_registry() -> AdminRegistry {
AdminRegistry::new()
}
#[test]
fn dashboard_emits_csrf_meta_and_script() {
let r = dummy_registry();
let html = dashboard_page(&r, &[], &[], "tok-123", "/admin", "/ops").into_string();
assert!(
html.contains(r#"<meta name="csrf-token" content="tok-123""#),
"CSRF meta tag missing: {html}"
);
assert!(
html.contains("/static/js/autumn-htmx-csrf.js"),
"HTMX CSRF helper script not loaded: {html}"
);
}
#[test]
fn dashboard_uses_configured_actuator_prefix() {
let r = dummy_registry();
let html = dashboard_page(&r, &[], &[], "tok", "/admin", "/ops").into_string();
assert!(
html.contains(r#"href="/ops/ui""#),
"sidebar link wrong: {html}"
);
assert!(
html.contains(r#"hx-get="/ops/ui/metrics""#),
"metrics polling URL wrong: {html}"
);
assert!(
!html.contains("/actuator/"),
"must not hardcode /actuator when prefix is /ops: {html}"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn jobs_page_renders_lists_actions_polling_and_csrf() {
use autumn_web::job::{
JobAdminPage, JobAdminRecord, JobAdminSnapshot, JobAdminStatus, JobScheduleSummary,
};
let r = dummy_registry();
let snapshot = JobAdminSnapshot {
enqueued: JobAdminPage::new(
vec![JobAdminRecord {
id: "job-enqueued".to_owned(),
name: "send_email".to_owned(),
status: JobAdminStatus::Enqueued,
enqueued_at: Some("2026-05-07T10:00:00Z".to_owned()),
started_at: None,
finished_at: None,
attempt: 1,
max_attempts: 5,
last_error: None,
principal_id: Some("42".to_owned()),
correlation_id: Some("req-123".to_owned()),
}],
1,
1,
25,
),
running: JobAdminPage::new(
vec![JobAdminRecord {
id: "job-running".to_owned(),
name: "reindex".to_owned(),
status: JobAdminStatus::Running,
enqueued_at: Some("2026-05-07T10:01:00Z".to_owned()),
started_at: Some("2026-05-07T10:02:00Z".to_owned()),
finished_at: None,
attempt: 1,
max_attempts: 3,
last_error: None,
principal_id: None,
correlation_id: None,
}],
1,
1,
25,
),
completed: JobAdminPage::new(
vec![JobAdminRecord {
id: "job-complete".to_owned(),
name: "digest".to_owned(),
status: JobAdminStatus::Completed,
enqueued_at: Some("2026-05-07T09:00:00Z".to_owned()),
started_at: Some("2026-05-07T09:01:00Z".to_owned()),
finished_at: Some("2026-05-07T09:02:00Z".to_owned()),
attempt: 1,
max_attempts: 3,
last_error: None,
principal_id: None,
correlation_id: None,
}],
1,
1,
25,
),
failed: JobAdminPage::new(
vec![JobAdminRecord {
id: "job-failed".to_owned(),
name: "send_email".to_owned(),
status: JobAdminStatus::Failed,
enqueued_at: Some("2026-05-07T08:00:00Z".to_owned()),
started_at: Some("2026-05-07T08:01:00Z".to_owned()),
finished_at: Some("2026-05-07T08:02:00Z".to_owned()),
attempt: 5,
max_attempts: 5,
last_error: Some("smtp refused recipient".repeat(6)),
principal_id: Some("7".to_owned()),
correlation_id: None,
}],
1,
1,
25,
),
schedules: vec![JobScheduleSummary {
name: "send-digest".to_owned(),
schedule: "every 1h".to_owned(),
next_run_at: None,
last_run_status: Some("ok".to_owned()),
}],
bounded_history_limit: 1_000,
};
let html = jobs_page(
&r,
&snapshot,
&[],
"tok-job",
"authenticity_token",
"/admin",
"/actuator",
)
.into_string();
assert!(html.contains("Jobs"));
assert!(html.contains("Enqueued"));
assert!(html.contains("Running"));
assert!(html.contains("Completed (last 24h)"));
assert!(html.contains("Failed (last 7d)"));
assert!(html.contains("send_email"));
assert!(html.contains("req-123"));
assert!(html.contains(r#"action="/admin/jobs/job-failed/retry""#));
assert!(html.contains(r#"action="/admin/jobs/job-failed/discard""#));
assert!(html.contains(r#"action="/admin/jobs/job-enqueued/cancel""#));
assert!(html.contains(r#"name="authenticity_token" value="tok-job""#));
assert!(!html.contains(r#"name="_csrf" value="tok-job""#));
assert!(html.contains(r#"hx-get="/admin/jobs/counters""#));
assert!(html.contains(r#"hx-trigger="load, every 2s""#));
assert!(html.contains("send-digest"));
}
#[test]
fn jobs_counters_fragment_preserves_polling_after_outer_swap() {
use autumn_web::job::JobAdminSnapshot;
let html = jobs_counters(&JobAdminSnapshot::empty(), "/admin").into_string();
assert!(html.contains(r#"id="jobs-counters""#));
assert!(html.contains(r#"hx-get="/admin/jobs/counters""#));
assert!(html.contains(r#"hx-trigger="load, every 2s""#));
assert!(html.contains(r#"hx-swap="outerHTML""#));
}
#[test]
fn form_page_renders_hidden_csrf_input() {
let r = dummy_registry();
let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
let html = model_form_page(
&r,
"widgets",
"Widget",
"Widgets",
&fields,
None,
None,
&[],
"tok-xyz",
"authenticity_token",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.contains(r#"<input type="hidden" name="authenticity_token" value="tok-xyz""#),
"custom CSRF hidden field missing: {html}"
);
assert!(!html.contains(r#"name="_csrf" value="tok-xyz""#));
}
#[test]
fn form_page_normalizes_datetime_for_browser_input() {
let r = dummy_registry();
let fields = vec![AdminField::new("created_at", AdminFieldKind::DateTime)];
let record = serde_json::json!({"id": 1, "created_at": "2026-04-24T12:34:56Z"});
let html = model_form_page(
&r,
"widgets",
"Widget",
"Widgets",
&fields,
Some(&record),
Some(1),
&[],
"t",
"_csrf",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.contains(r#"value="2026-04-24T12:34""#),
"datetime-local input should carry browser-friendly value: {html}"
);
assert!(
!html.contains(r#"value="2026-04-24T12:34:56Z""#),
"raw RFC3339 must not reach datetime-local input: {html}"
);
}
#[test]
fn form_page_action_uses_path_id_not_payload_id() {
let r = dummy_registry();
let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
let record = serde_json::json!({"id": 99, "name": "x"});
let html = model_form_page(
&r,
"widgets",
"Widget",
"Widgets",
&fields,
Some(&record),
Some(42),
&[],
"t",
"_csrf",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.contains(r#"action="/admin/widgets/42""#),
"form action should use path-based id 42, not payload id 99: {html}"
);
assert!(
!html.contains(r#"action="/admin/widgets/99""#),
"payload-derived id must not appear in form action: {html}"
);
}
#[test]
fn detail_page_edit_delete_links_use_path_id() {
let r = dummy_registry();
let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
let record = serde_json::json!({"id": 99, "name": "x"});
let html = model_detail_page(
&r,
"widgets",
"Widget",
"Widgets",
&fields,
&record,
"#42",
42,
&[],
"t",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.contains(r#"href="/admin/widgets/42/edit""#),
"Edit link must use path id 42: {html}"
);
assert!(
html.contains(r#"hx-delete="/admin/widgets/42""#),
"Delete must target path id 42: {html}"
);
assert!(
!html.contains("widgets/99"),
"payload id 99 must not route mutations: {html}"
);
}
#[test]
fn detail_view_escapes_malicious_json() {
let r = dummy_registry();
let fields = vec![AdminField::new("meta", AdminFieldKind::Json)];
let record = serde_json::json!({
"id": 1,
"meta": {"xss": "<script>alert(1)</script>"},
});
let html = model_detail_page(
&r,
"widgets",
"Widget",
"Widgets",
&fields,
&record,
"#1",
1,
&[],
"t",
"/admin",
"/actuator",
)
.into_string();
assert!(
!html.contains("<script>alert(1)</script>"),
"raw <script> must be escaped: {html}"
);
assert!(
html.contains("<script>alert(1)</script>"),
"escaped form expected: {html}"
);
}
#[test]
fn layout_loads_external_admin_js_not_inline() {
let r = dummy_registry();
let html = dashboard_page(&r, &[], &[], "t", "/admin", "/actuator").into_string();
let expected = format!(r#"src="/admin{}""#, &**ADMIN_JS_PATH);
assert!(
html.contains(&expected),
"admin.js must be referenced as an external script at {expected}: {html}"
);
assert!(
html.contains("/admin/static/admin.") && html.contains(".js\""),
"admin.js URL should be fingerprinted (admin.<hash>.js): {html}"
);
assert!(
!html.contains(r#"src="/admin/static/admin.js""#),
"unfingerprinted URL would invalidate immutable caching: {html}"
);
assert!(
!html.contains("onclick=\""),
"no inline event handlers allowed under default CSP: {html}"
);
}
#[test]
fn list_page_hides_hidden_fields_even_if_list_display_true() {
use crate::traits::ListResult;
let r = dummy_registry();
let fields = vec![
AdminField::new("name", AdminFieldKind::Text),
AdminField::new("internal_token", AdminFieldKind::Hidden),
];
let result = ListResult {
records: vec![serde_json::json!({
"id": 1,
"name": "alice",
"internal_token": "INT-9999",
})],
total: 1,
page: 1,
per_page: 25,
};
let html = model_list_page(
&r,
"users",
"Users",
&fields,
&[],
&result,
"",
None,
SortDirection::Asc,
&[],
&[],
"t",
"_csrf",
"/admin",
"/actuator",
)
.into_string();
assert!(
!html.contains("INT-9999"),
"hidden field value must not surface in list view: {html}"
);
assert!(
!html.contains("internal_token") && !html.contains("Internal Token"),
"hidden field column header must not appear: {html}"
);
}
#[test]
fn list_page_hides_password_fields_even_if_list_display_true() {
use crate::traits::ListResult;
let r = dummy_registry();
let fields = vec![
AdminField::new("name", AdminFieldKind::Text),
AdminField::new("password_hash", AdminFieldKind::Password),
];
let result = ListResult {
records: vec![serde_json::json!({
"id": 1,
"name": "alice",
"password_hash": "$argon2id$leaked",
})],
total: 1,
page: 1,
per_page: 25,
};
let html = model_list_page(
&r,
"users",
"Users",
&fields,
&[],
&result,
"",
None,
SortDirection::Asc,
&[],
&[],
"t",
"_csrf",
"/admin",
"/actuator",
)
.into_string();
assert!(
!html.contains("$argon2id$leaked"),
"raw password hash must not appear in list view: {html}"
);
assert!(
!html.contains("password_hash"),
"password column must not have a header in list view: {html}"
);
}
#[test]
fn list_page_handles_records_without_numeric_id() {
use crate::traits::ListResult;
let r = dummy_registry();
let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
let result = ListResult {
records: vec![
serde_json::json!({"id": 7, "name": "with id"}),
serde_json::json!({"name": "no id"}),
],
total: 2,
page: 1,
per_page: 25,
};
let html = model_list_page(
&r,
"widgets",
"Widgets",
&fields,
&[],
&result,
"",
None,
SortDirection::Asc,
&[],
&[],
"t",
"_csrf",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.contains(r#"href="/admin/widgets/7""#),
"row with id should have working View link: {html}"
);
assert!(
html.contains(r#"<span style="color: var(--text-muted); font-size: 0.75rem;">no id"#)
|| html.contains("no id</span>"),
"row without id should show 'no id' placeholder: {html}"
);
assert!(
!html.contains("/admin/widgets/0"),
"must not generate /0 links for rows missing id: {html}"
);
}
#[test]
fn list_page_carries_filters_into_sort_and_pagination_links() {
use crate::traits::ListResult;
let r = dummy_registry();
let mut name = AdminField::new("name", AdminFieldKind::Text);
name.sortable = true;
let fields = vec![name];
let result = ListResult {
records: vec![serde_json::json!({"id": 1, "name": "alice"})],
total: 60,
page: 1,
per_page: 25,
};
let active_filters = vec![
("status".to_owned(), "active".to_owned()),
("tier".to_owned(), "premium".to_owned()),
];
let html = model_list_page(
&r,
"users",
"Users",
&fields,
&[],
&result,
"",
None,
SortDirection::Asc,
&active_filters,
&[],
"t",
"_csrf",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.contains("filter.status=active"),
"sort link must preserve filter.status: {html}"
);
assert!(
html.contains("filter.tier=premium"),
"sort link must preserve filter.tier: {html}"
);
assert!(
html.contains("page=2") && html.contains("filter.status=active"),
"pagination link must preserve filter.status: {html}"
);
}
#[test]
fn search_form_carries_filters_as_hidden_inputs() {
use crate::traits::ListResult;
let r = dummy_registry();
let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
let result = ListResult {
records: vec![],
total: 0,
page: 1,
per_page: 25,
};
let active_filters = vec![
("status".to_owned(), "active".to_owned()),
("tier".to_owned(), "premium".to_owned()),
];
let html = model_list_page(
&r,
"users",
"Users",
&fields,
&[],
&result,
"",
None,
SortDirection::Asc,
&active_filters,
&[],
"t",
"_csrf",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.contains(r#"<input type="hidden" name="filter.status" value="active""#),
"search form should preserve filter.status: {html}"
);
assert!(
html.contains(r#"<input type="hidden" name="filter.tier" value="premium""#),
"search form should preserve filter.tier: {html}"
);
assert!(
html.contains(r#"hx-include="closest form""#),
"search input must hx-include the form so live-search carries filters: {html}"
);
}
#[test]
fn list_page_url_encodes_filter_values() {
use crate::traits::ListResult;
let r = dummy_registry();
let mut name = AdminField::new("name", AdminFieldKind::Text);
name.sortable = true;
let fields = vec![name];
let result = ListResult {
records: vec![],
total: 0,
page: 1,
per_page: 25,
};
let active_filters = vec![("q".to_owned(), "a&b=c".to_owned())];
let html = model_list_page(
&r,
"users",
"Users",
&fields,
&[],
&result,
"",
None,
SortDirection::Asc,
&active_filters,
&[],
"t",
"_csrf",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.contains("filter.q=a%26b%3Dc"),
"filter values must be percent-encoded in generated links: {html}"
);
}
#[test]
fn list_page_renders_bulk_action_form() {
use crate::traits::{ActionStyle, ListResult};
let r = dummy_registry();
let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
let actions = vec![
AdminAction {
name: "delete",
label: "Delete selected".to_owned(),
style: ActionStyle::Danger,
confirm: true,
},
AdminAction {
name: "archive",
label: "Archive".to_owned(),
style: ActionStyle::Default,
confirm: false,
},
];
let result = ListResult {
records: vec![serde_json::json!({"id": 1, "name": "x"})],
total: 1,
page: 1,
per_page: 25,
};
let html = model_list_page(
&r,
"widgets",
"Widgets",
&fields,
&actions,
&result,
"",
None,
SortDirection::Asc,
&[],
&[],
"tok",
"admin_csrf",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.contains(r#"action="/admin/widgets/actions""#),
"list view must wrap table in a form posting to /actions: {html}"
);
assert!(
html.contains(r#"name="admin_csrf" value="tok""#),
"configured CSRF token field must be in the bulk-action form: {html}"
);
assert!(!html.contains(r#"name="_csrf" value="tok""#));
assert!(html.contains(r#"value="delete""#));
assert!(html.contains(r#"value="archive""#));
assert!(
html.contains(r#"data-confirm="1""#),
"destructive action should set data-confirm: {html}"
);
}
#[test]
fn list_page_skips_action_bar_when_no_actions_declared() {
use crate::traits::ListResult;
let r = dummy_registry();
let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
let result = ListResult {
records: vec![],
total: 0,
page: 1,
per_page: 25,
};
let html = model_list_page(
&r,
"widgets",
"Widgets",
&fields,
&[], &result,
"",
None,
SortDirection::Asc,
&[],
&[],
"t",
"_csrf",
"/admin",
"/actuator",
)
.into_string();
assert!(
!html.contains("class=\"action-bar\""),
"no action-bar should render when actions is empty: {html}"
);
}
#[test]
fn list_page_omits_sort_link_for_unsortable_fields() {
use crate::traits::ListResult;
let r = dummy_registry();
let mut computed = AdminField::new("computed", AdminFieldKind::Text).label("Computed");
computed.sortable = false;
let fields = vec![AdminField::new("name", AdminFieldKind::Text), computed];
let result = ListResult {
records: vec![],
total: 0,
page: 1,
per_page: 25,
};
let html = model_list_page(
&r,
"widgets",
"Widgets",
&fields,
&[],
&result,
"",
None,
SortDirection::Asc,
&[],
&[],
"tok",
"_csrf",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.contains(r#"href="/admin/widgets?sort=name"#),
"sortable field should have a sort link: {html}"
);
assert!(
!html.contains("sort=computed"),
"non-sortable field must not emit a sort link: {html}"
);
assert!(
html.contains("Computed"),
"label should still render: {html}"
);
}
#[test]
fn admin_js_does_not_contain_inline_event_handlers() {
let js = include_str!("admin.js");
assert!(
js.contains("select-all"),
"admin.js should wire the select-all checkbox"
);
assert!(
js.contains("removeAttribute(\"name\")"),
"admin.js should strip blank password input names"
);
}
#[test]
fn pagination_range_start_underflow_protection() {
let result = crate::traits::ListResult {
total: 10,
per_page: 5,
page: 0,
records: vec![],
};
let _ = render_pagination(
&result,
"y",
"x",
None,
crate::traits::SortDirection::Asc,
"",
"",
);
}
}