diff --git a/internal/api/types.go b/internal/api/types.go index 48b4467..d7b9c9a 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -65,6 +65,11 @@ type PVCInventory struct { LastBackupAgeHours float64 `json:"last_backup_age_hours,omitempty"` BackupCount int `json:"backup_count"` CompletedBackups int `json:"completed_backups"` + ActiveBackups int `json:"active_backups"` + LastJobName string `json:"last_job_name,omitempty"` + LastJobState string `json:"last_job_state,omitempty"` + LastJobStartedAt string `json:"last_job_started_at,omitempty"` + LastJobProgressPct int `json:"last_job_progress_pct"` LastBackupSizeBytes float64 `json:"last_backup_size_bytes,omitempty"` TotalBackupSizeBytes float64 `json:"total_backup_size_bytes,omitempty"` Healthy bool `json:"healthy"` diff --git a/internal/k8s/jobs.go b/internal/k8s/jobs.go index 482c96c..d25e238 100644 --- a/internal/k8s/jobs.go +++ b/internal/k8s/jobs.go @@ -46,7 +46,7 @@ func (c *Client) ListBackupJobsForPVC(ctx context.Context, namespace, pvc string Namespace: job.Namespace, PVC: pvc, CreatedAt: job.CreationTimestamp.Time, - State: "Running", + State: "Pending", } if job.Status.CompletionTime != nil { summary.CompletionTime = job.Status.CompletionTime.Time @@ -56,6 +56,8 @@ func (c *Client) ListBackupJobsForPVC(ctx context.Context, namespace, pvc string summary.State = "Completed" case job.Status.Failed > 0: summary.State = "Failed" + case job.Status.Active > 0: + summary.State = "Running" } out = append(out, summary) } diff --git a/internal/server/b2.go b/internal/server/b2.go index d6c245c..3c794e1 100644 --- a/internal/server/b2.go +++ b/internal/server/b2.go @@ -29,14 +29,24 @@ func (s *Server) handleB2Usage(w http.ResponseWriter, r *http.Request) { return } + forceRefresh := queryBool(r.URL.Query().Get("refresh")) || queryBool(r.URL.Query().Get("force")) snapshot := s.getB2Usage() - if s.cfg.B2Enabled && snapshot.ScannedAt == "" { + if s.cfg.B2Enabled && (snapshot.ScannedAt == "" || forceRefresh) { s.refreshB2Usage(r.Context()) snapshot = s.getB2Usage() } writeJSON(w, http.StatusOK, snapshot) } +func queryBool(raw string) bool { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} + func (s *Server) getB2Usage() api.B2UsageResponse { s.b2Mu.RLock() defer s.b2Mu.RUnlock() diff --git a/internal/server/server.go b/internal/server/server.go index 3078250..b7b98fb 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -936,19 +936,35 @@ func (s *Server) enrichPVCInventory(ctx context.Context, entry *api.PVCInventory return } entry.BackupCount = len(jobs) + if len(jobs) > 0 { + entry.LastJobName = jobs[0].Name + entry.LastJobState = jobs[0].State + entry.LastJobProgressPct = backupJobProgressPct(jobs[0].State) + if !jobs[0].CreatedAt.IsZero() { + entry.LastJobStartedAt = jobs[0].CreatedAt.UTC().Format(time.RFC3339) + } + } completed := make([]k8s.BackupJobSummary, 0, len(jobs)) + active := 0 for _, job := range jobs { + if backupJobInProgress(job.State) { + active++ + } if strings.EqualFold(job.State, "Completed") { completed = append(completed, job) } } + entry.ActiveBackups = active entry.CompletedBackups = len(completed) if len(completed) == 0 { entry.Healthy = false - if len(jobs) == 0 { + switch { + case active > 0: + entry.HealthReason = "in_progress" + case len(jobs) == 0: entry.HealthReason = "missing" - } else { + default: entry.HealthReason = "no_completed" } return @@ -1498,6 +1514,28 @@ func backupJobTimestamp(job k8s.BackupJobSummary) time.Time { return job.CreatedAt } +func backupJobInProgress(state string) bool { + switch strings.ToLower(strings.TrimSpace(state)) { + case "pending", "running": + return true + default: + return false + } +} + +func backupJobProgressPct(state string) int { + switch strings.ToLower(strings.TrimSpace(state)) { + case "pending": + return 20 + case "running": + return 70 + case "completed", "failed": + return 100 + default: + return 0 + } +} + func latestCompletedBackup(backups []longhorn.Backup) (longhorn.Backup, time.Time, bool) { var selected longhorn.Backup var selectedTime time.Time diff --git a/internal/server/server_test.go b/internal/server/server_test.go index d31bee6..7901681 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -331,6 +331,68 @@ func TestResticInventoryUsesCompletedBackupJobs(t *testing.T) { } } +func TestResticInventoryMarksInProgressWhenOnlyActiveJobsExist(t *testing.T) { + startedAt := time.Now().UTC().Add(-5 * time.Minute) + srv := &Server{ + cfg: &config.Config{ + AuthRequired: false, + BackupDriver: "restic", + BackupMaxAge: 24 * time.Hour, + }, + client: &fakeKubeClient{ + pvcs: []k8s.PVCSummary{ + {Namespace: "apps", Name: "data", VolumeName: "pv-apps-data", Phase: "Bound"}, + }, + backupJobs: map[string][]k8s.BackupJobSummary{ + "apps/data": { + { + Name: "soteria-backup-data-20260413-010000", + Namespace: "apps", + PVC: "data", + CreatedAt: startedAt, + State: "Running", + }, + }, + }, + }, + longhorn: &fakeLonghornClient{}, + metrics: newTelemetry(), + } + srv.handler = http.HandlerFunc(srv.route) + + req := httptest.NewRequest(http.MethodGet, "/v1/inventory", nil) + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", res.Code, res.Body.String()) + } + + var payload api.InventoryResponse + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode inventory: %v", err) + } + if len(payload.Namespaces) != 1 || len(payload.Namespaces[0].PVCs) != 1 { + t.Fatalf("unexpected inventory payload: %#v", payload) + } + entry := payload.Namespaces[0].PVCs[0] + if entry.Healthy { + t.Fatalf("expected in-progress backup to be unhealthy until completion, got %#v", entry) + } + if entry.HealthReason != "in_progress" { + t.Fatalf("expected in_progress health reason, got %#v", entry.HealthReason) + } + if entry.ActiveBackups != 1 || entry.CompletedBackups != 0 { + t.Fatalf("expected one active backup and zero completed backups, got %#v", entry) + } + if entry.LastJobName == "" || entry.LastJobState != "Running" { + t.Fatalf("expected latest job metadata, got %#v", entry) + } + if entry.LastJobProgressPct != 70 { + t.Fatalf("expected running progress to be 70, got %#v", entry.LastJobProgressPct) + } +} + func TestResticBackupsEndpointReturnsLatestSelector(t *testing.T) { completedAt := time.Now().UTC().Add(-30 * time.Minute) srv := &Server{ diff --git a/web/src/App.tsx b/web/src/App.tsx index d9444cd..ac51a6a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -38,6 +38,11 @@ interface PVCInventory { last_backup_age_hours?: number; backup_count: number; completed_backups: number; + active_backups: number; + last_job_name?: string; + last_job_state?: string; + last_job_started_at?: string; + last_job_progress_pct: number; last_backup_size_bytes?: number; total_backup_size_bytes?: number; healthy: boolean; @@ -153,6 +158,24 @@ function formatTimestamp(value?: string): string { return parsed.toLocaleString(); } +function formatLabel(value?: string): string { + if (!value) { + return 'Needs attention'; + } + return value.replace(/_/g, ' '); +} + +function progressChipClass(state?: string): 'good' | 'warn' | 'bad' { + switch ((state || '').toLowerCase()) { + case 'completed': + return 'good'; + case 'failed': + return 'bad'; + default: + return 'warn'; + } +} + function suggestTargetPVCName(sourcePVC: string): string { const now = new Date(); const pad = (item: number) => String(item).padStart(2, '0'); @@ -197,6 +220,7 @@ function App() { const [b2Usage, setB2Usage] = useState(EMPTY_B2); const [b2Error, setB2Error] = useState(''); + const [b2Refreshing, setB2Refreshing] = useState(false); const [selection, setSelection] = useState({ kind: 'none' }); const [restoreNamespace, setRestoreNamespace] = useState(''); @@ -214,6 +238,18 @@ function App() { const [lastAction, setLastAction] = useState('No action yet.'); const [busy, setBusy] = useState(false); + const activeBackupCount = useMemo(() => { + if (!inventory) { + return 0; + } + let total = 0; + for (const namespace of inventory.namespaces) { + for (const pvc of namespace.pvcs) { + total += pvc.active_backups || 0; + } + } + return total; + }, [inventory]); const namespaceOptions = useMemo(() => { if (!inventory) { @@ -266,9 +302,9 @@ function App() { } }; - const loadB2Usage = async (): Promise => { + const loadB2Usage = async (forceRefresh = false): Promise => { try { - const payload = await fetchJSON('/v1/b2'); + const payload = await fetchJSON(forceRefresh ? '/v1/b2?refresh=1' : '/v1/b2'); setB2Usage(payload); setB2Error(''); } catch (error) { @@ -277,6 +313,15 @@ function App() { } }; + const refreshB2Usage = async (): Promise => { + setB2Refreshing(true); + try { + await loadB2Usage(true); + } finally { + setB2Refreshing(false); + } + }; + const refreshAll = async (): Promise => { setBusy(true); try { @@ -290,6 +335,18 @@ function App() { void refreshAll(); }, []); + useEffect(() => { + if (activeBackupCount <= 0) { + return undefined; + } + const handle = window.setInterval(() => { + void loadInventory(); + }, 8000); + return () => { + window.clearInterval(handle); + }; + }, [activeBackupCount]); + const triggerBackup = async (namespace: string, pvc: string): Promise => { setBusy(true); try { @@ -465,6 +522,11 @@ function App() {
{authLabel} + {activeBackupCount > 0 && ( + + {activeBackupCount} backup job{activeBackupCount === 1 ? '' : 's'} active + + )} @@ -493,34 +555,57 @@ function App() {
- {namespace.pvcs.map((pvc) => ( -
-
-
-

{pvc.pvc}

-

{pvc.volume || 'unknown volume'} | {pvc.storage_class || 'no class'} | {pvc.capacity || 'unknown size'}

+ {namespace.pvcs.map((pvc) => { + const healthClass = pvc.healthy ? 'good' : (pvc.health_reason === 'in_progress' ? 'warn' : 'bad'); + const healthLabel = pvc.healthy ? 'Healthy' : formatLabel(pvc.health_reason); + const progressPct = Math.max(0, Math.min(100, Number(pvc.last_job_progress_pct || 0))); + const progressClass = progressChipClass(pvc.last_job_state); + const showProgress = Boolean(pvc.last_job_name) || (pvc.active_backups || 0) > 0; + + return ( +
+
+
+

{pvc.pvc}

+

{pvc.volume || 'unknown volume'} | {pvc.storage_class || 'no class'} | {pvc.capacity || 'unknown size'}

+
+ + {healthLabel} +
- - {pvc.healthy ? 'Healthy' : pvc.health_reason || 'Needs attention'} - -
-

- Last backup: {pvc.last_backup_at ? `${formatTimestamp(pvc.last_backup_at)} (${(pvc.last_backup_age_hours || 0).toFixed(1)}h ago)` : 'never'} -

-

- Backups: {pvc.completed_backups}/{pvc.backup_count} completed | Latest size: {formatBytes(pvc.last_backup_size_bytes)} | Total stored: {formatBytes(pvc.total_backup_size_bytes)} -

- {pvc.error &&

{pvc.error}

} -
- - -
-
- ))} +

+ Last backup: {pvc.last_backup_at ? `${formatTimestamp(pvc.last_backup_at)} (${(pvc.last_backup_age_hours || 0).toFixed(1)}h ago)` : 'never'} +

+

+ Backups: {pvc.completed_backups}/{pvc.backup_count} completed | Latest size: {formatBytes(pvc.last_backup_size_bytes)} | Total stored: {formatBytes(pvc.total_backup_size_bytes)} +

+ {showProgress && ( +
+
+

+ Job: {pvc.last_job_name || 'n/a'} + {pvc.last_job_started_at ? ` | Started ${formatTimestamp(pvc.last_job_started_at)}` : ''} +

+ {pvc.last_job_state || 'Unknown'} +
+
+
0 ? 'active' : ''}`} style={{ width: `${progressPct}%` }} /> +
+

Progress {progressPct}% | Active jobs: {pvc.active_backups || 0}

+
+ )} + {pvc.error &&

{pvc.error}

} +
+ + +
+ + ); + })}
))} @@ -606,7 +691,9 @@ function App() {

B2 Consumption

- +
{b2Error &&

{b2Error}

} {!b2Error && !b2Usage.enabled &&

B2 monitoring is disabled in Soteria config.

} diff --git a/web/src/styles.css b/web/src/styles.css index e30ab22..415fde8 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -217,6 +217,73 @@ button:disabled { gap: 8px; } +.backup-progress { + border: 1px solid rgba(35, 50, 77, 0.7); + border-radius: 10px; + background: rgba(9, 14, 24, 0.72); + padding: 8px; + display: grid; + gap: 7px; +} + +.progress-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.progress-track { + border-radius: 999px; + border: 1px solid var(--line); + overflow: hidden; + height: 9px; + background: rgba(13, 24, 39, 0.95); +} + +.progress-fill { + height: 100%; + border-radius: 999px; + transition: width 240ms ease; +} + +.progress-fill.good { + background: linear-gradient(90deg, rgba(72, 200, 142, 0.72), rgba(91, 237, 164, 0.9)); +} + +.progress-fill.warn { + background: linear-gradient(90deg, rgba(241, 180, 90, 0.7), rgba(255, 206, 128, 0.9)); +} + +.progress-fill.bad { + background: linear-gradient(90deg, rgba(255, 106, 120, 0.72), rgba(255, 133, 148, 0.9)); +} + +.progress-fill.active { + background-size: 20px 20px; + background-image: + linear-gradient( + -45deg, + rgba(255, 255, 255, 0.08) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.08) 50%, + rgba(255, 255, 255, 0.08) 75%, + transparent 75%, + transparent + ); + animation: progress-shift 1s linear infinite; +} + +@keyframes progress-shift { + from { + background-position: 0 0; + } + to { + background-position: 20px 0; + } +} + .pvc-title-row { display: flex; justify-content: space-between;