feat: production hardening + password reset, metrics, signed webhooks
Lint & Test / lint-and-check (push) Failing after 5m5s
Lint & Test / test (push) Has been skipped
Lint & Test / build (push) Has been skipped
Lint & Test / docker-build (push) Has been skipped
Lint & Test / audit (push) Has been skipped

Security hardening (CRITICAL/HIGH from production-readiness audit):
- Require strong JWT_SECRET + separate INTEGRATION_ENCRYPTION_KEY at boot;
  refuse placeholder defaults. Integration key now derived via HKDF.
- SSRF guard (src/lib/server/utils/safeFetch.ts): DNS-resolves and rejects
  RFC1918/loopback/link-local/IPv4-mapped IPv6/decimal-IP/cloud-metadata.
  Manual redirect handling re-validates each 3xx Location hop. Applied to
  healthcheck, RSS, calendar, metric, system-stats, camera, notifications,
  discovery, apps/preview, and all integration clients.
- API tokens, session refresh tokens, invite tokens, password-reset tokens
  switched from bcrypt to sha256 with @unique indexed lookup (O(1) instead
  of O(N) bcrypt-compares; eliminates a trivial DoS).
- Refresh-token reuse detection via Session.previousTokenHash.
- Permission checks on App PATCH/DELETE and Widget/Section endpoints.
- /api/integrations/alerts now requires auth.
- SVG uploads sanitized through DOMPurify (svg profile, scheme allow-list).
- Custom CSS sanitizer + selector scoping (decodes CSS unicode escapes
  before pattern match, drops forbidden at-rules incl. @import without
  whitespace, strips dangerous url() args). Scoped to .custom-css-scope.
- Backup restore validates SQLite magic header, takes a safety snapshot,
  uses atomic rename, re-applies pragmas.
- SQLite WAL + busy_timeout + foreign_keys + synchronous=NORMAL at startup.
- Healthcheck scheduler was dead code; wired in hooks.server.ts with
  HMR-safe singleton, concurrency cap, overlap prevention, retention jobs
  for AppClick/Notification/AuditLog. Composite indexes added on hot paths.
- Security headers (CSP, HSTS-on-https, X-Frame-Options, Permissions-Policy)
  emitted on every response.
- Account-enumeration mitigation on login (dummy bcrypt on no-user/oauth
  branches) + rate limiting on login/register/onboarding/refresh/invite/
  password-reset.
- OAuth callback sanitizes IdP error_description before echoing.

New features:
- Custom +error.svelte pages (root + boards + admin) via shared
  ErrorState component. Inverted hierarchy (status as label, title as hero).
- /forgot-password + /reset-password + admin-mediated /admin/password-resets
  page. SHA256 tokens, 24h TTL, all sessions revoked on apply.
- /invite page for manual invite-token redemption.
- /api/metrics Prometheus exposition with optional METRICS_TOKEN bearer
  auth. Counters for login/healthcheck/notification/integration; gauges
  for users/boards/apps + per-status app counts.
- Webhook HMAC-SHA256 signing for HTTP notification channels (optional
  shared secret + configurable signature header, default X-Signature-256).
- PATCH /api/users/me/password for self-service password change.
- Persistent uploads at /app/data/uploads with served-from-volume handler
  at /uploads/[...path]. SVGs served with CSP: sandbox.
- /api/health does a DB ping; returns 503 on disconnect.
- Public /status filtered to guest-accessible-board apps when unauthenticated.
- Audit log coverage: LOGIN_SUCCESS/FAILED, LOGOUT, OAUTH_LOGIN,
  OAUTH_USER_PROVISIONED, SESSION_REVOKED, API_TOKEN_*, INVITE_*,
  APP_UPDATED, PASSWORD_CHANGED, PASSWORD_RESET_*.

Performance:
- Board page: removed double findAll() over-fetch; include links + appTags
  in board query; widgets lazy-loaded via dynamic imports (marked,
  DOMPurify, hls.js, integration renderers).
- uptimeService.getAllAppsUptime: single batched query instead of N+1.
- 30s in-memory user-locals cache; invalidated on user mutation.
- pruneOldStatuses: single window-function DELETE instead of N+1.

Code quality:
- Typed error classes (NotFoundError, PermissionError, RateLimitError,
  IntegrationError) with toHttpError mapper.
