(ui) Add consistent page headers to key views

Introduce a shared page-header pattern with kicker, title, subtitle, and
actions, then apply it to the dashboard, host detail, run detail, snapshot
detail, and retention plan pages.

Scope the global app header styles to avoid leaking sticky navigation styles
onto page-level headers, and add view assertions for the new page context.

Refs #28
This commit is contained in:
2026-05-21 11:37:25 +02:00
parent 8aa3f1d1f5
commit ad2cc5585e
7 changed files with 130 additions and 62 deletions

View File

@@ -43,7 +43,7 @@
outline: 3px solid rgba(7, 95, 174, 0.24); outline: 3px solid rgba(7, 95, 174, 0.24);
outline-offset: 2px; outline-offset: 2px;
} }
header { body > header {
background: var(--panel); background: var(--panel);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
@@ -105,6 +105,35 @@
} }
p { margin: 0 0 12px; } p { margin: 0 0 12px; }
p:last-child { margin-bottom: 0; } p:last-child { margin-bottom: 0; }
.page-header {
align-items: end;
display: flex;
gap: 18px;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
display: grid;
gap: 5px;
min-width: 0;
}
.page-title h1 { margin-bottom: 0; overflow-wrap: anywhere; }
.page-kicker {
color: var(--muted);
font-size: 12px;
font-weight: 750;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.page-subtitle {
color: var(--muted);
max-width: 760px;
}
.page-header .actions {
flex: 0 0 auto;
justify-content: flex-end;
margin-bottom: 0;
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
@@ -483,7 +512,7 @@
padding: 0; padding: 0;
} }
@media (max-width: 800px) { @media (max-width: 800px) {
header { padding: 0 14px; position: static; } body > header { padding: 0 14px; position: static; }
main { padding: 18px 14px 32px; } main { padding: 18px 14px 32px; }
nav { nav {
align-items: flex-start; align-items: flex-start;
@@ -493,6 +522,11 @@
} }
nav strong { flex-basis: 100%; margin-right: 0; } nav strong { flex-basis: 100%; margin-right: 0; }
nav .spacer { display: none; } nav .spacer { display: none; }
.page-header {
align-items: stretch;
display: grid;
}
.page-header .actions { justify-content: flex-start; }
.two-col { grid-template-columns: 1fr; } .two-col { grid-template-columns: 1fr; }
.host-card-header { display: grid; } .host-card-header { display: grid; }
.host-card-status { justify-content: flex-start; max-width: none; } .host-card-status { justify-content: flex-start; max-width: none; }

View File

@@ -3,12 +3,17 @@
{% block title %}pobsync dashboard{% endblock %} {% block title %}pobsync dashboard{% endblock %}
{% block content %} {% block content %}
<h1>Dashboard</h1> <header class="page-header">
<div class="page-title">
<section class="actions" aria-label="Dashboard actions"> <div class="page-kicker">Control panel</div>
<a class="button-link" href="{% url 'create_host_config' %}">New host</a> <h1>Dashboard</h1>
<a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a> <div class="page-subtitle">Backup health, required action, storage pressure, and recent activity in one place.</div>
</section> </div>
<section class="actions" aria-label="Dashboard actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
<a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a>
</section>
</header>
{% if not global_config or not counts.hosts %} {% if not global_config or not counts.hosts %}
<section class="panel"> <section class="panel">

View File

@@ -3,29 +3,34 @@
{% block title %}{{ host.host }} | pobsync{% endblock %} {% block title %}{{ host.host }} | pobsync{% endblock %}
{% block content %} {% block content %}
<h1>{{ host.host }}</h1> <header class="page-header">
<div class="page-title">
<section class="actions" aria-label="Host actions"> <div class="page-kicker">Host</div>
<a class="button-link" href="{% url 'edit_host_config' host.host %}">Edit config</a> <h1>{{ host.host }}</h1>
<form method="post" action="{% url 'discover_host_snapshots' host.host %}"> <div class="page-subtitle">{{ host.address }} · {{ host.enabled|yesno:"enabled,disabled" }}</div>
{% csrf_token %} </div>
<button type="submit">Discover snapshots</button> <section class="actions" aria-label="Host actions">
</form> <a class="button-link" href="{% url 'edit_host_config' host.host %}">Edit config</a>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a> <form method="post" action="{% url 'discover_host_snapshots' host.host %}">
<a class="button-link" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a> {% csrf_token %}
<form method="post" action="{% url 'prepare_host_directories' host.host %}"> <button type="submit">Discover snapshots</button>
{% csrf_token %} </form>
<button type="submit" class="secondary">Prepare directories</button> <a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
</form> <a class="button-link" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a>
<form method="post" action="{% url 'scan_host_known_key' host.host %}"> <form method="post" action="{% url 'prepare_host_directories' host.host %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="secondary">Scan SSH host key</button> <button type="submit" class="secondary">Prepare directories</button>
</form> </form>
<form method="post" action="{% url 'run_host_preflight' host.host %}"> <form method="post" action="{% url 'scan_host_known_key' host.host %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="secondary">Run connection preflight</button> <button type="submit" class="secondary">Scan SSH host key</button>
</form> </form>
</section> <form method="post" action="{% url 'run_host_preflight' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary">Run connection preflight</button>
</form>
</section>
</header>
<section class="grid" aria-label="Host summary"> <section class="grid" aria-label="Host summary">
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div> <div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>

View File

@@ -3,15 +3,20 @@
{% block title %}Retention plan | {{ host.host }}{% endblock %} {% block title %}Retention plan | {{ host.host }}{% endblock %}
{% block content %} {% block content %}
<h1>Retention Plan: {{ host.host }}</h1> <header class="page-header">
<div class="page-title">
<section class="actions" aria-label="Retention filters"> <div class="page-kicker">Retention</div>
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a> <h1>{{ host.host }}</h1>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=scheduled">Scheduled</a> <div class="page-subtitle">Preview which snapshots stay, which would be deleted, and whether incomplete cleanup is needed.</div>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=manual">Manual</a> </div>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=all">All</a> <section class="actions" aria-label="Retention filters">
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}&protect_bases=1">Protect bases</a> <a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
</section> <a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=scheduled">Scheduled</a>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=manual">Manual</a>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=all">All</a>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}&protect_bases=1">Protect bases</a>
</section>
</header>
<section class="grid" aria-label="Retention plan summary"> <section class="grid" aria-label="Retention plan summary">
<div class="metric"><div class="label">Kind</div><div class="value">{{ plan.kind }}</div></div> <div class="metric"><div class="label">Kind</div><div class="value">{{ plan.kind }}</div></div>

View File

@@ -3,25 +3,30 @@
{% block title %}Run {{ run.id }} | {{ run.host.host }}{% endblock %} {% block title %}Run {{ run.id }} | {{ run.host.host }}{% endblock %}
{% block content %} {% block content %}
<h1>Run {{ run.id }}</h1> <header class="page-header">
<div class="page-title">
<section class="actions" aria-label="Run actions"> <div class="page-kicker">Backup run</div>
<a class="button-link" href="{% url 'host_detail' run.host.host %}">Back to host</a> <h1>Run {{ run.id }}</h1>
{% if can_cancel %} <div class="page-subtitle">{{ run.host.host }} · {{ run.run_type }} · {{ run.status }}</div>
<form method="post" action="{% url 'cancel_run' run.id %}"> </div>
{% csrf_token %} <section class="actions" aria-label="Run actions">
<button type="submit" class="secondary">Cancel run</button> <a class="button-link" href="{% url 'host_detail' run.host.host %}">Back to host</a>
</form> {% if can_cancel %}
{% endif %} <form method="post" action="{% url 'cancel_run' run.id %}">
{% if run.status == "failed" or run.status == "warning" %}
{% if not run.reviewed_at %}
<form method="post" action="{% url 'resolve_run_review' run.id %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="secondary">Mark reviewed</button> <button type="submit" class="secondary">Cancel run</button>
</form> </form>
{% endif %} {% endif %}
{% endif %} {% if run.status == "failed" or run.status == "warning" %}
</section> {% if not run.reviewed_at %}
<form method="post" action="{% url 'resolve_run_review' run.id %}">
{% csrf_token %}
<button type="submit" class="secondary">Mark reviewed</button>
</form>
{% endif %}
{% endif %}
</section>
</header>
<section class="grid" aria-label="Run summary"> <section class="grid" aria-label="Run summary">
<div class="metric"><div class="label">Host</div><div class="value">{{ run.host.host }}</div></div> <div class="metric"><div class="label">Host</div><div class="value">{{ run.host.host }}</div></div>

View File

@@ -3,11 +3,16 @@
{% block title %}Snapshot {{ snapshot.dirname }} | {{ snapshot.host.host }}{% endblock %} {% block title %}Snapshot {{ snapshot.dirname }} | {{ snapshot.host.host }}{% endblock %}
{% block content %} {% block content %}
<h1>{{ snapshot.dirname }}</h1> <header class="page-header">
<div class="page-title">
<section class="actions" aria-label="Snapshot actions"> <div class="page-kicker">Snapshot</div>
<a class="button-link" href="{% url 'host_detail' snapshot.host.host %}">Back to host</a> <h1>{{ snapshot.dirname }}</h1>
</section> <div class="page-subtitle">{{ snapshot.host.host }} · {{ snapshot.kind }} · {{ snapshot.status }}</div>
</div>
<section class="actions" aria-label="Snapshot actions">
<a class="button-link" href="{% url 'host_detail' snapshot.host.host %}">Back to host</a>
</section>
</header>
<section class="grid" aria-label="Snapshot summary"> <section class="grid" aria-label="Snapshot summary">
<div class="metric"><div class="label">Host</div><div class="value">{{ snapshot.host.host }}</div></div> <div class="metric"><div class="label">Host</div><div class="value">{{ snapshot.host.host }}</div></div>

View File

@@ -101,6 +101,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("dashboard")) response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Control panel")
self.assertContains(response, "Backup health, required action, storage pressure, and recent activity in one place.")
self.assertContains(response, "Dashboard") self.assertContains(response, "Dashboard")
self.assertContains(response, "web-01") self.assertContains(response, "web-01")
self.assertContains(response, "20260519-021500Z__ABCDEFGH") self.assertContains(response, "20260519-021500Z__ABCDEFGH")
@@ -869,6 +871,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("host_detail", args=[host.host])) response = self.client.get(reverse("host_detail", args=[host.host]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Host")
self.assertContains(response, "web-01.example.test")
self.assertContains(response, "Effective Config") self.assertContains(response, "Effective Config")
self.assertContains(response, "Backup source:") self.assertContains(response, "Backup source:")
self.assertNotContains(response, "Source root:") self.assertNotContains(response, "Source root:")
@@ -1425,6 +1429,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("run_detail", args=[run.id])) response = self.client.get(reverse("run_detail", args=[run.id]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Backup run")
self.assertContains(response, "web-01")
self.assertContains(response, "Failure") self.assertContains(response, "Failure")
self.assertContains(response, "transport") self.assertContains(response, "transport")
self.assertContains(response, "Check network connectivity.") self.assertContains(response, "Check network connectivity.")
@@ -1615,6 +1621,7 @@ class ViewTests(TestCase):
response = self.client.get(reverse("snapshot_detail", args=[base.id])) response = self.client.get(reverse("snapshot_detail", args=[base.id]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Snapshot")
self.assertContains(response, base.dirname) self.assertContains(response, base.dirname)
self.assertContains(response, "BASESNAP") self.assertContains(response, "BASESNAP")
self.assertContains(response, "Stats") self.assertContains(response, "Stats")
@@ -1697,7 +1704,9 @@ class ViewTests(TestCase):
response = self.client.get(reverse("host_retention_plan", args=[host.host])) response = self.client.get(reverse("host_retention_plan", args=[host.host]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Retention Plan: web-01") self.assertContains(response, "Retention")
self.assertContains(response, "Preview which snapshots stay")
self.assertContains(response, "web-01")
self.assertContains(response, old_snapshot.dirname) self.assertContains(response, old_snapshot.dirname)
self.assertContains(response, new_snapshot.dirname) self.assertContains(response, new_snapshot.dirname)
self.assertContains(response, "newest") self.assertContains(response, "newest")