feat(backup): admin and account UI for server backups, import by section
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m44s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m44s
Admin settings: switch, time, retention, list with download and a "Jetzt sichern" button. Account settings: own server backups with download and restore (replace or add, for chosen sections, replace needs a second tap). The MindSift import gets the same section picker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c97765e08e
commit
64e077dd57
13 files changed
+446
-18
No files matched your search
@@ -41,6 +41,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
||||
| — | `/import/mindsift?mode=restore` | Wiederherstellen: löscht vor dem Import alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen (erst nachdem die Datei vollständig gelesen und geprüft ist) |
|
||||
| DELETE | `/user/data` | Alle eigenen Daten löschen, Konto bleibt (`{"password"}` nötig) |
|
||||
| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` |
|
||||
| GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen |
|
||||
| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` |
|
||||
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) |
|
||||
|
||||
### Globale Suche (`GET /api/v1/search?q=`)
|
||||
@@ -189,6 +191,8 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
||||
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben |
|
||||
| GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) |
|
||||
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
|
||||
| GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) |
|
||||
| GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen |
|
||||
|
||||
## Beispiele
|
||||
|
||||
|
||||
@@ -106,6 +106,14 @@ Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: D
|
||||
|
||||
Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`).
|
||||
|
||||
## Hintergrundjob: Backups
|
||||
|
||||
[backups.go](../internal/api/backups.go) / [backup.go](../internal/storage/backup.go): Ist „Automatische Backups“ in den Admin-Einstellungen an, läuft einmal täglich zur eingestellten Uhrzeit (`backup_time`, Zeitzone `TZ`; nach einem Neustart wird ein verpasster Lauf am selben Tag nachgeholt):
|
||||
- `backups/system/mindsift-db-JJJJ-MM-TT-HHMM.db.gz`: Abzug der ganzen Datenbank per `VACUUM INTO` (stimmig, auch während geschrieben wird), gzip.
|
||||
- `backups/users/<id>/mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto.
|
||||
- Danach werden Dateien älter als `backup_retention_days` (Standard 3) gelöscht. Dateien entstehen über `.part` und Umbenennen, halbe Backups tauchen nie in der Liste auf. Die API liefert nur Dateien mit genau diesem Namensmuster aus.
|
||||
- Im Demo-Modus läuft kein Zeitplan. Zurückspielen eines Datenbank-Abzugs: Container stoppen, `mindsift.db` durch die entpackte Datei ersetzen, starten. Benutzer spielen ihre eigenen Backups in den Kontoeinstellungen ein, auch nur für einzelne Bereiche (Notizen, Aufgaben, Listen, Inbox, Themes).
|
||||
|
||||
## Offline (PWA)
|
||||
|
||||
- **Service Worker** ([public/sw.js](../frontend/public/sw.js)): Seitenaufrufe Network-First mit der zuletzt geladenen `index.html` als Rückfall, Bundles unter `/assets/` (Hash im Namen) aus dem Cache, Icons und Manifest Stale-While-Revalidate. API-Anfragen fasst er nie an. Alte Caches verschwinden beim Aktivieren einer neuen `VERSION`, Bundles sind auf 80 Einträge begrenzt.
|
||||
|
||||
@@ -166,7 +166,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
||||
| `DEMO_RESET_MINUTES` | `20` | Nur im Demo-Modus: setzt die Datenbank in diesem Abstand auf die Beispieldaten zurück (`0` = nie). Angemeldete bleiben angemeldet, die App zeigt einen Countdown und lädt danach neu. |
|
||||
|
||||
* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
|
||||
* **Datensicherung:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer.
|
||||
* **Datensicherung:** In den Admin-Einstellungen lassen sich automatische tägliche Backups einschalten (ganze Datenbank plus ein Backup je Benutzer unter `data/backups/`, Standard 3 Tage aufbewahrt). Benutzer laden ihre Server-Backups herunter oder spielen sie ganz oder für einzelne Bereiche wieder ein. Der Ordner liegt neben der Datenbank: zusätzlich woanders sichern. Manuell geht weiterhin `sqlite3 mindsift.db ".backup 'backup.db'"`.
|
||||
* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen.
|
||||
* **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main`, `:stable` = zuletzt veröffentlichte Version. Ein Git-Tag wie `v1.2.0` erzeugt `:v1.2.0` und verschiebt `:stable` darauf.
|
||||
|
||||
|
||||
@@ -630,6 +630,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
<li>
|
||||
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern & aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Server-Backups:</strong> Hat die Administration automatische Backups eingeschaltet, findest du unter <em>Kontoeinstellungen → Private Webhooks & Export</em> die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Ohne Internet:</strong> Als App installiert (oder im Browser) startet MindSift auch offline mit dem zuletzt geladenen Stand. Gedanken erfassen sowie Aufgaben und Listeneinträge abhaken geht weiter; ein Hinweis unten zeigt, wie viele Änderungen warten, und sie werden automatisch übertragen, sobald die Verbindung zurück ist.
|
||||
</li>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api';
|
||||
import { passwordProblem } from '../../lib/password';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
import { BackupAdminCard } from './BackupAdminCard';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
|
||||
interface AdminSettingsViewProps {
|
||||
@@ -135,6 +136,9 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
smtpSkipVerify: Boolean(sys.smtpSkipVerify),
|
||||
allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks),
|
||||
smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv),
|
||||
backupEnabled: Boolean(sys.backupEnabled),
|
||||
backupTime: sys.backupTime || '03:00',
|
||||
backupRetentionDays: sys.backupRetentionDays || 3,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -1093,6 +1097,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BackupAdminCard settings={settings} onSave={(patch) => handleSaveSystemSettings(patch)} />
|
||||
|
||||
{/* Alarm webhooks into the local network (SSRF protection) */}
|
||||
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
||||
<div className="pr-4">
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Archive, Download, Loader2 } from 'lucide-react';
|
||||
import { BackupFile, SystemSettings } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
import { formatBytes, saveBlob } from '../../lib/download';
|
||||
|
||||
interface BackupAdminCardProps {
|
||||
settings: SystemSettings;
|
||||
onSave: (patch: Partial<SystemSettings>) => void;
|
||||
}
|
||||
|
||||
// Admin: automatic daily backups (whole database + one backup per user), list and manual run
|
||||
export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSave }) => {
|
||||
const [files, setFiles] = useState<BackupFile[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.adminListBackups().then(setFiles).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const runNow = async () => {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
setFiles(await api.adminRunBackup());
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Backup fehlgeschlagen');
|
||||
api.adminListBackups().then(setFiles).catch(() => {});
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const download = async (name: string) => {
|
||||
try {
|
||||
saveBlob(await api.adminDownloadBackup(name), name);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Download fehlgeschlagen');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="pr-4">
|
||||
<p className="font-bold text-main flex items-center gap-1.5">
|
||||
<Archive className="w-3.5 h-3.5 text-accent" />
|
||||
Automatische Backups
|
||||
</p>
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
Täglich ein Abzug der ganzen Datenbank und ein Backup je Benutzer unter <code className="font-mono">backups/</code> im
|
||||
Datenverzeichnis. Das schützt vor Fehlbedienung, nicht vor einer kaputten Platte: den Ordner zusätzlich woanders sichern.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={Boolean(settings.backupEnabled)}
|
||||
onChange={(backupEnabled) => onSave({ backupEnabled })}
|
||||
title={settings.backupEnabled ? 'Automatische Backups ausschalten' : 'Automatische Backups einschalten'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="text-[11px] text-muted">
|
||||
Uhrzeit
|
||||
<input
|
||||
type="time"
|
||||
value={settings.backupTime || '03:00'}
|
||||
onChange={(e) => e.target.value && onSave({ backupTime: e.target.value })}
|
||||
className="block mt-1 h-11 sm:h-8 px-2 input-field text-base sm:text-xs w-28"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-[11px] text-muted">
|
||||
Aufbewahren (Tage)
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={settings.backupRetentionDays || 3}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
if (n >= 1 && n <= 365) onSave({ backupRetentionDays: n });
|
||||
}}
|
||||
className="block mt-1 h-11 sm:h-8 px-2 input-field text-base sm:text-xs w-24"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runNow}
|
||||
disabled={running}
|
||||
className="btn-action h-11 sm:h-8 px-3 text-xs inline-flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
{running ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Archive className="w-3.5 h-3.5 text-accent" />}
|
||||
{running ? 'Sichere…' : 'Jetzt sichern'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-[11px] text-status-err">{error}</p>}
|
||||
|
||||
<div className="border border-border divide-y divide-border bg-panel">
|
||||
{files.length === 0 ? (
|
||||
<p className="p-3 text-[11px] text-faint">Noch keine Server-Backups vorhanden.</p>
|
||||
) : (
|
||||
files.map((f) => (
|
||||
<div key={f.name} className="px-3 py-2 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-mono text-main truncate">{f.name}</p>
|
||||
<p className="text-[11px] text-faint">
|
||||
{new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download(f.name)}
|
||||
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1 shrink-0"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-faint">
|
||||
Zurückspielen eines Datenbank-Abzugs: Container stoppen, <code className="font-mono">mindsift.db</code> durch die entpackte Datei
|
||||
ersetzen (<code className="font-mono">gunzip</code>), Container starten. Einzelne Benutzer stellen ihre Daten selbst in den
|
||||
Kontoeinstellungen wieder her.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Upload, Check, AlertCircle, FileArchive } from 'lucide-react';
|
||||
import { ImportSummary } from '../../lib/types';
|
||||
import { DATA_SECTIONS, DataSection, ImportSummary } from '../../lib/types';
|
||||
import { SectionPicker } from './SectionPicker';
|
||||
import { api } from '../../lib/api';
|
||||
|
||||
interface ImportCardProps {
|
||||
@@ -18,6 +19,7 @@ const summaryParts = (s: ImportSummary) =>
|
||||
s.tasks && `${s.tasks} Aufgabe${s.tasks === 1 ? '' : 'n'}`,
|
||||
s.lists && `${s.lists} Liste${s.lists === 1 ? '' : 'n'} mit ${s.listItems} Einträgen`,
|
||||
s.inbox && `${s.inbox} Gedanke${s.inbox === 1 ? '' : 'n'} für die Triage`,
|
||||
s.themes && `${s.themes} Theme${s.themes === 1 ? '' : 's'}`,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
// One import path: drop or pick a file, see a preview of what would be created, then import.
|
||||
@@ -33,6 +35,9 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
||||
// MindSift data can replace the current state instead of being added next to it
|
||||
const [mode, setMode] = useState<'merge' | 'restore'>('merge');
|
||||
const [confirmRestore, setConfirmRestore] = useState(false);
|
||||
// MindSift data: which parts to take from the file
|
||||
const [sections, setSections] = useState<DataSection[]>(DATA_SECTIONS.map((s) => s.id));
|
||||
const sectionParam = source === 'mindsift' ? sections : undefined;
|
||||
const restoring = source === 'mindsift' && mode === 'restore';
|
||||
|
||||
const reset = () => {
|
||||
@@ -49,7 +54,7 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
||||
setFile(f);
|
||||
setBusy(true);
|
||||
try {
|
||||
setPreview(await api.importData(source, f, true));
|
||||
setPreview(await api.importData(source, f, true, 'merge', sectionParam));
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Datei konnte nicht gelesen werden');
|
||||
} finally {
|
||||
@@ -62,7 +67,7 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge'));
|
||||
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge', sectionParam));
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setConfirmRestore(false);
|
||||
@@ -87,7 +92,7 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
||||
{(
|
||||
[
|
||||
['merge', 'Hinzufügen', 'Bestehende Daten behalten, Backup daneben anlegen.'],
|
||||
['restore', 'Wiederherstellen', 'Eigene Notizen, Aufgaben und Listen vorher löschen.'],
|
||||
['restore', 'Wiederherstellen', 'Gewählte Bereiche vorher leeren.'],
|
||||
] as const
|
||||
).map(([value, label, text]) => (
|
||||
<button
|
||||
@@ -114,6 +119,24 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
||||
</div>
|
||||
)}
|
||||
|
||||
{source === 'mindsift' && (
|
||||
<SectionPicker
|
||||
value={sections}
|
||||
onChange={(next) => {
|
||||
setSections(next);
|
||||
// The preview depends on the sections, so a picked file is read again
|
||||
if (file) {
|
||||
setBusy(true);
|
||||
api
|
||||
.importData(source, file, true, 'merge', next)
|
||||
.then(setPreview)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setBusy(false));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -194,8 +217,9 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
||||
className="mt-0.5 w-4 h-4 shrink-0"
|
||||
/>
|
||||
<span>
|
||||
<strong className="text-status-err">Achtung:</strong> Alle deine bisherigen Notizen, Ordner, Aufgaben, Gedanken und
|
||||
Listen werden vor dem Import unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer
|
||||
<strong className="text-status-err">Achtung:</strong> Deine bisherigen Daten in den gewählten Bereichen
|
||||
({DATA_SECTIONS.filter((x) => sections.includes(x.id)).map((x) => x.label).join(', ')}) werden vor dem Import
|
||||
unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer
|
||||
bleiben erhalten.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { DATA_SECTIONS, DataSection } from '../../lib/types';
|
||||
|
||||
interface SectionPickerProps {
|
||||
value: DataSection[];
|
||||
onChange: (sections: DataSection[]) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// Which parts of the own data a restore or import covers; at least one stays selected
|
||||
export const SectionPicker: React.FC<SectionPickerProps> = ({ value, onChange, label = 'Bereiche' }) => (
|
||||
<div className="flex flex-wrap items-center gap-1.5" role="group" aria-label={label}>
|
||||
<span className="text-[11px] text-muted mr-1">{label}:</span>
|
||||
{DATA_SECTIONS.map((sec) => {
|
||||
const on = value.includes(sec.id);
|
||||
return (
|
||||
<button
|
||||
key={sec.id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
onClick={() => {
|
||||
const next = on ? value.filter((v) => v !== sec.id) : [...value, sec.id];
|
||||
if (next.length > 0) onChange(next);
|
||||
}}
|
||||
className={`h-11 sm:h-7 px-2.5 rounded-btn border text-[11px] inline-flex items-center gap-1 transition-colors ${
|
||||
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="w-3 h-3 text-accent" />}
|
||||
{sec.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Archive, Check, Download, RotateCcw } from 'lucide-react';
|
||||
import { BackupFile, DataSection, DATA_SECTIONS, ImportSummary } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { formatBytes, saveBlob } from '../../lib/download';
|
||||
import { SectionPicker } from './SectionPicker';
|
||||
|
||||
// The user's own automatic backups on the server: download or restore (all or some sections)
|
||||
export const ServerBackupsCard: React.FC = () => {
|
||||
const [files, setFiles] = useState<BackupFile[]>([]);
|
||||
const [open, setOpen] = useState<string | null>(null); // backup being restored
|
||||
const [mode, setMode] = useState<'merge' | 'restore'>('restore');
|
||||
const [sections, setSections] = useState<DataSection[]>(DATA_SECTIONS.map((s) => s.id));
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<ImportSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.listMyBackups().then(setFiles).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const startRestore = (name: string) => {
|
||||
setOpen(open === name ? null : name);
|
||||
setConfirm(false);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const restore = async () => {
|
||||
if (!open) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setResult(await api.restoreMyBackup(open, mode, sections));
|
||||
setConfirm(false);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Wiederherstellen fehlgeschlagen');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sectionNames = DATA_SECTIONS.filter((s) => sections.includes(s.id))
|
||||
.map((s) => s.label)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||||
<Archive className="w-3.5 h-3.5 text-accent" />
|
||||
Server-Backups
|
||||
</h3>
|
||||
<p className="text-muted text-[11px] mt-0.5">
|
||||
Automatische tägliche Sicherungen deiner Daten auf dem Server (falls die Administration sie eingeschaltet hat).
|
||||
</p>
|
||||
</div>
|
||||
<div className="border border-border divide-y divide-border bg-canvas">
|
||||
{files.length === 0 ? (
|
||||
<p className="p-3 text-[11px] text-faint">Noch keine Server-Backups vorhanden.</p>
|
||||
) : (
|
||||
files.map((f) => (
|
||||
<div key={f.name} className="p-3 space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-main">{new Date(f.createdAt).toLocaleString('de-DE')}</p>
|
||||
<p className="text-[11px] text-faint">{formatBytes(f.size)}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => saveBlob(await api.downloadMyBackup(f.name), f.name)}
|
||||
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startRestore(f.name)}
|
||||
aria-expanded={open === f.name}
|
||||
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
||||
Wiederherstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open === f.name && (
|
||||
<div className="p-3 rounded-card border border-border bg-panel space-y-2.5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2" role="radiogroup" aria-label="Art der Wiederherstellung">
|
||||
{(
|
||||
[
|
||||
['restore', 'Ersetzen', 'Gewählte Bereiche vorher leeren.'],
|
||||
['merge', 'Hinzufügen', 'Bestehendes behalten, Backup daneben anlegen.'],
|
||||
] as const
|
||||
).map(([value, label, text]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={mode === value}
|
||||
onClick={() => {
|
||||
setMode(value);
|
||||
setConfirm(false);
|
||||
}}
|
||||
className={`p-2 min-h-[44px] text-left border rounded-card transition-colors ${
|
||||
mode === value ? (value === 'restore' ? 'border-status-err bg-status-err/10' : 'border-accent bg-accent/10') : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-xs font-semibold text-main">{label}</span>
|
||||
<span className="block text-[11px] text-muted">{text}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SectionPicker value={sections} onChange={setSections} />
|
||||
{result ? (
|
||||
<p className="text-[11px] text-status-ok flex items-center gap-1">
|
||||
<Check className="w-3.5 h-3.5" /> Wiederhergestellt: {result.notes} Notizen, {result.tasks} Aufgaben, {result.lists} Listen,{' '}
|
||||
{result.inbox} Gedanken, {result.themes ?? 0} Themes.
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => (mode === 'restore' && !confirm ? setConfirm(true) : restore())}
|
||||
className={`h-11 sm:h-8 px-4 text-xs font-semibold disabled:opacity-50 ${
|
||||
mode === 'restore' ? 'btn-action border-status-err text-status-err' : 'btn-primary'
|
||||
}`}
|
||||
>
|
||||
{busy
|
||||
? 'Stelle wieder her…'
|
||||
: mode === 'restore' && confirm
|
||||
? `Wirklich ${sectionNames} ersetzen?`
|
||||
: mode === 'restore'
|
||||
? 'Ersetzen'
|
||||
: 'Hinzufügen'}
|
||||
</button>
|
||||
)}
|
||||
{error && <p className="text-[11px] text-status-err">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { AuthStatus, User, UserTheme } from '../../lib/types';
|
||||
import { ApiKeysPanel } from './ApiKeysPanel';
|
||||
import { ServerBackupsCard } from './ServerBackupsCard';
|
||||
import { saveBlob } from '../../lib/download';
|
||||
import { api, clearToken, triggerHaptic } from '../../lib/api';
|
||||
import { passwordProblem } from '../../lib/password';
|
||||
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
|
||||
@@ -363,16 +365,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
|
||||
// --- Handlers: Export ---
|
||||
// Works on mobile browsers too: the link must be in the document and the URL alive until the click ran
|
||||
const saveBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
};
|
||||
|
||||
const [exportingBackup, setExportingBackup] = useState(false);
|
||||
const handleExportData = async () => {
|
||||
@@ -1166,6 +1158,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
|
||||
<ApiKeysPanel />
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<ServerBackupsCard />
|
||||
</div>
|
||||
|
||||
{/* Full Data Backup */}
|
||||
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
|
||||
+32
-1
@@ -4,6 +4,8 @@ import {
|
||||
DashboardStats,
|
||||
Document,
|
||||
TrashedDocument,
|
||||
BackupFile,
|
||||
DataSection,
|
||||
ApiKeyScope,
|
||||
Backlink,
|
||||
DocumentBlock,
|
||||
@@ -1155,7 +1157,8 @@ export const api = {
|
||||
source: 'mindsift' | 'obsidian',
|
||||
file: File,
|
||||
preview: boolean,
|
||||
mode: 'merge' | 'restore' = 'merge'
|
||||
mode: 'merge' | 'restore' = 'merge',
|
||||
sections?: DataSection[]
|
||||
): Promise<ImportSummary> {
|
||||
const token = getToken();
|
||||
const form = new FormData();
|
||||
@@ -1163,6 +1166,7 @@ export const api = {
|
||||
const params = new URLSearchParams();
|
||||
if (preview) params.set('preview', '1');
|
||||
if (mode === 'restore') params.set('mode', 'restore');
|
||||
if (sections && sections.length > 0) params.set('sections', sections.join(','));
|
||||
const res = await fetch(`/api/v1/import/${source}${params.toString() ? `?${params}` : ''}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
@@ -1173,6 +1177,33 @@ export const api = {
|
||||
return data as ImportSummary;
|
||||
},
|
||||
|
||||
// --- Automatic server backups ---
|
||||
async adminListBackups(): Promise<BackupFile[]> {
|
||||
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups'));
|
||||
},
|
||||
|
||||
async adminRunBackup(): Promise<BackupFile[]> {
|
||||
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups', { method: 'POST' }));
|
||||
},
|
||||
|
||||
async adminDownloadBackup(name: string): Promise<Blob> {
|
||||
return downloadBlob(`/api/v1/admin/backups/${encodeURIComponent(name)}`);
|
||||
},
|
||||
|
||||
async listMyBackups(): Promise<BackupFile[]> {
|
||||
return ensureArray(await request<BackupFile[]>('/api/v1/backups'));
|
||||
},
|
||||
|
||||
async downloadMyBackup(name: string): Promise<Blob> {
|
||||
return downloadBlob(`/api/v1/backups/${encodeURIComponent(name)}`);
|
||||
},
|
||||
|
||||
async restoreMyBackup(name: string, mode: 'merge' | 'restore', sections: DataSection[], preview = false): Promise<ImportSummary> {
|
||||
const params = new URLSearchParams({ mode, sections: sections.join(',') });
|
||||
if (preview) params.set('preview', '1');
|
||||
return request<ImportSummary>(`/api/v1/backups/${encodeURIComponent(name)}/restore?${params}`, { method: 'POST' });
|
||||
},
|
||||
|
||||
// Danger zone: both need the current password
|
||||
async purgeOwnData(password: string): Promise<void> {
|
||||
await request('/api/v1/user/data', { method: 'DELETE', body: JSON.stringify({ password }) });
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Saves a downloaded file. Works on mobile browsers too: the link must be in the document and the
|
||||
// object URL alive until the click ran.
|
||||
export function saveBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
}
|
||||
|
||||
export const formatBytes = (n: number): string =>
|
||||
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
@@ -186,8 +186,29 @@ export interface SystemSettings {
|
||||
smtpSkipVerify?: boolean;
|
||||
allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http)
|
||||
smtpConfiguredViaEnv?: boolean;
|
||||
// Automatic backups: daily time (HH:MM, server time zone) and days to keep them
|
||||
backupEnabled?: boolean;
|
||||
backupTime?: string;
|
||||
backupRetentionDays?: number;
|
||||
}
|
||||
|
||||
/** One file of a server backup listing. */
|
||||
export interface BackupFile {
|
||||
name: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Parts of the own data a backup can be restored for. */
|
||||
export type DataSection = 'notes' | 'tasks' | 'lists' | 'inbox' | 'themes';
|
||||
export const DATA_SECTIONS: { id: DataSection; label: string }[] = [
|
||||
{ id: 'notes', label: 'Notizen' },
|
||||
{ id: 'tasks', label: 'Aufgaben' },
|
||||
{ id: 'lists', label: 'Listen' },
|
||||
{ id: 'inbox', label: 'Inbox' },
|
||||
{ id: 'themes', label: 'Themes' },
|
||||
];
|
||||
|
||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
|
||||
|
||||
export interface DocumentBlock {
|
||||
@@ -464,6 +485,7 @@ export interface ImportSummary {
|
||||
lists: number;
|
||||
listItems: number;
|
||||
inbox: number;
|
||||
themes?: number;
|
||||
skipped: { path: string; reason: string }[];
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user