Backup page: pick an application instead of assembling a strategy by hand

The page now starts with the applications found on the cluster and on the
host, each showing what it offers. Choosing one opens a short flow:
select data and database, pick how often, pick a target, save. That
creates one strategy per selected part, so Gitea's volume and its
PostgreSQL dump become two ordinary strategies with their own history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-03 20:52:57 +02:00
parent 51364fdd76
commit b0831c008d
5 changed files with 420 additions and 1 deletions

View File

@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test'
test('an admin backs up Gitea data and database in one flow', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('admin@example.com')
await page.getByLabel('Password').fill('admin-password-123')
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
await page.goto('/backups')
// a target is needed first
await page.getByRole('button', { name: 'New target' }).click()
const name = `NAS ${Date.now()}`
await page.getByLabel('Name', { exact: true }).fill(name)
await page.getByLabel('Host', { exact: true }).fill('nas.local')
await page.getByLabel('Share').fill('backups')
await page.getByLabel('Username', { exact: true }).fill('backup')
await page.getByLabel('Password', { exact: true }).fill('smb-secret')
await page.getByRole('button', { name: 'Save target' }).click()
await expect(page.getByRole('row', { name: new RegExp(name) })).toBeVisible()
// the discovered applications are shown with what they offer
const gitea = page.getByTestId('application-k8s:gitea/gitea')
await expect(gitea).toContainText('Gitea')
await expect(gitea).toContainText('Data + DB')
await expect(page.getByTestId('application-host:monitoring.service')).toContainText('Monitoring')
// Gitea → select → schedule → target → save
await gitea.getByRole('button', { name: 'Back up' }).click()
await expect(page.getByRole('dialog')).toContainText('gitea-shared-storage')
await page.getByLabel('Target', { exact: true }).selectOption({ label: name })
await page.getByRole('button', { name: 'Create backup plan' }).click()
await expect(page.getByText(/2 backup strategies created/)).toBeVisible()
await expect(page.getByRole('row', { name: /Gitea – Gitea volume/ })).toBeVisible()
await expect(page.getByRole('row', { name: /Gitea – PostgreSQL dump/ })).toContainText(
'0 0 3 * * *',
)
})

View File

@ -301,3 +301,32 @@ export interface Dashboard {
recent_jobs: Omit<JobRun, 'log'>[] recent_jobs: Omit<JobRun, 'log'>[]
failed_jobs_24h: number failed_jobs_24h: number
} }
export type ApplicationKind = 'kubernetes' | 'host'
export type ComponentKind = 'data' | 'database' | 'manifests'
export interface BackupComponent {
id: string
kind: ComponentKind
label: string
source: BackupSource
recommended: boolean
}
export interface Application {
id: string
name: string
kind: ApplicationKind
namespace: string | null
detail: string
components: BackupComponent[]
}
export interface ApplicationBackupPayload {
application_id: string
component_ids: string[]
schedule: string
target_id: string
retention: number
passphrase: string | null
}

View File

