WP-30/31/32: backup targets, strategies and execution
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

SMB (smbclient) and FTP/FTPS (curl with netrc) targets with encrypted
credentials and connection test; strategies with cron schedule, retention,
optional openssl AES-256 encryption; sources: PVC hostpath tar, pg_dumpall
in the Postgres pod, namespace manifests, host directory. Backup job
collects, encrypts, uploads, verifies size, records sha256 and applies
retention on the target; scheduler starts due strategies. Backups page
with target/strategy forms, run now and history. Restore guide in docs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 23:15:38 +02:00
parent 39af18b336
commit 6134a47ff2
31 changed files with 2718 additions and 87 deletions

View File

@ -60,4 +60,5 @@ export const api = {
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
}

View File

@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { BackupSource, BackupStrategyPayload, BackupStrategyView } from '../api/types'
const props = defineProps<{
targets: { id: string; name: string }[]
current?: BackupStrategyView | null
busy?: boolean
}>()
const emit = defineEmits<{ submit: [payload: BackupStrategyPayload]; cancel: [] }>()
type SourceType = BackupSource['type']
const cur = props.current
const name = ref(cur?.name ?? '')
const sourceType = ref<SourceType>(cur?.source.type ?? 'volume_claim')
const namespace = ref(cur && 'namespace' in cur.source ? cur.source.namespace : 'gitea')
const pvc = ref(cur?.source.type === 'volume_claim' ? cur.source.pvc : '')
const pod = ref(cur?.source.type === 'postgres_dump' ? cur.source.pod : '')
const path = ref(cur?.source.type === 'host_path' ? cur.source.path : '')
const schedule = ref(cur?.schedule ?? '0 0 2 * * *')
const targetId = ref(cur?.target_id ?? props.targets[0]?.id ?? '')
const retention = ref<string>(cur?.retention.toString() ?? '7')
const passphrase = ref('')
const enabled = ref(cur?.enabled ?? true)
function source(): BackupSource {
switch (sourceType.value) {
case 'volume_claim':
return { type: 'volume_claim', namespace: namespace.value, pvc: pvc.value }
case 'postgres_dump':
return { type: 'postgres_dump', namespace: namespace.value, pod: pod.value }
case 'kubernetes_manifests':
return { type: 'kubernetes_manifests', namespace: namespace.value }
case 'host_path':
return { type: 'host_path', path: path.value }
}
}
function submit() {
// create: empty = no encryption; edit: empty keeps the stored passphrase
const pass = passphrase.value === '' ? (cur?.encrypted ? '' : null) : passphrase.value
emit('submit', {
name: name.value,
source: source(),
schedule: schedule.value,
target_id: targetId.value,
retention: Number(retention.value),
passphrase: pass,
enabled: enabled.value,
})
}
const input = 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2'
</script>
<template>
<form class="grid gap-4" @submit.prevent="submit">
<div>
<label for="s-name" class="block text-sm font-medium">Strategy name</label>
<input id="s-name" v-model="name" name="name" required :class="input" />
</div>
<div>
<label for="s-type" class="block text-sm font-medium">Source type</label>
<select id="s-type" v-model="sourceType" name="source_type" :class="input">
<option value="volume_claim">Persistent volume (e.g. Gitea data)</option>
<option value="postgres_dump">PostgreSQL dump (pg_dumpall in pod)</option>
<option value="kubernetes_manifests">Kubernetes manifests of a namespace</option>
<option value="host_path">Directory on the host</option>
</select>
</div>
<div v-if="sourceType !== 'host_path'" class="grid grid-cols-2 gap-4">
<div>
<label for="s-ns" class="block text-sm font-medium">Namespace</label>
<input id="s-ns" v-model="namespace" name="namespace" required :class="input" />
</div>
<div v-if="sourceType === 'volume_claim'">
<label for="s-pvc" class="block text-sm font-medium">PVC</label>
<input
id="s-pvc"
v-model="pvc"
name="pvc"
required
placeholder="gitea-shared-storage"
:class="input"
/>
</div>
<div v-if="sourceType === 'postgres_dump'">
<label for="s-pod" class="block text-sm font-medium">Pod</label>
<input
id="s-pod"
v-model="pod"
name="pod"
required
placeholder="gitea-postgresql-0"
:class="input"
/>
</div>
</div>
<div v-else>
<label for="s-path" class="block text-sm font-medium">Host path</label>
<input
id="s-path"
v-model="path"
name="path"
required
placeholder="/opt/monitoring/data"
:class="input"
/>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label for="s-cron" class="block text-sm font-medium">Schedule</label>
<input
id="s-cron"
v-model="schedule"
name="schedule"
required
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono"
/>
<p class="mt-1 text-xs text-gray-500">sec min hour day month weekday</p>
</div>
<div>
<label for="s-target" class="block text-sm font-medium">Target</label>
<select id="s-target" v-model="targetId" name="target_id" required :class="input">
<option v-for="t in targets" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
</div>
<div>
<label for="s-keep" class="block text-sm font-medium">Keep last</label>
<input
id="s-keep"
v-model="retention"
name="retention"
type="number"
min="1"
max="365"
required
:class="input"
/>
</div>
</div>
<div>
<label for="s-pass" class="block text-sm font-medium">Encryption passphrase (optional)</label>
<input
id="s-pass"
v-model="passphrase"
name="passphrase"
type="password"
autocomplete="new-password"
minlength="12"
:class="input"
/>
<p class="mt-1 text-xs text-gray-500">
<template v-if="cur?.encrypted"
>Archives are encrypted, leave empty to keep the passphrase.</template
>
<template v-else
>AES-256 via openssl; restore with
<code>openssl enc -d -aes-256-cbc -pbkdf2</code>.</template
>
</p>
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="enabled" name="enabled" type="checkbox" />
Enabled (runs on schedule)
</label>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
@click="emit('cancel')"
>
Cancel
</button>
<button
type="submit"
:disabled="busy"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
Save strategy
</button>
</div>
</form>
</template>

