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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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!"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user