From b0831c008d2321955ecae556f389858c246f2912 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Thu, 3 Sep 2026 20:52:57 +0200 Subject: [PATCH] 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 --- frontend/e2e/app-backup.spec.ts | 39 ++++ frontend/src/api/types.ts | 29 +++ .../components/ApplicationBackupForm.test.ts | 86 +++++++++ .../src/components/ApplicationBackupForm.vue | 175 ++++++++++++++++++ frontend/src/pages/BackupsPage.vue | 92 ++++++++- 5 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 frontend/e2e/app-backup.spec.ts create mode 100644 frontend/src/components/ApplicationBackupForm.test.ts create mode 100644 frontend/src/components/ApplicationBackupForm.vue diff --git a/frontend/e2e/app-backup.spec.ts b/frontend/e2e/app-backup.spec.ts new file mode 100644 index 0000000..48f7c12 --- /dev/null +++ b/frontend/e2e/app-backup.spec.ts @@ -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 * * *', + ) +}) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index a8fbe8f..e7d5351 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -301,3 +301,32 @@ export interface Dashboard { recent_jobs: Omit[] 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 +} diff --git a/frontend/src/components/ApplicationBackupForm.test.ts b/frontend/src/components/ApplicationBackupForm.test.ts new file mode 100644 index 0000000..e2804dc --- /dev/null +++ b/frontend/src/components/ApplicationBackupForm.test.ts @@ -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() + }) +}) diff --git a/frontend/src/components/ApplicationBackupForm.vue b/frontend/src/components/ApplicationBackupForm.vue new file mode 100644 index 0000000..68eeb93 --- /dev/null +++ b/frontend/src/components/ApplicationBackupForm.vue @@ -0,0 +1,175 @@ + + + diff --git a/frontend/src/pages/BackupsPage.vue b/frontend/src/pages/BackupsPage.vue index 91cf7b3..a7e2800 100644 --- a/frontend/src/pages/BackupsPage.vue +++ b/frontend/src/pages/BackupsPage.vue @@ -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([]) const strategies = ref([]) +const applications = ref([]) 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 | 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('/api/backups/targets'), api.get('/api/backups/strategies'), + api.get('/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('/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()

Backups

+

Applications

+

+ Found on the Kubernetes cluster and on the host. Pick one to back up its data and database. +

+
+
+
+ {{ a.name }} + + {{ a.kind === 'kubernetes' ? a.namespace : 'host' }} + +
+

{{ a.detail }}

+

+ Offers {{ offers(a) }} + · {{ a.components.length }} parts +

+

+ {{ plannedFor(a).length }} backup strateg{{ plannedFor(a).length === 1 ? 'y' : 'ies' }} + configured +

+ +
+

+ No applications discovered. The cluster may be unreachable. +

+
+
+ +

Targets