feat: the Backups screen says whether backups are keeping up, and the System view warns when they aren't
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 18s
CI and images / frontend-build (push) Successful in 25s
CI and images / backend-lint-and-test (push) Successful in 45s
CI and images / integration (push) Successful in 2m31s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m54s
CI and images / smoke-web (push) Successful in 48s
CI and images / promote (push) Successful in 1s
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 2s
CI and images / extension-test (push) Successful in 18s
CI and images / frontend-build (push) Successful in 25s
CI and images / backend-lint-and-test (push) Successful in 45s
CI and images / integration (push) Successful in 2m31s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m54s
CI and images / smoke-web (push) Successful in 48s
CI and images / promote (push) Successful in 1s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
- A Backups row on Settings → System and the nav dot turn amber when the newest database dump is more than a day old or failed, or the weekly bucket check found files missing that no media run has sent since. No row when backups aren't set up. - The weekly bucket check is kept as a run (kind "reconcile") with its counts, trimmed to the last 12, and listed on the Backups card. - The Backups card leads with the same verdict and the newest dump's size, shows bytes beside the running media job's file count, and lets each run open its list of failed files with why. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,21 @@
|
||||
{{ store.lastError }}
|
||||
</v-alert>
|
||||
|
||||
<!-- Whether the backups are keeping up: the same verdict as the Backups
|
||||
row on the System tab and the dot beside the brand. -->
|
||||
<v-alert
|
||||
v-if="backupPart && backupPart.state !== 'ok'"
|
||||
type="warning" variant="tonal" density="compact" class="mb-3"
|
||||
>{{ backupPart.detail }}.</v-alert>
|
||||
<div
|
||||
v-else-if="backupPart" class="d-flex align-center text-body-2 mb-3"
|
||||
style="font-variant-numeric: tabular-nums;"
|
||||
>
|
||||
<v-icon size="small" color="success" class="mr-2">mdi-check-circle</v-icon>
|
||||
{{ backupPart.detail }}<template v-if="newestDump?.size_bytes">,
|
||||
{{ humanBytes(newestDump.size_bytes) }}</template>.
|
||||
</div>
|
||||
|
||||
<!-- Database section -->
|
||||
<h3 class="fc-section-title">Database</h3>
|
||||
<div class="fc-settings-row mb-3">
|
||||
@@ -93,8 +108,12 @@
|
||||
@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.
|
||||
A run is going: {{ runningSent.toLocaleString() }} files
|
||||
({{ humanBytes(runningMedia.size_bytes || 0) }}) sent so far.
|
||||
</span>
|
||||
<p class="fc-muted text-caption mb-2" style="font-variant-numeric: tabular-nums;">
|
||||
{{ lastCheckLine }}
|
||||
</p>
|
||||
<BackupRunsTable :runs="store.mediaRuns" />
|
||||
|
||||
<v-divider class="my-4" />
|
||||
@@ -223,13 +242,32 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useBackupStore } from '../../stores/backup.js'
|
||||
import { useSystemHealthStore } from '../../stores/systemHealth.js'
|
||||
import BackupConfirmModal from '../modal/DestructiveConfirmModal.vue'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import { humanBytes } from '../../utils/bytes.js'
|
||||
import { formatDateTime } from '../../utils/date.js'
|
||||
import { formatDateTime, formatRelative } from '../../utils/date.js'
|
||||
import BackupRunsTable from './BackupRunsTable.vue'
|
||||
|
||||
const store = useBackupStore()
|
||||
const healthStore = useSystemHealthStore()
|
||||
|
||||
// The server's verdict on the backups; absent when none are set up.
|
||||
const backupPart = computed(() => healthStore.parts.find((p) => p.key === 'backup'))
|
||||
const newestDump = computed(() => store.dbRuns.find((r) => r.status === 'ok'))
|
||||
|
||||
// The weekly check of the bucket against what was sent.
|
||||
const lastCheckLine = computed(() => {
|
||||
const r = store.reconcileRuns.find((x) => x.status !== 'running')
|
||||
if (!r) return 'The bucket is checked against what was sent once a week; no check has run yet.'
|
||||
const when = formatRelative(r.finished_at || r.started_at)
|
||||
if (r.status === 'error') return `The last bucket check (${when}) failed: ${r.error}`
|
||||
const n = (k) => (r.manifest?.[k] || 0).toLocaleString()
|
||||
const missing = r.manifest?.dropped
|
||||
? `${n('dropped')} missing, sent again by the next run`
|
||||
: 'none missing'
|
||||
return `Bucket checked ${when}: ${n('listed')} files there, ${missing}.`
|
||||
})
|
||||
|
||||
const settings = computed(() => store.settings || {
|
||||
backup_db_nightly_enabled: false,
|
||||
@@ -345,7 +383,10 @@ function pollOnce() {
|
||||
if (offsite.value) {
|
||||
store.loadRuns('media')
|
||||
store.loadRuns('restore')
|
||||
if (polls++ % COVERAGE_EVERY === 0) store.loadMediaCoverage()
|
||||
if (polls++ % COVERAGE_EVERY === 0) {
|
||||
store.loadMediaCoverage()
|
||||
store.loadRuns('reconcile')
|
||||
}
|
||||
} else {
|
||||
store.loadRuns('images')
|
||||
}
|
||||
@@ -353,6 +394,7 @@ function pollOnce() {
|
||||
|
||||
onMounted(() => {
|
||||
store.loadSettings()
|
||||
healthStore.refresh()
|
||||
if (!store.destination) store.loadDestination().catch(() => {})
|
||||
pollOnce()
|
||||
pollId = setInterval(pollOnce, 5000)
|
||||
@@ -367,6 +409,7 @@ async function onTrigger(kind) {
|
||||
try {
|
||||
await store.triggerBackup(kind)
|
||||
pollOnce()
|
||||
healthStore.refresh()
|
||||
} finally {
|
||||
triggeringRef.value = false
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in runs" :key="r.id">
|
||||
<template v-for="r in runs" :key="r.id">
|
||||
<tr>
|
||||
<td class="fc-tabular" :title="r.started_at">
|
||||
{{ formatRelative(r.started_at) }}
|
||||
</td>
|
||||
@@ -24,6 +25,12 @@
|
||||
<div
|
||||
v-if="r.error" class="fc-run-error text-caption text-error" :title="r.error"
|
||||
>{{ r.error }}</div>
|
||||
<v-btn
|
||||
v-if="failures(r).length"
|
||||
size="x-small" variant="text" color="error" class="px-1"
|
||||
:append-icon="open[r.id] ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
@click="open[r.id] = !open[r.id]"
|
||||
>{{ failedLabel(r) }}</v-btn>
|
||||
</td>
|
||||
<td :title="r.offsite ? `${r.offsite.endpoint} · ${r.offsite.key}` : ''">
|
||||
<template v-if="r.offsite">
|
||||
@@ -57,6 +64,22 @@
|
||||
</KebabMenu>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- The files a run couldn't send or fetch, each with what went wrong.
|
||||
The run keeps the first few; the count covers them all. -->
|
||||
<tr v-if="open[r.id]" class="fc-failures">
|
||||
<td colspan="7">
|
||||
<ul class="fc-failures__list text-caption">
|
||||
<li v-for="(f, i) in failures(r)" :key="i">
|
||||
<code :title="f.path">{{ f.path }}</code>
|
||||
<span class="text-error"> — {{ f.error }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="moreFailed(r)" class="text-caption fc-muted">
|
||||
and {{ moreFailed(r).toLocaleString() }} more.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="!runs.length">
|
||||
<td colspan="7" class="text-center fc-muted py-4">{{ emptyText }}</td>
|
||||
</tr>
|
||||
@@ -65,6 +88,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { formatRelative as fmtRelative } from '../../utils/date.js'
|
||||
import KebabMenu from '../common/KebabMenu.vue'
|
||||
|
||||
@@ -74,6 +99,23 @@ defineProps({
|
||||
})
|
||||
defineEmits(['restore', 'delete', 'tag'])
|
||||
|
||||
// Which runs have their failed-file list open.
|
||||
const open = reactive({})
|
||||
|
||||
function failures(r) {
|
||||
return r.manifest?.failures || []
|
||||
}
|
||||
function failedCount(r) {
|
||||
return r.manifest?.failed || failures(r).length
|
||||
}
|
||||
function moreFailed(r) {
|
||||
return Math.max(0, failedCount(r) - failures(r).length)
|
||||
}
|
||||
function failedLabel(r) {
|
||||
const n = failedCount(r)
|
||||
return `${n.toLocaleString()} file${n === 1 ? '' : 's'} failed`
|
||||
}
|
||||
|
||||
function statusIcon(s) {
|
||||
return {
|
||||
ok: 'mdi-check-circle', error: 'mdi-close-circle',
|
||||
@@ -111,6 +153,12 @@ function formatRelative(iso) {
|
||||
<style scoped>
|
||||
.fc-backup-table { background: transparent; }
|
||||
.fc-tabular { font-variant-numeric: tabular-nums; }
|
||||
.fc-failures td { border-top: 0 !important; }
|
||||
.fc-failures__list { margin: 0 0 6px; padding-left: 18px; }
|
||||
.fc-failures__list code {
|
||||
display: inline-block; max-width: 420px; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom;
|
||||
}
|
||||
.fc-run-error {
|
||||
max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -10,12 +10,18 @@ export const useBackupStore = defineStore('backup', () => {
|
||||
const imagesRuns = ref([])
|
||||
const mediaRuns = ref([])
|
||||
const restoreRuns = ref([])
|
||||
// The weekly check of the bucket against what was sent.
|
||||
const reconcileRuns = ref([])
|
||||
const settings = ref(null)
|
||||
const loading = ref({
|
||||
dbRuns: false, imagesRuns: false, mediaRuns: false, restoreRuns: false, settings: false,
|
||||
dbRuns: false, imagesRuns: false, mediaRuns: false, restoreRuns: false,
|
||||
reconcileRuns: false, settings: false,
|
||||
})
|
||||
const lastError = ref(null)
|
||||
const RUNS = { db: dbRuns, images: imagesRuns, media: mediaRuns, restore: restoreRuns }
|
||||
const RUNS = {
|
||||
db: dbRuns, images: imagesRuns, media: mediaRuns, restore: restoreRuns,
|
||||
reconcile: reconcileRuns,
|
||||
}
|
||||
|
||||
async function loadRuns(kind) {
|
||||
const targetRef = RUNS[kind]
|
||||
@@ -170,7 +176,7 @@ export const useBackupStore = defineStore('backup', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
dbRuns, imagesRuns, mediaRuns, restoreRuns, mediaCoverage, loadMediaCoverage,
|
||||
dbRuns, imagesRuns, mediaRuns, restoreRuns, reconcileRuns, mediaCoverage, loadMediaCoverage,
|
||||
listOffsiteDumps, restoreFromOffsite, fetchMissingFiles,
|
||||
settings, loading, lastError,
|
||||
loadRuns, triggerBackup, restore, deleteRun, setTag,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import BackupRunsTable from '../../src/components/settings/BackupRunsTable.vue'
|
||||
import { mountComponent } from '../support/mountComponent.js'
|
||||
|
||||
const MEDIA_RUN = {
|
||||
id: 7, kind: 'media', status: 'ok', started_at: new Date().toISOString(),
|
||||
duration_seconds: 90, size_bytes: 2048,
|
||||
error: "3 file(s) weren't sent; the next run tries them again.",
|
||||
offsite: { bucket: 'curator', endpoint: 'http://archives.example', key: 'media/' },
|
||||
manifest: {
|
||||
failed: 3,
|
||||
failures: [
|
||||
{ path: '/images/a/one.jpg', error: 'HTTP 503: busy.' },
|
||||
{ path: '/images/b/two.zip', error: 'OSError: gone' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
describe('BackupRunsTable', () => {
|
||||
it('offers a run\'s failed files, counted in full', () => {
|
||||
const w = mountComponent(BackupRunsTable, { props: { runs: [MEDIA_RUN] } })
|
||||
expect(w.text()).toContain('3 files failed')
|
||||
expect(w.text()).not.toContain('/images/a/one.jpg')
|
||||
})
|
||||
|
||||
it('lists each failed file with why once opened, and says how many more', async () => {
|
||||
const w = mountComponent(BackupRunsTable, { props: { runs: [MEDIA_RUN] } })
|
||||
await w.find('v-btn').trigger('click')
|
||||
const t = w.text()
|
||||
expect(t).toContain('/images/a/one.jpg')
|
||||
expect(t).toContain('HTTP 503: busy.')
|
||||
expect(t).toContain('/images/b/two.zip')
|
||||
expect(t).toContain('and 1 more.')
|
||||
})
|
||||
|
||||
it('shows no failure button on a clean run', () => {
|
||||
const clean = { ...MEDIA_RUN, id: 8, error: null, manifest: { failed: 0, failures: [] } }
|
||||
const w = mountComponent(BackupRunsTable, { props: { runs: [clean] } })
|
||||
expect(w.text()).not.toContain('failed')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user