diff --git a/src/pobsync_backend/stats_summary.py b/src/pobsync_backend/stats_summary.py index 8382891..03de25d 100644 --- a/src/pobsync_backend/stats_summary.py +++ b/src/pobsync_backend/stats_summary.py @@ -37,6 +37,8 @@ def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: Globa available = _int_at(capacity, "available_bytes") daily_literal = _average_daily_literal(real_runs) + link_dest_savings_ratio = round(total_matched / savings_basis, 4) if savings_basis else None + return { "runs_sampled": len(real_runs), "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, "total_literal_data_bytes": total_literal, "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_days_until_full": int(available / daily_literal) if available and daily_literal else None, "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]: - 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)] - 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_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 { "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, "avg_literal_data_bytes": _average(literal_values), "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, "snapshot": run.snapshot, "snapshot_path": run.snapshot_path, + "status": run.status, "has_stats": bool(stats), "duration_seconds": _int_at(stats, "duration_seconds"), "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 +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]: if global_config is None or not global_config.backup_root: return {} diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 7cf0dc8..7177a6e 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -61,6 +61,10 @@ .metric { padding: 14px; } .metric .label { color: var(--muted); font-size: 12px; text-transform: uppercase; } .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.highlight { border-left: 4px solid var(--border); } .panel.highlight.failed { border-left-color: var(--failed); background: #fff7f7; } @@ -118,6 +122,23 @@ cursor: not-allowed; } .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 { align-items: center; display: flex; @@ -149,6 +170,43 @@ font-size: 12px; 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 { display: grid; gap: 12px; @@ -176,23 +234,42 @@ overflow-wrap: anywhere; } .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 { display: grid; - gap: 18px; - grid-template-columns: minmax(0, 1fr) minmax(240px, 320px); + gap: 24px; + 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 { display: grid; - gap: 12px; + gap: 16px 22px; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); } .host-card-stats { align-content: start; display: grid; - gap: 10px; + border-top: 1px solid #e6edf4; + gap: 12px 18px; grid-template-columns: repeat(2, minmax(0, 1fr)); + padding-top: 12px; } .host-card-item { display: grid; @@ -209,13 +286,9 @@ overflow-wrap: anywhere; } .host-card-stat { - background: #f8fafc; - border: 1px solid #e6edf4; - border-radius: 6px; display: grid; gap: 3px; min-width: 0; - padding: 10px; } .host-card-stat .label { color: var(--muted); @@ -286,7 +359,11 @@ main { padding: 16px; } nav { padding: 0; } .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-stats { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); } + .insight-grid { grid-template-columns: 1fr; } } diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index 9b2b6ba..b595b34 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -32,22 +32,109 @@
ok No queued, running, warning, or failed runs.
+ {% else %} +Add a host to start tracking backup status here.
+ {% endif %} +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.
+ {% endif %} +