Add local backup retention: keep-local toggle, download, delete-local
CI / Frontend build (push) Successful in 12s
CI / Script syntax (push) Successful in 3s
CI / Backend tests (push) Successful in 17s

Lets operators opt into keeping the local archive after upload (global
keep_local_backups setting), pull a remote archive back down into the
Proxmox dump directory for manual restore, and drop a locally-retained
copy without touching the remote object. Consolidates deletion so
retention, job-cascade delete, manual delete, and crash-recovery resume
all clean up local copies alongside remote ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-07-15 12:58:52 +02:00
parent 1288ba731d
commit 508decffe3
9 changed files with 404 additions and 35 deletions
+72 -4
View File
@@ -17,6 +17,7 @@ type Settings = {
default_retention_value: number
max_concurrent_backups: number
timezone: string
keep_local_backups: boolean
}
type Guest = { vmid: number; name: string; type: string; node: string; status: string }
@@ -71,6 +72,7 @@ const emptySettings: Settings = {
default_retention_value: 30,
max_concurrent_backups: 1,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
keep_local_backups: false,
}
const page = ref('Dashboard')
@@ -87,6 +89,8 @@ const dashboard = ref<any>({})
const selectedBackup = ref<Backup | null>(null)
const deletingJobId = ref<number | null>(null)
const deletingBackupId = ref<string | null>(null)
const downloadingBackupId = ref<string | null>(null)
const deletingLocalBackupId = ref<string | null>(null)
const nowMs = ref(Date.now())
let clockTimer: number | undefined
const filters = reactive({ guest_vmid: '', state: '' })
@@ -348,6 +352,46 @@ async function deleteBackup(backup: Backup) {
}
}
async function downloadBackup(backup: Backup) {
error.value = ''
actionMessage.value = ''
downloadingBackupId.value = backup.id
loading.value = true
actionMessage.value = `Downloading backup ${backup.id} from the remote. This can take a while for large archives...`
try {
const result = await api<Backup>(`/api/backups/${backup.id}/download`, { method: 'POST' })
selectedBackup.value = result
actionMessage.value = `Downloaded to ${result.local_path}. In the Proxmox UI, open storage "${settings.proxmox_storage}" on node "${settings.proxmox_node}", find this archive under Backups, and click Restore. This app does not perform restores itself.`
await loadBackups()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
downloadingBackupId.value = null
}
}
async function deleteLocalCopy(backup: Backup) {
error.value = ''
actionMessage.value = ''
const ok = window.confirm(`Delete the local copy of backup ${backup.id}? The remote copy is left untouched.`)
if (!ok) return
deletingLocalBackupId.value = backup.id
loading.value = true
actionMessage.value = `Deleting local copy of backup ${backup.id}...`
try {
const result = await api<Backup>(`/api/backups/${backup.id}/local`, { method: 'DELETE' })
selectedBackup.value = result
actionMessage.value = `Deleted local copy of backup ${backup.id}.`
await loadBackups()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
deletingLocalBackupId.value = null
}
}
function fmtBytes(value?: number) {
if (!value) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
@@ -451,6 +495,7 @@ onUnmounted(() => {
<label><span class="label">Max concurrent backups</span><input v-model.number="settings.max_concurrent_backups" type="number" min="1" class="w-full" /></label>
<label><span class="label">Discord webhook URL</span><input v-model="settings.discord_webhook_url" class="w-full" /></label>
</div>
<label class="mt-3 flex items-center gap-2 text-sm"><input type="checkbox" v-model="settings.keep_local_backups" /> Keep local backups after upload</label>
<label class="mt-3 block"><span class="label">Allowed CORS origins, one per line</span><textarea v-model="corsText" rows="3" class="w-full"></textarea></label>
<div class="mt-4 flex gap-2">
<button @click="saveSettings(true)" :disabled="loading || !discover.rclone_remotes.length">{{ loading ? 'Saving...' : 'Save setup' }}</button>
@@ -595,13 +640,31 @@ onUnmounted(() => {
<section v-else-if="page === 'Backup details'" class="panel">
<div class="mb-3 flex items-center justify-between gap-3">
<h2 class="font-semibold">Backup details</h2>
<button v-if="selectedBackup" class="bg-red-700 hover:bg-red-600" @click="deleteBackup(selectedBackup)" :disabled="loading">
{{ deletingBackupId === selectedBackup.id ? (selectedBackup.state === 'deleted' ? 'Nuking...' : 'Deleting...') : (selectedBackup.state === 'deleted' ? 'Nuke metadata' : 'Delete backup') }}
</button>
<div v-if="selectedBackup" class="flex flex-wrap gap-2">
<button
v-if="selectedBackup.state === 'completed' && selectedBackup.remote_path && !selectedBackup.local_path"
class="bg-slate-700 hover:bg-slate-600"
@click="downloadBackup(selectedBackup)"
:disabled="loading"
>
{{ downloadingBackupId === selectedBackup.id ? 'Downloading...' : 'Download to local' }}
</button>
<button
v-if="selectedBackup.state === 'completed' && selectedBackup.local_path"
class="bg-slate-700 hover:bg-slate-600"
@click="deleteLocalCopy(selectedBackup)"
:disabled="loading"
>
{{ deletingLocalBackupId === selectedBackup.id ? 'Deleting local...' : 'Delete local copy' }}
</button>
<button class="bg-red-700 hover:bg-red-600" @click="deleteBackup(selectedBackup)" :disabled="loading">
{{ deletingBackupId === selectedBackup.id ? (selectedBackup.state === 'deleted' ? 'Nuking...' : 'Deleting...') : (selectedBackup.state === 'deleted' ? 'Nuke metadata' : 'Delete backup') }}
</button>
</div>
</div>
<p v-if="!selectedBackup" class="text-sm text-slate-400">Select a backup from Backup history.</p>
<div v-else class="space-y-4">
<div class="grid gap-3 md:grid-cols-4">
<div class="grid gap-3 md:grid-cols-5">
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<div class="label">State</div>
<span :class="stateClass(selectedBackup.state)" class="mt-1 inline-block rounded border px-2 py-1 text-xs">{{ selectedBackup.state }}</span>
@@ -619,6 +682,10 @@ onUnmounted(() => {
<div class="label">Job</div>
<div>#{{ valueOrDash(selectedBackup.job_id) }}</div>
</div>
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<div class="label">Local copy</div>
<div>{{ selectedBackup.local_path ? 'Kept' : 'Not kept' }}</div>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
@@ -674,6 +741,7 @@ onUnmounted(() => {
<label><span class="label">Retention value</span><input v-model.number="settings.default_retention_value" type="number" min="1" class="w-full" /></label>
<label><span class="label">Max concurrent backups</span><input v-model.number="settings.max_concurrent_backups" type="number" min="1" class="w-full" /></label>
</div>
<label class="mt-3 flex items-center gap-2 text-sm"><input type="checkbox" v-model="settings.keep_local_backups" /> Keep local backups after upload</label>
<label class="mt-3 block"><span class="label">Allowed CORS origins</span><textarea v-model="corsText" rows="3" class="w-full"></textarea></label>
<button class="mt-4" @click="saveSettings(true)" :disabled="loading">{{ loading ? 'Saving...' : 'Save settings' }}</button>
</section>