Create cohesive control panel redesign #30

Merged
parkel merged 3 commits from issue-28-control-panel-redesign into master 2026-05-21 11:44:29 +02:00
7 changed files with 130 additions and 62 deletions
Showing only changes of commit ad2cc5585e - Show all commits

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 %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Control panel</div>
<h1>Dashboard</h1> <h1>Dashboard</h1>
<div class="page-subtitle">Backup health, required action, storage pressure, and recent activity in one place.</div>
</div>
<section class="actions" aria-label="Dashboard actions"> <section class="actions" aria-label="Dashboard actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a> <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> <a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a>
</section> </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,8 +3,12 @@
{% block title %}{{ host.host }} | pobsync{% endblock %} {% block title %}{{ host.host }} | pobsync{% endblock %}
{% block content %} {% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Host</div>
<h1>{{ host.host }}</h1> <h1>{{ host.host }}</h1>
<div class="page-subtitle">{{ host.address }} · {{ host.enabled|yesno:"enabled,disabled" }}</div>
</div>
<section class="actions" aria-label="Host actions"> <section class="actions" aria-label="Host actions">
<a class="button-link" href="{% url 'edit_host_config' host.host %}">Edit config</a> <a class="button-link" href="{% url 'edit_host_config' host.host %}">Edit config</a>
<form method="post" action="{% url 'discover_host_snapshots' host.host %}"> <form method="post" action="{% url 'discover_host_snapshots' host.host %}">
@@ -26,6 +30,7 @@
<button type="submit" class="secondary">Run connection preflight</button> <button type="submit" class="secondary">Run connection preflight</button>
</form> </form>
</section> </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,8 +3,12 @@
{% 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">
<div class="page-kicker">Retention</div>
<h1>{{ host.host }}</h1>
<div class="page-subtitle">Preview which snapshots stay, which would be deleted, and whether incomplete cleanup is needed.</div>
</div>
<section class="actions" aria-label="Retention filters"> <section class="actions" aria-label="Retention filters">
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a> <a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
<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=scheduled">Scheduled</a>
@@ -12,6 +16,7 @@
<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=all">All</a>
<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_retention_plan' host.host %}?kind={{ kind }}&protect_bases=1">Protect bases</a>
</section> </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,8 +3,12 @@
{% block title %}Run {{ run.id }} | {{ run.host.host }}{% endblock %} {% block title %}Run {{ run.id }} | {{ run.host.host }}{% endblock %}
{% block content %} {% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Backup run</div>
<h1>Run {{ run.id }}</h1> <h1>Run {{ run.id }}</h1>
<div class="page-subtitle">{{ run.host.host }} · {{ run.run_type }} · {{ run.status }}</div>
</div>
<section class="actions" aria-label="Run actions"> <section class="actions" aria-label="Run actions">
<a class="button-link" href="{% url 'host_detail' run.host.host %}">Back to host</a> <a class="button-link" href="{% url 'host_detail' run.host.host %}">Back to host</a>
{% if can_cancel %} {% if can_cancel %}
@@ -22,6 +26,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</section> </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 %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Snapshot</div>
<h1>{{ snapshot.dirname }}</h1> <h1>{{ snapshot.dirname }}</h1>
<div class="page-subtitle">{{ snapshot.host.host }} · {{ snapshot.kind }} · {{ snapshot.status }}</div>
</div>
<section class="actions" aria-label="Snapshot actions"> <section class="actions" aria-label="Snapshot actions">
<a class="button-link" href="{% url 'host_detail' snapshot.host.host %}">Back to host</a> <a class="button-link" href="{% url 'host_detail' snapshot.host.host %}">Back to host</a>
</section> </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")