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
@@ -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>