fc3h(ui): BackupCard.vue + BackupRunsTable.vue — combined card with DB + Images sub-sections
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="fc-backup-card">
|
||||||
|
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||||||
|
<v-icon icon="mdi-database-export" size="small" />
|
||||||
|
<span>Backups</span>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p 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.
|
||||||
|
</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>
|
||||||
|
<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" />
|
||||||
|
|
||||||
|
<!-- 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"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<BackupConfirmModal
|
||||||
|
v-model="confirmOpen"
|
||||||
|
:action="confirmAction"
|
||||||
|
:kind="confirmKind"
|
||||||
|
:run-id="confirmRunId"
|
||||||
|
:description="confirmDescription"
|
||||||
|
@confirm="onConfirmSubmit"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useBackupStore } from '../../stores/backup.js'
|
||||||
|
import BackupConfirmModal from './BackupConfirmModal.vue'
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
function pollOnce() {
|
||||||
|
if (document.hidden) return
|
||||||
|
store.loadRuns('db')
|
||||||
|
store.loadRuns('images')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.loadSettings()
|
||||||
|
pollOnce()
|
||||||
|
pollId = setInterval(pollOnce, 5000)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollId) { clearInterval(pollId); pollId = null }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onTrigger(kind) {
|
||||||
|
const triggeringRef = kind === 'db' ? dbTriggering : imagesTriggering
|
||||||
|
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-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
.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>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<v-table density="compact" class="fc-backup-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-right">Duration</th>
|
||||||
|
<th class="text-right">Size</th>
|
||||||
|
<th>Tag</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in runs" :key="r.id">
|
||||||
|
<td class="fc-tabular" :title="r.started_at">
|
||||||
|
{{ formatRelative(r.started_at) }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<v-icon size="small" :color="statusColor(r.status)">
|
||||||
|
{{ statusIcon(r.status) }}
|
||||||
|
</v-icon>
|
||||||
|
{{ r.status }}
|
||||||
|
</td>
|
||||||
|
<td class="text-right fc-tabular">{{ formatDuration(r) }}</td>
|
||||||
|
<td class="text-right fc-tabular">{{ formatBytes(r.size_bytes) }}</td>
|
||||||
|
<td>
|
||||||
|
<v-chip
|
||||||
|
v-if="r.tag" size="x-small" color="accent" variant="tonal"
|
||||||
|
>{{ r.tag }}</v-chip>
|
||||||
|
<span v-else class="fc-muted">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<v-menu>
|
||||||
|
<template #activator="{ props: act }">
|
||||||
|
<v-btn icon="mdi-dots-vertical" size="x-small" variant="text" v-bind="act" />
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item
|
||||||
|
:title="r.tag ? 'Untag' : 'Tag…'"
|
||||||
|
@click="$emit('tag', r)"
|
||||||
|
/>
|
||||||
|
<v-list-item
|
||||||
|
title="Restore…" :disabled="r.status !== 'ok'"
|
||||||
|
@click="$emit('restore', r)"
|
||||||
|
/>
|
||||||
|
<v-list-item
|
||||||
|
title="Delete…"
|
||||||
|
@click="$emit('delete', r)"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!runs.length">
|
||||||
|
<td colspan="6" class="text-center fc-muted py-4">No backups yet.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({ runs: { type: Array, default: () => [] } })
|
||||||
|
defineEmits(['restore', 'delete', 'tag'])
|
||||||
|
|
||||||
|
function statusIcon(s) {
|
||||||
|
return {
|
||||||
|
ok: 'mdi-check-circle', error: 'mdi-close-circle',
|
||||||
|
running: 'mdi-timer-sand', restoring: 'mdi-restore-clock',
|
||||||
|
restored: 'mdi-restore', pending: 'mdi-clock-outline',
|
||||||
|
}[s] || 'mdi-help-circle'
|
||||||
|
}
|
||||||
|
function statusColor(s) {
|
||||||
|
return {
|
||||||
|
ok: 'success', error: 'error',
|
||||||
|
running: 'accent', restoring: 'info',
|
||||||
|
restored: 'info', pending: 'on-surface-variant',
|
||||||
|
}[s] || 'on-surface-variant'
|
||||||
|
}
|
||||||
|
function formatDuration(r) {
|
||||||
|
if (r.duration_seconds == null) return '—'
|
||||||
|
const s = r.duration_seconds
|
||||||
|
if (s < 60) return `${s}s`
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||||
|
const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60)
|
||||||
|
return `${h}h ${m}m`
|
||||||
|
}
|
||||||
|
function formatBytes(b) {
|
||||||
|
if (b == null) return '—'
|
||||||
|
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||||
|
let i = 0; let v = b
|
||||||
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||||
|
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
||||||
|
}
|
||||||
|
function formatRelative(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const then = new Date(iso).getTime()
|
||||||
|
const diff = Math.max(0, (Date.now() - then) / 1000)
|
||||||
|
if (diff < 60) return `${Math.floor(diff)}s ago`
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-backup-table { background: transparent; }
|
||||||
|
.fc-tabular { font-variant-numeric: tabular-nums; }
|
||||||
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user