ui: add backup progress tracking and force B2 refresh

This commit is contained in:
Brad Stein 2026-04-13 03:46:38 -03:00
parent 2d5524a48d
commit 3f203b2c14
7 changed files with 305 additions and 34 deletions

View File

@ -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"`

View File

@ -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)
}

View File

@ -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()

View File

@ -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

View File

@ -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{

View File

@ -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<B2UsageResponse>(EMPTY_B2);
const [b2Error, setB2Error] = useState<string>('');
const [b2Refreshing, setB2Refreshing] = useState<boolean>(false);
const [selection, setSelection] = useState<RestoreSelection>({ kind: 'none' });
const [restoreNamespace, setRestoreNamespace] = useState<string>('');
@ -214,6 +238,18 @@ function App() {
const [lastAction, setLastAction] = useState<string>('No action yet.');
const [busy, setBusy] = useState<boolean>(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<void> => {
const loadB2Usage = async (forceRefresh = false): Promise<void> => {
try {
const payload = await fetchJSON<B2UsageResponse>('/v1/b2');
const payload = await fetchJSON<B2UsageResponse>(forceRefresh ? '/v1/b2?refresh=1' : '/v1/b2');
setB2Usage(payload);
setB2Error('');
} catch (error) {
@ -277,6 +313,15 @@ function App() {
}
};
const refreshB2Usage = async (): Promise<void> => {
setB2Refreshing(true);
try {
await loadB2Usage(true);
} finally {
setB2Refreshing(false);
}
};
const refreshAll = async (): Promise<void> => {
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<void> => {
setBusy(true);
try {
@ -465,6 +522,11 @@ function App() {
</div>
<div className="toolbar">
<span className={`chip ${auth ? 'good' : 'warn'}`}>{authLabel}</span>
{activeBackupCount > 0 && (
<span className="chip warn">
{activeBackupCount} backup job{activeBackupCount === 1 ? '' : 's'} active
</span>
)}
<button type="button" className="secondary" onClick={() => void refreshAll()} disabled={busy}>
{busy ? 'Refreshing...' : 'Refresh'}
</button>
@ -493,34 +555,57 @@ function App() {
</div>
</div>
<div className="pvc-grid">
{namespace.pvcs.map((pvc) => (
<article key={`${pvc.namespace}/${pvc.pvc}`} className="pvc-card">
<div className="pvc-title-row">
<div>
<h4>{pvc.pvc}</h4>
<p className="subtle tiny">{pvc.volume || 'unknown volume'} | {pvc.storage_class || 'no class'} | {pvc.capacity || 'unknown size'}</p>
{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 (
<article key={`${pvc.namespace}/${pvc.pvc}`} className="pvc-card">
<div className="pvc-title-row">
<div>
<h4>{pvc.pvc}</h4>
<p className="subtle tiny">{pvc.volume || 'unknown volume'} | {pvc.storage_class || 'no class'} | {pvc.capacity || 'unknown size'}</p>
</div>
<span className={`chip ${healthClass}`}>
{healthLabel}
</span>
</div>
<span className={`chip ${pvc.healthy ? 'good' : 'bad'}`}>
{pvc.healthy ? 'Healthy' : pvc.health_reason || 'Needs attention'}
</span>
</div>
<p className="subtle tiny">
Last backup: {pvc.last_backup_at ? `${formatTimestamp(pvc.last_backup_at)} (${(pvc.last_backup_age_hours || 0).toFixed(1)}h ago)` : 'never'}
</p>
<p className="subtle tiny">
Backups: {pvc.completed_backups}/{pvc.backup_count} completed | Latest size: {formatBytes(pvc.last_backup_size_bytes)} | Total stored: {formatBytes(pvc.total_backup_size_bytes)}
</p>
{pvc.error && <p className="error tiny">{pvc.error}</p>}
<div className="actions">
<button type="button" onClick={() => void triggerBackup(pvc.namespace, pvc.pvc)} disabled={busy}>
Backup now
</button>
<button type="button" className="secondary" onClick={() => void openPVCSelection(pvc.namespace, pvc.pvc)}>
Restore
</button>
</div>
</article>
))}
<p className="subtle tiny">
Last backup: {pvc.last_backup_at ? `${formatTimestamp(pvc.last_backup_at)} (${(pvc.last_backup_age_hours || 0).toFixed(1)}h ago)` : 'never'}
</p>
<p className="subtle tiny">
Backups: {pvc.completed_backups}/{pvc.backup_count} completed | Latest size: {formatBytes(pvc.last_backup_size_bytes)} | Total stored: {formatBytes(pvc.total_backup_size_bytes)}
</p>
{showProgress && (
<div className="backup-progress">
<div className="progress-header">
<p className="subtle tiny">
Job: {pvc.last_job_name || 'n/a'}
{pvc.last_job_started_at ? ` | Started ${formatTimestamp(pvc.last_job_started_at)}` : ''}
</p>
<span className={`chip ${progressClass}`}>{pvc.last_job_state || 'Unknown'}</span>
</div>
<div className="progress-track" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progressPct}>
<div className={`progress-fill ${progressClass} ${pvc.active_backups > 0 ? 'active' : ''}`} style={{ width: `${progressPct}%` }} />
</div>
<p className="subtle tiny">Progress {progressPct}% | Active jobs: {pvc.active_backups || 0}</p>
</div>
)}
{pvc.error && <p className="error tiny">{pvc.error}</p>}
<div className="actions">
<button type="button" onClick={() => void triggerBackup(pvc.namespace, pvc.pvc)} disabled={busy}>
Backup now
</button>
<button type="button" className="secondary" onClick={() => void openPVCSelection(pvc.namespace, pvc.pvc)}>
Restore
</button>
</div>
</article>
);
})}
</div>
</article>
))}
@ -606,7 +691,9 @@ function App() {
<section className="panel">
<div className="panel-header">
<h2>B2 Consumption</h2>
<button type="button" className="secondary" onClick={() => void loadB2Usage()} disabled={busy}>Refresh B2</button>
<button type="button" className="secondary" onClick={() => void refreshB2Usage()} disabled={b2Refreshing}>
{b2Refreshing ? 'Refreshing...' : 'Refresh B2'}
</button>
</div>
{b2Error && <p className="error">{b2Error}</p>}
{!b2Error && !b2Usage.enabled && <p className="subtle">B2 monitoring is disabled in Soteria config.</p>}

View File

@ -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;