Merge branch 'feature/database-backup'

This commit is contained in:
2026-04-02 23:16:24 +03:00
24 changed files with 1079 additions and 1183 deletions
+40
View File
@@ -0,0 +1,40 @@
# Feature Context: Database Backup & Restore
## Configuration
- **Development mode:** Automated
- **Execution mode:** Direct
- **Strategy:** Big Bang
- **Build:** `npm run build`
- **Test:** `npm run test`
- **Lint:** `npm run lint`
- **Dev server:** `npm run dev` (port: 5173)
## Current State
Starting implementation. Existing import/export code is still in place — will be removed in Phase 4.
## Tech Stack
- SvelteKit 5 + TypeScript
- Prisma ORM with SQLite (`data/launcher.db`)
- node-cron (already a dependency, used by healthcheckScheduler)
- Zod validation
- Tailwind CSS + bits-ui components
- svelte-i18n for localization
- lucide-svelte for icons
## Key Patterns from Codebase
- Services in `src/lib/server/services/` — pure functions, Prisma transactions
- API routes follow SvelteKit conventions with admin auth guards
- Scheduler pattern: `src/lib/server/jobs/healthcheckScheduler.ts` — cron start/stop, wired in hooks.server.ts
- Admin components in `src/lib/components/admin/`
- Audit logging via `auditLogService.ts`
## Cross-Phase Dependencies
- Phase 2 depends on Phase 1 (backupService)
- Phase 3 depends on Phases 1+2 (API endpoints)
- Phase 4 is independent cleanup
## Implementation Notes
- Using `VACUUM INTO` for safe backup creation (no locking)
- Backups stored in `data/backups/` directory
- Restore requires Prisma disconnect → file swap → reconnect
- SystemSettings gets new fields: backupEnabled, backupCronExpression, backupMaxCount
+39
View File
@@ -0,0 +1,39 @@
# Feature: Database Backup & Restore
**Branch:** `feature/database-backup`
**Base branch:** `master`
**Created:** 2026-04-02
**Status:** 🟡 In Progress
**Strategy:** Big Bang
**Mode:** Automated
**Execution:** Direct
## Summary
Replace JSON import/export with SQLite file-copy backup system. Support manual on-demand backups, optional periodic scheduling via node-cron, configurable retention (max backup count), download capability, and full database restore.
## Build & Test Commands
- **Build:** `npm run build`
- **Test:** `npm run test`
- **Lint:** `npm run lint`
## Phases
- [ ] Phase 1: Backup Service & API Endpoints [domain: backend] → [subplan](./phase-1-backup-service.md)
- [ ] Phase 2: Periodic Backup Scheduler [domain: backend] → [subplan](./phase-2-backup-scheduler.md)
- [ ] Phase 3: Frontend BackupPanel [domain: frontend] → [subplan](./phase-3-backup-panel.md)
- [ ] Phase 4: Cleanup Import/Export [domain: fullstack] → [subplan](./phase-4-cleanup.md)
## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 1: Backup Service & API | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 2: Backup Scheduler | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 3: BackupPanel UI | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 4: Cleanup | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
## Final Review
- [ ] Comprehensive code review
- [ ] Full build passes
- [ ] Full test suite passes
- [ ] Merged to `master`
+3
View File
@@ -201,6 +201,9 @@ model SystemSettings {
healthcheckDefaults String @default("{}") // JSON stored as string for SQLite
customCss String?
onboardingComplete Boolean @default(false)
backupEnabled Boolean @default(false)
backupCronExpression String @default("0 3 * * *") // default: daily at 3 AM
backupMaxCount Int @default(10)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+4
View File
@@ -6,6 +6,10 @@ import * as userService from '$lib/server/services/userService.js';
import * as apiTokenService from '$lib/server/services/apiTokenService.js';
import { extractBearerToken } from '$lib/server/middleware/authenticate.js';
import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
import { initBackupScheduler } from '$lib/server/jobs/backupScheduler.js';
// Initialize backup scheduler on server startup
initBackupScheduler();
const PUBLIC_PATHS = ['/login', '/register', '/auth/', '/api/health', '/api/onboarding', '/status'];
+428
View File
@@ -0,0 +1,428 @@
<script lang="ts">
import { untrack } from 'svelte';
import { t } from 'svelte-i18n';
interface BackupInfo {
filename: string;
size: number;
createdAt: string;
}
interface BackupSchedule {
backupEnabled: boolean;
backupCronExpression: string;
backupMaxCount: number;
}
type CronPreset = 'daily' | 'twice_daily' | 'weekly' | 'custom';
let backups: BackupInfo[] = $state([]);
let schedule: BackupSchedule = $state({
backupEnabled: false,
backupCronExpression: '0 3 * * *',
backupMaxCount: 10
});
let creating = $state(false);
let savingSchedule = $state(false);
let restoringFilename: string | null = $state(null);
let deletingFilename: string | null = $state(null);
let confirmRestore: string | null = $state(null);
let confirmDelete: string | null = $state(null);
let statusMessage = $state('');
let statusType: 'success' | 'error' | '' = $state('');
let customCron = $state('');
let cronPreset: CronPreset = $state('daily');
const CRON_PRESETS: Record<string, string> = {
daily: '0 3 * * *',
twice_daily: '0 */12 * * *',
weekly: '0 3 * * 0'
};
function clearStatus() {
statusMessage = '';
statusType = '';
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString();
}
function detectPreset(cronExpr: string): CronPreset {
for (const [key, value] of Object.entries(CRON_PRESETS)) {
if (cronExpr === value) return key as CronPreset;
}
return 'custom';
}
async function loadBackups() {
try {
const response = await fetch('/api/admin/backups');
const result = await response.json();
if (result.success) {
backups = result.data.backups;
schedule = result.data.schedule;
cronPreset = detectPreset(schedule.backupCronExpression);
if (cronPreset === 'custom') {
customCron = schedule.backupCronExpression;
}
}
} catch {
// Fail silently on initial load
}
}
async function handleCreate() {
clearStatus();
creating = true;
try {
const response = await fetch('/api/admin/backups', { method: 'POST' });
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to create backup');
}
statusMessage = $t('admin.backup_create_success');
statusType = 'success';
await loadBackups();
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to create backup';
statusType = 'error';
} finally {
creating = false;
}
}
async function handleDownload(filename: string) {
const a = document.createElement('a');
a.href = `/api/admin/backups/${encodeURIComponent(filename)}/download`;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
async function handleRestore(filename: string) {
clearStatus();
confirmRestore = null;
restoringFilename = filename;
try {
const response = await fetch(`/api/admin/backups/${encodeURIComponent(filename)}/restore`, {
method: 'POST'
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to restore backup');
}
statusMessage = $t('admin.backup_restore_success');
statusType = 'success';
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to restore backup';
statusType = 'error';
} finally {
restoringFilename = null;
}
}
async function handleDelete(filename: string) {
clearStatus();
confirmDelete = null;
deletingFilename = filename;
try {
const response = await fetch(`/api/admin/backups/${encodeURIComponent(filename)}`, {
method: 'DELETE'
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to delete backup');
}
statusMessage = $t('admin.backup_delete_success');
statusType = 'success';
await loadBackups();
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to delete backup';
statusType = 'error';
} finally {
deletingFilename = null;
}
}
async function handleSaveSchedule() {
clearStatus();
savingSchedule = true;
try {
const cronExpression = cronPreset === 'custom' ? customCron : CRON_PRESETS[cronPreset];
const response = await fetch('/api/admin/backups/schedule', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
backupEnabled: schedule.backupEnabled,
backupCronExpression: cronExpression,
backupMaxCount: schedule.backupMaxCount
})
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to save schedule');
}
schedule = result.data;
statusMessage = $t('admin.backup_schedule_saved');
statusType = 'success';
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to save schedule';
statusType = 'error';
} finally {
savingSchedule = false;
}
}
// Load backups on mount (untrack to avoid infinite re-trigger)
$effect(() => {
untrack(() => loadBackups());
});
</script>
<section class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-1 text-lg font-semibold text-card-foreground">{$t('admin.backup_title')}</h2>
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.backup_description')}</p>
<!-- Create Backup -->
<div class="mb-6">
<button
type="button"
onclick={handleCreate}
disabled={creating}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{creating ? $t('admin.backup_creating') : $t('admin.backup_create')}
</button>
</div>
<!-- Backup List -->
<div class="mb-6">
<h3 class="mb-3 text-sm font-medium text-foreground">{$t('admin.backup_list_title')}</h3>
{#if backups.length === 0}
<p class="text-sm text-muted-foreground">{$t('admin.backup_list_empty')}</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-border text-xs uppercase text-muted-foreground">
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_filename')}</th>
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_size')}</th>
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_date')}</th>
<th class="pb-2 font-medium">{$t('admin.backup_actions')}</th>
</tr>
</thead>
<tbody>
{#each backups as backup (backup.filename)}
<tr class="border-b border-border/50">
<td class="py-2.5 pr-4 font-mono text-xs text-foreground">{backup.filename}</td>
<td class="py-2.5 pr-4 text-muted-foreground">{formatBytes(backup.size)}</td>
<td class="py-2.5 pr-4 text-muted-foreground">{formatDate(backup.createdAt)}</td>
<td class="py-2.5">
<div class="flex gap-2">
<button
type="button"
onclick={() => handleDownload(backup.filename)}
class="rounded px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10"
>
{$t('admin.backup_download')}
</button>
<button
type="button"
onclick={() => (confirmRestore = backup.filename)}
disabled={restoringFilename === backup.filename}
class="rounded px-2 py-1 text-xs font-medium text-amber-600 hover:bg-amber-600/10 disabled:opacity-50 dark:text-amber-400"
>
{restoringFilename === backup.filename
? '...'
: $t('admin.backup_restore')}
</button>
<button
type="button"
onclick={() => (confirmDelete = backup.filename)}
disabled={deletingFilename === backup.filename}
class="rounded px-2 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50"
>
{deletingFilename === backup.filename
? '...'
: $t('admin.backup_delete')}
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Restore Confirmation Dialog -->
{#if confirmRestore}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<h3 class="mb-2 text-lg font-semibold text-card-foreground">
{$t('admin.backup_restore_confirm_title')}
</h3>
<p class="mb-4 text-sm text-muted-foreground">
{$t('admin.backup_restore_confirm')}
</p>
<p class="mb-4 font-mono text-xs text-foreground">{confirmRestore}</p>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={() => (confirmRestore = null)}
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
>
Cancel
</button>
<button
type="button"
onclick={() => confirmRestore && handleRestore(confirmRestore)}
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700"
>
{$t('admin.backup_restore')}
</button>
</div>
</div>
</div>
{/if}
<!-- Delete Confirmation Dialog -->
{#if confirmDelete}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<h3 class="mb-2 text-lg font-semibold text-card-foreground">
{$t('admin.backup_delete_confirm_title')}
</h3>
<p class="mb-4 text-sm text-muted-foreground">
{$t('admin.backup_delete_confirm')}
</p>
<p class="mb-4 font-mono text-xs text-foreground">{confirmDelete}</p>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={() => (confirmDelete = null)}
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
>
Cancel
</button>
<button
type="button"
onclick={() => confirmDelete && handleDelete(confirmDelete)}
class="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
>
{$t('admin.backup_delete')}
</button>
</div>
</div>
</div>
{/if}
<!-- Divider -->
<div class="my-6 border-t border-border"></div>
<!-- Schedule Configuration -->
<div>
<h3 class="mb-3 text-sm font-medium text-foreground">{$t('admin.backup_schedule_title')}</h3>
<div class="space-y-4">
<!-- Enable toggle -->
<label class="flex items-center gap-3">
<input
type="checkbox"
bind:checked={schedule.backupEnabled}
class="h-4 w-4 rounded border-border text-primary focus:ring-ring"
/>
<span class="text-sm text-foreground">{$t('admin.backup_schedule_enabled')}</span>
</label>
{#if schedule.backupEnabled}
<!-- Cron preset -->
<div>
<label for="cron-preset" class="mb-1 block text-sm text-muted-foreground">
{$t('admin.backup_schedule_cron')}
</label>
<select
id="cron-preset"
bind:value={cronPreset}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground sm:w-auto"
>
<option value="daily">{$t('admin.backup_schedule_preset_daily')}</option>
<option value="twice_daily">{$t('admin.backup_schedule_preset_twice_daily')}</option>
<option value="weekly">{$t('admin.backup_schedule_preset_weekly')}</option>
<option value="custom">{$t('admin.backup_schedule_preset_custom')}</option>
</select>
</div>
{#if cronPreset === 'custom'}
<div>
<input
type="text"
bind:value={customCron}
placeholder="0 3 * * *"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground sm:w-64"
/>
</div>
{/if}
<!-- Max count -->
<div>
<label for="max-count" class="mb-1 block text-sm text-muted-foreground">
{$t('admin.backup_schedule_max_count')}
</label>
<input
id="max-count"
type="number"
bind:value={schedule.backupMaxCount}
min="1"
max="100"
class="w-24 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
</div>
{/if}
<button
type="button"
onclick={handleSaveSchedule}
disabled={savingSchedule}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{savingSchedule ? $t('admin.backup_schedule_saving') : $t('admin.backup_schedule_save')}
</button>
</div>
</div>
<!-- Status message -->
{#if statusMessage}
<div
class="mt-4 rounded-md p-3 text-sm {statusType === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}"
>
{statusMessage}
</div>
{/if}
</section>
@@ -1,212 +0,0 @@
<script lang="ts">
import { t } from 'svelte-i18n';
type ImportMode = 'skip' | 'overwrite';
let importMode: ImportMode = $state('skip');
let fileInput: HTMLInputElement | undefined = $state();
let previewData: string = $state('');
let parsedData: unknown = $state(null);
let importing = $state(false);
let exporting = $state(false);
let statusMessage = $state('');
let statusType: 'success' | 'error' | '' = $state('');
function clearStatus() {
statusMessage = '';
statusType = '';
}
async function handleExport() {
clearStatus();
exporting = true;
try {
const response = await fetch('/api/admin/export');
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Export failed');
}
const disposition = response.headers.get('Content-Disposition');
const filenameMatch = disposition?.match(/filename="(.+)"/);
const filename = filenameMatch?.[1] ?? 'export.json';
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
statusMessage = $t('admin.export_success');
statusType = 'success';
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Export failed';
statusType = 'error';
} finally {
exporting = false;
}
}
function handleFileSelect(event: Event) {
clearStatus();
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) {
previewData = '';
parsedData = null;
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
try {
const data = JSON.parse(text);
parsedData = data;
previewData = JSON.stringify(data, null, 2);
} catch {
previewData = '';
parsedData = null;
statusMessage = $t('admin.import_invalid_json');
statusType = 'error';
}
};
reader.readAsText(file);
}
async function handleImport() {
if (!parsedData) return;
clearStatus();
importing = true;
try {
const response = await fetch('/api/admin/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: parsedData, mode: importMode })
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Import failed');
}
const d = result.data;
const parts: string[] = [];
if (d.apps.created > 0) parts.push(`Apps: +${d.apps.created}`);
if (d.apps.updated > 0) parts.push(`Apps updated: ${d.apps.updated}`);
if (d.apps.skipped > 0) parts.push(`Apps skipped: ${d.apps.skipped}`);
if (d.boards.created > 0) parts.push(`Boards: +${d.boards.created}`);
if (d.boards.updated > 0) parts.push(`Boards updated: ${d.boards.updated}`);
if (d.boards.skipped > 0) parts.push(`Boards skipped: ${d.boards.skipped}`);
if (d.groups.created > 0) parts.push(`Groups: +${d.groups.created}`);
if (d.groups.updated > 0) parts.push(`Groups updated: ${d.groups.updated}`);
if (d.groups.skipped > 0) parts.push(`Groups skipped: ${d.groups.skipped}`);
if (d.settingsUpdated) parts.push('Settings updated');
statusMessage = `${$t('admin.import_success')} ${parts.join(', ')}`;
statusType = 'success';
// Reset
previewData = '';
parsedData = null;
if (fileInput) fileInput.value = '';
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Import failed';
statusType = 'error';
} finally {
importing = false;
}
}
</script>
<section class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.import_export_title')}</h2>
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.import_export_description')}</p>
<!-- Export -->
<div class="mb-6">
<h3 class="mb-2 text-sm font-medium text-foreground">{$t('admin.export_section')}</h3>
<button
type="button"
onclick={handleExport}
disabled={exporting}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{exporting ? $t('admin.export_exporting') : $t('admin.export_button')}
</button>
</div>
<!-- Divider -->
<div class="my-6 border-t border-border"></div>
<!-- Import -->
<div>
<h3 class="mb-2 text-sm font-medium text-foreground">{$t('admin.import_section')}</h3>
<!-- File input -->
<div class="mb-4">
<label for="import-file" class="mb-1 block text-sm text-muted-foreground">
{$t('admin.import_select_file')}
</label>
<input
bind:this={fileInput}
id="import-file"
type="file"
accept=".json,application/json"
onchange={handleFileSelect}
class="block w-full text-sm text-foreground file:mr-4 file:rounded-md file:border file:border-border file:bg-background file:px-4 file:py-2 file:text-sm file:font-medium file:text-foreground hover:file:bg-muted"
/>
</div>
<!-- Preview -->
{#if previewData}
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-foreground">{$t('admin.import_preview')}</label>
<pre class="max-h-64 overflow-auto rounded-md border border-border bg-background p-3 font-mono text-xs text-foreground">{previewData}</pre>
</div>
{/if}
<!-- Mode selector -->
{#if parsedData}
<div class="mb-4">
<label for="import-mode" class="mb-1 block text-sm font-medium text-foreground">
{$t('admin.import_mode_label')}
</label>
<select
id="import-mode"
bind:value={importMode}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground sm:w-auto"
>
<option value="skip">{$t('admin.import_mode_skip')}</option>
<option value="overwrite">{$t('admin.import_mode_overwrite')}</option>
</select>
</div>
<button
type="button"
onclick={handleImport}
disabled={importing}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{importing ? $t('admin.import_importing') : $t('admin.import_button')}
</button>
{/if}
</div>
<!-- Status message -->
{#if statusMessage}
<div class="mt-4 rounded-md p-3 text-sm {statusType === 'success' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}">
{statusMessage}
</div>
{/if}
</section>
+31 -16
View File
@@ -235,22 +235,37 @@
"admin.discovery_traefik_url": "Traefik API URL",
"admin.discovery_traefik_url_hint": "Traefik dashboard API base URL (e.g. http://traefik:8080). Set via TRAEFIK_API_URL env var.",
"admin.import_export_title": "Import / Export",
"admin.import_export_description": "Export all data (apps, boards, groups, settings) as JSON, or import from a previously exported file.",
"admin.export_section": "Export Data",
"admin.export_button": "Export JSON",
"admin.export_exporting": "Exporting...",
"admin.export_success": "Export downloaded successfully.",
"admin.import_section": "Import Data",
"admin.import_select_file": "Select a JSON export file",
"admin.import_preview": "Preview",
"admin.import_mode_label": "Conflict Resolution",
"admin.import_mode_skip": "Skip existing (keep current data)",
"admin.import_mode_overwrite": "Overwrite existing (replace with imported data)",
"admin.import_button": "Import",
"admin.import_importing": "Importing...",
"admin.import_success": "Import completed.",
"admin.import_invalid_json": "Selected file is not valid JSON.",
"admin.backup_title": "Database Backup",
"admin.backup_description": "Create, restore, and schedule backups of your database. Backups are full copies of the SQLite database file.",
"admin.backup_create": "Create Backup",
"admin.backup_creating": "Creating...",
"admin.backup_create_success": "Backup created successfully.",
"admin.backup_list_title": "Backups",
"admin.backup_list_empty": "No backups yet. Create your first backup above.",
"admin.backup_filename": "Filename",
"admin.backup_size": "Size",
"admin.backup_date": "Created",
"admin.backup_actions": "Actions",
"admin.backup_download": "Download",
"admin.backup_restore": "Restore",
"admin.backup_delete": "Delete",
"admin.backup_restore_confirm_title": "Restore Backup",
"admin.backup_restore_confirm": "Are you sure you want to restore from this backup? This will replace all current data with the backup contents. This action cannot be undone.",
"admin.backup_restore_success": "Database restored successfully. Please reload the page.",
"admin.backup_delete_confirm_title": "Delete Backup",
"admin.backup_delete_confirm": "Are you sure you want to delete this backup? This action cannot be undone.",
"admin.backup_delete_success": "Backup deleted.",
"admin.backup_schedule_title": "Scheduled Backups",
"admin.backup_schedule_enabled": "Enable periodic backups",
"admin.backup_schedule_cron": "Schedule",
"admin.backup_schedule_max_count": "Max backups to keep",
"admin.backup_schedule_preset_daily": "Daily at 3 AM",
"admin.backup_schedule_preset_twice_daily": "Every 12 hours",
"admin.backup_schedule_preset_weekly": "Weekly (Sunday 3 AM)",
"admin.backup_schedule_preset_custom": "Custom cron",
"admin.backup_schedule_save": "Save Schedule",
"admin.backup_schedule_saving": "Saving...",
"admin.backup_schedule_saved": "Backup schedule updated.",
"search.placeholder": "Search apps and boards...",
"search.trigger": "Search...",
+31 -16
View File
@@ -224,22 +224,37 @@
"admin.discovery_traefik_url": "URL API Traefik",
"admin.discovery_traefik_url_hint": "Базовый URL API Traefik (напр. http://traefik:8080). Задаётся через TRAEFIK_API_URL.",
"admin.import_export_title": "Импорт / Экспорт",
"admin.import_export_description": "Экспортируйте все данные (приложения, доски, группы, настройки) в формате JSON или импортируйте из ранее экспортированного файла.",
"admin.export_section": "Экспорт данных",
"admin.export_button": "Экспорт JSON",
"admin.export_exporting": "Экспорт...",
"admin.export_success": "Экспорт успешно скачан.",
"admin.import_section": "Импорт данных",
"admin.import_select_file": "Выберите JSON-файл экспорта",
"admin.import_preview": "Предпросмотр",
"admin.import_mode_label": "Разрешение конфликтов",
"admin.import_mode_skip": "Пропустить существующие (оставить текущие данные)",
"admin.import_mode_overwrite": "Перезаписать существующие (заменить импортированными)",
"admin.import_button": "Импортировать",
"admin.import_importing": "Импорт...",
"admin.import_success": "Импорт завершён.",
"admin.import_invalid_json": "Выбранный файл не является корректным JSON.",
"admin.backup_title": "Резервное копирование",
"admin.backup_description": "Создавайте, восстанавливайте и планируйте резервные копии базы данных. Копии — это полные дубликаты файла базы SQLite.",
"admin.backup_create": "Создать копию",
"admin.backup_creating": "Создание...",
"admin.backup_create_success": "Резервная копия успешно создана.",
"admin.backup_list_title": "Резервные копии",
"admin.backup_list_empty": "Копий пока нет. Создайте первую копию выше.",
"admin.backup_filename": "Файл",
"admin.backup_size": "Размер",
"admin.backup_date": "Создана",
"admin.backup_actions": "Действия",
"admin.backup_download": "Скачать",
"admin.backup_restore": "Восстановить",
"admin.backup_delete": "Удалить",
"admin.backup_restore_confirm_title": "Восстановление из копии",
"admin.backup_restore_confirm": "Вы уверены, что хотите восстановить базу из этой копии? Все текущие данные будут заменены содержимым копии. Это действие нельзя отменить.",
"admin.backup_restore_success": "База данных восстановлена. Пожалуйста, перезагрузите страницу.",
"admin.backup_delete_confirm_title": "Удаление копии",
"admin.backup_delete_confirm": "Вы уверены, что хотите удалить эту резервную копию? Это действие нельзя отменить.",
"admin.backup_delete_success": "Копия удалена.",
"admin.backup_schedule_title": "Автоматическое копирование",
"admin.backup_schedule_enabled": "Включить периодическое копирование",
"admin.backup_schedule_cron": "Расписание",
"admin.backup_schedule_max_count": "Максимум хранимых копий",
"admin.backup_schedule_preset_daily": "Ежедневно в 3:00",
"admin.backup_schedule_preset_twice_daily": "Каждые 12 часов",
"admin.backup_schedule_preset_weekly": "Еженедельно (воскресенье 3:00)",
"admin.backup_schedule_preset_custom": "Свой cron",
"admin.backup_schedule_save": "Сохранить расписание",
"admin.backup_schedule_saving": "Сохранение...",
"admin.backup_schedule_saved": "Расписание резервного копирования обновлено.",
"search.placeholder": "Поиск приложений и досок...",
"search.trigger": "Поиск...",
"search.min_chars": "Введите минимум 2 символа для поиска",
+80
View File
@@ -0,0 +1,80 @@
import cron from 'node-cron';
import { createBackup, enforceRetention, getBackupSettings } from '$lib/server/services/backupService.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
let scheduledTask: cron.ScheduledTask | null = null;
/**
* Start the backup scheduler with the given settings.
* If already running, does nothing — call restartBackupScheduler() to reconfigure.
*/
export function startBackupScheduler(settings: {
readonly backupEnabled: boolean;
readonly backupCronExpression: string;
readonly backupMaxCount: number;
}): void {
if (scheduledTask) {
return;
}
if (!settings.backupEnabled) {
return;
}
if (!cron.validate(settings.backupCronExpression)) {
return;
}
scheduledTask = cron.schedule(settings.backupCronExpression, async () => {
try {
const backup = await createBackup();
enforceRetention(settings.backupMaxCount);
logAction(null, AuditAction.BACKUP_CREATED, 'backup', backup.filename, {
trigger: 'scheduled'
});
} catch (err) {
// Log failure to audit log so admins can see scheduled backups are failing
logAction(null, AuditAction.BACKUP_CREATED, 'backup', 'failed', {
trigger: 'scheduled',
error: err instanceof Error ? err.message : 'Unknown error'
});
}
});
}
/**
* Stop the backup scheduler.
*/
export function stopBackupScheduler(): void {
if (scheduledTask) {
scheduledTask.stop();
scheduledTask = null;
}
}
/**
* Restart the backup scheduler with new settings.
* Stops the existing scheduler (if any) and starts a new one.
*/
export function restartBackupScheduler(settings: {
readonly backupEnabled: boolean;
readonly backupCronExpression: string;
readonly backupMaxCount: number;
}): void {
stopBackupScheduler();
startBackupScheduler(settings);
}
/**
* Initialize the backup scheduler from database settings.
* Call this at server startup.
*/
export async function initBackupScheduler(): Promise<void> {
try {
const settings = await getBackupSettings();
startBackupScheduler(settings);
} catch {
// Swallow errors — backup scheduler is non-critical
}
}
@@ -1,186 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../prisma.js', () => ({
prisma: {
app: { findMany: vi.fn() },
board: { findMany: vi.fn() },
group: { findMany: vi.fn() },
systemSettings: { upsert: vi.fn() }
}
}));
import { prisma } from '../../prisma.js';
import { exportAllData } from '../exportService.js';
const mockApp = prisma.app as unknown as { findMany: ReturnType<typeof vi.fn> };
const mockBoard = prisma.board as unknown as { findMany: ReturnType<typeof vi.fn> };
const mockGroup = prisma.group as unknown as { findMany: ReturnType<typeof vi.fn> };
const mockSettings = prisma.systemSettings as unknown as {
upsert: ReturnType<typeof vi.fn>;
};
describe('exportService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('exportAllData', () => {
it('returns export data with version and timestamp', async () => {
mockApp.findMany.mockResolvedValue([]);
mockBoard.findMany.mockResolvedValue([]);
mockGroup.findMany.mockResolvedValue([]);
mockSettings.upsert.mockResolvedValue({
authMode: 'local',
registrationEnabled: true,
defaultTheme: 'dark',
defaultPrimaryColor: '#6366f1',
healthcheckDefaults: '{}'
});
const result = await exportAllData();
expect(result.version).toBe('1.0');
expect(result.exportedAt).toBeTruthy();
expect(result.apps).toEqual([]);
expect(result.boards).toEqual([]);
expect(result.groups).toEqual([]);
expect(result.settings).toEqual({
authMode: 'local',
registrationEnabled: true,
defaultTheme: 'dark',
defaultPrimaryColor: '#6366f1',
healthcheckDefaults: '{}'
});
});
it('maps apps to export format stripping internal fields', async () => {
mockApp.findMany.mockResolvedValue([
{
id: 'a1',
name: 'Gitea',
url: 'https://git.local',
icon: 'gitea',
iconType: 'simple',
description: 'Self-hosted Git',
category: 'dev',
tags: 'git,code',
healthcheckEnabled: true,
healthcheckInterval: 300,
healthcheckMethod: 'GET',
healthcheckExpectedStatus: 200,
healthcheckTimeout: 5000,
createdById: 'u1',
createdAt: new Date(),
updatedAt: new Date()
}
]);
mockBoard.findMany.mockResolvedValue([]);
mockGroup.findMany.mockResolvedValue([]);
mockSettings.upsert.mockResolvedValue({
authMode: 'local',
registrationEnabled: true,
defaultTheme: 'dark',
defaultPrimaryColor: '#6366f1',
healthcheckDefaults: '{}'
});
const result = await exportAllData();
expect(result.apps).toHaveLength(1);
expect(result.apps[0]).toEqual({
name: 'Gitea',
url: 'https://git.local',
icon: 'gitea',
iconType: 'simple',
description: 'Self-hosted Git',
category: 'dev',
tags: 'git,code',
healthcheckEnabled: true,
healthcheckInterval: 300,
healthcheckMethod: 'GET',
healthcheckExpectedStatus: 200,
healthcheckTimeout: 5000
});
// Internal fields should not be present
expect((result.apps[0] as unknown as Record<string, unknown>).id).toBeUndefined();
expect((result.apps[0] as unknown as Record<string, unknown>).createdById).toBeUndefined();
});
it('maps boards with nested sections and widgets', async () => {
mockApp.findMany.mockResolvedValue([]);
mockBoard.findMany.mockResolvedValue([
{
name: 'Dashboard',
icon: null,
description: 'Main board',
isDefault: true,
isGuestAccessible: false,
backgroundConfig: null,
sections: [
{
title: 'Apps',
icon: null,
order: 0,
isExpandedByDefault: true,
widgets: [
{
type: 'app',
order: 0,
config: '{}',
app: { name: 'Gitea' }
}
]
}
]
}
]);
mockGroup.findMany.mockResolvedValue([]);
mockSettings.upsert.mockResolvedValue({
authMode: 'local',
registrationEnabled: true,
defaultTheme: 'dark',
defaultPrimaryColor: '#6366f1',
healthcheckDefaults: '{}'
});
const result = await exportAllData();
expect(result.boards).toHaveLength(1);
expect(result.boards[0].name).toBe('Dashboard');
expect(result.boards[0].sections).toHaveLength(1);
expect(result.boards[0].sections[0].widgets).toHaveLength(1);
expect(result.boards[0].sections[0].widgets[0].appName).toBe('Gitea');
});
it('maps groups stripping internal fields', async () => {
mockApp.findMany.mockResolvedValue([]);
mockBoard.findMany.mockResolvedValue([]);
mockGroup.findMany.mockResolvedValue([
{
id: 'g1',
name: 'Admins',
description: 'Admin users',
isDefault: false,
createdAt: new Date(),
updatedAt: new Date()
}
]);
mockSettings.upsert.mockResolvedValue({
authMode: 'local',
registrationEnabled: true,
defaultTheme: 'dark',
defaultPrimaryColor: '#6366f1',
healthcheckDefaults: '{}'
});
const result = await exportAllData();
expect(result.groups).toHaveLength(1);
expect(result.groups[0]).toEqual({
name: 'Admins',
description: 'Admin users',
isDefault: false
});
});
});
});
@@ -1,220 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Build a transaction mock that mirrors prisma's nested structure
const txMock = {
app: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn() },
group: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
board: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn() },
section: { create: vi.fn(), deleteMany: vi.fn() },
widget: { create: vi.fn() },
systemSettings: { upsert: vi.fn() }
};
vi.mock('../../prisma.js', () => ({
prisma: {
$transaction: vi.fn(async (fn: (tx: typeof txMock) => Promise<void>) => {
await fn(txMock);
})
}
}));
import { validateImportData, importData } from '../importService.js';
import type { ExportData } from '../exportService.js';
function buildValidExportData(overrides: Partial<ExportData> = {}): ExportData {
return {
version: '1.0',
exportedAt: new Date().toISOString(),
apps: [],
boards: [],
groups: [],
settings: {
authMode: 'local',
registrationEnabled: true,
defaultTheme: 'dark',
defaultPrimaryColor: '#6366f1',
healthcheckDefaults: '{}'
},
...overrides
};
}
describe('importService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('validateImportData', () => {
it('accepts valid export data', () => {
const data = buildValidExportData();
const result = validateImportData(data);
expect(result.success).toBe(true);
});
it('rejects data missing required fields', () => {
const result = validateImportData({ version: '1.0' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.errors.length).toBeGreaterThan(0);
}
});
it('rejects non-object input', () => {
const result = validateImportData('not an object');
expect(result.success).toBe(false);
});
});
describe('importData', () => {
it('creates new apps when none exist', async () => {
const data = buildValidExportData({
apps: [
{
name: 'TestApp',
url: 'https://test.local',
icon: null,
iconType: 'lucide',
description: null,
category: null,
tags: '',
healthcheckEnabled: false,
healthcheckInterval: 300,
healthcheckMethod: 'GET',
healthcheckExpectedStatus: 200,
healthcheckTimeout: 5000
}
]
});
txMock.app.findFirst.mockResolvedValue(null);
txMock.app.create.mockResolvedValue({ id: 'new-app-id', name: 'TestApp' });
const result = await importData(data, 'skip');
expect(result.apps.created).toBe(1);
expect(result.apps.skipped).toBe(0);
expect(txMock.app.create).toHaveBeenCalledOnce();
});
it('skips existing apps in skip mode', async () => {
const data = buildValidExportData({
apps: [
{
name: 'Existing',
url: 'https://existing.local',
icon: null,
iconType: 'lucide',
description: null,
category: null,
tags: '',
healthcheckEnabled: false,
healthcheckInterval: 300,
healthcheckMethod: 'GET',
healthcheckExpectedStatus: 200,
healthcheckTimeout: 5000
}
]
});
txMock.app.findFirst.mockResolvedValue({ id: 'existing-id', name: 'Existing' });
const result = await importData(data, 'skip');
expect(result.apps.skipped).toBe(1);
expect(result.apps.created).toBe(0);
expect(txMock.app.update).not.toHaveBeenCalled();
});
it('overwrites existing apps in overwrite mode', async () => {
const data = buildValidExportData({
apps: [
{
name: 'Existing',
url: 'https://updated.local',
icon: null,
iconType: 'lucide',
description: 'updated',
category: null,
tags: '',
healthcheckEnabled: true,
healthcheckInterval: 60,
healthcheckMethod: 'GET',
healthcheckExpectedStatus: 200,
healthcheckTimeout: 5000
}
]
});
txMock.app.findFirst.mockResolvedValue({ id: 'existing-id', name: 'Existing' });
const result = await importData(data, 'overwrite');
expect(result.apps.updated).toBe(1);
expect(txMock.app.update).toHaveBeenCalledOnce();
});
it('creates new groups', async () => {
const data = buildValidExportData({
groups: [{ name: 'NewGroup', description: 'A group', isDefault: false }]
});
txMock.group.findUnique.mockResolvedValue(null);
const result = await importData(data, 'skip');
expect(result.groups.created).toBe(1);
expect(txMock.group.create).toHaveBeenCalledOnce();
});
it('creates boards with sections and widgets', async () => {
const data = buildValidExportData({
boards: [
{
name: 'Board1',
icon: null,
description: null,
isDefault: false,
isGuestAccessible: false,
backgroundConfig: null,
sections: [
{
title: 'Section1',
icon: null,
order: 0,
isExpandedByDefault: true,
widgets: [{ type: 'note', order: 0, config: '{}', appName: null }]
}
]
}
]
});
txMock.board.findFirst.mockResolvedValue(null);
txMock.board.create.mockResolvedValue({ id: 'board-id' });
txMock.section.create.mockResolvedValue({ id: 'section-id' });
const result = await importData(data, 'skip');
expect(result.boards.created).toBe(1);
expect(txMock.section.create).toHaveBeenCalledOnce();
expect(txMock.widget.create).toHaveBeenCalledOnce();
});
it('imports settings when provided', async () => {
const data = buildValidExportData({
settings: {
authMode: 'both',
registrationEnabled: false,
defaultTheme: 'light',
defaultPrimaryColor: '#ff0000',
healthcheckDefaults: '{}'
}
});
const result = await importData(data, 'skip');
expect(result.settingsUpdated).toBe(true);
expect(txMock.systemSettings.upsert).toHaveBeenCalledOnce();
});
});
});
+210
View File
@@ -0,0 +1,210 @@
import { prisma } from '../prisma.js';
import { DEFAULTS } from '$lib/utils/constants.js';
import fs from 'node:fs';
import path from 'node:path';
const BACKUP_DIR = path.resolve('data', 'backups');
let _restoring = false;
/**
* Check if a database restore is currently in progress.
* Other services can check this to avoid querying during restore.
*/
export function isRestoring(): boolean {
return _restoring;
}
export interface BackupInfo {
readonly filename: string;
readonly size: number;
readonly createdAt: string;
}
function ensureBackupDir(): void {
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
}
function getDatabasePath(): string {
const url = process.env.DATABASE_URL ?? 'file:../data/launcher.db';
// Strip the "file:" prefix and resolve relative to prisma/ directory
const relative = url.replace(/^file:/, '');
return path.resolve('prisma', relative);
}
/**
* Create a backup of the SQLite database using VACUUM INTO.
* This produces a clean, compacted copy without locking the live DB.
*/
export async function createBackup(): Promise<BackupInfo> {
ensureBackupDir();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `backup-${timestamp}.db`;
const backupPath = path.join(BACKUP_DIR, filename);
// Use VACUUM INTO for a safe, consistent copy
// Escape single quotes for defense-in-depth (path is server-generated, not user input)
const safePath = backupPath.replace(/\\/g, '/').replace(/'/g, "''");
await prisma.$executeRawUnsafe(`VACUUM INTO '${safePath}'`);
const stats = fs.statSync(backupPath);
return {
filename,
size: stats.size,
createdAt: stats.birthtime.toISOString()
};
}
/**
* List all existing backups, sorted newest first.
*/
export function listBackups(): ReadonlyArray<BackupInfo> {
ensureBackupDir();
const files = fs.readdirSync(BACKUP_DIR).filter((f) => f.endsWith('.db'));
return files
.map((filename) => {
const stats = fs.statSync(path.join(BACKUP_DIR, filename));
return {
filename,
size: stats.size,
createdAt: stats.birthtime.toISOString()
};
})
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
/**
* Get the absolute path to a backup file. Returns null if not found.
*/
export function getBackupFilePath(filename: string): string | null {
// Sanitize: prevent directory traversal
const sanitized = path.basename(filename);
const fullPath = path.join(BACKUP_DIR, sanitized);
if (!fs.existsSync(fullPath)) {
return null;
}
return fullPath;
}
/**
* Delete a backup file. Returns true if deleted, false if not found.
*/
export function deleteBackup(filename: string): boolean {
const fullPath = getBackupFilePath(filename);
if (!fullPath) {
return false;
}
fs.unlinkSync(fullPath);
return true;
}
/**
* Restore the database from a backup file.
* Sets a restoring flag, disconnects Prisma, replaces the DB file, then reconnects.
*/
export async function restoreBackup(filename: string): Promise<void> {
const backupPath = getBackupFilePath(filename);
if (!backupPath) {
throw new Error(`Backup not found: ${filename}`);
}
if (_restoring) {
throw new Error('A restore is already in progress');
}
const dbPath = getDatabasePath();
_restoring = true;
// Disconnect Prisma so the DB file is not locked
await prisma.$disconnect();
try {
// Replace the live database with the backup
fs.copyFileSync(backupPath, dbPath);
} finally {
// Always reconnect, even if copy fails
await prisma.$connect();
_restoring = false;
}
}
/**
* Enforce retention policy: delete oldest backups beyond maxCount.
*/
export function enforceRetention(maxCount: number): number {
const backups = listBackups();
if (backups.length <= maxCount) {
return 0;
}
// Backups are sorted newest-first; remove from the end
const toDelete = backups.slice(maxCount);
for (const backup of toDelete) {
deleteBackup(backup.filename);
}
return toDelete.length;
}
/**
* Load backup settings from SystemSettings.
*/
export async function getBackupSettings(): Promise<{
readonly backupEnabled: boolean;
readonly backupCronExpression: string;
readonly backupMaxCount: number;
}> {
const settings = await prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: {},
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
});
return {
backupEnabled: settings.backupEnabled,
backupCronExpression: settings.backupCronExpression,
backupMaxCount: settings.backupMaxCount
};
}
/**
* Update backup settings in SystemSettings.
*/
export async function updateBackupSettings(data: {
readonly backupEnabled?: boolean;
readonly backupCronExpression?: string;
readonly backupMaxCount?: number;
}): Promise<{
readonly backupEnabled: boolean;
readonly backupCronExpression: string;
readonly backupMaxCount: number;
}> {
const settings = await prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: {
...(data.backupEnabled !== undefined && { backupEnabled: data.backupEnabled }),
...(data.backupCronExpression !== undefined && {
backupCronExpression: data.backupCronExpression
}),
...(data.backupMaxCount !== undefined && { backupMaxCount: data.backupMaxCount })
},
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
});
return {
backupEnabled: settings.backupEnabled,
backupCronExpression: settings.backupCronExpression,
backupMaxCount: settings.backupMaxCount
};
}
-154
View File
@@ -1,154 +0,0 @@
import { prisma } from '../prisma.js';
import { DEFAULTS } from '$lib/utils/constants.js';
export interface ExportData {
readonly version: string;
readonly exportedAt: string;
readonly apps: ReadonlyArray<ExportApp>;
readonly boards: ReadonlyArray<ExportBoard>;
readonly groups: ReadonlyArray<ExportGroup>;
readonly settings: ExportSettings;
}
export interface ExportApp {
readonly name: string;
readonly url: string;
readonly icon: string | null;
readonly iconType: string;
readonly description: string | null;
readonly category: string | null;
readonly tags: string;
readonly healthcheckEnabled: boolean;
readonly healthcheckInterval: number;
readonly healthcheckMethod: string;
readonly healthcheckExpectedStatus: number;
readonly healthcheckTimeout: number;
}
export interface ExportWidget {
readonly type: string;
readonly order: number;
readonly config: string;
readonly appName: string | null;
}
export interface ExportSection {
readonly title: string;
readonly icon: string | null;
readonly order: number;
readonly isExpandedByDefault: boolean;
readonly widgets: ReadonlyArray<ExportWidget>;
}
export interface ExportBoard {
readonly name: string;
readonly icon: string | null;
readonly description: string | null;
readonly isDefault: boolean;
readonly isGuestAccessible: boolean;
readonly backgroundConfig: string | null;
readonly sections: ReadonlyArray<ExportSection>;
}
export interface ExportGroup {
readonly name: string;
readonly description: string | null;
readonly isDefault: boolean;
}
export interface ExportSettings {
readonly authMode: string;
readonly registrationEnabled: boolean;
readonly defaultTheme: string;
readonly defaultPrimaryColor: string;
readonly healthcheckDefaults: string;
}
export async function exportAllData(): Promise<ExportData> {
const [apps, boards, groups, settings] = await Promise.all([
prisma.app.findMany({
orderBy: { name: 'asc' }
}),
prisma.board.findMany({
orderBy: { createdAt: 'asc' },
include: {
sections: {
orderBy: { order: 'asc' },
include: {
widgets: {
orderBy: { order: 'asc' },
include: { app: { select: { name: true } } }
}
}
}
}
}),
prisma.group.findMany({
orderBy: { name: 'asc' }
}),
prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: {},
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
})
]);
const exportApps: ReadonlyArray<ExportApp> = apps.map((app) => ({
name: app.name,
url: app.url,
icon: app.icon,
iconType: app.iconType,
description: app.description,
category: app.category,
tags: app.tags,
healthcheckEnabled: app.healthcheckEnabled,
healthcheckInterval: app.healthcheckInterval,
healthcheckMethod: app.healthcheckMethod,
healthcheckExpectedStatus: app.healthcheckExpectedStatus,
healthcheckTimeout: app.healthcheckTimeout
}));
const exportBoards: ReadonlyArray<ExportBoard> = boards.map((board) => ({
name: board.name,
icon: board.icon,
description: board.description,
isDefault: board.isDefault,
isGuestAccessible: board.isGuestAccessible,
backgroundConfig: board.backgroundConfig,
sections: board.sections.map((section) => ({
title: section.title,
icon: section.icon,
order: section.order,
isExpandedByDefault: section.isExpandedByDefault,
widgets: section.widgets.map((widget) => ({
type: widget.type,
order: widget.order,
config: widget.config,
appName: widget.app?.name ?? null
}))
}))
}));
const exportGroups: ReadonlyArray<ExportGroup> = groups.map((group) => ({
name: group.name,
description: group.description,
isDefault: group.isDefault
}));
const exportSettings: ExportSettings = {
authMode: settings.authMode,
registrationEnabled: settings.registrationEnabled,
defaultTheme: settings.defaultTheme,
defaultPrimaryColor: settings.defaultPrimaryColor,
healthcheckDefaults: settings.healthcheckDefaults
};
return {
version: '1.0',
exportedAt: new Date().toISOString(),
apps: exportApps,
boards: exportBoards,
groups: exportGroups,
settings: exportSettings
};
}
-231
View File
@@ -1,231 +0,0 @@
import { prisma } from '../prisma.js';
import { importDataSchema } from '$lib/utils/validators.js';
import { DEFAULTS } from '$lib/utils/constants.js';
import type { ExportData } from './exportService.js';
export type ImportMode = 'skip' | 'overwrite';
export interface ImportResult {
readonly apps: { readonly created: number; readonly updated: number; readonly skipped: number };
readonly boards: { readonly created: number; readonly updated: number; readonly skipped: number };
readonly groups: { readonly created: number; readonly updated: number; readonly skipped: number };
readonly settingsUpdated: boolean;
}
export function validateImportData(
data: unknown
): { success: true; data: ExportData } | { success: false; errors: string[] } {
const parsed = importDataSchema.safeParse(data);
if (!parsed.success) {
const errors = parsed.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`);
return { success: false, errors };
}
return { success: true, data: parsed.data as ExportData };
}
export async function importData(data: ExportData, mode: ImportMode): Promise<ImportResult> {
const result = {
apps: { created: 0, updated: 0, skipped: 0 },
boards: { created: 0, updated: 0, skipped: 0 },
groups: { created: 0, updated: 0, skipped: 0 },
settingsUpdated: false
};
await prisma.$transaction(async (tx) => {
// --- Import Apps ---
const appNameToId = new Map<string, string>();
for (const appData of data.apps) {
const existing = await tx.app.findFirst({ where: { name: appData.name } });
if (existing) {
appNameToId.set(appData.name, existing.id);
if (mode === 'skip') {
result.apps.skipped++;
continue;
}
// overwrite
await tx.app.update({
where: { id: existing.id },
data: {
url: appData.url,
icon: appData.icon,
iconType: appData.iconType,
description: appData.description,
category: appData.category,
tags: appData.tags,
healthcheckEnabled: appData.healthcheckEnabled,
healthcheckInterval: appData.healthcheckInterval,
healthcheckMethod: appData.healthcheckMethod,
healthcheckExpectedStatus: appData.healthcheckExpectedStatus,
healthcheckTimeout: appData.healthcheckTimeout
}
});
result.apps.updated++;
} else {
const created = await tx.app.create({
data: {
name: appData.name,
url: appData.url,
icon: appData.icon,
iconType: appData.iconType,
description: appData.description,
category: appData.category,
tags: appData.tags,
healthcheckEnabled: appData.healthcheckEnabled,
healthcheckInterval: appData.healthcheckInterval,
healthcheckMethod: appData.healthcheckMethod,
healthcheckExpectedStatus: appData.healthcheckExpectedStatus,
healthcheckTimeout: appData.healthcheckTimeout
}
});
appNameToId.set(appData.name, created.id);
result.apps.created++;
}
}
// --- Import Groups ---
for (const groupData of data.groups) {
const existing = await tx.group.findUnique({ where: { name: groupData.name } });
if (existing) {
if (mode === 'skip') {
result.groups.skipped++;
continue;
}
await tx.group.update({
where: { id: existing.id },
data: {
description: groupData.description,
isDefault: groupData.isDefault
}
});
result.groups.updated++;
} else {
await tx.group.create({
data: {
name: groupData.name,
description: groupData.description,
isDefault: groupData.isDefault
}
});
result.groups.created++;
}
}
// --- Import Boards (with sections and widgets) ---
for (const boardData of data.boards) {
const existing = await tx.board.findFirst({ where: { name: boardData.name } });
if (existing) {
if (mode === 'skip') {
result.boards.skipped++;
continue;
}
// Overwrite: update board, delete old sections, recreate
await tx.section.deleteMany({ where: { boardId: existing.id } });
await tx.board.update({
where: { id: existing.id },
data: {
icon: boardData.icon,
description: boardData.description,
isDefault: boardData.isDefault,
isGuestAccessible: boardData.isGuestAccessible,
backgroundConfig: boardData.backgroundConfig
}
});
for (const sectionData of boardData.sections) {
const section = await tx.section.create({
data: {
boardId: existing.id,
title: sectionData.title,
icon: sectionData.icon,
order: sectionData.order,
isExpandedByDefault: sectionData.isExpandedByDefault
}
});
for (const widgetData of sectionData.widgets) {
const appId = widgetData.appName ? (appNameToId.get(widgetData.appName) ?? null) : null;
await tx.widget.create({
data: {
sectionId: section.id,
type: widgetData.type,
order: widgetData.order,
config: widgetData.config,
appId
}
});
}
}
result.boards.updated++;
} else {
const board = await tx.board.create({
data: {
name: boardData.name,
icon: boardData.icon,
description: boardData.description,
isDefault: boardData.isDefault,
isGuestAccessible: boardData.isGuestAccessible,
backgroundConfig: boardData.backgroundConfig
}
});
for (const sectionData of boardData.sections) {
const section = await tx.section.create({
data: {
boardId: board.id,
title: sectionData.title,
icon: sectionData.icon,
order: sectionData.order,
isExpandedByDefault: sectionData.isExpandedByDefault
}
});
for (const widgetData of sectionData.widgets) {
const appId = widgetData.appName ? (appNameToId.get(widgetData.appName) ?? null) : null;
await tx.widget.create({
data: {
sectionId: section.id,
type: widgetData.type,
order: widgetData.order,
config: widgetData.config,
appId
}
});
}
}
result.boards.created++;
}
}
// --- Import Settings (always merge) ---
if (data.settings) {
const settingsData: Record<string, unknown> = {};
const s = data.settings;
if (s.authMode !== undefined) settingsData.authMode = s.authMode;
if (s.registrationEnabled !== undefined)
settingsData.registrationEnabled = s.registrationEnabled;
if (s.defaultTheme !== undefined) settingsData.defaultTheme = s.defaultTheme;
if (s.defaultPrimaryColor !== undefined)
settingsData.defaultPrimaryColor = s.defaultPrimaryColor;
if (s.healthcheckDefaults !== undefined)
settingsData.healthcheckDefaults = s.healthcheckDefaults;
if (Object.keys(settingsData).length > 0) {
await tx.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: settingsData,
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID, ...settingsData }
});
result.settingsUpdated = true;
}
}
});
return result;
}
+4 -1
View File
@@ -137,7 +137,10 @@ export const AuditAction = {
APP_DELETED: 'app_deleted',
SETTINGS_UPDATED: 'settings_updated',
IMPORT: 'import',
EXPORT: 'export'
EXPORT: 'export',
BACKUP_CREATED: 'backup_created',
BACKUP_RESTORED: 'backup_restored',
BACKUP_DELETED: 'backup_deleted'
} as const;
export type AuditAction = (typeof AuditAction)[keyof typeof AuditAction];
+9 -63
View File
@@ -220,69 +220,12 @@ export const createPermissionSchema = z.object({
level: z.enum([PermissionLevel.VIEW, PermissionLevel.EDIT, PermissionLevel.ADMIN])
});
// --- Import/Export ---
// --- Backup Schedule ---
const importAppSchema = z.object({
name: z.string().min(1).max(200),
url: z.string().url(),
icon: z.string().max(500).nullable(),
iconType: z.string().max(50),
description: z.string().max(1000).nullable(),
category: z.string().max(100).nullable(),
tags: z.string().max(500),
healthcheckEnabled: z.boolean(),
healthcheckInterval: z.number().int().min(30).max(86400),
healthcheckMethod: z.string(),
healthcheckExpectedStatus: z.number().int().min(100).max(599),
healthcheckTimeout: z.number().int().min(1000).max(30000)
});
const importWidgetSchema = z.object({
type: z.string().min(1),
order: z.number().int().min(0),
config: z.string(),
appName: z.string().nullable()
});
const importSectionSchema = z.object({
title: z.string().min(1).max(200),
icon: z.string().max(500).nullable(),
order: z.number().int().min(0),
isExpandedByDefault: z.boolean(),
widgets: z.array(importWidgetSchema)
});
const importBoardSchema = z.object({
name: z.string().min(1).max(200),
icon: z.string().max(500).nullable(),
description: z.string().max(1000).nullable(),
isDefault: z.boolean(),
isGuestAccessible: z.boolean(),
backgroundConfig: z.string().nullable(),
sections: z.array(importSectionSchema)
});
const importGroupSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).nullable(),
isDefault: z.boolean()
});
const importSettingsSchema = z.object({
authMode: z.string().optional(),
registrationEnabled: z.boolean().optional(),
defaultTheme: z.string().optional(),
defaultPrimaryColor: z.string().optional(),
healthcheckDefaults: z.string().optional()
});
export const importDataSchema = z.object({
version: z.string(),
exportedAt: z.string(),
apps: z.array(importAppSchema).max(1000),
boards: z.array(importBoardSchema).max(100),
groups: z.array(importGroupSchema).max(100),
settings: importSettingsSchema
export const updateBackupScheduleSchema = z.object({
backupEnabled: z.boolean().optional(),
backupCronExpression: z.string().min(1).max(100).optional(),
backupMaxCount: z.number().int().min(1).max(100).optional()
});
// --- System Settings ---
@@ -468,7 +411,10 @@ export const auditLogQuerySchema = z.object({
AuditAction.APP_DELETED,
AuditAction.SETTINGS_UPDATED,
AuditAction.IMPORT,
AuditAction.EXPORT
AuditAction.EXPORT,
AuditAction.BACKUP_CREATED,
AuditAction.BACKUP_RESTORED,
AuditAction.BACKUP_DELETED
])
.optional(),
entityType: z.string().max(50).optional(),
+2 -2
View File
@@ -2,7 +2,7 @@
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import SettingsForm from '$lib/components/admin/SettingsForm.svelte';
import ImportExportPanel from '$lib/components/admin/ImportExportPanel.svelte';
import BackupPanel from '$lib/components/admin/BackupPanel.svelte';
import DiscoveryPanel from '$lib/components/admin/DiscoveryPanel.svelte';
let { data }: { data: PageData } = $props();
@@ -25,5 +25,5 @@
<DiscoveryPanel bind:dockerSocketPath bind:traefikApiUrl />
<ImportExportPanel />
<BackupPanel />
</div>
+45
View File
@@ -0,0 +1,45 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { createBackup, listBackups, enforceRetention, getBackupSettings } from '$lib/server/services/backupService.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
/**
* GET /api/admin/backups — List all backups.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
try {
const backups = listBackups();
const settings = await getBackupSettings();
return json(success({ backups, schedule: settings }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to list backups';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/admin/backups — Create a new backup.
*/
export const POST: RequestHandler = async (event) => {
const admin = requireAdmin(event);
try {
const backup = await createBackup();
// Enforce retention after creating backup
const settings = await getBackupSettings();
enforceRetention(settings.backupMaxCount);
logAction(admin.id, AuditAction.BACKUP_CREATED, 'backup', backup.filename);
return json(success(backup), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create backup';
return json(error(message), { status: 500 });
}
};
@@ -0,0 +1,29 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { deleteBackup } from '$lib/server/services/backupService.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
/**
* DELETE /api/admin/backups/:filename — Delete a backup.
*/
export const DELETE: RequestHandler = async (event) => {
const admin = requireAdmin(event);
const { filename } = event.params;
try {
const deleted = deleteBackup(filename);
if (!deleted) {
return json(error('Backup not found'), { status: 404 });
}
logAction(admin.id, AuditAction.BACKUP_DELETED, 'backup', filename);
return json(success({ deleted: true }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete backup';
return json(error(message), { status: 500 });
}
};
@@ -0,0 +1,33 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { getBackupFilePath } from '$lib/server/services/backupService.js';
import { error } from '$lib/server/utils/response.js';
import fs from 'node:fs';
import path from 'node:path';
import { Readable } from 'node:stream';
/**
* GET /api/admin/backups/:filename/download — Download a backup file (streamed).
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
const { filename } = event.params;
const filePath = getBackupFilePath(filename);
if (!filePath) {
return json(error('Backup not found'), { status: 404 });
}
const stats = fs.statSync(filePath);
const stream = fs.createReadStream(filePath);
return new Response(Readable.toWeb(stream) as ReadableStream, {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${path.basename(filePath)}"`,
'Content-Length': String(stats.size)
}
});
};
@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { restoreBackup } from '$lib/server/services/backupService.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
/**
* POST /api/admin/backups/:filename/restore — Restore the database from a backup.
*/
export const POST: RequestHandler = async (event) => {
const admin = requireAdmin(event);
const { filename } = event.params;
try {
await restoreBackup(filename);
logAction(admin.id, AuditAction.BACKUP_RESTORED, 'backup', filename);
return json(success({ restored: true }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to restore backup';
return json(error(message), { status: 500 });
}
};
@@ -0,0 +1,65 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { getBackupSettings, updateBackupSettings } from '$lib/server/services/backupService.js';
import { restartBackupScheduler } from '$lib/server/jobs/backupScheduler.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
import { updateBackupScheduleSchema } from '$lib/utils/validators.js';
import cron from 'node-cron';
/**
* GET /api/admin/backups/schedule — Get current backup schedule settings.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
try {
const settings = await getBackupSettings();
return json(success(settings));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to get backup schedule';
return json(error(message), { status: 500 });
}
};
/**
* PUT /api/admin/backups/schedule — Update backup schedule settings.
*/
export const PUT: RequestHandler = async (event) => {
const admin = requireAdmin(event);
try {
const raw = await event.request.json();
const parsed = updateBackupScheduleSchema.safeParse(raw);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(`Invalid input: ${messages}`), { status: 400 });
}
const body = parsed.data;
// Validate cron expression if provided
if (body.backupCronExpression && !cron.validate(body.backupCronExpression)) {
return json(error('Invalid cron expression'), { status: 400 });
}
const updated = await updateBackupSettings(body);
// Restart the scheduler with new settings
restartBackupScheduler(updated);
logAction(admin.id, AuditAction.SETTINGS_UPDATED, 'backup_schedule', 'singleton', {
backupEnabled: updated.backupEnabled,
backupCronExpression: updated.backupCronExpression,
backupMaxCount: updated.backupMaxCount
});
return json(success(updated));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update backup schedule';
return json(error(message), { status: 500 });
}
};
-33
View File
@@ -1,33 +0,0 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { exportAllData } from '$lib/server/services/exportService.js';
import { error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
/**
* GET /api/admin/export — Export all data as JSON file download. Admin only.
*/
export const GET: RequestHandler = async (event) => {
const admin = requireAdmin(event);
try {
logAction(admin.id, AuditAction.EXPORT, 'system', 'export');
const data = await exportAllData();
const jsonString = JSON.stringify(data, null, 2);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `web-app-launcher-export-${timestamp}.json`;
return new Response(jsonString, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="${filename}"`
}
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to export data';
return json(error(message), { status: 500 });
}
};
-49
View File
@@ -1,49 +0,0 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { validateImportData, importData } from '$lib/server/services/importService.js';
import type { ImportMode } from '$lib/server/services/importService.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
/**
* POST /api/admin/import — Import data from JSON. Admin only.
* Body: { data: ExportData, mode: "skip" | "overwrite" }
*/
export const POST: RequestHandler = async (event) => {
const admin = requireAdmin(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
if (!body || typeof body !== 'object') {
return json(error('Request body must be an object'), { status: 400 });
}
const { data, mode } = body as { data: unknown; mode: unknown };
if (!data) {
return json(error('Missing "data" field in request body'), { status: 400 });
}
const validMode: ImportMode = mode === 'overwrite' ? 'overwrite' : 'skip';
const validation = validateImportData(data);
if (!validation.success) {
return json(error(`Validation failed: ${validation.errors.join('; ')}`), { status: 400 });
}
try {
const result = await importData(validation.data, validMode);
logAction(admin.id, AuditAction.IMPORT, 'system', 'import', { mode: validMode });
return json(success(result));
} catch (err) {
const message = err instanceof Error ? err.message : 'Import failed';
return json(error(message), { status: 500 });
}
};