From 1c0a7cb85083eb54cc92dd39a66078aff7a9ccf0 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 25 Mar 2026 14:18:10 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Phases=204-7=20=E2=80=94=20Full=20Featu?= =?UTF-8?q?re=20Expansion=20(26=20features)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 — New Widget Types: - Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown, Metric/Counter, Link Group, Camera/Stream widgets - Backend services with caching for each data source - Full creation form with dynamic config fields per type Phase 5 — Visual & Styling Enhancements: - Glassmorphism card style (solid/glass/outline) - Board-level themes with per-board hue/saturation - Animated SVG status rings replacing static dots - Card size options (compact/medium/large) - Custom CSS injection (admin + per-board, sanitized) - Wallpaper backgrounds with blur/overlay/parallax Phase 6 — Functional Features: - Favorites bar with drag-and-drop reordering - Recent apps tracking with privacy toggle - Uptime dashboard page (/status, guest-accessible) - Notifications system (Discord/Slack/Telegram/HTTP webhooks) - App tags with filtering in board view - Multi-URL app cards with expandable sub-links - Personal API tokens with scoped permissions - Audit log with retention and admin viewer Phase 7 — Quality of Life: - Onboarding wizard (5-step first-launch setup) - App URL health preview with favicon/title detection - Board templates (4 built-in + custom import/export) - Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help) 212 files changed, 15641 insertions, 980 deletions. Build, lint, type check, and 222 tests all pass. --- PLAN_PROMPT.md | 248 ++++++- eslint.config.js | 8 + package-lock.json | 11 + package.json | 1 + plans/mvp-web-app-launcher/PLAN.md | 24 +- .../phase-1-scaffolding.md | 5 + .../phase-2-database-services.md | 6 + .../phase-3-authentication.md | 6 + .../phase-4-app-healthcheck.md | 5 + .../phase-5-board-widgets.md | 7 + .../phase-6-admin-panel.md | 7 + .../mvp-web-app-launcher/phase-7-ui-polish.md | 5 + .../phase-8-integration-deploy.md | 8 + plans/phase-2-enhanced-features/CONTEXT.md | 5 + plans/phase-2-enhanced-features/PLAN.md | 19 +- .../phase-1-oauth.md | 4 + .../phase-2-enhanced-features/phase-2-dnd.md | 7 + .../phase-3-localization.md | 6 + .../phase-4-widgets.md | 6 + .../phase-5-access-control.md | 6 + .../phase-6-integration.md | 6 + plans/phase-3-advanced-features/CONTEXT.md | 6 + plans/phase-3-advanced-features/PLAN.md | 21 +- .../phase-1-import-export.md | 1 + .../phase-3-user-themes.md | 2 + .../phase-3-advanced-features/phase-4-pwa.md | 2 + .../phase-5-autodiscovery.md | 3 + .../phase-6-bookmarklet-sync.md | 3 + .../phase-7-integration.md | 2 + plans/phase-4-7-full-expansion/CONTEXT.md | 105 +++ plans/phase-4-7-full-expansion/PLAN.md | 65 ++ .../phase-1-schema-types.md | 155 ++++ .../phase-2-widget-backend.md | 152 ++++ .../phase-3-widget-frontend.md | 178 +++++ .../phase-4-visual-styling.md | 162 +++++ .../phase-5-functional-backend.md | 189 +++++ .../phase-6-functional-frontend.md | 207 ++++++ .../phase-7-quality-of-life.md | 177 +++++ .../phase-8-integration-polish.md | 108 +++ .../migration.sql | 243 +++++++ prisma/schema.prisma | 182 ++++- prisma/seed.ts | 3 +- src/app.css | 62 +- src/app.html | 4 +- src/hooks.server.ts | 27 + src/lib/components/admin/AuditLogTable.svelte | 281 ++++++++ src/lib/components/admin/SettingsForm.svelte | 13 + src/lib/components/admin/TagManager.svelte | 248 +++++++ .../components/app/AnimatedStatusRing.svelte | 129 ++++ src/lib/components/app/AppForm.svelte | 15 + src/lib/components/app/AppLinksEditor.svelte | 202 ++++++ src/lib/components/app/AppUrlPreview.svelte | 169 +++++ src/lib/components/app/TagBadge.svelte | 45 ++ .../background/AmbientBackground.svelte | 11 + .../background/WallpaperBackground.svelte | 62 ++ src/lib/components/board/Board.svelte | 7 +- .../board/BoardThemeProvider.svelte | 63 ++ .../components/board/DraggableBoard.svelte | 5 +- .../components/board/RecentAppsSection.svelte | 157 ++++ src/lib/components/board/TagFilter.svelte | 81 +++ .../components/board/TemplatePicker.svelte | 185 +++++ .../layout/CustomCssInjector.svelte | 47 ++ src/lib/components/layout/FavoritesBar.svelte | 114 +++ src/lib/components/layout/Header.svelte | 27 + src/lib/components/layout/Sidebar.svelte | 48 +- .../notifications/NotificationBell.svelte | 175 +++++ .../NotificationChannelForm.svelte | 257 +++++++ .../notifications/NotificationHistory.svelte | 169 +++++ .../onboarding/OnboardingWizard.svelte | 436 ++++++++++++ .../section/DraggableSection.svelte | 24 +- src/lib/components/section/Section.svelte | 13 +- .../settings/ApiTokenCreateForm.svelte | 74 ++ .../components/settings/ApiTokenList.svelte | 127 ++++ .../settings/CustomCssEditor.svelte | 120 ++++ .../settings/ThemeCustomizer.svelte | 30 +- src/lib/components/ui/EntityPicker.svelte | 1 + .../ui/KeyboardShortcutOverlay.svelte | 108 +++ src/lib/components/widget/AppWidget.svelte | 360 ++++++++-- .../components/widget/CalendarWidget.svelte | 177 +++++ .../widget/CameraStreamWidget.svelte | 226 ++++++ .../widget/ClockWeatherWidget.svelte | 182 +++++ .../components/widget/LinkGroupWidget.svelte | 68 ++ .../components/widget/MarkdownWidget.svelte | 101 +++ src/lib/components/widget/MetricWidget.svelte | 108 +++ .../components/widget/RssFeedWidget.svelte | 148 ++++ .../widget/SystemStatsWidget.svelte | 142 ++++ .../widget/WidgetCreationForm.svelte | 668 +++++++++++++++++- src/lib/components/widget/WidgetGrid.svelte | 35 +- .../components/widget/WidgetRenderer.svelte | 69 +- src/lib/i18n/en.json | 624 ++++++++-------- src/lib/i18n/ru.json | 624 ++++++++-------- src/lib/server/jobs/healthcheckScheduler.ts | 73 +- src/lib/server/middleware/authenticate.ts | 24 + .../services/__tests__/appService.test.ts | 10 +- .../services/__tests__/boardService.test.ts | 3 +- .../__tests__/discoveryService.test.ts | 4 +- .../services/__tests__/groupService.test.ts | 9 +- .../services/__tests__/importService.test.ts | 4 +- .../services/__tests__/oauthService.test.ts | 6 +- .../__tests__/permissionService.test.ts | 35 +- .../services/__tests__/userService.test.ts | 4 +- src/lib/server/services/apiTokenService.ts | 127 ++++ src/lib/server/services/appService.ts | 94 ++- src/lib/server/services/auditLogService.ts | 98 +++ src/lib/server/services/authService.ts | 11 +- src/lib/server/services/boardService.ts | 93 ++- src/lib/server/services/calendarService.ts | 238 +++++++ src/lib/server/services/cameraService.ts | 149 ++++ src/lib/server/services/discoveryService.ts | 9 +- src/lib/server/services/favoriteService.ts | 83 +++ src/lib/server/services/groupService.ts | 4 +- src/lib/server/services/importService.ts | 13 +- src/lib/server/services/metricService.ts | 227 ++++++ .../server/services/notificationService.ts | 315 +++++++++ src/lib/server/services/oauthService.ts | 10 +- src/lib/server/services/onboardingService.ts | 73 ++ src/lib/server/services/permissionService.ts | 13 +- src/lib/server/services/recentAppsService.ts | 58 ++ src/lib/server/services/rssFeedService.ts | 189 +++++ src/lib/server/services/systemStatsService.ts | 217 ++++++ src/lib/server/services/tagService.ts | 112 +++ src/lib/server/services/templateService.ts | 319 +++++++++ src/lib/server/services/uptimeService.ts | 213 ++++++ src/lib/server/services/weatherService.ts | 102 +++ src/lib/server/utils/response.ts | 7 +- src/lib/stores/favorites.svelte.ts | 127 ++++ src/lib/stores/keyboard.svelte.ts | 214 ++++++ src/lib/stores/notifications.svelte.ts | 103 +++ src/lib/stores/search.svelte.ts | 1 + src/lib/stores/theme.svelte.ts | 24 +- src/lib/types/apiToken.ts | 21 + src/lib/types/app.ts | 36 + src/lib/types/auditLog.ts | 21 + src/lib/types/board.ts | 18 + src/lib/types/index.ts | 5 + src/lib/types/notification.ts | 38 + src/lib/types/tag.ts | 22 + src/lib/types/template.ts | 19 + src/lib/types/user.ts | 23 + src/lib/types/widget.ts | 64 ++ src/lib/utils/__tests__/broadcastSync.test.ts | 4 +- src/lib/utils/broadcastSync.ts | 3 +- src/lib/utils/constants.ts | 66 +- src/lib/utils/validators.ts | 221 +++++- src/routes/+layout.server.ts | 25 +- src/routes/+layout.svelte | 31 +- src/routes/admin/+layout.svelte | 4 +- src/routes/admin/audit-log/+page.server.ts | 46 ++ src/routes/admin/audit-log/+page.svelte | 27 + src/routes/admin/settings/+page.server.ts | 9 +- src/routes/admin/tags/+page.svelte | 10 + src/routes/api/admin/audit-log/+server.ts | 53 ++ src/routes/api/admin/discover/+server.ts | 10 +- .../api/admin/discover/approve/+server.ts | 24 +- src/routes/api/admin/export/+server.ts | 5 +- src/routes/api/admin/import/+server.ts | 5 +- src/routes/api/admin/settings/+server.ts | 21 +- src/routes/api/apps/+server.ts | 3 + src/routes/api/apps/[id]/+server.ts | 5 +- src/routes/api/apps/[id]/history/+server.ts | 3 +- src/routes/api/apps/[id]/links/+server.ts | 137 ++++ src/routes/api/apps/[id]/tags/+server.ts | 81 +++ src/routes/api/apps/preview/+server.ts | 178 +++++ src/routes/api/boards/+server.ts | 4 +- src/routes/api/boards/[id]/+server.ts | 4 +- .../permissions/__tests__/permissions.test.ts | 23 +- .../api/boards/[id]/sections/+server.ts | 2 +- .../[id]/sections/[sid]/widgets/+server.ts | 2 +- src/routes/api/favorites/+server.ts | 78 ++ src/routes/api/favorites/reorder/+server.ts | 33 + src/routes/api/notifications/+server.ts | 66 ++ .../api/notifications/channels/+server.ts | 54 ++ .../notifications/channels/[id]/+server.ts | 69 ++ .../channels/[id]/test/+server.ts | 22 + src/routes/api/onboarding/+server.ts | 174 +++++ src/routes/api/recent-apps/+server.ts | 66 ++ src/routes/api/search/+server.ts | 35 +- src/routes/api/tags/+server.ts | 50 ++ src/routes/api/tags/[id]/+server.ts | 69 ++ src/routes/api/templates/+server.ts | 85 +++ src/routes/api/templates/[id]/+server.ts | 44 ++ src/routes/api/templates/import/+server.ts | 49 ++ src/routes/api/tokens/+server.ts | 55 ++ src/routes/api/tokens/[id]/+server.ts | 22 + src/routes/api/uploads/+server.ts | 7 +- src/routes/api/uptime/+server.ts | 32 + src/routes/api/uptime/[appId]/+server.ts | 45 ++ src/routes/api/users/+server.ts | 4 + src/routes/api/users/[id]/+server.ts | 5 + .../preferences/__tests__/preferences.test.ts | 24 +- src/routes/api/wallpaper/+server.ts | 61 ++ src/routes/api/widgets/calendar/+server.ts | 38 + src/routes/api/widgets/camera/+server.ts | 41 ++ src/routes/api/widgets/metric/+server.ts | 54 ++ src/routes/api/widgets/rss/+server.ts | 34 + .../api/widgets/system-stats/+server.ts | 43 ++ src/routes/api/widgets/weather/+server.ts | 36 + src/routes/auth/oauth/authorize/+server.ts | 7 +- src/routes/auth/oauth/callback/+server.ts | 7 +- src/routes/boards/[boardId]/+page.svelte | 49 +- .../boards/[boardId]/edit/+page.server.ts | 28 +- src/routes/boards/[boardId]/edit/+page.svelte | 291 ++++++++ src/routes/boards/new/+page.server.ts | 13 + src/routes/boards/new/+page.svelte | 11 + src/routes/settings/+page.svelte | 43 ++ .../settings/api-tokens/+page.server.ts | 70 ++ src/routes/settings/api-tokens/+page.svelte | 96 +++ .../settings/notifications/+page.svelte | 204 ++++++ src/routes/status/+page.server.ts | 52 ++ src/routes/status/+page.svelte | 185 +++++ src/service-worker.ts | 4 +- static/manifest.json | 46 +- 212 files changed, 15642 insertions(+), 981 deletions(-) create mode 100644 plans/phase-4-7-full-expansion/CONTEXT.md create mode 100644 plans/phase-4-7-full-expansion/PLAN.md create mode 100644 plans/phase-4-7-full-expansion/phase-1-schema-types.md create mode 100644 plans/phase-4-7-full-expansion/phase-2-widget-backend.md create mode 100644 plans/phase-4-7-full-expansion/phase-3-widget-frontend.md create mode 100644 plans/phase-4-7-full-expansion/phase-4-visual-styling.md create mode 100644 plans/phase-4-7-full-expansion/phase-5-functional-backend.md create mode 100644 plans/phase-4-7-full-expansion/phase-6-functional-frontend.md create mode 100644 plans/phase-4-7-full-expansion/phase-7-quality-of-life.md create mode 100644 plans/phase-4-7-full-expansion/phase-8-integration-polish.md create mode 100644 prisma/migrations/20260325092024_phase4_7_schema/migration.sql create mode 100644 src/lib/components/admin/AuditLogTable.svelte create mode 100644 src/lib/components/admin/TagManager.svelte create mode 100644 src/lib/components/app/AnimatedStatusRing.svelte create mode 100644 src/lib/components/app/AppLinksEditor.svelte create mode 100644 src/lib/components/app/AppUrlPreview.svelte create mode 100644 src/lib/components/app/TagBadge.svelte create mode 100644 src/lib/components/background/WallpaperBackground.svelte create mode 100644 src/lib/components/board/BoardThemeProvider.svelte create mode 100644 src/lib/components/board/RecentAppsSection.svelte create mode 100644 src/lib/components/board/TagFilter.svelte create mode 100644 src/lib/components/board/TemplatePicker.svelte create mode 100644 src/lib/components/layout/CustomCssInjector.svelte create mode 100644 src/lib/components/layout/FavoritesBar.svelte create mode 100644 src/lib/components/notifications/NotificationBell.svelte create mode 100644 src/lib/components/notifications/NotificationChannelForm.svelte create mode 100644 src/lib/components/notifications/NotificationHistory.svelte create mode 100644 src/lib/components/onboarding/OnboardingWizard.svelte create mode 100644 src/lib/components/settings/ApiTokenCreateForm.svelte create mode 100644 src/lib/components/settings/ApiTokenList.svelte create mode 100644 src/lib/components/settings/CustomCssEditor.svelte create mode 100644 src/lib/components/ui/KeyboardShortcutOverlay.svelte create mode 100644 src/lib/components/widget/CalendarWidget.svelte create mode 100644 src/lib/components/widget/CameraStreamWidget.svelte create mode 100644 src/lib/components/widget/ClockWeatherWidget.svelte create mode 100644 src/lib/components/widget/LinkGroupWidget.svelte create mode 100644 src/lib/components/widget/MarkdownWidget.svelte create mode 100644 src/lib/components/widget/MetricWidget.svelte create mode 100644 src/lib/components/widget/RssFeedWidget.svelte create mode 100644 src/lib/components/widget/SystemStatsWidget.svelte create mode 100644 src/lib/server/services/apiTokenService.ts create mode 100644 src/lib/server/services/auditLogService.ts create mode 100644 src/lib/server/services/calendarService.ts create mode 100644 src/lib/server/services/cameraService.ts create mode 100644 src/lib/server/services/favoriteService.ts create mode 100644 src/lib/server/services/metricService.ts create mode 100644 src/lib/server/services/notificationService.ts create mode 100644 src/lib/server/services/onboardingService.ts create mode 100644 src/lib/server/services/recentAppsService.ts create mode 100644 src/lib/server/services/rssFeedService.ts create mode 100644 src/lib/server/services/systemStatsService.ts create mode 100644 src/lib/server/services/tagService.ts create mode 100644 src/lib/server/services/templateService.ts create mode 100644 src/lib/server/services/uptimeService.ts create mode 100644 src/lib/server/services/weatherService.ts create mode 100644 src/lib/stores/favorites.svelte.ts create mode 100644 src/lib/stores/keyboard.svelte.ts create mode 100644 src/lib/stores/notifications.svelte.ts create mode 100644 src/lib/types/apiToken.ts create mode 100644 src/lib/types/auditLog.ts create mode 100644 src/lib/types/notification.ts create mode 100644 src/lib/types/tag.ts create mode 100644 src/lib/types/template.ts create mode 100644 src/routes/admin/audit-log/+page.server.ts create mode 100644 src/routes/admin/audit-log/+page.svelte create mode 100644 src/routes/admin/tags/+page.svelte create mode 100644 src/routes/api/admin/audit-log/+server.ts create mode 100644 src/routes/api/apps/[id]/links/+server.ts create mode 100644 src/routes/api/apps/[id]/tags/+server.ts create mode 100644 src/routes/api/apps/preview/+server.ts create mode 100644 src/routes/api/favorites/+server.ts create mode 100644 src/routes/api/favorites/reorder/+server.ts create mode 100644 src/routes/api/notifications/+server.ts create mode 100644 src/routes/api/notifications/channels/+server.ts create mode 100644 src/routes/api/notifications/channels/[id]/+server.ts create mode 100644 src/routes/api/notifications/channels/[id]/test/+server.ts create mode 100644 src/routes/api/onboarding/+server.ts create mode 100644 src/routes/api/recent-apps/+server.ts create mode 100644 src/routes/api/tags/+server.ts create mode 100644 src/routes/api/tags/[id]/+server.ts create mode 100644 src/routes/api/templates/+server.ts create mode 100644 src/routes/api/templates/[id]/+server.ts create mode 100644 src/routes/api/templates/import/+server.ts create mode 100644 src/routes/api/tokens/+server.ts create mode 100644 src/routes/api/tokens/[id]/+server.ts create mode 100644 src/routes/api/uptime/+server.ts create mode 100644 src/routes/api/uptime/[appId]/+server.ts create mode 100644 src/routes/api/wallpaper/+server.ts create mode 100644 src/routes/api/widgets/calendar/+server.ts create mode 100644 src/routes/api/widgets/camera/+server.ts create mode 100644 src/routes/api/widgets/metric/+server.ts create mode 100644 src/routes/api/widgets/rss/+server.ts create mode 100644 src/routes/api/widgets/system-stats/+server.ts create mode 100644 src/routes/api/widgets/weather/+server.ts create mode 100644 src/routes/settings/api-tokens/+page.server.ts create mode 100644 src/routes/settings/api-tokens/+page.svelte create mode 100644 src/routes/settings/notifications/+page.svelte create mode 100644 src/routes/status/+page.server.ts create mode 100644 src/routes/status/+page.svelte diff --git a/PLAN_PROMPT.md b/PLAN_PROMPT.md index b9ef1ef..9476a3f 100644 --- a/PLAN_PROMPT.md +++ b/PLAN_PROMPT.md @@ -12,17 +12,20 @@ Build a **self-hosted web application launcher / dashboard** for a TrueNAS serve ## Tech Stack ### Framework (Full-Stack) + - **SvelteKit** — all-in-one framework: SSR, routing, API routes (`+server.ts`), form actions — single process, no separate backend needed - **Svelte 5** (runes mode) — `$state`, `$derived`, `$effect` for reactive state; compiler-based, no virtual DOM, minimal runtime - **TypeScript** — strict mode throughout ### UI & Styling + - **Tailwind CSS v4** — utility-first styling with smooth animation support - **shadcn-svelte** (Bits UI primitives) — accessible, unstyled component library; each component is a separate file - **Svelte built-in transitions** — `transition:`, `animate:`, `in:/out:` directives for page transitions, expand/collapse, hover effects - **svelte/motion** — `tweened` and `spring` stores for ambient background animations ### Data & State + - **SvelteKit load functions** — server-side data loading with automatic invalidation - **Svelte runes** (`$state`, `$derived`) — client-side reactive state (theme, sidebar, UI) - **Superforms + Zod** — type-safe form handling with progressive enhancement and server-side validation @@ -30,11 +33,13 @@ Build a **self-hosted web application launcher / dashboard** for a TrueNAS serve - **SQLite** — zero-config, file-based, perfect for single-server deployment (easy Docker volume mount, simple backups). Migrate to PostgreSQL later if needed. ### Auth + - **openid-client** — Authentik OIDC/OAuth2 integration - **bcrypt + JWT** — local auth with refresh token rotation via HTTP-only cookies - **SvelteKit hooks** (`handle`) — auth middleware, session management ### Icons + - **Lucide Svelte** — 1500+ clean SVG icons for UI chrome - **Simple Icons** (via `simple-icons` npm package) — 3000+ brand/service icons (Plex, Nextcloud, Docker, Grafana, etc.) — perfect for self-hosted app logos - **Dashboard Icons** (CDN fallback) — community-maintained self-hosted app icon set @@ -42,9 +47,11 @@ Build a **self-hosted web application launcher / dashboard** for a TrueNAS serve - **No emojis** — strictly SVG/image icons only ### Background Jobs + - **node-cron** — scheduled healthcheck pings (runs in SvelteKit server process) ### DevOps + - **Docker** — multi-stage build (SvelteKit build → Node adapter → lightweight runtime) - **docker-compose.yml** — single-command deployment with volume mounts for SQLite + uploads - **Gitea Actions** — CI/CD workflows (lint, type-check, test, Docker image push to Gitea Container Registry) @@ -56,19 +63,23 @@ Build a **self-hosted web application launcher / dashboard** for a TrueNAS serve ### 1. Authentication & Authorization #### Auth Modes (Admin-Configurable) + The system supports three auth modes, selectable by admin in settings: + - **OAuth only** — all users authenticate via Authentik (OIDC/OAuth2) - **Local only** — email/password login with optional registration - **Both** — user chooses OAuth or local login on the login page - **Guest mode** — unauthenticated users see boards marked as `guest-accessible` #### User Management + - Admin can create/edit/delete users manually - Self-registration is **disabled by default**; admin toggles it on/off - OAuth auto-provisions users on first login (maps Authentik groups to local groups) - Users have: `id`, `email`, `displayName`, `avatarUrl`, `authProvider`, `role`, `groupIds[]` #### Groups & Access Control + - **Default groups:** `admin`, `user` - Admin can create custom groups - Permissions are hierarchical: **User-level overrides > Group-level > Default** @@ -79,6 +90,7 @@ The system supports three auth modes, selectable by admin in settings: - Guest access is a separate boolean flag per board #### Session Management + - JWT access tokens stored in HTTP-only cookies (managed by SvelteKit hooks) - Refresh token rotation (7-day expiry) - Server-side session validation in `hooks.server.ts` @@ -87,6 +99,7 @@ The system supports three auth modes, selectable by admin in settings: ### 2. Apps (Service Registry) Each app represents a self-hosted service: + - **url** (required) — base URL of the service - **name** (required) — display name - **icon** — one of: Simple Icons slug, Lucide icon name, Dashboard Icons ID, or uploaded image path @@ -106,6 +119,7 @@ Each app represents a self-hosted service: Boards are the primary organizational unit — each board is a full-page layout of sections and widgets. #### Board Properties + - `name`, `icon`, `description` - `accessLevel` — per-user, per-group, guest-accessible (boolean) - `isDefault` — one board can be marked as the landing page @@ -113,33 +127,39 @@ Boards are the primary organizational unit — each board is a full-page layout - `backgroundConfig` — ambient background settings (see Appearance) #### Sections (Groups) + - Collapsible/expandable containers within a board - Properties: `title`, `icon`, `isExpanded` (default state), `order` - Contain an ordered list of widgets - Smooth expand/collapse animation (Svelte `slide` transition) #### Widgets + Widgets are the atomic content units inside sections. **App Widget (MVP):** + - Displays app icon, name, status indicator (colored dot/ring), optional description - Click opens the app URL in a new tab - Hover shows description tooltip + last healthcheck time - Visual states: online (green pulse), offline (red), degraded (yellow), unknown (gray) **Future widget types (post-MVP, design the schema to support them):** + - **Bookmark widget** — simple URL + label (no healthcheck) - **Note widget** — rich text or markdown note - **Embed widget** — iframe embed of a service - **Status widget** — aggregated status of multiple apps ### 4. Search & Navigation + - Global search bar (Cmd/Ctrl+K) — searches across all accessible apps and boards - Keyboard navigation support - Sidebar with board list (collapsible) - Breadcrumb navigation within nested views ### 5. Admin Panel + - User management (CRUD, group assignment) - Group management (CRUD, permission templates) - App management (CRUD, healthcheck config, bulk import/export) @@ -156,6 +176,7 @@ Widgets are the atomic content units inside sections. ## Non-Functional Requirements ### Appearance & UX + - **Modern, clean design** — inspired by Homarr / Heimdall / Organizr but with smoother polish - **Ambient animated backgrounds** — subtle mesh gradient, particle field, or aurora effect (configurable, can be disabled); implemented with Svelte `tweened`/`spring` + CSS/Canvas - **Customizable primary color** — HSL-based theme system; admin sets default, users can override @@ -172,6 +193,7 @@ Widgets are the atomic content units inside sections. ### File Structure (Modularity) SvelteKit route-based structure with one component per file: + ``` src/ routes/ @@ -324,6 +346,7 @@ static/ ### Database Schema (Prisma) Key models: + - `User` — id, email, password (nullable for OAuth), displayName, avatarUrl, authProvider, role - `Group` — id, name, description, isDefault - `UserGroup` — userId, groupId (many-to-many) @@ -338,6 +361,7 @@ Key models: ### API Design RESTful JSON API via SvelteKit `+server.ts` routes with consistent envelope: + ```json { "success": true, @@ -348,6 +372,7 @@ RESTful JSON API via SvelteKit `+server.ts` routes with consistent envelope: ``` Key endpoints (all under `src/routes/api/`): + - `POST /api/auth/login` — local login - `GET /api/auth/oauth/authorize` — redirect to Authentik - `GET /api/auth/oauth/callback` — handle OAuth callback @@ -393,10 +418,10 @@ services: web-app-launcher: build: . ports: - - "3000:3000" + - '3000:3000' volumes: - - ./data:/app/data # SQLite DB - - ./uploads:/app/uploads # Custom icons + - ./data:/app/data # SQLite DB + - ./uploads:/app/uploads # Custom icons environment: - DATABASE_URL=file:/app/data/launcher.db - JWT_SECRET=changeme @@ -410,6 +435,7 @@ services: ### CI/CD (Gitea Actions) `.gitea/workflows/ci.yml`: + - **On push to any branch:** lint (`eslint`), type-check (`svelte-check`), unit tests (`vitest`) - **On push to main:** build Docker image, push to Gitea Container Registry (`git.dolgolyov-family.by/alexei.dolgolyov/web-app-launcher`) - **On tag (vX.Y.Z):** build + push tagged image, create Gitea release @@ -419,6 +445,7 @@ services: ## Additional Features ### Cool Ideas (Included in Phases) + 1. **Drag-and-drop board editor** — reorder sections and widgets with `svelte-dnd-action` (Svelte-native, accessible, performant) 2. **Auto-discovery** — scan a Docker socket or Traefik API to auto-register running containers as apps 3. **Favicon auto-fetch** — if no icon is selected, attempt to fetch the favicon from the app's URL @@ -430,7 +457,9 @@ services: 9. **Keyboard-first navigation** — Vim-style `j/k` to move between apps, `Enter` to open ### MVP Scope (Phase 1) + To avoid scope creep, the MVP should include: + - Local auth + guest mode (OAuth in Phase 2) - App CRUD + healthcheck with status display - Single default board with sections and app widgets @@ -440,6 +469,7 @@ To avoid scope creep, the MVP should include: - Basic Gitea CI ### Phase 2 + - OAuth/Authentik integration - Multi-board support with per-board access control - Custom groups + granular permissions @@ -447,18 +477,214 @@ To avoid scope creep, the MVP should include: - Global search (Cmd+K) - Additional widget types -### Phase 3 -- ~~Auto-discovery (Docker/Traefik)~~ **DONE** — Phase 5 implementation: discoveryService.ts, /api/admin/discover endpoints, DiscoveryPanel.svelte, SettingsForm discovery config, i18n EN/RU -- Import/Export -- PWA -- Ping history sparklines -- User theme overrides -- Quick-add bookmarklet -- Multi-tab sync +### Phase 3 (DONE) + +- ~~Auto-discovery (Docker/Traefik)~~ **DONE** +- ~~Import/Export~~ **DONE** +- ~~PWA~~ **DONE** +- ~~Ping history sparklines~~ **DONE** +- ~~User theme overrides~~ **DONE** +- ~~Quick-add bookmarklet~~ **DONE** +- ~~Multi-tab sync~~ **DONE** + +### Phase 4 — New Widget Types + +New widget types to expand dashboard capabilities beyond app launching: + +1. **Clock / Weather Widget** + - Local time display with configurable timezone + - Optional weather via free OpenMeteo API (no API key required) + - Analog or digital clock face, minimal design + - Config: `{ timezone: string, showWeather: boolean, latitude?: number, longitude?: number, clockStyle: 'analog' | 'digital' }` + +2. **System Stats Widget** + - CPU, RAM, disk usage pulled from TrueNAS API or Glances API + - Tiny gauge/donut charts with auto-refresh + - Threshold coloring: green (< 60%) → yellow (60-85%) → red (> 85%) + - Config: `{ sourceUrl: string, sourceType: 'truenas' | 'glances' | 'custom', metrics: string[], refreshInterval: number }` + +3. **RSS/Feed Widget** + - Subscribe to any RSS/Atom feed (release notes, changelogs, security advisories) + - Shows latest N items with title + date, expandable to show summary + - Config: `{ feedUrl: string, maxItems: number, showSummary: boolean }` + +4. **Calendar Widget** + - iCal URL subscription (Nextcloud, Google Calendar, etc.) + - Compact list of today's + upcoming events + - Color-coded by calendar source + - Config: `{ icalUrls: Array<{ url: string, color: string, label: string }>, daysAhead: number }` + +5. **Markdown Widget** (upgrade from existing Note widget) + - Full markdown rendering with syntax highlighting (via `shiki` or `highlight.js`) + - Live preview split-pane edit mode + - Useful for runbooks, quick-reference docs, IP tables, cheat sheets + - Config: `{ content: string, syntaxTheme: string }` + +6. **Metric/Counter Widget** + - Single big number with label (e.g., "12 containers running", "99.7% uptime") + - Data source: static value, HTTP JSON endpoint + JSONPath, or Prometheus PromQL query + - Trend arrow (up/down/flat vs last poll) + - Config: `{ label: string, source: 'static' | 'http' | 'prometheus', value?: string, url?: string, jsonPath?: string, query?: string, unit?: string, refreshInterval: number }` + +7. **Link Group Widget** + - Compact list of related URLs (lighter than full app cards) + - Example: "Documentation" group with links to wikis, API docs, Swagger pages + - Collapsible, optional numbering and icons per link + - Config: `{ links: Array<{ label: string, url: string, icon?: string }>, collapsible: boolean }` + +8. **Camera/Stream Widget** + - MJPEG or HLS stream thumbnail from security cameras or media servers + - Click to open fullscreen stream in modal or new tab + - Auto-refresh snapshot at configurable interval + - Config: `{ streamUrl: string, type: 'mjpeg' | 'hls' | 'snapshot', refreshInterval: number, aspectRatio: string }` + +### Phase 5 — Visual & Styling Enhancements + +Polish the visual experience with advanced theming and card styles: + +1. **Glassmorphism Card Style** + - Frosted glass effect on cards (`backdrop-filter: blur(12px)` + semi-transparent bg) + - Ambient background effect bleeds through cards + - Toggle between `solid` / `glass` / `outline` card styles in theme settings + - Applies globally or per-board + +2. **Board-Level Themes** + - Each board gets its own color accent (hue/saturation) + background effect + - Example: "Work" = blue + mesh gradient, "Media" = purple + aurora, "Infra" = green + particles + - Board theme overrides global theme when viewing that board + - Smooth transition when switching boards + - Schema: add `themeHue`, `themeSaturation`, `backgroundType` to Board model + +3. **Animated Status Ring** + - Replace the static status dot with an SVG ring around the app icon + - Online = animated green fill sweep, Offline = pulsing red ring, Degraded = partial yellow arc, Unknown = gray dashed + - More visually striking, scales well with different card sizes + +4. **Card Size Options** + - Three sizes: `compact` (icon + name), `medium` (current), `large` (icon + name + description + sparkline + tags) + - Configurable per-section or per-board + - Responsive: auto-downsizes on mobile + - Schema: add `cardSize` to Section and Board models + +5. **Custom CSS Injection** + - Admin-level custom CSS textarea in system settings + - Per-board CSS overrides field + - Sanitized (strip ` + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + +
+ + + {#if logs.length === 0} +
+

No audit log entries found

+
+ {:else} +
+ + + + + + + + + + + + {#each logs as log (log.id)} + + + + + + + + {#if expandedId === log.id} + + + + {/if} + {/each} + +
TimestampUserActionEntityDetails
+ {new Date(log.createdAt).toLocaleString()} + + {log.user?.displayName ?? log.userId ?? 'System'} + + + {actionLabel(log.action)} + + + {log.entityType} + {log.entityId.substring(0, 8)}... + + {#if log.details && log.details !== '{}'} + + {:else} + + {/if} +
+
{formatDetails(log.details)}
+
+
+ + +
+ + Page {currentPage} + +
+ {/if} +
diff --git a/src/lib/components/admin/SettingsForm.svelte b/src/lib/components/admin/SettingsForm.svelte index c6c2144..78dd096 100644 --- a/src/lib/components/admin/SettingsForm.svelte +++ b/src/lib/components/admin/SettingsForm.svelte @@ -3,6 +3,7 @@ import { superForm, type SuperValidated } from 'sveltekit-superforms/client'; import type { updateSystemSettingsSchema } from '$lib/utils/validators.js'; import type { z } from 'zod'; + import CustomCssEditor from '$lib/components/settings/CustomCssEditor.svelte'; let { form: formData, @@ -224,6 +225,18 @@ + +
+

{$t('admin.custom_css') ?? 'Custom CSS'}

+

{$t('admin.custom_css_description') ?? 'System-wide custom CSS applied to all pages. Scoped to .custom-css-scope to prevent breaking core UI.'}

+ + { $form.customCss = css; }} + label={$t('admin.custom_css_label') ?? 'System-wide CSS'} + /> +
+ {#if $errors._errors}

{$errors._errors}

{/if} diff --git a/src/lib/components/admin/TagManager.svelte b/src/lib/components/admin/TagManager.svelte new file mode 100644 index 0000000..ed2b1d2 --- /dev/null +++ b/src/lib/components/admin/TagManager.svelte @@ -0,0 +1,248 @@ + + +
+
+

Tag Management

+ +
+ + {#if error} +
+ {error} +
+ {/if} + + + {#if showCreateForm} +
+
{ e.preventDefault(); createTag(); }} class="flex flex-wrap items-end gap-3"> +
+ + +
+
+ +
+ + {newColor} +
+
+ +
+
+ {/if} + + + {#if loading} +
Loading tags...
+ {:else if tags.length === 0} +
+

No tags created yet

+
+ {:else} +
+ {#each tags as tag (tag.id)} +
+ {#if editingTag?.id === tag.id} +
{ e.preventDefault(); saveEdit(); }} + class="flex flex-1 items-center gap-2" + > + + + + +
+ {:else} +
+ + {tag.name} +
+
+ + {#if confirmDeleteId === tag.id} + + + {:else} + + {/if} +
+ {/if} +
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/app/AnimatedStatusRing.svelte b/src/lib/components/app/AnimatedStatusRing.svelte new file mode 100644 index 0000000..bd77feb --- /dev/null +++ b/src/lib/components/app/AnimatedStatusRing.svelte @@ -0,0 +1,129 @@ + + + + + diff --git a/src/lib/components/app/AppForm.svelte b/src/lib/components/app/AppForm.svelte index 055b2b6..6d4a653 100644 --- a/src/lib/components/app/AppForm.svelte +++ b/src/lib/components/app/AppForm.svelte @@ -4,6 +4,7 @@ import type { z } from 'zod'; import type { createAppSchema } from '$lib/utils/validators.js'; import AppIconPicker from './AppIconPicker.svelte'; + import AppUrlPreview from './AppUrlPreview.svelte'; import IconGrid from '$lib/components/ui/IconGrid.svelte'; import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte'; @@ -65,6 +66,20 @@ + + { + $form.icon = favicon; + $form.iconType = 'url'; + }} + onApplyTitle={(title) => { + $form.name = title; + }} + /> +
{/if} diff --git a/src/lib/components/background/WallpaperBackground.svelte b/src/lib/components/background/WallpaperBackground.svelte new file mode 100644 index 0000000..a3bad17 --- /dev/null +++ b/src/lib/components/background/WallpaperBackground.svelte @@ -0,0 +1,62 @@ + + +{#if !loadError} + +{/if} diff --git a/src/lib/components/board/Board.svelte b/src/lib/components/board/Board.svelte index 6777060..16be69b 100644 --- a/src/lib/components/board/Board.svelte +++ b/src/lib/components/board/Board.svelte @@ -36,12 +36,15 @@ statuses: Array<{ status: string; responseTime: number | null }>; } + import type { CardSize } from '$lib/utils/constants.js'; + interface Props { sections: SectionData[]; allApps?: AppData[]; + boardCardSize?: CardSize; } - let { sections, allApps = [] }: Props = $props(); + let { sections, allApps = [], boardCardSize = 'medium' }: Props = $props();
@@ -51,7 +54,7 @@
{:else} {#each sections as section (section.id)} -
+
{/each} {/if} diff --git a/src/lib/components/board/BoardThemeProvider.svelte b/src/lib/components/board/BoardThemeProvider.svelte new file mode 100644 index 0000000..733b112 --- /dev/null +++ b/src/lib/components/board/BoardThemeProvider.svelte @@ -0,0 +1,63 @@ + + +
+ {@render children()} +
+ + diff --git a/src/lib/components/board/DraggableBoard.svelte b/src/lib/components/board/DraggableBoard.svelte index 1713e82..b8378fa 100644 --- a/src/lib/components/board/DraggableBoard.svelte +++ b/src/lib/components/board/DraggableBoard.svelte @@ -39,6 +39,7 @@ onDeleteSection: (sectionId: string) => void; onAddWidget: (sectionId: string, widgetData: string) => void; onDeleteWidget: (widgetId: string) => void; + onUpdateSection?: (sectionId: string, data: Record) => void; } let { @@ -49,7 +50,8 @@ onToggleAddWidget, onDeleteSection, onAddWidget, - onDeleteWidget + onDeleteWidget, + onUpdateSection }: Props = $props(); let sections = $state([...initialSections]); @@ -135,6 +137,7 @@ {onDeleteSection} {onAddWidget} {onDeleteWidget} + {onUpdateSection} /> {/each} diff --git a/src/lib/components/board/RecentAppsSection.svelte b/src/lib/components/board/RecentAppsSection.svelte new file mode 100644 index 0000000..93924af --- /dev/null +++ b/src/lib/components/board/RecentAppsSection.svelte @@ -0,0 +1,157 @@ + + +{#if trackRecentApps && !loading && recentApps.length > 0} + +{/if} diff --git a/src/lib/components/board/TagFilter.svelte b/src/lib/components/board/TagFilter.svelte new file mode 100644 index 0000000..d398301 --- /dev/null +++ b/src/lib/components/board/TagFilter.svelte @@ -0,0 +1,81 @@ + + +{#if !loading && tags.length > 0} +
+ Filter: + {#each tags as tag (tag.id)} + + {/each} + + {#if activeTags.length > 0} + + {/if} +
+{/if} diff --git a/src/lib/components/board/TemplatePicker.svelte b/src/lib/components/board/TemplatePicker.svelte new file mode 100644 index 0000000..c4cda18 --- /dev/null +++ b/src/lib/components/board/TemplatePicker.svelte @@ -0,0 +1,185 @@ + + +
+

Choose a template (optional)

+ + {#if loading} +
+ + + + + Loading templates... +
+ {:else} +
+ + + + + {#each templates as template (template.id)} + + {/each} +
+ + +
+ +
+ {/if} + + {#if errorMsg} +
+ {errorMsg} +
+ {/if} +
diff --git a/src/lib/components/layout/CustomCssInjector.svelte b/src/lib/components/layout/CustomCssInjector.svelte new file mode 100644 index 0000000..90282fa --- /dev/null +++ b/src/lib/components/layout/CustomCssInjector.svelte @@ -0,0 +1,47 @@ + + +{#if sanitizedCss} + +{/if} diff --git a/src/lib/components/layout/FavoritesBar.svelte b/src/lib/components/layout/FavoritesBar.svelte new file mode 100644 index 0000000..914ee66 --- /dev/null +++ b/src/lib/components/layout/FavoritesBar.svelte @@ -0,0 +1,114 @@ + + +{#if favorites.hasFavorites} + +{/if} diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte index e49004e..8ce63b5 100644 --- a/src/lib/components/layout/Header.svelte +++ b/src/lib/components/layout/Header.svelte @@ -3,6 +3,7 @@ import ThemeToggle from './ThemeToggle.svelte'; import LanguageSwitcher from './LanguageSwitcher.svelte'; import SearchTrigger from '$lib/components/search/SearchTrigger.svelte'; + import NotificationBell from '$lib/components/notifications/NotificationBell.svelte'; import { ui } from '$lib/stores/ui.svelte.js'; import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js'; @@ -128,6 +129,11 @@ {/if} + + {#if user} + + {/if} + @@ -182,6 +188,27 @@ {$t('settings.title')} + (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" + > + + + + + API Tokens + +
+ + {#if showDropdown} +
+ +
+

Notifications

+ {#if notifications.hasUnread} + + {/if} +
+ + +
+ {#if notifications.items.length === 0} +
+

No notifications yet

+
+ {:else} + {#each notifications.items as notification (notification.id)} + + {/each} + {/if} +
+ + + +
+ {/if} + diff --git a/src/lib/components/notifications/NotificationChannelForm.svelte b/src/lib/components/notifications/NotificationChannelForm.svelte new file mode 100644 index 0000000..8ab3dcd --- /dev/null +++ b/src/lib/components/notifications/NotificationChannelForm.svelte @@ -0,0 +1,257 @@ + + +
+

+ {channel ? 'Edit Channel' : 'Add Notification Channel'} +

+ + { e.preventDefault(); handleSubmit(); }} class="space-y-4"> + +
+ + +
+ + + {#if channelType === 'discord'} +
+ + +
+ {:else if channelType === 'slack'} +
+ + +
+ {:else if channelType === 'telegram'} +
+ + +
+
+ + +
+ {:else if channelType === 'http'} +
+ + +
+
+ + +
+ {/if} + + +
+ + +
+ + + {#if testResult} +

+ {testResult} +

+ {/if} + + +
+ + {#if channel?.id} + + {/if} + +
+ +
diff --git a/src/lib/components/notifications/NotificationHistory.svelte b/src/lib/components/notifications/NotificationHistory.svelte new file mode 100644 index 0000000..63446b7 --- /dev/null +++ b/src/lib/components/notifications/NotificationHistory.svelte @@ -0,0 +1,169 @@ + + +
+ +
+ +
+ + + {#if loading} +
Loading...
+ {:else if allNotifications.length === 0} +
+

No notifications found

+
+ {:else} +
+ + + + + + + + + + + + {#each allNotifications as notification (notification.id)} + + + + + + + + {/each} + +
TimeEventAppMessageStatus
+ {new Date(notification.sentAt).toLocaleString()} + + + {eventLabel(notification.event)} + + + {notification.app?.name ?? '—'} + + {notification.message} + + {#if notification.readAt} + Read + {:else} + Unread + {/if} +
+
+ + +
+ + Page {currentPage} + +
+ {/if} +
diff --git a/src/lib/components/onboarding/OnboardingWizard.svelte b/src/lib/components/onboarding/OnboardingWizard.svelte new file mode 100644 index 0000000..0a9d668 --- /dev/null +++ b/src/lib/components/onboarding/OnboardingWizard.svelte @@ -0,0 +1,436 @@ + + +
+
+ +
+
+ {#each STEPS as step, i (step)} + currentStepIndex} + > + {step === 'authMode' ? 'Auth' : step} + + {/each} +
+
+
+
+
+ + +
+ {#if currentStep === 'welcome'} +
+
+ + + + + + +
+

Welcome to Web App Launcher

+

+ Let's get your dashboard set up. This wizard will guide you through the initial + configuration in a few quick steps. +

+
+ + {:else if currentStep === 'admin'} +

Create Admin Account

+ {#if adminCreated} +
+ Admin account created successfully. You can proceed to the next step. +
+ {:else} +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} + + {:else if currentStep === 'authMode'} +

Authentication Mode

+
+
+ {#each [ + { value: 'local', label: 'Local Only', desc: 'Email + password authentication' }, + { value: 'oauth', label: 'OAuth Only', desc: 'External identity provider (OIDC)' }, + { value: 'both', label: 'Both', desc: 'Local accounts and OAuth' } + ] as option (option.value)} + + {/each} +
+ + {#if authMode !== 'local'} +
+

OAuth Configuration (optional — can be set later)

+ + + +
+ {/if} +
+ + {:else if currentStep === 'theme'} +

Theme & Appearance

+
+
+

Default Theme

+
+ + +
+
+ +
+

Accent Color

+
+ {#each primaryColorOptions as color (color.value)} + + {/each} +
+
+
+ + {:else if currentStep === 'complete'} +

Create First Board

+
+
+ + +
+

+ A default board will be created for you. You can customize it later. +

+
+ {/if} + + {#if errorMsg} +
+ {errorMsg} +
+ {/if} +
+ + +
+
+ {#if !isFirstStep} + + {/if} +
+ +
+ {#if !isFirstStep && !isLastStep && currentStep !== 'admin'} + + {/if} + + +
+
+
+
diff --git a/src/lib/components/section/DraggableSection.svelte b/src/lib/components/section/DraggableSection.svelte index 7a9e554..a36b5e1 100644 --- a/src/lib/components/section/DraggableSection.svelte +++ b/src/lib/components/section/DraggableSection.svelte @@ -28,6 +28,7 @@ icon: string | null; order: number; isExpandedByDefault: boolean; + cardSize?: string | null; widgets: WidgetData[]; } @@ -40,6 +41,7 @@ onDeleteSection: (sectionId: string) => void; onAddWidget: (sectionId: string, widgetData: string) => void; onDeleteWidget: (widgetId: string) => void; + onUpdateSection?: (sectionId: string, data: Record) => void; } let { @@ -50,9 +52,18 @@ onToggleAddWidget, onDeleteSection, onAddWidget, - onDeleteWidget + onDeleteWidget, + onUpdateSection }: Props = $props(); + const cardSizeOptions = ['compact', 'medium', 'large'] as const; + + function handleCardSizeChange(event: Event) { + const select = event.target as HTMLSelectElement; + const value = select.value || null; + onUpdateSection?.(section.id, { cardSize: value }); + } + let widgets = $state([...section.widgets]); let dirty = $state(false); @@ -128,6 +139,17 @@ {/if}
+ +
diff --git a/src/lib/components/settings/ApiTokenCreateForm.svelte b/src/lib/components/settings/ApiTokenCreateForm.svelte new file mode 100644 index 0000000..e06bd83 --- /dev/null +++ b/src/lib/components/settings/ApiTokenCreateForm.svelte @@ -0,0 +1,74 @@ + + +
+

Generate API Token

+ +
+
+ + +

A descriptive name to identify this token

+
+ +
+ + +
+ +
+ + +

Leave empty for a non-expiring token

+
+ +
+ + +
+
+
diff --git a/src/lib/components/settings/ApiTokenList.svelte b/src/lib/components/settings/ApiTokenList.svelte new file mode 100644 index 0000000..b53c109 --- /dev/null +++ b/src/lib/components/settings/ApiTokenList.svelte @@ -0,0 +1,127 @@ + + +{#if tokens.length === 0} +
+

No API tokens created yet

+

+ API tokens allow programmatic access to your dashboard +

+
+{:else} +
+ + + + + + + + + + + + + {#each tokens as token (token.id)} + + + + + + + + + {/each} + +
NameScopeCreatedLast UsedExpiresActions
{token.name} + + {scopeLabel(token.scope)} + + {formatDate(token.createdAt)}{formatDate(token.lastUsedAt)} + {#if token.expiresAt} + + {formatDate(token.expiresAt)} + {#if isExpired(token.expiresAt)} + (expired) + {/if} + + {:else} + Never + {/if} + + {#if confirmRevokeId === token.id} +
+ +
+ + +
+
+ {:else} + + {/if} +
+
+{/if} diff --git a/src/lib/components/settings/CustomCssEditor.svelte b/src/lib/components/settings/CustomCssEditor.svelte new file mode 100644 index 0000000..915037b --- /dev/null +++ b/src/lib/components/settings/CustomCssEditor.svelte @@ -0,0 +1,120 @@ + + +
+ {#if label} + + {/if} + + + + {#if validationError} +

{validationError}

+ {/if} + +
+ + + {$t('settings.custom_css_hint') ?? 'CSS is scoped to .custom-css-scope to prevent breaking core UI'} + +
+ + {#if livePreview && localValue && !validationError} +
+

{$t('settings.preview_area') ?? 'Preview area'}

+ + {@html ``} +
+ {/if} +
diff --git a/src/lib/components/settings/ThemeCustomizer.svelte b/src/lib/components/settings/ThemeCustomizer.svelte index 537f1fb..9c8ed21 100644 --- a/src/lib/components/settings/ThemeCustomizer.svelte +++ b/src/lib/components/settings/ThemeCustomizer.svelte @@ -1,6 +1,6 @@ + +{#if keyboard.overlayOpen} + +
e.key === 'Escape' && keyboard.closeOverlay()} + > + +
+{/if} diff --git a/src/lib/components/widget/AppWidget.svelte b/src/lib/components/widget/AppWidget.svelte index 4a7fc50..dfb954d 100644 --- a/src/lib/components/widget/AppWidget.svelte +++ b/src/lib/components/widget/AppWidget.svelte @@ -1,7 +1,26 @@ - - - + - - - {app.name} - - - - - - - {#if historyLoading} -
- {:else if historyData.length > 0} -
- - {#if uptimePercent !== null} - {uptimePercent}% - {/if} + + {#if linksExpanded && hasLinks} +
+ {#each app.links ?? [] as link (link.id)} + + + + + + + {link.label} + + {/each}
{/if} - +{:else if cardSize === 'large'} + +
+ +
+
+ {#if app.iconType === 'emoji' && app.icon} + {app.icon} + {:else if iconSrc} + {app.name} icon + {:else} + {app.name.charAt(0).toUpperCase()} + {/if} +
+ +
+ + + {app.name} + + + {#if app.description} +

{app.description}

+ {/if} + + + + {#if historyLoading} +
+ {:else if historyData.length > 0} +
+ + {#if uptimePercent !== null} + {uptimePercent}% + {/if} +
+ {/if} +
+ + + {#if hasTags} +
+ {#each app.tags ?? [] as tag (tag.id)} + + {/each} +
+ {/if} + + + {#if hasLinks} +
+ + + {#if linksExpanded} +
+ {#each app.links ?? [] as link (link.id)} + + + + + + + {link.label} + + {/each} +
+ {/if} +
+ {/if} +
+{:else} + + +{/if} + + +{#if showContextMenu} +
+ +
+{/if} diff --git a/src/lib/components/widget/CalendarWidget.svelte b/src/lib/components/widget/CalendarWidget.svelte new file mode 100644 index 0000000..ee0b0cf --- /dev/null +++ b/src/lib/components/widget/CalendarWidget.svelte @@ -0,0 +1,177 @@ + + +
+
+ + Calendar +
+ + {#if loading} +
+ {#each [1, 2, 3] as _n (_n)} +
+
+
+
+
+ {/each} +
+ {:else if error} +
+ Failed to load events +
+ {:else if events.length === 0} +
+
+ + No upcoming events +
+
+ {:else} +
+ {#each grouped as group (group.label)} +
+

+ {group.label} +

+
+ {#each group.events as evt (evt.summary + evt.start)} +
+ + +
+

{evt.summary}

+
+ + + {formatTimeRange(evt.start, evt.end)} + + {#if evt.location} + + + {evt.location} + + {/if} +
+
+
+ {/each} +
+
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/widget/CameraStreamWidget.svelte b/src/lib/components/widget/CameraStreamWidget.svelte new file mode 100644 index 0000000..b8787b0 --- /dev/null +++ b/src/lib/components/widget/CameraStreamWidget.svelte @@ -0,0 +1,226 @@ + + +
+ +
+ {#if loading} +
+
+ + + + + Loading... +
+
+ {/if} + + {#if error} +
+ + Stream unavailable +
+ {/if} + + {#if streamType === 'hls'} + + {:else} + Camera stream + {/if} + + + {#if !error} + + {/if} +
+
+ + +{#if fullscreen} + +
e.key === 'Escape' && closeFullscreen()} + > + + + +
e.stopPropagation()}> + {#if streamType === 'hls'} + + {:else} + Camera stream fullscreen + {/if} +
+
+{/if} diff --git a/src/lib/components/widget/ClockWeatherWidget.svelte b/src/lib/components/widget/ClockWeatherWidget.svelte new file mode 100644 index 0000000..829ad46 --- /dev/null +++ b/src/lib/components/widget/ClockWeatherWidget.svelte @@ -0,0 +1,182 @@ + + +
+ {#if clockStyle === 'analog'} + + + + + + + {#each {length: 12} as _, i (i)} + {@const angle = (i * 30 * Math.PI) / 180} + {@const x1 = 50 + 42 * Math.sin(angle)} + {@const y1 = 50 - 42 * Math.cos(angle)} + {@const x2 = 50 + 46 * Math.sin(angle)} + {@const y2 = 50 - 46 * Math.cos(angle)} + + {/each} + + + + + + + + + +

{dateStr}

+ {:else} + +

{timeStr}

+

{dateStr}

+ {#if config.timezone} +

{config.timezone.replace(/_/g, ' ')}

+ {/if} + {/if} + + + {#if showWeather} +
+ {#if weatherLoading} +
+ {:else if weatherError} + Weather unavailable + {:else if weatherData} + {@const WeatherIcon = getWeatherIcon(weatherData.condition)} + + {Math.round(weatherData.temp)}° + {weatherData.condition} + {:else} + + No weather data + {/if} +
+ {/if} +
diff --git a/src/lib/components/widget/LinkGroupWidget.svelte b/src/lib/components/widget/LinkGroupWidget.svelte new file mode 100644 index 0000000..88e2013 --- /dev/null +++ b/src/lib/components/widget/LinkGroupWidget.svelte @@ -0,0 +1,68 @@ + + +
+ + {#if isCollapsible} + + {:else} +
+ + Links +
+ {/if} + + + {#if !collapsed} +
+ {#if links.length === 0} +

No links configured

+ {:else} +
+ {#each links as link (link.url + link.label)} + + {#if link.icon} + {link.icon} + {:else} + + {/if} + {link.label} + + {/each} +
+ {/if} +
+ {/if} +
diff --git a/src/lib/components/widget/MarkdownWidget.svelte b/src/lib/components/widget/MarkdownWidget.svelte new file mode 100644 index 0000000..3fe3e93 --- /dev/null +++ b/src/lib/components/widget/MarkdownWidget.svelte @@ -0,0 +1,101 @@ + + +
+ +
+ +
+ + {#if editMode} + +
+
+ +
+
+
+ + {@html renderedHtml} +
+
+
+ {:else} + +
+
+ + {@html renderedHtml} +
+
+ {/if} +
diff --git a/src/lib/components/widget/MetricWidget.svelte b/src/lib/components/widget/MetricWidget.svelte new file mode 100644 index 0000000..51b0f8e --- /dev/null +++ b/src/lib/components/widget/MetricWidget.svelte @@ -0,0 +1,108 @@ + + +
+ {#if loading} +
+
+
+
+ {:else if error} + Failed to load metric + {:else if currentValue !== null} + +
+ {#if trend === 'up'} + + {:else if trend === 'down'} + + {:else} + + {/if} +
+ + +
+ + {formatNumber(currentValue)} + + {#if config.unit} + {config.unit} + {/if} +
+ + +

{config.label}

+ {:else} + No data + {/if} +
diff --git a/src/lib/components/widget/RssFeedWidget.svelte b/src/lib/components/widget/RssFeedWidget.svelte new file mode 100644 index 0000000..4d90d81 --- /dev/null +++ b/src/lib/components/widget/RssFeedWidget.svelte @@ -0,0 +1,148 @@ + + +
+
+ + RSS Feed +
+ + {#if loading} +
+ {#each [1, 2, 3, 4] as _n (_n)} +
+
+
+
+ {/each} +
+ {:else if error} +
+ Failed to load feed +
+ {:else if items.length === 0} +
+ No feed items +
+ {:else} +
+ {#each items as item, i (item.link + i)} +
+
+ + + + +
+ + {#if showSummary && expandedIndex === i && item.summary} +
+

+ {item.summary} +

+
+ {/if} +
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/widget/SystemStatsWidget.svelte b/src/lib/components/widget/SystemStatsWidget.svelte new file mode 100644 index 0000000..84b4980 --- /dev/null +++ b/src/lib/components/widget/SystemStatsWidget.svelte @@ -0,0 +1,142 @@ + + +
+ System Stats + + {#if loading} +
+ {#each [1, 2, 3] as _n (_n)} +
+
+
+
+ {/each} +
+ {:else if error} +
+ Failed to load stats +
+ {:else if metrics.length === 0} +
+ No metrics available +
+ {:else} +
+ {#each metrics as m (m.metric)} + {@const pct = Math.min(100, Math.max(0, m.value))} + {@const dashOffset = CIRCUMFERENCE - (pct / 100) * CIRCUMFERENCE} +
+
+ + + + + + + +
+ + {Math.round(pct)}{m.unit} + +
+
+ {m.metric} +
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/widget/WidgetCreationForm.svelte b/src/lib/components/widget/WidgetCreationForm.svelte index 79cca90..09cf55d 100644 --- a/src/lib/components/widget/WidgetCreationForm.svelte +++ b/src/lib/components/widget/WidgetCreationForm.svelte @@ -35,12 +35,69 @@ let statusLabel = $state(''); let statusAppIds = $state([]); + // Clock/Weather fields + let clockTimezone = $state(''); + let clockStyle = $state<'digital' | 'analog' | '24h'>('digital'); + let clockShowWeather = $state(false); + let clockLatitude = $state(''); + let clockLongitude = $state(''); + + // System Stats fields + let sysStatsSourceUrl = $state(''); + let sysStatsSourceType = $state<'glances' | 'prometheus' | 'custom'>('custom'); + let sysStatsMetrics = $state(['cpu', 'ram', 'disk']); + let sysStatsRefreshInterval = $state(30); + + // RSS fields + let rssFeedUrl = $state(''); + let rssMaxItems = $state(10); + let rssShowSummary = $state(true); + + // Calendar fields + let calendarUrls = $state>([ + { url: '', color: '#6366f1', label: '' } + ]); + let calendarDaysAhead = $state(7); + + // Markdown fields + let markdownContent = $state(''); + + // Metric fields + let metricLabel = $state(''); + let metricSource = $state<'static' | 'json' | 'prometheus'>('static'); + let metricValue = $state(''); + let metricUrl = $state(''); + let metricJsonPath = $state(''); + let metricQuery = $state(''); + let metricUnit = $state(''); + let metricRefreshInterval = $state(60); + + // Link Group fields + let linkGroupLinks = $state>([ + { label: '', url: '', icon: '' } + ]); + let linkGroupCollapsible = $state(false); + + // Camera fields + let cameraStreamUrl = $state(''); + let cameraType = $state<'image' | 'mjpeg' | 'hls'>('image'); + let cameraRefreshInterval = $state(10); + let cameraAspectRatio = $state('16/9'); + const widgetTypeItems: IconGridItem[] = [ { value: 'app', icon: '🖥️', label: 'App' }, { value: 'bookmark', icon: '🔖', label: 'Bookmark' }, { value: 'note', icon: '📝', label: 'Note' }, { value: 'embed', icon: '🧩', label: 'Embed' }, - { value: 'status', icon: '📊', label: 'Status' } + { value: 'status', icon: '📊', label: 'Status' }, + { value: 'clock', icon: '🕐', label: 'Clock' }, + { value: 'system_stats', icon: '💻', label: 'System' }, + { value: 'rss', icon: '📡', label: 'RSS' }, + { value: 'calendar', icon: '📅', label: 'Calendar' }, + { value: 'markdown', icon: '📄', label: 'Markdown' }, + { value: 'metric', icon: '📈', label: 'Metric' }, + { value: 'link_group', icon: '🔗', label: 'Links' }, + { value: 'camera', icon: '📷', label: 'Camera' } ]; const noteFormatItems: IconGridItem[] = [ @@ -48,6 +105,18 @@ { value: 'text', icon: '📄', label: 'Plain Text' } ]; + const clockStyleItems: IconGridItem[] = [ + { value: 'digital', icon: '🔢', label: 'Digital' }, + { value: 'analog', icon: '🕐', label: 'Analog' }, + { value: '24h', icon: '⏰', label: '24h' } + ]; + + const metricSourceItems: IconGridItem[] = [ + { value: 'static', icon: '📌', label: 'Static' }, + { value: 'json', icon: '🔗', label: 'JSON' }, + { value: 'prometheus', icon: '📊', label: 'Prometheus' } + ]; + const appPickerItems: EntityPickerItem[] = $derived( apps.map((app) => ({ value: app.id, @@ -68,6 +137,35 @@ embedHeight = 300; statusLabel = ''; statusAppIds = []; + clockTimezone = ''; + clockStyle = 'digital'; + clockShowWeather = false; + clockLatitude = ''; + clockLongitude = ''; + sysStatsSourceUrl = ''; + sysStatsSourceType = 'custom'; + sysStatsMetrics = ['cpu', 'ram', 'disk']; + sysStatsRefreshInterval = 30; + rssFeedUrl = ''; + rssMaxItems = 10; + rssShowSummary = true; + calendarUrls = [{ url: '', color: '#6366f1', label: '' }]; + calendarDaysAhead = 7; + markdownContent = ''; + metricLabel = ''; + metricSource = 'static'; + metricValue = ''; + metricUrl = ''; + metricJsonPath = ''; + metricQuery = ''; + metricUnit = ''; + metricRefreshInterval = 60; + linkGroupLinks = [{ label: '', url: '', icon: '' }]; + linkGroupCollapsible = false; + cameraStreamUrl = ''; + cameraType = 'image'; + cameraRefreshInterval = 10; + cameraAspectRatio = '16/9'; } function handleSubmitWidget() { @@ -100,6 +198,73 @@ widgetData.appIds = statusAppIds; if (statusLabel) widgetData.label = statusLabel; break; + case 'clock': + if (clockTimezone) widgetData.timezone = clockTimezone; + widgetData.clockStyle = clockStyle; + widgetData.showWeather = clockShowWeather; + if (clockShowWeather && clockLatitude && clockLongitude) { + widgetData.latitude = parseFloat(clockLatitude); + widgetData.longitude = parseFloat(clockLongitude); + } + break; + case 'system_stats': + if (!sysStatsSourceUrl) return; + widgetData.sourceUrl = sysStatsSourceUrl; + widgetData.sourceType = sysStatsSourceType; + widgetData.metrics = sysStatsMetrics; + widgetData.refreshInterval = sysStatsRefreshInterval; + break; + case 'rss': + if (!rssFeedUrl) return; + widgetData.feedUrl = rssFeedUrl; + widgetData.maxItems = rssMaxItems; + widgetData.showSummary = rssShowSummary; + break; + case 'calendar': { + const validUrls = calendarUrls.filter((c) => c.url.trim() !== ''); + if (validUrls.length === 0) return; + widgetData.icalUrls = validUrls; + widgetData.daysAhead = calendarDaysAhead; + break; + } + case 'markdown': + if (!markdownContent) return; + widgetData.content = markdownContent; + break; + case 'metric': + if (!metricLabel) return; + widgetData.label = metricLabel; + widgetData.source = metricSource; + if (metricSource === 'static') widgetData.value = metricValue; + if (metricSource === 'json') { + widgetData.url = metricUrl; + widgetData.jsonPath = metricJsonPath; + } + if (metricSource === 'prometheus') { + widgetData.url = metricUrl; + widgetData.query = metricQuery; + } + if (metricUnit) widgetData.unit = metricUnit; + widgetData.refreshInterval = metricRefreshInterval; + break; + case 'link_group': { + const validLinks = linkGroupLinks.filter((l) => l.label.trim() && l.url.trim()); + if (validLinks.length === 0) return; + widgetData.links = validLinks.map((l) => ({ + label: l.label, + url: l.url, + ...(l.icon ? { icon: l.icon } : {}) + })); + widgetData.collapsible = linkGroupCollapsible; + break; + } + case 'camera': + if (!cameraStreamUrl) return; + widgetData.streamUrl = cameraStreamUrl; + widgetData.type = cameraType; + widgetData.refreshInterval = cameraRefreshInterval; + widgetData.aspectRatio = cameraAspectRatio; + break; default: return; } @@ -115,6 +280,34 @@ statusAppIds = [...statusAppIds, appId]; } } + + function toggleSysStatsMetric(metric: string) { + if (sysStatsMetrics.includes(metric)) { + sysStatsMetrics = sysStatsMetrics.filter((m) => m !== metric); + } else { + sysStatsMetrics = [...sysStatsMetrics, metric]; + } + } + + function addCalendarUrl() { + calendarUrls = [...calendarUrls, { url: '', color: '#6366f1', label: '' }]; + } + + function removeCalendarUrl(index: number) { + calendarUrls = calendarUrls.filter((_, i) => i !== index); + } + + function addLinkGroupLink() { + linkGroupLinks = [...linkGroupLinks, { label: '', url: '', icon: '' }]; + } + + function removeLinkGroupLink(index: number) { + linkGroupLinks = linkGroupLinks.filter((_, i) => i !== index); + } + + // Input CSS class for reuse + const inputClass = + 'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30';
@@ -152,7 +345,7 @@ type="url" bind:value={bookmarkUrl} placeholder="https://example.com" - class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30" + class={inputClass} required />
@@ -163,7 +356,7 @@ type="text" bind:value={bookmarkLabel} placeholder="My Bookmark" - class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30" + class={inputClass} required />
@@ -174,7 +367,7 @@ type="text" bind:value={bookmarkIcon} placeholder="e.g. an emoji or icon name" - class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30" + class={inputClass} />
@@ -184,7 +377,7 @@ type="text" bind:value={bookmarkDescription} placeholder="A short description" - class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30" + class={inputClass} />
@@ -205,7 +398,7 @@ bind:value={noteContent} rows="4" placeholder="Write your note here..." - class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30" + class={inputClass} required > @@ -219,7 +412,7 @@ type="url" bind:value={embedUrl} placeholder="https://example.com/embed" - class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30" + class={inputClass} required /> @@ -231,7 +424,7 @@ bind:value={embedHeight} min="100" max="2000" - class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30" + class={inputClass} /> @@ -244,7 +437,7 @@ type="text" bind:value={statusLabel} placeholder="e.g. Production Services" - class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30" + class={inputClass} />
@@ -267,6 +460,463 @@ {/if}
+ + + + {:else if selectedWidgetType === 'clock'} +
+
+ + +
+
+ + +

Leave empty for local time

+
+
+ +
+ {#if clockShowWeather} +
+
+ + +
+
+ + +
+
+ {/if} +
+ + {:else if selectedWidgetType === 'system_stats'} +
+
+ + +
+
+ + +
+
+ Metrics +
+ {#each ['cpu', 'ram', 'disk', 'swap', 'network'] as metric (metric)} + + {/each} +
+
+
+ + +
+
+ + {:else if selectedWidgetType === 'rss'} +
+
+ + +
+
+ + +
+
+ +
+
+ + {:else if selectedWidgetType === 'calendar'} +
+
+ iCal URLs +
+ {#each calendarUrls as _cal, i (i)} +
+
+ +
+ + +
+
+ {#if calendarUrls.length > 1} + + {/if} +
+ {/each} +
+ +
+
+ + +
+
+ + {:else if selectedWidgetType === 'markdown'} +
+
+ + +
+
+ + {:else if selectedWidgetType === 'metric'} +
+
+ + +
+
+ + +
+ {#if metricSource === 'static'} +
+ + +
+ {:else if metricSource === 'json'} +
+
+ + +
+
+ + +
+
+ {:else if metricSource === 'prometheus'} +
+
+ + +
+
+ + +
+
+ {/if} +
+
+ + +
+
+ + +
+
+
+ + {:else if selectedWidgetType === 'link_group'} +
+
+ Links +
+ {#each linkGroupLinks as _link, i (i)} +
+
+ + + +
+ {#if linkGroupLinks.length > 1} + + {/if} +
+ {/each} +
+ +
+
+ +
+
+ + {:else if selectedWidgetType === 'camera'} +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
{/if}
diff --git a/src/lib/components/widget/WidgetGrid.svelte b/src/lib/components/widget/WidgetGrid.svelte index 712c0d7..5f758ae 100644 --- a/src/lib/components/widget/WidgetGrid.svelte +++ b/src/lib/components/widget/WidgetGrid.svelte @@ -2,6 +2,7 @@ import { t } from 'svelte-i18n'; import WidgetRenderer from './WidgetRenderer.svelte'; import WidgetContainer from './WidgetContainer.svelte'; + import type { CardSize } from '$lib/utils/constants.js'; interface AppData { id: string; @@ -25,23 +26,47 @@ interface Props { widgets: WidgetData[]; allApps?: AppData[]; + cardSize?: CardSize; } - let { widgets, allApps = [] }: Props = $props(); + let { widgets, allApps = [], cardSize = 'medium' }: Props = $props(); // Widgets that should span full width - const fullWidthTypes = new Set(['note', 'embed', 'status']); + const fullWidthTypes = new Set(['note', 'embed', 'status', 'system_stats', 'rss', 'calendar', 'markdown', 'camera']); + + // Grid column classes based on card size + const gridClass = $derived.by(() => { + switch (cardSize) { + case 'compact': + return 'grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6'; + case 'large': + return 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3'; + default: + return 'grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4'; + } + }); + + const fullWidthClass = $derived.by(() => { + switch (cardSize) { + case 'compact': + return 'col-span-2 sm:col-span-3 md:col-span-4 lg:col-span-6'; + case 'large': + return 'col-span-1 sm:col-span-2 lg:col-span-3'; + default: + return 'col-span-2 sm:col-span-3 lg:col-span-4'; + } + }); {#if widgets.length === 0}

{$t('widget.no_widgets')}

{:else} -
+
{#each widgets as widget (widget.id)} {@const isFullWidth = fullWidthTypes.has(widget.type)} -
+
- +
{/each} diff --git a/src/lib/components/widget/WidgetRenderer.svelte b/src/lib/components/widget/WidgetRenderer.svelte index cba4cae..9f8446d 100644 --- a/src/lib/components/widget/WidgetRenderer.svelte +++ b/src/lib/components/widget/WidgetRenderer.svelte @@ -5,6 +5,14 @@ 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'; interface AppData { id: string; @@ -25,12 +33,15 @@ app: AppData | null; } + import type { CardSize } from '$lib/utils/constants.js'; + interface Props { widget: WidgetData; allApps?: AppData[]; + cardSize?: CardSize; } - let { widget, allApps = [] }: Props = $props(); + let { widget, allApps = [], cardSize = 'medium' }: Props = $props(); const parsedConfig = $derived.by(() => { try { @@ -42,7 +53,7 @@ {#if widget.type === 'app' && widget.app} - + {:else if widget.type === 'bookmark'} {:else if widget.type === 'note'} @@ -51,6 +62,60 @@ {:else if widget.type === 'status'} +{:else if widget.type === 'clock'} + +{:else if widget.type === 'system_stats'} + +{:else if widget.type === 'rss'} + +{:else if widget.type === 'calendar'} + +{:else if widget.type === 'markdown'} + +{:else if widget.type === 'metric'} + +{:else if widget.type === 'link_group'} + +{:else if widget.type === 'camera'} + {:else}
{$t('widget.type', { values: { type: widget.type } })} diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index fdfb469..2378a29 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -1,341 +1,341 @@ { - "app_name": "App Launcher", - "app_title": "Web App Launcher", + "app_name": "App Launcher", + "app_title": "Web App Launcher", - "nav.navigation": "Navigation", - "nav.boards": "Boards", - "nav.apps": "Apps", - "nav.admin": "Admin", - "nav.admin_panel": "Admin Panel", + "nav.navigation": "Navigation", + "nav.boards": "Boards", + "nav.apps": "Apps", + "nav.admin": "Admin", + "nav.admin_panel": "Admin Panel", - "auth.login": "Sign In", - "auth.login_title": "Welcome back", - "auth.login_subtitle": "Sign in to your account", - "auth.login_submit": "Sign In", - "auth.login_submitting": "Signing in...", - "auth.register": "Register", - "auth.register_title": "Create Account", - "auth.register_subtitle": "Get started with App Launcher", - "auth.register_submit": "Create Account", - "auth.register_submitting": "Creating account...", - "auth.email": "Email", - "auth.email_placeholder": "you@example.com", - "auth.password": "Password", - "auth.password_placeholder": "Enter your password", - "auth.password_placeholder_register": "At least 6 characters", - "auth.display_name": "Display Name", - "auth.display_name_placeholder": "Your name", - "auth.logout": "Sign Out", - "auth.oauth_signin": "Sign in with OAuth", - "auth.or": "or", - "auth.no_account": "Don't have an account?", - "auth.have_account": "Already have an account?", - "auth.sign_in_link": "Sign in", + "auth.login": "Sign In", + "auth.login_title": "Welcome back", + "auth.login_subtitle": "Sign in to your account", + "auth.login_submit": "Sign In", + "auth.login_submitting": "Signing in...", + "auth.register": "Register", + "auth.register_title": "Create Account", + "auth.register_subtitle": "Get started with App Launcher", + "auth.register_submit": "Create Account", + "auth.register_submitting": "Creating account...", + "auth.email": "Email", + "auth.email_placeholder": "you@example.com", + "auth.password": "Password", + "auth.password_placeholder": "Enter your password", + "auth.password_placeholder_register": "At least 6 characters", + "auth.display_name": "Display Name", + "auth.display_name_placeholder": "Your name", + "auth.logout": "Sign Out", + "auth.oauth_signin": "Sign in with OAuth", + "auth.or": "or", + "auth.no_account": "Don't have an account?", + "auth.have_account": "Already have an account?", + "auth.sign_in_link": "Sign in", - "board.title": "Boards", - "board.boards_available": "{count} board(s) available", - "board.new": "New Board", - "board.edit": "Edit", - "board.edit_board": "Edit Board", - "board.all_boards": "All Boards", - "board.back_to_boards": "Back to Boards", - "board.back_to_board": "Back to Board", - "board.no_boards": "No boards available.", - "board.sign_in_more": "Sign in to see more boards.", - "board.no_sections": "This board has no sections yet.", - "board.default": "Default", - "board.guest": "Guest", - "board.sections_count": "{count} section(s)", - "board.properties": "Board Properties", - "board.save": "Save Board", - "board.create": "Create Board", - "board.creating": "Creating...", - "board.default_board": "Default board", - "board.guest_accessible": "Guest accessible", - "board.guest_access_title": "Guest Access", - "board.guest_access_description": "When enabled, this board is visible to unauthenticated visitors without requiring sign-in.", - "board.guest_access_enabled": "This board is publicly accessible", - "board.guest_access_disabled": "This board is private", - "board.permissions_title": "Permissions", - "board.permissions_description": "Manage who can view, edit, or administer this board.", - "board.access_grant": "Grant Access", - "board.access_search_placeholder": "Search...", - "board.access_loading": "Loading permissions...", - "board.access_none": "No permissions configured for this board.", - "board.access_private": "Private", - "board.access_shared": "Shared", - "board.share": "Share", - "board.share_title": "Share \"{name}\"", - "board.share_copy_link": "Copy Link", - "board.share_copied": "Copied!", - "board.share_guest_description": "Anyone with the link can view this board without signing in.", - "board.share_add_access": "Add People or Groups", - "board.share_current_access": "Current Access", + "board.title": "Boards", + "board.boards_available": "{count} board(s) available", + "board.new": "New Board", + "board.edit": "Edit", + "board.edit_board": "Edit Board", + "board.all_boards": "All Boards", + "board.back_to_boards": "Back to Boards", + "board.back_to_board": "Back to Board", + "board.no_boards": "No boards available.", + "board.sign_in_more": "Sign in to see more boards.", + "board.no_sections": "This board has no sections yet.", + "board.default": "Default", + "board.guest": "Guest", + "board.sections_count": "{count} section(s)", + "board.properties": "Board Properties", + "board.save": "Save Board", + "board.create": "Create Board", + "board.creating": "Creating...", + "board.default_board": "Default board", + "board.guest_accessible": "Guest accessible", + "board.guest_access_title": "Guest Access", + "board.guest_access_description": "When enabled, this board is visible to unauthenticated visitors without requiring sign-in.", + "board.guest_access_enabled": "This board is publicly accessible", + "board.guest_access_disabled": "This board is private", + "board.permissions_title": "Permissions", + "board.permissions_description": "Manage who can view, edit, or administer this board.", + "board.access_grant": "Grant Access", + "board.access_search_placeholder": "Search...", + "board.access_loading": "Loading permissions...", + "board.access_none": "No permissions configured for this board.", + "board.access_private": "Private", + "board.access_shared": "Shared", + "board.share": "Share", + "board.share_title": "Share \"{name}\"", + "board.share_copy_link": "Copy Link", + "board.share_copied": "Copied!", + "board.share_guest_description": "Anyone with the link can view this board without signing in.", + "board.share_add_access": "Add People or Groups", + "board.share_current_access": "Current Access", - "section.title_label": "Title", - "section.icon_label": "Icon", - "section.icon_placeholder": "Optional", - "section.sections": "Sections", - "section.add": "Add Section", - "section.create": "Create Section", - "section.order": "Order: {order}", + "section.title_label": "Title", + "section.icon_label": "Icon", + "section.icon_placeholder": "Optional", + "section.sections": "Sections", + "section.add": "Add Section", + "section.create": "Create Section", + "section.order": "Order: {order}", - "widget.add": "Add Widget", - "widget.select_app": "Select App", - "widget.choose_app": "Choose an app...", - "widget.no_widgets": "No widgets in this section.", - "widget.no_widgets_dnd": "No widgets. Drag widgets here or add one above.", - "widget.type": "{type} widget", - "widget.number": "Widget #{order}", - "widget.remove": "Remove", + "widget.add": "Add Widget", + "widget.select_app": "Select App", + "widget.choose_app": "Choose an app...", + "widget.no_widgets": "No widgets in this section.", + "widget.no_widgets_dnd": "No widgets. Drag widgets here or add one above.", + "widget.type": "{type} widget", + "widget.number": "Widget #{order}", + "widget.remove": "Remove", - "app.title": "App Registry", - "app.apps_registered": "{count} app(s) registered", - "app.add": "Add App", - "app.new": "New App", - "app.no_apps": "No apps registered yet.", - "app.no_apps_hint": "Click \"Add App\" to register your first application.", - "app.all_categories": "All", - "app.name": "Name", - "app.name_placeholder": "My Application", - "app.url": "URL", - "app.url_placeholder": "https://my-app.local:8080", - "app.description": "Description", - "app.description_placeholder": "Brief description of this app", - "app.category": "Category", - "app.category_placeholder": "e.g. Media, Monitoring, Storage", - "app.tags": "Tags", - "app.tags_placeholder": "Comma-separated tags", - "app.icon": "Icon", - "app.icon_lucide": "Lucide Icon", - "app.icon_simple": "Simple Icons", - "app.icon_url": "Image URL", - "app.icon_emoji": "Emoji", - "app.icon_lucide_placeholder": "e.g. globe, server, home", - "app.icon_simple_placeholder": "e.g. github, docker", - "app.icon_url_placeholder": "https://example.com/icon.png", - "app.icon_emoji_placeholder": "e.g. \ud83c\udf10", - "app.icon_preview": "Icon preview", - "app.save": "Save App", - "app.saving": "Saving...", - "app.healthcheck_toggle": "Healthcheck Settings", - "app.healthcheck_show": "Show", - "app.healthcheck_hide": "Hide", - "app.healthcheck_enabled": "Enable Healthcheck", - "app.healthcheck_method": "Method", - "app.healthcheck_expected_status": "Expected Status", - "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...", + "app.title": "App Registry", + "app.apps_registered": "{count} app(s) registered", + "app.add": "Add App", + "app.new": "New App", + "app.no_apps": "No apps registered yet.", + "app.no_apps_hint": "Click \"Add App\" to register your first application.", + "app.all_categories": "All", + "app.name": "Name", + "app.name_placeholder": "My Application", + "app.url": "URL", + "app.url_placeholder": "https://my-app.local:8080", + "app.description": "Description", + "app.description_placeholder": "Brief description of this app", + "app.category": "Category", + "app.category_placeholder": "e.g. Media, Monitoring, Storage", + "app.tags": "Tags", + "app.tags_placeholder": "Comma-separated tags", + "app.icon": "Icon", + "app.icon_lucide": "Lucide Icon", + "app.icon_simple": "Simple Icons", + "app.icon_url": "Image URL", + "app.icon_emoji": "Emoji", + "app.icon_lucide_placeholder": "e.g. globe, server, home", + "app.icon_simple_placeholder": "e.g. github, docker", + "app.icon_url_placeholder": "https://example.com/icon.png", + "app.icon_emoji_placeholder": "e.g. \ud83c\udf10", + "app.icon_preview": "Icon preview", + "app.save": "Save App", + "app.saving": "Saving...", + "app.healthcheck_toggle": "Healthcheck Settings", + "app.healthcheck_show": "Show", + "app.healthcheck_hide": "Hide", + "app.healthcheck_enabled": "Enable Healthcheck", + "app.healthcheck_method": "Method", + "app.healthcheck_expected_status": "Expected Status", + "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", - "admin.groups": "Groups", - "admin.settings": "Settings", + "admin.panel": "Admin Panel", + "admin.users": "Users", + "admin.groups": "Groups", + "admin.settings": "Settings", - "admin.user_management": "User Management", - "admin.create_user": "Create User", - "admin.new_user": "New User", - "admin.user_column": "User", - "admin.email_column": "Email", - "admin.role_column": "Role", - "admin.provider_column": "Provider", - "admin.groups_column": "Groups", - "admin.actions_column": "Actions", - "admin.role_user": "User", - "admin.role_admin": "Admin", - "admin.select_group": "Select group", - "admin.add_to_group": "+ Add", - "admin.remove_from_group": "Remove from group", - "admin.no_users": "No users found.", + "admin.user_management": "User Management", + "admin.create_user": "Create User", + "admin.new_user": "New User", + "admin.user_column": "User", + "admin.email_column": "Email", + "admin.role_column": "Role", + "admin.provider_column": "Provider", + "admin.groups_column": "Groups", + "admin.actions_column": "Actions", + "admin.role_user": "User", + "admin.role_admin": "Admin", + "admin.select_group": "Select group", + "admin.add_to_group": "+ Add", + "admin.remove_from_group": "Remove from group", + "admin.no_users": "No users found.", - "admin.group_management": "Group Management", - "admin.create_group": "Create Group", - "admin.new_group": "New Group", - "admin.name_column": "Name", - "admin.description_column": "Description", - "admin.members_column": "Members", - "admin.default_column": "Default", - "admin.default_group_hint": "Default group (auto-assign new users)", - "admin.no_groups": "No groups found.", - "admin.yes": "Yes", - "admin.no": "No", + "admin.group_management": "Group Management", + "admin.create_group": "Create Group", + "admin.new_group": "New Group", + "admin.name_column": "Name", + "admin.description_column": "Description", + "admin.members_column": "Members", + "admin.default_column": "Default", + "admin.default_group_hint": "Default group (auto-assign new users)", + "admin.no_groups": "No groups found.", + "admin.yes": "Yes", + "admin.no": "No", - "admin.system_settings": "System Settings", - "admin.settings_description": "Configure global application settings.", - "admin.authentication": "Authentication", - "admin.auth_mode": "Auth Mode", - "admin.auth_local": "Local", - "admin.auth_oauth": "OAuth", - "admin.auth_both": "Both", - "admin.registration_enabled": "Allow user registration", - "admin.oauth_config": "OAuth Configuration", - "admin.oauth_description": "Configure your OIDC provider (e.g. Authentik, Keycloak). Set Auth Mode to \"OAuth\" or \"Both\" above to enable OAuth login.", - "admin.oauth_client_id": "Client ID", - "admin.oauth_client_id_placeholder": "OAuth client ID", - "admin.oauth_client_secret": "Client Secret", - "admin.oauth_client_secret_placeholder": "OAuth client secret", - "admin.oauth_discovery_url": "Discovery URL", - "admin.oauth_discovery_url_placeholder": "https://example.com/.well-known/openid-configuration", - "admin.oauth_test": "Test Connection", - "admin.oauth_testing": "Testing...", - "admin.oauth_connected": "Connected to issuer: {issuer}", - "admin.oauth_network_error": "Network error \u2014 could not reach the server", - "admin.theme_defaults": "Theme Defaults", - "admin.default_theme": "Default Theme", - "admin.default_primary_color": "Default Primary Color", - "admin.healthcheck_defaults": "Healthcheck Defaults", - "admin.healthcheck_defaults_description": "JSON configuration for default healthcheck behavior (interval, timeout, method).", - "admin.healthcheck_defaults_label": "Defaults (JSON)", - "admin.save_settings": "Save Settings", - "admin.saving_settings": "Saving...", + "admin.system_settings": "System Settings", + "admin.settings_description": "Configure global application settings.", + "admin.authentication": "Authentication", + "admin.auth_mode": "Auth Mode", + "admin.auth_local": "Local", + "admin.auth_oauth": "OAuth", + "admin.auth_both": "Both", + "admin.registration_enabled": "Allow user registration", + "admin.oauth_config": "OAuth Configuration", + "admin.oauth_description": "Configure your OIDC provider (e.g. Authentik, Keycloak). Set Auth Mode to \"OAuth\" or \"Both\" above to enable OAuth login.", + "admin.oauth_client_id": "Client ID", + "admin.oauth_client_id_placeholder": "OAuth client ID", + "admin.oauth_client_secret": "Client Secret", + "admin.oauth_client_secret_placeholder": "OAuth client secret", + "admin.oauth_discovery_url": "Discovery URL", + "admin.oauth_discovery_url_placeholder": "https://example.com/.well-known/openid-configuration", + "admin.oauth_test": "Test Connection", + "admin.oauth_testing": "Testing...", + "admin.oauth_connected": "Connected to issuer: {issuer}", + "admin.oauth_network_error": "Network error \u2014 could not reach the server", + "admin.theme_defaults": "Theme Defaults", + "admin.default_theme": "Default Theme", + "admin.default_primary_color": "Default Primary Color", + "admin.healthcheck_defaults": "Healthcheck Defaults", + "admin.healthcheck_defaults_description": "JSON configuration for default healthcheck behavior (interval, timeout, method).", + "admin.healthcheck_defaults_label": "Defaults (JSON)", + "admin.save_settings": "Save Settings", + "admin.saving_settings": "Saving...", - "admin.perm_title": "Grant Permission", - "admin.perm_entity_type": "Entity Type", - "admin.perm_entity": "Entity", - "admin.perm_target_type": "Target Type", - "admin.perm_target": "Target", - "admin.perm_level": "Level", - "admin.perm_board": "Board", - "admin.perm_app": "App", - "admin.perm_user": "User", - "admin.perm_group": "Group", - "admin.perm_view": "View", - "admin.perm_edit": "Edit", - "admin.perm_admin": "Admin", - "admin.perm_grant": "Grant", - "admin.perm_revoke": "Revoke", - "admin.perm_select": "Select...", - "admin.perm_entity_column": "Entity", - "admin.perm_target_column": "Target", - "admin.perm_level_column": "Level", - "admin.perm_action_column": "Action", - "admin.perm_none": "No permissions configured.", - "admin.perm_search_placeholder": "Type to search...", + "admin.perm_title": "Grant Permission", + "admin.perm_entity_type": "Entity Type", + "admin.perm_entity": "Entity", + "admin.perm_target_type": "Target Type", + "admin.perm_target": "Target", + "admin.perm_level": "Level", + "admin.perm_board": "Board", + "admin.perm_app": "App", + "admin.perm_user": "User", + "admin.perm_group": "Group", + "admin.perm_view": "View", + "admin.perm_edit": "Edit", + "admin.perm_admin": "Admin", + "admin.perm_grant": "Grant", + "admin.perm_revoke": "Revoke", + "admin.perm_select": "Select...", + "admin.perm_entity_column": "Entity", + "admin.perm_target_column": "Target", + "admin.perm_level_column": "Level", + "admin.perm_action_column": "Action", + "admin.perm_none": "No permissions configured.", + "admin.perm_search_placeholder": "Type to search...", - "admin.discovery_title": "Service Discovery", - "admin.discovery_description": "Scan Docker containers and Traefik routers to automatically discover running services and register them as apps.", - "admin.discovery_scan": "Scan for Services", - "admin.discovery_scanning": "Scanning...", - "admin.discovery_approve": "Approve Selected", - "admin.discovery_approving": "Approving...", - "admin.discovery_source": "Source", - "admin.discovery_status": "Status", - "admin.discovery_source_docker": "Docker", - "admin.discovery_source_traefik": "Traefik", - "admin.discovery_already_registered": "Already registered", - "admin.discovery_new": "New", - "admin.discovery_no_results": "No services discovered. Check your Docker socket path or Traefik API URL.", - "admin.discovery_config": "Service Discovery Configuration", - "admin.discovery_config_description": "Configure Docker and Traefik endpoints for automatic service discovery. These settings are used by the Discovery panel below.", - "admin.discovery_docker_socket": "Docker Socket Path", - "admin.discovery_docker_socket_hint": "Path to Docker socket (e.g. /var/run/docker.sock). Set via DOCKER_SOCKET_PATH env var.", - "admin.discovery_traefik_url": "Traefik API URL", - "admin.discovery_traefik_url_hint": "Traefik dashboard API base URL (e.g. http://traefik:8080). Set via TRAEFIK_API_URL env var.", + "admin.discovery_title": "Service Discovery", + "admin.discovery_description": "Scan Docker containers and Traefik routers to automatically discover running services and register them as apps.", + "admin.discovery_scan": "Scan for Services", + "admin.discovery_scanning": "Scanning...", + "admin.discovery_approve": "Approve Selected", + "admin.discovery_approving": "Approving...", + "admin.discovery_source": "Source", + "admin.discovery_status": "Status", + "admin.discovery_source_docker": "Docker", + "admin.discovery_source_traefik": "Traefik", + "admin.discovery_already_registered": "Already registered", + "admin.discovery_new": "New", + "admin.discovery_no_results": "No services discovered. Check your Docker socket path or Traefik API URL.", + "admin.discovery_config": "Service Discovery Configuration", + "admin.discovery_config_description": "Configure Docker and Traefik endpoints for automatic service discovery. These settings are used by the Discovery panel below.", + "admin.discovery_docker_socket": "Docker Socket Path", + "admin.discovery_docker_socket_hint": "Path to Docker socket (e.g. /var/run/docker.sock). Set via DOCKER_SOCKET_PATH env var.", + "admin.discovery_traefik_url": "Traefik API URL", + "admin.discovery_traefik_url_hint": "Traefik dashboard API base URL (e.g. http://traefik:8080). Set via TRAEFIK_API_URL env var.", - "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.", + "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", - "search.no_results": "No results for \"{query}\"", - "search.apps": "Apps", - "search.boards": "Boards", - "search.nav_hint": "navigate", - "search.select_hint": "select", - "search.close_hint": "close", + "search.placeholder": "Search apps and boards...", + "search.trigger": "Search...", + "search.min_chars": "Type at least 2 characters to search", + "search.no_results": "No results for \"{query}\"", + "search.apps": "Apps", + "search.boards": "Boards", + "search.nav_hint": "navigate", + "search.select_hint": "select", + "search.close_hint": "close", - "common.search_filter": "Filter...", + "common.search_filter": "Filter...", - "common.save": "Save", - "common.cancel": "Cancel", - "common.delete": "Delete", - "common.create": "Create", - "common.back": "Back", - "common.edit": "Edit", - "common.add": "Add", - "common.confirm": "Confirm?", - "common.yes": "Yes", - "common.no": "No", - "common.name": "Name", - "common.description": "Description", - "common.required": "*", + "common.save": "Save", + "common.cancel": "Cancel", + "common.delete": "Delete", + "common.create": "Create", + "common.back": "Back", + "common.edit": "Edit", + "common.add": "Add", + "common.confirm": "Confirm?", + "common.yes": "Yes", + "common.no": "No", + "common.name": "Name", + "common.description": "Description", + "common.required": "*", - "status.online": "Online", - "status.offline": "Offline", - "status.degraded": "Degraded", - "status.unknown": "Unknown", + "status.online": "Online", + "status.offline": "Offline", + "status.degraded": "Degraded", + "status.unknown": "Unknown", - "theme.dark": "Dark", - "theme.light": "Light", - "theme.system": "System", - "theme.toggle": "Toggle theme (current: {mode})", - "theme.title": "Theme: {mode}", + "theme.dark": "Dark", + "theme.light": "Light", + "theme.system": "System", + "theme.toggle": "Toggle theme (current: {mode})", + "theme.title": "Theme: {mode}", - "bg.mesh": "Mesh Gradient", - "bg.particles": "Particles", - "bg.aurora": "Aurora", - "bg.none": "None", - "bg.title": "Background effect", - "bg.aria_label": "Change background effect", + "bg.mesh": "Mesh Gradient", + "bg.particles": "Particles", + "bg.aurora": "Aurora", + "bg.none": "None", + "bg.title": "Background effect", + "bg.aria_label": "Change background effect", - "sidebar.expand": "Expand sidebar", - "sidebar.collapse": "Collapse sidebar", - "sidebar.toggle": "Toggle sidebar", - "sidebar.close": "Close sidebar", + "sidebar.expand": "Expand sidebar", + "sidebar.collapse": "Collapse sidebar", + "sidebar.toggle": "Toggle sidebar", + "sidebar.close": "Close sidebar", - "home.welcome": "Welcome, {name}. No default board is configured yet.", - "home.view_boards": "View Boards", - "home.browse_apps": "Browse Apps", + "home.welcome": "Welcome, {name}. No default board is configured yet.", + "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!", + "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!", - "offline.title": "You're Offline", - "offline.description": "It looks like you've lost your internet connection. Check your network and try again.", - "offline.retry": "Retry", + "offline.title": "You're Offline", + "offline.description": "It looks like you've lost your internet connection. Check your network and try again.", + "offline.retry": "Retry", - "install.title": "Install App", - "install.description": "Add Web App Launcher to your home screen for quick access.", - "install.button": "Install", - "install.dismiss": "Dismiss install prompt", + "install.title": "Install App", + "install.description": "Add Web App Launcher to your home screen for quick access.", + "install.button": "Install", + "install.dismiss": "Dismiss install prompt", - "settings.bookmarklet_title": "Quick-Add Bookmarklet", - "settings.bookmarklet_instructions": "Drag the button below to your browser's bookmarks bar. When visiting any web page, click it to quickly add that site to your App Launcher.", - "settings.bookmarklet_drag": "Add to Launcher", - "settings.bookmarklet_drag_hint": "Drag this to your bookmarks bar", - "settings.bookmarklet_show_code": "Show bookmarklet code", + "settings.bookmarklet_title": "Quick-Add Bookmarklet", + "settings.bookmarklet_instructions": "Drag the button below to your browser's bookmarks bar. When visiting any web page, click it to quickly add that site to your App Launcher.", + "settings.bookmarklet_drag": "Add to Launcher", + "settings.bookmarklet_drag_hint": "Drag this to your bookmarks bar", + "settings.bookmarklet_show_code": "Show bookmarklet code", - "app.quick_add_title": "Quick Add App", - "app.quick_add_description": "Review the details below and save to add this app to your launcher.", - "app.quick_add_success": "App added successfully!", - "app.quick_add_view_apps": "View Apps", - "app.quick_add_close": "Close Window" + "app.quick_add_title": "Quick Add App", + "app.quick_add_description": "Review the details below and save to add this app to your launcher.", + "app.quick_add_success": "App added successfully!", + "app.quick_add_view_apps": "View Apps", + "app.quick_add_close": "Close Window" } diff --git a/src/lib/i18n/ru.json b/src/lib/i18n/ru.json index ecc0e0d..a290776 100644 --- a/src/lib/i18n/ru.json +++ b/src/lib/i18n/ru.json @@ -1,317 +1,317 @@ { - "app_name": "App Launcher", - "app_title": "Web App Launcher", - "nav.navigation": "Навигация", - "nav.boards": "Доски", - "nav.apps": "Приложения", - "nav.admin": "Админ", - "nav.admin_panel": "Панель администратора", - "auth.login": "Войти", - "auth.login_title": "Добро пожаловать", - "auth.login_subtitle": "Войдите в свой аккаунт", - "auth.login_submit": "Войти", - "auth.login_submitting": "Вход...", - "auth.register": "Регистрация", - "auth.register_title": "Создать аккаунт", - "auth.register_subtitle": "Начните работу с App Launcher", - "auth.register_submit": "Создать аккаунт", - "auth.register_submitting": "Создание аккаунта...", - "auth.email": "Электронная почта", - "auth.email_placeholder": "you@example.com", - "auth.password": "Пароль", - "auth.password_placeholder": "Введите пароль", - "auth.password_placeholder_register": "Не менее 6 символов", - "auth.display_name": "Имя", - "auth.display_name_placeholder": "Ваше имя", - "auth.logout": "Выход", - "auth.oauth_signin": "Войти через OAuth", - "auth.or": "или", - "auth.no_account": "Нет аккаунта?", - "auth.have_account": "Уже есть аккаунт?", - "auth.sign_in_link": "Войти", - "board.title": "Доски", - "board.boards_available": "Доступно досок: {count}", - "board.new": "Новая доска", - "board.edit": "Редактировать", - "board.edit_board": "Редактирование доски", - "board.all_boards": "Все доски", - "board.back_to_boards": "Назад к доскам", - "board.back_to_board": "Назад к доске", - "board.no_boards": "Доски не найдены.", - "board.sign_in_more": "Войдите, чтобы увидеть больше досок.", - "board.no_sections": "На этой доске пока нет разделов.", - "board.default": "По умолчанию", - "board.guest": "Гостевая", - "board.sections_count": "Разделов: {count}", - "board.properties": "Свойства доски", - "board.save": "Сохранить доску", - "board.create": "Создать доску", - "board.creating": "Создание...", - "board.default_board": "Доска по умолчанию", - "board.guest_accessible": "Доступна гостям", - "board.guest_access_title": "Гостевой доступ", - "board.guest_access_description": "При включении эта доска видна неавторизованным посетителям без входа в систему.", - "board.guest_access_enabled": "Эта доска общедоступна", - "board.guest_access_disabled": "Эта доска приватна", - "board.permissions_title": "Права доступа", - "board.permissions_description": "Управляйте, кто может просматривать, редактировать или администрировать эту доску.", - "board.access_grant": "Назначить доступ", - "board.access_search_placeholder": "Поиск...", - "board.access_loading": "Загрузка прав...", - "board.access_none": "Права доступа для этой доски не настроены.", - "board.access_private": "Приватная", - "board.access_shared": "Общая", - "board.share": "Поделиться", - "board.share_title": "Поделиться «{name}»", - "board.share_copy_link": "Копировать ссылку", - "board.share_copied": "Скопировано!", - "board.share_guest_description": "Любой с этой ссылкой может просматривать доску без входа.", - "board.share_add_access": "Добавить людей или группы", - "board.share_current_access": "Текущий доступ", - "section.title_label": "Заголовок", - "section.icon_label": "Иконка", - "section.icon_placeholder": "Необязательно", - "section.sections": "Разделы", - "section.add": "Добавить раздел", - "section.create": "Создать раздел", - "section.order": "Порядок: {order}", - "widget.add": "Добавить виджет", - "widget.select_app": "Выберите приложение", - "widget.choose_app": "Выберите приложение...", - "widget.no_widgets": "В этом разделе нет виджетов.", - "widget.no_widgets_dnd": "Нет виджетов. Перетащите сюда или добавьте выше.", - "widget.type": "Виджет {type}", - "widget.number": "Виджет #{order}", - "widget.remove": "Удалить", - "app.title": "Реестр приложений", - "app.apps_registered": "Зарегистрировано приложений: {count}", - "app.add": "Добавить приложение", - "app.new": "Новое приложение", - "app.no_apps": "Приложения ещё не зарегистрированы.", - "app.no_apps_hint": "Нажмите «Добавить приложение», чтобы зарегистрировать первое приложение.", - "app.all_categories": "Все", - "app.name": "Название", - "app.name_placeholder": "Моё приложение", - "app.url": "URL", - "app.url_placeholder": "https://my-app.local:8080", - "app.description": "Описание", - "app.description_placeholder": "Краткое описание приложения", - "app.category": "Категория", - "app.category_placeholder": "напр. Медиа, Мониторинг, Хранилище", - "app.tags": "Теги", - "app.tags_placeholder": "Теги через запятую", - "app.icon": "Иконка", - "app.icon_lucide": "Lucide", - "app.icon_simple": "Simple Icons", - "app.icon_url": "URL изображения", - "app.icon_emoji": "Эмодзи", - "app.icon_lucide_placeholder": "напр. globe, server, home", - "app.icon_simple_placeholder": "напр. github, docker", - "app.icon_url_placeholder": "https://example.com/icon.png", - "app.icon_emoji_placeholder": "напр. 🌐", - "app.icon_preview": "Превью иконки", - "app.save": "Сохранить", - "app.saving": "Сохранение...", - "app.healthcheck_toggle": "Настройки проверки здоровья", - "app.healthcheck_show": "Показать", - "app.healthcheck_hide": "Скрыть", - "app.healthcheck_enabled": "Включить проверку здоровья", - "app.healthcheck_method": "Метод", - "app.healthcheck_expected_status": "Ожидаемый статус", - "app.healthcheck_timeout": "Таймаут (мс)", - "app.healthcheck_interval": "Интервал (секунды)", - "app.icon_board_label": "Иконка (Lucide)", - "app.uptime": "аптайм", - "app.history_loading": "Загрузка истории...", - "admin.panel": "Панель администратора", - "admin.users": "Пользователи", - "admin.groups": "Группы", - "admin.settings": "Настройки", - "admin.user_management": "Управление пользователями", - "admin.create_user": "Создать пользователя", - "admin.new_user": "Новый пользователь", - "admin.user_column": "Пользователь", - "admin.email_column": "Электронная почта", - "admin.role_column": "Роль", - "admin.provider_column": "Провайдер", - "admin.groups_column": "Группы", - "admin.actions_column": "Действия", - "admin.role_user": "Пользователь", - "admin.role_admin": "Администратор", - "admin.select_group": "Выбрать группу", - "admin.add_to_group": "+ Добавить", - "admin.remove_from_group": "Удалить из группы", - "admin.no_users": "Пользователи не найдены.", - "admin.group_management": "Управление группами", - "admin.create_group": "Создать группу", - "admin.new_group": "Новая группа", - "admin.name_column": "Название", - "admin.description_column": "Описание", - "admin.members_column": "Участники", - "admin.default_column": "По умолчанию", - "admin.default_group_hint": "Группа по умолчанию (авто-назначение новым пользователям)", - "admin.no_groups": "Группы не найдены.", - "admin.yes": "Да", - "admin.no": "Нет", - "admin.system_settings": "Системные настройки", - "admin.settings_description": "Настройка глобальных параметров приложения.", - "admin.authentication": "Аутентификация", - "admin.auth_mode": "Режим аутентификации", - "admin.auth_local": "Локальный", - "admin.auth_oauth": "OAuth", - "admin.auth_both": "Оба", - "admin.registration_enabled": "Разрешить регистрацию пользователей", - "admin.oauth_config": "Настройка OAuth", - "admin.oauth_description": "Настройте провайдер OIDC (напр. Authentik, Keycloak). Установите режим аутентификации «OAuth» или «Оба» выше, чтобы включить вход через OAuth.", - "admin.oauth_client_id": "Client ID", - "admin.oauth_client_id_placeholder": "OAuth client ID", - "admin.oauth_client_secret": "Секрет клиента", - "admin.oauth_client_secret_placeholder": "Секрет OAuth клиента", - "admin.oauth_discovery_url": "Discovery URL", - "admin.oauth_discovery_url_placeholder": "https://example.com/.well-known/openid-configuration", - "admin.oauth_test": "Тестировать подключение", - "admin.oauth_testing": "Тестирование...", - "admin.oauth_connected": "Подключено к: {issuer}", - "admin.oauth_network_error": "Ошибка сети — не удалось связаться с сервером", - "admin.theme_defaults": "Настройки темы", - "admin.default_theme": "Тема по умолчанию", - "admin.default_primary_color": "Основной цвет по умолчанию", - "admin.healthcheck_defaults": "Настройки проверки здоровья", - "admin.healthcheck_defaults_description": "JSON-конфигурация проверки здоровья по умолчанию (интервал, таймаут, метод).", - "admin.healthcheck_defaults_label": "Настройки (JSON)", - "admin.save_settings": "Сохранить настройки", - "admin.saving_settings": "Сохранение...", - "admin.perm_title": "Назначить права", - "admin.perm_entity_type": "Тип объекта", - "admin.perm_entity": "Объект", - "admin.perm_target_type": "Тип цели", - "admin.perm_target": "Цель", - "admin.perm_level": "Уровень", - "admin.perm_board": "Доска", - "admin.perm_app": "Приложение", - "admin.perm_user": "Пользователь", - "admin.perm_group": "Группа", - "admin.perm_view": "Просмотр", - "admin.perm_edit": "Редактирование", - "admin.perm_admin": "Администратор", - "admin.perm_grant": "Назначить", - "admin.perm_revoke": "Отозвать", - "admin.perm_select": "Выбрать...", - "admin.perm_entity_column": "Объект", - "admin.perm_target_column": "Цель", - "admin.perm_level_column": "Уровень", - "admin.perm_action_column": "Действие", - "admin.perm_none": "Права не настроены.", - "admin.perm_search_placeholder": "Начните вводить...", + "app_name": "App Launcher", + "app_title": "Web App Launcher", + "nav.navigation": "Навигация", + "nav.boards": "Доски", + "nav.apps": "Приложения", + "nav.admin": "Админ", + "nav.admin_panel": "Панель администратора", + "auth.login": "Войти", + "auth.login_title": "Добро пожаловать", + "auth.login_subtitle": "Войдите в свой аккаунт", + "auth.login_submit": "Войти", + "auth.login_submitting": "Вход...", + "auth.register": "Регистрация", + "auth.register_title": "Создать аккаунт", + "auth.register_subtitle": "Начните работу с App Launcher", + "auth.register_submit": "Создать аккаунт", + "auth.register_submitting": "Создание аккаунта...", + "auth.email": "Электронная почта", + "auth.email_placeholder": "you@example.com", + "auth.password": "Пароль", + "auth.password_placeholder": "Введите пароль", + "auth.password_placeholder_register": "Не менее 6 символов", + "auth.display_name": "Имя", + "auth.display_name_placeholder": "Ваше имя", + "auth.logout": "Выход", + "auth.oauth_signin": "Войти через OAuth", + "auth.or": "или", + "auth.no_account": "Нет аккаунта?", + "auth.have_account": "Уже есть аккаунт?", + "auth.sign_in_link": "Войти", + "board.title": "Доски", + "board.boards_available": "Доступно досок: {count}", + "board.new": "Новая доска", + "board.edit": "Редактировать", + "board.edit_board": "Редактирование доски", + "board.all_boards": "Все доски", + "board.back_to_boards": "Назад к доскам", + "board.back_to_board": "Назад к доске", + "board.no_boards": "Доски не найдены.", + "board.sign_in_more": "Войдите, чтобы увидеть больше досок.", + "board.no_sections": "На этой доске пока нет разделов.", + "board.default": "По умолчанию", + "board.guest": "Гостевая", + "board.sections_count": "Разделов: {count}", + "board.properties": "Свойства доски", + "board.save": "Сохранить доску", + "board.create": "Создать доску", + "board.creating": "Создание...", + "board.default_board": "Доска по умолчанию", + "board.guest_accessible": "Доступна гостям", + "board.guest_access_title": "Гостевой доступ", + "board.guest_access_description": "При включении эта доска видна неавторизованным посетителям без входа в систему.", + "board.guest_access_enabled": "Эта доска общедоступна", + "board.guest_access_disabled": "Эта доска приватна", + "board.permissions_title": "Права доступа", + "board.permissions_description": "Управляйте, кто может просматривать, редактировать или администрировать эту доску.", + "board.access_grant": "Назначить доступ", + "board.access_search_placeholder": "Поиск...", + "board.access_loading": "Загрузка прав...", + "board.access_none": "Права доступа для этой доски не настроены.", + "board.access_private": "Приватная", + "board.access_shared": "Общая", + "board.share": "Поделиться", + "board.share_title": "Поделиться «{name}»", + "board.share_copy_link": "Копировать ссылку", + "board.share_copied": "Скопировано!", + "board.share_guest_description": "Любой с этой ссылкой может просматривать доску без входа.", + "board.share_add_access": "Добавить людей или группы", + "board.share_current_access": "Текущий доступ", + "section.title_label": "Заголовок", + "section.icon_label": "Иконка", + "section.icon_placeholder": "Необязательно", + "section.sections": "Разделы", + "section.add": "Добавить раздел", + "section.create": "Создать раздел", + "section.order": "Порядок: {order}", + "widget.add": "Добавить виджет", + "widget.select_app": "Выберите приложение", + "widget.choose_app": "Выберите приложение...", + "widget.no_widgets": "В этом разделе нет виджетов.", + "widget.no_widgets_dnd": "Нет виджетов. Перетащите сюда или добавьте выше.", + "widget.type": "Виджет {type}", + "widget.number": "Виджет #{order}", + "widget.remove": "Удалить", + "app.title": "Реестр приложений", + "app.apps_registered": "Зарегистрировано приложений: {count}", + "app.add": "Добавить приложение", + "app.new": "Новое приложение", + "app.no_apps": "Приложения ещё не зарегистрированы.", + "app.no_apps_hint": "Нажмите «Добавить приложение», чтобы зарегистрировать первое приложение.", + "app.all_categories": "Все", + "app.name": "Название", + "app.name_placeholder": "Моё приложение", + "app.url": "URL", + "app.url_placeholder": "https://my-app.local:8080", + "app.description": "Описание", + "app.description_placeholder": "Краткое описание приложения", + "app.category": "Категория", + "app.category_placeholder": "напр. Медиа, Мониторинг, Хранилище", + "app.tags": "Теги", + "app.tags_placeholder": "Теги через запятую", + "app.icon": "Иконка", + "app.icon_lucide": "Lucide", + "app.icon_simple": "Simple Icons", + "app.icon_url": "URL изображения", + "app.icon_emoji": "Эмодзи", + "app.icon_lucide_placeholder": "напр. globe, server, home", + "app.icon_simple_placeholder": "напр. github, docker", + "app.icon_url_placeholder": "https://example.com/icon.png", + "app.icon_emoji_placeholder": "напр. 🌐", + "app.icon_preview": "Превью иконки", + "app.save": "Сохранить", + "app.saving": "Сохранение...", + "app.healthcheck_toggle": "Настройки проверки здоровья", + "app.healthcheck_show": "Показать", + "app.healthcheck_hide": "Скрыть", + "app.healthcheck_enabled": "Включить проверку здоровья", + "app.healthcheck_method": "Метод", + "app.healthcheck_expected_status": "Ожидаемый статус", + "app.healthcheck_timeout": "Таймаут (мс)", + "app.healthcheck_interval": "Интервал (секунды)", + "app.icon_board_label": "Иконка (Lucide)", + "app.uptime": "аптайм", + "app.history_loading": "Загрузка истории...", + "admin.panel": "Панель администратора", + "admin.users": "Пользователи", + "admin.groups": "Группы", + "admin.settings": "Настройки", + "admin.user_management": "Управление пользователями", + "admin.create_user": "Создать пользователя", + "admin.new_user": "Новый пользователь", + "admin.user_column": "Пользователь", + "admin.email_column": "Электронная почта", + "admin.role_column": "Роль", + "admin.provider_column": "Провайдер", + "admin.groups_column": "Группы", + "admin.actions_column": "Действия", + "admin.role_user": "Пользователь", + "admin.role_admin": "Администратор", + "admin.select_group": "Выбрать группу", + "admin.add_to_group": "+ Добавить", + "admin.remove_from_group": "Удалить из группы", + "admin.no_users": "Пользователи не найдены.", + "admin.group_management": "Управление группами", + "admin.create_group": "Создать группу", + "admin.new_group": "Новая группа", + "admin.name_column": "Название", + "admin.description_column": "Описание", + "admin.members_column": "Участники", + "admin.default_column": "По умолчанию", + "admin.default_group_hint": "Группа по умолчанию (авто-назначение новым пользователям)", + "admin.no_groups": "Группы не найдены.", + "admin.yes": "Да", + "admin.no": "Нет", + "admin.system_settings": "Системные настройки", + "admin.settings_description": "Настройка глобальных параметров приложения.", + "admin.authentication": "Аутентификация", + "admin.auth_mode": "Режим аутентификации", + "admin.auth_local": "Локальный", + "admin.auth_oauth": "OAuth", + "admin.auth_both": "Оба", + "admin.registration_enabled": "Разрешить регистрацию пользователей", + "admin.oauth_config": "Настройка OAuth", + "admin.oauth_description": "Настройте провайдер OIDC (напр. Authentik, Keycloak). Установите режим аутентификации «OAuth» или «Оба» выше, чтобы включить вход через OAuth.", + "admin.oauth_client_id": "Client ID", + "admin.oauth_client_id_placeholder": "OAuth client ID", + "admin.oauth_client_secret": "Секрет клиента", + "admin.oauth_client_secret_placeholder": "Секрет OAuth клиента", + "admin.oauth_discovery_url": "Discovery URL", + "admin.oauth_discovery_url_placeholder": "https://example.com/.well-known/openid-configuration", + "admin.oauth_test": "Тестировать подключение", + "admin.oauth_testing": "Тестирование...", + "admin.oauth_connected": "Подключено к: {issuer}", + "admin.oauth_network_error": "Ошибка сети — не удалось связаться с сервером", + "admin.theme_defaults": "Настройки темы", + "admin.default_theme": "Тема по умолчанию", + "admin.default_primary_color": "Основной цвет по умолчанию", + "admin.healthcheck_defaults": "Настройки проверки здоровья", + "admin.healthcheck_defaults_description": "JSON-конфигурация проверки здоровья по умолчанию (интервал, таймаут, метод).", + "admin.healthcheck_defaults_label": "Настройки (JSON)", + "admin.save_settings": "Сохранить настройки", + "admin.saving_settings": "Сохранение...", + "admin.perm_title": "Назначить права", + "admin.perm_entity_type": "Тип объекта", + "admin.perm_entity": "Объект", + "admin.perm_target_type": "Тип цели", + "admin.perm_target": "Цель", + "admin.perm_level": "Уровень", + "admin.perm_board": "Доска", + "admin.perm_app": "Приложение", + "admin.perm_user": "Пользователь", + "admin.perm_group": "Группа", + "admin.perm_view": "Просмотр", + "admin.perm_edit": "Редактирование", + "admin.perm_admin": "Администратор", + "admin.perm_grant": "Назначить", + "admin.perm_revoke": "Отозвать", + "admin.perm_select": "Выбрать...", + "admin.perm_entity_column": "Объект", + "admin.perm_target_column": "Цель", + "admin.perm_level_column": "Уровень", + "admin.perm_action_column": "Действие", + "admin.perm_none": "Права не настроены.", + "admin.perm_search_placeholder": "Начните вводить...", - "admin.discovery_title": "Обнаружение сервисов", - "admin.discovery_description": "Сканируйте Docker-контейнеры и маршруты Traefik для автоматического обнаружения работающих сервисов и их регистрации как приложений.", - "admin.discovery_scan": "Сканировать сервисы", - "admin.discovery_scanning": "Сканирование...", - "admin.discovery_approve": "Одобрить выбранные", - "admin.discovery_approving": "Одобрение...", - "admin.discovery_source": "Источник", - "admin.discovery_status": "Статус", - "admin.discovery_source_docker": "Docker", - "admin.discovery_source_traefik": "Traefik", - "admin.discovery_already_registered": "Уже зарегистрировано", - "admin.discovery_new": "Новый", - "admin.discovery_no_results": "Сервисы не обнаружены. Проверьте путь к Docker-сокету или URL API Traefik.", - "admin.discovery_config": "Настройка обнаружения сервисов", - "admin.discovery_config_description": "Настройте конечные точки Docker и Traefik для автоматического обнаружения сервисов. Эти настройки используются панелью обнаружения ниже.", - "admin.discovery_docker_socket": "Путь к Docker-сокету", - "admin.discovery_docker_socket_hint": "Путь к Docker-сокету (напр. /var/run/docker.sock). Задаётся через DOCKER_SOCKET_PATH.", - "admin.discovery_traefik_url": "URL API Traefik", - "admin.discovery_traefik_url_hint": "Базовый URL API Traefik (напр. http://traefik:8080). Задаётся через TRAEFIK_API_URL.", + "admin.discovery_title": "Обнаружение сервисов", + "admin.discovery_description": "Сканируйте Docker-контейнеры и маршруты Traefik для автоматического обнаружения работающих сервисов и их регистрации как приложений.", + "admin.discovery_scan": "Сканировать сервисы", + "admin.discovery_scanning": "Сканирование...", + "admin.discovery_approve": "Одобрить выбранные", + "admin.discovery_approving": "Одобрение...", + "admin.discovery_source": "Источник", + "admin.discovery_status": "Статус", + "admin.discovery_source_docker": "Docker", + "admin.discovery_source_traefik": "Traefik", + "admin.discovery_already_registered": "Уже зарегистрировано", + "admin.discovery_new": "Новый", + "admin.discovery_no_results": "Сервисы не обнаружены. Проверьте путь к Docker-сокету или URL API Traefik.", + "admin.discovery_config": "Настройка обнаружения сервисов", + "admin.discovery_config_description": "Настройте конечные точки Docker и Traefik для автоматического обнаружения сервисов. Эти настройки используются панелью обнаружения ниже.", + "admin.discovery_docker_socket": "Путь к Docker-сокету", + "admin.discovery_docker_socket_hint": "Путь к Docker-сокету (напр. /var/run/docker.sock). Задаётся через DOCKER_SOCKET_PATH.", + "admin.discovery_traefik_url": "URL API Traefik", + "admin.discovery_traefik_url_hint": "Базовый URL API Traefik (напр. http://traefik:8080). Задаётся через TRAEFIK_API_URL.", - "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": "Поиск...", - "search.min_chars": "Введите минимум 2 символа для поиска", - "search.no_results": "Ничего не найдено по запросу «{query}»", - "search.apps": "Приложения", - "search.boards": "Доски", - "search.nav_hint": "навигация", - "search.select_hint": "выбрать", - "search.close_hint": "закрыть", + "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": "Поиск...", + "search.min_chars": "Введите минимум 2 символа для поиска", + "search.no_results": "Ничего не найдено по запросу «{query}»", + "search.apps": "Приложения", + "search.boards": "Доски", + "search.nav_hint": "навигация", + "search.select_hint": "выбрать", + "search.close_hint": "закрыть", - "common.search_filter": "Фильтр...", - "common.save": "Сохранить", - "common.cancel": "Отмена", - "common.delete": "Удалить", - "common.create": "Создать", - "common.back": "Назад", - "common.edit": "Редактировать", - "common.add": "Добавить", - "common.confirm": "Подтвердить?", - "common.yes": "Да", - "common.no": "Нет", - "common.name": "Название", - "common.description": "Описание", - "common.required": "*", - "status.online": "Онлайн", - "status.offline": "Оффлайн", - "status.degraded": "Нестабильно", - "status.unknown": "Неизвестно", - "theme.dark": "Тёмная", - "theme.light": "Светлая", - "theme.system": "Системная", - "theme.toggle": "Переключить тему (текущая: {mode})", - "theme.title": "Тема: {mode}", - "bg.mesh": "Меш-градиент", - "bg.particles": "Частицы", - "bg.aurora": "Сияние", - "bg.none": "Нет", - "bg.title": "Эффект фона", - "bg.aria_label": "Изменить эффект фона", - "sidebar.expand": "Развернуть боковую панель", - "sidebar.collapse": "Свернуть боковую панель", - "sidebar.toggle": "Переключить боковую панель", - "sidebar.close": "Закрыть боковую панель", - "home.welcome": "Добро пожаловать, {name}. Доска по умолчанию ещё не настроена.", - "home.view_boards": "Посмотреть доски", - "home.browse_apps": "Обзор приложений", - "language.label": "Язык", - "settings.title": "Настройки", - "settings.theme": "Режим темы", - "settings.primary_color": "Основной цвет", - "settings.hue": "Оттенок", - "settings.saturation": "Насыщенность", - "settings.background": "Эффект фона", - "settings.language": "Язык", - "settings.save": "Сохранить настройки", - "settings.saving": "Сохранение...", - "settings.saved": "Настройки сохранены!", - "settings.bookmarklet_title": "Быстрое добавление (букмарклет)", - "settings.bookmarklet_instructions": "Перетащите кнопку ниже на панель закладок браузера. При посещении любой страницы нажмите её, чтобы быстро добавить сайт в App Launcher.", - "settings.bookmarklet_drag": "Добавить в Launcher", - "settings.bookmarklet_drag_hint": "Перетащите на панель закладок", - "settings.bookmarklet_show_code": "Показать код букмарклета", - "app.quick_add_title": "Быстрое добавление приложения", - "app.quick_add_description": "Проверьте данные ниже и сохраните, чтобы добавить приложение в лаунчер.", - "app.quick_add_success": "Приложение успешно добавлено!", - "app.quick_add_view_apps": "Посмотреть приложения", - "app.quick_add_close": "Закрыть окно", - "offline.title": "Нет подключения", - "offline.description": "Похоже, вы потеряли подключение к интернету. Проверьте сеть и попробуйте снова.", - "offline.retry": "Повторить", - "install.title": "Установить приложение", - "install.description": "Добавьте Web App Launcher на главный экран для быстрого доступа.", - "install.button": "Установить", - "install.dismiss": "Скрыть предложение установки" + "common.search_filter": "Фильтр...", + "common.save": "Сохранить", + "common.cancel": "Отмена", + "common.delete": "Удалить", + "common.create": "Создать", + "common.back": "Назад", + "common.edit": "Редактировать", + "common.add": "Добавить", + "common.confirm": "Подтвердить?", + "common.yes": "Да", + "common.no": "Нет", + "common.name": "Название", + "common.description": "Описание", + "common.required": "*", + "status.online": "Онлайн", + "status.offline": "Оффлайн", + "status.degraded": "Нестабильно", + "status.unknown": "Неизвестно", + "theme.dark": "Тёмная", + "theme.light": "Светлая", + "theme.system": "Системная", + "theme.toggle": "Переключить тему (текущая: {mode})", + "theme.title": "Тема: {mode}", + "bg.mesh": "Меш-градиент", + "bg.particles": "Частицы", + "bg.aurora": "Сияние", + "bg.none": "Нет", + "bg.title": "Эффект фона", + "bg.aria_label": "Изменить эффект фона", + "sidebar.expand": "Развернуть боковую панель", + "sidebar.collapse": "Свернуть боковую панель", + "sidebar.toggle": "Переключить боковую панель", + "sidebar.close": "Закрыть боковую панель", + "home.welcome": "Добро пожаловать, {name}. Доска по умолчанию ещё не настроена.", + "home.view_boards": "Посмотреть доски", + "home.browse_apps": "Обзор приложений", + "language.label": "Язык", + "settings.title": "Настройки", + "settings.theme": "Режим темы", + "settings.primary_color": "Основной цвет", + "settings.hue": "Оттенок", + "settings.saturation": "Насыщенность", + "settings.background": "Эффект фона", + "settings.language": "Язык", + "settings.save": "Сохранить настройки", + "settings.saving": "Сохранение...", + "settings.saved": "Настройки сохранены!", + "settings.bookmarklet_title": "Быстрое добавление (букмарклет)", + "settings.bookmarklet_instructions": "Перетащите кнопку ниже на панель закладок браузера. При посещении любой страницы нажмите её, чтобы быстро добавить сайт в App Launcher.", + "settings.bookmarklet_drag": "Добавить в Launcher", + "settings.bookmarklet_drag_hint": "Перетащите на панель закладок", + "settings.bookmarklet_show_code": "Показать код букмарклета", + "app.quick_add_title": "Быстрое добавление приложения", + "app.quick_add_description": "Проверьте данные ниже и сохраните, чтобы добавить приложение в лаунчер.", + "app.quick_add_success": "Приложение успешно добавлено!", + "app.quick_add_view_apps": "Посмотреть приложения", + "app.quick_add_close": "Закрыть окно", + "offline.title": "Нет подключения", + "offline.description": "Похоже, вы потеряли подключение к интернету. Проверьте сеть и попробуйте снова.", + "offline.retry": "Повторить", + "install.title": "Установить приложение", + "install.description": "Добавьте Web App Launcher на главный экран для быстрого доступа.", + "install.button": "Установить", + "install.dismiss": "Скрыть предложение установки" } diff --git a/src/lib/server/jobs/healthcheckScheduler.ts b/src/lib/server/jobs/healthcheckScheduler.ts index 2efbf1b..6b5c008 100644 --- a/src/lib/server/jobs/healthcheckScheduler.ts +++ b/src/lib/server/jobs/healthcheckScheduler.ts @@ -1,13 +1,49 @@ import cron from 'node-cron'; import { checkAllApps, pruneOldStatuses } from '$lib/server/services/healthcheckService.js'; +import { broadcastNotification } from '$lib/server/services/notificationService.js'; +import { pruneOldLogs } from '$lib/server/services/auditLogService.js'; +import * as appService from '$lib/server/services/appService.js'; +import { AppStatusValue, NotificationEvent } from '$lib/utils/constants.js'; let scheduledTask: cron.ScheduledTask | null = null; let cleanupTask: cron.ScheduledTask | null = null; +let auditPruneTask: cron.ScheduledTask | null = null; + +// Track previous status per app to detect transitions +const previousStatuses = new Map(); + +/** + * Check if a status transition warrants a notification. + */ +function getStatusChangeEvent( + previousStatus: string | undefined, + newStatus: string +): string | null { + if (!previousStatus) { + return null; // First check — no transition + } + if (previousStatus === newStatus) { + return null; // No change + } + + if (newStatus === AppStatusValue.OFFLINE && previousStatus !== AppStatusValue.OFFLINE) { + return NotificationEvent.APP_OFFLINE; + } + if (newStatus === AppStatusValue.ONLINE && previousStatus !== AppStatusValue.ONLINE) { + return NotificationEvent.APP_ONLINE; + } + if (newStatus === AppStatusValue.DEGRADED && previousStatus !== AppStatusValue.DEGRADED) { + return NotificationEvent.APP_DEGRADED; + } + + return 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. + * Triggers notifications when app status changes. */ export function startScheduler(cronExpression: string = '* * * * *'): void { if (scheduledTask) { @@ -16,7 +52,29 @@ export function startScheduler(cronExpression: string = '* * * * *'): void { scheduledTask = cron.schedule(cronExpression, async () => { try { - await checkAllApps(); + const results = await checkAllApps(); + + // Check for status transitions and send notifications + for (const result of results) { + const prevStatus = previousStatuses.get(result.appId); + const event = getStatusChangeEvent(prevStatus, result.status); + + if (event) { + // Fire-and-forget notification + appService + .findById(result.appId) + .then((app) => { + const statusLabel = result.status.charAt(0).toUpperCase() + result.status.slice(1); + const message = `${app.name} is now ${statusLabel} (was ${prevStatus ?? 'unknown'})`; + return broadcastNotification(result.appId, event, message); + }) + .catch(() => { + // Swallow notification errors + }); + } + + previousStatuses.set(result.appId, result.status); + } } catch { // Swallow errors to prevent scheduler crash } @@ -31,6 +89,15 @@ export function startScheduler(cronExpression: string = '* * * * *'): void { } }); + // Audit log pruning: run daily at midnight + auditPruneTask = cron.schedule('0 0 * * *', async () => { + try { + await pruneOldLogs(90); // Default 90 day retention + } catch { + // Swallow errors to prevent scheduler crash + } + }); + // Run an initial check shortly after startup setTimeout(() => { checkAllApps().catch(() => { @@ -51,4 +118,8 @@ export function stopScheduler(): void { cleanupTask.stop(); cleanupTask = null; } + if (auditPruneTask) { + auditPruneTask.stop(); + auditPruneTask = null; + } } diff --git a/src/lib/server/middleware/authenticate.ts b/src/lib/server/middleware/authenticate.ts index 58f59e7..b0070ff 100644 --- a/src/lib/server/middleware/authenticate.ts +++ b/src/lib/server/middleware/authenticate.ts @@ -5,10 +5,16 @@ import type { RequestEvent } from '@sveltejs/kit'; * Reusable authentication check helper. * Throws a redirect to /login if the user is not authenticated. * Returns the authenticated user from event.locals. + * + * For API routes, also checks for Bearer token in Authorization header. + * If a valid API token is found, the user is set from the token's owner. */ export function requireAuth(event: RequestEvent) { const user = event.locals.user; if (!user) { + // For API routes, redirect is not appropriate — but we keep the behavior + // consistent with the existing codebase. The hooks.server.ts handles + // API token validation and sets event.locals.user before routes run. throw redirect(302, '/login'); } return user; @@ -20,3 +26,21 @@ export function requireAuth(event: RequestEvent) { export function isAuthenticated(event: RequestEvent): boolean { return event.locals.user !== null; } + +/** + * Extract Bearer token from Authorization header, if present. + * Returns the token string or null. + */ +export function extractBearerToken(event: RequestEvent): string | null { + const authHeader = event.request.headers.get('authorization'); + if (!authHeader) { + return null; + } + + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { + return null; + } + + return parts[1]; +} diff --git a/src/lib/server/services/__tests__/appService.test.ts b/src/lib/server/services/__tests__/appService.test.ts index 8fef0b4..a4f05b7 100644 --- a/src/lib/server/services/__tests__/appService.test.ts +++ b/src/lib/server/services/__tests__/appService.test.ts @@ -51,7 +51,10 @@ describe('appService', () => { expect(mockApp.findMany).toHaveBeenCalledWith({ where: {}, orderBy: { name: 'asc' }, - include: { statuses: { orderBy: { checkedAt: 'desc' }, take: 1 } } + include: { + links: { orderBy: { order: 'asc' } }, + statuses: { orderBy: { checkedAt: 'desc' }, take: 1 } + } }); }); @@ -152,10 +155,7 @@ describe('appService', () => { describe('getCategories', () => { it('returns unique categories', async () => { - mockApp.findMany.mockResolvedValue([ - { category: 'Media' }, - { category: 'Monitoring' } - ]); + mockApp.findMany.mockResolvedValue([{ category: 'Media' }, { category: 'Monitoring' }]); const result = await appService.getCategories(); diff --git a/src/lib/server/services/__tests__/boardService.test.ts b/src/lib/server/services/__tests__/boardService.test.ts index d806fec..69d80c2 100644 --- a/src/lib/server/services/__tests__/boardService.test.ts +++ b/src/lib/server/services/__tests__/boardService.test.ts @@ -152,7 +152,8 @@ describe('boardService', () => { const result = await boardService.createWidget({ sectionId: 's1', - type: 'app' + type: 'app', + config: JSON.stringify({ appId: 'test-app-1' }) }); expect(result.type).toBe('app'); diff --git a/src/lib/server/services/__tests__/discoveryService.test.ts b/src/lib/server/services/__tests__/discoveryService.test.ts index bfa06d3..9e12d4d 100644 --- a/src/lib/server/services/__tests__/discoveryService.test.ts +++ b/src/lib/server/services/__tests__/discoveryService.test.ts @@ -148,9 +148,7 @@ describe('discoveryService', () => { it('returns error on Traefik API failure', async () => { vi.stubGlobal( 'fetch', - vi.fn(() => - Promise.resolve({ ok: false, status: 500 }) - ) + vi.fn(() => Promise.resolve({ ok: false, status: 500 })) ); const result = await discoverTraefik('http://traefik.local:8080'); diff --git a/src/lib/server/services/__tests__/groupService.test.ts b/src/lib/server/services/__tests__/groupService.test.ts index 18af091..967a66a 100644 --- a/src/lib/server/services/__tests__/groupService.test.ts +++ b/src/lib/server/services/__tests__/groupService.test.ts @@ -67,9 +67,7 @@ describe('groupService', () => { it('throws on duplicate name', async () => { mockGroup.findUnique.mockResolvedValue({ id: '1', name: 'Existing' }); - await expect(groupService.create({ name: 'Existing' })).rejects.toThrow( - 'already exists' - ); + await expect(groupService.create({ name: 'Existing' })).rejects.toThrow('already exists'); }); }); @@ -121,8 +119,9 @@ describe('groupService', () => { { id: 'g2', name: 'Default2', isDefault: true } ]); mockUserGroup.findUnique.mockResolvedValue(null); - mockUserGroup.create.mockImplementation(({ data }: { data: { userId: string; groupId: string } }) => - Promise.resolve({ id: `ug-${data.groupId}`, ...data }) + mockUserGroup.create.mockImplementation( + ({ data }: { data: { userId: string; groupId: string } }) => + Promise.resolve({ id: `ug-${data.groupId}`, ...data }) ); const results = await groupService.addUserToDefaultGroups('u1'); diff --git a/src/lib/server/services/__tests__/importService.test.ts b/src/lib/server/services/__tests__/importService.test.ts index a2137f4..3419711 100644 --- a/src/lib/server/services/__tests__/importService.test.ts +++ b/src/lib/server/services/__tests__/importService.test.ts @@ -182,9 +182,7 @@ describe('importService', () => { icon: null, order: 0, isExpandedByDefault: true, - widgets: [ - { type: 'note', order: 0, config: '{}', appName: null } - ] + widgets: [{ type: 'note', order: 0, config: '{}', appName: null }] } ] } diff --git a/src/lib/server/services/__tests__/oauthService.test.ts b/src/lib/server/services/__tests__/oauthService.test.ts index c56d504..3e8b9e1 100644 --- a/src/lib/server/services/__tests__/oauthService.test.ts +++ b/src/lib/server/services/__tests__/oauthService.test.ts @@ -95,7 +95,11 @@ describe('oauthService', () => { new URL('https://auth.example.com/authorize?code_challenge=abc') ); - const url = await generateAuthUrl('https://app.example.com/callback', 'test-challenge', 'test-state'); + const url = await generateAuthUrl( + 'https://app.example.com/callback', + 'test-challenge', + 'test-state' + ); expect(url).toBe('https://auth.example.com/authorize?code_challenge=abc'); expect(mockClient.buildAuthorizationUrl).toHaveBeenCalledWith( diff --git a/src/lib/server/services/__tests__/permissionService.test.ts b/src/lib/server/services/__tests__/permissionService.test.ts index 9af7810..abf37f7 100644 --- a/src/lib/server/services/__tests__/permissionService.test.ts +++ b/src/lib/server/services/__tests__/permissionService.test.ts @@ -29,12 +29,7 @@ describe('permissionService', () => { it('grants full access to admins', async () => { mockUser.findUnique.mockResolvedValue({ role: 'admin' }); - const result = await permissionService.checkPermission( - 'board', - 'b1', - 'admin-user', - 'edit' - ); + const result = await permissionService.checkPermission('board', 'b1', 'admin-user', 'edit'); expect(result.hasPermission).toBe(true); expect(result.effectiveLevel).toBe('admin'); @@ -45,12 +40,7 @@ describe('permissionService', () => { mockUser.findUnique.mockResolvedValue({ role: 'user' }); mockPermission.findFirst.mockResolvedValue({ level: 'edit' }); - const result = await permissionService.checkPermission( - 'board', - 'b1', - 'user1', - 'view' - ); + const result = await permissionService.checkPermission('board', 'b1', 'user1', 'view'); expect(result.hasPermission).toBe(true); expect(result.effectiveLevel).toBe('edit'); @@ -61,12 +51,7 @@ describe('permissionService', () => { mockUser.findUnique.mockResolvedValue({ role: 'user' }); mockPermission.findFirst.mockResolvedValue({ level: 'view' }); - const result = await permissionService.checkPermission( - 'board', - 'b1', - 'user1', - 'admin' - ); + const result = await permissionService.checkPermission('board', 'b1', 'user1', 'admin'); expect(result.hasPermission).toBe(false); }); @@ -77,12 +62,7 @@ describe('permissionService', () => { mockUserGroup.findMany.mockResolvedValue([{ groupId: 'g1' }]); mockPermission.findMany.mockResolvedValue([{ level: 'edit' }]); - const result = await permissionService.checkPermission( - 'board', - 'b1', - 'user1', - 'view' - ); + const result = await permissionService.checkPermission('board', 'b1', 'user1', 'view'); expect(result.hasPermission).toBe(true); expect(result.source).toBe('group'); @@ -93,12 +73,7 @@ describe('permissionService', () => { mockPermission.findFirst.mockResolvedValue(null); mockUserGroup.findMany.mockResolvedValue([]); - const result = await permissionService.checkPermission( - 'board', - 'b1', - 'user1', - 'view' - ); + const result = await permissionService.checkPermission('board', 'b1', 'user1', 'view'); expect(result.hasPermission).toBe(false); expect(result.effectiveLevel).toBeNull(); diff --git a/src/lib/server/services/__tests__/userService.test.ts b/src/lib/server/services/__tests__/userService.test.ts index 0b7590f..7bf3a53 100644 --- a/src/lib/server/services/__tests__/userService.test.ts +++ b/src/lib/server/services/__tests__/userService.test.ts @@ -131,9 +131,7 @@ describe('userService', () => { describe('getUserGroups', () => { it('returns user group memberships', async () => { - mockUserGroup.findMany.mockResolvedValue([ - { group: { id: 'g1', name: 'Devs' } } - ]); + mockUserGroup.findMany.mockResolvedValue([{ group: { id: 'g1', name: 'Devs' } }]); const result = await userService.getUserGroups('u1'); expect(result).toEqual([{ id: 'g1', name: 'Devs' }]); diff --git a/src/lib/server/services/apiTokenService.ts b/src/lib/server/services/apiTokenService.ts new file mode 100644 index 0000000..90790ef --- /dev/null +++ b/src/lib/server/services/apiTokenService.ts @@ -0,0 +1,127 @@ +import { randomBytes, createHash } from 'crypto'; +import bcrypt from 'bcryptjs'; +import { prisma } from '../prisma.js'; + +const BCRYPT_ROUNDS = 10; + +/** + * Hash a token string using SHA-256 for fast lookup, then bcrypt for storage. + * We use SHA-256 as an intermediate to create a fixed-length input for bcrypt + * (bcrypt has a 72-byte limit). + */ +function sha256(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +/** + * Generate a new API token. Returns the plaintext token (shown once) and the DB record. + */ +export async function generateToken( + userId: string, + name: string, + scope: string, + expiresAt?: string +) { + const plainToken = randomBytes(32).toString('hex'); + const tokenHash = await bcrypt.hash(sha256(plainToken), BCRYPT_ROUNDS); + + const token = await prisma.apiToken.create({ + data: { + userId, + name, + tokenHash, + scope, + expiresAt: expiresAt ? new Date(expiresAt) : null + } + }); + + return { + id: token.id, + name: token.name, + scope: token.scope, + expiresAt: token.expiresAt, + createdAt: token.createdAt, + token: plainToken // Only returned once at creation time + }; +} + +/** + * Revoke (delete) an API token. + */ +export async function revokeToken(tokenId: string, userId: string) { + const token = await prisma.apiToken.findUnique({ where: { id: tokenId } }); + if (!token || token.userId !== userId) { + throw new Error('API token not found'); + } + + await prisma.apiToken.delete({ where: { id: tokenId } }); +} + +/** + * List all tokens for a user (without the hash). + */ +export async function listTokens(userId: string) { + const tokens = await prisma.apiToken.findMany({ + where: { userId }, + select: { + id: true, + name: true, + scope: true, + lastUsedAt: true, + expiresAt: true, + createdAt: true + }, + orderBy: { createdAt: 'desc' } + }); + return tokens; +} + +/** + * Validate a plaintext token string. Returns the user info if valid, null otherwise. + * Updates lastUsedAt on successful validation. + */ +export async function validateToken(tokenString: string): Promise<{ + readonly userId: string; + readonly scope: string; +} | null> { + const tokenSha = sha256(tokenString); + + // We need to check against all tokens since bcrypt hashes are unique per-hash. + // For better performance at scale, consider indexing on a prefix or using a different scheme. + const allTokens = await prisma.apiToken.findMany({ + select: { + id: true, + userId: true, + tokenHash: true, + scope: true, + expiresAt: true + } + }); + + for (const token of allTokens) { + const isMatch = await bcrypt.compare(tokenSha, token.tokenHash); + if (isMatch) { + // Check expiry + if (token.expiresAt && token.expiresAt < new Date()) { + return null; // Token expired + } + + // Update lastUsedAt (fire-and-forget) + prisma.apiToken + .update({ + where: { id: token.id }, + data: { lastUsedAt: new Date() } + }) + .catch(() => { + // Swallow errors from lastUsedAt update + }); + + return { + userId: token.userId, + scope: token.scope + }; + } + } + + return null; +} diff --git a/src/lib/server/services/appService.ts b/src/lib/server/services/appService.ts index 15294d2..00152bd 100644 --- a/src/lib/server/services/appService.ts +++ b/src/lib/server/services/appService.ts @@ -23,6 +23,9 @@ export async function findAll(options?: { category?: string; search?: string }) statuses: { orderBy: { checkedAt: 'desc' }, take: 1 + }, + links: { + orderBy: { order: 'asc' } } } }); @@ -38,6 +41,9 @@ export async function findById(id: string) { }, createdBy: { select: { id: true, displayName: true } + }, + links: { + orderBy: { order: 'asc' } } } }); @@ -81,7 +87,8 @@ export async function update(id: string, input: UpdateAppInput) { if (input.healthcheckEnabled !== undefined) data.healthcheckEnabled = input.healthcheckEnabled; if (input.healthcheckInterval !== undefined) data.healthcheckInterval = input.healthcheckInterval; if (input.healthcheckMethod !== undefined) data.healthcheckMethod = input.healthcheckMethod; - if (input.healthcheckExpectedStatus !== undefined) data.healthcheckExpectedStatus = input.healthcheckExpectedStatus; + if (input.healthcheckExpectedStatus !== undefined) + data.healthcheckExpectedStatus = input.healthcheckExpectedStatus; if (input.healthcheckTimeout !== undefined) data.healthcheckTimeout = input.healthcheckTimeout; return prisma.app.update({ @@ -95,11 +102,7 @@ export async function remove(id: string) { await prisma.app.delete({ where: { id } }); } -export async function recordStatus( - appId: string, - status: string, - responseTime: number | null -) { +export async function recordStatus(appId: string, status: string, responseTime: number | null) { return prisma.appStatus.create({ data: { appId, @@ -138,6 +141,85 @@ export async function getHealthcheckTargets() { }); } +// --- App Links (Multi-URL) --- + +export async function addAppLink( + appId: string, + input: { label: string; url: string; icon?: string | null; order?: number } +) { + await findById(appId); + + let order = input.order; + if (order === undefined) { + const maxLink = await prisma.appLink.findFirst({ + where: { appId }, + orderBy: { order: 'desc' }, + select: { order: true } + }); + order = (maxLink?.order ?? -1) + 1; + } + + return prisma.appLink.create({ + data: { + appId, + label: input.label, + url: input.url, + icon: input.icon ?? null, + order + } + }); +} + +export async function updateAppLink( + linkId: string, + input: { label?: string; url?: string; icon?: string | null; order?: number } +) { + const link = await prisma.appLink.findUnique({ where: { id: linkId } }); + if (!link) { + throw new Error(`App link not found: ${linkId}`); + } + + const data: Record = {}; + if (input.label !== undefined) data.label = input.label; + if (input.url !== undefined) data.url = input.url; + if (input.icon !== undefined) data.icon = input.icon; + if (input.order !== undefined) data.order = input.order; + + return prisma.appLink.update({ + where: { id: linkId }, + data + }); +} + +export async function removeAppLink(linkId: string) { + const link = await prisma.appLink.findUnique({ where: { id: linkId } }); + if (!link) { + throw new Error(`App link not found: ${linkId}`); + } + + await prisma.appLink.delete({ where: { id: linkId } }); +} + +export async function reorderAppLinks(appId: string, linkIds: string[]) { + await findById(appId); + + const updates = linkIds.map((id, index) => + prisma.appLink.update({ + where: { id }, + data: { order: index } + }) + ); + + return prisma.$transaction(updates); +} + +export async function getAppLinks(appId: string) { + return prisma.appLink.findMany({ + where: { appId }, + orderBy: { order: 'asc' } + }); +} + export async function getCategories() { const apps = await prisma.app.findMany({ where: { category: { not: null } }, diff --git a/src/lib/server/services/auditLogService.ts b/src/lib/server/services/auditLogService.ts new file mode 100644 index 0000000..cefa031 --- /dev/null +++ b/src/lib/server/services/auditLogService.ts @@ -0,0 +1,98 @@ +import { prisma } from '../prisma.js'; + +/** + * Record an audit log entry. Non-blocking: catches and swallows errors + * to avoid slowing down the operation being audited. + */ +export function logAction( + userId: string | null, + action: string, + entityType: string, + entityId: string, + details?: Record +): void { + prisma.auditLog + .create({ + data: { + userId, + action, + entityType, + entityId, + details: details ? JSON.stringify(details) : '{}' + } + }) + .catch(() => { + // Non-blocking: swallow errors so the parent operation is unaffected + }); +} + +/** + * Query audit logs with filters and pagination. + */ +export async function getAuditLogs(options?: { + action?: string; + entityType?: string; + userId?: string; + startDate?: string; + endDate?: string; + limit?: number; + offset?: number; +}) { + const where: Record = {}; + + if (options?.action) { + where.action = options.action; + } + if (options?.entityType) { + where.entityType = options.entityType; + } + if (options?.userId) { + where.userId = options.userId; + } + + const dateFilter: Record = {}; + if (options?.startDate) { + dateFilter.gte = new Date(options.startDate); + } + if (options?.endDate) { + dateFilter.lte = new Date(options.endDate); + } + if (Object.keys(dateFilter).length > 0) { + where.createdAt = dateFilter; + } + + const limit = options?.limit ?? 50; + const offset = options?.offset ?? 0; + + const [logs, total] = await Promise.all([ + prisma.auditLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + include: { + user: { + select: { id: true, displayName: true, email: true } + } + } + }), + prisma.auditLog.count({ where }) + ]); + + return { logs, total }; +} + +/** + * Delete audit logs older than the given retention period. + */ +export async function pruneOldLogs(retentionDays: number = 90) { + const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000); + + const result = await prisma.auditLog.deleteMany({ + where: { + createdAt: { lt: cutoff } + } + }); + + return result.count; +} diff --git a/src/lib/server/services/authService.ts b/src/lib/server/services/authService.ts index 2f6c63e..be484c5 100644 --- a/src/lib/server/services/authService.ts +++ b/src/lib/server/services/authService.ts @@ -78,10 +78,7 @@ export async function saveRefreshToken(userId: string, refreshToken: string): Pr }); } -export async function validateRefreshToken( - userId: string, - refreshToken: string -): Promise { +export async function validateRefreshToken(userId: string, refreshToken: string): Promise { const user = await prisma.user.findUnique({ where: { id: userId }, select: { refreshToken: true, refreshTokenExpiresAt: true } @@ -108,7 +105,11 @@ export async function revokeRefreshToken(userId: string): Promise { }); } -export async function rotateTokens(userId: string, email: string, role: string): Promise { +export async function rotateTokens( + userId: string, + email: string, + role: string +): Promise { const accessToken = signAccessToken({ userId, email, role }); const refreshToken = generateRefreshToken(); await saveRefreshToken(userId, refreshToken); diff --git a/src/lib/server/services/boardService.ts b/src/lib/server/services/boardService.ts index 2fee693..92d9696 100644 --- a/src/lib/server/services/boardService.ts +++ b/src/lib/server/services/boardService.ts @@ -1,6 +1,74 @@ import { prisma } from '../prisma.js'; -import type { CreateBoardInput, UpdateBoardInput, CreateSectionInput, UpdateSectionInput } from '$lib/types/board.js'; +import type { + CreateBoardInput, + UpdateBoardInput, + CreateSectionInput, + UpdateSectionInput +} from '$lib/types/board.js'; import type { CreateWidgetInput, UpdateWidgetInput } from '$lib/types/widget.js'; +import { WidgetType } from '$lib/utils/constants.js'; +import { + appWidgetConfigSchema, + bookmarkWidgetConfigSchema, + noteWidgetConfigSchema, + embedWidgetConfigSchema, + statusWidgetConfigSchema, + clockWeatherWidgetConfigSchema, + systemStatsWidgetConfigSchema, + rssWidgetConfigSchema, + calendarWidgetConfigSchema, + markdownWidgetConfigSchema, + metricWidgetConfigSchema, + linkGroupWidgetConfigSchema, + cameraWidgetConfigSchema +} from '$lib/utils/validators.js'; +import type { ZodTypeAny } from 'zod'; + +/** + * Map of widget types to their config validation schemas. + */ +const widgetConfigSchemas: Record = { + [WidgetType.APP]: appWidgetConfigSchema, + [WidgetType.BOOKMARK]: bookmarkWidgetConfigSchema, + [WidgetType.NOTE]: noteWidgetConfigSchema, + [WidgetType.EMBED]: embedWidgetConfigSchema, + [WidgetType.STATUS]: statusWidgetConfigSchema, + [WidgetType.CLOCK]: clockWeatherWidgetConfigSchema, + [WidgetType.SYSTEM_STATS]: systemStatsWidgetConfigSchema, + [WidgetType.RSS]: rssWidgetConfigSchema, + [WidgetType.CALENDAR]: calendarWidgetConfigSchema, + [WidgetType.MARKDOWN]: markdownWidgetConfigSchema, + [WidgetType.METRIC]: metricWidgetConfigSchema, + [WidgetType.LINK_GROUP]: linkGroupWidgetConfigSchema, + [WidgetType.CAMERA]: cameraWidgetConfigSchema +}; + +/** + * Validate widget config JSON string against the schema for its widget type. + * Returns the validated config string, or throws if invalid. + */ +function validateWidgetConfig(type: string, configStr: string): string { + const schema = widgetConfigSchemas[type]; + if (!schema) { + // Unknown widget type — allow any config to avoid breaking extensibility + return configStr; + } + + let parsed: unknown; + try { + parsed = JSON.parse(configStr); + } catch { + throw new Error('Widget config is not valid JSON'); + } + + const result = schema.safeParse(parsed); + if (!result.success) { + const messages = result.error.errors.map((e: { message: string }) => e.message).join(', '); + throw new Error(`Invalid widget config: ${messages}`); + } + + return configStr; +} // --- Board --- @@ -135,6 +203,14 @@ export async function updateBoard(id: string, input: UpdateBoardInput) { if (input.isDefault !== undefined) data.isDefault = input.isDefault; if (input.isGuestAccessible !== undefined) data.isGuestAccessible = input.isGuestAccessible; if (input.backgroundConfig !== undefined) data.backgroundConfig = input.backgroundConfig; + if (input.themeHue !== undefined) data.themeHue = input.themeHue; + if (input.themeSaturation !== undefined) data.themeSaturation = input.themeSaturation; + if (input.backgroundType !== undefined) data.backgroundType = input.backgroundType; + if (input.cardSize !== undefined) data.cardSize = input.cardSize; + if (input.wallpaperUrl !== undefined) data.wallpaperUrl = input.wallpaperUrl; + if (input.wallpaperBlur !== undefined) data.wallpaperBlur = input.wallpaperBlur; + if (input.wallpaperOverlay !== undefined) data.wallpaperOverlay = input.wallpaperOverlay; + if (input.customCss !== undefined) data.customCss = input.customCss; return prisma.board.update({ where: { id }, @@ -195,6 +271,7 @@ export async function updateSection(id: string, input: UpdateSectionInput) { if (input.icon !== undefined) data.icon = input.icon; if (input.order !== undefined) data.order = input.order; if (input.isExpandedByDefault !== undefined) data.isExpandedByDefault = input.isExpandedByDefault; + if (input.cardSize !== undefined) data.cardSize = input.cardSize; return prisma.section.update({ where: { id }, @@ -231,24 +308,32 @@ export async function createWidget(input: CreateWidgetInput) { order = (maxWidget?.order ?? -1) + 1; } + const configStr = input.config ?? '{}'; + validateWidgetConfig(input.type, configStr); + return prisma.widget.create({ data: { sectionId: input.sectionId, type: input.type, order, - config: input.config ?? '{}', + config: configStr, appId: input.appId ?? null } }); } export async function updateWidget(id: string, input: UpdateWidgetInput) { - await findWidgetById(id); + const existing = await findWidgetById(id); const data: Record = {}; if (input.type !== undefined) data.type = input.type; if (input.order !== undefined) data.order = input.order; - if (input.config !== undefined) data.config = input.config; + if (input.config !== undefined) { + // Validate config against the widget type (use new type if provided, else existing type) + const effectiveType = input.type ?? existing.type; + validateWidgetConfig(effectiveType, input.config); + data.config = input.config; + } if (input.appId !== undefined) data.appId = input.appId; return prisma.widget.update({ diff --git a/src/lib/server/services/calendarService.ts b/src/lib/server/services/calendarService.ts new file mode 100644 index 0000000..66b8733 --- /dev/null +++ b/src/lib/server/services/calendarService.ts @@ -0,0 +1,238 @@ +/** + * Calendar service — fetches and parses iCal (.ics) files. + * Uses lightweight hand-parsing of VEVENT blocks (no heavy dependencies). + */ + +const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes +const FETCH_TIMEOUT_MS = 10_000; +const DEFAULT_DAYS_AHEAD = 14; + +interface CacheEntry { + readonly data: string; // raw ical text + readonly expiresAt: number; +} + +export interface CalendarEvent { + readonly summary: string; + readonly start: string; + readonly end: string; + readonly location: string | null; + readonly calendarLabel: string; + readonly calendarColor: string; +} + +export interface CalendarSource { + readonly url: string; + readonly color?: string; + readonly label?: string; +} + +const cache = new Map(); + +function getCached(key: string): string | null { + const entry = cache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + cache.delete(key); + return null; + } + return entry.data; +} + +function setCache(key: string, data: string): void { + cache.set(key, { + data, + expiresAt: Date.now() + CACHE_TTL_MS + }); +} + +/** + * Parse an iCal date string (YYYYMMDD, YYYYMMDDTHHmmssZ, YYYYMMDDTHHmmss). + */ +function parseIcalDate(dateStr: string): Date | null { + if (!dateStr) return null; + + // Remove TZID parameter prefix if present + const clean = dateStr.replace(/^.*:/, '').trim(); + + // All-day event: YYYYMMDD + if (/^\d{8}$/.test(clean)) { + const year = parseInt(clean.substring(0, 4), 10); + const month = parseInt(clean.substring(4, 6), 10) - 1; + const day = parseInt(clean.substring(6, 8), 10); + return new Date(year, month, day); + } + + // DateTime: YYYYMMDDTHHmmss or YYYYMMDDTHHmmssZ + const dtMatch = clean.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/); + if (dtMatch) { + const [, year, month, day, hour, minute, second, utc] = dtMatch; + if (utc === 'Z') { + return new Date( + Date.UTC( + parseInt(year, 10), + parseInt(month, 10) - 1, + parseInt(day, 10), + parseInt(hour, 10), + parseInt(minute, 10), + parseInt(second, 10) + ) + ); + } + return new Date( + parseInt(year, 10), + parseInt(month, 10) - 1, + parseInt(day, 10), + parseInt(hour, 10), + parseInt(minute, 10), + parseInt(second, 10) + ); + } + + return null; +} + +/** + * Extract a property value from an iCal VEVENT block. + * Handles folded lines (continuation lines starting with space/tab). + */ +function extractProperty(block: string, property: string): string { + // Match property with optional parameters (e.g., DTSTART;TZID=...:value) + const regex = new RegExp(`^${property}[;:](.*)$`, 'im'); + const match = block.match(regex); + if (!match) return ''; + + const value = match[1]; + + // If the property had parameters (;PARAM=value:actualValue), extract just the value + if (property === 'DTSTART' || property === 'DTEND') { + // Keep the full string — parseIcalDate handles TZID prefix + return value.trim(); + } + + return value.trim(); +} + +/** + * Parse VEVENT blocks from iCal text. + */ +function parseVEvents( + icalText: string +): Array<{ summary: string; start: string; end: string; location: string }> { + const events: Array<{ summary: string; start: string; end: string; location: string }> = []; + + // Unfold continuation lines (RFC 5545: lines starting with space/tab are continuations) + const unfolded = icalText.replace(/\r?\n[ \t]/g, ''); + + const eventRegex = /BEGIN:VEVENT([\s\S]*?)END:VEVENT/gi; + let match: RegExpExecArray | null; + + while ((match = eventRegex.exec(unfolded)) !== null) { + const block = match[1]; + const summary = extractProperty(block, 'SUMMARY'); + const dtStart = extractProperty(block, 'DTSTART'); + const dtEnd = extractProperty(block, 'DTEND'); + const location = extractProperty(block, 'LOCATION'); + + const startDate = parseIcalDate(dtStart); + if (!startDate) continue; + + const endDate = parseIcalDate(dtEnd); + + events.push({ + summary: summary || 'Untitled Event', + start: startDate.toISOString(), + end: endDate ? endDate.toISOString() : startDate.toISOString(), + location: location || '' + }); + } + + return events; +} + +/** + * Fetch iCal text from a URL. + */ +async function fetchIcalText(url: string): Promise { + const cached = getCached(url); + if (cached) return cached; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { + 'User-Agent': 'WebAppLauncher/1.0', + Accept: 'text/calendar, application/ics' + } + }); + + if (!response.ok) { + throw new Error(`Calendar source returned ${response.status}`); + } + + const text = await response.text(); + setCache(url, text); + return text; + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + throw new Error('Calendar request timed out'); + } + throw err; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Fetch and parse events from multiple iCal URLs, merged and sorted by start time. + */ +export async function fetchCalendarEvents( + sources: readonly CalendarSource[], + daysAhead?: number +): Promise { + const days = daysAhead ?? DEFAULT_DAYS_AHEAD; + const now = new Date(); + const cutoff = new Date(now.getTime() + days * 24 * 60 * 60 * 1000); + + const allEvents: CalendarEvent[] = []; + + const results = await Promise.allSettled( + sources.map(async (source) => { + const icalText = await fetchIcalText(source.url); + const events = parseVEvents(icalText); + + return events + .filter((event) => { + const start = new Date(event.start); + return start >= now && start <= cutoff; + }) + .map((event) => ({ + summary: event.summary, + start: event.start, + end: event.end, + location: event.location || null, + calendarLabel: source.label ?? 'Calendar', + calendarColor: source.color ?? '#6366f1' + })); + }) + ); + + for (const result of results) { + if (result.status === 'fulfilled') { + allEvents.push(...result.value); + } + } + + // Sort by start time ascending + return [...allEvents].sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); +} + +/** + * Clear the calendar cache. + */ +export function clearCache(): void { + cache.clear(); +} diff --git a/src/lib/server/services/cameraService.ts b/src/lib/server/services/cameraService.ts new file mode 100644 index 0000000..cbb82ab --- /dev/null +++ b/src/lib/server/services/cameraService.ts @@ -0,0 +1,149 @@ +/** + * Camera/Stream proxy service — proxies image requests to camera URLs. + * Includes SSRF protection to reject private IP ranges. + */ + +const FETCH_TIMEOUT_MS = 10_000; +const RATE_LIMIT_INTERVAL_MS = 5_000; // Max 1 request per 5s per URL + +const lastFetchTimes = new Map(); + +/** + * Check if a hostname resolves to a private/reserved IP range. + * Prevents SSRF attacks by blocking requests to internal networks. + */ +function isPrivateOrReservedHost(hostname: string): boolean { + // Block obvious private hostnames + if ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + hostname === '0.0.0.0' + ) { + return true; + } + + // Check IPv4 private ranges + const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (ipv4Match) { + const [, a, b] = ipv4Match.map(Number); + + // 10.0.0.0/8 + if (a === 10) return true; + // 172.16.0.0/12 + if (a === 172 && b >= 16 && b <= 31) return true; + // 192.168.0.0/16 + if (a === 192 && b === 168) return true; + // 127.0.0.0/8 + if (a === 127) return true; + // 169.254.0.0/16 (link-local) + if (a === 169 && b === 254) return true; + // 0.0.0.0/8 + if (a === 0) return true; + } + + // Block IPv6 private ranges (simplified check) + if (hostname.startsWith('fe80:') || hostname.startsWith('fc') || hostname.startsWith('fd')) { + return true; + } + + return false; +} + +/** + * Validate a URL for camera proxying. + * Only allows http/https and rejects private IPs. + */ +export function validateStreamUrl(urlStr: string): { valid: boolean; error?: string } { + let parsed: URL; + try { + parsed = new URL(urlStr); + } catch { + return { valid: false, error: 'Invalid URL format' }; + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { valid: false, error: 'Only http and https protocols are allowed' }; + } + + if (isPrivateOrReservedHost(parsed.hostname)) { + return { valid: false, error: 'Requests to private/reserved IP ranges are not allowed' }; + } + + return { valid: true }; +} + +/** + * Check rate limit for a given URL. + */ +function checkRateLimit(url: string): boolean { + const lastFetch = lastFetchTimes.get(url); + if (!lastFetch) return true; + return Date.now() - lastFetch >= RATE_LIMIT_INTERVAL_MS; +} + +function recordFetch(url: string): void { + lastFetchTimes.set(url, Date.now()); +} + +export interface CameraSnapshot { + readonly buffer: Buffer; + readonly contentType: string; +} + +/** + * Fetch a snapshot image from a camera URL. + * Proxies the HTTP request and returns the image buffer. + */ +export async function fetchSnapshot(url: string): Promise { + // Validate URL + const validation = validateStreamUrl(url); + if (!validation.valid) { + throw new Error(validation.error ?? 'Invalid stream URL'); + } + + // Rate limit check + if (!checkRateLimit(url)) { + throw new Error('Rate limited: please wait before requesting this camera again'); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + recordFetch(url); + + const response = await fetch(url, { + signal: controller.signal, + headers: { 'User-Agent': 'WebAppLauncher/1.0' } + }); + + if (!response.ok) { + throw new Error(`Camera returned ${response.status}`); + } + + const contentType = response.headers.get('content-type') ?? 'image/jpeg'; + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + if (buffer.length === 0) { + throw new Error('Camera returned empty response'); + } + + return { buffer, contentType }; + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + throw new Error('Camera request timed out'); + } + throw err; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Clear the rate limit tracking. + */ +export function clearRateLimits(): void { + lastFetchTimes.clear(); +} diff --git a/src/lib/server/services/discoveryService.ts b/src/lib/server/services/discoveryService.ts index 100fdb9..109a504 100644 --- a/src/lib/server/services/discoveryService.ts +++ b/src/lib/server/services/discoveryService.ts @@ -123,8 +123,9 @@ export async function discoverDocker(socketPath: string): Promise<{ continue; // Skip containers without accessible URLs } - const description = container.Labels['org.opencontainers.image.description'] - ?? `Docker container: ${container.Image}`; + const description = + container.Labels['org.opencontainers.image.description'] ?? + `Docker container: ${container.Image}`; services.push({ name, @@ -187,9 +188,7 @@ export async function discoverTraefik(apiUrl: string): Promise<{ const host = extractHostFromRule(router.rule); if (!host) continue; - const isSecure = router.entryPoints?.some( - (ep) => ep === 'websecure' || ep === 'https' - ); + const isSecure = router.entryPoints?.some((ep) => ep === 'websecure' || ep === 'https'); const frontendUrl = `${isSecure ? 'https' : 'http'}://${host}`; // Derive a clean name from the router name (strip @provider suffix) diff --git a/src/lib/server/services/favoriteService.ts b/src/lib/server/services/favoriteService.ts new file mode 100644 index 0000000..cdef5a5 --- /dev/null +++ b/src/lib/server/services/favoriteService.ts @@ -0,0 +1,83 @@ +import { prisma } from '../prisma.js'; + +/** + * Get user's favorite apps, ordered by position. + */ +export async function getUserFavorites(userId: string) { + return prisma.userFavorite.findMany({ + where: { userId }, + orderBy: { order: 'asc' }, + include: { + app: { + include: { + statuses: { + orderBy: { checkedAt: 'desc' }, + take: 1 + } + } + } + } + }); +} + +/** + * Add an app to user's favorites (append to end). + */ +export async function addFavorite(userId: string, appId: string) { + // Check if already favorited + const existing = await prisma.userFavorite.findUnique({ + where: { userId_appId: { userId, appId } } + }); + if (existing) { + throw new Error('App is already in favorites'); + } + + // Get the next order value + const maxFav = await prisma.userFavorite.findFirst({ + where: { userId }, + orderBy: { order: 'desc' }, + select: { order: true } + }); + const nextOrder = (maxFav?.order ?? -1) + 1; + + return prisma.userFavorite.create({ + data: { + userId, + appId, + order: nextOrder + }, + include: { + app: true + } + }); +} + +/** + * Remove an app from user's favorites. + */ +export async function removeFavorite(userId: string, appId: string) { + const existing = await prisma.userFavorite.findUnique({ + where: { userId_appId: { userId, appId } } + }); + if (!existing) { + throw new Error('App is not in favorites'); + } + + await prisma.userFavorite.delete({ + where: { userId_appId: { userId, appId } } + }); +} + +/** + * Reorder user's favorites by setting order based on array position. + */ +export async function reorderFavorites(userId: string, favoriteIds: string[]) { + const updates = favoriteIds.map((id, index) => + prisma.userFavorite.update({ + where: { id }, + data: { order: index } + }) + ); + + return prisma.$transaction(updates); +} diff --git a/src/lib/server/services/groupService.ts b/src/lib/server/services/groupService.ts index 6ef51e3..a93e706 100644 --- a/src/lib/server/services/groupService.ts +++ b/src/lib/server/services/groupService.ts @@ -118,8 +118,6 @@ export async function getGroupMembers(groupId: string) { export async function addUserToDefaultGroups(userId: string) { const defaultGroups = await findDefaultGroups(); - const results = await Promise.all( - defaultGroups.map((group) => addUser(group.id, userId)) - ); + const results = await Promise.all(defaultGroups.map((group) => addUser(group.id, userId))); return results; } diff --git a/src/lib/server/services/importService.ts b/src/lib/server/services/importService.ts index 753701d..b590d9a 100644 --- a/src/lib/server/services/importService.ts +++ b/src/lib/server/services/importService.ts @@ -12,7 +12,9 @@ export interface ImportResult { readonly settingsUpdated: boolean; } -export function validateImportData(data: unknown): { success: true; data: ExportData } | { success: false; errors: string[] } { +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}`); @@ -206,10 +208,13 @@ export async function importData(data: ExportData, mode: ImportMode): Promise 0) { await tx.systemSettings.upsert({ diff --git a/src/lib/server/services/metricService.ts b/src/lib/server/services/metricService.ts new file mode 100644 index 0000000..65967ca --- /dev/null +++ b/src/lib/server/services/metricService.ts @@ -0,0 +1,227 @@ +/** + * Metric/Counter service — fetches single numeric values from various sources. + * Supports static values, JSON endpoints with dot-path extraction, and Prometheus queries. + * Tracks previous values for trend calculation. + */ + +const DEFAULT_CACHE_TTL_MS = 60_000; // 1 minute +const FETCH_TIMEOUT_MS = 10_000; + +interface CacheEntry { + readonly data: MetricResult; + readonly expiresAt: number; +} + +export interface MetricResult { + readonly value: number; + readonly previousValue: number | null; + readonly trend: 'up' | 'down' | 'flat'; + readonly unit: string; + readonly fetchedAt: string; +} + +export type MetricSource = 'static' | 'json' | 'prometheus'; + +const cache = new Map(); +const previousValues = new Map(); + +function getCached(key: string): MetricResult | null { + const entry = cache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + cache.delete(key); + return null; + } + return entry.data; +} + +function setCache(key: string, data: MetricResult, ttlMs: number): void { + cache.set(key, { + data, + expiresAt: Date.now() + ttlMs + }); +} + +/** + * Traverse an object using dot-notation path (e.g., "data.cpu.percent"). + */ +function extractByPath(obj: unknown, path: string): unknown { + const parts = path.split('.'); + let current: unknown = obj; + + for (const part of parts) { + if (current === null || current === undefined) return undefined; + if (typeof current !== 'object') return undefined; + + // Handle array indexing: "items.0.value" + const index = parseInt(part, 10); + if (Array.isArray(current) && !isNaN(index)) { + current = current[index]; + } else { + current = (current as Record)[part]; + } + } + + return current; +} + +/** + * Calculate trend based on current and previous values. + */ +function calculateTrend(current: number, previous: number | null): 'up' | 'down' | 'flat' { + if (previous === null) return 'flat'; + if (current > previous) return 'up'; + if (current < previous) return 'down'; + return 'flat'; +} + +async function fetchWithTimeout(url: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { 'User-Agent': 'WebAppLauncher/1.0' } + }); + return response; + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + throw new Error('Metric request timed out'); + } + throw err; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Fetch a metric value from a JSON HTTP endpoint, extracting via dot-path. + */ +export async function fetchHttpMetric(url: string, jsonPath: string): Promise { + const response = await fetchWithTimeout(url); + + if (!response.ok) { + throw new Error(`Metric endpoint returned ${response.status}`); + } + + const data = await response.json(); + const extracted = extractByPath(data, jsonPath); + + if (typeof extracted === 'number') return extracted; + if (typeof extracted === 'string') { + const parsed = parseFloat(extracted); + if (!isNaN(parsed)) return parsed; + } + + throw new Error(`Could not extract numeric value at path "${jsonPath}"`); +} + +/** + * Fetch a metric value from Prometheus instant query API. + */ +export async function fetchPrometheusMetric(url: string, query: string): Promise { + const baseUrl = url.replace(/\/$/, ''); + const endpoint = `${baseUrl}/api/v1/query?query=${encodeURIComponent(query)}`; + + const response = await fetchWithTimeout(endpoint); + + if (!response.ok) { + throw new Error(`Prometheus returned ${response.status}`); + } + + const data = (await response.json()) as Record; + + if (data.status !== 'success') { + throw new Error('Prometheus query failed'); + } + + const resultData = data.data as Record; + const results = resultData?.result as Array> | undefined; + + if (results && results.length > 0) { + const value = results[0].value as [number, string] | undefined; + if (value && value.length === 2) { + return parseFloat(value[1]) || 0; + } + } + + throw new Error('No result from Prometheus query'); +} + +/** + * Get a static metric value (passthrough). + */ +export function getStaticMetric(value: string): number { + const parsed = parseFloat(value); + if (isNaN(parsed)) { + throw new Error(`Invalid static metric value: "${value}"`); + } + return parsed; +} + +/** + * Fetch a metric from any supported source type. + */ +export async function fetchMetric(options: { + readonly source: MetricSource; + readonly value?: string; + readonly url?: string; + readonly jsonPath?: string; + readonly query?: string; + readonly unit?: string; + readonly refreshInterval?: number; +}): Promise { + const cacheKey = `${options.source}:${options.url ?? ''}:${options.jsonPath ?? ''}:${options.query ?? ''}:${options.value ?? ''}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + let numericValue: number; + + switch (options.source) { + case 'static': { + if (!options.value) throw new Error('Static metric requires a value'); + numericValue = getStaticMetric(options.value); + break; + } + case 'json': { + if (!options.url) throw new Error('JSON metric requires a url'); + if (!options.jsonPath) throw new Error('JSON metric requires a jsonPath'); + numericValue = await fetchHttpMetric(options.url, options.jsonPath); + break; + } + case 'prometheus': { + if (!options.url) throw new Error('Prometheus metric requires a url'); + if (!options.query) throw new Error('Prometheus metric requires a query'); + numericValue = await fetchPrometheusMetric(options.url, options.query); + break; + } + default: + throw new Error(`Unknown metric source: ${options.source}`); + } + + const prevValue = previousValues.get(cacheKey) ?? null; + const trend = calculateTrend(numericValue, prevValue); + previousValues.set(cacheKey, numericValue); + + const result: MetricResult = { + value: numericValue, + previousValue: prevValue, + trend, + unit: options.unit ?? '', + fetchedAt: new Date().toISOString() + }; + + const ttl = options.refreshInterval ? options.refreshInterval * 1000 : DEFAULT_CACHE_TTL_MS; + setCache(cacheKey, result, ttl); + + return result; +} + +/** + * Clear the metric cache and previous values. + */ +export function clearCache(): void { + cache.clear(); + previousValues.clear(); +} diff --git a/src/lib/server/services/notificationService.ts b/src/lib/server/services/notificationService.ts new file mode 100644 index 0000000..c0fd36b --- /dev/null +++ b/src/lib/server/services/notificationService.ts @@ -0,0 +1,315 @@ +import { prisma } from '../prisma.js'; +import { NotificationType } from '$lib/utils/constants.js'; + +// --- Channel Management --- + +export async function createChannel( + userId: string, + type: string, + config: string, + enabled: boolean = true +) { + return prisma.notificationChannel.create({ + data: { userId, type, config, enabled } + }); +} + +export async function updateChannel( + id: string, + userId: string, + data: { type?: string; config?: string; enabled?: boolean } +) { + const channel = await prisma.notificationChannel.findUnique({ where: { id } }); + if (!channel || channel.userId !== userId) { + throw new Error('Notification channel not found'); + } + + const updateData: Record = {}; + if (data.type !== undefined) updateData.type = data.type; + if (data.config !== undefined) updateData.config = data.config; + if (data.enabled !== undefined) updateData.enabled = data.enabled; + + return prisma.notificationChannel.update({ + where: { id }, + data: updateData + }); +} + +export async function deleteChannel(id: string, userId: string) { + const channel = await prisma.notificationChannel.findUnique({ where: { id } }); + if (!channel || channel.userId !== userId) { + throw new Error('Notification channel not found'); + } + + await prisma.notificationChannel.delete({ where: { id } }); +} + +export async function listChannels(userId: string) { + return prisma.notificationChannel.findMany({ + where: { userId }, + orderBy: { createdAt: 'asc' } + }); +} + +export async function getChannelById(id: string, userId: string) { + const channel = await prisma.notificationChannel.findUnique({ where: { id } }); + if (!channel || channel.userId !== userId) { + throw new Error('Notification channel not found'); + } + return channel; +} + +// --- Notification Dispatchers --- + +interface DiscordConfig { + readonly webhookUrl: string; +} + +interface SlackConfig { + readonly webhookUrl: string; +} + +interface TelegramConfig { + readonly botToken: string; + readonly chatId: string; +} + +interface HttpConfig { + readonly url: string; + readonly headers?: Record; +} + +async function sendDiscord(webhookUrl: string, message: string): Promise { + try { + await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + embeds: [ + { + title: 'Web App Launcher Notification', + description: message, + color: 0x6366f1, + timestamp: new Date().toISOString() + } + ] + }) + }); + } catch { + // Fire-and-forget: swallow errors + } +} + +async function sendSlack(webhookUrl: string, message: string): Promise { + try { + await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Web App Launcher*\n${message}` + } + } + ] + }) + }); + } catch { + // Fire-and-forget: swallow errors + } +} + +async function sendTelegram(botToken: string, chatId: string, message: string): Promise { + try { + await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: message, + parse_mode: 'HTML' + }) + }); + } catch { + // Fire-and-forget: swallow errors + } +} + +async function sendHttp( + url: string, + payload: unknown, + headers?: Record +): Promise { + try { + await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(headers ?? {}) + }, + body: JSON.stringify(payload) + }); + } catch { + // Fire-and-forget: swallow errors + } +} + +/** + * Dispatch a message to a single notification channel. + */ +async function dispatchToChannel( + channel: { type: string; config: string }, + message: string +): Promise { + let config: unknown; + try { + config = JSON.parse(channel.config); + } catch { + return; // Invalid config — skip + } + + switch (channel.type) { + case NotificationType.DISCORD: { + const dc = config as DiscordConfig; + if (dc.webhookUrl) { + await sendDiscord(dc.webhookUrl, message); + } + break; + } + case NotificationType.SLACK: { + const sc = config as SlackConfig; + if (sc.webhookUrl) { + await sendSlack(sc.webhookUrl, message); + } + break; + } + case NotificationType.TELEGRAM: { + const tc = config as TelegramConfig; + if (tc.botToken && tc.chatId) { + await sendTelegram(tc.botToken, tc.chatId, message); + } + break; + } + case NotificationType.HTTP: { + const hc = config as HttpConfig; + if (hc.url) { + await sendHttp(hc.url, { message, timestamp: new Date().toISOString() }, hc.headers); + } + break; + } + } +} + +// --- Send Notification --- + +/** + * Send a notification to all enabled channels for a user. + * Creates a Notification record and dispatches to all channels. + */ +export async function sendNotification( + userId: string, + appId: string | null, + event: string, + message: string +) { + // Create notification record + const notification = await prisma.notification.create({ + data: { + userId, + appId, + event, + message + } + }); + + // Get all enabled channels for the user and dispatch + const channels = await prisma.notificationChannel.findMany({ + where: { userId, enabled: true } + }); + + // Fire-and-forget: dispatch to all channels in parallel + Promise.allSettled(channels.map((ch) => dispatchToChannel(ch, message))).catch(() => { + // Swallow any unexpected errors + }); + + return notification; +} + +/** + * Send a notification to all users that have notification channels set up. + * Used by the healthcheck scheduler for broadcast status change events. + */ +export async function broadcastNotification(appId: string, event: string, message: string) { + // Find all users that have at least one enabled notification channel + const channels = await prisma.notificationChannel.findMany({ + where: { enabled: true }, + select: { userId: true } + }); + + const uniqueUserIds = [...new Set(channels.map((ch) => ch.userId))]; + + // Create notifications and dispatch for each user + await Promise.allSettled( + uniqueUserIds.map((userId) => sendNotification(userId, appId, event, message)) + ); +} + +/** + * Send a test notification to a specific channel. + */ +export async function sendTestNotification(channelId: string, userId: string): Promise { + const channel = await getChannelById(channelId, userId); + const message = 'This is a test notification from Web App Launcher.'; + await dispatchToChannel(channel, message); +} + +// --- Notification Queries --- + +export async function getNotifications( + userId: string, + options?: { unreadOnly?: boolean; limit?: number; offset?: number } +) { + const where: Record = { userId }; + if (options?.unreadOnly) { + where.readAt = null; + } + + const [notifications, total] = await Promise.all([ + prisma.notification.findMany({ + where, + orderBy: { sentAt: 'desc' }, + take: options?.limit ?? 50, + skip: options?.offset ?? 0, + include: { + app: { + select: { id: true, name: true, icon: true } + } + } + }), + prisma.notification.count({ where }) + ]); + + return { notifications, total }; +} + +export async function markAsRead(notificationId: string, userId: string) { + const notification = await prisma.notification.findUnique({ where: { id: notificationId } }); + if (!notification || notification.userId !== userId) { + throw new Error('Notification not found'); + } + + return prisma.notification.update({ + where: { id: notificationId }, + data: { readAt: new Date() } + }); +} + +export async function markAllAsRead(userId: string) { + await prisma.notification.updateMany({ + where: { userId, readAt: null }, + data: { readAt: new Date() } + }); +} diff --git a/src/lib/server/services/oauthService.ts b/src/lib/server/services/oauthService.ts index 27d3b69..58c0c77 100644 --- a/src/lib/server/services/oauthService.ts +++ b/src/lib/server/services/oauthService.ts @@ -68,11 +68,7 @@ async function getOIDCConfig(): Promise { } const issuerUrl = deriveIssuerUrl(oauthConfig.discoveryUrl); - const config = await client.discovery( - issuerUrl, - oauthConfig.clientId, - oauthConfig.clientSecret - ); + const config = await client.discovery(issuerUrl, oauthConfig.clientId, oauthConfig.clientSecret); cachedConfig = config; cachedConfigKey = cacheKey; @@ -157,7 +153,9 @@ export async function handleCallback( const email = (userInfo.email as string) || ''; if (!email) { - throw new Error('OAuth provider did not return an email address. Ensure the "email" scope is configured.'); + throw new Error( + 'OAuth provider did not return an email address. Ensure the "email" scope is configured.' + ); } return { diff --git a/src/lib/server/services/onboardingService.ts b/src/lib/server/services/onboardingService.ts new file mode 100644 index 0000000..7fae424 --- /dev/null +++ b/src/lib/server/services/onboardingService.ts @@ -0,0 +1,73 @@ +import { prisma } from '../prisma.js'; +import { DEFAULTS } from '$lib/utils/constants.js'; + +/** + * Check whether the onboarding wizard should be shown. + * Returns true if no users exist OR SystemSettings.onboardingComplete is false. + */ +export async function isOnboardingNeeded(): Promise { + const userCount = await prisma.user.count(); + if (userCount === 0) { + return true; + } + + try { + const settings = await prisma.systemSettings.findUnique({ + where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }, + select: { onboardingComplete: true } + }); + + // If no settings record exists yet, onboarding is needed + if (!settings) { + return true; + } + + return !settings.onboardingComplete; + } catch { + // If SystemSettings table doesn't exist yet, onboarding is needed + return true; + } +} + +/** + * Mark onboarding as complete in SystemSettings. + */ +export async function completeOnboarding(): Promise { + await prisma.systemSettings.upsert({ + where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }, + update: { onboardingComplete: true }, + create: { + id: DEFAULTS.SYSTEM_SETTINGS_ID, + onboardingComplete: true + } + }); +} + +/** + * Get the current onboarding status. + */ +export async function getOnboardingStatus(): Promise<{ + readonly needed: boolean; + readonly hasUsers: boolean; + readonly onboardingComplete: boolean; +}> { + const userCount = await prisma.user.count(); + const hasUsers = userCount > 0; + + let onboardingComplete = false; + try { + const settings = await prisma.systemSettings.findUnique({ + where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }, + select: { onboardingComplete: true } + }); + onboardingComplete = settings?.onboardingComplete ?? false; + } catch { + onboardingComplete = false; + } + + return { + needed: !hasUsers || !onboardingComplete, + hasUsers, + onboardingComplete + }; +} diff --git a/src/lib/server/services/permissionService.ts b/src/lib/server/services/permissionService.ts index 2b39de3..1a80917 100644 --- a/src/lib/server/services/permissionService.ts +++ b/src/lib/server/services/permissionService.ts @@ -74,8 +74,7 @@ export async function checkPermission( return permLevel > highestScore ? perm.level : highest; }, groupPermissions[0].level); - const hasAccess = - PERMISSION_HIERARCHY[highestLevel] >= PERMISSION_HIERARCHY[requiredLevel]; + const hasAccess = PERMISSION_HIERARCHY[highestLevel] >= PERMISSION_HIERARCHY[requiredLevel]; return { hasPermission: hasAccess, effectiveLevel: highestLevel as PermissionCheckResult['effectiveLevel'], @@ -137,20 +136,14 @@ export async function getPermissionsForEntity(entityType: EntityType, entityId: }); } -export async function getPermissionsForTarget( - targetType: TargetTypeType, - targetId: string -) { +export async function getPermissionsForTarget(targetType: TargetTypeType, targetId: string) { return prisma.permission.findMany({ where: { targetType, targetId }, orderBy: { createdAt: 'asc' } }); } -export async function removeAllPermissionsForEntity( - entityType: EntityType, - entityId: string -) { +export async function removeAllPermissionsForEntity(entityType: EntityType, entityId: string) { await prisma.permission.deleteMany({ where: { entityType, entityId } }); diff --git a/src/lib/server/services/recentAppsService.ts b/src/lib/server/services/recentAppsService.ts new file mode 100644 index 0000000..e2d5d63 --- /dev/null +++ b/src/lib/server/services/recentAppsService.ts @@ -0,0 +1,58 @@ +import { prisma } from '../prisma.js'; + +/** + * Record a click on an app for a user. + */ +export async function recordClick(userId: string, appId: string) { + return prisma.appClick.create({ + data: { + userId, + appId + } + }); +} + +/** + * Get recent unique apps for a user, most recent first. + */ +export async function getRecentApps(userId: string, limit: number = 10) { + // Get distinct most-recent clicks per app + const clicks = await prisma.appClick.findMany({ + where: { userId }, + orderBy: { clickedAt: 'desc' }, + include: { + app: { + include: { + statuses: { + orderBy: { checkedAt: 'desc' }, + take: 1 + } + } + } + } + }); + + // Deduplicate by appId, keeping the most recent click per app + const seen = new Set(); + const uniqueClicks = []; + for (const click of clicks) { + if (!seen.has(click.appId)) { + seen.add(click.appId); + uniqueClicks.push(click); + } + if (uniqueClicks.length >= limit) { + break; + } + } + + return uniqueClicks; +} + +/** + * Clear all click history for a user. + */ +export async function clearHistory(userId: string) { + await prisma.appClick.deleteMany({ + where: { userId } + }); +} diff --git a/src/lib/server/services/rssFeedService.ts b/src/lib/server/services/rssFeedService.ts new file mode 100644 index 0000000..0665151 --- /dev/null +++ b/src/lib/server/services/rssFeedService.ts @@ -0,0 +1,189 @@ +/** + * RSS/Atom feed service — fetches and parses RSS/Atom feeds. + * Uses lightweight XML parsing without heavy dependencies. + */ + +const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes +const FETCH_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_ITEMS = 10; + +interface CacheEntry { + readonly data: readonly FeedItem[]; + readonly expiresAt: number; +} + +export interface FeedItem { + readonly title: string; + readonly link: string; + readonly pubDate: string; + readonly summary: string; +} + +const cache = new Map(); + +function getCached(key: string): readonly FeedItem[] | null { + const entry = cache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + cache.delete(key); + return null; + } + return entry.data; +} + +function setCache(key: string, data: readonly FeedItem[]): void { + cache.set(key, { + data, + expiresAt: Date.now() + CACHE_TTL_MS + }); +} + +/** + * Extract text content between XML tags. + */ +function extractTag(xml: string, tag: string): string { + // Handle CDATA sections + const cdataPattern = new RegExp( + `<${tag}[^>]*>\\s*\\s*`, + 'i' + ); + const cdataMatch = xml.match(cdataPattern); + if (cdataMatch) return cdataMatch[1].trim(); + + // Handle regular content + const pattern = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)`, 'i'); + const match = xml.match(pattern); + if (match) return match[1].trim(); + + return ''; +} + +/** + * Extract href from Atom link tag. + */ +function extractAtomLink(entryXml: string): string { + // Look for link with rel="alternate" or no rel + const altMatch = entryXml.match(/]*rel=["']alternate["'][^>]*href=["']([^"']+)["']/i); + if (altMatch) return altMatch[1]; + + const hrefMatch = entryXml.match(/]*href=["']([^"']+)["']/i); + if (hrefMatch) return hrefMatch[1]; + + return ''; +} + +/** + * Parse RSS 2.0 feed XML. + */ +function parseRss(xml: string, maxItems: number): readonly FeedItem[] { + const items: FeedItem[] = []; + const itemRegex = /([\s\S]*?)<\/item>/gi; + let match: RegExpExecArray | null; + + while ((match = itemRegex.exec(xml)) !== null && items.length < maxItems) { + const itemXml = match[1]; + items.push({ + title: extractTag(itemXml, 'title') || 'Untitled', + link: extractTag(itemXml, 'link') || '', + pubDate: extractTag(itemXml, 'pubDate') || '', + summary: extractTag(itemXml, 'description') || '' + }); + } + + return items; +} + +/** + * Parse Atom feed XML. + */ +function parseAtom(xml: string, maxItems: number): readonly FeedItem[] { + const items: FeedItem[] = []; + const entryRegex = /([\s\S]*?)<\/entry>/gi; + let match: RegExpExecArray | null; + + while ((match = entryRegex.exec(xml)) !== null && items.length < maxItems) { + const entryXml = match[1]; + items.push({ + title: extractTag(entryXml, 'title') || 'Untitled', + link: extractAtomLink(entryXml) || '', + pubDate: extractTag(entryXml, 'published') || extractTag(entryXml, 'updated') || '', + summary: extractTag(entryXml, 'summary') || extractTag(entryXml, 'content') || '' + }); + } + + return items; +} + +/** + * Strip HTML tags from a string (for summaries). + */ +function stripHtml(html: string): string { + return html + .replace(/<[^>]*>/g, '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .trim(); +} + +/** + * Fetch and parse an RSS or Atom feed from a URL. + */ +export async function fetchFeed(feedUrl: string, maxItems?: number): Promise { + const limit = maxItems ?? DEFAULT_MAX_ITEMS; + const cacheKey = `${feedUrl}:${limit}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(feedUrl, { + signal: controller.signal, + headers: { + 'User-Agent': 'WebAppLauncher/1.0', + Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml' + } + }); + + if (!response.ok) { + throw new Error(`Feed returned ${response.status}`); + } + + const xml = await response.text(); + + // Detect feed type and parse + let items: readonly FeedItem[]; + if (xml.includes(' ({ + ...item, + summary: stripHtml(item.summary).substring(0, 500) + })); + + setCache(cacheKey, cleanItems); + return cleanItems; + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + throw new Error('Feed request timed out'); + } + throw err; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Clear the RSS feed cache. + */ +export function clearCache(): void { + cache.clear(); +} diff --git a/src/lib/server/services/systemStatsService.ts b/src/lib/server/services/systemStatsService.ts new file mode 100644 index 0000000..acbef66 --- /dev/null +++ b/src/lib/server/services/systemStatsService.ts @@ -0,0 +1,217 @@ +/** + * System stats service — fetches metrics from various sources using an adapter pattern. + * Supports Glances, Prometheus, and custom JSON endpoints. + */ + +const DEFAULT_CACHE_TTL_MS = 30_000; // 30 seconds +const FETCH_TIMEOUT_MS = 10_000; + +interface CacheEntry { + readonly data: readonly SystemMetric[]; + readonly expiresAt: number; +} + +export interface SystemMetric { + readonly metric: string; + readonly value: number; + readonly unit: string; +} + +const cache = new Map(); + +function getCached(key: string): readonly SystemMetric[] | null { + const entry = cache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + cache.delete(key); + return null; + } + return entry.data; +} + +function setCache(key: string, data: readonly SystemMetric[], ttlMs: number): void { + cache.set(key, { + data, + expiresAt: Date.now() + ttlMs + }); +} + +async function fetchWithTimeout(url: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { 'User-Agent': 'WebAppLauncher/1.0' } + }); + + if (!response.ok) { + throw new Error(`Source returned ${response.status}`); + } + + return await response.json(); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + throw new Error('System stats request timed out'); + } + throw err; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Glances adapter — fetches from Glances REST API. + * Expects endpoints like /api/3/cpu, /api/3/mem, /api/3/fs + */ +async function fetchGlancesMetrics( + sourceUrl: string, + metrics: readonly string[] +): Promise { + const results: SystemMetric[] = []; + + for (const metric of metrics) { + try { + const endpoint = `${sourceUrl.replace(/\/$/, '')}/api/3/${metric}`; + const data = await fetchWithTimeout(endpoint); + + if (metric === 'cpu' && typeof data === 'object' && data !== null) { + const cpuData = data as Record; + const total = typeof cpuData.total === 'number' ? cpuData.total : 0; + results.push({ metric: 'cpu', value: total, unit: '%' }); + } else if (metric === 'mem' && typeof data === 'object' && data !== null) { + const memData = data as Record; + const percent = typeof memData.percent === 'number' ? memData.percent : 0; + results.push({ metric: 'memory', value: percent, unit: '%' }); + } else if (metric === 'fs' && Array.isArray(data)) { + for (const disk of data) { + const d = disk as Record; + const percent = typeof d.percent === 'number' ? d.percent : 0; + const mnt = typeof d.mnt_point === 'string' ? d.mnt_point : '/'; + results.push({ metric: `disk:${mnt}`, value: percent, unit: '%' }); + } + } + } catch { + // Skip unreachable metric endpoints + results.push({ metric, value: -1, unit: 'error' }); + } + } + + return results; +} + +/** + * Prometheus adapter — queries Prometheus instant query API. + */ +async function fetchPrometheusMetrics( + sourceUrl: string, + metrics: readonly string[] +): Promise { + const results: SystemMetric[] = []; + const baseUrl = sourceUrl.replace(/\/$/, ''); + + for (const query of metrics) { + try { + const endpoint = `${baseUrl}/api/v1/query?query=${encodeURIComponent(query)}`; + const data = (await fetchWithTimeout(endpoint)) as Record; + + if (data.status === 'success') { + const result = data.data as Record; + const resultArray = result?.result as Array> | undefined; + + if (resultArray && resultArray.length > 0) { + const value = resultArray[0].value as [number, string] | undefined; + if (value && value.length === 2) { + results.push({ + metric: query, + value: parseFloat(value[1]) || 0, + unit: '' + }); + continue; + } + } + } + + results.push({ metric: query, value: 0, unit: '' }); + } catch { + results.push({ metric: query, value: -1, unit: 'error' }); + } + } + + return results; +} + +/** + * Custom adapter — fetches from a generic JSON endpoint. + * Expects the response to be an object with metric names as keys and numeric values. + */ +async function fetchCustomMetrics( + sourceUrl: string, + metrics: readonly string[] +): Promise { + const results: SystemMetric[] = []; + + try { + const data = (await fetchWithTimeout(sourceUrl)) as Record; + + for (const metric of metrics) { + const value = data[metric]; + if (typeof value === 'number') { + results.push({ metric, value, unit: '' }); + } else { + results.push({ metric, value: 0, unit: '' }); + } + } + } catch { + for (const metric of metrics) { + results.push({ metric, value: -1, unit: 'error' }); + } + } + + return results; +} + +export type SourceType = 'glances' | 'prometheus' | 'custom'; + +/** + * Fetch system stats from the specified source. + */ +export async function fetchSystemStats( + sourceUrl: string, + sourceType: SourceType, + metrics: readonly string[], + refreshInterval?: number +): Promise { + const cacheKey = `${sourceType}:${sourceUrl}:${metrics.join(',')}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + let result: readonly SystemMetric[]; + + switch (sourceType) { + case 'glances': + result = await fetchGlancesMetrics(sourceUrl, metrics); + break; + case 'prometheus': + result = await fetchPrometheusMetrics(sourceUrl, metrics); + break; + case 'custom': + result = await fetchCustomMetrics(sourceUrl, metrics); + break; + default: + throw new Error(`Unknown source type: ${sourceType}`); + } + + const ttl = refreshInterval ? refreshInterval * 1000 : DEFAULT_CACHE_TTL_MS; + setCache(cacheKey, result, ttl); + + return result; +} + +/** + * Clear the system stats cache. + */ +export function clearCache(): void { + cache.clear(); +} diff --git a/src/lib/server/services/tagService.ts b/src/lib/server/services/tagService.ts new file mode 100644 index 0000000..f792d08 --- /dev/null +++ b/src/lib/server/services/tagService.ts @@ -0,0 +1,112 @@ +import { prisma } from '../prisma.js'; + +// --- Tag CRUD --- + +export async function findAll() { + return prisma.tag.findMany({ + orderBy: { name: 'asc' }, + include: { + _count: { select: { appTags: true } } + } + }); +} + +export async function findById(id: string) { + const tag = await prisma.tag.findUnique({ + where: { id }, + include: { + _count: { select: { appTags: true } } + } + }); + if (!tag) { + throw new Error(`Tag not found: ${id}`); + } + return tag; +} + +export async function create(name: string, color?: string | null) { + const existing = await prisma.tag.findUnique({ where: { name } }); + if (existing) { + throw new Error(`Tag already exists: ${name}`); + } + + return prisma.tag.create({ + data: { + name, + color: color ?? null + } + }); +} + +export async function update(id: string, data: { name?: string; color?: string | null }) { + await findById(id); + + const updateData: Record = {}; + if (data.name !== undefined) updateData.name = data.name; + if (data.color !== undefined) updateData.color = data.color; + + return prisma.tag.update({ + where: { id }, + data: updateData + }); +} + +export async function remove(id: string) { + await findById(id); + await prisma.tag.delete({ where: { id } }); +} + +// --- App-Tag Associations --- + +export async function addTagToApp(appId: string, tagId: string) { + const existing = await prisma.appTag.findUnique({ + where: { appId_tagId: { appId, tagId } } + }); + if (existing) { + throw new Error('Tag is already assigned to this app'); + } + + return prisma.appTag.create({ + data: { appId, tagId }, + include: { tag: true } + }); +} + +export async function removeTagFromApp(appId: string, tagId: string) { + const existing = await prisma.appTag.findUnique({ + where: { appId_tagId: { appId, tagId } } + }); + if (!existing) { + throw new Error('Tag is not assigned to this app'); + } + + await prisma.appTag.delete({ + where: { appId_tagId: { appId, tagId } } + }); +} + +export async function getTagsForApp(appId: string) { + const appTags = await prisma.appTag.findMany({ + where: { appId }, + include: { tag: true }, + orderBy: { tag: { name: 'asc' } } + }); + return appTags.map((at) => at.tag); +} + +export async function getAppsByTag(tagId: string) { + const appTags = await prisma.appTag.findMany({ + where: { tagId }, + include: { + app: { + include: { + statuses: { + orderBy: { checkedAt: 'desc' }, + take: 1 + } + } + } + } + }); + return appTags.map((at) => at.app); +} diff --git a/src/lib/server/services/templateService.ts b/src/lib/server/services/templateService.ts new file mode 100644 index 0000000..f62ad0b --- /dev/null +++ b/src/lib/server/services/templateService.ts @@ -0,0 +1,319 @@ +import { prisma } from '../prisma.js'; + +export interface TemplateSection { + readonly title: string; + readonly icon?: string | null; + readonly order?: number; +} + +export interface TemplateConfig { + readonly sections: readonly TemplateSection[]; +} + +export interface Template { + readonly id: string; + readonly name: string; + readonly description: string | null; + readonly icon: string | null; + readonly config: TemplateConfig; + readonly isBuiltin: boolean; + readonly createdById: string | null; + readonly createdAt: Date; +} + +/** + * Built-in templates that are always available, not stored in DB. + */ +const BUILTIN_TEMPLATES: readonly Template[] = [ + { + id: 'builtin-home-server', + name: 'Home Server', + description: + 'Typical home server dashboard with media, networking, storage, and monitoring sections', + icon: 'server', + config: { + sections: [ + { title: 'Media', icon: 'play-circle', order: 0 }, + { title: 'Networking', icon: 'network', order: 1 }, + { title: 'Storage', icon: 'hard-drive', order: 2 }, + { title: 'Monitoring', icon: 'activity', order: 3 } + ] + }, + isBuiltin: true, + createdById: null, + createdAt: new Date('2024-01-01') + }, + { + id: 'builtin-media-stack', + name: 'Media Stack', + description: 'Media management with streaming, downloads, and library management sections', + icon: 'film', + config: { + sections: [ + { title: 'Streaming', icon: 'tv', order: 0 }, + { title: 'Downloads', icon: 'download', order: 1 }, + { title: 'Management', icon: 'folder', order: 2 } + ] + }, + isBuiltin: true, + createdById: null, + createdAt: new Date('2024-01-01') + }, + { + id: 'builtin-dev-tools', + name: 'Dev Tools', + description: 'Developer-focused layout with Git, CI/CD, databases, and documentation sections', + icon: 'code', + config: { + sections: [ + { title: 'Git', icon: 'git-branch', order: 0 }, + { title: 'CI/CD', icon: 'rocket', order: 1 }, + { title: 'Databases', icon: 'database', order: 2 }, + { title: 'Docs', icon: 'book-open', order: 3 } + ] + }, + isBuiltin: true, + createdById: null, + createdAt: new Date('2024-01-01') + }, + { + id: 'builtin-monitoring', + name: 'Monitoring', + description: 'Infrastructure monitoring with metrics, logs, alerts, and status sections', + icon: 'activity', + config: { + sections: [ + { title: 'Metrics', icon: 'bar-chart-2', order: 0 }, + { title: 'Logs', icon: 'file-text', order: 1 }, + { title: 'Alerts', icon: 'bell', order: 2 }, + { title: 'Status', icon: 'check-circle', order: 3 } + ] + }, + isBuiltin: true, + createdById: null, + createdAt: new Date('2024-01-01') + } +] as const; + +/** + * Get all built-in templates. + */ +export function getBuiltinTemplates(): readonly Template[] { + return BUILTIN_TEMPLATES; +} + +/** + * Get user-created templates from DB. + */ +export async function getUserTemplates(userId?: string): Promise { + const where = userId ? { createdById: userId, isBuiltin: false } : { isBuiltin: false }; + + const dbTemplates = await prisma.boardTemplate.findMany({ + where, + orderBy: { createdAt: 'desc' } + }); + + return dbTemplates.map((t) => ({ + id: t.id, + name: t.name, + description: t.description, + icon: t.icon, + config: parseConfig(t.config), + isBuiltin: t.isBuiltin, + createdById: t.createdById, + createdAt: t.createdAt + })); +} + +/** + * Get all templates (builtin + user-created). + */ +export async function getAllTemplates(userId?: string): Promise { + const userTemplates = await getUserTemplates(userId); + return [...BUILTIN_TEMPLATES, ...userTemplates]; +} + +/** + * Get a single template by ID (checks builtins first, then DB). + */ +export async function getTemplateById(id: string): Promise