Add per-backup status dot (green/yellow/red/unknown) to the UI

backup-k8s-apps.sh now tracks step-level failures (manifests, secret,
db_dump, pvc_data) into an ERRORS array during the per-app loop, and
writes a .meta.json sidecar alongside each archive once all 3 storage
tiers are known - apps included, final size, the error list, and
per-tier ok/failed/skipped status. Sidecar is mirrored to VM/R2 the
same best-effort way the .sha256 sidecar already is, and cleaned up
by both local and R2 retention pruning plus manual delete.

modules/backups.py reads these sidecars (never decompresses the
archive) to compute a status per backup at list-render time:
  - red: any recorded error, or any tier status starting with "failed"
  - yellow: no errors, but size deviates >40% from the rolling average
    of the last 5 backups sharing the same app-combination (apps_key)
  - green: no errors, size within range (or first backup of its
    app-combination - nothing to compare against yet)
  - unknown: no sidecar at all (legacy myapps-backup-* archives, or
    any k8s backup made before this shipped) - no backfill attempted,
    old runs never recorded step-level failures to reconstruct from

/backups route now passes get_local_backups_with_status()/
get_vm_backups_with_status() instead of the plain filename lists
(get_local_backups()/get_vm_backups() themselves are untouched -
/restore and /api/backups still use the plain versions, they don't
need the dot). Template renders a colored dot next to each entry with
a tooltip showing apps/size.

Verified: real n8n backup produces a correct sidecar synced to all 3
tiers; JSON-writer argv parsing and the red/yellow/green/unknown
decision logic each checked against synthetic cases; full Jinja render
checked against real local + VM data pulled from the live pod.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017avLHFqkiti3g62Anq9sVA
This commit is contained in:
root
2026-08-21 02:46:50 +02:00
parent de0b1ee4db
commit d498c81d5b
5 changed files with 265 additions and 27 deletions

View File

@@ -286,14 +286,15 @@
{% for b in backups %}
<div class="backup-item">
<div class="backup-item-main">
<span class="backup-name">{{ b }}</span>
<span class="status-dot status-dot-{{ b.status }}" title="{{ b.status }}{% if b.apps %} · apps: {{ b.apps|join(', ') }}{% endif %}{% if b.size_human %} · {{ b.size_human }}{% endif %}"></span>
<span class="backup-name">{{ b.name }}</span>
</div>
<div class="backup-actions">
<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('local','{{ b }}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('local','{{ b }}',this)"><i class="fas fa-shield-check"></i> Audit</button>
<button class="btn btn-ghost btn-sm" onclick="quickRestore('local','{{ b }}')">↩ Restore</button>
<button class="btn btn-ghost btn-sm" onclick="quickUploadR2('{{ b }}',this)" title="Upload to R2"><i class="fas fa-cloud-upload-alt"></i></button>
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('local','{{ b }}',this)"><i class="fas fa-trash"></i></button>
<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('local','{{ b.name }}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('local','{{ b.name }}',this)"><i class="fas fa-shield-check"></i> Audit</button>
<button class="btn btn-ghost btn-sm" onclick="quickRestore('local','{{ b.name }}')">↩ Restore</button>
<button class="btn btn-ghost btn-sm" onclick="quickUploadR2('{{ b.name }}',this)" title="Upload to R2"><i class="fas fa-cloud-upload-alt"></i></button>
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('local','{{ b.name }}',this)"><i class="fas fa-trash"></i></button>
</div>
</div>
{% else %}<div class="empty-state"><i class="fas fa-inbox"></i>No backups</div>{% endfor %}
@@ -310,13 +311,14 @@
{% for b in vm_backups %}
<div class="backup-item">
<div class="backup-item-main">
<span class="backup-name">{{ b }}</span>
<span class="status-dot status-dot-{{ b.status }}" title="{{ b.status }}{% if b.apps %} · apps: {{ b.apps|join(', ') }}{% endif %}{% if b.size_human %} · {{ b.size_human }}{% endif %}"></span>
<span class="backup-name">{{ b.name }}</span>
</div>
<div class="backup-actions">
<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('vm','{{ b }}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('vm','{{ b }}',this)"><i class="fas fa-shield-check"></i> Audit</button>
<button class="btn btn-ghost btn-sm" onclick="quickRestore('vm','{{ b }}')">↩ Restore</button>
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('vm','{{ b }}',this)"><i class="fas fa-trash"></i></button>
<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('vm','{{ b.name }}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('vm','{{ b.name }}',this)"><i class="fas fa-shield-check"></i> Audit</button>
<button class="btn btn-ghost btn-sm" onclick="quickRestore('vm','{{ b.name }}')">↩ Restore</button>
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('vm','{{ b.name }}',this)"><i class="fas fa-trash"></i></button>
</div>
</div>
{% else %}<div class="empty-state"><i class="fas fa-inbox"></i>No VM backups</div>{% endfor %}