View File

@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { BackupTargetPayload, BackupTargetView, StorageKind } from '../api/types'
const props = defineProps<{ current?: BackupTargetView | null; busy?: boolean }>()
const emit = defineEmits<{ submit: [payload: BackupTargetPayload]; cancel: [] }>()
const name = ref(props.current?.name ?? '')
const kind = ref<StorageKind>(props.current?.kind ?? 'smb')
const host = ref(props.current?.host ?? '')
const port = ref<string>(props.current?.port?.toString() ?? '')
const share = ref(props.current?.share ?? '')
const path = ref(props.current?.path ?? '')
const username = ref(props.current?.username ?? '')
const password = ref('')
const tls = ref(props.current?.tls ?? false)
function submit() {
emit('submit', {
name: name.value,
kind: kind.value,
host: host.value,
port: port.value ? Number(port.value) : null,
share: kind.value === 'smb' ? share.value : '',
path: path.value,
username: username.value,
password: password.value,
tls: kind.value === 'ftp' ? tls.value : false,
})
}
const input = 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2'
</script>
<template>
<form class="grid gap-4" @submit.prevent="submit">
<div>
<label for="t-name" class="block text-sm font-medium">Name</label>
<input id="t-name" v-model="name" name="name" required :class="input" />
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label for="t-kind" class="block text-sm font-medium">Type</label>
<select id="t-kind" v-model="kind" name="kind" :class="input">
<option value="smb">SMB / CIFS</option>
<option value="ftp">FTP / FTPS</option>
</select>
</div>
<div class="col-span-2">
<label for="t-host" class="block text-sm font-medium">Host</label>
<input id="t-host" v-model="host" name="host" required :class="input" />
</div>
</div>
<div v-if="kind === 'smb'">
<label for="t-share" class="block text-sm font-medium">Share</label>
<input id="t-share" v-model="share" name="share" required :class="input" />
</div>
<div v-else class="grid grid-cols-2 gap-4">
<div>
<label for="t-port" class="block text-sm font-medium">Port</label>
<input
id="t-port"
v-model="port"
name="port"
type="number"
min="1"
max="65535"
placeholder="21"
:class="input"
/>
</div>
<label class="flex items-end gap-2 pb-2 text-sm">
<input v-model="tls" name="tls" type="checkbox" />
Use TLS (FTPS)
</label>
</div>
<div>
<label for="t-path" class="block text-sm font-medium">Directory</label>
<input
id="t-path"
v-model="path"
name="path"
placeholder="backups/softvisor"
:class="input"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="t-user" class="block text-sm font-medium">Username</label>
<input id="t-user" v-model="username" name="username" :class="input" />
</div>
<div>
<label for="t-pass" class="block text-sm font-medium">Password</label>
<input
id="t-pass"
v-model="password"
name="password"
type="password"
autocomplete="new-password"
:class="input"
/>
<p v-if="current?.password_set" class="mt-1 text-xs text-gray-500">
A password is stored, leave empty to keep it.
</p>
</div>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
@click="emit('cancel')"
>
Cancel
</button>
<button
type="submit"
:disabled="busy"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
Save target
</button>
</div>
</form>
</template>

