CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 25s
CI and images / extension-test (push) Successful in 18s
CI and images / backend-lint-and-test (push) Failing after 44s
CI and images / integration (push) Failing after 2m28s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
With an off-site destination set up, the library's files are sent to the bucket's media/ folder as media/ab/cd/<sha256><ext>, beside the database dumps under database/. Each file goes once: a new backup_blob table records what each destination holds (migration 0121), and a run's work list is every image and attachment sha with no row yet, so runs are incremental and a new bucket starts its copy from the beginning. - The bucket checks each file against the sha recorded at import; a file over 1 GiB goes in parts and is checked before the upload is completed. A file missing or changed on disk is reported, not stored. - A run is chunked: each 45-minute chunk commits after every file and queues the next, so other long-maintenance jobs get turns. One run at a time. The stall sweep judges a media run by its progress heartbeat (backup_run.last_progress_at), not its start. - A file that can't be sent is listed on the run (status ok, with the error line) and tried again by the next run. A refused put for an object already in an append-only bucket counts as sent. - Nightly, with a destination set, runs the dump and then the media. - Weekly reconcile lists media/ and re-sends what the bucket lost, and adopts what it already holds (a restored database sends nothing twice). - Uploads use the bandwidth limit; read transactions end before transfers. - Backups card: with a destination, an "Images and attachments" section shows coverage (files and bytes in the bucket), "Send new files now", and the media runs. Without one, the local tarball section is unchanged. - Media run history keeps the latest 60 rows. Task #6879, milestone 586. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
9.0 KiB
Vue
273 lines
9.0 KiB
Vue
<template>
|
|
<MaintenanceTile
|
|
icon="mdi-database-export"
|
|
title="Backups"
|
|
blurb="Database + image backups, schedules and restore."
|
|
>
|
|
<p v-if="offsite" class="fc-muted text-body-2 mb-4">
|
|
Database dumps stream to the <strong>{{ offsite.bucket }}</strong> bucket
|
|
at {{ offsite.endpoint }}, with no copy kept on this server; the bucket's
|
|
own retention thins old dumps. Images and attachments go to the same
|
|
bucket's <code>media/</code> folder, below.
|
|
</p>
|
|
<p v-else class="fc-muted text-body-2 mb-4">
|
|
pg_dump for the database (fast, nightly-schedulable);
|
|
tar+zstd for the images (slow, manual only). Files live in
|
|
<code>/images/_backups/</code>. Tag a backup to protect it
|
|
from autoprune. Set up an off-site destination above to send
|
|
database dumps off this server instead.
|
|
</p>
|
|
|
|
<v-alert
|
|
v-if="store.lastError"
|
|
type="warning" variant="tonal" density="compact" class="mb-3"
|
|
>
|
|
{{ store.lastError }}
|
|
</v-alert>
|
|
|
|
<!-- Database section -->
|
|
<h3 class="fc-section-title">Database</h3>
|
|
<div class="fc-settings-row mb-3">
|
|
<v-switch
|
|
:model-value="settings.backup_db_nightly_enabled"
|
|
color="accent" density="compact" hide-details
|
|
label="Nightly"
|
|
@update:model-value="onSettingChange('backup_db_nightly_enabled', $event)"
|
|
/>
|
|
<v-text-field
|
|
:model-value="settings.backup_db_nightly_hour_utc"
|
|
label="Hour (UTC)" type="number" min="0" max="23"
|
|
density="compact" hide-details style="max-width: 110px;"
|
|
@update:model-value="onSettingChange('backup_db_nightly_hour_utc', Number($event))"
|
|
/>
|
|
<v-text-field
|
|
:model-value="settings.backup_db_keep_last_n"
|
|
label="Keep last" type="number" min="1" max="365"
|
|
density="compact" hide-details style="max-width: 110px;"
|
|
@update:model-value="onSettingChange('backup_db_keep_last_n', Number($event))"
|
|
/>
|
|
</div>
|
|
<p v-if="offsite" class="fc-muted text-caption mb-3">
|
|
Nightly sends the database, then any new images and attachments. Keep
|
|
last applies to this list and to any dumps still on this server.
|
|
</p>
|
|
<v-btn
|
|
color="accent" variant="flat" rounded="pill"
|
|
prepend-icon="mdi-database-arrow-up" class="mb-3"
|
|
:loading="dbTriggering"
|
|
@click="onTrigger('db')"
|
|
>Run DB backup now</v-btn>
|
|
|
|
<BackupRunsTable
|
|
:runs="store.dbRuns"
|
|
@restore="onRestore"
|
|
@delete="onDelete"
|
|
@tag="onTag"
|
|
/>
|
|
|
|
<v-divider class="my-4" />
|
|
|
|
<!-- Off-site: images and attachments as one file per fingerprint -->
|
|
<template v-if="offsite">
|
|
<h3 class="fc-section-title">Images and attachments</h3>
|
|
<p class="fc-muted text-body-2 mb-2">
|
|
Each file goes to the bucket's <code>media/</code> folder once, named by
|
|
its fingerprint, and the bucket checks it against the fingerprint taken
|
|
at import. Only files not sent yet go in each run. With Nightly on, they
|
|
follow the database dump every night.
|
|
</p>
|
|
<div v-if="coverage" class="mb-3">
|
|
<div class="text-body-2 mb-1" style="font-variant-numeric: tabular-nums;">
|
|
{{ coverage.files_sent.toLocaleString() }} of
|
|
{{ coverage.files_total.toLocaleString() }} files in the bucket
|
|
({{ humanBytes(coverage.bytes_sent) }} of {{ humanBytes(coverage.bytes_total) }})
|
|
</div>
|
|
<v-progress-linear
|
|
:model-value="coveragePct" color="accent" height="6" rounded
|
|
/>
|
|
</div>
|
|
<v-btn
|
|
color="accent" variant="flat" rounded="pill"
|
|
prepend-icon="mdi-cloud-upload-outline" class="mb-3"
|
|
:loading="mediaTriggering" :disabled="mediaRunning"
|
|
@click="onTrigger('media')"
|
|
>Send new files now</v-btn>
|
|
<span v-if="mediaRunning" class="text-caption fc-muted ml-2">
|
|
A run is going: {{ runningSent.toLocaleString() }} files sent so far.
|
|
</span>
|
|
<BackupRunsTable :runs="store.mediaRuns" />
|
|
</template>
|
|
|
|
<template v-else>
|
|
<!-- Images section -->
|
|
<h3 class="fc-section-title">Images</h3>
|
|
<div class="fc-settings-row mb-3">
|
|
<v-text-field
|
|
:model-value="settings.backup_images_keep_last_n"
|
|
label="Keep last" type="number" min="1" max="100"
|
|
density="compact" hide-details style="max-width: 110px;"
|
|
@update:model-value="onSettingChange('backup_images_keep_last_n', Number($event))"
|
|
/>
|
|
</div>
|
|
<v-btn
|
|
color="accent" variant="flat" rounded="pill"
|
|
prepend-icon="mdi-folder-zip" class="mb-3"
|
|
:loading="imagesTriggering"
|
|
@click="onTrigger('images')"
|
|
>Run images backup now</v-btn>
|
|
<span class="text-caption fc-muted ml-2">
|
|
ⓘ Hours for a 200GB+ library.
|
|
</span>
|
|
|
|
<BackupRunsTable
|
|
:runs="store.imagesRuns"
|
|
@restore="onRestore"
|
|
@delete="onDelete"
|
|
@tag="onTag"
|
|
/>
|
|
</template>
|
|
<BackupConfirmModal
|
|
v-model="confirmOpen"
|
|
:action="confirmAction"
|
|
:kind="confirmKind"
|
|
:run-id="confirmRunId"
|
|
:description="confirmDescription"
|
|
@confirm="onConfirmSubmit"
|
|
/>
|
|
</MaintenanceTile>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
|
|
import { useBackupStore } from '../../stores/backup.js'
|
|
import BackupConfirmModal from '../modal/DestructiveConfirmModal.vue'
|
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
|
import { humanBytes } from '../../utils/bytes.js'
|
|
import BackupRunsTable from './BackupRunsTable.vue'
|
|
|
|
const store = useBackupStore()
|
|
|
|
const settings = computed(() => store.settings || {
|
|
backup_db_nightly_enabled: false,
|
|
backup_db_nightly_hour_utc: 3,
|
|
backup_db_keep_last_n: 14,
|
|
backup_images_keep_last_n: 3,
|
|
})
|
|
|
|
// The off-site destination, once it is fully set up: database dumps go there.
|
|
const offsite = computed(() => (
|
|
store.destination?.configured ? store.destination : null
|
|
))
|
|
|
|
const coverage = computed(() => store.mediaCoverage)
|
|
const coveragePct = computed(() => {
|
|
const c = coverage.value
|
|
return c && c.files_total ? (100 * c.files_sent) / c.files_total : 0
|
|
})
|
|
const runningMedia = computed(() => store.mediaRuns.find((r) => r.status === 'running'))
|
|
const mediaRunning = computed(() => Boolean(runningMedia.value))
|
|
const runningSent = computed(() => runningMedia.value?.manifest?.sent || 0)
|
|
|
|
const mediaTriggering = ref(false)
|
|
const dbTriggering = ref(false)
|
|
const imagesTriggering = ref(false)
|
|
const confirmOpen = ref(false)
|
|
const confirmAction = ref('restore')
|
|
const confirmKind = ref('db')
|
|
const confirmRunId = ref(0)
|
|
const confirmDescription = ref('')
|
|
|
|
let pollId = null
|
|
let polls = 0
|
|
// Coverage counts every library file, so it refreshes every sixth poll
|
|
// (30 s) rather than with the run lists.
|
|
const COVERAGE_EVERY = 6
|
|
function pollOnce() {
|
|
if (document.hidden) return
|
|
store.loadRuns('db')
|
|
if (offsite.value) {
|
|
store.loadRuns('media')
|
|
if (polls++ % COVERAGE_EVERY === 0) store.loadMediaCoverage()
|
|
} else {
|
|
store.loadRuns('images')
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
store.loadSettings()
|
|
if (!store.destination) store.loadDestination().catch(() => {})
|
|
pollOnce()
|
|
pollId = setInterval(pollOnce, 5000)
|
|
})
|
|
onUnmounted(() => {
|
|
if (pollId) { clearInterval(pollId); pollId = null }
|
|
})
|
|
|
|
async function onTrigger(kind) {
|
|
const triggeringRef = { db: dbTriggering, images: imagesTriggering, media: mediaTriggering }[kind]
|
|
triggeringRef.value = true
|
|
try {
|
|
await store.triggerBackup(kind)
|
|
pollOnce()
|
|
} finally {
|
|
triggeringRef.value = false
|
|
}
|
|
}
|
|
|
|
async function onSettingChange(field, value) {
|
|
await store.patchSettings({ [field]: value })
|
|
}
|
|
|
|
function _openConfirm(action, run) {
|
|
confirmAction.value = action
|
|
confirmKind.value = run.kind
|
|
confirmRunId.value = run.id
|
|
confirmDescription.value = (
|
|
action === 'restore'
|
|
? `Source artifact: ${run.kind === 'db' ? run.sql_path : run.tar_path}`
|
|
: 'Removes the BackupRun row and unlinks the artifact files.'
|
|
)
|
|
confirmOpen.value = true
|
|
}
|
|
|
|
function onRestore(run) { _openConfirm('restore', run) }
|
|
function onDelete(run) { _openConfirm('delete', run) }
|
|
|
|
async function onTag(run) {
|
|
const next = prompt(
|
|
`Tag for backup #${run.id} (blank to clear):`,
|
|
run.tag || '',
|
|
)
|
|
if (next === null) return
|
|
await store.setTag(run.id, next.trim() || null)
|
|
pollOnce()
|
|
}
|
|
|
|
async function onConfirmSubmit(token) {
|
|
try {
|
|
if (confirmAction.value === 'restore') {
|
|
await store.restore(confirmRunId.value, token)
|
|
} else {
|
|
await store.deleteRun(confirmRunId.value, token)
|
|
}
|
|
pollOnce()
|
|
} catch {
|
|
// store.lastError already updated; surfaced in v-alert above.
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-backup-card { border-radius: 8px; }
|
|
.fc-section-title {
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
font-size: 16px; font-weight: 500;
|
|
color: rgb(var(--v-theme-on-surface));
|
|
margin-bottom: 8px;
|
|
}
|
|
.fc-settings-row {
|
|
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
|
|
}
|
|
</style>
|