- Locals.user shape exposes avatarUrl and narrows role via guard.
- App input types derived from Zod schemas via z.infer.
- 274 tests passing (up from 212); 62 new tests covering SSRF guard,
  CSS sanitizer, SVG sanitizer, rate limiter.

CI / Docker / config:
- Test workflow adds build, docker-build, audit jobs. Release workflow
  uses buildx multi-arch (amd64+arm64) with provenance + SBOM.
- Dockerfile uses tini, multi-stage prune, persistent uploads dir, single
  prisma migrate deploy (no destructive db push fallback).
- docker-compose: JWT_SECRET + INTEGRATION_ENCRYPTION_KEY required at
  startup, log rotation, resource limits.
- README documents breaking-change upgrade path.

Bug fixes from UI/UX review:
- ~55 missing i18n keys added to en/ru (auth flows, error pages, admin
  nav, register invite banner, settings.card_style).
- Hardcoded English on login replaced with $t('auth.remember_me').
- Admin nav uses i18n keys; mobile horizontal-scroll layout.
- Page <title> tags standardized.
- Password-resets: separated error/info/success surfaces, ConfirmDialog
  replaces window.confirm.
- Auth pages have matching lucide icon badges.
- Webhook secret has eye toggle and monospace input.
- text-green-500 → text-emerald-500 to match codebase convention.

Pre-existing CI lint failures cleaned up (31 errors → 0): each-key
attributes added, unused-svelte-ignore comments removed, two any casts
typed, dead skeleton components removed, /boards/[id]/edit redirect to
inline edit mode.