@ -0,0 +1,86 @@
import { mount } from '@vue/test-utils'
import ApplicationBackupForm from './ApplicationBackupForm.vue'
import type { Application } from '../api/types'
const gitea: Application = {
id: 'k8s:gitea/gitea',
name: 'Gitea',
kind: 'kubernetes',
namespace: 'gitea',
detail: '3 workload(s): gitea, postgresql, valkey',
components: [
{
id: 'data:gitea-shared-storage',
kind: 'data',
label: 'Gitea volume gitea-shared-storage (10Gi)',
source: { type: 'volume_claim', namespace: 'gitea', pvc: 'gitea-shared-storage' },
recommended: true,
},
{
id: 'data:valkey-data-0',
kind: 'data',
label: 'Valkey volume valkey-data-0',
source: { type: 'volume_claim', namespace: 'gitea', pvc: 'valkey-data-0' },
recommended: false,
},
{
id: 'db:gitea-postgresql-0',
kind: 'database',
label: 'PostgreSQL dump of gitea-postgresql-0',
source: { type: 'postgres_dump', namespace: 'gitea', pod: 'gitea-postgresql-0' },
recommended: true,
},
],
}
const targets = [
{ id: 't1', name: 'NAS' },
{ id: 't2', name: 'FTP' },
]
const props = { application: gitea, targets }
describe('ApplicationBackupForm', () => {
it('preselects the recommended components and leaves caches out', () => {
const w = mount(ApplicationBackupForm, { props })
const boxes = w.findAll('input[type=checkbox]')
expect(boxes).toHaveLength(3)
expect((boxes[0].element as HTMLInputElement).checked).toBe(true)
expect((boxes[1].element as HTMLInputElement).checked).toBe(false)
expect((boxes[2].element as HTMLInputElement).checked).toBe(true)
expect(w.text()).toContain('Data + DB')
})
it('defaults to a nightly backup at three and submits the selection', async () => {
const w = mount(ApplicationBackupForm, { props })
expect((w.find('input[name=schedule]').element as HTMLInputElement).value).toBe('0 0 3 * * *')
await w.find('select[name=target_id]').setValue('t2')
await w.find('form').trigger('submit')
expect(w.emitted('submit')![0][0]).toEqual({
application_id: 'k8s:gitea/gitea',
component_ids: ['data:gitea-shared-storage', 'db:gitea-postgresql-0'],
schedule: '0 0 3 * * *',
target_id: 't2',
retention: 7,
passphrase: null,
})
})
it('refuses an empty selection and offers a daily preset', async () => {
const w = mount(ApplicationBackupForm, { props })
for (const b of w.findAll('input[type=checkbox]')) await b.setValue(false)
await w.find('form').trigger('submit')
expect(w.emitted('submit')).toBeUndefined()
expect(w.text()).toContain('Select at least one')
await w.findAll('input[type=checkbox]')[0].setValue(true)
await w.find('select[name=preset]').setValue('0 0 */6 * * *')
expect((w.find('input[name=schedule]').element as HTMLInputElement).value).toBe('0 0 */6 * * *')
})
it('says when no target exists yet', () => {
const w = mount(ApplicationBackupForm, { props: { application: gitea, targets: [] } })
expect(w.text()).toContain('Add a backup target first')
expect(w.find('button[type=submit]').attributes('disabled')).toBeDefined()
})
})

View File

@ -0,0 +1,175 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Application, ApplicationBackupPayload } from '../api/types'
const props = defineProps<{
application: Application
targets: { id: string; name: string }[]
busy?: boolean
}>()
const emit = defineEmits<{ submit: [payload: ApplicationBackupPayload]; cancel: [] }>()
const selected = ref<Set<string>>(
new Set(props.application.components.filter((c) => c.recommended).map((c) => c.id)),
)
const schedule = ref('0 0 3 * * *')
const preset = ref('0 0 3 * * *')
const targetId = ref(props.targets[0]?.id ?? '')
const retention = ref('7')
const passphrase = ref('')
const error = ref('')
const presets = [
{ cron: '0 0 3 * * *', label: 'Every day at 3:00' },
{ cron: '0 0 */6 * * *', label: 'Every six hours' },
{ cron: '0 0 3 * * 0', label: 'Every Sunday at 3:00' },
{ cron: '0 30 3 1 * *', label: 'Monthly on the 1st at 3:30' },
]
/** Short summary of what is selected, e.g. "Data + DB". */
const summary = computed(() => {
const kinds = props.application.components
.filter((c) => selected.value.has(c.id))
.map((c) => c.kind)
const parts: string[] = []
if (kinds.includes('data')) parts.push('Data')
if (kinds.includes('database')) parts.push('DB')
if (kinds.includes('manifests')) parts.push('Manifests')
return parts.join(' + ') || 'nothing selected'
})
function toggle(id: string, on: boolean) {
if (on) selected.value.add(id)
else selected.value.delete(id)
}
function applyPreset() {
schedule.value = preset.value
}
function submit() {
error.value = ''
if (selected.value.size === 0) {
error.value = 'Select at least one part of the application'
return
}
emit('submit', {
application_id: props.application.id,
component_ids: props.application.components
.filter((c) => selected.value.has(c.id))
.map((c) => c.id),
schedule: schedule.value,
target_id: targetId.value,
retention: Number(retention.value),
passphrase: passphrase.value || null,
})
}
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>
<p class="text-sm text-gray-600">{{ application.detail }}</p>
<p class="mt-3 text-sm font-medium">
What should be backed up? <span class="text-gray-500">({{ summary }})</span>
</p>
<label
v-for="c in application.components"
:key="c.id"
class="mt-2 flex items-start gap-2 rounded-md border border-gray-200 p-2 text-sm"
>
<input
type="checkbox"
class="mt-0.5"
:checked="selected.has(c.id)"
@change="toggle(c.id, ($event.target as HTMLInputElement).checked)"
/>
<span>
<span class="font-medium">{{ c.label }}</span>
<span class="ml-2 rounded bg-gray-100 px-1.5 py-0.5 text-xs uppercase text-gray-600">{{
c.kind
}}</span>
<span v-if="!c.recommended" class="ml-1 text-xs text-gray-500">optional</span>
</span>
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="ab-preset" class="block text-sm font-medium">How often?</label>
<select id="ab-preset" v-model="preset" name="preset" :class="input" @change="applyPreset">
<option v-for="p in presets" :key="p.cron" :value="p.cron">{{ p.label }}</option>
</select>
</div>
<div>
<label for="ab-schedule" class="block text-sm font-medium">Schedule</label>
<input
id="ab-schedule"
v-model="schedule"
name="schedule"
required
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="ab-target" class="block text-sm font-medium">Target</label>
<select id="ab-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>
<p v-if="targets.length === 0" class="mt-1 text-xs text-red-600">
Add a backup target first.
</p>
</div>
<div>
<label for="ab-keep" class="block text-sm font-medium">Keep last</label>
<input
id="ab-keep"
v-model="retention"
name="retention"
type="number"
min="1"
max="365"
required
:class="input"
/>
</div>
</div>
<div>
<label for="ab-pass" class="block text-sm font-medium"
>Encryption passphrase (optional)</label
>
<input
id="ab-pass"
v-model="passphrase"
name="passphrase"
type="password"
autocomplete="new-password"
minlength="12"
:class="input"
/>
</div>
<p v-if="error" role="alert" class="text-sm text-red-600">{{ error }}</p>
<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 || targets.length === 0"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
Create backup plan
</button>
</div>
</form>
</template>

