feat: with an off-site destination set up, database backups stream to it
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 1s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 41s
CI and images / integration (push) Successful in 2m25s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m50s
CI and images / smoke-web (push) Successful in 49s
CI and images / promote (push) Successful in 2s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 1s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 41s
CI and images / integration (push) Successful in 2m25s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m50s
CI and images / smoke-web (push) Successful in 49s
CI and images / promote (push) Successful in 2s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
The nightly database backup (and "Run DB backup now") now sends pg_dump's output straight to the off-site bucket as a multipart upload, with no copy staged on this server. The object is named database/fabledcurator-<UTC time>.dump and appears only once pg_dump has exited cleanly and every part has landed; a failed dump aborts the upload and records pg_dump's last line. Without a destination, dumps are written to /images/_backups as before. - backup_s3: Upload (start / add part / complete / abort), with a busy or silent bucket rested and asked again (15 s, 60 s, Retry-After obeyed), and a Pacer that holds uploads to the new bandwidth limit. - Settings: "Upload limit (Mbit/s)" on the destination card, 200 by default, 0 for none (migration 0120). - pg_dump runs under nice (and ionice where installed), locally too. - Backups card says where dumps go; the runs table gains "Saved to" and shows a failed run's error. Off-site rows have no restore/tag/delete here: restoring from the bucket is a later step (#6880). Task #6878, milestone 586. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,18 @@
|
||||
title="Backups"
|
||||
blurb="Database + image backups, schedules and restore."
|
||||
>
|
||||
<p class="fc-muted text-body-2 mb-4">
|
||||
<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. The images backup below still writes a
|
||||
tar+zstd to <code>/images/_backups/</code>, by hand only.
|
||||
</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.
|
||||
from autoprune. Set up an off-site destination above to send
|
||||
database dumps off this server instead.
|
||||
</p>
|
||||
|
||||
<v-alert
|
||||
@@ -40,6 +47,9 @@
|
||||
@update:model-value="onSettingChange('backup_db_keep_last_n', Number($event))"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="offsite" class="fc-muted text-caption mb-3">
|
||||
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"
|
||||
@@ -110,6 +120,11 @@ const settings = computed(() => store.settings || {
|
||||
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 dbTriggering = ref(false)
|
||||
const imagesTriggering = ref(false)
|
||||
const confirmOpen = ref(false)
|
||||
@@ -127,6 +142,7 @@ function pollOnce() {
|
||||
|
||||
onMounted(() => {
|
||||
store.loadSettings()
|
||||
if (!store.destination) store.loadDestination().catch(() => {})
|
||||
pollOnce()
|
||||
pollId = setInterval(pollOnce, 5000)
|
||||
})
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
:hint="secretSet ? 'A saved secret is never shown again.' : ''" persistent-hint
|
||||
class="mb-3" :disabled="busy"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="form.upload_limit_mbps" label="Upload limit (Mbit/s)"
|
||||
type="number" min="0" density="compact" class="fc-dest-limit mb-3"
|
||||
hint="0 for no limit. A lower limit leaves more of the link free, and makes each upload take longer."
|
||||
persistent-hint :disabled="busy"
|
||||
/>
|
||||
|
||||
<div class="d-flex align-center flex-wrap mb-2" style="gap: 8px;">
|
||||
<v-btn
|
||||
@@ -77,6 +83,9 @@
|
||||
<p v-if="!configured && !testResult" class="fc-muted text-caption mb-0">
|
||||
No destination is set up, so nothing is sent off this server yet.
|
||||
</p>
|
||||
<p v-else-if="configured && !testResult" class="fc-muted text-caption mb-0">
|
||||
Database backups go to this bucket. Set when they run under Backups below.
|
||||
</p>
|
||||
|
||||
<v-dialog v-model="confirmRemove" max-width="440">
|
||||
<v-card>
|
||||
@@ -106,7 +115,11 @@ import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
const store = useBackupStore()
|
||||
|
||||
const FIELDS = ['endpoint', 'bucket', 'access_key', 'region']
|
||||
const form = reactive({ endpoint: '', bucket: '', access_key: '', region: 'us-east-1', secret_key: '' })
|
||||
const DEFAULT_LIMIT = 200
|
||||
const form = reactive({
|
||||
endpoint: '', bucket: '', access_key: '', region: 'us-east-1', secret_key: '',
|
||||
upload_limit_mbps: DEFAULT_LIMIT,
|
||||
})
|
||||
const saved = ref(null)
|
||||
const loadError = ref('')
|
||||
const saving = ref(false)
|
||||
@@ -122,11 +135,13 @@ const dirty = computed(() => (
|
||||
!saved.value
|
||||
|| form.secret_key.trim() !== ''
|
||||
|| FIELDS.some((f) => (form[f] || '').trim() !== (saved.value[f] || ''))
|
||||
|| form.upload_limit_mbps !== saved.value.upload_limit_mbps
|
||||
))
|
||||
|
||||
function apply(dest) {
|
||||
saved.value = dest
|
||||
for (const f of FIELDS) form[f] = dest[f] || (f === 'region' ? 'us-east-1' : '')
|
||||
form.upload_limit_mbps = dest.upload_limit_mbps ?? DEFAULT_LIMIT
|
||||
form.secret_key = ''
|
||||
}
|
||||
|
||||
@@ -134,6 +149,8 @@ function fields() {
|
||||
const out = {}
|
||||
for (const f of FIELDS) out[f] = form[f]
|
||||
if (form.secret_key.trim()) out.secret_key = form.secret_key
|
||||
const limit = Number(form.upload_limit_mbps)
|
||||
if (Number.isInteger(limit) && limit >= 0) out.upload_limit_mbps = limit
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -195,4 +212,5 @@ async function onRemove() {
|
||||
.fc-dest-row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.fc-dest-row > :first-child { flex: 1 1 200px; }
|
||||
.fc-dest-region { flex: 0 1 160px; }
|
||||
.fc-dest-limit { max-width: 260px; }
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Status</th>
|
||||
<th>Saved to</th>
|
||||
<th class="text-right">Duration</th>
|
||||
<th class="text-right">Size</th>
|
||||
<th>Tag</th>
|
||||
@@ -20,6 +21,15 @@
|
||||
{{ statusIcon(r.status) }}
|
||||
</v-icon>
|
||||
{{ r.status }}
|
||||
<div
|
||||
v-if="r.error" class="fc-run-error text-caption text-error" :title="r.error"
|
||||
>{{ r.error }}</div>
|
||||
</td>
|
||||
<td :title="r.offsite ? `${r.offsite.endpoint} · ${r.offsite.key}` : ''">
|
||||
<template v-if="r.offsite">
|
||||
<v-icon size="small" class="mr-1">mdi-cloud-check-outline</v-icon>{{ r.offsite.bucket }}
|
||||
</template>
|
||||
<span v-else class="fc-muted">This server</span>
|
||||
</td>
|
||||
<td class="text-right fc-tabular">{{ formatDuration(r) }}</td>
|
||||
<td class="text-right fc-tabular">{{ formatBytes(r.size_bytes) }}</td>
|
||||
@@ -30,7 +40,8 @@
|
||||
<span v-else class="fc-muted">—</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<KebabMenu :label="`Actions for backup ${r.id}`">
|
||||
<span v-if="r.offsite" class="fc-muted">—</span>
|
||||
<KebabMenu v-else :label="`Actions for backup ${r.id}`">
|
||||
<v-list-item
|
||||
:title="r.tag ? 'Untag' : 'Tag…'"
|
||||
@click="$emit('tag', r)"
|
||||
@@ -47,7 +58,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!runs.length">
|
||||
<td colspan="6" class="text-center fc-muted py-4">No backups yet.</td>
|
||||
<td colspan="7" class="text-center fc-muted py-4">No backups yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
@@ -97,4 +108,7 @@ function formatRelative(iso) {
|
||||
<style scoped>
|
||||
.fc-backup-table { background: transparent; }
|
||||
.fc-tabular { font-variant-numeric: tabular-nums; }
|
||||
.fc-run-error {
|
||||
max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user