(ui) Add dashboard operational status summary #15
@@ -176,23 +176,42 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.host-card-status {
|
.host-card-status {
|
||||||
flex: 0 0 auto;
|
display: flex;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
max-width: 50%;
|
||||||
}
|
}
|
||||||
.host-card-layout {
|
.host-card-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 24px;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(240px, 320px);
|
grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr);
|
||||||
|
}
|
||||||
|
.host-card-section {
|
||||||
|
align-content: start;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.host-card-section-title {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.host-card-timeline {
|
.host-card-timeline {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 16px 22px;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||||
}
|
}
|
||||||
.host-card-stats {
|
.host-card-stats {
|
||||||
align-content: start;
|
align-content: start;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
border-top: 1px solid #e6edf4;
|
||||||
|
gap: 12px 18px;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
.host-card-item {
|
.host-card-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -209,13 +228,9 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.host-card-stat {
|
.host-card-stat {
|
||||||
background: #f8fafc;
|
|
||||||
border: 1px solid #e6edf4;
|
|
||||||
border-radius: 6px;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 10px;
|
|
||||||
}
|
}
|
||||||
.host-card-stat .label {
|
.host-card-stat .label {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -286,7 +301,10 @@
|
|||||||
main { padding: 16px; }
|
main { padding: 16px; }
|
||||||
nav { padding: 0; }
|
nav { padding: 0; }
|
||||||
.two-col { grid-template-columns: 1fr; }
|
.two-col { grid-template-columns: 1fr; }
|
||||||
|
.host-card-header { display: grid; }
|
||||||
|
.host-card-status { justify-content: flex-start; max-width: none; }
|
||||||
.host-card-layout { grid-template-columns: 1fr; }
|
.host-card-layout { grid-template-columns: 1fr; }
|
||||||
|
.host-card-stats { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -63,9 +63,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="host-card-status">
|
<div class="host-card-status">
|
||||||
<span class="status {% if host.enabled %}ok{% else %}skipped{% endif %}">{{ host.enabled|yesno:"enabled,disabled" }}</span>
|
<span class="status {% if host.enabled %}ok{% else %}skipped{% endif %}">{{ host.enabled|yesno:"enabled,disabled" }}</span>
|
||||||
|
{% if host.queued_run_count %}
|
||||||
|
<span class="status queued">queued {{ host.queued_run_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if host.running_run_count %}
|
||||||
|
<span class="status running">running {{ host.running_run_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if host.warning_run_count %}
|
||||||
|
<span class="status warning">warning {{ host.warning_run_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if host.failed_run_count %}
|
||||||
|
<span class="status failed">failed {{ host.failed_run_count }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="host-card-layout">
|
<div class="host-card-layout">
|
||||||
|
<div class="host-card-section">
|
||||||
|
<div class="host-card-section-title">Backup activity</div>
|
||||||
<div class="host-card-timeline">
|
<div class="host-card-timeline">
|
||||||
<div class="host-card-item">
|
<div class="host-card-item">
|
||||||
<div class="label">Latest Snapshot</div>
|
<div class="label">Latest Snapshot</div>
|
||||||
@@ -113,6 +127,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="host-card-section">
|
||||||
|
<div class="host-card-section-title">Snapshot health</div>
|
||||||
<div class="host-card-stats">
|
<div class="host-card-stats">
|
||||||
<div class="host-card-stat">
|
<div class="host-card-stat">
|
||||||
<div class="label">Snapshots</div>
|
<div class="label">Snapshots</div>
|
||||||
@@ -132,6 +149,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% if host.retention_warning.has_warning %}
|
{% if host.retention_warning.has_warning %}
|
||||||
<div class="host-card-warning">
|
<div class="host-card-warning">
|
||||||
<span class="status warning">retention</span>
|
<span class="status warning">retention</span>
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ class ViewTests(TestCase):
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
BackupRun.objects.create(host=host, status=BackupRun.Status.QUEUED)
|
||||||
|
BackupRun.objects.create(host=host, status=BackupRun.Status.RUNNING)
|
||||||
|
BackupRun.objects.create(
|
||||||
|
host=host,
|
||||||
|
run_type=BackupRun.RunType.MANUAL,
|
||||||
|
status=BackupRun.Status.FAILED,
|
||||||
|
started_at=datetime(2026, 5, 19, 1, 15, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
response = self.client.get(reverse("dashboard"))
|
response = self.client.get(reverse("dashboard"))
|
||||||
|
|
||||||
@@ -70,6 +78,12 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "warning")
|
self.assertContains(response, "warning")
|
||||||
self.assertContains(response, "manual")
|
self.assertContains(response, "manual")
|
||||||
self.assertContains(response, "scheduled")
|
self.assertContains(response, "scheduled")
|
||||||
|
self.assertContains(response, "Backup activity")
|
||||||
|
self.assertContains(response, "Snapshot health")
|
||||||
|
self.assertContains(response, "queued 1")
|
||||||
|
self.assertContains(response, "running 1")
|
||||||
|
self.assertContains(response, "warning 1")
|
||||||
|
self.assertContains(response, "failed 1")
|
||||||
|
|
||||||
def test_dashboard_renders_backup_trend_summary(self) -> None:
|
def test_dashboard_renders_backup_trend_summary(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from django.contrib import messages
|
|||||||
from django.contrib.admin.views.decorators import staff_member_required
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import FileResponse, Http404
|
from django.http import FileResponse, Http404
|
||||||
from django.db.models import Count
|
from django.db.models import Count, Q
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
@@ -45,7 +45,14 @@ def dashboard(request):
|
|||||||
global_config = GlobalConfig.objects.filter(name="default").first()
|
global_config = GlobalConfig.objects.filter(name="default").first()
|
||||||
hosts = list(
|
hosts = list(
|
||||||
HostConfig.objects.select_related("schedule")
|
HostConfig.objects.select_related("schedule")
|
||||||
.annotate(snapshot_count=Count("snapshots", distinct=True), run_count=Count("runs", distinct=True))
|
.annotate(
|
||||||
|
snapshot_count=Count("snapshots", distinct=True),
|
||||||
|
run_count=Count("runs", distinct=True),
|
||||||
|
queued_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.QUEUED), distinct=True),
|
||||||
|
running_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.RUNNING), distinct=True),
|
||||||
|
warning_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.WARNING), distinct=True),
|
||||||
|
failed_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.FAILED), distinct=True),
|
||||||
|
)
|
||||||
.order_by("host")
|
.order_by("host")
|
||||||
)
|
)
|
||||||
for host_config in hosts:
|
for host_config in hosts:
|
||||||
|
|||||||
Reference in New Issue
Block a user