M4: admin settings page and JSON API
- AdminApiController (admin only, CSRF-protected): status, create key, download recovery kit, confirm (statement accepted + exact phrase), backends with their usable auth mechanisms, add/test/remove locations, start backups, schedule, snapshots, browse, queue restores - RestoreJob runs web-initiated restores in the background; backup and restore runs are kept apart in the run table - admin section 'NG Backup' with a Vue 3 / @nextcloud/vue 9 page: key and recovery-kit flow (download, tick statement, 'Yes, I confirm'), location form built from files_external's parameter definitions, schedule, run list with polling, snapshot browser and restore with the three modes - auth mechanisms that depend on a user session or on files_external's own stored credentials are not offered API tested with curl on NC34 (status, backends, schedule validation, confirmation refusal, browse, queued restore and backup completed by cron); non-admins get 403, anonymous 401.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<NcSettingsSection :name="t('ng_backup', 'Backups')"
|
||||
:description="t('ng_backup', 'Backups run in the background through Nextcloud\'s cron. Unchanged files are not read again; only new data is uploaded.')">
|
||||
<div class="ngb-row">
|
||||
<NcTextField v-model="schedule" class="ngb-time" :label="t('ng_backup', 'Daily at (HH:MM, empty = off)')" />
|
||||
<NcButton :disabled="schedule === status.schedule" @click="saveSchedule">{{ t('ng_backup', 'Save') }}</NcButton>
|
||||
</div>
|
||||
<div class="ngb-row">
|
||||
<NcButton v-for="target in status.targets" :key="target.id" :disabled="isRunning(target.id)" @click="start(target)">
|
||||
{{ isRunning(target.id) ? t('ng_backup', 'Backup to {name} running …', { name: target.name }) : t('ng_backup', 'Back up now to {name}', { name: target.name }) }}
|
||||
</NcButton>
|
||||
</div>
|
||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||
|
||||
<table v-if="status.runs.length" class="ngb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('ng_backup', 'Started') }}</th>
|
||||
<th>{{ t('ng_backup', 'Location') }}</th>
|
||||
<th>{{ t('ng_backup', 'Kind') }}</th>
|
||||
<th>{{ t('ng_backup', 'Status') }}</th>
|
||||
<th>{{ t('ng_backup', 'Files') }}</th>
|
||||
<th>{{ t('ng_backup', 'Uploaded') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="run in status.runs" :key="run.id" :title="run.error || ''">
|
||||
<td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</td>
|
||||
<td>{{ targetName(run.targetId) }}</td>
|
||||
<td>{{ run.kind }}</td>
|
||||
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
|
||||
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
|
||||
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</NcSettingsSection>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import api from '../api.js'
|
||||
import { formatSize } from '../format.js'
|
||||
|
||||
const props = defineProps({ status: { type: Object, required: true } })
|
||||
const emit = defineEmits(['changed'])
|
||||
const schedule = ref(props.status.schedule)
|
||||
const error = ref('')
|
||||
watch(() => props.status.schedule, (s) => { schedule.value = s })
|
||||
|
||||
const isRunning = (targetId) => props.status.runs.some(r => r.targetId === targetId && r.kind === 'full' && r.status === 'running')
|
||||
const targetName = (id) => props.status.targets.find(tg => tg.id === id)?.name ?? '#' + id
|
||||
|
||||
async function start(target) {
|
||||
error.value = ''
|
||||
try {
|
||||
await api.startBackup(target.id, '')
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSchedule() {
|
||||
error.value = ''
|
||||
try {
|
||||
await api.setSchedule(schedule.value.trim())
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ngb-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.ngb-time { max-width: 260px; }
|
||||
.ngb-table { width: 100%; border-collapse: collapse; }
|
||||
.ngb-table th, .ngb-table td { text-align: start; padding: 4px 8px; border-bottom: 1px solid var(--color-border); }
|
||||
.ngb-failed { color: var(--color-error-text); }
|
||||
.ngb-done { color: var(--color-success-text); }
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<NcSettingsSection :name="t('ng_backup', 'Backup key and recovery kit')"
|
||||
:description="t('ng_backup', 'All backups are encrypted with one key. The server keeps it for scheduled backups; the recovery kit, protected by your passphrase, is what you need to restore on a new server.')">
|
||||
<!-- 1. Create the key -->
|
||||
<form v-if="!status.key.initialized" class="ngb-form" @submit.prevent="createKey">
|
||||
<NcPasswordField v-model="pass1" :label="t('ng_backup', 'Passphrase (at least 12 characters)')" autocomplete="new-password" />
|
||||
<NcPasswordField v-model="pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password"
|
||||
:error="pass2 !== '' && pass1 !== pass2" :helper-text="pass2 !== '' && pass1 !== pass2 ? t('ng_backup', 'The passphrases do not match') : ''" />
|
||||
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 12 || pass1 !== pass2">
|
||||
{{ t('ng_backup', 'Create backup key') }}
|
||||
</NcButton>
|
||||
</form>
|
||||
|
||||
<template v-else>
|
||||
<p>{{ t('ng_backup', 'Key fingerprint: {fingerprint}', { fingerprint: status.key.fingerprint }) }}</p>
|
||||
|
||||
<!-- 2. Download the kit and confirm -->
|
||||
<div v-if="!status.key.confirmation" class="ngb-confirm">
|
||||
<NcNoteCard type="warning">
|
||||
{{ t('ng_backup', 'Before the first backup: download the recovery kit and store it, together with your passphrase, somewhere safe outside this server.') }}
|
||||
</NcNoteCard>
|
||||
<NcButton :href="kitUrl" download @click="downloaded = true">
|
||||
{{ t('ng_backup', 'Download recovery kit') }}
|
||||
</NcButton>
|
||||
<NcCheckboxRadioSwitch v-model="accepted" :disabled="!downloaded">
|
||||
{{ status.statement }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcButton variant="primary" :disabled="!downloaded || !accepted || busy" @click="confirm">
|
||||
{{ status.confirmationPhrase }}
|
||||
</NcButton>
|
||||
</div>
|
||||
<p v-else>
|
||||
{{ t('ng_backup', 'Recovery kit confirmed by {user} on {date}.', { user: status.key.confirmation.uid, date: new Date(status.key.confirmation.time * 1000).toLocaleString() }) }}
|
||||
<a :href="kitUrl" download>{{ t('ng_backup', 'Download the kit again') }}</a>
|
||||
</p>
|
||||
</template>
|
||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||
</NcSettingsSection>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
|
||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import api from '../api.js'
|
||||
|
||||
const props = defineProps({ status: { type: Object, required: true } })
|
||||
const emit = defineEmits(['changed'])
|
||||
|
||||
const pass1 = ref('')
|
||||
const pass2 = ref('')
|
||||
const downloaded = ref(false)
|
||||
const accepted = ref(false)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const kitUrl = api.kitUrl()
|
||||
|
||||
async function createKey() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.initKey(pass1.value)
|
||||
pass1.value = pass2.value = ''
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.confirmKit(accepted.value, props.status.confirmationPhrase)
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ngb-form, .ngb-confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 600px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<NcSettingsSection :name="t('ng_backup', 'Backup locations')"
|
||||
:description="t('ng_backup', 'Uses the connection types of External storage (S3, SFTP, WebDAV, ...), but never mounts them: the location is only connected while a backup or restore runs. Credentials are stored encrypted.')">
|
||||
<ul v-if="status.targets.length" class="ngb-list">
|
||||
<li v-for="target in status.targets" :key="target.id">
|
||||
<strong>{{ target.name }}</strong>
|
||||
<span class="ngb-muted">{{ target.backend }} · {{ target.path }}</span>
|
||||
<span v-if="tests[target.id]" :class="tests[target.id].ok ? 'ngb-ok' : 'ngb-err'">
|
||||
{{ tests[target.id].ok ? t('ng_backup', 'OK, {n} snapshots', { n: tests[target.id].snapshots }) : tests[target.id].error }}
|
||||
</span>
|
||||
<NcButton variant="tertiary" @click="test(target)">{{ t('ng_backup', 'Test') }}</NcButton>
|
||||
<NcButton variant="tertiary" @click="remove(target)">{{ t('ng_backup', 'Remove') }}</NcButton>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<NcButton v-if="!adding" @click="startAdd">{{ t('ng_backup', 'Add location') }}</NcButton>
|
||||
<form v-else class="ngb-form" @submit.prevent="add">
|
||||
<NcTextField v-model="form.name" :label="t('ng_backup', 'Name')" />
|
||||
<NcSelect v-model="backend" :options="backends" label="name" :input-label="t('ng_backup', 'Type')" :clearable="false" />
|
||||
<NcSelect v-if="backend" v-model="auth" :options="backend.auth" label="name" :input-label="t('ng_backup', 'Authentication')" :clearable="false" />
|
||||
<template v-for="field in fields" :key="field.key">
|
||||
<NcCheckboxRadioSwitch v-if="field.type === 1" v-model="form.options[field.key]">{{ field.label }}</NcCheckboxRadioSwitch>
|
||||
<NcPasswordField v-else-if="field.type === 2" v-model="form.options[field.key]" :label="field.label + (field.optional ? '' : ' *')" autocomplete="off" />
|
||||
<NcTextField v-else v-model="form.options[field.key]" :label="field.label + (field.optional ? '' : ' *')" />
|
||||
</template>
|
||||
<NcTextField v-model="form.path" :label="t('ng_backup', 'Folder on the location for the backups')" />
|
||||
<div class="ngb-row">
|
||||
<NcButton type="submit" variant="primary" :disabled="busy || !form.name || !backend || !auth">{{ t('ng_backup', 'Connect and add') }}</NcButton>
|
||||
<NcButton @click="adding = false">{{ t('ng_backup', 'Cancel') }}</NcButton>
|
||||
</div>
|
||||
</form>
|
||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||
</NcSettingsSection>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
|
||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import api from '../api.js'
|
||||
|
||||
defineProps({ status: { type: Object, required: true } })
|
||||
const emit = defineEmits(['changed'])
|
||||
|
||||
const adding = ref(false)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const backends = ref([])
|
||||
const backend = ref(null)
|
||||
const auth = ref(null)
|
||||
const tests = reactive({})
|
||||
const form = reactive({ name: '', path: 'ng_backup', options: {} })
|
||||
|
||||
// files_external DefinitionParameter: type 0 text, 1 boolean, 2 password; flag 1 = optional, 4 = hidden
|
||||
const toFields = (params) => Object.entries(params || {})
|
||||
.filter(([, p]) => !(p.flags & 4))
|
||||
.map(([key, p]) => ({ key, label: p.value, type: p.type, optional: !!(p.flags & 1) }))
|
||||
const fields = computed(() => [...toFields(backend.value?.parameters), ...toFields(auth.value?.parameters)])
|
||||
|
||||
watch(backend, (b) => {
|
||||
auth.value = b?.auth?.[0] ?? null
|
||||
form.options = {}
|
||||
})
|
||||
|
||||
async function startAdd() {
|
||||
adding.value = true
|
||||
error.value = ''
|
||||
if (!backends.value.length) {
|
||||
try {
|
||||
backends.value = await api.backends()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path })
|
||||
adding.value = false
|
||||
Object.assign(form, { name: '', path: 'ng_backup', options: {} })
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function test(target) {
|
||||
tests[target.id] = await api.testTarget(target.id)
|
||||
}
|
||||
|
||||
async function remove(target) {
|
||||
if (!window.confirm(t('ng_backup', 'Remove "{name}" from NG Backup? The backups on the location itself are kept.', { name: target.name }))) {
|
||||
return
|
||||
}
|
||||
await api.removeTarget(target.id)
|
||||
emit('changed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ngb-list li { display: flex; align-items: center; gap: 12px; padding: 4px 0; }
|
||||
.ngb-muted { color: var(--color-text-maxcontrast); }
|
||||
.ngb-ok { color: var(--color-success-text); }
|
||||
.ngb-err { color: var(--color-error-text); }
|
||||
.ngb-form { display: flex; flex-direction: column; gap: 10px; max-width: 600px; margin-top: 12px; }
|
||||
.ngb-row { display: flex; gap: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<NcSettingsSection :name="t('ng_backup', 'Restore')"
|
||||
:description="t('ng_backup', 'Pick a restore point and a user folder or file. Restores run in the background and go through Nextcloud, so overwritten files keep their old content as a version and removed files go to the trash.')">
|
||||
<div class="ngb-row">
|
||||
<NcSelect v-model="target" :options="status.targets" label="name" :input-label="t('ng_backup', 'Location')" :clearable="false" />
|
||||
<NcSelect v-if="target" v-model="snapshot" :options="snapshots" :get-option-label="snapshotLabel" :input-label="t('ng_backup', 'Restore point')" :clearable="false" />
|
||||
</div>
|
||||
|
||||
<template v-if="snapshot">
|
||||
<div class="ngb-crumbs">
|
||||
<NcButton variant="tertiary" @click="open('data')">data</NcButton>
|
||||
<template v-for="(crumb, i) in crumbs" :key="crumb.path">
|
||||
<span>/</span>
|
||||
<NcButton variant="tertiary" :disabled="i === crumbs.length - 1" @click="open(crumb.path)">{{ crumb.name }}</NcButton>
|
||||
</template>
|
||||
</div>
|
||||
<NcLoadingIcon v-if="loading" />
|
||||
<ul v-else class="ngb-entries">
|
||||
<li v-for="item in items" :key="item.name" :class="{ selected: selected === join(item.name) }">
|
||||
<NcButton v-if="item.type === 'dir'" variant="tertiary" @click="open(join(item.name))">📁 {{ item.name }}/</NcButton>
|
||||
<span v-else class="ngb-file">📄 {{ item.name }}</span>
|
||||
<span class="ngb-muted">{{ formatSize(item.size) }}{{ item.type === 'dir' ? ' · ' + item.files + ' files' : '' }}</span>
|
||||
<NcButton v-if="restorable(join(item.name))" variant="tertiary" @click="selected = join(item.name)">{{ t('ng_backup', 'Select') }}</NcButton>
|
||||
</li>
|
||||
</ul>
|
||||
<NcButton v-if="restorable(path) && path !== selected" variant="tertiary" @click="selected = path">{{ t('ng_backup', 'Select this folder') }}</NcButton>
|
||||
|
||||
<div v-if="selected" class="ngb-restore">
|
||||
<p>{{ t('ng_backup', 'Selected: {path}', { path: selected }) }}</p>
|
||||
<NcCheckboxRadioSwitch v-for="m in modes" :key="m.id" v-model="mode" :value="m.id" type="radio" name="ngb-mode">{{ m.label }}</NcCheckboxRadioSwitch>
|
||||
<NcButton variant="primary" :disabled="busy" @click="restore">{{ t('ng_backup', 'Restore') }}</NcButton>
|
||||
</div>
|
||||
</template>
|
||||
<NcNoteCard v-if="message" type="success">{{ message }}</NcNoteCard>
|
||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||
</NcSettingsSection>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import api from '../api.js'
|
||||
import { formatSize } from '../format.js'
|
||||
|
||||
const props = defineProps({ status: { type: Object, required: true } })
|
||||
const emit = defineEmits(['changed'])
|
||||
|
||||
const target = ref(props.status.targets[0] ?? null)
|
||||
const snapshots = ref([])
|
||||
const snapshot = ref(null)
|
||||
const path = ref('data')
|
||||
const items = ref([])
|
||||
const loading = ref(false)
|
||||
const selected = ref('')
|
||||
const mode = ref('new-folder')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const message = ref('')
|
||||
const modes = [
|
||||
{ id: 'new-folder', label: t('ng_backup', 'Into a new folder "Restored <date>" (nothing existing is changed)') },
|
||||
{ id: 'merge', label: t('ng_backup', 'Into the original place, overwrite (old content kept as a version)') },
|
||||
{ id: 'replace', label: t('ng_backup', 'Into the original place, overwrite and move files that are not in the backup to the trash') },
|
||||
]
|
||||
|
||||
const snapshotLabel = (s) => new Date(s.createdAt * 1000).toLocaleString() + (s.label ? ' – ' + s.label : '')
|
||||
const join = (name) => path.value + '/' + name
|
||||
const restorable = (p) => /^data\/[^/]+\/files(\/.*)?$/.test(p)
|
||||
const crumbs = computed(() => {
|
||||
const parts = path.value.split('/').slice(1)
|
||||
return parts.map((name, i) => ({ name, path: ['data', ...parts.slice(0, i + 1)].join('/') }))
|
||||
})
|
||||
|
||||
watch(target, async (tg) => {
|
||||
snapshot.value = null
|
||||
snapshots.value = tg ? await api.snapshots(tg.id) : []
|
||||
snapshot.value = snapshots.value[0] ?? null
|
||||
}, { immediate: true })
|
||||
watch(snapshot, () => { selected.value = ''; open('data') })
|
||||
|
||||
async function open(p) {
|
||||
if (!snapshot.value) {
|
||||
return
|
||||
}
|
||||
path.value = p
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
items.value = await api.browse(target.value.id, snapshot.value.id, p)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
busy.value = true
|
||||
error.value = message.value = ''
|
||||
try {
|
||||
await api.startRestore(target.value.id, snapshot.value.id, selected.value, mode.value)
|
||||
message.value = t('ng_backup', 'Restore queued. It runs with the next cron job; progress is shown under Backups.')
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ngb-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.ngb-crumbs { display: flex; align-items: center; flex-wrap: wrap; }
|
||||
.ngb-entries li { display: flex; align-items: center; gap: 12px; min-height: 36px; }
|
||||
.ngb-entries li.selected { background: var(--color-primary-element-light); border-radius: var(--border-radius); }
|
||||
.ngb-file { padding-inline-start: 12px; }
|
||||
.ngb-muted { color: var(--color-text-maxcontrast); }
|
||||
.ngb-restore { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; max-width: 700px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user