Compare commits
5 Commits
17215fd191
...
8858e049ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 8858e049ee | |||
| a75b97c4c0 | |||
| b4fc5a14b2 | |||
| a0fd33fcb8 | |||
| ef1761385e |
@@ -37,6 +37,8 @@ def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: Globa
|
|||||||
available = _int_at(capacity, "available_bytes")
|
available = _int_at(capacity, "available_bytes")
|
||||||
daily_literal = _average_daily_literal(real_runs)
|
daily_literal = _average_daily_literal(real_runs)
|
||||||
|
|
||||||
|
link_dest_savings_ratio = round(total_matched / savings_basis, 4) if savings_basis else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"runs_sampled": len(real_runs),
|
"runs_sampled": len(real_runs),
|
||||||
"avg_duration_seconds": _average(duration_values),
|
"avg_duration_seconds": _average(duration_values),
|
||||||
@@ -44,7 +46,8 @@ def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: Globa
|
|||||||
"avg_literal_data_bytes": avg_literal,
|
"avg_literal_data_bytes": avg_literal,
|
||||||
"total_literal_data_bytes": total_literal,
|
"total_literal_data_bytes": total_literal,
|
||||||
"total_matched_data_bytes": total_matched,
|
"total_matched_data_bytes": total_matched,
|
||||||
"link_dest_savings_ratio": round(total_matched / savings_basis, 4) if savings_basis else None,
|
"link_dest_savings_ratio": link_dest_savings_ratio,
|
||||||
|
"link_dest_savings_percent": round(link_dest_savings_ratio * 100, 1) if link_dest_savings_ratio is not None else None,
|
||||||
"estimated_runs_until_full": int(available / avg_literal) if available and avg_literal else None,
|
"estimated_runs_until_full": int(available / avg_literal) if available and avg_literal else None,
|
||||||
"estimated_days_until_full": int(available / daily_literal) if available and daily_literal else None,
|
"estimated_days_until_full": int(available / daily_literal) if available and daily_literal else None,
|
||||||
"capacity": capacity,
|
"capacity": capacity,
|
||||||
@@ -52,9 +55,10 @@ def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: Globa
|
|||||||
|
|
||||||
|
|
||||||
def collect_host_stats(*, host: HostConfig, limit: int = 8) -> dict[str, Any]:
|
def collect_host_stats(*, host: HostConfig, limit: int = 8) -> dict[str, Any]:
|
||||||
runs = list(host.runs.select_related("snapshot").filter(status__in=_COMPLETED_BACKUP_STATUSES).order_by("-started_at", "-created_at")[:50])
|
runs = list(host.runs.select_related("snapshot").order_by("-started_at", "-created_at")[:50])
|
||||||
real_runs = [_run_summary(run) for run in runs if _is_real_run(run)]
|
real_runs = [_run_summary(run) for run in runs if _is_real_run(run)]
|
||||||
trend_runs = [run for run in real_runs if run["has_stats"]][:limit]
|
completed_real_runs = [run for run in real_runs if run["status"] in _COMPLETED_BACKUP_STATUSES]
|
||||||
|
trend_runs = [run for run in completed_real_runs if run["has_stats"]][:limit]
|
||||||
latest_snapshot = host.snapshots.order_by("-started_at", "-discovered_at", "-id").first()
|
latest_snapshot = host.snapshots.order_by("-started_at", "-discovered_at", "-id").first()
|
||||||
latest_snapshot_stats = _snapshot_summary(latest_snapshot) if latest_snapshot else {}
|
latest_snapshot_stats = _snapshot_summary(latest_snapshot) if latest_snapshot else {}
|
||||||
|
|
||||||
@@ -67,7 +71,9 @@ def collect_host_stats(*, host: HostConfig, limit: int = 8) -> dict[str, Any]:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"runs": [_with_bar_percentages(run, max_literal=max_literal, max_matched=max_matched) for run in trend_runs],
|
"runs": [_with_bar_percentages(run, max_literal=max_literal, max_matched=max_matched) for run in trend_runs],
|
||||||
"latest_run": real_runs[0] if real_runs else {},
|
"latest_run": completed_real_runs[0] if completed_real_runs else {},
|
||||||
|
"latest_good_run": _first_run_with_status(real_runs, {BackupRun.Status.SUCCESS}),
|
||||||
|
"latest_problem_run": _first_run_with_status(real_runs, {BackupRun.Status.WARNING, BackupRun.Status.FAILED}),
|
||||||
"latest_snapshot": latest_snapshot_stats,
|
"latest_snapshot": latest_snapshot_stats,
|
||||||
"avg_literal_data_bytes": _average(literal_values),
|
"avg_literal_data_bytes": _average(literal_values),
|
||||||
"avg_daily_literal_data_bytes": _average_daily_literal(trend_runs),
|
"avg_daily_literal_data_bytes": _average_daily_literal(trend_runs),
|
||||||
@@ -87,6 +93,7 @@ def _run_summary(run: BackupRun) -> dict[str, Any]:
|
|||||||
"ended_at": run.ended_at,
|
"ended_at": run.ended_at,
|
||||||
"snapshot": run.snapshot,
|
"snapshot": run.snapshot,
|
||||||
"snapshot_path": run.snapshot_path,
|
"snapshot_path": run.snapshot_path,
|
||||||
|
"status": run.status,
|
||||||
"has_stats": bool(stats),
|
"has_stats": bool(stats),
|
||||||
"duration_seconds": _int_at(stats, "duration_seconds"),
|
"duration_seconds": _int_at(stats, "duration_seconds"),
|
||||||
"rsync": stats.get("rsync") if isinstance(stats.get("rsync"), dict) else {},
|
"rsync": stats.get("rsync") if isinstance(stats.get("rsync"), dict) else {},
|
||||||
@@ -121,6 +128,13 @@ def _is_real_run(run: BackupRun) -> bool:
|
|||||||
return requested.get("dry_run") is not True
|
return requested.get("dry_run") is not True
|
||||||
|
|
||||||
|
|
||||||
|
def _first_run_with_status(runs: list[dict[str, Any]], statuses: set[str]) -> dict[str, Any]:
|
||||||
|
for run in runs:
|
||||||
|
if run["status"] in statuses:
|
||||||
|
return run
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _capacity_from_system(global_config: GlobalConfig | None) -> dict[str, Any]:
|
def _capacity_from_system(global_config: GlobalConfig | None) -> dict[str, Any]:
|
||||||
if global_config is None or not global_config.backup_root:
|
if global_config is None or not global_config.backup_root:
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -61,6 +61,10 @@
|
|||||||
.metric { padding: 14px; }
|
.metric { padding: 14px; }
|
||||||
.metric .label { color: var(--muted); font-size: 12px; text-transform: uppercase; }
|
.metric .label { color: var(--muted); font-size: 12px; text-transform: uppercase; }
|
||||||
.metric .value { font-size: 26px; font-weight: 650; margin-top: 4px; }
|
.metric .value { font-size: 26px; font-weight: 650; margin-top: 4px; }
|
||||||
|
.metric.failed { border-color: #e8b4b4; background: #fff7f7; }
|
||||||
|
.metric.warning { border-color: #e7cf8a; background: #fffaf0; }
|
||||||
|
.metric.running { border-color: #e7cf8a; background: #fffaf0; }
|
||||||
|
.metric.queued { border-color: #b5cdea; background: #eef6ff; }
|
||||||
.panel { padding: 16px; margin-bottom: 18px; overflow: auto; }
|
.panel { padding: 16px; margin-bottom: 18px; overflow: auto; }
|
||||||
.panel.highlight { border-left: 4px solid var(--border); }
|
.panel.highlight { border-left: 4px solid var(--border); }
|
||||||
.panel.highlight.failed { border-left-color: var(--failed); background: #fff7f7; }
|
.panel.highlight.failed { border-left-color: var(--failed); background: #fff7f7; }
|
||||||
@@ -118,6 +122,23 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.inline-form { margin: 0; }
|
.inline-form { margin: 0; }
|
||||||
|
.status-overview {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.status-summary {
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.status-summary.failed { border-color: #e8b4b4; background: #fff7f7; color: var(--failed); }
|
||||||
|
.status-summary.warning,
|
||||||
|
.status-summary.running { border-color: #e7cf8a; background: #fffaf0; color: var(--running); }
|
||||||
|
.status-summary.queued { border-color: #b5cdea; background: #eef6ff; color: var(--link); }
|
||||||
.operator-state {
|
.operator-state {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -149,6 +170,43 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
.insight-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px 24px;
|
||||||
|
grid-template-columns: minmax(260px, 1.3fr) repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
.insight-main,
|
||||||
|
.insight-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.insight-main .label,
|
||||||
|
.insight-item .label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.insight-main .value,
|
||||||
|
.insight-item .value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 650;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.storage-meter {
|
||||||
|
background: #edf2f7;
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 10px;
|
||||||
|
margin: 4px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.storage-meter span {
|
||||||
|
background: var(--link);
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
.host-list {
|
.host-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -176,23 +234,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 +286,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 +359,11 @@
|
|||||||
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)); }
|
||||||
|
.insight-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -32,22 +32,109 @@
|
|||||||
<div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div>
|
<div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div>
|
||||||
<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>
|
||||||
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
|
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
|
||||||
<div class="metric"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
|
<div class="metric {% if counts.queued_runs %}queued{% endif %}"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
|
||||||
<div class="metric"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div>
|
<div class="metric {% if counts.running_runs %}running{% endif %}"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
|
||||||
|
<div class="metric {% if counts.warning_runs %}warning{% endif %}"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></div>
|
||||||
|
<div class="metric {% if counts.failed_runs %}failed{% endif %}"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if stats_summary.runs_sampled %}
|
<section class="panel">
|
||||||
<section class="grid" aria-label="Backup trends">
|
<h2>Operational Status</h2>
|
||||||
<div class="metric"><div class="label">Backup Root Used</div><div class="value">{{ stats_summary.capacity.used_percent|default:"" }}{% if stats_summary.capacity.used_percent is not None %}%{% endif %}</div></div>
|
{% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %}
|
||||||
<div class="metric"><div class="label">Available</div><div class="value">{{ stats_summary.capacity.available_bytes|filesizeformat }}</div></div>
|
<div class="status-overview">
|
||||||
<div class="metric"><div class="label">Avg New Data</div><div class="value">{{ stats_summary.avg_literal_data_bytes|filesizeformat }}</div></div>
|
{% if counts.failed_runs %}
|
||||||
<div class="metric"><div class="label">Avg Daily New</div><div class="value">{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}</div></div>
|
<div class="status-summary failed">
|
||||||
<div class="metric"><div class="label">Avg Duration</div><div class="value">{{ stats_summary.avg_duration_seconds|default:"" }}{% if stats_summary.avg_duration_seconds is not None %}s{% endif %}</div></div>
|
<span class="status failed">failed</span>
|
||||||
<div class="metric"><div class="label">Link-Dest Savings</div><div class="value">{{ stats_summary.link_dest_savings_ratio|default:"" }}</div></div>
|
<strong>{{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need{{ counts.failed_runs|pluralize:"s," }} review.</strong>
|
||||||
<div class="metric"><div class="label">Runs Until Full</div><div class="value">{{ stats_summary.estimated_runs_until_full|default:"" }}</div></div>
|
</div>
|
||||||
<div class="metric"><div class="label">Days Until Full</div><div class="value">{{ stats_summary.estimated_days_until_full|default:"" }}</div></div>
|
{% endif %}
|
||||||
</section>
|
{% if counts.warning_runs %}
|
||||||
{% endif %}
|
<div class="status-summary warning">
|
||||||
|
<span class="status warning">warning</span>
|
||||||
|
<strong>{{ counts.warning_runs }} run{{ counts.warning_runs|pluralize }} completed with warnings.</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if counts.running_runs %}
|
||||||
|
<div class="status-summary running">
|
||||||
|
<span class="status running">running</span>
|
||||||
|
<strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if counts.queued_runs %}
|
||||||
|
<div class="status-summary queued">
|
||||||
|
<span class="status queued">queued</span>
|
||||||
|
<strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting for the worker.</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% elif counts.hosts %}
|
||||||
|
<p><span class="status ok">ok</span> No queued, running, warning, or failed runs.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Add a host to start tracking backup status here.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Backup Trends</h2>
|
||||||
|
{% if stats_summary.runs_sampled %}
|
||||||
|
<div class="insight-grid" aria-label="Backup trends">
|
||||||
|
<div class="insight-main">
|
||||||
|
<div class="label">Storage Used</div>
|
||||||
|
<div class="value">
|
||||||
|
{% if stats_summary.capacity.used_percent is not None %}
|
||||||
|
{{ stats_summary.capacity.used_percent|floatformat:1 }}%
|
||||||
|
{% else %}
|
||||||
|
unknown
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if stats_summary.capacity.used_percent is not None %}
|
||||||
|
<div class="storage-meter" aria-label="Backup root storage usage">
|
||||||
|
<span style="width: {{ stats_summary.capacity.used_percent|floatformat:0 }}%"></span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="muted">
|
||||||
|
{{ stats_summary.capacity.available_bytes|filesizeformat }} available from the backup root.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="insight-item">
|
||||||
|
<div class="label">Runway</div>
|
||||||
|
<div class="value">
|
||||||
|
{% if stats_summary.estimated_days_until_full %}
|
||||||
|
{{ stats_summary.estimated_days_until_full }} days
|
||||||
|
{% elif stats_summary.estimated_runs_until_full %}
|
||||||
|
{{ stats_summary.estimated_runs_until_full }} runs
|
||||||
|
{% else %}
|
||||||
|
unknown
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="muted">Estimated from average new data per day.</div>
|
||||||
|
</div>
|
||||||
|
<div class="insight-item">
|
||||||
|
<div class="label">New Data</div>
|
||||||
|
<div class="value">{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}/day</div>
|
||||||
|
<div class="muted">{{ stats_summary.avg_literal_data_bytes|filesizeformat }} per backup on average.</div>
|
||||||
|
</div>
|
||||||
|
<div class="insight-item">
|
||||||
|
<div class="label">Link-Dest Savings</div>
|
||||||
|
<div class="value">
|
||||||
|
{% if stats_summary.link_dest_savings_percent is not None %}
|
||||||
|
{{ stats_summary.link_dest_savings_percent|floatformat:1 }}%
|
||||||
|
{% else %}
|
||||||
|
unknown
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="muted">{{ stats_summary.total_matched_data_bytes|filesizeformat }} reused across sampled runs.</div>
|
||||||
|
</div>
|
||||||
|
<div class="insight-item">
|
||||||
|
<div class="label">Average Duration</div>
|
||||||
|
<div class="value">{{ stats_summary.avg_duration_seconds|default:"unknown" }}{% if stats_summary.avg_duration_seconds is not None %}s{% endif %}</div>
|
||||||
|
<div class="muted">Based on {{ stats_summary.runs_sampled }} completed backup run{{ stats_summary.runs_sampled|pluralize }} with stats.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No completed backup runs with stats yet. This section will show disk usage, growth estimates, and link-dest savings after the first real backup finishes.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Hosts</h2>
|
<h2>Hosts</h2>
|
||||||
@@ -61,60 +148,90 @@
|
|||||||
</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-timeline">
|
<div class="host-card-section">
|
||||||
<div class="host-card-item">
|
<div class="host-card-section-title">Backup activity</div>
|
||||||
<div class="label">Latest Snapshot</div>
|
<div class="host-card-timeline">
|
||||||
<div class="value">
|
<div class="host-card-item">
|
||||||
{% if host.latest_snapshot %}
|
<div class="label">Latest Snapshot</div>
|
||||||
<a href="{% url 'snapshot_detail' host.latest_snapshot.id %}">{{ host.latest_snapshot.dirname }}</a>
|
<div class="value">
|
||||||
<div class="muted">{{ host.latest_snapshot.kind }} {{ host.latest_snapshot.status }}</div>
|
{% if host.latest_snapshot %}
|
||||||
{% else %}
|
<a href="{% url 'snapshot_detail' host.latest_snapshot.id %}">{{ host.latest_snapshot.dirname }}</a>
|
||||||
<span class="muted">none</span>
|
<div class="muted">{{ host.latest_snapshot.kind }} {{ host.latest_snapshot.status }}</div>
|
||||||
{% endif %}
|
{% else %}
|
||||||
|
<span class="muted">none</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="host-card-item">
|
||||||
<div class="host-card-item">
|
<div class="label">Last Good Backup</div>
|
||||||
<div class="label">Latest Run</div>
|
<div class="value">
|
||||||
<div class="value">
|
{% if host.stats_summary.latest_good_run.id %}
|
||||||
{% if host.stats_summary.latest_run.id %}
|
<a href="{% url 'run_detail' host.stats_summary.latest_good_run.id %}">Run {{ host.stats_summary.latest_good_run.id }}</a>
|
||||||
<a href="{% url 'run_detail' host.stats_summary.latest_run.id %}">Run {{ host.stats_summary.latest_run.id }}</a>
|
<div class="muted">{{ host.stats_summary.latest_good_run.run_type }} {{ host.stats_summary.latest_good_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_good_run.duration_seconds is not None %}s{% endif %}</div>
|
||||||
<div class="muted">{{ host.stats_summary.latest_run.run_type }} {{ host.stats_summary.latest_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_run.duration_seconds is not None %}s{% endif %}</div>
|
{% else %}
|
||||||
{% else %}
|
<span class="muted">none</span>
|
||||||
<span class="muted">none</span>
|
{% endif %}
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="host-card-item">
|
||||||
<div class="host-card-item">
|
<div class="label">Latest Issue</div>
|
||||||
<div class="label">Next Run</div>
|
<div class="value">
|
||||||
<div class="value">
|
{% if host.stats_summary.latest_problem_run.id %}
|
||||||
{% if host.next_run_at %}
|
<a href="{% url 'run_detail' host.stats_summary.latest_problem_run.id %}">Run {{ host.stats_summary.latest_problem_run.id }}</a>
|
||||||
{{ host.next_run_at|date:"Y-m-d H:i T" }}
|
<div><span class="status {{ host.stats_summary.latest_problem_run.status }}">{{ host.stats_summary.latest_problem_run.status }}</span></div>
|
||||||
<div class="muted">{{ scheduler_timezone }}</div>
|
<div class="muted">{{ host.stats_summary.latest_problem_run.run_type }} {{ host.stats_summary.latest_problem_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_problem_run.duration_seconds is not None %}s{% endif %}</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="muted">none</span>
|
<span class="muted">none</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="host-card-item">
|
||||||
|
<div class="label">Next Run</div>
|
||||||
|
<div class="value">
|
||||||
|
{% if host.next_run_at %}
|
||||||
|
{{ host.next_run_at|date:"Y-m-d H:i T" }}
|
||||||
|
<div class="muted">{{ scheduler_timezone }}</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">none</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="host-card-stats">
|
<div class="host-card-section">
|
||||||
<div class="host-card-stat">
|
<div class="host-card-section-title">Snapshot health</div>
|
||||||
<div class="label">Snapshots</div>
|
<div class="host-card-stats">
|
||||||
<div class="value">{{ host.snapshot_count }}</div>
|
<div class="host-card-stat">
|
||||||
</div>
|
<div class="label">Snapshots</div>
|
||||||
<div class="host-card-stat">
|
<div class="value">{{ host.snapshot_count }}</div>
|
||||||
<div class="label">Runs</div>
|
</div>
|
||||||
<div class="value">{{ host.run_count }}</div>
|
<div class="host-card-stat">
|
||||||
</div>
|
<div class="label">Runs</div>
|
||||||
<div class="host-card-stat">
|
<div class="value">{{ host.run_count }}</div>
|
||||||
<div class="label">New Data</div>
|
</div>
|
||||||
<div class="value">{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</div>
|
<div class="host-card-stat">
|
||||||
</div>
|
<div class="label">New Data</div>
|
||||||
<div class="host-card-stat">
|
<div class="value">{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</div>
|
||||||
<div class="label">Retention</div>
|
</div>
|
||||||
<div class="value">d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</div>
|
<div class="host-card-stat">
|
||||||
|
<div class="label">Retention</div>
|
||||||
|
<div class="value">d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,27 @@ class ViewTests(TestCase):
|
|||||||
snapshot=snapshot,
|
snapshot=snapshot,
|
||||||
started_at=datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc),
|
started_at=datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc),
|
||||||
)
|
)
|
||||||
|
warning_run = BackupRun.objects.create(
|
||||||
|
host=host,
|
||||||
|
run_type=BackupRun.RunType.SCHEDULED,
|
||||||
|
status=BackupRun.Status.WARNING,
|
||||||
|
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||||
|
result={
|
||||||
|
"ok": True,
|
||||||
|
"prune": {
|
||||||
|
"ok": False,
|
||||||
|
"error": "Retention warning",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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"))
|
||||||
|
|
||||||
@@ -50,8 +71,24 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "web-01")
|
self.assertContains(response, "web-01")
|
||||||
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
||||||
self.assertContains(response, "success")
|
self.assertContains(response, "success")
|
||||||
|
self.assertContains(response, "Last Good Backup")
|
||||||
|
self.assertContains(response, "Latest Issue")
|
||||||
self.assertContains(response, f"Run {run.id}")
|
self.assertContains(response, f"Run {run.id}")
|
||||||
|
self.assertContains(response, f"Run {warning_run.id}")
|
||||||
|
self.assertContains(response, "warning")
|
||||||
self.assertContains(response, "manual")
|
self.assertContains(response, "manual")
|
||||||
|
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")
|
||||||
|
self.assertContains(response, "Operational Status")
|
||||||
|
self.assertContains(response, "1 failed run needs review.")
|
||||||
|
self.assertContains(response, "1 run completed with warnings.")
|
||||||
|
self.assertContains(response, "1 backup run in progress.")
|
||||||
|
self.assertContains(response, "1 backup run waiting for the worker.")
|
||||||
|
|
||||||
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)
|
||||||
@@ -88,10 +125,15 @@ 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, "Backup Root Used")
|
self.assertContains(response, "Backup Trends")
|
||||||
self.assertContains(response, "Runs Until Full")
|
self.assertContains(response, "Storage Used")
|
||||||
self.assertContains(response, "Avg Daily New")
|
self.assertContains(response, "Runway")
|
||||||
self.assertContains(response, "Days Until Full")
|
self.assertContains(response, "New Data")
|
||||||
|
self.assertContains(response, "Link-Dest Savings")
|
||||||
|
self.assertContains(response, "80.0%")
|
||||||
|
self.assertContains(response, "10 days")
|
||||||
|
self.assertContains(response, "Warnings")
|
||||||
|
self.assertContains(response, "Queued")
|
||||||
self.assertContains(response, "Next Run")
|
self.assertContains(response, "Next Run")
|
||||||
self.assertContains(response, "UTC")
|
self.assertContains(response, "UTC")
|
||||||
self.assertContains(response, "10")
|
self.assertContains(response, "10")
|
||||||
@@ -99,6 +141,29 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "manual")
|
self.assertContains(response, "manual")
|
||||||
self.assertContains(response, "1000")
|
self.assertContains(response, "1000")
|
||||||
|
|
||||||
|
def test_dashboard_explains_missing_backup_trends(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
GlobalConfig.objects.create(name="default", backup_root="/opt/pobsync/backups")
|
||||||
|
HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("dashboard"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Backup Trends")
|
||||||
|
self.assertContains(response, "No completed backup runs with stats yet.")
|
||||||
|
self.assertContains(response, "growth estimates")
|
||||||
|
|
||||||
|
def test_dashboard_shows_all_clear_operational_status(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
GlobalConfig.objects.create(name="default", backup_root="/opt/pobsync/backups")
|
||||||
|
HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("dashboard"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Operational Status")
|
||||||
|
self.assertContains(response, "No queued, running, warning, or failed runs.")
|
||||||
|
|
||||||
def test_dashboard_surfaces_retention_warnings(self) -> None:
|
def test_dashboard_surfaces_retention_warnings(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
host = HostConfig.objects.create(
|
host = HostConfig.objects.create(
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -71,7 +78,9 @@ def dashboard(request):
|
|||||||
"enabled_schedules": ScheduleConfig.objects.filter(enabled=True).count(),
|
"enabled_schedules": ScheduleConfig.objects.filter(enabled=True).count(),
|
||||||
"snapshots": SnapshotRecord.objects.count(),
|
"snapshots": SnapshotRecord.objects.count(),
|
||||||
"runs": BackupRun.objects.count(),
|
"runs": BackupRun.objects.count(),
|
||||||
|
"queued_runs": BackupRun.objects.filter(status=BackupRun.Status.QUEUED).count(),
|
||||||
"running_runs": BackupRun.objects.filter(status=BackupRun.Status.RUNNING).count(),
|
"running_runs": BackupRun.objects.filter(status=BackupRun.Status.RUNNING).count(),
|
||||||
|
"warning_runs": BackupRun.objects.filter(status=BackupRun.Status.WARNING).count(),
|
||||||
"failed_runs": BackupRun.objects.filter(status=BackupRun.Status.FAILED).count(),
|
"failed_runs": BackupRun.objects.filter(status=BackupRun.Status.FAILED).count(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user