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:
39
frontend/e2e/app-backup.spec.ts
Normal file
39
frontend/e2e/app-backup.spec.ts
Normal 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 * * *',
|
||||
)
|
||||
})
|
||||
@ -301,3 +301,32 @@ export interface Dashboard {
|
||||
recent_jobs: Omit<JobRun, 'log'>[]
|
||||
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
|
||||
}
|
||||
|
||||
86
frontend/src/components/ApplicationBackupForm.test.ts
Normal file
86
frontend/src/components/ApplicationBackupForm.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
175
frontend/src/components/ApplicationBackupForm.vue
Normal file
175
frontend/src/components/ApplicationBackupForm.vue
Normal 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>
|
||||
@ -2,6 +2,8 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { api, ApiError } from '../api/client'
|
||||
import type {
|
||||
Application,
|
||||
ApplicationBackupPayload,
|
||||
BackupRecord,
|
||||
BackupStrategyPayload,
|
||||
BackupTargetPayload,
|
||||
@ -13,15 +15,18 @@ import { useAuthStore } from '../stores/auth'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import TargetForm from '../components/TargetForm.vue'
|
||||
import StrategyForm from '../components/StrategyForm.vue'
|
||||
import ApplicationBackupForm from '../components/ApplicationBackupForm.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
const targets = ref<BackupTargetView[]>([])
|
||||
const strategies = ref<StrategyStatus[]>([])
|
||||
const applications = ref<Application[]>([])
|
||||
const dialog = ref<
|
||||
| { kind: 'target'; current: BackupTargetView | null }
|
||||
| { kind: 'strategy'; current: StrategyStatus | null }
|
||||
| { kind: 'history'; strategy: StrategyStatus; records: BackupRecord[] }
|
||||
| { kind: 'application'; application: Application }
|
||||
| null
|
||||
>(null)
|
||||
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')
|
||||
|
||||
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<StrategyStatus[]>('/api/backups/strategies'),
|
||||
api.get<Application[]>('/api/backups/applications'),
|
||||
])
|
||||
}
|
||||
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) {
|
||||
try {
|
||||
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>
|
||||
|
||||
<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">
|
||||
<h2 class="text-lg font-medium">Targets</h2>
|
||||
<button
|
||||
@ -326,6 +405,17 @@ const fmt = (d: string) => new Date(d).toLocaleString()
|
||||
@cancel="dialog = null"
|
||||
/>
|
||||
</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'">
|
||||
<h2 class="mb-4 text-lg font-semibold">
|
||||
{{ dialog.current ? 'Edit strategy' : 'New strategy' }}
|
||||
|
||||
Reference in New Issue
Block a user