Add local backup retention: keep-local toggle, download, delete-local
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:
+72
-4
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user