use autumn_web::flash::FlashMessage;
use autumn_web::job::{
JobAdminPage, JobAdminRecord, JobAdminSnapshot, JobAdminStatus, JobScheduleSummary,
};
use autumn_web::runtime_config::{ConfigChangeRecord, ConfigEntry};
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, AdminHistoryPage, AdminImportReport, CsvImportMode,
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 RUNTIME_CONFIG_NAV_SLUG: &str = "__admin_config";
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,
csrf_token_header: &str,
messages: &[FlashMessage],
show_config: bool,
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) data-header=(csrf_token_header);
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"
}
}
@if show_config {
li {
a href={ (prefix) "/config" }
class=[(active_slug == Some(RUNTIME_CONFIG_NAV_SLUG)).then_some("active")] {
"Runtime Config"
}
}
}
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);
}
}
#[allow(clippy::too_many_arguments)]
pub fn jobs_page(
registry: &AdminRegistry,
snapshot: &JobAdminSnapshot,
messages: &[FlashMessage],
csrf_token: &str,
csrf_form_field: &str,
csrf_token_header: &str,
prefix: &str,
actuator_prefix: &str,
show_config: bool,
) -> 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,
csrf_token_header,
messages,
show_config,
&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) },
)
}
#[allow(clippy::too_many_arguments)]
pub fn dashboard_page(
registry: &AdminRegistry,
model_counts: &[(&str, &str, u64)], messages: &[FlashMessage],
csrf_token: &str,
csrf_token_header: &str,
prefix: &str,
actuator_prefix: &str,
show_config: bool,
) -> 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,
csrf_token_header,
messages,
show_config,
&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,
csrf_token_header: &str,
prefix: &str,
actuator_prefix: &str,
show_config: bool,
supports_csv_export: bool,
supports_csv_import: bool,
) -> 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 export_csv_url = {
let mut params: Vec<String> = Vec::new();
if !search_enc.is_empty() {
params.push(format!("q={search_enc}"));
}
if let Some(sort) = sort_by {
params.push(format!("sort={}", url_encode(sort)));
params.push(format!("dir={}", sort_dir.as_str()));
}
for (k, v) in filters {
params.push(format!("filter.{}={}", url_encode(k), url_encode(v)));
}
if params.is_empty() {
format!("{prefix}/{model_slug}/export.csv")
} else {
format!("{prefix}/{model_slug}/export.csv?{}", params.join("&"))
}
};
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) ")"
}
}
div style="display: flex; gap: 0.5rem; align-items: center;" {
@if supports_csv_export {
a href=(export_csv_url) class="btn btn-sm"
title="Download all matching records as CSV" {
"⬇ Download CSV"
}
}
@if supports_csv_import {
a href={ (prefix) "/" (model_slug) "/import" } class="btn btn-sm"
title="Upload a CSV file to import records" {
"⬆ Import CSV"
}
}
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,
csrf_token_header,
messages,
show_config,
&content,
)
}
#[allow(clippy::too_many_arguments)]
pub fn model_import_form_page(
registry: &AdminRegistry,
model_slug: &str,
model_name_plural: &str,
messages: &[FlashMessage],
csrf_token: &str,
csrf_form_field: &str,
csrf_token_header: &str,
prefix: &str,
actuator_prefix: &str,
show_config: bool,
) -> 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 { "Import CSV" }
}
div class="card" {
div class="card-header" {
span class="card-title" { "Import " (model_name_plural) " from CSV" }
}
div style="padding: 1.5rem;" {
p style="color: var(--text-muted); margin-bottom: 1rem;" {
"Upload a CSV file with a header row. Column names must match the model's field names."
}
form id="autumn-csv-import-form"
method="post"
action={ (prefix) "/" (model_slug) "/import?" (csrf_form_field) "=" (csrf_token) }
enctype="multipart/form-data" {
(csrf_hidden_input(csrf_token, csrf_form_field))
div style="margin-bottom: 1rem;" {
label for="csv-file" style="display: block; margin-bottom: 0.25rem; font-weight: 500;" {
"CSV File"
}
input type="file" id="csv-file" name="file"
accept=".csv,text/csv"
required
class="form-input" {}
}
div style="margin-bottom: 1.5rem;" {
label for="import-mode" style="display: block; margin-bottom: 0.25rem; font-weight: 500;" {
"Import Mode"
}
select id="import-mode" name="mode" class="form-input"
style="width: auto; display: inline-block;" {
option value="insert" selected { "Insert (add as new records)" }
option value="dry_run" { "Dry Run (validate only, no writes)" }
}
}
div style="display: flex; gap: 0.75rem; align-items: center;" {
button type="submit" class="btn btn-primary" { "Upload and Import" }
a href={ (prefix) "/" (model_slug) } class="btn" { "Cancel" }
}
}
div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border);" {
h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.5rem;" {
"Tips"
}
ul style="color: var(--text-muted); font-size: 0.875rem; padding-left: 1.25rem;" {
li { "The first row must be a header row with column names." }
li { "Column names must match the model's field names." }
li { "Use Dry Run to preview the import and catch errors before writing." }
li {
"Download a template: "
a href={ (prefix) "/" (model_slug) "/export.csv" } { "export.csv" }
}
}
}
}
}
};
admin_layout(
registry,
Some(model_slug),
model_name_plural,
prefix,
actuator_prefix,
csrf_token,
csrf_token_header,
messages,
show_config,
&content,
)
}
#[allow(clippy::too_many_arguments)]
pub fn model_import_result_page(
registry: &AdminRegistry,
model_slug: &str,
model_name_plural: &str,
report: &AdminImportReport,
mode: CsvImportMode,
messages: &[FlashMessage],
csrf_token: &str,
csrf_token_header: &str,
prefix: &str,
actuator_prefix: &str,
show_config: bool,
) -> Markup {
let mode_label = match mode {
CsvImportMode::DryRun => "Dry Run",
CsvImportMode::Insert => "Insert",
};
let total = report.inserted + report.updated + report.skipped + report.errors.len() as u64;
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 { "Import Result" }
}
div class="card" {
div class="card-header" {
span class="card-title" { "Import Report — " (mode_label) }
}
div style="padding: 1.5rem;" {
div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.5rem;" {
div style="text-align: center; padding: 1rem; background: var(--success-light); border-radius: 0.375rem;" {
div style="font-size: 1.5rem; font-weight: 700; color: var(--success);" { (report.inserted) }
div style="font-size: 0.75rem; color: var(--text-muted);" { "Inserted" }
}
div style="text-align: center; padding: 1rem; background: var(--primary-light); border-radius: 0.375rem;" {
div style="font-size: 1.5rem; font-weight: 700; color: var(--primary);" { (report.updated) }
div style="font-size: 0.75rem; color: var(--text-muted);" { "Updated" }
}
div style="text-align: center; padding: 1rem; background: var(--border); border-radius: 0.375rem;" {
div style="font-size: 1.5rem; font-weight: 700;" { (report.skipped) }
div style="font-size: 0.75rem; color: var(--text-muted);" { "Skipped" }
}
div style="text-align: center; padding: 1rem; background: var(--danger-light); border-radius: 0.375rem;" {
div style="font-size: 1.5rem; font-weight: 700; color: var(--danger);" { (report.errors.len()) }
div style="font-size: 0.75rem; color: var(--text-muted);" { "Errors" }
}
}
p style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1.5rem;" {
"Processed " (total) " data rows."
@if matches!(mode, CsvImportMode::DryRun) {
" (Dry run — no records were written.)"
}
}
@if !report.errors.is_empty() {
h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem; color: var(--danger);" {
"Row Errors"
}
div class="table-wrap" {
table {
thead {
tr {
th { "Line" }
th { "Column" }
th { "Message" }
}
}
tbody {
@for err in &report.errors {
tr {
td { (err.line) }
td {
@if let Some(col) = &err.column {
code { (col) }
} @else {
span style="color: var(--text-muted);" { "—" }
}
}
td { (err.message) }
}
}
}
}
}
}
div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;" {
a href={ (prefix) "/" (model_slug) } class="btn btn-primary" { "Back to list" }
a href={ (prefix) "/" (model_slug) "/import" } class="btn" { "Import another file" }
}
}
}
};
admin_layout(
registry,
Some(model_slug),
model_name_plural,
prefix,
actuator_prefix,
csrf_token,
csrf_token_header,
messages,
show_config,
&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,
csrf_token_header: &str,
prefix: &str,
actuator_prefix: &str,
has_history: bool,
show_config: bool,
) -> 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 {
@if has_history {
a href={ (prefix) "/" (model_slug) "/" (id) "/history" }
class="btn btn-secondary" { "History" }
" "
}
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,
csrf_token_header,
messages,
show_config,
&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,
csrf_token_header: &str,
prefix: &str,
actuator_prefix: &str,
show_config: bool,
) -> 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,
csrf_token_header,
messages,
show_config,
&content,
)
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub fn config_page(
registry: &AdminRegistry,
entries: &[ConfigEntry],
messages: &[FlashMessage],
csrf_token: &str,
csrf_form_field: &str,
csrf_token_header: &str,
prefix: &str,
actuator_prefix: &str,
) -> Markup {
let content = html! {
div class="breadcrumbs" {
a href=(prefix) { "Admin" }
span class="sep" { "›" }
span { "Runtime Config" }
}
h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem;" {
"Runtime Config"
}
p style="color: var(--text-muted); margin-bottom: 1.5rem; font-size: 0.875rem;" {
"Live-tunable operational knobs. Changes take effect immediately without a restart."
}
@if entries.is_empty() {
div class="card" {
p style="color: var(--text-muted); padding: 1rem;" {
"No config keys have been registered. Declare keys with "
code { "ConfigRegistry::define" }
" and pass the service via "
code { "AdminPlugin::with_runtime_config" }
"."
}
}
} @else {
div class="card" {
table class="table" {
thead {
tr {
th { "Key" }
th { "Type" }
th { "Current Value" }
th { "Default" }
th { "Status" }
th { "Actions" }
}
}
tbody {
@for entry in entries {
tr {
td {
strong { (entry.name) }
@if let Some(desc) = &entry.description {
br;
span style="color: var(--text-muted); font-size: 0.8125rem;" {
(desc)
}
}
}
td { code style="font-size: 0.8125rem;" { (entry.value_type) } }
td { code style="font-size: 0.8125rem;" { (entry.current.to_raw()) } }
td {
code style="font-size: 0.8125rem; color: var(--text-muted);" {
(entry.default.to_raw())
}
}
td {
@if entry.is_overridden {
span style="color: var(--warning); font-size: 0.8125rem; font-weight: 500;" {
"overridden"
}
} @else {
span style="color: var(--text-muted); font-size: 0.8125rem;" {
"default"
}
}
}
td {
div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;" {
form method="post"
action={ (prefix) "/config/" (entry.name) "/set" }
style="display: flex; gap: 0.25rem; align-items: center;" {
(csrf_hidden_input(csrf_token, csrf_form_field))
input type="text" name="value"
value=(entry.current.to_raw())
style="width: 11rem; font-size: 0.8125rem; padding: 0.25rem 0.5rem; border: 1px solid var(--border); border-radius: 0.25rem;" {}
button type="submit" class="btn btn-sm btn-primary" { "Save" }
}
@if entry.is_overridden {
form method="post"
action={ (prefix) "/config/" (entry.name) "/unset" } {
(csrf_hidden_input(csrf_token, csrf_form_field))
button type="submit" class="btn btn-sm" { "Reset" }
}
}
a href={ (prefix) "/config/" (entry.name) "/history" }
class="btn btn-sm" { "History" }
}
}
}
}
}
}
}
}
};
admin_layout(
registry,
Some(RUNTIME_CONFIG_NAV_SLUG),
"Runtime Config",
prefix,
actuator_prefix,
csrf_token,
csrf_token_header,
messages,
true,
&content,
)
}
#[allow(clippy::too_many_arguments)]
pub fn config_history_page(
registry: &AdminRegistry,
key: &str,
history: &[ConfigChangeRecord],
messages: &[FlashMessage],
csrf_token: &str,
csrf_token_header: &str,
prefix: &str,
actuator_prefix: &str,
) -> Markup {
let title = format!("History: {key}");
let content = html! {
div class="breadcrumbs" {
a href=(prefix) { "Admin" }
span class="sep" { "›" }
a href={ (prefix) "/config" } { "Runtime Config" }
span class="sep" { "›" }
span { (key) }
}
h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 1rem;" {
"History: " (key)
}
div class="card" {
@if history.is_empty() {
p style="color: var(--text-muted); padding: 1rem;" {
"No changes recorded for this key yet."
}
} @else {
table class="table" {
thead {
tr {
th { "Timestamp (UTC)" }
th { "Actor" }
th { "Old Value" }
th { "New Value" }
}
}
tbody {
@for record in history {
tr {
td {
code style="font-size: 0.8125rem;" {
(format_timestamp(record.timestamp_secs))
}
}
td { (record.actor.as_deref().unwrap_or("—")) }
td {
@match &record.old_value {
Some(v) => {
code style="font-size: 0.8125rem;" { (v.to_raw()) }
}
None => {
span style="color: var(--text-muted);" { "—" }
}
}
}
td {
@match &record.new_value {
Some(v) => {
code style="font-size: 0.8125rem;" { (v.to_raw()) }
}
None => {
span style="color: var(--text-muted); font-style: italic;" {
"reset to default"
}
}
}
}
}
}
}
}
}
}
a href={ (prefix) "/config" } class="btn" style="margin-top: 1rem;" {
"← Back to Runtime Config"
}
};
admin_layout(
registry,
Some(RUNTIME_CONFIG_NAV_SLUG),
&title,
prefix,
actuator_prefix,
csrf_token,
csrf_token_header,
messages,
true,
&content,
)
}
fn format_timestamp(ts: u64) -> String {
use chrono::{DateTime, Utc};
let secs = i64::try_from(ts).unwrap_or(i64::MAX);
DateTime::from_timestamp(secs, 0).map_or_else(
|| ts.to_string(),
|dt: DateTime<Utc>| dt.format("%Y-%m-%d %H:%M:%S").to_string(),
)
}
fn render_cell_value(record: &Value, field: &AdminField) -> Markup {
if matches!(field.kind, AdminFieldKind::Password) {
return html! { "••••••••" };
}
if field.encrypted && !field.encrypted_visible {
return html! { span title="encrypted at rest" { "••••••••" } };
}
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 {
if field.encrypted && !field.encrypted_visible {
return html! { span title="encrypted at rest" { "••••••••" } };
}
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 {
if field.encrypted && record.is_some() {
return html! {
input type="text" class="form-input" value="••••••••" disabled
title="Encrypted at rest — managed outside the admin";
};
}
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
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub fn model_history_page(
registry: &AdminRegistry,
model_slug: &str,
model_name: &str,
model_name_plural: &str,
record_id_val: i64,
history: &AdminHistoryPage,
prefix: &str,
actuator_prefix: &str,
csrf_token_header: &str,
show_config: bool,
) -> Markup {
let record_display = format!("{model_name} #{record_id_val}");
let history_page_href = |page: u64| {
format!(
"{prefix}/{model_slug}/{record_id_val}/history?page={page}&per_page={}",
history.per_page
)
};
let empty_messages: &[FlashMessage] = &[];
let content = html! {
div class="breadcrumbs" {
a href=(prefix) { "Admin" }
span class="sep" { "›" }
a href={ (prefix) "/" (model_slug) } { (model_name_plural) }
span class="sep" { "›" }
a href={ (prefix) "/" (model_slug) "/" (record_id_val) } { (record_display) }
span class="sep" { "›" }
span { "History" }
}
div class="card" {
div class="card-header" {
span class="card-title" { "Version History" }
small { " " (history.total) " entries" }
}
@if history.entries.is_empty() {
p class="text-muted" style="padding:1rem" { "No history entries yet." }
} @else {
table class="admin-table" {
thead {
tr {
th { "#" }
th { "Operation" }
th { "Actor" }
th { "Request ID" }
th { "Changes" }
th { "Recorded At" }
}
}
tbody {
@for entry in &history.entries {
tr {
td { (entry.id) }
td {
span class={ "badge badge-" (entry.op) } { (entry.op) }
}
td { code { (entry.actor) } }
td {
@if let Some(ref req_id) = entry.request_id {
code class="text-muted" { (req_id) }
} @else {
span class="text-muted" { "—" }
}
}
td {
@if entry.changes.is_empty() {
span class="text-muted" { "no changes" }
} @else {
details {
summary { (entry.changes.len()) " column(s)" }
ul class="change-list" {
@for change in &entry.changes {
li {
@if let Some(col) = change.get("column").and_then(Value::as_str) {
code { (col) }
}
@if change.get("sensitive").and_then(Value::as_bool).unwrap_or(false) {
span class="badge-sensitive" { " [sensitive]" }
} @else {
" "
span class="text-muted" { "before: " }
@if let Some(before) = change.get("before") {
code { (before) }
} @else {
em { "null" }
}
" → "
span class="text-muted" { "after: " }
@if let Some(after) = change.get("after") {
code { (after) }
} @else {
em { "null" }
}
}
}
}
}
}
}
}
td {
time datetime=(entry.recorded_at.to_rfc3339()) {
(entry.recorded_at.format("%Y-%m-%d %H:%M:%S UTC"))
}
}
}
}
}
}
@if history.total_pages() > 1 {
div class="pagination" {
@if history.page > 1 {
a href=(history_page_href(history.page - 1))
class="btn btn-secondary btn-sm" { "← Prev" }
}
span { " Page " (history.page) " of " (history.total_pages()) " " }
@if history.has_next_page() {
a href=(history_page_href(history.page + 1))
class="btn btn-secondary btn-sm" { "Next →" }
}
}
}
}
}
};
admin_layout(
registry,
Some(model_slug),
&record_display,
prefix,
actuator_prefix,
"",
csrf_token_header,
empty_messages,
show_config,
&content,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypted_columns_are_redacted_in_admin_views() {
let record = serde_json::json!({ "id": 1, "ssn": "123-45-6789" });
let field = AdminField::new("ssn", AdminFieldKind::Text).encrypted();
let cell = render_cell_value(&record, &field).into_string();
let detail = render_detail_value(&record, &field).into_string();
assert!(
!cell.contains("123-45-6789"),
"list cell must redact: {cell}"
);
assert!(
!detail.contains("123-45-6789"),
"detail must redact: {detail}"
);
assert!(cell.contains("••••••••"));
assert!(detail.contains("••••••••"));
}
#[test]
fn admin_visible_encrypted_column_renders_plaintext_in_views() {
let record = serde_json::json!({ "id": 1, "audit_note": "visible-note" });
let field = AdminField::new("audit_note", AdminFieldKind::Text).encrypted_visible();
let cell = render_cell_value(&record, &field).into_string();
let detail = render_detail_value(&record, &field).into_string();
assert!(cell.contains("visible-note"), "admin_visible cell: {cell}");
assert!(
detail.contains("visible-note"),
"admin_visible detail: {detail}"
);
}
#[test]
fn edit_form_never_prefills_encrypted_plaintext() {
let record = serde_json::json!({ "ssn": "123-45-6789", "audit_note": "visible-note" });
for field in [
AdminField::new("ssn", AdminFieldKind::Text).encrypted(),
AdminField::new("audit_note", AdminFieldKind::Text).encrypted_visible(),
] {
let col = field.name;
let form = render_form_widget(&field, Some(&record)).into_string();
assert!(
!form.contains("123-45-6789") && !form.contains("visible-note"),
"edit form must not pre-fill encrypted plaintext for {col}: {form}"
);
assert!(form.contains("disabled"), "edit control disabled: {form}");
assert!(
!form.contains("name="),
"edit control must not submit: {form}"
);
}
}
#[test]
fn create_form_allows_setting_initial_encrypted_value() {
let field = AdminField::new("ssn", AdminFieldKind::Text).encrypted();
let form = render_form_widget(&field, None).into_string();
assert!(
form.contains("name=\"ssn\""),
"create control must submit the value: {form}"
);
assert!(
!form.contains("disabled"),
"create control editable: {form}"
);
assert!(
!form.contains("••••••••"),
"create control is an empty input, not the redaction mask: {form}"
);
}
#[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 history_page_pagination_preserves_per_page() {
let r = dummy_registry();
let history = AdminHistoryPage {
entries: vec![crate::traits::AdminHistoryEntry {
id: 1,
actor: "system".to_owned(),
op: "insert".to_owned(),
request_id: None,
changes: vec![],
recorded_at: chrono::Utc::now(),
}],
total: 250,
page: 2,
per_page: 100,
};
let html = model_history_page(
&r,
"posts",
"Post",
"Posts",
42,
&history,
"/admin",
"/ops",
"X-CSRF-Token",
false,
)
.into_string();
assert!(
html.contains("/admin/posts/42/history?page=1&per_page=100"),
"previous history page link must preserve per_page: {html}"
);
assert!(
html.contains("/admin/posts/42/history?page=3&per_page=100"),
"next history page link must preserve per_page: {html}"
);
}
#[test]
fn dashboard_emits_csrf_meta_and_script() {
let r = dummy_registry();
let html = dashboard_page(
&r,
&[],
&[],
"tok-123",
"X-CSRF-Token",
"/admin",
"/ops",
false,
)
.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", "X-CSRF-Token", "/admin", "/ops", false)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
false,
)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
false,
)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
false,
false,
)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
false,
false,
)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
false,
false,
)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
false,
false,
)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
false,
false,
)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
false,
false,
)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
false,
false,
)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
false,
false,
)
.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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
false,
false,
)
.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 list_page_shows_csv_download_link_when_export_enabled() {
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",
"X-CSRF-Token",
"/admin",
"/actuator",
false, true, false, )
.into_string();
assert!(
html.contains(r#"href="/admin/widgets/export.csv""#),
"Download CSV link must appear when supports_csv_export=true: {html}"
);
assert!(
!html.contains("/import"),
"Import CSV link must not appear when supports_csv_import=false: {html}"
);
}
#[test]
fn list_page_shows_import_link_when_import_enabled() {
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",
"X-CSRF-Token",
"/admin",
"/actuator",
false, false, true, )
.into_string();
assert!(
html.contains(r#"href="/admin/widgets/import""#),
"Import CSV link must appear when supports_csv_import=true: {html}"
);
assert!(
!html.contains("export.csv"),
"Download CSV link must not appear when supports_csv_export=false: {html}"
);
}
#[test]
fn list_page_hides_csv_buttons_when_both_disabled() {
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",
"X-CSRF-Token",
"/admin",
"/actuator",
false,
false,
false,
)
.into_string();
assert!(
!html.contains("export.csv"),
"no export link when disabled: {html}"
);
assert!(
!html.contains("/import"),
"no import link when disabled: {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,
"",
"",
);
}
#[test]
fn config_page_empty_shows_no_keys_registered_message() {
let r = dummy_registry();
let html = config_page(
&r,
&[],
&[],
"tok",
"_csrf",
"X-CSRF-Token",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.contains("No config keys have been registered"),
"empty state message missing: {html}"
);
assert!(
html.contains("Runtime Config"),
"page title missing: {html}"
);
}
#[test]
fn config_page_renders_key_name_type_and_value() {
use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
let r = dummy_registry();
let entries = vec![ConfigEntry {
name: "max_upload_mb".to_owned(),
value_type: ConfigValueType::Int,
current: ConfigValue::Int(50),
default: ConfigValue::Int(50),
is_overridden: false,
description: Some("Max upload in MB".to_owned()),
}];
let html = config_page(
&r,
&entries,
&[],
"tok",
"_csrf",
"X-CSRF-Token",
"/admin",
"/actuator",
)
.into_string();
assert!(html.contains("max_upload_mb"), "key name missing: {html}");
assert!(
html.contains("Max upload in MB"),
"description missing: {html}"
);
assert!(
html.contains(r#"action="/admin/config/max_upload_mb/set""#),
"set form action missing: {html}"
);
assert!(
html.contains(r#"href="/admin/config/max_upload_mb/history""#),
"history link missing: {html}"
);
}
#[test]
fn config_page_overridden_key_shows_unset_form() {
use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
let r = dummy_registry();
let entries = vec![ConfigEntry {
name: "rate_limit".to_owned(),
value_type: ConfigValueType::Int,
current: ConfigValue::Int(200),
default: ConfigValue::Int(100),
is_overridden: true,
description: None,
}];
let html = config_page(
&r,
&entries,
&[],
"tok",
"_csrf",
"X-CSRF-Token",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.contains(r#"action="/admin/config/rate_limit/unset""#),
"unset form should appear for overridden key: {html}"
);
}
#[test]
fn config_page_shows_overridden_status() {
use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
let r = dummy_registry();
let entries = vec![ConfigEntry {
name: "rate_limit".to_owned(),
value_type: ConfigValueType::Int,
current: ConfigValue::Int(200),
default: ConfigValue::Int(100),
is_overridden: true,
description: None,
}];
let html = config_page(
&r,
&entries,
&[],
"tok",
"_csrf",
"X-CSRF-Token",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.to_lowercase().contains("overridden"),
"overridden status missing: {html}"
);
}
#[test]
fn config_page_shows_default_status_for_unoverridden_key() {
use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
let r = dummy_registry();
let entries = vec![ConfigEntry {
name: "feature_flag".to_owned(),
value_type: ConfigValueType::Bool,
current: ConfigValue::Bool(false),
default: ConfigValue::Bool(false),
is_overridden: false,
description: None,
}];
let html = config_page(
&r,
&entries,
&[],
"tok",
"_csrf",
"X-CSRF-Token",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.to_lowercase().contains("default"),
"default status missing: {html}"
);
}
#[test]
fn config_page_embeds_csrf_token_in_forms() {
use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
let r = dummy_registry();
let entries = vec![ConfigEntry {
name: "timeout_secs".to_owned(),
value_type: ConfigValueType::Int,
current: ConfigValue::Int(30),
default: ConfigValue::Int(30),
is_overridden: false,
description: None,
}];
let html = config_page(
&r,
&entries,
&[],
"csrf-tok-789",
"authenticity_token",
"X-CSRF-Token",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.contains(r#"name="authenticity_token" value="csrf-tok-789""#),
"CSRF token not embedded in config forms: {html}"
);
}
#[test]
fn config_history_page_shows_empty_state() {
let r = dummy_registry();
let html = config_history_page(
&r,
"rate_limit",
&[],
&[],
"tok",
"X-CSRF-Token",
"/admin",
"/actuator",
)
.into_string();
assert!(
html.contains("No changes recorded"),
"empty history message missing: {html}"
);
assert!(html.contains("rate_limit"), "key name missing: {html}");
}
#[test]
fn config_history_page_renders_change_records() {
use autumn_web::runtime_config::{ConfigChangeRecord, ConfigValue};
let r = dummy_registry();
let history = vec![ConfigChangeRecord {
key: "rate_limit".to_owned(),
old_value: Some(ConfigValue::Int(100)),
new_value: Some(ConfigValue::Int(200)),
actor: Some("ops@example.com".to_owned()),
timestamp_secs: 1_700_000_000,
}];
let html = config_history_page(
&r,
"rate_limit",
&history,
&[],
"tok",
"X-CSRF-Token",
"/admin",
"/actuator",
)
.into_string();
assert!(html.contains("rate_limit"), "key name missing: {html}");
assert!(html.contains("ops@example.com"), "actor missing: {html}");
assert!(html.contains("100"), "old value missing: {html}");
assert!(html.contains("200"), "new value missing: {html}");
}
#[test]
fn config_history_page_handles_unset_record() {
use autumn_web::runtime_config::{ConfigChangeRecord, ConfigValue};
let r = dummy_registry();
let history = vec![ConfigChangeRecord {
key: "flag".to_owned(),
old_value: Some(ConfigValue::Bool(true)),
new_value: None,
actor: None,
timestamp_secs: 0,
}];
let html = config_history_page(
&r,
"flag",
&history,
&[],
"tok",
"X-CSRF-Token",
"/admin",
"/actuator",
)
.into_string();
assert!(html.contains("flag"), "key name missing: {html}");
assert!(html.contains("—"), "null actor placeholder missing: {html}");
}
#[test]
fn format_timestamp_formats_unix_epoch() {
let s = format_timestamp(0);
assert!(s.contains("1970"), "epoch should format as 1970: {s}");
}
#[test]
fn format_timestamp_formats_known_instant() {
let s = format_timestamp(1_700_000_000);
assert!(s.contains("2023"), "expected 2023 in formatted output: {s}");
}
}