feat(phase3): import/export, sparklines, user theme overrides

- JSON import/export with conflict resolution (skip/overwrite) + admin UI
- Ping history sparklines on AppWidget and AppCard (24h, 288 points)
- Hourly cleanup job for old AppStatus records
- User theme preferences (hue, saturation, mode, background, locale)
- Settings page with ThemeCustomizer (sliders, toggles, live preview)
- Prisma migration for user preference fields
- i18n translations for all new strings (EN/RU)
This commit is contained in:
2026-03-25 00:51:01 +03:00
parent d155b3ce4a
commit c6a7de895d
30 changed files with 1633 additions and 44 deletions
+21 -1
View File
@@ -1,7 +1,27 @@
# Feature Context: Phase 3 — Advanced Features
## Current State
Phase 2 is complete and merged. 176 tests, full build passes. Starting Phase 3 advanced features.
Phase 2 is complete and merged. 176 tests, full build passes. Phase 3 in progress. Phase 1 (Import/Export), Phase 2 (Sparklines), and Phase 3 (User Theme Overrides) are complete.
### Phase 1 (Import/Export) Summary
exportService, importService, admin API endpoints, ImportExportPanel UI, Zod validation schema, i18n EN/RU translations.
### Phase 2 (Sparklines) Summary
- History API at `/api/apps/[id]/history` — returns last 288 status records with uptime percentage
- `SparklineChart.svelte` — inline SVG bar chart with color-coded status bars (green/red/yellow/gray)
- `AppWidget.svelte` and `AppCard.svelte` updated to fetch and display sparklines on mount
- `pruneOldStatuses()` in healthcheck service — deletes records >24h, caps at 288 per app
- Hourly cleanup cron job in healthcheck scheduler
- i18n keys: `app.uptime`, `app.history_loading` (EN/RU)
### Phase 3 (User Theme Overrides) Summary
- Prisma migration: added `themeMode`, `primaryHue`, `primarySaturation`, `backgroundType`, `locale` nullable fields to User model
- Preferences API at `/api/users/me/preferences` — GET returns preferences, PATCH updates subset
- Settings page at `/settings` with `ThemeCustomizer.svelte` — hue/saturation sliders, mode toggle (dark/light/system), background selector, locale picker, save button
- Theme store `loadFromServer(prefs)` method applies server preferences over localStorage defaults
- `+layout.server.ts` passes `userPreferences` in layout data; `+layout.svelte` applies them on mount
- Header user menu includes "Settings" link
- i18n keys: `settings.title`, `settings.theme`, `settings.primary_color`, `settings.hue`, `settings.saturation`, `settings.background`, `settings.language`, `settings.save`, `settings.saving`, `settings.saved` (EN/RU)
## Cross-Phase Dependencies
- Phases 1-3 are independent (import/export, sparklines, user themes)
+5 -5
View File
@@ -19,9 +19,9 @@ Add import/export, ping history sparklines, user theme overrides, PWA support, D
## Phases
- [ ] Phase 1: Import/Export [fullstack] → [subplan](./phase-1-import-export.md)
- [x] Phase 1: Import/Export [fullstack] → [subplan](./phase-1-import-export.md)
- [ ] Phase 2: Ping History Sparklines [fullstack] → [subplan](./phase-2-sparklines.md)
- [ ] Phase 3: User Theme Overrides [fullstack] → [subplan](./phase-3-user-themes.md)
- [x] Phase 3: User Theme Overrides [fullstack] → [subplan](./phase-3-user-themes.md)
- [ ] Phase 4: PWA Support [frontend] → [subplan](./phase-4-pwa.md)
- [ ] Phase 5: Auto-Discovery Docker/Traefik [backend] → [subplan](./phase-5-autodiscovery.md)
- [ ] Phase 6: Bookmarklet & Multi-Tab Sync [fullstack] → [subplan](./phase-6-bookmarklet-sync.md)
@@ -31,9 +31,9 @@ Add import/export, ping history sparklines, user theme overrides, PWA support, D
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 1: Import/Export | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 2: Sparklines | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 3: User Themes | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 1: Import/Export | fullstack | ✅ Done | ⬜ | ⬜ | ⬜ |
| Phase 2: Sparklines | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ |
| Phase 3: User Themes | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ |
| Phase 4: PWA | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 5: Auto-Discovery | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 6: Bookmarklet/Sync | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
@@ -1,18 +1,19 @@
# Phase 1: Import/Export
**Status:** ⬜ Not Started
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Tasks
- [ ] Task 1: Create `src/lib/server/services/exportService.ts` — export all data (apps, boards, sections, widgets, groups, settings) as JSON
- [ ] Task 2: Create `src/lib/server/services/importService.ts` — import JSON with conflict resolution (skip/overwrite)
- [ ] Task 3: Create `src/routes/api/admin/export/+server.ts` — GET endpoint, returns JSON file download
- [ ] Task 4: Create `src/routes/api/admin/import/+server.ts` — POST endpoint, accepts JSON upload
- [ ] Task 5: Update admin settings page — add Import/Export section with download button and file upload
- [ ] Task 6: Create `src/lib/components/admin/ImportExportPanel.svelte` — UI with export button, file picker, preview, and import button
- [ ] Task 7: Add Zod schema for validating import data structure
- [ ] Task 8: Add i18n translations for import/export strings (EN/RU)
- [x] Task 1: Create `src/lib/server/services/exportService.ts` — export all data (apps, boards, sections, widgets, groups, settings) as JSON
- [x] Task 2: Create `src/lib/server/services/importService.ts` — import JSON with conflict resolution (skip/overwrite)
- [x] Task 3: Create `src/routes/api/admin/export/+server.ts` — GET endpoint, returns JSON file download
- [x] Task 4: Create `src/routes/api/admin/import/+server.ts` — POST endpoint, accepts JSON upload
- [x] Task 5: Update admin settings page — add Import/Export section with download button and file upload
- [x] Task 6: Create `src/lib/components/admin/ImportExportPanel.svelte` — UI with export button, file picker, preview, and import button
- [x] Task 7: Add Zod schema for validating import data structure
- [x] Task 8: Add i18n translations for import/export strings (EN/RU)
## Handoff to Next Phase
<!-- Filled in after completion -->
All import/export functionality implemented. Export service gathers all apps, boards (with sections/widgets), groups, and system settings into a versioned JSON structure. Import service validates with Zod, supports skip/overwrite conflict resolution, and runs in a Prisma transaction. Admin-only API endpoints with Content-Disposition for file download. UI panel with file upload, JSON preview, mode selector, and status feedback.
@@ -1,18 +1,20 @@
# Phase 2: Ping History Sparklines
**Status:** ⬜ Not Started
**Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Tasks
- [ ] Task 1: Create `src/routes/api/apps/[id]/history/+server.ts` — GET last 24h of healthcheck results
- [ ] Task 2: Create `src/lib/components/app/SparklineChart.svelte` — tiny inline SVG sparkline (green=up, red=down)
- [ ] Task 3: Update `src/lib/components/widget/AppWidget.svelte` — show sparkline below status badge
- [ ] Task 4: Update `src/lib/components/app/AppCard.svelte` — show sparkline on app cards
- [ ] Task 5: Calculate and display uptime percentage (last 24h)
- [ ] Task 6: Update healthcheck service to retain last 288 records per app (24h at 5min intervals)
- [ ] Task 7: Add cleanup job to prune old AppStatus records beyond retention period
- [ ] Task 8: Add i18n translations (EN/RU)
- [x] Task 1: Create `src/routes/api/apps/[id]/history/+server.ts` — GET last 24h of healthcheck results
- [x] Task 2: Create `src/lib/components/app/SparklineChart.svelte` — tiny inline SVG sparkline (green=up, red=down)
- [x] Task 3: Update `src/lib/components/widget/AppWidget.svelte` — show sparkline below status badge
- [x] Task 4: Update `src/lib/components/app/AppCard.svelte` — show sparkline on app cards
- [x] Task 5: Calculate and display uptime percentage (last 24h)
- [x] Task 6: Update healthcheck service to retain last 288 records per app (24h at 5min intervals)
- [x] Task 7: Add cleanup job to prune old AppStatus records beyond retention period
- [x] Task 8: Add i18n translations (EN/RU)
## Handoff to Next Phase
<!-- Filled in after completion -->
All sparkline features implemented. History API returns last 288 records with uptime percentage. SparklineChart renders color-coded bars (green/red/yellow/gray). Cleanup job prunes records older than 24h hourly. Both AppWidget and AppCard fetch and display sparklines with uptime percentage on mount.
@@ -1,19 +1,19 @@
# Phase 3: User Theme Overrides
**Status:** ⬜ Not Started
**Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Tasks
- [ ] Task 1: Add `themeMode`, `primaryHue`, `primarySaturation`, `backgroundType`, `locale` fields to User model (Prisma migration)
- [ ] Task 2: Create `src/routes/api/users/me/preferences/+server.ts` — GET/PATCH user preferences
- [ ] Task 3: Create `src/routes/settings/+page.server.ts` — user settings page data
- [ ] Task 4: Create `src/routes/settings/+page.svelte` — user settings page with theme customization
- [ ] Task 5: Create `src/lib/components/settings/ThemeCustomizer.svelte` — HSL color picker, background selector, mode toggle
- [ ] Task 6: Update theme store to load user preferences from server on login
- [ ] Task 7: Update `+layout.server.ts` to pass user preferences
- [ ] Task 8: Add user settings link to header user menu
- [ ] Task 9: Add i18n translations (EN/RU)
- [x] Task 1: Add `themeMode`, `primaryHue`, `primarySaturation`, `backgroundType`, `locale` fields to User model (Prisma migration)
- [x] Task 2: Create `src/routes/api/users/me/preferences/+server.ts` — GET/PATCH user preferences
- [x] Task 3: Create `src/routes/settings/+page.server.ts` — user settings page data
- [x] Task 4: Create `src/routes/settings/+page.svelte` — user settings page with theme customization
- [x] Task 5: Create `src/lib/components/settings/ThemeCustomizer.svelte` — HSL color picker, background selector, mode toggle
- [x] Task 6: Update theme store to load user preferences from server on login
- [x] Task 7: Update `+layout.server.ts` to pass user preferences
- [x] Task 8: Add user settings link to header user menu
- [x] Task 9: Add i18n translations (EN/RU)
## Handoff to Next Phase
<!-- Filled in after completion -->
Phase 3 (User Theme Overrides) complete. Added nullable preference fields to User model, preferences API (GET/PATCH), settings page with ThemeCustomizer component (hue/saturation sliders, mode toggle, background selector, locale picker), server-side preference loading in layout, and Settings link in Header user menu. i18n translations added for EN and RU.
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "backgroundType" TEXT;
ALTER TABLE "User" ADD COLUMN "locale" TEXT;
ALTER TABLE "User" ADD COLUMN "primaryHue" INTEGER;
ALTER TABLE "User" ADD COLUMN "primarySaturation" INTEGER;
ALTER TABLE "User" ADD COLUMN "themeMode" TEXT;
+6
View File
@@ -20,6 +20,12 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
themeMode String?
primaryHue Int?
primarySaturation Int?
backgroundType String?
locale String?
groups UserGroup[]
createdApps App[]
boards Board[]
@@ -0,0 +1,212 @@
<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>
+41
View File
@@ -1,5 +1,8 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { onMount } from 'svelte';
import AppHealthBadge from './AppHealthBadge.svelte';
import SparklineChart from './SparklineChart.svelte';
interface AppWithStatus {
id: string;
@@ -12,14 +15,40 @@
statuses: Array<{ status: string; responseTime: number | null; checkedAt: string | Date }>;
}
interface StatusPoint {
status: string;
checkedAt: string;
}
interface Props {
app: AppWithStatus;
}
let { app }: Props = $props();
let historyData: StatusPoint[] = $state([]);
let uptimePercent: number | null = $state(null);
let historyLoading = $state(true);
const currentStatus = $derived(app.statuses?.[0]?.status ?? 'unknown');
onMount(async () => {
try {
const res = await fetch(`/api/apps/${app.id}/history`);
if (res.ok) {
const json = await res.json();
if (json.success && json.data) {
historyData = json.data.history ?? [];
uptimePercent = json.data.uptimePercent ?? null;
}
}
} catch {
// Silently fail — sparkline is non-critical
} finally {
historyLoading = false;
}
});
const iconDisplay = $derived.by(() => {
if (!app.icon) return null;
@@ -75,6 +104,18 @@
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">{app.description}</p>
{/if}
<!-- Sparkline -->
{#if historyLoading}
<div class="mt-2 h-5 w-20 animate-pulse rounded bg-muted"></div>
{:else if historyData.length > 0}
<div class="mt-2 flex items-center gap-1.5">
<SparklineChart data={historyData} />
{#if uptimePercent !== null}
<span class="text-[10px] text-muted-foreground">{uptimePercent}% {$t('app.uptime')}</span>
{/if}
</div>
{/if}
{#if app.category}
<span
class="mt-2 inline-block self-start rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
@@ -0,0 +1,51 @@
<script lang="ts">
interface StatusPoint {
status: string;
checkedAt: string;
}
interface Props {
data: StatusPoint[];
width?: number;
height?: number;
}
let { data, width = 80, height = 20 }: Props = $props();
const STATUS_COLORS: Record<string, string> = {
online: '#22c55e',
offline: '#ef4444',
degraded: '#eab308',
unknown: '#6b7280'
};
const barWidth = $derived(data.length > 0 ? Math.max(1, (width - 2) / data.length) : 1);
const bars = $derived(
data.map((point, i) => ({
x: 1 + i * barWidth,
color: STATUS_COLORS[point.status] ?? STATUS_COLORS.unknown
}))
);
</script>
<svg
{width}
{height}
viewBox="0 0 {width} {height}"
role="img"
aria-label="Status history sparkline"
class="inline-block align-middle"
>
{#each bars as bar}
<rect
x={bar.x}
y={2}
width={Math.max(0.5, barWidth - 0.5)}
height={height - 4}
fill={bar.color}
rx="0.5"
opacity="0.85"
/>
{/each}
</svg>
+21
View File
@@ -161,6 +161,27 @@
<p class="truncate text-xs text-muted-foreground">{user.email}</p>
</div>
<a
href="/settings"
onclick={() => (showUserMenu = false)}
class="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
>
<svg
class="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
{$t('settings.title')}
</a>
<form method="POST" action="/auth/logout">
<button
type="submit"
@@ -0,0 +1,238 @@
<script lang="ts">
import { t, locale as i18nLocale } from 'svelte-i18n';
import { theme, type ThemeMode, type BackgroundType } from '$lib/stores/theme.svelte.js';
interface UserPreferences {
themeMode: string | null;
primaryHue: number | null;
primarySaturation: number | null;
backgroundType: string | null;
locale: string | null;
}
interface Props {
preferences: UserPreferences;
}
let { preferences }: Props = $props();
let saving = $state(false);
let saved = $state(false);
let errorMessage = $state('');
const themeModes: { value: ThemeMode; labelKey: string }[] = [
{ value: 'dark', labelKey: 'theme.dark' },
{ value: 'light', labelKey: 'theme.light' },
{ value: 'system', labelKey: 'theme.system' }
];
const bgOptions: { value: BackgroundType; labelKey: string }[] = [
{ value: 'none', labelKey: 'bg.none' },
{ value: 'mesh', labelKey: 'bg.mesh' },
{ value: 'particles', labelKey: 'bg.particles' },
{ value: 'aurora', labelKey: 'bg.aurora' }
];
const localeOptions = [
{ value: 'en', label: 'English' },
{ value: 'ru', label: 'Русский' }
];
// Generate hue gradient CSS for the hue slider track
const hueGradient = $derived.by(() => {
const stops = Array.from({ length: 13 }, (_, i) => {
const h = i * 30;
return `hsl(${h}, ${theme.primarySaturation}%, 50%)`;
});
return `linear-gradient(to right, ${stops.join(', ')})`;
});
// Generate saturation gradient for the saturation slider track
const satGradient = $derived(
`linear-gradient(to right, hsl(${theme.primaryHue}, 0%, 50%), hsl(${theme.primaryHue}, 100%, 50%))`
);
// Color preview
const previewColor = $derived(
`hsl(${theme.primaryHue}, ${theme.primarySaturation}%, 50%)`
);
function setMode(mode: ThemeMode) {
theme.setMode(mode);
}
function setBackground(bg: BackgroundType) {
theme.setBackground(bg);
}
function setLocale(loc: string) {
i18nLocale.set(loc);
}
async function savePreferences() {
saving = true;
saved = false;
errorMessage = '';
try {
const body = {
themeMode: theme.mode,
primaryHue: theme.primaryHue,
primarySaturation: theme.primarySaturation,
backgroundType: theme.backgroundType,
locale: $i18nLocale ?? 'en'
};
const res = await fetch('/api/users/me/preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const data = await res.json();
errorMessage = data.error ?? 'Failed to save preferences';
return;
}
saved = true;
setTimeout(() => {
saved = false;
}, 2000);
} catch {
errorMessage = 'Network error';
} finally {
saving = false;
}
}
</script>
<div class="space-y-8">
<!-- Theme Mode -->
<section>
<h2 class="mb-3 text-lg font-semibold text-foreground">{$t('settings.theme')}</h2>
<div class="flex gap-1 rounded-lg border border-border bg-muted/50 p-1">
{#each themeModes as opt (opt.value)}
<button
type="button"
onclick={() => setMode(opt.value)}
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {theme.mode === opt.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
>
{$t(opt.labelKey)}
</button>
{/each}
</div>
</section>
<!-- Primary Color -->
<section>
<h2 class="mb-3 text-lg font-semibold text-foreground">{$t('settings.primary_color')}</h2>
<!-- Color preview -->
<div class="mb-4 flex items-center gap-3">
<div
class="h-10 w-10 rounded-lg border border-border"
style="background-color: {previewColor};"
></div>
<span class="text-sm text-muted-foreground">
HSL({theme.primaryHue}, {theme.primarySaturation}%, 50%)
</span>
</div>
<!-- Hue slider -->
<div class="mb-4">
<label class="mb-1.5 block text-sm text-muted-foreground">{$t('settings.hue')}</label>
<div class="relative">
<div
class="h-3 w-full rounded-full"
style="background: {hueGradient};"
></div>
<input
type="range"
min="0"
max="360"
step="1"
bind:value={theme.primaryHue}
class="absolute inset-0 h-3 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-current [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:shadow-md"
style="color: {previewColor};"
/>
</div>
</div>
<!-- Saturation slider -->
<div>
<label class="mb-1.5 block text-sm text-muted-foreground">{$t('settings.saturation')}</label>
<div class="relative">
<div
class="h-3 w-full rounded-full"
style="background: {satGradient};"
></div>
<input
type="range"
min="0"
max="100"
step="1"
bind:value={theme.primarySaturation}
class="absolute inset-0 h-3 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-current [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:shadow-md"
style="color: {previewColor};"
/>
</div>
</div>
</section>
<!-- Background -->
<section>
<h2 class="mb-3 text-lg font-semibold text-foreground">{$t('settings.background')}</h2>
<div class="flex gap-1 rounded-lg border border-border bg-muted/50 p-1">
{#each bgOptions as opt (opt.value)}
<button
type="button"
onclick={() => setBackground(opt.value)}
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {theme.backgroundType === opt.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
>
{$t(opt.labelKey)}
</button>
{/each}
</div>
</section>
<!-- Locale -->
<section>
<h2 class="mb-3 text-lg font-semibold text-foreground">{$t('settings.language')}</h2>
<div class="flex gap-1 rounded-lg border border-border bg-muted/50 p-1">
{#each localeOptions as opt (opt.value)}
<button
type="button"
onclick={() => setLocale(opt.value)}
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {$i18nLocale === opt.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
>
{opt.label}
</button>
{/each}
</div>
</section>
<!-- Save button -->
<div class="flex items-center gap-3">
<button
type="button"
onclick={savePreferences}
disabled={saving}
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{saving ? $t('settings.saving') : $t('settings.save')}
</button>
{#if saved}
<span class="text-sm text-green-500">{$t('settings.saved')}</span>
{/if}
{#if errorMessage}
<span class="text-sm text-destructive">{errorMessage}</span>
{/if}
</div>
</div>
@@ -1,5 +1,8 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { onMount } from 'svelte';
import AppHealthBadge from '$lib/components/app/AppHealthBadge.svelte';
import SparklineChart from '$lib/components/app/SparklineChart.svelte';
interface AppData {
id: string;
@@ -11,12 +14,21 @@
statuses: Array<{ status: string; responseTime: number | null }>;
}
interface StatusPoint {
status: string;
checkedAt: string;
}
interface Props {
app: AppData;
}
let { app }: Props = $props();
let historyData: StatusPoint[] = $state([]);
let uptimePercent: number | null = $state(null);
let historyLoading = $state(true);
const latestStatus = $derived(app.statuses[0]?.status ?? 'unknown');
const iconSrc = $derived.by(() => {
@@ -33,6 +45,23 @@
return null;
}
});
onMount(async () => {
try {
const res = await fetch(`/api/apps/${app.id}/history`);
if (res.ok) {
const json = await res.json();
if (json.success && json.data) {
historyData = json.data.history ?? [];
uptimePercent = json.data.uptimePercent ?? null;
}
}
} catch {
// Silently fail — sparkline is non-critical
} finally {
historyLoading = false;
}
});
</script>
<a
@@ -65,4 +94,16 @@
<!-- Status -->
<AppHealthBadge status={latestStatus} />
<!-- Sparkline -->
{#if historyLoading}
<div class="h-5 w-20 animate-pulse rounded bg-muted"></div>
{:else if historyData.length > 0}
<div class="flex items-center gap-1">
<SparklineChart data={historyData} />
{#if uptimePercent !== null}
<span class="text-[10px] text-muted-foreground">{uptimePercent}%</span>
{/if}
</div>
{/if}
</a>
+31 -1
View File
@@ -127,6 +127,8 @@
"app.healthcheck_timeout": "Timeout (ms)",
"app.healthcheck_interval": "Interval (seconds)",
"app.icon_board_label": "Icon (Lucide name)",
"app.uptime": "uptime",
"app.history_loading": "Loading history...",
"admin.panel": "Admin Panel",
"admin.users": "Users",
@@ -213,6 +215,23 @@
"admin.perm_none": "No permissions configured.",
"admin.perm_search_placeholder": "Type to search...",
"admin.import_export_title": "Import / Export",
"admin.import_export_description": "Export all data (apps, boards, groups, settings) as JSON, or import from a previously exported file.",
"admin.export_section": "Export Data",
"admin.export_button": "Export JSON",
"admin.export_exporting": "Exporting...",
"admin.export_success": "Export downloaded successfully.",
"admin.import_section": "Import Data",
"admin.import_select_file": "Select a JSON export file",
"admin.import_preview": "Preview",
"admin.import_mode_label": "Conflict Resolution",
"admin.import_mode_skip": "Skip existing (keep current data)",
"admin.import_mode_overwrite": "Overwrite existing (replace with imported data)",
"admin.import_button": "Import",
"admin.import_importing": "Importing...",
"admin.import_success": "Import completed.",
"admin.import_invalid_json": "Selected file is not valid JSON.",
"search.placeholder": "Search apps and boards...",
"search.trigger": "Search...",
"search.min_chars": "Type at least 2 characters to search",
@@ -261,5 +280,16 @@
"home.view_boards": "View Boards",
"home.browse_apps": "Browse Apps",
"language.label": "Language"
"language.label": "Language",
"settings.title": "Settings",
"settings.theme": "Theme Mode",
"settings.primary_color": "Primary Color",
"settings.hue": "Hue",
"settings.saturation": "Saturation",
"settings.background": "Background Effect",
"settings.language": "Language",
"settings.save": "Save Preferences",
"settings.saving": "Saving...",
"settings.saved": "Preferences saved!"
}
+32 -2
View File
@@ -127,6 +127,8 @@
"app.healthcheck_timeout": "\u0422\u0430\u0439\u043c\u0430\u0443\u0442 (\u043c\u0441)",
"app.healthcheck_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b (\u0441\u0435\u043a\u0443\u043d\u0434\u044b)",
"app.icon_board_label": "\u0418\u043a\u043e\u043d\u043a\u0430 (Lucide)",
"app.uptime": "\u0430\u043f\u0442\u0430\u0439\u043c",
"app.history_loading": "\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u0438\u0441\u0442\u043e\u0440\u0438\u0438...",
"admin.panel": "\u041f\u0430\u043d\u0435\u043b\u044c \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0430",
"admin.users": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438",
@@ -213,7 +215,24 @@
"admin.perm_none": "\u041f\u0440\u0430\u0432\u0430 \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b.",
"admin.perm_search_placeholder": "\u041d\u0430\u0447\u043d\u0438\u0442\u0435 \u0432\u0432\u043e\u0434\u0438\u0442\u044c...",
"search.placeholder": "\u041f\u043e\u0438\u0441\u043a \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439 \u0438 \u0434\u043e\u0441\u043e\u043a...",
"admin.import_export_title": "Импорт / Экспорт",
"admin.import_export_description": "Экспортируйте все данные (приложения, доски, группы, настройки) в формате JSON или импортируйте из ранее экспортированного файла.",
"admin.export_section": "Экспорт данных",
"admin.export_button": "Экспорт JSON",
"admin.export_exporting": "Экспорт...",
"admin.export_success": "Экспорт успешно скачан.",
"admin.import_section": "Импорт данных",
"admin.import_select_file": "Выберите JSON-файл экспорта",
"admin.import_preview": "Предпросмотр",
"admin.import_mode_label": "Разрешение конфликтов",
"admin.import_mode_skip": "Пропустить существующие (оставить текущие данные)",
"admin.import_mode_overwrite": "Перезаписать существующие (заменить импортированными)",
"admin.import_button": "Импортировать",
"admin.import_importing": "Импорт...",
"admin.import_success": "Импорт завершён.",
"admin.import_invalid_json": "Выбранный файл не является корректным JSON.",
"search.placeholder": "Поиск приложений и досок...",
"search.trigger": "\u041f\u043e\u0438\u0441\u043a...",
"search.min_chars": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043c\u0438\u043d\u0438\u043c\u0443\u043c 2 \u0441\u0438\u043c\u0432\u043e\u043b\u0430 \u0434\u043b\u044f \u043f\u043e\u0438\u0441\u043a\u0430",
"search.no_results": "\u041d\u0438\u0447\u0435\u0433\u043e \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e \u043f\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u0443 \u00ab{query}\u00bb",
@@ -261,5 +280,16 @@
"home.view_boards": "\u041f\u043e\u0441\u043c\u043e\u0442\u0440\u0435\u0442\u044c \u0434\u043e\u0441\u043a\u0438",
"home.browse_apps": "\u041e\u0431\u0437\u043e\u0440 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439",
"language.label": "\u042f\u0437\u044b\u043a"
"language.label": "\u042f\u0437\u044b\u043a",
"settings.title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438",
"settings.theme": "\u0420\u0435\u0436\u0438\u043c \u0442\u0435\u043c\u044b",
"settings.primary_color": "\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0446\u0432\u0435\u0442",
"settings.hue": "\u041e\u0442\u0442\u0435\u043d\u043e\u043a",
"settings.saturation": "\u041d\u0430\u0441\u044b\u0449\u0435\u043d\u043d\u043e\u0441\u0442\u044c",
"settings.background": "\u042d\u0444\u0444\u0435\u043a\u0442 \u0444\u043e\u043d\u0430",
"settings.language": "\u042f\u0437\u044b\u043a",
"settings.save": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438",
"settings.saving": "\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435...",
"settings.saved": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u044b!"
}
+17 -2
View File
@@ -1,11 +1,13 @@
import cron from 'node-cron';
import { checkAllApps } from '$lib/server/services/healthcheckService.js';
import { checkAllApps, pruneOldStatuses } from '$lib/server/services/healthcheckService.js';
let scheduledTask: cron.ScheduledTask | null = null;
let cleanupTask: cron.ScheduledTask | null = null;
/**
* Start the healthcheck scheduler.
* Runs checkAllApps on a cron schedule (default: every 60 seconds).
* Also starts an hourly cleanup job to prune old status records.
*/
export function startScheduler(cronExpression: string = '* * * * *'): void {
if (scheduledTask) {
@@ -20,6 +22,15 @@ export function startScheduler(cronExpression: string = '* * * * *'): void {
}
});
// Cleanup job: run every hour at minute 0
cleanupTask = cron.schedule('0 * * * *', async () => {
try {
await pruneOldStatuses();
} catch {
// Swallow errors to prevent scheduler crash
}
});
// Run an initial check shortly after startup
setTimeout(() => {
checkAllApps().catch(() => {
@@ -29,11 +40,15 @@ export function startScheduler(cronExpression: string = '* * * * *'): void {
}
/**
* Stop the healthcheck scheduler.
* Stop the healthcheck scheduler and cleanup job.
*/
export function stopScheduler(): void {
if (scheduledTask) {
scheduledTask.stop();
scheduledTask = null;
}
if (cleanupTask) {
cleanupTask.stop();
cleanupTask = null;
}
}
+154
View File
@@ -0,0 +1,154 @@
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,6 +1,10 @@
import * as appService from './appService.js';
import { prisma } from '../prisma.js';
import { AppStatusValue } from '$lib/utils/constants.js';
const MAX_RECORDS_PER_APP = 288;
const RETENTION_HOURS = 24;
export interface HealthcheckResult {
readonly appId: string;
readonly status: string;
@@ -81,3 +85,50 @@ export async function checkAllApps(): Promise<readonly HealthcheckResult[]> {
return outcomes;
}
/**
* Prune old AppStatus records.
* - Deletes records older than RETENTION_HOURS
* - Keeps at most MAX_RECORDS_PER_APP per app (deletes oldest excess)
*/
export async function pruneOldStatuses(): Promise<void> {
const cutoff = new Date(Date.now() - RETENTION_HOURS * 60 * 60 * 1000);
// Step 1: Delete all records older than retention period
await prisma.appStatus.deleteMany({
where: {
checkedAt: { lt: cutoff }
}
});
// Step 2: For each app, keep at most MAX_RECORDS_PER_APP
const apps = await prisma.app.findMany({
where: { healthcheckEnabled: true },
select: { id: true }
});
for (const app of apps) {
const count = await prisma.appStatus.count({
where: { appId: app.id }
});
if (count > MAX_RECORDS_PER_APP) {
const excess = count - MAX_RECORDS_PER_APP;
// Find the oldest records to delete
const oldestRecords = await prisma.appStatus.findMany({
where: { appId: app.id },
orderBy: { checkedAt: 'asc' },
take: excess,
select: { id: true }
});
if (oldestRecords.length > 0) {
await prisma.appStatus.deleteMany({
where: {
id: { in: oldestRecords.map((r) => r.id) }
}
});
}
}
}
}
+226
View File
@@ -0,0 +1,226 @@
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;
}
+24
View File
@@ -118,6 +118,30 @@ class ThemeStore {
this.primaryHue = Math.max(0, Math.min(360, hue));
this.primarySaturation = Math.max(0, Math.min(100, saturation));
}
/**
* Apply non-null server-stored user preferences over localStorage defaults.
* Call from +layout.svelte when user data is available.
*/
loadFromServer(prefs: {
themeMode?: string | null;
primaryHue?: number | null;
primarySaturation?: number | null;
backgroundType?: string | null;
}) {
if (prefs.themeMode != null) {
this.mode = prefs.themeMode as ThemeMode;
}
if (prefs.primaryHue != null) {
this.primaryHue = prefs.primaryHue;
}
if (prefs.primarySaturation != null) {
this.primarySaturation = prefs.primarySaturation;
}
if (prefs.backgroundType != null) {
this.backgroundType = prefs.backgroundType as BackgroundType;
}
}
}
export const theme = new ThemeStore();
+65
View File
@@ -181,6 +181,71 @@ export const createPermissionSchema = z.object({
level: z.enum([PermissionLevel.VIEW, PermissionLevel.EDIT, PermissionLevel.ADMIN])
});
// --- Import/Export ---
const importAppSchema = z.object({
name: z.string().min(1).max(200),
url: z.string().url(),
icon: z.string().max(500).nullable(),
iconType: z.string().max(50),
description: z.string().max(1000).nullable(),
category: z.string().max(100).nullable(),
tags: z.string().max(500),
healthcheckEnabled: z.boolean(),
healthcheckInterval: z.number().int().min(30).max(86400),
healthcheckMethod: z.string(),
healthcheckExpectedStatus: z.number().int().min(100).max(599),
healthcheckTimeout: z.number().int().min(1000).max(30000)
});
const importWidgetSchema = z.object({
type: z.string().min(1),
order: z.number().int().min(0),
config: z.string(),
appName: z.string().nullable()
});
const importSectionSchema = z.object({
title: z.string().min(1).max(200),
icon: z.string().max(500).nullable(),
order: z.number().int().min(0),
isExpandedByDefault: z.boolean(),
widgets: z.array(importWidgetSchema)
});
const importBoardSchema = z.object({
name: z.string().min(1).max(200),
icon: z.string().max(500).nullable(),
description: z.string().max(1000).nullable(),
isDefault: z.boolean(),
isGuestAccessible: z.boolean(),
backgroundConfig: z.string().nullable(),
sections: z.array(importSectionSchema)
});
const importGroupSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).nullable(),
isDefault: z.boolean()
});
const importSettingsSchema = z.object({
authMode: z.string().optional(),
registrationEnabled: z.boolean().optional(),
defaultTheme: z.string().optional(),
defaultPrimaryColor: z.string().optional(),
healthcheckDefaults: z.string().optional()
});
export const importDataSchema = z.object({
version: z.string(),
exportedAt: z.string(),
apps: z.array(importAppSchema),
boards: z.array(importBoardSchema),
groups: z.array(importGroupSchema),
settings: importSettingsSchema
});
// --- System Settings ---
export const updateSystemSettingsSchema = z.object({
+29 -1
View File
@@ -33,8 +33,36 @@ export const load: LayoutServerLoad = async ({ locals }) => {
boards = [];
}
// Fetch user preferences if authenticated
let userPreferences: {
themeMode: string | null;
primaryHue: number | null;
primarySaturation: number | null;
backgroundType: string | null;
locale: string | null;
} | null = null;
if (locals.user) {
try {
const dbUser = await prisma.user.findUnique({
where: { id: locals.user.id },
select: {
themeMode: true,
primaryHue: true,
primarySaturation: true,
backgroundType: true,
locale: true
}
});
userPreferences = dbUser ?? null;
} catch {
// Fail gracefully
}
}
return {
user: locals.user,
sidebarBoards: boards
sidebarBoards: boards,
userPreferences
};
};
+9
View File
@@ -9,9 +9,18 @@
import { theme } from '$lib/stores/theme.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { search } from '$lib/stores/search.svelte';
import { locale as i18nLocale } from 'svelte-i18n';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
// Apply user preferences from server (overrides localStorage defaults)
if (data.userPreferences) {
theme.loadFromServer(data.userPreferences);
if (data.userPreferences.locale) {
i18nLocale.set(data.userPreferences.locale);
}
}
// Initialize store effects within component context
theme.initEffects();
ui.initEffects();
+4 -1
View File
@@ -2,6 +2,7 @@
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import SettingsForm from '$lib/components/admin/SettingsForm.svelte';
import ImportExportPanel from '$lib/components/admin/ImportExportPanel.svelte';
let { data }: { data: PageData } = $props();
</script>
@@ -10,11 +11,13 @@
<title>{$t('admin.system_settings')}{$t('admin.panel')}</title>
</svelte:head>
<div>
<div class="space-y-8">
<div class="mb-6">
<h1 class="text-2xl font-bold text-card-foreground">{$t('admin.system_settings')}</h1>
<p class="mt-1 text-sm text-muted-foreground">{$t('admin.settings_description')}</p>
</div>
<SettingsForm form={data.form} />
<ImportExportPanel />
</div>
+30
View File
@@ -0,0 +1,30 @@
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';
/**
* GET /api/admin/export — Export all data as JSON file download. Admin only.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
try {
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 });
}
};
+46
View File
@@ -0,0 +1,46 @@
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';
/**
* POST /api/admin/import — Import data from JSON. Admin only.
* Body: { data: ExportData, mode: "skip" | "overwrite" }
*/
export const POST: RequestHandler = async (event) => {
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);
return json(success(result));
} catch (err) {
const message = err instanceof Error ? err.message : 'Import failed';
return json(error(message), { status: 500 });
}
};
@@ -0,0 +1,46 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as appService from '$lib/server/services/appService.js';
import { success, error } from '$lib/server/utils/response.js';
const MAX_HISTORY_RECORDS = 288;
/**
* GET /api/apps/:id/history — Get last 24h of healthcheck history for an app.
* Returns status points sorted ascending (oldest first) and uptime percentage.
*/
export const GET: RequestHandler = async (event) => {
requireAuth(event);
const { id } = event.params;
try {
await appService.findById(id);
const history = await appService.getStatusHistory(id, MAX_HISTORY_RECORDS);
// History comes back desc from the service; reverse to ascending for sparkline
const ascending = [...history].reverse();
const totalChecks = ascending.length;
const onlineChecks = ascending.filter((s) => s.status === 'online').length;
const uptimePercent = totalChecks > 0 ? Math.round((onlineChecks / totalChecks) * 1000) / 10 : 0;
return json(
success({
history: ascending.map((s) => ({
status: s.status,
responseTime: s.responseTime,
checkedAt: s.checkedAt
})),
uptimePercent,
totalChecks
})
);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch history';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
@@ -0,0 +1,148 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import { prisma } from '$lib/server/prisma.js';
import { success, error } from '$lib/server/utils/response.js';
const ALLOWED_FIELDS = [
'themeMode',
'primaryHue',
'primarySaturation',
'backgroundType',
'locale'
] as const;
const VALID_THEME_MODES = ['dark', 'light', 'system'];
const VALID_BG_TYPES = ['mesh', 'particles', 'aurora', 'none'];
const VALID_LOCALES = ['en', 'ru'];
/**
* GET /api/users/me/preferences — Return current user's theme/locale preferences.
*/
export const GET: RequestHandler = async (event) => {
const user = requireAuth(event);
try {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
themeMode: true,
primaryHue: true,
primarySaturation: true,
backgroundType: true,
locale: true
}
});
if (!dbUser) {
return json(error('User not found'), { status: 404 });
}
return json(success(dbUser));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch preferences';
return json(error(message), { status: 500 });
}
};
/**
* PATCH /api/users/me/preferences — Update any subset of user preferences.
*/
export const PATCH: RequestHandler = async (event) => {
const user = requireAuth(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return json(error('Request body must be a JSON object'), { status: 400 });
}
const raw = body as Record<string, unknown>;
const data: Record<string, unknown> = {};
// Validate themeMode
if ('themeMode' in raw) {
if (raw.themeMode !== null && !VALID_THEME_MODES.includes(raw.themeMode as string)) {
return json(error('Invalid themeMode. Must be: dark, light, or system'), { status: 400 });
}
data.themeMode = raw.themeMode as string | null;
}
// Validate primaryHue
if ('primaryHue' in raw) {
if (raw.primaryHue !== null) {
const hue = Number(raw.primaryHue);
if (!Number.isFinite(hue) || hue < 0 || hue > 360) {
return json(error('primaryHue must be a number between 0 and 360'), { status: 400 });
}
data.primaryHue = Math.round(hue);
} else {
data.primaryHue = null;
}
}
// Validate primarySaturation
if ('primarySaturation' in raw) {
if (raw.primarySaturation !== null) {
const sat = Number(raw.primarySaturation);
if (!Number.isFinite(sat) || sat < 0 || sat > 100) {
return json(error('primarySaturation must be a number between 0 and 100'), {
status: 400
});
}
data.primarySaturation = Math.round(sat);
} else {
data.primarySaturation = null;
}
}
// Validate backgroundType
if ('backgroundType' in raw) {
if (raw.backgroundType !== null && !VALID_BG_TYPES.includes(raw.backgroundType as string)) {
return json(error('Invalid backgroundType. Must be: mesh, particles, aurora, or none'), {
status: 400
});
}
data.backgroundType = raw.backgroundType as string | null;
}
// Validate locale
if ('locale' in raw) {
if (raw.locale !== null && !VALID_LOCALES.includes(raw.locale as string)) {
return json(error('Invalid locale. Must be: en or ru'), { status: 400 });
}
data.locale = raw.locale as string | null;
}
// Filter out any unknown keys
const hasValidFields = Object.keys(data).some((k) =>
ALLOWED_FIELDS.includes(k as (typeof ALLOWED_FIELDS)[number])
);
if (!hasValidFields) {
return json(error('No valid preference fields provided'), { status: 400 });
}
try {
const updated = await prisma.user.update({
where: { id: user.id },
data,
select: {
themeMode: true,
primaryHue: true,
primarySaturation: true,
backgroundType: true,
locale: true
}
});
return json(success(updated));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update preferences';
return json(error(message), { status: 500 });
}
};
+28
View File
@@ -0,0 +1,28 @@
import type { PageServerLoad } from './$types.js';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import { prisma } from '$lib/server/prisma.js';
export const load: PageServerLoad = async (event) => {
const user = requireAuth(event);
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
themeMode: true,
primaryHue: true,
primarySaturation: true,
backgroundType: true,
locale: true
}
});
return {
preferences: dbUser ?? {
themeMode: null,
primaryHue: null,
primarySaturation: null,
backgroundType: null,
locale: null
}
};
};
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import ThemeCustomizer from '$lib/components/settings/ThemeCustomizer.svelte';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{$t('settings.title')} | {$t('app_name')}</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<h1 class="mb-6 text-2xl font-bold text-foreground">{$t('settings.title')}</h1>
<ThemeCustomizer preferences={data.preferences} />
</div>