View File

@ -2,6 +2,8 @@
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { api, ApiError } from '../api/client' import { api, ApiError } from '../api/client'
import type { import type {
Application,
ApplicationBackupPayload,
BackupRecord, BackupRecord,
BackupStrategyPayload, BackupStrategyPayload,
BackupTargetPayload, BackupTargetPayload,
@ -13,15 +15,18 @@ import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast' import { useToastStore } from '../stores/toast'
import TargetForm from '../components/TargetForm.vue' import TargetForm from '../components/TargetForm.vue'
import StrategyForm from '../components/StrategyForm.vue' import StrategyForm from '../components/StrategyForm.vue'
import ApplicationBackupForm from '../components/ApplicationBackupForm.vue'
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToastStore() const toast = useToastStore()
const targets = ref<BackupTargetView[]>([]) const targets = ref<BackupTargetView[]>([])
const strategies = ref<StrategyStatus[]>([]) const strategies = ref<StrategyStatus[]>([])
const applications = ref<Application[]>([])
const dialog = ref< const dialog = ref<
| { kind: 'target'; current: BackupTargetView | null } | { kind: 'target'; current: BackupTargetView | null }
| { kind: 'strategy'; current: StrategyStatus | null } | { kind: 'strategy'; current: StrategyStatus | null }
| { kind: 'history'; strategy: StrategyStatus; records: BackupRecord[] } | { kind: 'history'; strategy: StrategyStatus; records: BackupRecord[] }
| { kind: 'application'; application: Application }
| null | null
>(null) >(null)
const busy = ref(false) const busy = ref(false)
@ -31,9 +36,10 @@ let timer: ReturnType<typeof setInterval> | undefined
const fail = (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Request failed') const fail = (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Request failed')
async function load() { async function load() {
;[targets.value, strategies.value] = await Promise.all([ ;[targets.value, strategies.value, applications.value] = await Promise.all([
api.get<BackupTargetView[]>('/api/backups/targets'), api.get<BackupTargetView[]>('/api/backups/targets'),
api.get<StrategyStatus[]>('/api/backups/strategies'), api.get<StrategyStatus[]>('/api/backups/strategies'),
api.get<Application[]>('/api/backups/applications'),
]) ])
} }
onMounted(() => load().catch(fail)) onMounted(() => load().catch(fail))
@ -56,6 +62,38 @@ async function saveTarget(payload: BackupTargetPayload) {
} }
} }
/** Create one strategy per selected component of an application. */
async function createFromApplication(payload: ApplicationBackupPayload) {
busy.value = true
try {
const created = await api.post<unknown[]>('/api/backups/applications', payload)
toast.success(
`${created.length} backup ${created.length === 1 ? 'strategy' : 'strategies'} created`,
)
dialog.value = null
await load()
} catch (e) {
fail(e)
} finally {
busy.value = false
}
}
/** Short summary of what an application offers, e.g. "Data + DB". */
function offers(a: Application) {
const kinds = a.components.filter((c) => c.recommended).map((c) => c.kind)
const parts: string[] = []
if (kinds.includes('data')) parts.push('Data')
if (kinds.includes('database')) parts.push('DB')
return parts.join(' + ') || 'Manifests only'
}
/** Strategies that already exist for one of the application's components. */
function plannedFor(a: Application) {
const sources = a.components.map((c) => JSON.stringify(c.source))
return strategies.value.filter((s) => sources.includes(JSON.stringify(s.source)))
}
async function testTarget(t: BackupTargetView) { async function testTarget(t: BackupTargetView) {
try { try {
await api.post(`/api/backups/targets/${t.id}/test`, {}) await api.post(`/api/backups/targets/${t.id}/test`, {})
@ -166,6 +204,47 @@ const fmt = (d: string) => new Date(d).toLocaleString()
<h1 class="text-2xl font-semibold">Backups</h1> <h1 class="text-2xl font-semibold">Backups</h1>
<section class="mt-8"> <section class="mt-8">
<h2 class="text-lg font-medium">Applications</h2>
<p class="mb-3 text-sm text-gray-600">
Found on the Kubernetes cluster and on the host. Pick one to back up its data and database.
</p>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div
v-for="a in applications"
:key="a.id"
:data-testid="`application-${a.id}`"
class="rounded-lg border border-gray-200 bg-white p-4"
>
<div class="flex items-baseline justify-between">
<span class="text-lg font-medium">{{ a.name }}</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-xs uppercase text-gray-600">
{{ a.kind === 'kubernetes' ? a.namespace : 'host' }}
</span>
</div>
<p class="mt-0.5 text-sm text-gray-600">{{ a.detail }}</p>
<p class="mt-2 text-sm">
Offers <span class="font-medium">{{ offers(a) }}</span>
<span class="text-gray-500"> · {{ a.components.length }} parts</span>
</p>
<p v-if="plannedFor(a).length" class="mt-1 text-xs text-green-700">
{{ plannedFor(a).length }} backup strateg{{ plannedFor(a).length === 1 ? 'y' : 'ies' }}
configured
</p>
<button
v-if="auth.isAdmin"
class="mt-3 rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
@click="dialog = { kind: 'application', application: a }"
>
Back up
</button>
</div>
<p v-if="applications.length === 0" class="text-sm text-gray-500">
No applications discovered. The cluster may be unreachable.
</p>
</div>
</section>
<section class="mt-10">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-lg font-medium">Targets</h2> <h2 class="text-lg font-medium">Targets</h2>
<button <button
@ -326,6 +405,17 @@ const fmt = (d: string) => new Date(d).toLocaleString()
@cancel="dialog = null" @cancel="dialog = null"
/> />
</template> </template>
<template v-else-if="dialog.kind === 'application'">
<h2 class="mb-1 text-lg font-semibold">Back up {{ dialog.application.name }}</h2>
<ApplicationBackupForm
:key="dialog.application.id"
:application="dialog.application"
:targets="targets"
:busy="busy"
@submit="createFromApplication"
@cancel="dialog = null"
/>
</template>
<template v-else-if="dialog.kind === 'strategy'"> <template v-else-if="dialog.kind === 'strategy'">
<h2 class="mb-4 text-lg font-semibold"> <h2 class="mb-4 text-lg font-semibold">
{{ dialog.current ? 'Edit strategy' : 'New strategy' }} {{ dialog.current ? 'Edit strategy' : 'New strategy' }}