feat: production hardening + password reset, metrics, signed webhooks
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:
@@ -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 });
|
||||
|
||||
@@ -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=<hex></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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user