View File

@ -0,0 +1,365 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { api, ApiError } from '../api/client'
import type {
BackupRecord,
BackupStrategyPayload,
BackupTargetPayload,
BackupTargetView,
JobRun,
StrategyStatus,
} from '../api/types'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
import TargetForm from '../components/TargetForm.vue'
import StrategyForm from '../components/StrategyForm.vue'
const auth = useAuthStore()
const toast = useToastStore()
const targets = ref<BackupTargetView[]>([])
const strategies = ref<StrategyStatus[]>([])
const dialog = ref<
| { kind: 'target'; current: BackupTargetView | null }
| { kind: 'strategy'; current: StrategyStatus | null }
| { kind: 'history'; strategy: StrategyStatus; records: BackupRecord[] }
| null
>(null)
const busy = ref(false)
const running = ref<Record<string, JobRun>>({})
let timer: ReturnType<typeof setInterval> | undefined
const fail = (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Request failed')
async function load() {
;[targets.value, strategies.value] = await Promise.all([
api.get<BackupTargetView[]>('/api/backups/targets'),
api.get<StrategyStatus[]>('/api/backups/strategies'),
])
}
onMounted(() => load().catch(fail))
onUnmounted(() => clearInterval(timer))
async function saveTarget(payload: BackupTargetPayload) {
if (!dialog.value || dialog.value.kind !== 'target') return
busy.value = true
try {
if (dialog.value.current)
await api.put(`/api/backups/targets/${dialog.value.current.id}`, payload)
else await api.post('/api/backups/targets', payload)
toast.success('Target saved')
dialog.value = null
await load()
} catch (e) {
fail(e)
} finally {
busy.value = false
}
}
async function testTarget(t: BackupTargetView) {
try {
await api.post(`/api/backups/targets/${t.id}/test`, {})
toast.success(`Connection ok: ${t.name}`)
} catch (e) {
fail(e)
}
}
async function deleteTarget(t: BackupTargetView) {
if (!confirm(`Delete target ${t.name}?`)) return
try {
await api.delete(`/api/backups/targets/${t.id}`)
await load()
} catch (e) {
fail(e)
}
}
async function saveStrategy(payload: BackupStrategyPayload) {
if (!dialog.value || dialog.value.kind !== 'strategy') return
busy.value = true
try {
if (dialog.value.current)
await api.put(`/api/backups/strategies/${dialog.value.current.id}`, payload)
else await api.post('/api/backups/strategies', payload)
toast.success('Strategy saved')
dialog.value = null
await load()
} catch (e) {
fail(e)
} finally {
busy.value = false
}
}
async function deleteStrategy(s: StrategyStatus) {
if (!confirm(`Delete strategy ${s.name}? Existing backups on the target are kept.`)) return
try {
await api.delete(`/api/backups/strategies/${s.id}`)
await load()
} catch (e) {
fail(e)
}
}
async function runNow(s: StrategyStatus) {
try {
const run = await api.post<JobRun>(`/api/backups/strategies/${s.id}/run`, {})
running.value[s.id] = run
toast.success(`Backup '${s.name}' started`)
clearInterval(timer)
timer = setInterval(async () => {
let active = false
for (const [sid, r] of Object.entries(running.value)) {
if (r.status !== 'running') continue
const fresh = await api.get<JobRun>(`/api/jobs/${r.id}`)
running.value[sid] = fresh
if (fresh.status === 'running') active = true
else {
if (fresh.status === 'success') toast.success('Backup finished')
else toast.error('Backup failed, see Jobs for the log')
await load()
}
}
if (!active) clearInterval(timer)
}, 2000)
} catch (e) {
fail(e)
}
}
async function history(s: StrategyStatus) {
try {
dialog.value = {
kind: 'history',
strategy: s,
records: await api.get<BackupRecord[]>(`/api/backups/strategies/${s.id}/records`),
}
} catch (e) {
fail(e)
}
}
const sourceLabel = (s: StrategyStatus) => {
const src = s.source
switch (src.type) {
case 'volume_claim':
return `PVC ${src.namespace}/${src.pvc}`
case 'postgres_dump':
return `pg_dumpall ${src.namespace}/${src.pod}`
case 'kubernetes_manifests':
return `manifests ${src.namespace}`
case 'host_path':
return `host ${src.path}`
}
}
const size = (b: number) =>
b > 1e9
? `${(b / 1e9).toFixed(1)} GB`
: b > 1e6
? `${(b / 1e6).toFixed(1)} MB`
: `${Math.ceil(b / 1e3)} kB`
const fmt = (d: string) => new Date(d).toLocaleString()
</script>
<template>
<h1 class="text-2xl font-semibold">Backups</h1>
<section class="mt-8">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium">Targets</h2>
<button
v-if="auth.isAdmin"
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
@click="dialog = { kind: 'target', current: null }"
>
New target
</button>
</div>
<table class="mt-3 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
<th class="py-2">Name</th>
<th>Type</th>
<th>Location</th>
<th>User</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="t in targets" :key="t.id" class="border-b border-gray-100">
<td class="py-1.5 font-medium">{{ t.name }}</td>
<td class="uppercase">{{ t.kind }}{{ t.tls ? 'S' : '' }}</td>
<td class="font-mono text-xs">
{{
t.kind === 'smb'
? `//${t.host}/${t.share}/${t.path}`
: `ftp://${t.host}${t.port ? ':' + t.port : ''}/${t.path}`
}}
</td>
<td>{{ t.username || '–' }}</td>
<td class="space-x-3 whitespace-nowrap text-right">
<template v-if="auth.isAdmin">
<button class="text-blue-600 hover:underline" @click="testTarget(t)">Test</button>
<button
class="text-blue-600 hover:underline"
@click="dialog = { kind: 'target', current: t }"
>
Edit
</button>
<button class="text-red-600 hover:underline" @click="deleteTarget(t)">Delete</button>
</template>
</td>
</tr>
<tr v-if="targets.length === 0">
<td colspan="5" class="py-4 text-center text-gray-500">
No targets yet. Add an SMB share or FTP server first.
</td>
</tr>
</tbody>
</table>
</section>
<section class="mt-10">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium">Strategies</h2>
<button
v-if="auth.isAdmin"
:disabled="targets.length === 0"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
@click="dialog = { kind: 'strategy', current: null }"
>
New strategy
</button>
</div>
<table class="mt-3 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
<th class="py-2">Name</th>
<th>Source</th>
<th>Schedule</th>
<th>Target</th>
<th>Keep</th>
<th>Last backup</th>
<th></th>
</tr>
</thead>
<tbody>
<tr
v-for="s in strategies"
:key="s.id"
class="border-b border-gray-100"
:class="s.enabled ? '' : 'text-gray-400'"
>
<td class="py-1.5 font-medium">
{{ s.name }}
<span v-if="s.encrypted" class="ml-1 rounded bg-gray-100 px-1 text-xs text-gray-600"
>encrypted</span
>
<span v-if="!s.enabled" class="ml-1 rounded bg-gray-100 px-1 text-xs">disabled</span>
</td>
<td class="font-mono text-xs">{{ sourceLabel(s) }}</td>
<td class="font-mono text-xs">{{ s.schedule }}</td>
<td>{{ s.target_name }}</td>
<td>{{ s.retention }}</td>
<td class="text-xs">
<template v-if="running[s.id]?.status === 'running'"
><span class="text-blue-600">running…</span></template
>
<template v-else-if="s.last_backup">
<div class="font-mono">{{ s.last_backup.filename }}</div>
<div class="text-gray-500">
{{ fmt(s.last_backup.created_at) }} · {{ size(s.last_backup.size_bytes) }}
</div>
</template>
<template v-else>never</template>
</td>
<td class="space-x-3 whitespace-nowrap text-right">
<button class="text-blue-600 hover:underline" @click="history(s)">History</button>
<template v-if="auth.isAdmin">
<button
class="text-blue-600 hover:underline"
:disabled="running[s.id]?.status === 'running'"
@click="runNow(s)"
>
Run now
</button>
<button
class="text-blue-600 hover:underline"
@click="dialog = { kind: 'strategy', current: s }"
>
Edit
</button>
<button class="text-red-600 hover:underline" @click="deleteStrategy(s)">
Delete
</button>
</template>
</td>
</tr>
<tr v-if="strategies.length === 0">
<td colspan="7" class="py-4 text-center text-gray-500">No strategies yet.</td>
</tr>
</tbody>
</table>
</section>
<div
v-if="dialog"
class="fixed inset-0 flex items-center justify-center overflow-y-auto bg-black/30"
>
<div class="w-full max-w-xl rounded-lg bg-white p-6 shadow-lg" role="dialog">
<template v-if="dialog.kind === 'target'">
<h2 class="mb-4 text-lg font-semibold">
{{ dialog.current ? 'Edit target' : 'New target' }}
</h2>
<TargetForm
:key="dialog.current?.id ?? 'new'"
:current="dialog.current"
:busy="busy"
@submit="saveTarget"
@cancel="dialog = null"
/>
</template>
<template v-else-if="dialog.kind === 'strategy'">
<h2 class="mb-4 text-lg font-semibold">
{{ dialog.current ? 'Edit strategy' : 'New strategy' }}
</h2>
<StrategyForm
:key="dialog.current?.id ?? 'new'"
:targets="targets"
:current="dialog.current"
:busy="busy"
@submit="saveStrategy"
@cancel="dialog = null"
/>
</template>
<template v-else>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Backups of {{ dialog.strategy.name }}</h2>
<button class="text-blue-600 hover:underline" @click="dialog = null">Close</button>
</div>
<table data-testid="backup-records" class="mt-4 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
<th class="py-2">File</th>
<th>Created</th>
<th>Size</th>
<th>SHA-256</th>
</tr>
</thead>
<tbody>
<tr v-for="r in dialog.records" :key="r.id" class="border-b border-gray-100">
<td class="py-1 font-mono text-xs">{{ r.filename }}</td>
<td class="text-xs">{{ fmt(r.created_at) }}</td>
<td class="text-xs">{{ size(r.size_bytes) }}</td>
<td class="font-mono text-xs" :title="r.sha256">{{ r.sha256.slice(0, 12) }}…</td>
</tr>
<tr v-if="dialog.records.length === 0">
<td colspan="4" class="py-3 text-center text-gray-500">No backups yet.</td>
</tr>
</tbody>
</table>
</template>
</div>
</div>
</template>

View File

@ -1,8 +0,0 @@
<script setup lang="ts">
defineProps<{ title: string }>()
</script>
<template>
<h1 class="text-2xl font-semibold">{{ title }}</h1>
<p class="mt-2 text-gray-600">Not implemented yet.</p>
</template>

View File

@ -5,12 +5,12 @@ import AppShell from './components/AppShell.vue'
import LoginPage from './pages/LoginPage.vue'
import DashboardPage from './pages/DashboardPage.vue'
import UsersPage from './pages/UsersPage.vue'
import PlaceholderPage from './pages/PlaceholderPage.vue'
import SettingsPage from './pages/SettingsPage.vue'
import JobsPage from './pages/JobsPage.vue'
import UpdatesPage from './pages/UpdatesPage.vue'
import ClusterPage from './pages/ClusterPage.vue'
import VulnerabilitiesPage from './pages/VulnerabilitiesPage.vue'
import BackupsPage from './pages/BackupsPage.vue'
export const router = createRouter({
history: createWebHistory(),
@ -24,7 +24,7 @@ export const router = createRouter({
{ path: 'updates', component: UpdatesPage },
{ path: 'cluster', component: ClusterPage },
{ path: 'vulnerabilities', component: VulnerabilitiesPage },
{ path: 'backups', component: PlaceholderPage, props: { title: 'Backups' } },
{ path: 'backups', component: BackupsPage },
{ path: 'users', component: UsersPage, meta: { admin: true } },
{ path: 'jobs', component: JobsPage },
{ path: 'settings', component: SettingsPage },