Create cohesive control panel redesign #30
@@ -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; }
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user