d48e2b7d9d
Major features: - Full backup & restore system (JSON snapshots of all 20 tables + settings) - Web UI, REST API, OCC CLI commands, scheduled background job - Self-update from Gitea releases with Ed25519 signature verification - Configurable column visibility on all data tables (persisted via localStorage) Fixes: - NC admin group fallback for PermissionService (IGroupManager) - Bundle import inline error correction (editable error rows) New files: BackupService, BackupSettingsService, BackupController, BackupJob, SelfUpdateService, 4 OCC commands, ColumnPicker component, Backup.vue, Ed25519 signing scripts, signature verification tests (18 tests) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
312 lines
11 KiB
Vue
312 lines
11 KiB
Vue
<template>
|
|
<div class="backup-page">
|
|
<h2>Backup & Wiederherstellung</h2>
|
|
|
|
<!-- Messages -->
|
|
<div v-if="store.error" class="backup-page__message backup-page__message--error">
|
|
{{ store.error }}
|
|
<NcButton @click="store.clearError()">Schliessen</NcButton>
|
|
</div>
|
|
<div v-if="store.success" class="backup-page__message backup-page__message--success">
|
|
{{ store.success }}
|
|
<NcButton @click="store.clearSuccess()">Schliessen</NcButton>
|
|
</div>
|
|
|
|
<!-- App Update -->
|
|
<div class="backup-page__section">
|
|
<h3>App-Update</h3>
|
|
<div class="backup-page__update">
|
|
<div class="backup-page__update-status">
|
|
<span>Installierte Version: <strong>v{{ store.updateInfo?.currentVersion || '...' }}</strong></span>
|
|
<template v-if="store.updateInfo">
|
|
<template v-if="store.updateInfo.available">
|
|
<span class="backup-page__badge backup-page__badge--update">
|
|
v{{ store.updateInfo.latestVersion }} verfuegbar
|
|
</span>
|
|
</template>
|
|
<template v-else-if="store.updateInfo.latestVersion">
|
|
<span class="backup-page__badge">Aktuell</span>
|
|
</template>
|
|
<template v-else-if="store.updateInfo.error">
|
|
<span class="backup-page__badge backup-page__badge--none">
|
|
Pruefung fehlgeschlagen
|
|
</span>
|
|
</template>
|
|
</template>
|
|
</div>
|
|
<div v-if="store.updateInfo?.releaseBody" class="backup-page__update-notes">
|
|
{{ store.updateInfo.releaseBody }}
|
|
</div>
|
|
<div class="backup-page__update-actions">
|
|
<NcButton @click="store.checkForUpdate()">
|
|
Nach Updates suchen
|
|
</NcButton>
|
|
<NcButton v-if="store.updateInfo?.available"
|
|
type="primary"
|
|
:disabled="store.updating"
|
|
@click="confirmUpdate">
|
|
<NcLoadingIcon v-if="store.updating" :size="20" />
|
|
<template v-else>Update installieren</template>
|
|
</NcButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Schedule settings -->
|
|
<div class="backup-page__section">
|
|
<h3>Automatische Backups</h3>
|
|
<div class="backup-page__settings">
|
|
<div class="backup-page__setting">
|
|
<label for="backup-schedule">Zeitplan</label>
|
|
<select id="backup-schedule"
|
|
:value="store.settings.schedule"
|
|
class="backup-page__select"
|
|
@change="updateSchedule($event.target.value)">
|
|
<option value="disabled">Deaktiviert</option>
|
|
<option value="daily">Taeglich</option>
|
|
<option value="weekly">Woechentlich</option>
|
|
</select>
|
|
</div>
|
|
<div class="backup-page__setting">
|
|
<label for="backup-retention">Aufbewahrung (Anzahl)</label>
|
|
<input id="backup-retention"
|
|
type="number"
|
|
min="1"
|
|
max="100"
|
|
:value="store.settings.retentionCount"
|
|
class="backup-page__input"
|
|
@change="updateRetention(parseInt($event.target.value))">
|
|
</div>
|
|
<div class="backup-page__setting">
|
|
<label>Automatisches Passwort</label>
|
|
<span v-if="store.settings.hasPassword" class="backup-page__badge">Gesetzt</span>
|
|
<span v-else class="backup-page__badge backup-page__badge--none">Nicht gesetzt</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Manual backup -->
|
|
<div class="backup-page__section">
|
|
<h3>Manuelles Backup</h3>
|
|
<div class="backup-page__create">
|
|
<div class="backup-page__setting">
|
|
<label for="backup-password">Passwort (optional)</label>
|
|
<input id="backup-password"
|
|
v-model="createPassword"
|
|
type="password"
|
|
placeholder="Ohne Passwort"
|
|
class="backup-page__input">
|
|
</div>
|
|
<NcButton type="primary"
|
|
:disabled="store.creating"
|
|
@click="doCreate">
|
|
<NcLoadingIcon v-if="store.creating" :size="20" />
|
|
<template v-else>Backup erstellen</template>
|
|
</NcButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Backup list -->
|
|
<div class="backup-page__section">
|
|
<h3>Vorhandene Backups</h3>
|
|
|
|
<NcLoadingIcon v-if="store.loading && store.backups.length === 0"
|
|
:size="48"
|
|
class="backup-page__loading" />
|
|
|
|
<div v-else-if="store.backups.length === 0" class="backup-page__empty">
|
|
Keine Backups vorhanden.
|
|
</div>
|
|
|
|
<table v-else class="backup-page__table">
|
|
<thead>
|
|
<tr>
|
|
<th>Dateiname</th>
|
|
<th>Erstellt</th>
|
|
<th>Groesse</th>
|
|
<th>Tabellen</th>
|
|
<th>Zeilen</th>
|
|
<th>Ausloser</th>
|
|
<th>Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="backup in store.backups" :key="backup.filename">
|
|
<td>{{ backup.filename }}</td>
|
|
<td>{{ formatDateTime(backup.createdAt) }}</td>
|
|
<td>{{ formatBytes(backup.size) }}</td>
|
|
<td>{{ Object.keys(backup.tables || {}).length }}</td>
|
|
<td>{{ totalRows(backup) }}</td>
|
|
<td>{{ formatTrigger(backup.triggeredBy) }}</td>
|
|
<td class="backup-page__actions">
|
|
<NcButton @click="store.downloadBackup(backup.filename)">
|
|
Download
|
|
</NcButton>
|
|
<NcButton type="warning"
|
|
:disabled="store.restoring"
|
|
@click="confirmRestore(backup)">
|
|
Wiederherstellen
|
|
</NcButton>
|
|
<NcButton type="error"
|
|
@click="confirmDelete(backup)">
|
|
Loeschen
|
|
</NcButton>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Restore confirmation dialog -->
|
|
<div v-if="restoreTarget" class="backup-page__dialog-backdrop">
|
|
<div class="backup-page__dialog">
|
|
<h3>Backup wiederherstellen</h3>
|
|
<p>
|
|
<strong>WARNUNG:</strong> Alle bestehenden Daten werden geloescht und durch
|
|
das Backup <strong>{{ restoreTarget.filename }}</strong> ersetzt.
|
|
</p>
|
|
<p>Erstellt: {{ formatDateTime(restoreTarget.createdAt) }}</p>
|
|
<div class="backup-page__setting">
|
|
<label for="restore-password">Passwort (falls verschluesselt)</label>
|
|
<input id="restore-password"
|
|
v-model="restorePassword"
|
|
type="password"
|
|
placeholder="Ohne Passwort"
|
|
class="backup-page__input">
|
|
</div>
|
|
<div class="backup-page__dialog-actions">
|
|
<NcButton @click="restoreTarget = null">Abbrechen</NcButton>
|
|
<NcButton type="error"
|
|
:disabled="store.restoring"
|
|
@click="doRestore">
|
|
<NcLoadingIcon v-if="store.restoring" :size="20" />
|
|
<template v-else>Jetzt wiederherstellen</template>
|
|
</NcButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { NcButton, NcLoadingIcon } from '@nextcloud/vue'
|
|
import { useBackupStore } from '../stores/backup.js'
|
|
|
|
const store = useBackupStore()
|
|
|
|
const createPassword = ref('')
|
|
const restoreTarget = ref(null)
|
|
const restorePassword = ref('')
|
|
|
|
onMounted(() => {
|
|
store.fetchBackups()
|
|
store.fetchSettings()
|
|
store.checkForUpdate()
|
|
})
|
|
|
|
async function doCreate() {
|
|
await store.createBackup(createPassword.value || null)
|
|
createPassword.value = ''
|
|
}
|
|
|
|
function confirmRestore(backup) {
|
|
restoreTarget.value = backup
|
|
restorePassword.value = ''
|
|
}
|
|
|
|
async function doRestore() {
|
|
const filename = restoreTarget.value.filename
|
|
restoreTarget.value = null
|
|
await store.restoreBackup(filename, restorePassword.value || null)
|
|
}
|
|
|
|
async function confirmUpdate() {
|
|
if (confirm('Update installieren? Die App-Dateien werden ersetzt. Danach muss ggf. "occ upgrade" ausgefuehrt werden.')) {
|
|
await store.installUpdate(false)
|
|
}
|
|
}
|
|
|
|
function confirmDelete(backup) {
|
|
if (confirm(`Backup "${backup.filename}" wirklich loeschen?`)) {
|
|
store.deleteBackup(backup.filename)
|
|
}
|
|
}
|
|
|
|
function updateSchedule(value) {
|
|
store.updateSettings({ schedule: value })
|
|
}
|
|
|
|
function updateRetention(value) {
|
|
if (value >= 1) {
|
|
store.updateSettings({ retentionCount: value })
|
|
}
|
|
}
|
|
|
|
function formatDateTime(isoString) {
|
|
if (!isoString) return '-'
|
|
const d = new Date(isoString)
|
|
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (!bytes) return '-'
|
|
if (bytes >= 1048576) return (bytes / 1048576).toFixed(2) + ' MB'
|
|
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
|
return bytes + ' B'
|
|
}
|
|
|
|
function totalRows(backup) {
|
|
if (!backup.tables) return 0
|
|
return Object.values(backup.tables).reduce((sum, n) => sum + n, 0)
|
|
}
|
|
|
|
function formatTrigger(trigger) {
|
|
const map = { manual: 'Manuell', scheduled: 'Geplant', cli: 'CLI' }
|
|
return map[trigger] || trigger || '-'
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.backup-page { padding: 20px; max-width: 1200px; }
|
|
.backup-page h2 { margin: 0 0 20px 0; }
|
|
.backup-page__section { margin-bottom: 32px; }
|
|
.backup-page__section h3 { margin: 0 0 12px 0; }
|
|
|
|
.backup-page__message { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-radius: var(--border-radius); margin-bottom: 16px; color: white; }
|
|
.backup-page__message--error { background: var(--color-error); }
|
|
.backup-page__message--success { background: var(--color-success); }
|
|
|
|
.backup-page__settings { display: flex; gap: 24px; flex-wrap: wrap; align-items: flex-end; padding: 12px 16px; background: var(--color-background-dark); border-radius: var(--border-radius-large); }
|
|
.backup-page__setting { display: flex; flex-direction: column; gap: 4px; }
|
|
.backup-page__setting label { font-size: 0.8em; color: var(--color-text-lighter); font-weight: 500; }
|
|
.backup-page__select { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-main-background); }
|
|
.backup-page__input { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-main-background); }
|
|
|
|
.backup-page__badge { display: inline-block; padding: 2px 8px; border-radius: var(--border-radius-pill); font-size: 0.85em; font-weight: 500; background: var(--color-success); color: white; }
|
|
.backup-page__badge--none { background: var(--color-text-lighter); }
|
|
.backup-page__badge--update { background: var(--color-primary); }
|
|
|
|
.backup-page__update { padding: 12px 16px; background: var(--color-background-dark); border-radius: var(--border-radius-large); }
|
|
.backup-page__update-status { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
|
.backup-page__update-notes { font-size: 0.9em; color: var(--color-text-lighter); margin-bottom: 12px; white-space: pre-line; }
|
|
.backup-page__update-actions { display: flex; gap: 8px; }
|
|
|
|
.backup-page__create { display: flex; gap: 16px; align-items: flex-end; }
|
|
.backup-page__loading { display: flex; justify-content: center; margin-top: 40px; }
|
|
.backup-page__empty { color: var(--color-text-lighter); padding: 24px; text-align: center; }
|
|
|
|
.backup-page__table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
|
|
.backup-page__table th { text-align: left; padding: 10px 12px; border-bottom: 2px solid var(--color-border-dark); font-weight: bold; white-space: nowrap; }
|
|
.backup-page__table td { padding: 8px 12px; border-bottom: 1px solid var(--color-border); vertical-align: middle; }
|
|
.backup-page__table tr:hover { background: var(--color-background-hover); }
|
|
.backup-page__actions { white-space: nowrap; }
|
|
.backup-page__actions :deep(.button-vue) { display: inline-flex; margin: 2px; }
|
|
|
|
.backup-page__dialog-backdrop { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
|
.backup-page__dialog { background: var(--color-main-background); padding: 24px; border-radius: var(--border-radius-large); max-width: 520px; width: 90%; box-shadow: 0 4px 24px rgba(0,0,0,0.2); }
|
|
.backup-page__dialog h3 { margin: 0 0 12px 0; }
|
|
.backup-page__dialog p { margin: 0 0 12px 0; }
|
|
.backup-page__dialog-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
|
|
</style>
|