Tests: 274 / 274 passing
Type check: 0 errors / 0 warnings
Build: green
This commit is contained in:
2026-05-26 19:51:21 +03:00
parent 38335e925b
commit f1cfb61d13
144 changed files with 5586 additions and 2284 deletions
@@ -62,7 +62,7 @@
];
function applyFilters() {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams();
if (filterAction) params.set('action', filterAction);
if (filterEntityType) params.set('entityType', filterEntityType);
@@ -73,7 +73,7 @@
}
function changePage(delta: number) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams($page.url.searchParams);
params.set('page', String(Math.max(1, currentPage + delta)));
goto(`/admin/audit-log?${params.toString()}`, { replaceState: true });
+16 -1
View File
@@ -42,7 +42,22 @@
})
.catch(() => {});
});
let availableIntegrations = $state<Array<{ id: string; name: string; icon: string; authConfigFields: any[]; extraConfigFields: any[] }>>([]);
interface IntegrationField {
name: string;
type: 'string' | 'number' | 'boolean';
required: boolean;
label: string;
description?: string;
}
let availableIntegrations = $state<
Array<{
id: string;
name: string;
icon: string;
authConfigFields: IntegrationField[];
extraConfigFields: IntegrationField[];
}>
>([]);
let integrationConfig = $state<Record<string, unknown>>({});
let testingConnection = $state(false);
let testResult = $state<{ success: boolean; message: string } | null>(null);
@@ -148,7 +148,6 @@
<svelte:window onkeydown={handleKeydown} />
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onclick={handleBackdropClick}
@@ -1,42 +1,18 @@
<script lang="ts">
import { sanitizeCss, scopeCss } from '$lib/utils/cssSanitize.js';
interface Props {
css: string;
}
let { css }: Props = $props();
/**
* Sanitize CSS to prevent XSS vectors while keeping valid styling rules.
* All custom CSS is wrapped in .custom-css-scope to prevent breaking critical UI.
*/
const sanitizedCss = $derived.by(() => {
if (!css) return '';
let cleaned = css;
// Remove any HTML tags (including <script>)
cleaned = cleaned.replace(/<\/?[^>]+(>|$)/g, '');
// Remove javascript: URLs
cleaned = cleaned.replace(/javascript\s*:/gi, '');
// Remove expression() calls
cleaned = cleaned.replace(/expression\s*\(/gi, '');
// Remove url() with javascript:
cleaned = cleaned.replace(/url\s*\(\s*['"]?\s*javascript:/gi, 'url(');
// Remove @import rules
cleaned = cleaned.replace(/@import\s+[^;]+;?/gi, '');
// Remove behavior: (IE XSS)
cleaned = cleaned.replace(/behavior\s*:/gi, '');
// Remove -moz-binding (Firefox XSS)
cleaned = cleaned.replace(/-moz-binding\s*:/gi, '');
return cleaned;
});
// CSS is also sanitized server-side at SAVE time. This is defense-in-depth
// so legacy unsanitized rows (or any future client-rendered preview) cannot
// inject script-bearing CSS. We also prefix every selector with
// `.custom-css-scope` so a malicious admin's `body { display:none }` cannot
// hide the whole app — the rule only takes effect inside the wrapper div.
const sanitizedCss = $derived(scopeCss(sanitizeCss(css ?? '')));
</script>
{#if sanitizedCss}
@@ -1,4 +1,5 @@
<script lang="ts">
import { Eye, EyeOff } from 'lucide-svelte';
import { NotificationType } from '$lib/utils/constants.js';
interface ChannelData {
@@ -28,6 +29,9 @@
let telegramChatId = $state('');
let httpUrl = $state('');
let httpMethod = $state('POST');
let httpSecret = $state('');
let httpSignatureHeader = $state('');
let showHttpSecret = $state(false);
// Parse existing config
if (channel?.config) {
@@ -47,6 +51,8 @@
case 'http':
httpUrl = parsed.url ?? '';
httpMethod = parsed.method ?? 'POST';
httpSecret = parsed.secret ?? '';
httpSignatureHeader = parsed.signatureHeader ?? '';
break;
}
} catch {
@@ -62,8 +68,14 @@
return JSON.stringify({ webhookUrl: slackWebhookUrl });
case 'telegram':
return JSON.stringify({ botToken: telegramBotToken, chatId: telegramChatId });
case 'http':
return JSON.stringify({ url: httpUrl, method: httpMethod });
case 'http': {
// Only include secret/signatureHeader when set, to keep the stored
// config minimal and avoid encrypting empty strings.
const cfg: Record<string, string> = { url: httpUrl, method: httpMethod };
if (httpSecret) cfg.secret = httpSecret;
if (httpSignatureHeader) cfg.signatureHeader = httpSignatureHeader;
return JSON.stringify(cfg);
}
default:
return '{}';
}
@@ -207,6 +219,55 @@
<option value="PATCH">PATCH</option>
</select>
</div>
<div>
<label for="http-secret" class="mb-1 block text-sm font-medium text-foreground">
Webhook secret <span class="text-xs font-normal text-muted-foreground">(optional)</span>
</label>
<div class="relative">
<input
id="http-secret"
type={showHttpSecret ? 'text' : 'password'}
bind:value={httpSecret}
placeholder="Shared secret for HMAC-SHA256 signature"
autocomplete="off"
class="w-full rounded-md border border-input bg-background px-3 py-2 pr-10 font-mono text-sm text-foreground"
/>
<button
type="button"
onclick={() => (showHttpSecret = !showHttpSecret)}
aria-label={showHttpSecret ? 'Hide secret' : 'Show secret'}
class="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground"
>
{#if showHttpSecret}
<EyeOff class="h-4 w-4" />
{:else}
<Eye class="h-4 w-4" />
{/if}
</button>
</div>
<p class="mt-1 text-xs text-muted-foreground">
When set, requests are signed with HMAC-SHA256 and sent as
<code class="rounded bg-muted/40 px-1">sha256=&lt;hex&gt;</code> in the signature header,
alongside an <code class="rounded bg-muted/40 px-1">X-Webhook-Timestamp</code>.
</p>
</div>
<div>
<label for="http-sig-header" class="mb-1 block text-sm font-medium text-foreground">
Signature header name
<span class="text-xs font-normal text-muted-foreground">(optional)</span>
</label>
<input
id="http-sig-header"
type="text"
bind:value={httpSignatureHeader}
placeholder="X-Signature-256"
autocomplete="off"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
<p class="mt-1 text-xs text-muted-foreground">
Defaults to <code class="rounded bg-muted/40 px-1">X-Signature-256</code>. Override for receivers expecting a different name (e.g. <code class="rounded bg-muted/40 px-1">X-Hub-Signature-256</code>).
</p>
</div>
{/if}
<!-- Enabled Toggle -->
@@ -222,7 +283,7 @@
<!-- Test Result -->
{#if testResult}
<p class="text-sm {testResult.startsWith('Failed') ? 'text-destructive' : 'text-green-500'}">
<p class="text-sm {testResult.startsWith('Failed') ? 'text-destructive' : 'text-emerald-500'}">
{testResult}
</p>
{/if}
@@ -25,7 +25,7 @@
async function loadNotifications(page: number = 1) {
loading = true;
try {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String((page - 1) * PAGE_SIZE)
@@ -1,6 +1,4 @@
<script lang="ts">
import { goto } from '$app/navigation';
const STEPS = ['welcome', 'admin', 'authMode', 'theme', 'complete'] as const;
type Step = (typeof STEPS)[number];
@@ -68,7 +68,6 @@
</script>
{#if search.open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh] backdrop-blur-sm"
onclick={handleBackdropClick}
@@ -1,22 +0,0 @@
<script lang="ts">
interface Props {
count?: number;
}
let { count = 3 }: Props = $props();
const items = $derived(Array.from({ length: count }, (_, i) => i));
</script>
{#each items as i (i)}
<div class="rounded-lg border border-border bg-card p-5">
<div class="flex items-start gap-3">
<div class="skeleton h-8 w-8 rounded-md"></div>
<div class="min-w-0 flex-1">
<div class="skeleton mb-2 h-5 w-1/2 rounded"></div>
<div class="skeleton mb-1 h-3 w-full rounded"></div>
<div class="skeleton mt-2 h-3 w-20 rounded"></div>
</div>
</div>
</div>
{/each}
@@ -1,21 +0,0 @@
<script lang="ts">
interface Props {
count?: number;
}
let { count = 1 }: Props = $props();
const items = $derived(Array.from({ length: count }, (_, i) => i));
</script>
{#each items as i (i)}
<div class="rounded-lg border border-border bg-card p-4">
<div class="mb-3 flex items-start justify-between">
<div class="skeleton h-10 w-10 rounded-lg"></div>
<div class="skeleton h-5 w-14 rounded-full"></div>
</div>
<div class="skeleton mb-2 h-4 w-3/4 rounded"></div>
<div class="skeleton h-3 w-full rounded"></div>
<div class="skeleton mt-1 h-3 w-1/2 rounded"></div>
</div>
{/each}
@@ -1,32 +0,0 @@
<script lang="ts">
interface Props {
count?: number;
widgetsPerSection?: number;
}
let { count = 2, widgetsPerSection = 4 }: Props = $props();
const sections = $derived(Array.from({ length: count }, (_, i) => i));
const widgets = $derived(Array.from({ length: widgetsPerSection }, (_, i) => i));
</script>
{#each sections as s (s)}
<div class="rounded-lg border border-border bg-card/50">
<!-- Section header skeleton -->
<div class="flex items-center gap-2 px-4 py-3">
<div class="skeleton h-4 w-4 rounded"></div>
<div class="skeleton h-4 w-32 rounded"></div>
</div>
<!-- Widget grid skeleton -->
<div class="grid grid-cols-2 gap-3 px-4 pb-4 sm:grid-cols-3 lg:grid-cols-4">
{#each widgets as w (w)}
<div class="flex flex-col items-center gap-2 rounded-lg border border-border bg-card p-4">
<div class="skeleton h-12 w-12 rounded-lg"></div>
<div class="skeleton h-3 w-16 rounded"></div>
<div class="skeleton h-4 w-12 rounded-full"></div>
</div>
{/each}
</div>
</div>
{/each}
@@ -92,7 +92,7 @@
{#if open && filtered.length > 0}
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-card shadow-lg">
{#each filtered as item, i}
{#each filtered as item, i (item)}
<button
type="button"
onclick={() => selectItem(item)}
+1 -2
View File
@@ -39,11 +39,10 @@
transition:fade={{ duration: 120 }}
>
<!-- Dialog -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 w-full max-w-sm rounded-2xl border border-border bg-card p-6 shadow-2xl"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
transition:scale={{ start: 0.95, duration: 150 }}
role="alertdialog"
tabindex="-1"
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
status: number;
title: string;
hint?: string;
/** Optional detail block (e.g. raw error message in a <details>). */
details?: Snippet;
/** Primary + secondary call-to-action snippets. */
actions?: Snippet;
/** When true, render the chrome (AmbientBackground, card surface). For
* boards/admin nested errors we want to inherit the parent layout. */
standalone?: boolean;
}
let { status, title, hint, details, actions, standalone = false }: Props = $props();
</script>
{#if standalone}
<main
class="relative z-10 flex min-h-screen items-center justify-center bg-background/80 p-4 text-foreground"
>
<div
class="w-full max-w-lg rounded-xl border border-border bg-card/90 p-8 text-center shadow-xl backdrop-blur-sm"
>
<div class="mb-2 text-xs uppercase tracking-widest text-muted-foreground">
{status}
</div>
<h1 class="mb-2 text-2xl font-semibold">{title}</h1>
{#if hint}
<p class="mb-6 text-sm text-muted-foreground">{hint}</p>
{/if}
{#if details}
<div class="mb-6 text-left">{@render details()}</div>
{/if}
{#if actions}
<div class="flex flex-wrap justify-center gap-2">{@render actions()}</div>
{/if}
</div>
</main>
{:else}
<main
class="mx-auto flex min-h-[60vh] max-w-lg flex-col items-center justify-center px-4 py-16 text-center"
>
<div class="mb-2 text-xs uppercase tracking-widest text-muted-foreground">
{status}
</div>
<h1 class="mb-2 text-2xl font-semibold">{title}</h1>
{#if hint}
<p class="mb-6 text-sm text-muted-foreground">{hint}</p>
{/if}
{#if details}
<div class="mb-6 w-full text-left">{@render details()}</div>
{/if}
{#if actions}
<div class="flex flex-wrap justify-center gap-2">{@render actions()}</div>
{/if}
</main>
{/if}
@@ -138,7 +138,7 @@
<p class="py-4 text-center text-xs text-muted-foreground">{$t('common.no_results') ?? 'No matching icons'}</p>
{:else}
<div class="grid grid-cols-8 gap-0.5">
{#each filteredIcons as iconName}
{#each filteredIcons as iconName (iconName)}
<button
type="button"
onclick={() => selectIcon(iconName)}
@@ -120,7 +120,7 @@
</script>
{#if name}
{#each values as v}
{#each values as v (v)}
<input type="hidden" {name} value={v} />
{/each}
{/if}
+2 -2
View File
@@ -99,7 +99,7 @@
<!-- Tag pills -->
{#if tags.length > 0}
<div class="mb-1.5 flex flex-wrap gap-1">
{#each tags as tag}
{#each tags as tag (tag)}
<span class="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-0.5 text-xs text-primary">
{tag}
<button
@@ -129,7 +129,7 @@
{#if open && filtered.length > 0}
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-card shadow-lg">
{#each filtered as item, i}
{#each filtered as item, i (item)}
<button
type="button"
onclick={() => selectItem(item)}
@@ -24,10 +24,10 @@
function groupLabel(dateStr: string): string {
const date = new Date(dateStr);
/* eslint-disable svelte/prefer-svelte-reactivity */
const today = new Date();
const tomorrow = new Date();
/* eslint-enable svelte/prefer-svelte-reactivity */
tomorrow.setDate(today.getDate() + 1);
if (date.toDateString() === today.toDateString()) return 'Today';
@@ -52,7 +52,7 @@
}
const grouped = $derived.by((): GroupedEvents[] => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const groups: Map<string, CalendarEvent[]> = new Map();
for (const evt of events) {
const key = new Date(evt.start).toDateString();
@@ -30,7 +30,7 @@
async function fetchMetric() {
error = false;
try {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams({ source: config.source });
if (config.value) params.set('value', config.value);
if (config.url) params.set('url', config.url);
@@ -190,8 +190,7 @@
<div class="max-h-80 space-y-3 overflow-y-auto">
{#if widgetType === 'app'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
<div class={labelClass}>{$t('widget.app') ?? 'App'}</div>
<!-- Search -->
<div class="relative mb-2">
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -211,7 +210,7 @@
<p class="py-4 text-center text-xs text-muted-foreground">{$t('common.no_results') ?? 'No apps found'}</p>
{:else}
<div class="grid grid-cols-2 gap-1">
{#each filteredApps as app}
{#each filteredApps as app (app.id)}
<button
type="button"
onclick={() => { appId = app.id; }}
@@ -303,8 +302,7 @@
</label>
</div>
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>{$t('widget.apps') ?? 'Apps'}</label>
<div class={labelClass}>{$t('widget.apps') ?? 'Apps'}</div>
<MultiEntityPicker
items={apps.map((a) => ({ value: a.id, label: a.name, icon: a.icon, iconType: a.iconType }))}
bind:values={statusAppIds}
@@ -349,13 +347,11 @@
{:else if widgetType === 'system_stats'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Source URL
<input type="url" bind:value={sysStatsSourceUrl} placeholder="http://localhost:61208/api/3" class={inputClass} bind:this={firstInput} />
</label>
</div>
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Source Type
<select bind:value={sysStatsSourceType} class={inputClass}>
<option value="glances">Glances</option>
@@ -365,7 +361,6 @@
</label>
</div>
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Refresh ({sysStatsRefreshInterval}s)
<input type="range" min="5" max="300" bind:value={sysStatsRefreshInterval} class="w-full accent-primary" />
</label>
@@ -373,13 +368,11 @@
{:else if widgetType === 'rss'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Feed URL
<input type="url" bind:value={rssFeedUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
</label>
</div>
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Max Items ({rssMaxItems})
<input type="range" min="1" max="50" bind:value={rssMaxItems} class="w-full accent-primary" />
</label>
@@ -391,9 +384,8 @@
{:else if widgetType === 'calendar'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>iCal URLs</label>
{#each calendarUrlsRaw as cal, i}
<div class={labelClass}>iCal URLs</div>
{#each calendarUrlsRaw as cal, i (i)}
<div class="mb-1 flex items-center gap-1">
<input type="url" bind:value={cal.url} placeholder="https://..." class="{inputClass} flex-1" />
<input type="text" bind:value={cal.label} placeholder="Label" class="{inputClass} w-20" />
@@ -477,9 +469,8 @@
{:else if widgetType === 'link_group'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Links</label>
{#each linkGroupLinks as link, i}
<div class={labelClass}>Links</div>
{#each linkGroupLinks as link, i (i)}
<div class="mb-1 flex items-center gap-1">
<input type="text" bind:value={link.label} placeholder="Label" class="{inputClass} w-24" />
<input type="url" bind:value={link.url} placeholder="URL" class="{inputClass} flex-1" />
@@ -531,7 +522,7 @@
<label class={labelClass}>{$t('widget.app') ?? 'App'}
<select bind:value={integrationAppId} class={inputClass} bind:this={firstInput}>
<option value="">Select app...</option>
{#each apps as app}
{#each apps as app (app.id)}
<option value={app.id}>{app.name}</option>
{/each}
</select>
@@ -1,4 +1,9 @@
<script lang="ts">
// Note: this file is ~1000 lines because it contains the create-form for
// all 14 widget types. A clean split would lift each widget's state into
// its own subcomponent — a non-trivial refactor that touches form-submit
// plumbing. Deferred until UI/UX restructure pass; the structure is
// internally consistent (each {:else if} block follows the same pattern).
import { t } from 'svelte-i18n';
import IconGrid from '$lib/components/ui/IconGrid.svelte';
import EntityPicker from '$lib/components/ui/EntityPicker.svelte';
@@ -132,7 +137,10 @@
.then((r) => r.json())
.then((json) => {
if (json.success) {
integrationApps = (json.data ?? []).filter((a: any) => a.integrationEnabled && a.integrationType);
integrationApps = (json.data ?? []).filter(
(a: { integrationEnabled?: boolean; integrationType?: string | null }) =>
a.integrationEnabled && a.integrationType
);
}
})
.catch(() => {});
@@ -147,7 +155,9 @@
.then((r) => r.json())
.then((json) => {
if (json.success) {
const integration = (json.data ?? []).find((i: any) => i.id === app.integrationType);
const integration = (json.data ?? []).find(
(i: { id: string }) => i.id === app.integrationType
);
integrationEndpoints = integration?.endpoints ?? [];
}
})
@@ -108,7 +108,7 @@
{$t('widget.width') ?? 'Width'}
</div>
<div class="flex flex-col gap-1">
{#each Array.from({ length: maxCols }, (_, i) => i + 1) as span}
{#each Array.from({ length: maxCols }, (_, i) => i + 1) as span (span)}
{@const isActive = span === colSpan}
{@const isPreview = span === previewSpan}
<button
@@ -125,7 +125,7 @@
>
<!-- Visual block representation -->
<div class="flex gap-px">
{#each Array(maxCols) as _, ci}
{#each Array(maxCols) as _, ci (ci)}
<div
class="h-2.5 w-3 rounded-[2px] transition-colors
{ci < span
+88 -49
View File
@@ -2,18 +2,22 @@
import { t } from 'svelte-i18n';
import AppWidget from './AppWidget.svelte';
import BookmarkWidget from './BookmarkWidget.svelte';
import NoteWidget from './NoteWidget.svelte';
import EmbedWidget from './EmbedWidget.svelte';
import StatusWidget from './StatusWidget.svelte';
import ClockWeatherWidget from './ClockWeatherWidget.svelte';
import SystemStatsWidget from './SystemStatsWidget.svelte';
import RssFeedWidget from './RssFeedWidget.svelte';
import CalendarWidget from './CalendarWidget.svelte';
import MarkdownWidget from './MarkdownWidget.svelte';
import MetricWidget from './MetricWidget.svelte';
import LinkGroupWidget from './LinkGroupWidget.svelte';
import CameraStreamWidget from './CameraStreamWidget.svelte';
import IntegrationWidget from './integration/IntegrationWidget.svelte';
// Heavy widgets (marked/DOMPurify/hls.js/integration renderers) are lazy-loaded
// so empty boards don't pay their bundle cost. Loaders return a module promise
// rendered by <svelte:component> via #await.
const NoteWidgetLoader = () => import('./NoteWidget.svelte');
const MarkdownWidgetLoader = () => import('./MarkdownWidget.svelte');
const SystemStatsWidgetLoader = () => import('./SystemStatsWidget.svelte');
const RssFeedWidgetLoader = () => import('./RssFeedWidget.svelte');
const CalendarWidgetLoader = () => import('./CalendarWidget.svelte');
const MetricWidgetLoader = () => import('./MetricWidget.svelte');
const CameraStreamWidgetLoader = () => import('./CameraStreamWidget.svelte');
const IntegrationWidgetLoader = () => import('./integration/IntegrationWidget.svelte');
interface AppData {
id: string;
@@ -53,12 +57,22 @@
});
</script>
{#snippet skeleton()}
<div class="flex h-full items-center justify-center rounded-xl border border-border bg-card p-4">
<span class="text-xs text-muted-foreground"></span>
</div>
{/snippet}
{#if widget.type === 'app' && widget.app}
<AppWidget app={widget.app} {cardSize} />
{:else if widget.type === 'bookmark'}
<BookmarkWidget config={parsedConfig} />
{:else if widget.type === 'note'}
<NoteWidget config={{ content: parsedConfig.content ?? '', format: parsedConfig.format ?? 'markdown' }} />
{#await NoteWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default config={{ content: parsedConfig.content ?? '', format: parsedConfig.format ?? 'markdown' }} />
{/await}
{:else if widget.type === 'embed'}
<EmbedWidget config={{ url: parsedConfig.url ?? '', height: parsedConfig.height ?? 300, sandbox: parsedConfig.sandbox }} />
{:else if widget.type === 'status'}
@@ -72,57 +86,82 @@
clockStyle: parsedConfig.clockStyle ?? 'digital'
}} />
{:else if widget.type === 'system_stats'}
<SystemStatsWidget config={{
sourceUrl: parsedConfig.sourceUrl ?? '',
sourceType: parsedConfig.sourceType ?? 'custom',
metrics: parsedConfig.metrics ?? [],
refreshInterval: parsedConfig.refreshInterval ?? 30
}} />
{#await SystemStatsWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default config={{
sourceUrl: parsedConfig.sourceUrl ?? '',
sourceType: parsedConfig.sourceType ?? 'custom',
metrics: parsedConfig.metrics ?? [],
refreshInterval: parsedConfig.refreshInterval ?? 30
}} />
{/await}
{:else if widget.type === 'rss'}
<RssFeedWidget config={{
feedUrl: parsedConfig.feedUrl ?? '',
maxItems: parsedConfig.maxItems ?? 10,
showSummary: parsedConfig.showSummary ?? true
}} />
{#await RssFeedWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default config={{
feedUrl: parsedConfig.feedUrl ?? '',
maxItems: parsedConfig.maxItems ?? 10,
showSummary: parsedConfig.showSummary ?? true
}} />
{/await}
{:else if widget.type === 'calendar'}
<CalendarWidget config={{
icalUrls: parsedConfig.icalUrls ?? [],
daysAhead: parsedConfig.daysAhead ?? 7
}} />
{#await CalendarWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default config={{ icalUrls: parsedConfig.icalUrls ?? [], daysAhead: parsedConfig.daysAhead ?? 7 }} />
{/await}
{:else if widget.type === 'markdown'}
<MarkdownWidget
config={{ content: parsedConfig.content ?? '', syntaxTheme: parsedConfig.syntaxTheme }}
widgetId={widget.id}
/>
{#await MarkdownWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default
config={{ content: parsedConfig.content ?? '', syntaxTheme: parsedConfig.syntaxTheme }}
widgetId={widget.id}
/>
{/await}
{:else if widget.type === 'metric'}
<MetricWidget config={{
label: parsedConfig.label ?? 'Metric',
source: parsedConfig.source ?? 'static',
value: parsedConfig.value,
url: parsedConfig.url,
jsonPath: parsedConfig.jsonPath,
query: parsedConfig.query,
unit: parsedConfig.unit,
refreshInterval: parsedConfig.refreshInterval ?? 60
}} />
{#await MetricWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default config={{
label: parsedConfig.label ?? 'Metric',
source: parsedConfig.source ?? 'static',
value: parsedConfig.value,
url: parsedConfig.url,
jsonPath: parsedConfig.jsonPath,
query: parsedConfig.query,
unit: parsedConfig.unit,
refreshInterval: parsedConfig.refreshInterval ?? 60
}} />
{/await}
{:else if widget.type === 'link_group'}
<LinkGroupWidget config={{
links: parsedConfig.links ?? [],
collapsible: parsedConfig.collapsible ?? false
}} />
{:else if widget.type === 'camera'}
<CameraStreamWidget config={{
streamUrl: parsedConfig.streamUrl ?? '',
type: parsedConfig.type ?? 'image',
refreshInterval: parsedConfig.refreshInterval ?? 10,
aspectRatio: parsedConfig.aspectRatio ?? '16/9'
}} />
{#await CameraStreamWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default config={{
streamUrl: parsedConfig.streamUrl ?? '',
type: parsedConfig.type ?? 'image',
refreshInterval: parsedConfig.refreshInterval ?? 10,
aspectRatio: parsedConfig.aspectRatio ?? '16/9'
}} />
{/await}
{:else if widget.type === 'integration'}
<IntegrationWidget config={{
appId: parsedConfig.appId ?? '',
endpointId: parsedConfig.endpointId ?? '',
refreshInterval: parsedConfig.refreshInterval ?? 60
}} />
{#await IntegrationWidgetLoader()}
{@render skeleton()}
{:then mod}
<mod.default config={{
appId: parsedConfig.appId ?? '',
endpointId: parsedConfig.endpointId ?? '',
refreshInterval: parsedConfig.refreshInterval ?? 60
}} />
{/await}
{:else}
<div class="flex h-full items-center justify-center rounded-xl border border-border bg-card p-4">
<span class="text-xs text-muted-foreground">{$t('widget.type', { values: { type: widget.type } })}</span>
@@ -121,7 +121,7 @@
<p class="py-8 text-center text-sm text-muted-foreground">{$t('common.no_results') ?? 'No matching widget types'}</p>
{:else}
<div class="grid grid-cols-2 gap-1.5">
{#each filteredTypes as wt}
{#each filteredTypes as wt (wt.value)}
<button
type="button"
onclick={() => onSelect(wt.value)}
@@ -129,7 +129,7 @@
>
<div class="mt-0.5 shrink-0 rounded-lg bg-primary/10 p-2 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
{#each iconFor(wt.value).split('|') as segment}
{#each iconFor(wt.value).split('|') as segment, si (si)}
{#if segment.includes('m') || segment.includes('M') || segment.includes('a') || segment.includes('z') || segment.includes('A') || segment.includes('c') || segment.includes('l') || segment.includes('v') || segment.includes('h') || segment.includes('V') || segment.includes('H')}
<path d={segment} />
{:else}
@@ -23,8 +23,8 @@
<p class="py-4 text-center text-sm text-muted-foreground">No chart data</p>
{:else}
<svg viewBox="0 0 100 60" class="h-40 w-full" preserveAspectRatio="none">
{#each data.datasets as dataset, di}
{#each dataset.values as value, i}
{#each data.datasets as dataset, di (di)}
{#each dataset.values as value, i (i)}
{@const barHeight = (value / maxValue) * 50}
{@const x = (i / data.labels.length) * 100 + 1 + di * (barWidth / data.datasets.length)}
<rect
@@ -42,7 +42,7 @@
</svg>
{#if data.datasets.length > 1}
<div class="mt-2 flex flex-wrap justify-center gap-3">
{#each data.datasets as dataset, di}
{#each data.datasets as dataset, di (di)}
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<span class="h-2 w-2 rounded-full" style="background-color: {dataset.color ?? defaultColors[di % defaultColors.length]}"></span>
{dataset.label}
@@ -25,7 +25,7 @@
{#if alerts.length > 0}
<div class="space-y-2 px-4 pt-2">
{#each alerts as alert}
{#each alerts as alert, i (i)}
<AlertBannerRenderer data={alert} />
{/each}
</div>