Merge branch 'feature/database-backup'
This commit is contained in:
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -201,6 +201,9 @@ model SystemSettings {
|
|||||||
healthcheckDefaults String @default("{}") // JSON stored as string for SQLite
|
healthcheckDefaults String @default("{}") // JSON stored as string for SQLite
|
||||||
customCss String?
|
customCss String?
|
||||||
onboardingComplete Boolean @default(false)
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import * as userService from '$lib/server/services/userService.js';
|
|||||||
import * as apiTokenService from '$lib/server/services/apiTokenService.js';
|
import * as apiTokenService from '$lib/server/services/apiTokenService.js';
|
||||||
import { extractBearerToken } from '$lib/server/middleware/authenticate.js';
|
import { extractBearerToken } from '$lib/server/middleware/authenticate.js';
|
||||||
import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.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'];
|
const PUBLIC_PATHS = ['/login', '/register', '/auth/', '/api/health', '/api/onboarding', '/status'];
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -235,22 +235,37 @@
|
|||||||
"admin.discovery_traefik_url": "Traefik API URL",
|
"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.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.backup_title": "Database Backup",
|
||||||
"admin.import_export_description": "Export all data (apps, boards, groups, settings) as JSON, or import from a previously exported file.",
|
"admin.backup_description": "Create, restore, and schedule backups of your database. Backups are full copies of the SQLite database file.",
|
||||||
"admin.export_section": "Export Data",
|
"admin.backup_create": "Create Backup",
|
||||||
"admin.export_button": "Export JSON",
|
"admin.backup_creating": "Creating...",
|
||||||
"admin.export_exporting": "Exporting...",
|
"admin.backup_create_success": "Backup created successfully.",
|
||||||
"admin.export_success": "Export downloaded successfully.",
|
"admin.backup_list_title": "Backups",
|
||||||
"admin.import_section": "Import Data",
|
"admin.backup_list_empty": "No backups yet. Create your first backup above.",
|
||||||
"admin.import_select_file": "Select a JSON export file",
|
"admin.backup_filename": "Filename",
|
||||||
"admin.import_preview": "Preview",
|
"admin.backup_size": "Size",
|
||||||
"admin.import_mode_label": "Conflict Resolution",
|
"admin.backup_date": "Created",
|
||||||
"admin.import_mode_skip": "Skip existing (keep current data)",
|
"admin.backup_actions": "Actions",
|
||||||
"admin.import_mode_overwrite": "Overwrite existing (replace with imported data)",
|
"admin.backup_download": "Download",
|
||||||
"admin.import_button": "Import",
|
"admin.backup_restore": "Restore",
|
||||||
"admin.import_importing": "Importing...",
|
"admin.backup_delete": "Delete",
|
||||||
"admin.import_success": "Import completed.",
|
"admin.backup_restore_confirm_title": "Restore Backup",
|
||||||
"admin.import_invalid_json": "Selected file is not valid JSON.",
|
"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.placeholder": "Search apps and boards...",
|
||||||
"search.trigger": "Search...",
|
"search.trigger": "Search...",
|
||||||
|
|||||||
+31
-16
@@ -224,22 +224,37 @@
|
|||||||
"admin.discovery_traefik_url": "URL API Traefik",
|
"admin.discovery_traefik_url": "URL API Traefik",
|
||||||
"admin.discovery_traefik_url_hint": "Базовый URL API Traefik (напр. http://traefik:8080). Задаётся через TRAEFIK_API_URL.",
|
"admin.discovery_traefik_url_hint": "Базовый URL API Traefik (напр. http://traefik:8080). Задаётся через TRAEFIK_API_URL.",
|
||||||
|
|
||||||
"admin.import_export_title": "Импорт / Экспорт",
|
"admin.backup_title": "Резервное копирование",
|
||||||
"admin.import_export_description": "Экспортируйте все данные (приложения, доски, группы, настройки) в формате JSON или импортируйте из ранее экспортированного файла.",
|
"admin.backup_description": "Создавайте, восстанавливайте и планируйте резервные копии базы данных. Копии — это полные дубликаты файла базы SQLite.",
|
||||||
"admin.export_section": "Экспорт данных",
|
"admin.backup_create": "Создать копию",
|
||||||
"admin.export_button": "Экспорт JSON",
|
"admin.backup_creating": "Создание...",
|
||||||
"admin.export_exporting": "Экспорт...",
|
"admin.backup_create_success": "Резервная копия успешно создана.",
|
||||||
"admin.export_success": "Экспорт успешно скачан.",
|
"admin.backup_list_title": "Резервные копии",
|
||||||
"admin.import_section": "Импорт данных",
|
"admin.backup_list_empty": "Копий пока нет. Создайте первую копию выше.",
|
||||||
"admin.import_select_file": "Выберите JSON-файл экспорта",
|
"admin.backup_filename": "Файл",
|
||||||
"admin.import_preview": "Предпросмотр",
|
"admin.backup_size": "Размер",
|
||||||
"admin.import_mode_label": "Разрешение конфликтов",
|
"admin.backup_date": "Создана",
|
||||||
"admin.import_mode_skip": "Пропустить существующие (оставить текущие данные)",
|
"admin.backup_actions": "Действия",
|
||||||
"admin.import_mode_overwrite": "Перезаписать существующие (заменить импортированными)",
|
"admin.backup_download": "Скачать",
|
||||||
"admin.import_button": "Импортировать",
|
"admin.backup_restore": "Восстановить",
|
||||||
"admin.import_importing": "Импорт...",
|
"admin.backup_delete": "Удалить",
|
||||||
"admin.import_success": "Импорт завершён.",
|
"admin.backup_restore_confirm_title": "Восстановление из копии",
|
||||||
"admin.import_invalid_json": "Выбранный файл не является корректным JSON.",
|
"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.placeholder": "Поиск приложений и досок...",
|
||||||
"search.trigger": "Поиск...",
|
"search.trigger": "Поиск...",
|
||||||
"search.min_chars": "Введите минимум 2 символа для поиска",
|
"search.min_chars": "Введите минимум 2 символа для поиска",
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -137,7 +137,10 @@ export const AuditAction = {
|
|||||||
APP_DELETED: 'app_deleted',
|
APP_DELETED: 'app_deleted',
|
||||||
SETTINGS_UPDATED: 'settings_updated',
|
SETTINGS_UPDATED: 'settings_updated',
|
||||||
IMPORT: 'import',
|
IMPORT: 'import',
|
||||||
EXPORT: 'export'
|
EXPORT: 'export',
|
||||||
|
BACKUP_CREATED: 'backup_created',
|
||||||
|
BACKUP_RESTORED: 'backup_restored',
|
||||||
|
BACKUP_DELETED: 'backup_deleted'
|
||||||
} as const;
|
} as const;
|
||||||
export type AuditAction = (typeof AuditAction)[keyof typeof AuditAction];
|
export type AuditAction = (typeof AuditAction)[keyof typeof AuditAction];
|
||||||
|
|
||||||
|
|||||||
@@ -220,69 +220,12 @@ export const createPermissionSchema = z.object({
|
|||||||
level: z.enum([PermissionLevel.VIEW, PermissionLevel.EDIT, PermissionLevel.ADMIN])
|
level: z.enum([PermissionLevel.VIEW, PermissionLevel.EDIT, PermissionLevel.ADMIN])
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Import/Export ---
|
// --- Backup Schedule ---
|
||||||
|
|
||||||
const importAppSchema = z.object({
|
export const updateBackupScheduleSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
backupEnabled: z.boolean().optional(),
|
||||||
url: z.string().url(),
|
backupCronExpression: z.string().min(1).max(100).optional(),
|
||||||
icon: z.string().max(500).nullable(),
|
backupMaxCount: z.number().int().min(1).max(100).optional()
|
||||||
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
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- System Settings ---
|
// --- System Settings ---
|
||||||
@@ -468,7 +411,10 @@ export const auditLogQuerySchema = z.object({
|
|||||||
AuditAction.APP_DELETED,
|
AuditAction.APP_DELETED,
|
||||||
AuditAction.SETTINGS_UPDATED,
|
AuditAction.SETTINGS_UPDATED,
|
||||||
AuditAction.IMPORT,
|
AuditAction.IMPORT,
|
||||||
AuditAction.EXPORT
|
AuditAction.EXPORT,
|
||||||
|
AuditAction.BACKUP_CREATED,
|
||||||
|
AuditAction.BACKUP_RESTORED,
|
||||||
|
AuditAction.BACKUP_DELETED
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
entityType: z.string().max(50).optional(),
|
entityType: z.string().max(50).optional(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types.js';
|
import type { PageData } from './$types.js';
|
||||||
import SettingsForm from '$lib/components/admin/SettingsForm.svelte';
|
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';
|
import DiscoveryPanel from '$lib/components/admin/DiscoveryPanel.svelte';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
@@ -25,5 +25,5 @@
|
|||||||
|
|
||||||
<DiscoveryPanel bind:dockerSocketPath bind:traefikApiUrl />
|
<DiscoveryPanel bind:dockerSocketPath bind:traefikApiUrl />
|
||||||
|
|
||||||
<ImportExportPanel />
|
<BackupPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user