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'>[]
|
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
|
||||||
|
}
|
||||||
|
|||||||
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 { 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' }}
|
||||||
|
|||||||
Reference in New Issue
Block a user