Add opt-in backup overlay with third-container restic store

Backups are per-site and not part of the base stack, so they ship as a separate compose overlay with an isolated restic REST server the app can only reach through the backup utility (no shell or db creds). The sidecar image trusts the corporate proxy root CA at build time so its Oracle yum, github, and PyPI fetches succeed behind TLS interception; shell scripts are pinned to LF via .gitattributes so the container entrypoints do not die on a CRLF shebang.
This commit is contained in:
benjamin.baker
2026-09-03 20:06:45 -04:00
parent d5d823fd8b
commit 19071ec6c6
14 changed files with 1592 additions and 1 deletions

View File

@@ -0,0 +1,587 @@
<template>
<div class="backups-page">
<div class="page-header">
<h2>Backups</h2>
</div>
<!-- Confidence banner: the single most reassuring thing an admin wants to
see - is there a recent, verified backup? Everything else is secondary. -->
<div class="confidence-banner" :class="bannerClass">
<span class="banner-icon">
<component :is="bannerIcon" :size="22" />
</span>
<div class="banner-text">
<strong>{{ bannerHeadline }}</strong>
<span class="banner-sub">{{ bannerSub }}</span>
</div>
<button class="btn-primary" :disabled="isBusy" @click="runBackup">
{{ isBusy ? 'Working...' : 'Back up now' }}
</button>
</div>
<!-- Light explanation, details tucked away. Leads the UI-simplification
charge: one plain sentence up top, the depth behind a disclosure. -->
<details class="explain">
<summary>What is backed up, and how restore works</summary>
<div class="explain-body">
<p>
Each backup is one recoverable set: the full database (users,
settings, and every asset), the uploaded files, and a manifest that
records the exact code version so a restore can never land on the
wrong schema.
</p>
<p>
Backups keep <strong>any day in the last 5</strong>, plus weekly and
monthly copies. Restoring your <em>data</em> is separate from rolling
back <em>code</em>: code is handled in git. If a restore needs a newer
code version than you are running, this screen tells you before it
lets you proceed.
</p>
</div>
</details>
<!-- Destination configuration: where sets ship and the credentials to get
there. SMB and AWS are wired but not yet armed (banner says so)
because their client libraries wait on the security review. -->
<details class="explain destination" :open="settingsOpen">
<summary @click.prevent="settingsOpen = !settingsOpen">
Backup destination
<span class="dest-chip">{{ destinationLabel(settings.destinationkind) }}</span>
</summary>
<div class="explain-body">
<div v-if="settingsLoading" class="muted">Loading destination...</div>
<template v-else>
<label class="field">
<span class="field-label">Ship backups to</span>
<select v-model="settings.destinationkind" class="input">
<option
v-for="kind in settings.destinationkinds"
:key="kind"
:value="kind"
>
{{ destinationLabel(kind) }}
</option>
</select>
</label>
<p v-if="settings.destinationkind === 'local'" class="muted small">
Sets stay on the mounted backups volume. No credentials needed.
</p>
<div
v-else
class="not-armed"
>
<component :is="ShieldAlert" :size="16" />
<span>
This destination is configured but not yet armed. Its client
library is held back until the security review clears it against
external exploit; until then sets keep writing locally.
</span>
</div>
<template v-if="settings.destinationkind === 'smb'">
<label class="field">
<span class="field-label">Server host</span>
<input v-model="settings.fields.backup_smb_host" class="input" />
</label>
<label class="field">
<span class="field-label">Share</span>
<input v-model="settings.fields.backup_smb_share" class="input" />
</label>
<label class="field">
<span class="field-label">Path within share</span>
<input v-model="settings.fields.backup_smb_path" class="input" />
</label>
<label class="field">
<span class="field-label">Username</span>
<input v-model="settings.fields.backup_smb_username" class="input" />
</label>
<label class="field">
<span class="field-label">Password</span>
<input
v-model="settings.fields.backup_smb_password"
type="password"
class="input"
autocomplete="new-password"
/>
</label>
</template>
<template v-else-if="settings.destinationkind === 'aws'">
<label class="field">
<span class="field-label">Region</span>
<input v-model="settings.fields.backup_aws_region" class="input" />
</label>
<label class="field">
<span class="field-label">Bucket</span>
<input v-model="settings.fields.backup_aws_bucket" class="input" />
</label>
<label class="field">
<span class="field-label">Key prefix</span>
<input v-model="settings.fields.backup_aws_prefix" class="input" />
</label>
<label class="field">
<span class="field-label">Access key id</span>
<input v-model="settings.fields.backup_aws_accesskeyid" class="input" />
</label>
<label class="field">
<span class="field-label">Secret access key</span>
<input
v-model="settings.fields.backup_aws_secretaccesskey"
type="password"
class="input"
autocomplete="new-password"
/>
</label>
</template>
<div class="modal-actions">
<button class="btn-primary" :disabled="savingSettings" @click="saveSettings">
{{ savingSettings ? 'Saving...' : 'Save destination' }}
</button>
</div>
</template>
</div>
</details>
<div v-if="loading" class="muted">Loading backups...</div>
<div v-else-if="!sets.length" class="empty-state">
<p>No backups yet. Run your first one above - it is safe to do anytime.</p>
</div>
<table v-else class="backups-table">
<thead>
<tr>
<th>Taken</th>
<th>Size</th>
<th>Status</th>
<th>Restore</th>
</tr>
</thead>
<tbody>
<tr v-for="set in sets" :key="set.snapshotid">
<td>
<span class="taken">{{ formatTaken(set.takenat) }}</span>
<span class="setid">{{ set.snapshotid }}</span>
</td>
<td>{{ formatSize(set.sizebytes) }}</td>
<td>
<span class="chip" :class="statusClass(set)">
{{ statusLabel(set) }}
</span>
</td>
<td>
<button
class="btn-restore"
:class="{ blocked: !set.iscompatible }"
:disabled="isBusy"
@click="openRestore(set)"
>
Restore...
</button>
</td>
</tr>
</tbody>
</table>
<!-- Guarded restore modal: the one genuinely risky action, so it spends the
UI budget on the compatibility verdict before allowing the click. -->
<div v-if="restoreTarget" class="modal-backdrop" @click.self="closeRestore">
<div class="modal">
<h3>Restore this backup?</h3>
<p class="modal-set">{{ restoreTarget.snapshotid }}</p>
<div class="compat" :class="restoreTarget.iscompatible ? 'ok' : 'warn'">
<component :is="restoreTarget.iscompatible ? ShieldCheck : AlertTriangle" :size="18" />
<span v-if="restoreTarget.iscompatible">
Compatible with the code you are running. Safe to restore.
</span>
<span v-else>
This backup was taken on a newer schema than the running code.
Upgrade the code first, then retry - restore is blocked until then.
</span>
</div>
<details class="explain small">
<summary>What restoring does</summary>
<div class="explain-body">
<p>
Restoring replaces the current database and uploaded files with the
contents of this backup. Code is not changed. This cannot be undone,
so it is gated on the compatibility check above.
</p>
</div>
</details>
<div class="modal-actions">
<button class="btn-ghost" @click="closeRestore">Cancel</button>
<button
class="btn-danger"
:disabled="!restoreTarget.iscompatible || isBusy"
@click="confirmRestore"
>
Restore
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ShieldCheck, AlertTriangle, ShieldAlert, Clock } from 'lucide-vue-next'
import api from '../../api'
import { useToast } from '../../composables/toast'
const toast = useToast()
const sets = ref([])
const loading = ref(true)
const isBusy = ref(false)
const lastGoodAt = ref(null)
const restoreTarget = ref(null)
const settingsOpen = ref(false)
const settingsLoading = ref(true)
const savingSettings = ref(false)
const settings = ref({
destinationkind: 'local',
destinationkinds: ['local'],
fields: {},
})
const DESTINATION_LABELS = {
local: 'Local volume',
smb: 'SMB / CIFS share',
aws: 'AWS S3 bucket',
}
function destinationLabel(kind) {
return DESTINATION_LABELS[kind] || kind
}
async function loadSettings() {
settingsLoading.value = true
try {
const response = await api.get('/shopdbbackups/settings')
const data = response.data.data || {}
settings.value = {
destinationkind: data.destinationkind || 'local',
destinationkinds: data.destinationkinds || ['local'],
fields: data.fields || {},
}
} catch (error) {
toast.error('Could not load backup destination.')
} finally {
settingsLoading.value = false
}
}
async function saveSettings() {
savingSettings.value = true
try {
const response = await api.put('/shopdbbackups/settings', {
destinationkind: settings.value.destinationkind,
fields: settings.value.fields,
})
const data = response.data.data || {}
settings.value = {
destinationkind: data.destinationkind || 'local',
destinationkinds: data.destinationkinds || ['local'],
fields: data.fields || {},
}
toast.success('Backup destination saved.')
} catch (error) {
const message = error.response?.data?.data?.error?.message
toast.error(message || 'Could not save the backup destination.')
} finally {
savingSettings.value = false
}
}
async function load() {
loading.value = true
try {
const response = await api.get('/shopdbbackups')
sets.value = response.data.data || []
lastGoodAt.value = response.data.meta?.lastgoodat || null
} catch (error) {
toast.error('Could not load backups.')
} finally {
loading.value = false
}
}
async function runBackup() {
isBusy.value = true
try {
const response = await api.post('/shopdbbackups')
toast.success(response.data.message || 'Backup queued.')
} catch (error) {
toast.error('Could not queue a backup.')
} finally {
isBusy.value = false
load()
}
}
function openRestore(set) {
restoreTarget.value = set
}
function closeRestore() {
restoreTarget.value = null
}
async function confirmRestore() {
if (!restoreTarget.value?.iscompatible) return
isBusy.value = true
try {
await api.post(`/shopdbbackups/${restoreTarget.value.snapshotid}/restore`)
toast.success('Restore queued and validated against the live schema.')
closeRestore()
} catch (error) {
const message = error.response?.data?.data?.error?.message
toast.error(message || 'Could not queue the restore.')
} finally {
isBusy.value = false
load()
}
}
// -- Confidence banner state ------------------------------------------------
const STALE_HOURS = 48
const hoursSinceLastGood = computed(() => {
if (!lastGoodAt.value) return null
const then = new Date(lastGoodAt.value.replace(/Z$/, 'Z')).getTime()
return (Date.now() - then) / 36e5
})
const bannerState = computed(() => {
if (!lastGoodAt.value) return 'none'
if (hoursSinceLastGood.value > STALE_HOURS) return 'stale'
return 'good'
})
const bannerClass = computed(() => `banner-${bannerState.value}`)
const bannerIcon = computed(() => ({
good: ShieldCheck, stale: Clock, none: ShieldAlert,
}[bannerState.value]))
const bannerHeadline = computed(() => ({
good: 'Your data is protected',
stale: 'Backup is overdue',
none: 'No verified backup yet',
}[bannerState.value]))
const bannerSub = computed(() => {
if (bannerState.value === 'none') return 'Run one now - it only takes a moment.'
return `Last verified backup ${formatTaken(lastGoodAt.value)}.`
})
// -- Formatting -------------------------------------------------------------
function formatTaken(value) {
if (!value) return 'unknown'
const date = new Date(value.replace(/Z$/, 'Z'))
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
function formatSize(bytes) {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`
}
function statusClass(set) {
if (set.ischeckpassed) return 'chip-verified'
if (!set.hasmanifest) return 'chip-unverified'
return 'chip-pending'
}
function statusLabel(set) {
if (set.ischeckpassed) return 'Verified'
if (!set.hasmanifest) return 'Unverified'
return 'Not checked'
}
onMounted(() => {
load()
loadSettings()
})
</script>
<style scoped>
.page-header {
margin-bottom: 1rem;
}
.confidence-banner {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.1rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-card);
margin-bottom: 1rem;
}
.confidence-banner .banner-text {
display: flex;
flex-direction: column;
flex: 1;
}
.banner-sub {
color: var(--text-light);
font-size: 0.85rem;
}
.banner-good { border-color: #2f855a; }
.banner-good .banner-icon { color: #38a169; }
.banner-stale { border-color: #b7791f; }
.banner-stale .banner-icon { color: #d69e2e; }
.banner-none { border-color: #c53030; }
.banner-none .banner-icon { color: #e53e3e; }
.explain {
margin-bottom: 1.25rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
}
.explain.small { margin: 0.75rem 0; }
.explain summary {
cursor: pointer;
padding: 0.6rem 0.9rem;
font-size: 0.86rem;
color: var(--text-light);
user-select: none;
}
.explain-body {
padding: 0 0.9rem 0.8rem 0.9rem;
}
.explain-body p {
margin: 0 0 0.6rem 0;
color: var(--text-light);
font-size: 0.86rem;
line-height: 1.5;
}
.backups-table {
width: 100%;
border-collapse: collapse;
}
.backups-table th,
.backups-table td {
text-align: left;
padding: 0.6rem 0.7rem;
border-bottom: 1px solid var(--border);
font-size: 0.88rem;
}
.backups-table th {
color: var(--text-light);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.taken { display: block; color: var(--text); }
.setid { display: block; color: var(--text-light); font-size: 0.74rem; }
.chip {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.74rem;
font-weight: 600;
}
.chip-verified { background: rgba(56, 161, 105, 0.18); color: #48bb78; }
.chip-pending { background: rgba(214, 158, 46, 0.18); color: #ecc94b; }
.chip-unverified { background: rgba(229, 62, 62, 0.18); color: #fc8181; }
.btn-primary {
background: var(--primary);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.5rem 0.9rem;
cursor: pointer;
font-weight: 600;
}
.btn-primary:disabled { opacity: 0.6; cursor: default; }
.btn-restore {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.35rem 0.7rem;
cursor: pointer;
}
.btn-restore.blocked { opacity: 0.55; }
.empty-state, .muted {
color: var(--text-light);
padding: 1rem 0;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal {
width: min(460px, 92vw);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.2rem;
}
.modal h3 { margin: 0 0 0.3rem 0; }
.modal-set { color: var(--text-light); font-size: 0.8rem; margin: 0 0 0.9rem 0; }
.compat {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.7rem 0.8rem;
border-radius: 8px;
font-size: 0.85rem;
line-height: 1.4;
}
.compat.ok { background: rgba(56, 161, 105, 0.15); color: #9ae6b4; }
.compat.warn { background: rgba(214, 158, 46, 0.15); color: #f6e05e; }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1rem;
}
.btn-ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.5rem 0.9rem;
cursor: pointer;
}
.btn-danger {
background: #c53030;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.5rem 0.9rem;
cursor: pointer;
font-weight: 600;
}
.btn-danger:disabled { opacity: 0.5; cursor: default; }
</style>

View File

@@ -4,7 +4,7 @@
// Order: site identity + the reference-data catalogs users touch daily come
// first, then the platform/system groups (integrations, communication, search,
// plugins, access) cluster together at the end.
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload } from 'lucide-vue-next'
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload, DatabaseBackup } from 'lucide-vue-next'
export const settingsGroups = [
{
@@ -113,6 +113,7 @@ export const settingsGroups = [
title: 'System',
cards: [
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
{ to: '/settings/shopdbbackups', icon: DatabaseBackup, title: 'Site Backups', description: 'Scheduled database and file backups with guided restore; keeps any day in the last 5' },
],
},
{