ui: add backup progress tracking and force B2 refresh
This commit is contained in:
parent
2d5524a48d
commit
3f203b2c14
@ -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"`
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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{
|
||||
|
||||
147
web/src/App.tsx
147
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<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>}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user