From d155b3ce4ae1d7df92ca8cf3e2987793ce7132fd Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 25 Mar 2026 00:16:41 +0300 Subject: [PATCH 1/5] chore: add plan files for Phase 3 advanced features --- plans/phase-3-advanced-features/CONTEXT.md | 11 +++++ plans/phase-3-advanced-features/PLAN.md | 46 +++++++++++++++++++ .../phase-1-import-export.md | 18 ++++++++ .../phase-2-sparklines.md | 18 ++++++++ .../phase-3-user-themes.md | 19 ++++++++ .../phase-3-advanced-features/phase-4-pwa.md | 16 +++++++ .../phase-5-autodiscovery.md | 23 ++++++++++ .../phase-6-bookmarklet-sync.md | 21 +++++++++ .../phase-7-integration.md | 21 +++++++++ 9 files changed, 193 insertions(+) create mode 100644 plans/phase-3-advanced-features/CONTEXT.md create mode 100644 plans/phase-3-advanced-features/PLAN.md create mode 100644 plans/phase-3-advanced-features/phase-1-import-export.md create mode 100644 plans/phase-3-advanced-features/phase-2-sparklines.md create mode 100644 plans/phase-3-advanced-features/phase-3-user-themes.md create mode 100644 plans/phase-3-advanced-features/phase-4-pwa.md create mode 100644 plans/phase-3-advanced-features/phase-5-autodiscovery.md create mode 100644 plans/phase-3-advanced-features/phase-6-bookmarklet-sync.md create mode 100644 plans/phase-3-advanced-features/phase-7-integration.md diff --git a/plans/phase-3-advanced-features/CONTEXT.md b/plans/phase-3-advanced-features/CONTEXT.md new file mode 100644 index 0000000..51ff6e4 --- /dev/null +++ b/plans/phase-3-advanced-features/CONTEXT.md @@ -0,0 +1,11 @@ +# Feature Context: Phase 3 — Advanced Features + +## Current State +Phase 2 is complete and merged. 176 tests, full build passes. Starting Phase 3 advanced features. + +## Cross-Phase Dependencies +- Phases 1-3 are independent (import/export, sparklines, user themes) +- Phase 4 (PWA) is independent +- Phase 5 (auto-discovery) is independent +- Phase 6 (bookmarklet/sync) depends on existing API +- Phase 7 (integration) depends on all prior phases diff --git a/plans/phase-3-advanced-features/PLAN.md b/plans/phase-3-advanced-features/PLAN.md new file mode 100644 index 0000000..c62d019 --- /dev/null +++ b/plans/phase-3-advanced-features/PLAN.md @@ -0,0 +1,46 @@ +# Feature: Phase 3 — Advanced Features + +**Branch:** `feature/phase-3-advanced-features` +**Base branch:** `master` +**Created:** 2026-03-25 +**Status:** 🟡 In Progress +**Strategy:** Big Bang +**Mode:** Automated +**Execution:** Orchestrator + +## Summary +Add import/export, ping history sparklines, user theme overrides, PWA support, Docker/Traefik auto-discovery, quick-add bookmarklet, and multi-tab sync. + +## Build & Test Commands +- **Build:** `npm run build` +- **Test:** `npm test` +- **Lint:** `npm run lint` +- **Type Check:** `npm run check` + +## Phases + +- [ ] Phase 1: Import/Export [fullstack] → [subplan](./phase-1-import-export.md) +- [ ] Phase 2: Ping History Sparklines [fullstack] → [subplan](./phase-2-sparklines.md) +- [ ] Phase 3: User Theme Overrides [fullstack] → [subplan](./phase-3-user-themes.md) +- [ ] Phase 4: PWA Support [frontend] → [subplan](./phase-4-pwa.md) +- [ ] Phase 5: Auto-Discovery Docker/Traefik [backend] → [subplan](./phase-5-autodiscovery.md) +- [ ] Phase 6: Bookmarklet & Multi-Tab Sync [fullstack] → [subplan](./phase-6-bookmarklet-sync.md) +- [ ] Phase 7: Integration & Polish [fullstack] → [subplan](./phase-7-integration.md) + +## Phase Progress Log + +| Phase | Domain | Status | Review | Build | Committed | +|-------|--------|--------|--------|-------|-----------| +| Phase 1: Import/Export | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 2: Sparklines | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 3: User Themes | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 4: PWA | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 5: Auto-Discovery | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 6: Bookmarklet/Sync | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 7: Integration | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | + +## Final Review +- [ ] Comprehensive code review +- [ ] Full build passes +- [ ] Full test suite passes +- [ ] Merged to `master` diff --git a/plans/phase-3-advanced-features/phase-1-import-export.md b/plans/phase-3-advanced-features/phase-1-import-export.md new file mode 100644 index 0000000..fc2d73d --- /dev/null +++ b/plans/phase-3-advanced-features/phase-1-import-export.md @@ -0,0 +1,18 @@ +# Phase 1: Import/Export + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Tasks +- [ ] Task 1: Create `src/lib/server/services/exportService.ts` — export all data (apps, boards, sections, widgets, groups, settings) as JSON +- [ ] Task 2: Create `src/lib/server/services/importService.ts` — import JSON with conflict resolution (skip/overwrite) +- [ ] Task 3: Create `src/routes/api/admin/export/+server.ts` — GET endpoint, returns JSON file download +- [ ] Task 4: Create `src/routes/api/admin/import/+server.ts` — POST endpoint, accepts JSON upload +- [ ] Task 5: Update admin settings page — add Import/Export section with download button and file upload +- [ ] Task 6: Create `src/lib/components/admin/ImportExportPanel.svelte` — UI with export button, file picker, preview, and import button +- [ ] Task 7: Add Zod schema for validating import data structure +- [ ] Task 8: Add i18n translations for import/export strings (EN/RU) + +## Handoff to Next Phase + diff --git a/plans/phase-3-advanced-features/phase-2-sparklines.md b/plans/phase-3-advanced-features/phase-2-sparklines.md new file mode 100644 index 0000000..496c2ad --- /dev/null +++ b/plans/phase-3-advanced-features/phase-2-sparklines.md @@ -0,0 +1,18 @@ +# Phase 2: Ping History Sparklines + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Tasks +- [ ] Task 1: Create `src/routes/api/apps/[id]/history/+server.ts` — GET last 24h of healthcheck results +- [ ] Task 2: Create `src/lib/components/app/SparklineChart.svelte` — tiny inline SVG sparkline (green=up, red=down) +- [ ] Task 3: Update `src/lib/components/widget/AppWidget.svelte` — show sparkline below status badge +- [ ] Task 4: Update `src/lib/components/app/AppCard.svelte` — show sparkline on app cards +- [ ] Task 5: Calculate and display uptime percentage (last 24h) +- [ ] Task 6: Update healthcheck service to retain last 288 records per app (24h at 5min intervals) +- [ ] Task 7: Add cleanup job to prune old AppStatus records beyond retention period +- [ ] Task 8: Add i18n translations (EN/RU) + +## Handoff to Next Phase + diff --git a/plans/phase-3-advanced-features/phase-3-user-themes.md b/plans/phase-3-advanced-features/phase-3-user-themes.md new file mode 100644 index 0000000..9661f38 --- /dev/null +++ b/plans/phase-3-advanced-features/phase-3-user-themes.md @@ -0,0 +1,19 @@ +# Phase 3: User Theme Overrides + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Tasks +- [ ] Task 1: Add `themeMode`, `primaryHue`, `primarySaturation`, `backgroundType`, `locale` fields to User model (Prisma migration) +- [ ] Task 2: Create `src/routes/api/users/me/preferences/+server.ts` — GET/PATCH user preferences +- [ ] Task 3: Create `src/routes/settings/+page.server.ts` — user settings page data +- [ ] Task 4: Create `src/routes/settings/+page.svelte` — user settings page with theme customization +- [ ] Task 5: Create `src/lib/components/settings/ThemeCustomizer.svelte` — HSL color picker, background selector, mode toggle +- [ ] Task 6: Update theme store to load user preferences from server on login +- [ ] Task 7: Update `+layout.server.ts` to pass user preferences +- [ ] Task 8: Add user settings link to header user menu +- [ ] Task 9: Add i18n translations (EN/RU) + +## Handoff to Next Phase + diff --git a/plans/phase-3-advanced-features/phase-4-pwa.md b/plans/phase-3-advanced-features/phase-4-pwa.md new file mode 100644 index 0000000..f4dbbe8 --- /dev/null +++ b/plans/phase-3-advanced-features/phase-4-pwa.md @@ -0,0 +1,16 @@ +# Phase 4: PWA Support + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Tasks +- [ ] Task 1: Create `static/manifest.json` — web app manifest with name, icons, theme color, display: standalone +- [ ] Task 2: Create app icons in `static/` — 192x192 and 512x512 PNG (simple grid icon) +- [ ] Task 3: Create `src/service-worker.ts` — SvelteKit service worker with cache-first for static assets, network-first for API +- [ ] Task 4: Update `src/app.html` — add manifest link, theme-color meta, apple-mobile-web-app meta tags +- [ ] Task 5: Create offline fallback page — show when no network and no cache +- [ ] Task 6: Add install prompt UI — detect `beforeinstallprompt` event, show install banner + +## Handoff to Next Phase + diff --git a/plans/phase-3-advanced-features/phase-5-autodiscovery.md b/plans/phase-3-advanced-features/phase-5-autodiscovery.md new file mode 100644 index 0000000..691e6e3 --- /dev/null +++ b/plans/phase-3-advanced-features/phase-5-autodiscovery.md @@ -0,0 +1,23 @@ +# Phase 5: Auto-Discovery (Docker/Traefik) + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Tasks +- [ ] Task 1: Create `src/lib/server/services/discoveryService.ts` — Docker socket scanning and Traefik API parsing +- [ ] Task 2: Create `src/routes/api/admin/discover/+server.ts` — POST triggers discovery scan, returns found services +- [ ] Task 3: Create `src/routes/api/admin/discover/approve/+server.ts` — POST approves discovered apps (creates them) +- [ ] Task 4: Create `src/lib/components/admin/DiscoveryPanel.svelte` — UI to trigger scan, review results, approve/reject +- [ ] Task 5: Add discovery settings to SystemSettings (Docker socket path, Traefik API URL, auto-scan toggle) +- [ ] Task 6: Update admin settings page with discovery configuration section +- [ ] Task 7: Add env vars: DOCKER_SOCKET_PATH, TRAEFIK_API_URL +- [ ] Task 8: Add i18n translations (EN/RU) + +## Notes +- Docker discovery: read from `/var/run/docker.sock` (or configured path), list containers, extract labels for name/URL +- Traefik discovery: query Traefik API `/api/http/routers` and `/api/http/services` +- Both are optional — gracefully handle when Docker socket or Traefik API is unavailable + +## Handoff to Next Phase + diff --git a/plans/phase-3-advanced-features/phase-6-bookmarklet-sync.md b/plans/phase-3-advanced-features/phase-6-bookmarklet-sync.md new file mode 100644 index 0000000..41c2a5d --- /dev/null +++ b/plans/phase-3-advanced-features/phase-6-bookmarklet-sync.md @@ -0,0 +1,21 @@ +# Phase 6: Quick-Add Bookmarklet & Multi-Tab Sync + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Tasks +- [ ] Task 1: Create `src/routes/api/apps/quick-add/+server.ts` — POST endpoint that accepts URL + title, creates app with defaults +- [ ] Task 2: Create `src/lib/components/admin/BookmarkletGenerator.svelte` — generates bookmarklet JS code with user's API token +- [ ] Task 3: Add bookmarklet section to user settings page +- [ ] Task 4: Create `src/lib/utils/broadcastSync.ts` — BroadcastChannel wrapper for cross-tab sync +- [ ] Task 5: Sync theme changes across tabs (dark/light toggle, primary color) +- [ ] Task 6: Sync board changes across tabs (new boards appear in sidebar) +- [ ] Task 7: Add i18n translations (EN/RU) + +## Notes +- Bookmarklet: `javascript:void(fetch('ORIGIN/api/apps/quick-add',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer TOKEN'},body:JSON.stringify({url:location.href,name:document.title})}))` +- BroadcastChannel: create channel 'wal-sync', post messages on theme/board changes, listen in layout + +## Handoff to Next Phase + diff --git a/plans/phase-3-advanced-features/phase-7-integration.md b/plans/phase-3-advanced-features/phase-7-integration.md new file mode 100644 index 0000000..081d042 --- /dev/null +++ b/plans/phase-3-advanced-features/phase-7-integration.md @@ -0,0 +1,21 @@ +# Phase 7: Integration & Polish + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Tasks +- [ ] Task 1: Fix all TypeScript/build errors +- [ ] Task 2: Verify `npm run build` succeeds +- [ ] Task 3: Verify `npm run check` passes +- [ ] Task 4: Verify `npm run lint` passes +- [ ] Task 5: Write tests for export/import services +- [ ] Task 6: Write tests for discovery service +- [ ] Task 7: Write tests for user preferences API +- [ ] Task 8: Write tests for quick-add API +- [ ] Task 9: Update seed script with sample data +- [ ] Task 10: Verify all existing tests pass +- [ ] Task 11: Run Prisma migrations + +## Handoff + From c6a7de895d0016f96ca0b851a9d1b160c98f8b06 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 25 Mar 2026 00:51:01 +0300 Subject: [PATCH 2/5] feat(phase3): import/export, sparklines, user theme overrides - JSON import/export with conflict resolution (skip/overwrite) + admin UI - Ping history sparklines on AppWidget and AppCard (24h, 288 points) - Hourly cleanup job for old AppStatus records - User theme preferences (hue, saturation, mode, background, locale) - Settings page with ThemeCustomizer (sliders, toggles, live preview) - Prisma migration for user preference fields - i18n translations for all new strings (EN/RU) --- plans/phase-3-advanced-features/CONTEXT.md | 22 +- plans/phase-3-advanced-features/PLAN.md | 10 +- .../phase-1-import-export.md | 21 +- .../phase-2-sparklines.md | 22 +- .../phase-3-user-themes.md | 22 +- .../migration.sql | 6 + prisma/schema.prisma | 6 + .../components/admin/ImportExportPanel.svelte | 212 ++++++++++++++++ src/lib/components/app/AppCard.svelte | 41 +++ src/lib/components/app/SparklineChart.svelte | 51 ++++ src/lib/components/layout/Header.svelte | 21 ++ .../settings/ThemeCustomizer.svelte | 238 ++++++++++++++++++ src/lib/components/widget/AppWidget.svelte | 41 +++ src/lib/i18n/en.json | 32 ++- src/lib/i18n/ru.json | 34 ++- src/lib/server/jobs/healthcheckScheduler.ts | 19 +- src/lib/server/services/exportService.ts | 154 ++++++++++++ src/lib/server/services/healthcheckService.ts | 51 ++++ src/lib/server/services/importService.ts | 226 +++++++++++++++++ src/lib/stores/theme.svelte.ts | 24 ++ src/lib/utils/validators.ts | 65 +++++ src/routes/+layout.server.ts | 30 ++- src/routes/+layout.svelte | 9 + src/routes/admin/settings/+page.svelte | 5 +- src/routes/api/admin/export/+server.ts | 30 +++ src/routes/api/admin/import/+server.ts | 46 ++++ src/routes/api/apps/[id]/history/+server.ts | 46 ++++ .../api/users/me/preferences/+server.ts | 148 +++++++++++ src/routes/settings/+page.server.ts | 28 +++ src/routes/settings/+page.svelte | 17 ++ 30 files changed, 1633 insertions(+), 44 deletions(-) create mode 100644 prisma/migrations/20260324212043_add_user_preferences/migration.sql create mode 100644 src/lib/components/admin/ImportExportPanel.svelte create mode 100644 src/lib/components/app/SparklineChart.svelte create mode 100644 src/lib/components/settings/ThemeCustomizer.svelte create mode 100644 src/lib/server/services/exportService.ts create mode 100644 src/lib/server/services/importService.ts create mode 100644 src/routes/api/admin/export/+server.ts create mode 100644 src/routes/api/admin/import/+server.ts create mode 100644 src/routes/api/apps/[id]/history/+server.ts create mode 100644 src/routes/api/users/me/preferences/+server.ts create mode 100644 src/routes/settings/+page.server.ts create mode 100644 src/routes/settings/+page.svelte diff --git a/plans/phase-3-advanced-features/CONTEXT.md b/plans/phase-3-advanced-features/CONTEXT.md index 51ff6e4..914ad53 100644 --- a/plans/phase-3-advanced-features/CONTEXT.md +++ b/plans/phase-3-advanced-features/CONTEXT.md @@ -1,7 +1,27 @@ # Feature Context: Phase 3 — Advanced Features ## Current State -Phase 2 is complete and merged. 176 tests, full build passes. Starting Phase 3 advanced features. +Phase 2 is complete and merged. 176 tests, full build passes. Phase 3 in progress. Phase 1 (Import/Export), Phase 2 (Sparklines), and Phase 3 (User Theme Overrides) are complete. + +### Phase 1 (Import/Export) Summary +exportService, importService, admin API endpoints, ImportExportPanel UI, Zod validation schema, i18n EN/RU translations. + +### Phase 2 (Sparklines) Summary +- History API at `/api/apps/[id]/history` — returns last 288 status records with uptime percentage +- `SparklineChart.svelte` — inline SVG bar chart with color-coded status bars (green/red/yellow/gray) +- `AppWidget.svelte` and `AppCard.svelte` updated to fetch and display sparklines on mount +- `pruneOldStatuses()` in healthcheck service — deletes records >24h, caps at 288 per app +- Hourly cleanup cron job in healthcheck scheduler +- i18n keys: `app.uptime`, `app.history_loading` (EN/RU) + +### Phase 3 (User Theme Overrides) Summary +- Prisma migration: added `themeMode`, `primaryHue`, `primarySaturation`, `backgroundType`, `locale` nullable fields to User model +- Preferences API at `/api/users/me/preferences` — GET returns preferences, PATCH updates subset +- Settings page at `/settings` with `ThemeCustomizer.svelte` — hue/saturation sliders, mode toggle (dark/light/system), background selector, locale picker, save button +- Theme store `loadFromServer(prefs)` method applies server preferences over localStorage defaults +- `+layout.server.ts` passes `userPreferences` in layout data; `+layout.svelte` applies them on mount +- Header user menu includes "Settings" link +- i18n keys: `settings.title`, `settings.theme`, `settings.primary_color`, `settings.hue`, `settings.saturation`, `settings.background`, `settings.language`, `settings.save`, `settings.saving`, `settings.saved` (EN/RU) ## Cross-Phase Dependencies - Phases 1-3 are independent (import/export, sparklines, user themes) diff --git a/plans/phase-3-advanced-features/PLAN.md b/plans/phase-3-advanced-features/PLAN.md index c62d019..895fe97 100644 --- a/plans/phase-3-advanced-features/PLAN.md +++ b/plans/phase-3-advanced-features/PLAN.md @@ -19,9 +19,9 @@ Add import/export, ping history sparklines, user theme overrides, PWA support, D ## Phases -- [ ] Phase 1: Import/Export [fullstack] → [subplan](./phase-1-import-export.md) +- [x] Phase 1: Import/Export [fullstack] → [subplan](./phase-1-import-export.md) - [ ] Phase 2: Ping History Sparklines [fullstack] → [subplan](./phase-2-sparklines.md) -- [ ] Phase 3: User Theme Overrides [fullstack] → [subplan](./phase-3-user-themes.md) +- [x] Phase 3: User Theme Overrides [fullstack] → [subplan](./phase-3-user-themes.md) - [ ] Phase 4: PWA Support [frontend] → [subplan](./phase-4-pwa.md) - [ ] Phase 5: Auto-Discovery Docker/Traefik [backend] → [subplan](./phase-5-autodiscovery.md) - [ ] Phase 6: Bookmarklet & Multi-Tab Sync [fullstack] → [subplan](./phase-6-bookmarklet-sync.md) @@ -31,9 +31,9 @@ Add import/export, ping history sparklines, user theme overrides, PWA support, D | Phase | Domain | Status | Review | Build | Committed | |-------|--------|--------|--------|-------|-----------| -| Phase 1: Import/Export | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | -| Phase 2: Sparklines | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | -| Phase 3: User Themes | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 1: Import/Export | fullstack | ✅ Done | ⬜ | ⬜ | ⬜ | +| Phase 2: Sparklines | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ | +| Phase 3: User Themes | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ | | Phase 4: PWA | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 5: Auto-Discovery | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 6: Bookmarklet/Sync | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | diff --git a/plans/phase-3-advanced-features/phase-1-import-export.md b/plans/phase-3-advanced-features/phase-1-import-export.md index fc2d73d..89a07ff 100644 --- a/plans/phase-3-advanced-features/phase-1-import-export.md +++ b/plans/phase-3-advanced-features/phase-1-import-export.md @@ -1,18 +1,19 @@ # Phase 1: Import/Export -**Status:** ⬜ Not Started +**Status:** ✅ Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack ## Tasks -- [ ] Task 1: Create `src/lib/server/services/exportService.ts` — export all data (apps, boards, sections, widgets, groups, settings) as JSON -- [ ] Task 2: Create `src/lib/server/services/importService.ts` — import JSON with conflict resolution (skip/overwrite) -- [ ] Task 3: Create `src/routes/api/admin/export/+server.ts` — GET endpoint, returns JSON file download -- [ ] Task 4: Create `src/routes/api/admin/import/+server.ts` — POST endpoint, accepts JSON upload -- [ ] Task 5: Update admin settings page — add Import/Export section with download button and file upload -- [ ] Task 6: Create `src/lib/components/admin/ImportExportPanel.svelte` — UI with export button, file picker, preview, and import button -- [ ] Task 7: Add Zod schema for validating import data structure -- [ ] Task 8: Add i18n translations for import/export strings (EN/RU) +- [x] Task 1: Create `src/lib/server/services/exportService.ts` — export all data (apps, boards, sections, widgets, groups, settings) as JSON +- [x] Task 2: Create `src/lib/server/services/importService.ts` — import JSON with conflict resolution (skip/overwrite) +- [x] Task 3: Create `src/routes/api/admin/export/+server.ts` — GET endpoint, returns JSON file download +- [x] Task 4: Create `src/routes/api/admin/import/+server.ts` — POST endpoint, accepts JSON upload +- [x] Task 5: Update admin settings page — add Import/Export section with download button and file upload +- [x] Task 6: Create `src/lib/components/admin/ImportExportPanel.svelte` — UI with export button, file picker, preview, and import button +- [x] Task 7: Add Zod schema for validating import data structure +- [x] Task 8: Add i18n translations for import/export strings (EN/RU) ## Handoff to Next Phase - + +All import/export functionality implemented. Export service gathers all apps, boards (with sections/widgets), groups, and system settings into a versioned JSON structure. Import service validates with Zod, supports skip/overwrite conflict resolution, and runs in a Prisma transaction. Admin-only API endpoints with Content-Disposition for file download. UI panel with file upload, JSON preview, mode selector, and status feedback. diff --git a/plans/phase-3-advanced-features/phase-2-sparklines.md b/plans/phase-3-advanced-features/phase-2-sparklines.md index 496c2ad..dd25193 100644 --- a/plans/phase-3-advanced-features/phase-2-sparklines.md +++ b/plans/phase-3-advanced-features/phase-2-sparklines.md @@ -1,18 +1,20 @@ # Phase 2: Ping History Sparklines -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack ## Tasks -- [ ] Task 1: Create `src/routes/api/apps/[id]/history/+server.ts` — GET last 24h of healthcheck results -- [ ] Task 2: Create `src/lib/components/app/SparklineChart.svelte` — tiny inline SVG sparkline (green=up, red=down) -- [ ] Task 3: Update `src/lib/components/widget/AppWidget.svelte` — show sparkline below status badge -- [ ] Task 4: Update `src/lib/components/app/AppCard.svelte` — show sparkline on app cards -- [ ] Task 5: Calculate and display uptime percentage (last 24h) -- [ ] Task 6: Update healthcheck service to retain last 288 records per app (24h at 5min intervals) -- [ ] Task 7: Add cleanup job to prune old AppStatus records beyond retention period -- [ ] Task 8: Add i18n translations (EN/RU) + +- [x] Task 1: Create `src/routes/api/apps/[id]/history/+server.ts` — GET last 24h of healthcheck results +- [x] Task 2: Create `src/lib/components/app/SparklineChart.svelte` — tiny inline SVG sparkline (green=up, red=down) +- [x] Task 3: Update `src/lib/components/widget/AppWidget.svelte` — show sparkline below status badge +- [x] Task 4: Update `src/lib/components/app/AppCard.svelte` — show sparkline on app cards +- [x] Task 5: Calculate and display uptime percentage (last 24h) +- [x] Task 6: Update healthcheck service to retain last 288 records per app (24h at 5min intervals) +- [x] Task 7: Add cleanup job to prune old AppStatus records beyond retention period +- [x] Task 8: Add i18n translations (EN/RU) ## Handoff to Next Phase - + +All sparkline features implemented. History API returns last 288 records with uptime percentage. SparklineChart renders color-coded bars (green/red/yellow/gray). Cleanup job prunes records older than 24h hourly. Both AppWidget and AppCard fetch and display sparklines with uptime percentage on mount. diff --git a/plans/phase-3-advanced-features/phase-3-user-themes.md b/plans/phase-3-advanced-features/phase-3-user-themes.md index 9661f38..2626324 100644 --- a/plans/phase-3-advanced-features/phase-3-user-themes.md +++ b/plans/phase-3-advanced-features/phase-3-user-themes.md @@ -1,19 +1,19 @@ # Phase 3: User Theme Overrides -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack ## Tasks -- [ ] Task 1: Add `themeMode`, `primaryHue`, `primarySaturation`, `backgroundType`, `locale` fields to User model (Prisma migration) -- [ ] Task 2: Create `src/routes/api/users/me/preferences/+server.ts` — GET/PATCH user preferences -- [ ] Task 3: Create `src/routes/settings/+page.server.ts` — user settings page data -- [ ] Task 4: Create `src/routes/settings/+page.svelte` — user settings page with theme customization -- [ ] Task 5: Create `src/lib/components/settings/ThemeCustomizer.svelte` — HSL color picker, background selector, mode toggle -- [ ] Task 6: Update theme store to load user preferences from server on login -- [ ] Task 7: Update `+layout.server.ts` to pass user preferences -- [ ] Task 8: Add user settings link to header user menu -- [ ] Task 9: Add i18n translations (EN/RU) +- [x] Task 1: Add `themeMode`, `primaryHue`, `primarySaturation`, `backgroundType`, `locale` fields to User model (Prisma migration) +- [x] Task 2: Create `src/routes/api/users/me/preferences/+server.ts` — GET/PATCH user preferences +- [x] Task 3: Create `src/routes/settings/+page.server.ts` — user settings page data +- [x] Task 4: Create `src/routes/settings/+page.svelte` — user settings page with theme customization +- [x] Task 5: Create `src/lib/components/settings/ThemeCustomizer.svelte` — HSL color picker, background selector, mode toggle +- [x] Task 6: Update theme store to load user preferences from server on login +- [x] Task 7: Update `+layout.server.ts` to pass user preferences +- [x] Task 8: Add user settings link to header user menu +- [x] Task 9: Add i18n translations (EN/RU) ## Handoff to Next Phase - +Phase 3 (User Theme Overrides) complete. Added nullable preference fields to User model, preferences API (GET/PATCH), settings page with ThemeCustomizer component (hue/saturation sliders, mode toggle, background selector, locale picker), server-side preference loading in layout, and Settings link in Header user menu. i18n translations added for EN and RU. diff --git a/prisma/migrations/20260324212043_add_user_preferences/migration.sql b/prisma/migrations/20260324212043_add_user_preferences/migration.sql new file mode 100644 index 0000000..3442b2b --- /dev/null +++ b/prisma/migrations/20260324212043_add_user_preferences/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "backgroundType" TEXT; +ALTER TABLE "User" ADD COLUMN "locale" TEXT; +ALTER TABLE "User" ADD COLUMN "primaryHue" INTEGER; +ALTER TABLE "User" ADD COLUMN "primarySaturation" INTEGER; +ALTER TABLE "User" ADD COLUMN "themeMode" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 10d0ddb..9867842 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,6 +20,12 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + themeMode String? + primaryHue Int? + primarySaturation Int? + backgroundType String? + locale String? + groups UserGroup[] createdApps App[] boards Board[] diff --git a/src/lib/components/admin/ImportExportPanel.svelte b/src/lib/components/admin/ImportExportPanel.svelte new file mode 100644 index 0000000..272d697 --- /dev/null +++ b/src/lib/components/admin/ImportExportPanel.svelte @@ -0,0 +1,212 @@ + + +
+

{$t('admin.import_export_title')}

+

{$t('admin.import_export_description')}

+ + +
+

{$t('admin.export_section')}

+ +
+ + +
+ + +
+

{$t('admin.import_section')}

+ + +
+ + +
+ + + {#if previewData} +
+ +
{previewData}
+
+ {/if} + + + {#if parsedData} +
+ + +
+ + + {/if} +
+ + + {#if statusMessage} +
+ {statusMessage} +
+ {/if} +
diff --git a/src/lib/components/app/AppCard.svelte b/src/lib/components/app/AppCard.svelte index ff01800..64a8642 100644 --- a/src/lib/components/app/AppCard.svelte +++ b/src/lib/components/app/AppCard.svelte @@ -1,5 +1,8 @@ + + + {#each bars as bar} + + {/each} + diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte index f5eca66..e49004e 100644 --- a/src/lib/components/layout/Header.svelte +++ b/src/lib/components/layout/Header.svelte @@ -161,6 +161,27 @@

{user.email}

+ (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" + > + + + + + {$t('settings.title')} + +
+ {/each} + + + + +
+

{$t('settings.primary_color')}

+ + +
+
+ + HSL({theme.primaryHue}, {theme.primarySaturation}%, 50%) + +
+ + +
+ +
+
+ +
+
+ + +
+ +
+
+ +
+
+
+ + +
+

{$t('settings.background')}

+
+ {#each bgOptions as opt (opt.value)} + + {/each} +
+
+ + +
+

{$t('settings.language')}

+
+ {#each localeOptions as opt (opt.value)} + + {/each} +
+
+ + +
+ + {#if saved} + {$t('settings.saved')} + {/if} + {#if errorMessage} + {errorMessage} + {/if} +
+ diff --git a/src/lib/components/widget/AppWidget.svelte b/src/lib/components/widget/AppWidget.svelte index 3d5b26f..1305a59 100644 --- a/src/lib/components/widget/AppWidget.svelte +++ b/src/lib/components/widget/AppWidget.svelte @@ -1,5 +1,8 @@ + + + {#if historyLoading} +
+ {:else if historyData.length > 0} +
+ + {#if uptimePercent !== null} + {uptimePercent}% + {/if} +
+ {/if}
diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 02e0c2d..c52afdc 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -127,6 +127,8 @@ "app.healthcheck_timeout": "Timeout (ms)", "app.healthcheck_interval": "Interval (seconds)", "app.icon_board_label": "Icon (Lucide name)", + "app.uptime": "uptime", + "app.history_loading": "Loading history...", "admin.panel": "Admin Panel", "admin.users": "Users", @@ -213,6 +215,23 @@ "admin.perm_none": "No permissions configured.", "admin.perm_search_placeholder": "Type to search...", + "admin.import_export_title": "Import / Export", + "admin.import_export_description": "Export all data (apps, boards, groups, settings) as JSON, or import from a previously exported file.", + "admin.export_section": "Export Data", + "admin.export_button": "Export JSON", + "admin.export_exporting": "Exporting...", + "admin.export_success": "Export downloaded successfully.", + "admin.import_section": "Import Data", + "admin.import_select_file": "Select a JSON export file", + "admin.import_preview": "Preview", + "admin.import_mode_label": "Conflict Resolution", + "admin.import_mode_skip": "Skip existing (keep current data)", + "admin.import_mode_overwrite": "Overwrite existing (replace with imported data)", + "admin.import_button": "Import", + "admin.import_importing": "Importing...", + "admin.import_success": "Import completed.", + "admin.import_invalid_json": "Selected file is not valid JSON.", + "search.placeholder": "Search apps and boards...", "search.trigger": "Search...", "search.min_chars": "Type at least 2 characters to search", @@ -261,5 +280,16 @@ "home.view_boards": "View Boards", "home.browse_apps": "Browse Apps", - "language.label": "Language" + "language.label": "Language", + + "settings.title": "Settings", + "settings.theme": "Theme Mode", + "settings.primary_color": "Primary Color", + "settings.hue": "Hue", + "settings.saturation": "Saturation", + "settings.background": "Background Effect", + "settings.language": "Language", + "settings.save": "Save Preferences", + "settings.saving": "Saving...", + "settings.saved": "Preferences saved!" } diff --git a/src/lib/i18n/ru.json b/src/lib/i18n/ru.json index db6c236..c1b9b03 100644 --- a/src/lib/i18n/ru.json +++ b/src/lib/i18n/ru.json @@ -127,6 +127,8 @@ "app.healthcheck_timeout": "\u0422\u0430\u0439\u043c\u0430\u0443\u0442 (\u043c\u0441)", "app.healthcheck_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b (\u0441\u0435\u043a\u0443\u043d\u0434\u044b)", "app.icon_board_label": "\u0418\u043a\u043e\u043d\u043a\u0430 (Lucide)", + "app.uptime": "\u0430\u043f\u0442\u0430\u0439\u043c", + "app.history_loading": "\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u0438\u0441\u0442\u043e\u0440\u0438\u0438...", "admin.panel": "\u041f\u0430\u043d\u0435\u043b\u044c \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0430", "admin.users": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438", @@ -213,7 +215,24 @@ "admin.perm_none": "\u041f\u0440\u0430\u0432\u0430 \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b.", "admin.perm_search_placeholder": "\u041d\u0430\u0447\u043d\u0438\u0442\u0435 \u0432\u0432\u043e\u0434\u0438\u0442\u044c...", - "search.placeholder": "\u041f\u043e\u0438\u0441\u043a \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439 \u0438 \u0434\u043e\u0441\u043e\u043a...", + "admin.import_export_title": "Импорт / Экспорт", + "admin.import_export_description": "Экспортируйте все данные (приложения, доски, группы, настройки) в формате JSON или импортируйте из ранее экспортированного файла.", + "admin.export_section": "Экспорт данных", + "admin.export_button": "Экспорт JSON", + "admin.export_exporting": "Экспорт...", + "admin.export_success": "Экспорт успешно скачан.", + "admin.import_section": "Импорт данных", + "admin.import_select_file": "Выберите JSON-файл экспорта", + "admin.import_preview": "Предпросмотр", + "admin.import_mode_label": "Разрешение конфликтов", + "admin.import_mode_skip": "Пропустить существующие (оставить текущие данные)", + "admin.import_mode_overwrite": "Перезаписать существующие (заменить импортированными)", + "admin.import_button": "Импортировать", + "admin.import_importing": "Импорт...", + "admin.import_success": "Импорт завершён.", + "admin.import_invalid_json": "Выбранный файл не является корректным JSON.", + + "search.placeholder": "Поиск приложений и досок...", "search.trigger": "\u041f\u043e\u0438\u0441\u043a...", "search.min_chars": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043c\u0438\u043d\u0438\u043c\u0443\u043c 2 \u0441\u0438\u043c\u0432\u043e\u043b\u0430 \u0434\u043b\u044f \u043f\u043e\u0438\u0441\u043a\u0430", "search.no_results": "\u041d\u0438\u0447\u0435\u0433\u043e \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e \u043f\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u0443 \u00ab{query}\u00bb", @@ -261,5 +280,16 @@ "home.view_boards": "\u041f\u043e\u0441\u043c\u043e\u0442\u0440\u0435\u0442\u044c \u0434\u043e\u0441\u043a\u0438", "home.browse_apps": "\u041e\u0431\u0437\u043e\u0440 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439", - "language.label": "\u042f\u0437\u044b\u043a" + "language.label": "\u042f\u0437\u044b\u043a", + + "settings.title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438", + "settings.theme": "\u0420\u0435\u0436\u0438\u043c \u0442\u0435\u043c\u044b", + "settings.primary_color": "\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0446\u0432\u0435\u0442", + "settings.hue": "\u041e\u0442\u0442\u0435\u043d\u043e\u043a", + "settings.saturation": "\u041d\u0430\u0441\u044b\u0449\u0435\u043d\u043d\u043e\u0441\u0442\u044c", + "settings.background": "\u042d\u0444\u0444\u0435\u043a\u0442 \u0444\u043e\u043d\u0430", + "settings.language": "\u042f\u0437\u044b\u043a", + "settings.save": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438", + "settings.saving": "\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435...", + "settings.saved": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u044b!" } diff --git a/src/lib/server/jobs/healthcheckScheduler.ts b/src/lib/server/jobs/healthcheckScheduler.ts index ddeda64..2efbf1b 100644 --- a/src/lib/server/jobs/healthcheckScheduler.ts +++ b/src/lib/server/jobs/healthcheckScheduler.ts @@ -1,11 +1,13 @@ import cron from 'node-cron'; -import { checkAllApps } from '$lib/server/services/healthcheckService.js'; +import { checkAllApps, pruneOldStatuses } from '$lib/server/services/healthcheckService.js'; let scheduledTask: cron.ScheduledTask | null = null; +let cleanupTask: cron.ScheduledTask | null = null; /** * Start the healthcheck scheduler. * Runs checkAllApps on a cron schedule (default: every 60 seconds). + * Also starts an hourly cleanup job to prune old status records. */ export function startScheduler(cronExpression: string = '* * * * *'): void { if (scheduledTask) { @@ -20,6 +22,15 @@ export function startScheduler(cronExpression: string = '* * * * *'): void { } }); + // Cleanup job: run every hour at minute 0 + cleanupTask = cron.schedule('0 * * * *', async () => { + try { + await pruneOldStatuses(); + } catch { + // Swallow errors to prevent scheduler crash + } + }); + // Run an initial check shortly after startup setTimeout(() => { checkAllApps().catch(() => { @@ -29,11 +40,15 @@ export function startScheduler(cronExpression: string = '* * * * *'): void { } /** - * Stop the healthcheck scheduler. + * Stop the healthcheck scheduler and cleanup job. */ export function stopScheduler(): void { if (scheduledTask) { scheduledTask.stop(); scheduledTask = null; } + if (cleanupTask) { + cleanupTask.stop(); + cleanupTask = null; + } } diff --git a/src/lib/server/services/exportService.ts b/src/lib/server/services/exportService.ts new file mode 100644 index 0000000..28dc19f --- /dev/null +++ b/src/lib/server/services/exportService.ts @@ -0,0 +1,154 @@ +import { prisma } from '../prisma.js'; +import { DEFAULTS } from '$lib/utils/constants.js'; + +export interface ExportData { + readonly version: string; + readonly exportedAt: string; + readonly apps: ReadonlyArray; + readonly boards: ReadonlyArray; + readonly groups: ReadonlyArray; + readonly settings: ExportSettings; +} + +export interface ExportApp { + readonly name: string; + readonly url: string; + readonly icon: string | null; + readonly iconType: string; + readonly description: string | null; + readonly category: string | null; + readonly tags: string; + readonly healthcheckEnabled: boolean; + readonly healthcheckInterval: number; + readonly healthcheckMethod: string; + readonly healthcheckExpectedStatus: number; + readonly healthcheckTimeout: number; +} + +export interface ExportWidget { + readonly type: string; + readonly order: number; + readonly config: string; + readonly appName: string | null; +} + +export interface ExportSection { + readonly title: string; + readonly icon: string | null; + readonly order: number; + readonly isExpandedByDefault: boolean; + readonly widgets: ReadonlyArray; +} + +export interface ExportBoard { + readonly name: string; + readonly icon: string | null; + readonly description: string | null; + readonly isDefault: boolean; + readonly isGuestAccessible: boolean; + readonly backgroundConfig: string | null; + readonly sections: ReadonlyArray; +} + +export interface ExportGroup { + readonly name: string; + readonly description: string | null; + readonly isDefault: boolean; +} + +export interface ExportSettings { + readonly authMode: string; + readonly registrationEnabled: boolean; + readonly defaultTheme: string; + readonly defaultPrimaryColor: string; + readonly healthcheckDefaults: string; +} + +export async function exportAllData(): Promise { + const [apps, boards, groups, settings] = await Promise.all([ + prisma.app.findMany({ + orderBy: { name: 'asc' } + }), + prisma.board.findMany({ + orderBy: { createdAt: 'asc' }, + include: { + sections: { + orderBy: { order: 'asc' }, + include: { + widgets: { + orderBy: { order: 'asc' }, + include: { app: { select: { name: true } } } + } + } + } + } + }), + prisma.group.findMany({ + orderBy: { name: 'asc' } + }), + prisma.systemSettings.upsert({ + where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }, + update: {}, + create: { id: DEFAULTS.SYSTEM_SETTINGS_ID } + }) + ]); + + const exportApps: ReadonlyArray = apps.map((app) => ({ + name: app.name, + url: app.url, + icon: app.icon, + iconType: app.iconType, + description: app.description, + category: app.category, + tags: app.tags, + healthcheckEnabled: app.healthcheckEnabled, + healthcheckInterval: app.healthcheckInterval, + healthcheckMethod: app.healthcheckMethod, + healthcheckExpectedStatus: app.healthcheckExpectedStatus, + healthcheckTimeout: app.healthcheckTimeout + })); + + const exportBoards: ReadonlyArray = boards.map((board) => ({ + name: board.name, + icon: board.icon, + description: board.description, + isDefault: board.isDefault, + isGuestAccessible: board.isGuestAccessible, + backgroundConfig: board.backgroundConfig, + sections: board.sections.map((section) => ({ + title: section.title, + icon: section.icon, + order: section.order, + isExpandedByDefault: section.isExpandedByDefault, + widgets: section.widgets.map((widget) => ({ + type: widget.type, + order: widget.order, + config: widget.config, + appName: widget.app?.name ?? null + })) + })) + })); + + const exportGroups: ReadonlyArray = groups.map((group) => ({ + name: group.name, + description: group.description, + isDefault: group.isDefault + })); + + const exportSettings: ExportSettings = { + authMode: settings.authMode, + registrationEnabled: settings.registrationEnabled, + defaultTheme: settings.defaultTheme, + defaultPrimaryColor: settings.defaultPrimaryColor, + healthcheckDefaults: settings.healthcheckDefaults + }; + + return { + version: '1.0', + exportedAt: new Date().toISOString(), + apps: exportApps, + boards: exportBoards, + groups: exportGroups, + settings: exportSettings + }; +} diff --git a/src/lib/server/services/healthcheckService.ts b/src/lib/server/services/healthcheckService.ts index e18bb3a..4cf99d6 100644 --- a/src/lib/server/services/healthcheckService.ts +++ b/src/lib/server/services/healthcheckService.ts @@ -1,6 +1,10 @@ import * as appService from './appService.js'; +import { prisma } from '../prisma.js'; import { AppStatusValue } from '$lib/utils/constants.js'; +const MAX_RECORDS_PER_APP = 288; +const RETENTION_HOURS = 24; + export interface HealthcheckResult { readonly appId: string; readonly status: string; @@ -81,3 +85,50 @@ export async function checkAllApps(): Promise { return outcomes; } + +/** + * Prune old AppStatus records. + * - Deletes records older than RETENTION_HOURS + * - Keeps at most MAX_RECORDS_PER_APP per app (deletes oldest excess) + */ +export async function pruneOldStatuses(): Promise { + const cutoff = new Date(Date.now() - RETENTION_HOURS * 60 * 60 * 1000); + + // Step 1: Delete all records older than retention period + await prisma.appStatus.deleteMany({ + where: { + checkedAt: { lt: cutoff } + } + }); + + // Step 2: For each app, keep at most MAX_RECORDS_PER_APP + const apps = await prisma.app.findMany({ + where: { healthcheckEnabled: true }, + select: { id: true } + }); + + for (const app of apps) { + const count = await prisma.appStatus.count({ + where: { appId: app.id } + }); + + if (count > MAX_RECORDS_PER_APP) { + const excess = count - MAX_RECORDS_PER_APP; + // Find the oldest records to delete + const oldestRecords = await prisma.appStatus.findMany({ + where: { appId: app.id }, + orderBy: { checkedAt: 'asc' }, + take: excess, + select: { id: true } + }); + + if (oldestRecords.length > 0) { + await prisma.appStatus.deleteMany({ + where: { + id: { in: oldestRecords.map((r) => r.id) } + } + }); + } + } + } +} diff --git a/src/lib/server/services/importService.ts b/src/lib/server/services/importService.ts new file mode 100644 index 0000000..753701d --- /dev/null +++ b/src/lib/server/services/importService.ts @@ -0,0 +1,226 @@ +import { prisma } from '../prisma.js'; +import { importDataSchema } from '$lib/utils/validators.js'; +import { DEFAULTS } from '$lib/utils/constants.js'; +import type { ExportData } from './exportService.js'; + +export type ImportMode = 'skip' | 'overwrite'; + +export interface ImportResult { + readonly apps: { readonly created: number; readonly updated: number; readonly skipped: number }; + readonly boards: { readonly created: number; readonly updated: number; readonly skipped: number }; + readonly groups: { readonly created: number; readonly updated: number; readonly skipped: number }; + readonly settingsUpdated: boolean; +} + +export function validateImportData(data: unknown): { success: true; data: ExportData } | { success: false; errors: string[] } { + const parsed = importDataSchema.safeParse(data); + if (!parsed.success) { + const errors = parsed.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`); + return { success: false, errors }; + } + return { success: true, data: parsed.data as ExportData }; +} + +export async function importData(data: ExportData, mode: ImportMode): Promise { + const result = { + apps: { created: 0, updated: 0, skipped: 0 }, + boards: { created: 0, updated: 0, skipped: 0 }, + groups: { created: 0, updated: 0, skipped: 0 }, + settingsUpdated: false + }; + + await prisma.$transaction(async (tx) => { + // --- Import Apps --- + const appNameToId = new Map(); + + for (const appData of data.apps) { + const existing = await tx.app.findFirst({ where: { name: appData.name } }); + + if (existing) { + appNameToId.set(appData.name, existing.id); + if (mode === 'skip') { + result.apps.skipped++; + continue; + } + // overwrite + await tx.app.update({ + where: { id: existing.id }, + data: { + url: appData.url, + icon: appData.icon, + iconType: appData.iconType, + description: appData.description, + category: appData.category, + tags: appData.tags, + healthcheckEnabled: appData.healthcheckEnabled, + healthcheckInterval: appData.healthcheckInterval, + healthcheckMethod: appData.healthcheckMethod, + healthcheckExpectedStatus: appData.healthcheckExpectedStatus, + healthcheckTimeout: appData.healthcheckTimeout + } + }); + result.apps.updated++; + } else { + const created = await tx.app.create({ + data: { + name: appData.name, + url: appData.url, + icon: appData.icon, + iconType: appData.iconType, + description: appData.description, + category: appData.category, + tags: appData.tags, + healthcheckEnabled: appData.healthcheckEnabled, + healthcheckInterval: appData.healthcheckInterval, + healthcheckMethod: appData.healthcheckMethod, + healthcheckExpectedStatus: appData.healthcheckExpectedStatus, + healthcheckTimeout: appData.healthcheckTimeout + } + }); + appNameToId.set(appData.name, created.id); + result.apps.created++; + } + } + + // --- Import Groups --- + for (const groupData of data.groups) { + const existing = await tx.group.findUnique({ where: { name: groupData.name } }); + + if (existing) { + if (mode === 'skip') { + result.groups.skipped++; + continue; + } + await tx.group.update({ + where: { id: existing.id }, + data: { + description: groupData.description, + isDefault: groupData.isDefault + } + }); + result.groups.updated++; + } else { + await tx.group.create({ + data: { + name: groupData.name, + description: groupData.description, + isDefault: groupData.isDefault + } + }); + result.groups.created++; + } + } + + // --- Import Boards (with sections and widgets) --- + for (const boardData of data.boards) { + const existing = await tx.board.findFirst({ where: { name: boardData.name } }); + + if (existing) { + if (mode === 'skip') { + result.boards.skipped++; + continue; + } + // Overwrite: update board, delete old sections, recreate + await tx.section.deleteMany({ where: { boardId: existing.id } }); + await tx.board.update({ + where: { id: existing.id }, + data: { + icon: boardData.icon, + description: boardData.description, + isDefault: boardData.isDefault, + isGuestAccessible: boardData.isGuestAccessible, + backgroundConfig: boardData.backgroundConfig + } + }); + + for (const sectionData of boardData.sections) { + const section = await tx.section.create({ + data: { + boardId: existing.id, + title: sectionData.title, + icon: sectionData.icon, + order: sectionData.order, + isExpandedByDefault: sectionData.isExpandedByDefault + } + }); + + for (const widgetData of sectionData.widgets) { + const appId = widgetData.appName ? (appNameToId.get(widgetData.appName) ?? null) : null; + await tx.widget.create({ + data: { + sectionId: section.id, + type: widgetData.type, + order: widgetData.order, + config: widgetData.config, + appId + } + }); + } + } + + result.boards.updated++; + } else { + const board = await tx.board.create({ + data: { + name: boardData.name, + icon: boardData.icon, + description: boardData.description, + isDefault: boardData.isDefault, + isGuestAccessible: boardData.isGuestAccessible, + backgroundConfig: boardData.backgroundConfig + } + }); + + for (const sectionData of boardData.sections) { + const section = await tx.section.create({ + data: { + boardId: board.id, + title: sectionData.title, + icon: sectionData.icon, + order: sectionData.order, + isExpandedByDefault: sectionData.isExpandedByDefault + } + }); + + for (const widgetData of sectionData.widgets) { + const appId = widgetData.appName ? (appNameToId.get(widgetData.appName) ?? null) : null; + await tx.widget.create({ + data: { + sectionId: section.id, + type: widgetData.type, + order: widgetData.order, + config: widgetData.config, + appId + } + }); + } + } + + result.boards.created++; + } + } + + // --- Import Settings (always merge) --- + if (data.settings) { + const settingsData: Record = {}; + const s = data.settings; + + if (s.authMode !== undefined) settingsData.authMode = s.authMode; + if (s.registrationEnabled !== undefined) settingsData.registrationEnabled = s.registrationEnabled; + if (s.defaultTheme !== undefined) settingsData.defaultTheme = s.defaultTheme; + if (s.defaultPrimaryColor !== undefined) settingsData.defaultPrimaryColor = s.defaultPrimaryColor; + if (s.healthcheckDefaults !== undefined) settingsData.healthcheckDefaults = s.healthcheckDefaults; + + if (Object.keys(settingsData).length > 0) { + await tx.systemSettings.upsert({ + where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }, + update: settingsData, + create: { id: DEFAULTS.SYSTEM_SETTINGS_ID, ...settingsData } + }); + result.settingsUpdated = true; + } + } + }); + + return result; +} diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts index 48e6540..008345e 100644 --- a/src/lib/stores/theme.svelte.ts +++ b/src/lib/stores/theme.svelte.ts @@ -118,6 +118,30 @@ class ThemeStore { this.primaryHue = Math.max(0, Math.min(360, hue)); this.primarySaturation = Math.max(0, Math.min(100, saturation)); } + + /** + * Apply non-null server-stored user preferences over localStorage defaults. + * Call from +layout.svelte when user data is available. + */ + loadFromServer(prefs: { + themeMode?: string | null; + primaryHue?: number | null; + primarySaturation?: number | null; + backgroundType?: string | null; + }) { + if (prefs.themeMode != null) { + this.mode = prefs.themeMode as ThemeMode; + } + if (prefs.primaryHue != null) { + this.primaryHue = prefs.primaryHue; + } + if (prefs.primarySaturation != null) { + this.primarySaturation = prefs.primarySaturation; + } + if (prefs.backgroundType != null) { + this.backgroundType = prefs.backgroundType as BackgroundType; + } + } } export const theme = new ThemeStore(); diff --git a/src/lib/utils/validators.ts b/src/lib/utils/validators.ts index d6de82d..77f4ccd 100644 --- a/src/lib/utils/validators.ts +++ b/src/lib/utils/validators.ts @@ -181,6 +181,71 @@ export const createPermissionSchema = z.object({ level: z.enum([PermissionLevel.VIEW, PermissionLevel.EDIT, PermissionLevel.ADMIN]) }); +// --- Import/Export --- + +const importAppSchema = z.object({ + name: z.string().min(1).max(200), + url: z.string().url(), + icon: z.string().max(500).nullable(), + iconType: z.string().max(50), + description: z.string().max(1000).nullable(), + category: z.string().max(100).nullable(), + tags: z.string().max(500), + healthcheckEnabled: z.boolean(), + healthcheckInterval: z.number().int().min(30).max(86400), + healthcheckMethod: z.string(), + healthcheckExpectedStatus: z.number().int().min(100).max(599), + healthcheckTimeout: z.number().int().min(1000).max(30000) +}); + +const importWidgetSchema = z.object({ + type: z.string().min(1), + order: z.number().int().min(0), + config: z.string(), + appName: z.string().nullable() +}); + +const importSectionSchema = z.object({ + title: z.string().min(1).max(200), + icon: z.string().max(500).nullable(), + order: z.number().int().min(0), + isExpandedByDefault: z.boolean(), + widgets: z.array(importWidgetSchema) +}); + +const importBoardSchema = z.object({ + name: z.string().min(1).max(200), + icon: z.string().max(500).nullable(), + description: z.string().max(1000).nullable(), + isDefault: z.boolean(), + isGuestAccessible: z.boolean(), + backgroundConfig: z.string().nullable(), + sections: z.array(importSectionSchema) +}); + +const importGroupSchema = z.object({ + name: z.string().min(1).max(100), + description: z.string().max(500).nullable(), + isDefault: z.boolean() +}); + +const importSettingsSchema = z.object({ + authMode: z.string().optional(), + registrationEnabled: z.boolean().optional(), + defaultTheme: z.string().optional(), + defaultPrimaryColor: z.string().optional(), + healthcheckDefaults: z.string().optional() +}); + +export const importDataSchema = z.object({ + version: z.string(), + exportedAt: z.string(), + apps: z.array(importAppSchema), + boards: z.array(importBoardSchema), + groups: z.array(importGroupSchema), + settings: importSettingsSchema +}); + // --- System Settings --- export const updateSystemSettingsSchema = z.object({ diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 17920b5..4c3803e 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -33,8 +33,36 @@ export const load: LayoutServerLoad = async ({ locals }) => { boards = []; } + // Fetch user preferences if authenticated + let userPreferences: { + themeMode: string | null; + primaryHue: number | null; + primarySaturation: number | null; + backgroundType: string | null; + locale: string | null; + } | null = null; + + if (locals.user) { + try { + const dbUser = await prisma.user.findUnique({ + where: { id: locals.user.id }, + select: { + themeMode: true, + primaryHue: true, + primarySaturation: true, + backgroundType: true, + locale: true + } + }); + userPreferences = dbUser ?? null; + } catch { + // Fail gracefully + } + } + return { user: locals.user, - sidebarBoards: boards + sidebarBoards: boards, + userPreferences }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 6da0017..3e1caaf 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -9,9 +9,18 @@ import { theme } from '$lib/stores/theme.svelte'; import { ui } from '$lib/stores/ui.svelte'; import { search } from '$lib/stores/search.svelte'; + import { locale as i18nLocale } from 'svelte-i18n'; let { data, children }: { data: LayoutData; children: Snippet } = $props(); + // Apply user preferences from server (overrides localStorage defaults) + if (data.userPreferences) { + theme.loadFromServer(data.userPreferences); + if (data.userPreferences.locale) { + i18nLocale.set(data.userPreferences.locale); + } + } + // Initialize store effects within component context theme.initEffects(); ui.initEffects(); diff --git a/src/routes/admin/settings/+page.svelte b/src/routes/admin/settings/+page.svelte index 1984d27..f649544 100644 --- a/src/routes/admin/settings/+page.svelte +++ b/src/routes/admin/settings/+page.svelte @@ -2,6 +2,7 @@ import { t } from 'svelte-i18n'; import type { PageData } from './$types.js'; import SettingsForm from '$lib/components/admin/SettingsForm.svelte'; + import ImportExportPanel from '$lib/components/admin/ImportExportPanel.svelte'; let { data }: { data: PageData } = $props(); @@ -10,11 +11,13 @@ {$t('admin.system_settings')} — {$t('admin.panel')} -
+

{$t('admin.system_settings')}

{$t('admin.settings_description')}

+ +
diff --git a/src/routes/api/admin/export/+server.ts b/src/routes/api/admin/export/+server.ts new file mode 100644 index 0000000..cfb4362 --- /dev/null +++ b/src/routes/api/admin/export/+server.ts @@ -0,0 +1,30 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAdmin } from '$lib/server/middleware/authorize.js'; +import { exportAllData } from '$lib/server/services/exportService.js'; +import { error } from '$lib/server/utils/response.js'; + +/** + * GET /api/admin/export — Export all data as JSON file download. Admin only. + */ +export const GET: RequestHandler = async (event) => { + requireAdmin(event); + + try { + const data = await exportAllData(); + const jsonString = JSON.stringify(data, null, 2); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const filename = `web-app-launcher-export-${timestamp}.json`; + + return new Response(jsonString, { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="${filename}"` + } + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to export data'; + return json(error(message), { status: 500 }); + } +}; diff --git a/src/routes/api/admin/import/+server.ts b/src/routes/api/admin/import/+server.ts new file mode 100644 index 0000000..2a35883 --- /dev/null +++ b/src/routes/api/admin/import/+server.ts @@ -0,0 +1,46 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAdmin } from '$lib/server/middleware/authorize.js'; +import { validateImportData, importData } from '$lib/server/services/importService.js'; +import type { ImportMode } from '$lib/server/services/importService.js'; +import { success, error } from '$lib/server/utils/response.js'; + +/** + * POST /api/admin/import — Import data from JSON. Admin only. + * Body: { data: ExportData, mode: "skip" | "overwrite" } + */ +export const POST: RequestHandler = async (event) => { + requireAdmin(event); + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + if (!body || typeof body !== 'object') { + return json(error('Request body must be an object'), { status: 400 }); + } + + const { data, mode } = body as { data: unknown; mode: unknown }; + + if (!data) { + return json(error('Missing "data" field in request body'), { status: 400 }); + } + + const validMode: ImportMode = mode === 'overwrite' ? 'overwrite' : 'skip'; + + const validation = validateImportData(data); + if (!validation.success) { + return json(error(`Validation failed: ${validation.errors.join('; ')}`), { status: 400 }); + } + + try { + const result = await importData(validation.data, validMode); + return json(success(result)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Import failed'; + return json(error(message), { status: 500 }); + } +}; diff --git a/src/routes/api/apps/[id]/history/+server.ts b/src/routes/api/apps/[id]/history/+server.ts new file mode 100644 index 0000000..030a106 --- /dev/null +++ b/src/routes/api/apps/[id]/history/+server.ts @@ -0,0 +1,46 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import * as appService from '$lib/server/services/appService.js'; +import { success, error } from '$lib/server/utils/response.js'; + +const MAX_HISTORY_RECORDS = 288; + +/** + * GET /api/apps/:id/history — Get last 24h of healthcheck history for an app. + * Returns status points sorted ascending (oldest first) and uptime percentage. + */ +export const GET: RequestHandler = async (event) => { + requireAuth(event); + + const { id } = event.params; + + try { + await appService.findById(id); + + const history = await appService.getStatusHistory(id, MAX_HISTORY_RECORDS); + + // History comes back desc from the service; reverse to ascending for sparkline + const ascending = [...history].reverse(); + + const totalChecks = ascending.length; + const onlineChecks = ascending.filter((s) => s.status === 'online').length; + const uptimePercent = totalChecks > 0 ? Math.round((onlineChecks / totalChecks) * 1000) / 10 : 0; + + return json( + success({ + history: ascending.map((s) => ({ + status: s.status, + responseTime: s.responseTime, + checkedAt: s.checkedAt + })), + uptimePercent, + totalChecks + }) + ); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch history'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; diff --git a/src/routes/api/users/me/preferences/+server.ts b/src/routes/api/users/me/preferences/+server.ts new file mode 100644 index 0000000..82ea85f --- /dev/null +++ b/src/routes/api/users/me/preferences/+server.ts @@ -0,0 +1,148 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import { prisma } from '$lib/server/prisma.js'; +import { success, error } from '$lib/server/utils/response.js'; + +const ALLOWED_FIELDS = [ + 'themeMode', + 'primaryHue', + 'primarySaturation', + 'backgroundType', + 'locale' +] as const; + +const VALID_THEME_MODES = ['dark', 'light', 'system']; +const VALID_BG_TYPES = ['mesh', 'particles', 'aurora', 'none']; +const VALID_LOCALES = ['en', 'ru']; + +/** + * GET /api/users/me/preferences — Return current user's theme/locale preferences. + */ +export const GET: RequestHandler = async (event) => { + const user = requireAuth(event); + + try { + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + themeMode: true, + primaryHue: true, + primarySaturation: true, + backgroundType: true, + locale: true + } + }); + + if (!dbUser) { + return json(error('User not found'), { status: 404 }); + } + + return json(success(dbUser)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch preferences'; + return json(error(message), { status: 500 }); + } +}; + +/** + * PATCH /api/users/me/preferences — Update any subset of user preferences. + */ +export const PATCH: RequestHandler = async (event) => { + const user = requireAuth(event); + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return json(error('Request body must be a JSON object'), { status: 400 }); + } + + const raw = body as Record; + const data: Record = {}; + + // Validate themeMode + if ('themeMode' in raw) { + if (raw.themeMode !== null && !VALID_THEME_MODES.includes(raw.themeMode as string)) { + return json(error('Invalid themeMode. Must be: dark, light, or system'), { status: 400 }); + } + data.themeMode = raw.themeMode as string | null; + } + + // Validate primaryHue + if ('primaryHue' in raw) { + if (raw.primaryHue !== null) { + const hue = Number(raw.primaryHue); + if (!Number.isFinite(hue) || hue < 0 || hue > 360) { + return json(error('primaryHue must be a number between 0 and 360'), { status: 400 }); + } + data.primaryHue = Math.round(hue); + } else { + data.primaryHue = null; + } + } + + // Validate primarySaturation + if ('primarySaturation' in raw) { + if (raw.primarySaturation !== null) { + const sat = Number(raw.primarySaturation); + if (!Number.isFinite(sat) || sat < 0 || sat > 100) { + return json(error('primarySaturation must be a number between 0 and 100'), { + status: 400 + }); + } + data.primarySaturation = Math.round(sat); + } else { + data.primarySaturation = null; + } + } + + // Validate backgroundType + if ('backgroundType' in raw) { + if (raw.backgroundType !== null && !VALID_BG_TYPES.includes(raw.backgroundType as string)) { + return json(error('Invalid backgroundType. Must be: mesh, particles, aurora, or none'), { + status: 400 + }); + } + data.backgroundType = raw.backgroundType as string | null; + } + + // Validate locale + if ('locale' in raw) { + if (raw.locale !== null && !VALID_LOCALES.includes(raw.locale as string)) { + return json(error('Invalid locale. Must be: en or ru'), { status: 400 }); + } + data.locale = raw.locale as string | null; + } + + // Filter out any unknown keys + const hasValidFields = Object.keys(data).some((k) => + ALLOWED_FIELDS.includes(k as (typeof ALLOWED_FIELDS)[number]) + ); + if (!hasValidFields) { + return json(error('No valid preference fields provided'), { status: 400 }); + } + + try { + const updated = await prisma.user.update({ + where: { id: user.id }, + data, + select: { + themeMode: true, + primaryHue: true, + primarySaturation: true, + backgroundType: true, + locale: true + } + }); + + return json(success(updated)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to update preferences'; + return json(error(message), { status: 500 }); + } +}; diff --git a/src/routes/settings/+page.server.ts b/src/routes/settings/+page.server.ts new file mode 100644 index 0000000..af73ab1 --- /dev/null +++ b/src/routes/settings/+page.server.ts @@ -0,0 +1,28 @@ +import type { PageServerLoad } from './$types.js'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import { prisma } from '$lib/server/prisma.js'; + +export const load: PageServerLoad = async (event) => { + const user = requireAuth(event); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + themeMode: true, + primaryHue: true, + primarySaturation: true, + backgroundType: true, + locale: true + } + }); + + return { + preferences: dbUser ?? { + themeMode: null, + primaryHue: null, + primarySaturation: null, + backgroundType: null, + locale: null + } + }; +}; diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte new file mode 100644 index 0000000..e5c90b4 --- /dev/null +++ b/src/routes/settings/+page.svelte @@ -0,0 +1,17 @@ + + + + {$t('settings.title')} | {$t('app_name')} + + +
+

{$t('settings.title')}

+ + +
From dd6958b4d6b1be9f73053c1a96f3627e7f9226a3 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 25 Mar 2026 00:59:19 +0300 Subject: [PATCH 3/5] feat(phase3): PWA, auto-discovery, bookmarklet, multi-tab sync - PWA: manifest, service worker (cache-first static, network-first API), offline page, install prompt banner - Auto-discovery: Docker socket + Traefik API scanning, approval UI - Quick-add bookmarklet: popup-based add page, favicon auto-detect - Multi-tab sync: BroadcastChannel for theme + data changes - i18n translations for all new strings (EN/RU) --- .env.example | 4 + PLAN_PROMPT.md | 2 +- src/app.html | 6 + .../components/admin/DiscoveryPanel.svelte | 247 ++++++++ src/lib/components/admin/SettingsForm.svelte | 40 +- .../components/layout/InstallPrompt.svelte | 103 ++++ src/lib/components/layout/MainLayout.svelte | 4 + .../settings/BookmarkletGenerator.svelte | 63 ++ src/lib/i18n/en.json | 43 +- src/lib/i18n/ru.json | 537 +++++++++--------- src/lib/server/services/discoveryService.ts | 262 +++++++++ src/lib/stores/theme.svelte.ts | 38 ++ src/lib/utils/broadcastSync.ts | 75 +++ src/routes/+layout.svelte | 14 + src/routes/admin/settings/+page.server.ts | 9 +- src/routes/admin/settings/+page.svelte | 8 +- src/routes/api/admin/discover/+server.ts | 41 ++ .../api/admin/discover/approve/+server.ts | 70 +++ src/routes/api/apps/quick-add/+server.ts | 71 +++ src/routes/apps/+page.svelte | 11 + src/routes/apps/quick-add/+page.server.ts | 64 +++ src/routes/apps/quick-add/+page.svelte | 58 ++ src/routes/boards/[boardId]/+page.svelte | 2 + src/routes/offline/+page.svelte | 38 ++ src/routes/settings/+page.svelte | 5 +- src/service-worker.ts | 131 +++++ static/icon.svg | 7 + static/manifest.json | 25 + 28 files changed, 1712 insertions(+), 266 deletions(-) create mode 100644 src/lib/components/admin/DiscoveryPanel.svelte create mode 100644 src/lib/components/layout/InstallPrompt.svelte create mode 100644 src/lib/components/settings/BookmarkletGenerator.svelte create mode 100644 src/lib/server/services/discoveryService.ts create mode 100644 src/lib/utils/broadcastSync.ts create mode 100644 src/routes/api/admin/discover/+server.ts create mode 100644 src/routes/api/admin/discover/approve/+server.ts create mode 100644 src/routes/api/apps/quick-add/+server.ts create mode 100644 src/routes/apps/quick-add/+page.server.ts create mode 100644 src/routes/apps/quick-add/+page.svelte create mode 100644 src/routes/offline/+page.svelte create mode 100644 src/service-worker.ts create mode 100644 static/icon.svg create mode 100644 static/manifest.json diff --git a/.env.example b/.env.example index 3bf0f40..79902c6 100644 --- a/.env.example +++ b/.env.example @@ -24,5 +24,9 @@ GUEST_MODE="true" HEALTHCHECK_CRON="*/5 * * * *" HEALTHCHECK_TIMEOUT_MS="5000" +# Service Discovery (optional — configure here or in Admin > Settings) +DOCKER_SOCKET_PATH="/var/run/docker.sock" +TRAEFIK_API_URL="" + # Node environment NODE_ENV="production" diff --git a/PLAN_PROMPT.md b/PLAN_PROMPT.md index 4e544db..b9ef1ef 100644 --- a/PLAN_PROMPT.md +++ b/PLAN_PROMPT.md @@ -448,7 +448,7 @@ To avoid scope creep, the MVP should include: - Additional widget types ### Phase 3 -- Auto-discovery (Docker/Traefik) +- ~~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 diff --git a/src/app.html b/src/app.html index dca9062..ffe8745 100644 --- a/src/app.html +++ b/src/app.html @@ -4,6 +4,12 @@ + + + + + + + +
+

{$t('admin.discovery_title')}

+

{$t('admin.discovery_description')}

+ + +
+ +
+ + + {#if scanErrors.length > 0} +
+ {#each scanErrors as scanError} +

{scanError}

+ {/each} +
+ {/if} + + + {#if services.length > 0} +
+ + + + + + + + + + + + {#each services as service, i} + + + + + + + + {/each} + +
+ 0} + onchange={toggleSelectAll} + disabled={selectableCount === 0} + class="h-4 w-4 rounded border-input" + /> + {$t('common.name')}URL{$t('admin.discovery_source')}{$t('admin.discovery_status')}
+ toggleSelect(i)} + disabled={service.alreadyRegistered} + class="h-4 w-4 rounded border-input" + /> + {service.name} + + {service.url} + + + + {service.source === 'docker' ? $t('admin.discovery_source_docker') : $t('admin.discovery_source_traefik')} + + + {#if service.alreadyRegistered} + {$t('admin.discovery_already_registered')} + {:else} + {$t('admin.discovery_new')} + {/if} +
+
+ + + {#if selectableCount > 0} +
+ +
+ {/if} + {/if} + + + {#if statusMessage} +
+ {statusMessage} +
+ {/if} +
diff --git a/src/lib/components/admin/SettingsForm.svelte b/src/lib/components/admin/SettingsForm.svelte index 3dc7419..c6c2144 100644 --- a/src/lib/components/admin/SettingsForm.svelte +++ b/src/lib/components/admin/SettingsForm.svelte @@ -4,7 +4,15 @@ import type { updateSystemSettingsSchema } from '$lib/utils/validators.js'; import type { z } from 'zod'; - let { form: formData }: { form: SuperValidated> } = $props(); + let { + form: formData, + dockerSocketPath = $bindable('/var/run/docker.sock'), + traefikApiUrl = $bindable('') + }: { + form: SuperValidated>; + dockerSocketPath?: string; + traefikApiUrl?: string; + } = $props(); const { form, errors, enhance, delayed } = superForm(formData); @@ -186,6 +194,36 @@
+ +
+

{$t('admin.discovery_config')}

+

{$t('admin.discovery_config_description')}

+
+
+ + +

{$t('admin.discovery_docker_socket_hint')}

+
+
+ + +

{$t('admin.discovery_traefik_url_hint')}

+
+
+
+ {#if $errors._errors}

{$errors._errors}

{/if} diff --git a/src/lib/components/layout/InstallPrompt.svelte b/src/lib/components/layout/InstallPrompt.svelte new file mode 100644 index 0000000..f1cd3b0 --- /dev/null +++ b/src/lib/components/layout/InstallPrompt.svelte @@ -0,0 +1,103 @@ + + +{#if visible} + +{/if} diff --git a/src/lib/components/layout/MainLayout.svelte b/src/lib/components/layout/MainLayout.svelte index e5199f6..2bdd061 100644 --- a/src/lib/components/layout/MainLayout.svelte +++ b/src/lib/components/layout/MainLayout.svelte @@ -3,6 +3,7 @@ import type { Snippet } from 'svelte'; import Sidebar from './Sidebar.svelte'; import Header from './Header.svelte'; + import InstallPrompt from './InstallPrompt.svelte'; import AmbientBackground from '$lib/components/background/AmbientBackground.svelte'; import SearchDialog from '$lib/components/search/SearchDialog.svelte'; import { ui } from '$lib/stores/ui.svelte.js'; @@ -66,3 +67,6 @@ + + + diff --git a/src/lib/components/settings/BookmarkletGenerator.svelte b/src/lib/components/settings/BookmarkletGenerator.svelte new file mode 100644 index 0000000..33a60a6 --- /dev/null +++ b/src/lib/components/settings/BookmarkletGenerator.svelte @@ -0,0 +1,63 @@ + + +
+

+ {$t('settings.bookmarklet_title')} +

+

+ {$t('settings.bookmarklet_instructions')} +

+ + + +
+ + {$t('settings.bookmarklet_show_code')} + +
{bookmarkletCode}
+
+
diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index c52afdc..5f60d62 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -215,6 +215,26 @@ "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.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", @@ -291,5 +311,26 @@ "settings.language": "Language", "settings.save": "Save Preferences", "settings.saving": "Saving...", - "settings.saved": "Preferences saved!" + "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", + + "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", + + "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 c1b9b03..c46e5af 100644 --- a/src/lib/i18n/ru.json +++ b/src/lib/i18n/ru.json @@ -1,219 +1,228 @@ { "app_name": "App Launcher", "app_title": "Web App Launcher", - - "nav.navigation": "\u041d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u044f", - "nav.boards": "\u0414\u043e\u0441\u043a\u0438", - "nav.apps": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f", - "nav.admin": "\u0410\u0434\u043c\u0438\u043d", - "nav.admin_panel": "\u041f\u0430\u043d\u0435\u043b\u044c \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0430", - - "auth.login": "\u0412\u043e\u0439\u0442\u0438", - "auth.login_title": "\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c", - "auth.login_subtitle": "\u0412\u043e\u0439\u0434\u0438\u0442\u0435 \u0432 \u0441\u0432\u043e\u0439 \u0430\u043a\u043a\u0430\u0443\u043d\u0442", - "auth.login_submit": "\u0412\u043e\u0439\u0442\u0438", - "auth.login_submitting": "\u0412\u0445\u043e\u0434...", - "auth.register": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f", - "auth.register_title": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0430\u043a\u043a\u0430\u0443\u043d\u0442", - "auth.register_subtitle": "\u041d\u0430\u0447\u043d\u0438\u0442\u0435 \u0440\u0430\u0431\u043e\u0442\u0443 \u0441 App Launcher", - "auth.register_submit": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0430\u043a\u043a\u0430\u0443\u043d\u0442", - "auth.register_submitting": "\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435 \u0430\u043a\u043a\u0430\u0443\u043d\u0442\u0430...", - "auth.email": "\u042d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u0430\u044f \u043f\u043e\u0447\u0442\u0430", + "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": "\u041f\u0430\u0440\u043e\u043b\u044c", - "auth.password_placeholder": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c", - "auth.password_placeholder_register": "\u041d\u0435 \u043c\u0435\u043d\u0435\u0435 6 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432", - "auth.display_name": "\u0418\u043c\u044f", - "auth.display_name_placeholder": "\u0412\u0430\u0448\u0435 \u0438\u043c\u044f", - "auth.logout": "\u0412\u044b\u0445\u043e\u0434", - "auth.oauth_signin": "\u0412\u043e\u0439\u0442\u0438 \u0447\u0435\u0440\u0435\u0437 OAuth", - "auth.or": "\u0438\u043b\u0438", - "auth.no_account": "\u041d\u0435\u0442 \u0430\u043a\u043a\u0430\u0443\u043d\u0442\u0430?", - "auth.have_account": "\u0423\u0436\u0435 \u0435\u0441\u0442\u044c \u0430\u043a\u043a\u0430\u0443\u043d\u0442?", - "auth.sign_in_link": "\u0412\u043e\u0439\u0442\u0438", - - "board.title": "\u0414\u043e\u0441\u043a\u0438", - "board.boards_available": "\u0414\u043e\u0441\u0442\u0443\u043f\u043d\u043e \u0434\u043e\u0441\u043e\u043a: {count}", - "board.new": "\u041d\u043e\u0432\u0430\u044f \u0434\u043e\u0441\u043a\u0430", - "board.edit": "\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c", - "board.edit_board": "\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0434\u043e\u0441\u043a\u0438", - "board.all_boards": "\u0412\u0441\u0435 \u0434\u043e\u0441\u043a\u0438", - "board.back_to_boards": "\u041d\u0430\u0437\u0430\u0434 \u043a \u0434\u043e\u0441\u043a\u0430\u043c", - "board.back_to_board": "\u041d\u0430\u0437\u0430\u0434 \u043a \u0434\u043e\u0441\u043a\u0435", - "board.no_boards": "\u0414\u043e\u0441\u043a\u0438 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.", - "board.sign_in_more": "\u0412\u043e\u0439\u0434\u0438\u0442\u0435, \u0447\u0442\u043e\u0431\u044b \u0443\u0432\u0438\u0434\u0435\u0442\u044c \u0431\u043e\u043b\u044c\u0448\u0435 \u0434\u043e\u0441\u043e\u043a.", - "board.no_sections": "\u041d\u0430 \u044d\u0442\u043e\u0439 \u0434\u043e\u0441\u043a\u0435 \u043f\u043e\u043a\u0430 \u043d\u0435\u0442 \u0440\u0430\u0437\u0434\u0435\u043b\u043e\u0432.", - "board.default": "\u041f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e", - "board.guest": "\u0413\u043e\u0441\u0442\u0435\u0432\u0430\u044f", - "board.sections_count": "\u0420\u0430\u0437\u0434\u0435\u043b\u043e\u0432: {count}", - "board.properties": "\u0421\u0432\u043e\u0439\u0441\u0442\u0432\u0430 \u0434\u043e\u0441\u043a\u0438", - "board.save": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c \u0434\u043e\u0441\u043a\u0443", - "board.create": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0434\u043e\u0441\u043a\u0443", - "board.creating": "\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435...", - "board.default_board": "\u0414\u043e\u0441\u043a\u0430 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e", - "board.guest_accessible": "\u0414\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0433\u043e\u0441\u0442\u044f\u043c", - "board.guest_access_title": "\u0413\u043e\u0441\u0442\u0435\u0432\u043e\u0439 \u0434\u043e\u0441\u0442\u0443\u043f", - "board.guest_access_description": "\u041f\u0440\u0438 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u044d\u0442\u0430 \u0434\u043e\u0441\u043a\u0430 \u0432\u0438\u0434\u043d\u0430 \u043d\u0435\u0430\u0432\u0442\u043e\u0440\u0438\u0437\u043e\u0432\u0430\u043d\u043d\u044b\u043c \u043f\u043e\u0441\u0435\u0442\u0438\u0442\u0435\u043b\u044f\u043c \u0431\u0435\u0437 \u0432\u0445\u043e\u0434\u0430 \u0432 \u0441\u0438\u0441\u0442\u0435\u043c\u0443.", - "board.guest_access_enabled": "\u042d\u0442\u0430 \u0434\u043e\u0441\u043a\u0430 \u043e\u0431\u0449\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430", - "board.guest_access_disabled": "\u042d\u0442\u0430 \u0434\u043e\u0441\u043a\u0430 \u043f\u0440\u0438\u0432\u0430\u0442\u043d\u0430", - "board.permissions_title": "\u041f\u0440\u0430\u0432\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430", - "board.permissions_description": "\u0423\u043f\u0440\u0430\u0432\u043b\u044f\u0439\u0442\u0435, \u043a\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u043f\u0440\u043e\u0441\u043c\u0430\u0442\u0440\u0438\u0432\u0430\u0442\u044c, \u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0438\u043b\u0438 \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u044d\u0442\u0443 \u0434\u043e\u0441\u043a\u0443.", - "board.access_grant": "\u041d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f", - "board.access_search_placeholder": "\u041f\u043e\u0438\u0441\u043a...", - "board.access_loading": "\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u043f\u0440\u0430\u0432...", - "board.access_none": "\u041f\u0440\u0430\u0432\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u0434\u043e\u0441\u043a\u0438 \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b.", - "board.access_private": "\u041f\u0440\u0438\u0432\u0430\u0442\u043d\u0430\u044f", - "board.access_shared": "\u041e\u0431\u0449\u0430\u044f", - "board.share": "\u041f\u043e\u0434\u0435\u043b\u0438\u0442\u044c\u0441\u044f", - "board.share_title": "\u041f\u043e\u0434\u0435\u043b\u0438\u0442\u044c\u0441\u044f \u00ab{name}\u00bb", - "board.share_copy_link": "\u041a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0441\u0441\u044b\u043b\u043a\u0443", - "board.share_copied": "\u0421\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u043e!", - "board.share_guest_description": "\u041b\u044e\u0431\u043e\u0439 \u0441 \u044d\u0442\u043e\u0439 \u0441\u0441\u044b\u043b\u043a\u043e\u0439 \u043c\u043e\u0436\u0435\u0442 \u043f\u0440\u043e\u0441\u043c\u0430\u0442\u0440\u0438\u0432\u0430\u0442\u044c \u0434\u043e\u0441\u043a\u0443 \u0431\u0435\u0437 \u0432\u0445\u043e\u0434\u0430.", - "board.share_add_access": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u043b\u044e\u0434\u0435\u0439 \u0438\u043b\u0438 \u0433\u0440\u0443\u043f\u043f\u044b", - "board.share_current_access": "\u0422\u0435\u043a\u0443\u0449\u0438\u0439 \u0434\u043e\u0441\u0442\u0443\u043f", - - "section.title_label": "\u0417\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a", - "section.icon_label": "\u0418\u043a\u043e\u043d\u043a\u0430", - "section.icon_placeholder": "\u041d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e", - "section.sections": "\u0420\u0430\u0437\u0434\u0435\u043b\u044b", - "section.add": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0440\u0430\u0437\u0434\u0435\u043b", - "section.create": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0440\u0430\u0437\u0434\u0435\u043b", - "section.order": "\u041f\u043e\u0440\u044f\u0434\u043e\u043a: {order}", - - "widget.add": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0432\u0438\u0434\u0436\u0435\u0442", - "widget.select_app": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435", - "widget.choose_app": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435...", - "widget.no_widgets": "\u0412 \u044d\u0442\u043e\u043c \u0440\u0430\u0437\u0434\u0435\u043b\u0435 \u043d\u0435\u0442 \u0432\u0438\u0434\u0436\u0435\u0442\u043e\u0432.", - "widget.no_widgets_dnd": "\u041d\u0435\u0442 \u0432\u0438\u0434\u0436\u0435\u0442\u043e\u0432. \u041f\u0435\u0440\u0435\u0442\u0430\u0449\u0438\u0442\u0435 \u0441\u044e\u0434\u0430 \u0438\u043b\u0438 \u0434\u043e\u0431\u0430\u0432\u044c\u0442\u0435 \u0432\u044b\u0448\u0435.", - "widget.type": "\u0412\u0438\u0434\u0436\u0435\u0442 {type}", - "widget.number": "\u0412\u0438\u0434\u0436\u0435\u0442 #{order}", - "widget.remove": "\u0423\u0434\u0430\u043b\u0438\u0442\u044c", - - "app.title": "\u0420\u0435\u0435\u0441\u0442\u0440 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439", - "app.apps_registered": "\u0417\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u043e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439: {count}", - "app.add": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435", - "app.new": "\u041d\u043e\u0432\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435", - "app.no_apps": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0435\u0449\u0451 \u043d\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u044b.", - "app.no_apps_hint": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u00bb, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u0435\u0440\u0432\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435.", - "app.all_categories": "\u0412\u0441\u0435", - "app.name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", - "app.name_placeholder": "\u041c\u043e\u0451 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435", + "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": "\u041e\u043f\u0438\u0441\u0430\u043d\u0438\u0435", - "app.description_placeholder": "\u041a\u0440\u0430\u0442\u043a\u043e\u0435 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f", - "app.category": "\u041a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f", - "app.category_placeholder": "\u043d\u0430\u043f\u0440. \u041c\u0435\u0434\u0438\u0430, \u041c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433, \u0425\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435", - "app.tags": "\u0422\u0435\u0433\u0438", - "app.tags_placeholder": "\u0422\u0435\u0433\u0438 \u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043f\u044f\u0442\u0443\u044e", - "app.icon": "\u0418\u043a\u043e\u043d\u043a\u0430", + "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 \u0438\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f", - "app.icon_emoji": "\u042d\u043c\u043e\u0434\u0437\u0438", - "app.icon_lucide_placeholder": "\u043d\u0430\u043f\u0440. globe, server, home", - "app.icon_simple_placeholder": "\u043d\u0430\u043f\u0440. github, docker", + "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": "\u043d\u0430\u043f\u0440. \ud83c\udf10", - "app.icon_preview": "\u041f\u0440\u0435\u0432\u044c\u044e \u0438\u043a\u043e\u043d\u043a\u0438", - "app.save": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c", - "app.saving": "\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435...", - "app.healthcheck_toggle": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u044f", - "app.healthcheck_show": "\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c", - "app.healthcheck_hide": "\u0421\u043a\u0440\u044b\u0442\u044c", - "app.healthcheck_enabled": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443 \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u044f", - "app.healthcheck_method": "\u041c\u0435\u0442\u043e\u0434", - "app.healthcheck_expected_status": "\u041e\u0436\u0438\u0434\u0430\u0435\u043c\u044b\u0439 \u0441\u0442\u0430\u0442\u0443\u0441", - "app.healthcheck_timeout": "\u0422\u0430\u0439\u043c\u0430\u0443\u0442 (\u043c\u0441)", - "app.healthcheck_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b (\u0441\u0435\u043a\u0443\u043d\u0434\u044b)", - "app.icon_board_label": "\u0418\u043a\u043e\u043d\u043a\u0430 (Lucide)", - "app.uptime": "\u0430\u043f\u0442\u0430\u0439\u043c", - "app.history_loading": "\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u0438\u0441\u0442\u043e\u0440\u0438\u0438...", - - "admin.panel": "\u041f\u0430\u043d\u0435\u043b\u044c \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0430", - "admin.users": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438", - "admin.groups": "\u0413\u0440\u0443\u043f\u043f\u044b", - "admin.settings": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438", - - "admin.user_management": "\u0423\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f\u043c\u0438", - "admin.create_user": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f", - "admin.new_user": "\u041d\u043e\u0432\u044b\u0439 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c", - "admin.user_column": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c", - "admin.email_column": "\u042d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u0430\u044f \u043f\u043e\u0447\u0442\u0430", - "admin.role_column": "\u0420\u043e\u043b\u044c", - "admin.provider_column": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440", - "admin.groups_column": "\u0413\u0440\u0443\u043f\u043f\u044b", - "admin.actions_column": "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044f", - "admin.role_user": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c", - "admin.role_admin": "\u0410\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440", - "admin.select_group": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u0443", - "admin.add_to_group": "+ \u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c", - "admin.remove_from_group": "\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u0438\u0437 \u0433\u0440\u0443\u043f\u043f\u044b", - "admin.no_users": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.", - - "admin.group_management": "\u0423\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0433\u0440\u0443\u043f\u043f\u0430\u043c\u0438", - "admin.create_group": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u0443", - "admin.new_group": "\u041d\u043e\u0432\u0430\u044f \u0433\u0440\u0443\u043f\u043f\u0430", - "admin.name_column": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", - "admin.description_column": "\u041e\u043f\u0438\u0441\u0430\u043d\u0438\u0435", - "admin.members_column": "\u0423\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u0438", - "admin.default_column": "\u041f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e", - "admin.default_group_hint": "\u0413\u0440\u0443\u043f\u043f\u0430 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e (\u0430\u0432\u0442\u043e-\u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043d\u043e\u0432\u044b\u043c \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f\u043c)", - "admin.no_groups": "\u0413\u0440\u0443\u043f\u043f\u044b \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.", - "admin.yes": "\u0414\u0430", - "admin.no": "\u041d\u0435\u0442", - - "admin.system_settings": "\u0421\u0438\u0441\u0442\u0435\u043c\u043d\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438", - "admin.settings_description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0433\u043b\u043e\u0431\u0430\u043b\u044c\u043d\u044b\u0445 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043e\u0432 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f.", - "admin.authentication": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f", - "admin.auth_mode": "\u0420\u0435\u0436\u0438\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438", - "admin.auth_local": "\u041b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0439", + "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": "\u041e\u0431\u0430", - "admin.registration_enabled": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044e \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439", - "admin.oauth_config": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 OAuth", - "admin.oauth_description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u043f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440 OIDC (\u043d\u0430\u043f\u0440. Authentik, Keycloak). \u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u0435 \u0440\u0435\u0436\u0438\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u00abOAuth\u00bb \u0438\u043b\u0438 \u00ab\u041e\u0431\u0430\u00bb \u0432\u044b\u0448\u0435, \u0447\u0442\u043e\u0431\u044b \u0432\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0432\u0445\u043e\u0434 \u0447\u0435\u0440\u0435\u0437 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": "\u0421\u0435\u043a\u0440\u0435\u0442 \u043a\u043b\u0438\u0435\u043d\u0442\u0430", - "admin.oauth_client_secret_placeholder": "\u0421\u0435\u043a\u0440\u0435\u0442 OAuth \u043a\u043b\u0438\u0435\u043d\u0442\u0430", + "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": "\u0422\u0435\u0441\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", - "admin.oauth_testing": "\u0422\u0435\u0441\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435...", - "admin.oauth_connected": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043e \u043a: {issuer}", - "admin.oauth_network_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u0435\u0442\u0438 \u2014 \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0441\u0432\u044f\u0437\u0430\u0442\u044c\u0441\u044f \u0441 \u0441\u0435\u0440\u0432\u0435\u0440\u043e\u043c", - "admin.theme_defaults": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0442\u0435\u043c\u044b", - "admin.default_theme": "\u0422\u0435\u043c\u0430 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e", - "admin.default_primary_color": "\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0446\u0432\u0435\u0442 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e", - "admin.healthcheck_defaults": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u044f", - "admin.healthcheck_defaults_description": "JSON-\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u044f \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e (\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b, \u0442\u0430\u0439\u043c\u0430\u0443\u0442, \u043c\u0435\u0442\u043e\u0434).", - "admin.healthcheck_defaults_label": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 (JSON)", - "admin.save_settings": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438", - "admin.saving_settings": "\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435...", + "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.perm_title": "\u041d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c \u043f\u0440\u0430\u0432\u0430", - "admin.perm_entity_type": "\u0422\u0438\u043f \u043e\u0431\u044a\u0435\u043a\u0442\u0430", - "admin.perm_entity": "\u041e\u0431\u044a\u0435\u043a\u0442", - "admin.perm_target_type": "\u0422\u0438\u043f \u0446\u0435\u043b\u0438", - "admin.perm_target": "\u0426\u0435\u043b\u044c", - "admin.perm_level": "\u0423\u0440\u043e\u0432\u0435\u043d\u044c", - "admin.perm_board": "\u0414\u043e\u0441\u043a\u0430", - "admin.perm_app": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435", - "admin.perm_user": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c", - "admin.perm_group": "\u0413\u0440\u0443\u043f\u043f\u0430", - "admin.perm_view": "\u041f\u0440\u043e\u0441\u043c\u043e\u0442\u0440", - "admin.perm_edit": "\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435", - "admin.perm_admin": "\u0410\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440", - "admin.perm_grant": "\u041d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c", - "admin.perm_revoke": "\u041e\u0442\u043e\u0437\u0432\u0430\u0442\u044c", - "admin.perm_select": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c...", - "admin.perm_entity_column": "\u041e\u0431\u044a\u0435\u043a\u0442", - "admin.perm_target_column": "\u0426\u0435\u043b\u044c", - "admin.perm_level_column": "\u0423\u0440\u043e\u0432\u0435\u043d\u044c", - "admin.perm_action_column": "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u0435", - "admin.perm_none": "\u041f\u0440\u0430\u0432\u0430 \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b.", - "admin.perm_search_placeholder": "\u041d\u0430\u0447\u043d\u0438\u0442\u0435 \u0432\u0432\u043e\u0434\u0438\u0442\u044c...", + "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 или импортируйте из ранее экспортированного файла.", @@ -231,65 +240,73 @@ "admin.import_importing": "Импорт...", "admin.import_success": "Импорт завершён.", "admin.import_invalid_json": "Выбранный файл не является корректным JSON.", - "search.placeholder": "Поиск приложений и досок...", - "search.trigger": "\u041f\u043e\u0438\u0441\u043a...", - "search.min_chars": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043c\u0438\u043d\u0438\u043c\u0443\u043c 2 \u0441\u0438\u043c\u0432\u043e\u043b\u0430 \u0434\u043b\u044f \u043f\u043e\u0438\u0441\u043a\u0430", - "search.no_results": "\u041d\u0438\u0447\u0435\u0433\u043e \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e \u043f\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u0443 \u00ab{query}\u00bb", - "search.apps": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f", - "search.boards": "\u0414\u043e\u0441\u043a\u0438", - - "common.save": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c", - "common.cancel": "\u041e\u0442\u043c\u0435\u043d\u0430", - "common.delete": "\u0423\u0434\u0430\u043b\u0438\u0442\u044c", - "common.create": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c", - "common.back": "\u041d\u0430\u0437\u0430\u0434", - "common.edit": "\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c", - "common.add": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c", - "common.confirm": "\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c?", - "common.yes": "\u0414\u0430", - "common.no": "\u041d\u0435\u0442", - "common.name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", - "common.description": "\u041e\u043f\u0438\u0441\u0430\u043d\u0438\u0435", + "search.trigger": "Поиск...", + "search.min_chars": "Введите минимум 2 символа для поиска", + "search.no_results": "Ничего не найдено по запросу «{query}»", + "search.apps": "Приложения", + "search.boards": "Доски", + "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": "\u041e\u043d\u043b\u0430\u0439\u043d", - "status.offline": "\u041e\u0444\u0444\u043b\u0430\u0439\u043d", - "status.degraded": "\u041d\u0435\u0441\u0442\u0430\u0431\u0438\u043b\u044c\u043d\u043e", - "status.unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u043e", - - "theme.dark": "\u0422\u0451\u043c\u043d\u0430\u044f", - "theme.light": "\u0421\u0432\u0435\u0442\u043b\u0430\u044f", - "theme.system": "\u0421\u0438\u0441\u0442\u0435\u043c\u043d\u0430\u044f", - "theme.toggle": "\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0442\u0435\u043c\u0443 (\u0442\u0435\u043a\u0443\u0449\u0430\u044f: {mode})", - "theme.title": "\u0422\u0435\u043c\u0430: {mode}", - - "bg.mesh": "\u041c\u0435\u0448-\u0433\u0440\u0430\u0434\u0438\u0435\u043d\u0442", - "bg.particles": "\u0427\u0430\u0441\u0442\u0438\u0446\u044b", - "bg.aurora": "\u0421\u0438\u044f\u043d\u0438\u0435", - "bg.none": "\u041d\u0435\u0442", - "bg.title": "\u042d\u0444\u0444\u0435\u043a\u0442 \u0444\u043e\u043d\u0430", - "bg.aria_label": "\u0418\u0437\u043c\u0435\u043d\u0438\u0442\u044c \u044d\u0444\u0444\u0435\u043a\u0442 \u0444\u043e\u043d\u0430", - - "sidebar.expand": "\u0420\u0430\u0437\u0432\u0435\u0440\u043d\u0443\u0442\u044c \u0431\u043e\u043a\u043e\u0432\u0443\u044e \u043f\u0430\u043d\u0435\u043b\u044c", - "sidebar.collapse": "\u0421\u0432\u0435\u0440\u043d\u0443\u0442\u044c \u0431\u043e\u043a\u043e\u0432\u0443\u044e \u043f\u0430\u043d\u0435\u043b\u044c", - "sidebar.toggle": "\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0431\u043e\u043a\u043e\u0432\u0443\u044e \u043f\u0430\u043d\u0435\u043b\u044c", - "sidebar.close": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c \u0431\u043e\u043a\u043e\u0432\u0443\u044e \u043f\u0430\u043d\u0435\u043b\u044c", - - "home.welcome": "\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c, {name}. \u0414\u043e\u0441\u043a\u0430 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0435\u0449\u0451 \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430.", - "home.view_boards": "\u041f\u043e\u0441\u043c\u043e\u0442\u0440\u0435\u0442\u044c \u0434\u043e\u0441\u043a\u0438", - "home.browse_apps": "\u041e\u0431\u0437\u043e\u0440 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439", - - "language.label": "\u042f\u0437\u044b\u043a", - - "settings.title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438", - "settings.theme": "\u0420\u0435\u0436\u0438\u043c \u0442\u0435\u043c\u044b", - "settings.primary_color": "\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0446\u0432\u0435\u0442", - "settings.hue": "\u041e\u0442\u0442\u0435\u043d\u043e\u043a", - "settings.saturation": "\u041d\u0430\u0441\u044b\u0449\u0435\u043d\u043d\u043e\u0441\u0442\u044c", - "settings.background": "\u042d\u0444\u0444\u0435\u043a\u0442 \u0444\u043e\u043d\u0430", - "settings.language": "\u042f\u0437\u044b\u043a", - "settings.save": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438", - "settings.saving": "\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435...", - "settings.saved": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u044b!" + "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/services/discoveryService.ts b/src/lib/server/services/discoveryService.ts new file mode 100644 index 0000000..0906e7a --- /dev/null +++ b/src/lib/server/services/discoveryService.ts @@ -0,0 +1,262 @@ +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; +import { findAll as findAllApps } from './appService.js'; + +const execAsync = promisify(exec); + +// --- Types --- + +export interface DiscoveredService { + readonly name: string; + readonly url: string; + readonly source: 'docker' | 'traefik'; + readonly icon?: string; + readonly description?: string; + readonly alreadyRegistered: boolean; +} + +export interface DiscoveryConfig { + readonly dockerSocketPath?: string; + readonly traefikApiUrl?: string; +} + +export interface DiscoveryResult { + readonly services: readonly DiscoveredService[]; + readonly errors: readonly string[]; +} + +// --- Docker types --- + +interface DockerContainer { + readonly Id: string; + readonly Names: readonly string[]; + readonly Image: string; + readonly Ports: readonly DockerPort[]; + readonly Labels: Record; + readonly State: string; +} + +interface DockerPort { + readonly IP?: string; + readonly PrivatePort: number; + readonly PublicPort?: number; + readonly Type: string; +} + +// --- Traefik types --- + +interface TraefikRouter { + readonly name: string; + readonly rule: string; + readonly service: string; + readonly entryPoints?: readonly string[]; + readonly status?: string; +} + +interface TraefikService { + readonly name: string; + readonly loadBalancer?: { + readonly servers?: readonly { readonly url: string }[]; + }; +} + +// --- Docker Discovery --- + +function extractUrlFromDockerLabels(labels: Record): string | null { + // Check for Traefik Host rule in labels + for (const [key, value] of Object.entries(labels)) { + if (key.match(/traefik\.http\.routers\..+\.rule/)) { + const hostMatch = value.match(/Host\(`([^`]+)`\)/); + if (hostMatch) { + const scheme = labels[key.replace('.rule', '.entrypoints')]?.includes('websecure') + ? 'https' + : 'http'; + return `${scheme}://${hostMatch[1]}`; + } + } + } + return null; +} + +function extractNameFromContainer(container: DockerContainer): string { + const rawName = container.Names[0] ?? container.Id.slice(0, 12); + // Docker container names start with / + return rawName.replace(/^\//, ''); +} + +function buildUrlFromPorts(ports: readonly DockerPort[]): string | null { + const publicPort = ports.find((p) => p.PublicPort && p.Type === 'tcp'); + if (publicPort?.PublicPort) { + const host = publicPort.IP && publicPort.IP !== '0.0.0.0' ? publicPort.IP : 'localhost'; + return `http://${host}:${publicPort.PublicPort}`; + } + return null; +} + +export async function discoverDocker(socketPath: string): Promise<{ + readonly services: readonly DiscoveredService[]; + readonly error?: string; +}> { + try { + // Use curl with Unix socket to query Docker API + const { stdout } = await execAsync( + `curl -s --unix-socket "${socketPath}" http://localhost/containers/json?all=false`, + { timeout: 10000 } + ); + + const containers: DockerContainer[] = JSON.parse(stdout); + + const services: DiscoveredService[] = []; + + for (const container of containers) { + const name = extractNameFromContainer(container); + const labelUrl = extractUrlFromDockerLabels(container.Labels); + const portUrl = buildUrlFromPorts(container.Ports); + const url = labelUrl ?? portUrl; + + if (!url) { + continue; // Skip containers without accessible URLs + } + + const description = container.Labels['org.opencontainers.image.description'] + ?? `Docker container: ${container.Image}`; + + services.push({ + name, + url, + source: 'docker', + icon: container.Labels['org.opencontainers.image.title']?.toLowerCase(), + description, + alreadyRegistered: false // Will be resolved in discoverAll + }); + } + + return { services }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Docker discovery failed'; + return { services: [], error: message }; + } +} + +// --- Traefik Discovery --- + +function extractHostFromRule(rule: string): string | null { + const hostMatch = rule.match(/Host\(`([^`]+)`\)/); + return hostMatch ? hostMatch[1] : null; +} + +export async function discoverTraefik(apiUrl: string): Promise<{ + readonly services: readonly DiscoveredService[]; + readonly error?: string; +}> { + try { + const normalizedUrl = apiUrl.replace(/\/+$/, ''); + + const [routersRes, servicesRes] = await Promise.all([ + fetch(`${normalizedUrl}/api/http/routers`), + fetch(`${normalizedUrl}/api/http/services`) + ]); + + if (!routersRes.ok) { + return { + services: [], + error: `Traefik routers API returned ${routersRes.status}` + }; + } + + const routers: TraefikRouter[] = await routersRes.json(); + const traefikServices: TraefikService[] = servicesRes.ok ? await servicesRes.json() : []; + + // Build a map of service name -> backend URL + const serviceUrlMap = new Map(); + for (const svc of traefikServices) { + const backendUrl = svc.loadBalancer?.servers?.[0]?.url; + if (backendUrl) { + serviceUrlMap.set(svc.name, backendUrl); + } + } + + const services: DiscoveredService[] = []; + + for (const router of routers) { + const host = extractHostFromRule(router.rule); + if (!host) continue; + + 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) + const name = router.name.replace(/@.*$/, ''); + + services.push({ + name, + url: frontendUrl, + source: 'traefik', + description: serviceUrlMap.get(router.service) + ? `Backend: ${serviceUrlMap.get(router.service)}` + : undefined, + alreadyRegistered: false // Will be resolved in discoverAll + }); + } + + return { services }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Traefik discovery failed'; + return { services: [], error: message }; + } +} + +// --- Combined Discovery --- + +export async function discoverAll(config: DiscoveryConfig): Promise { + const errors: string[] = []; + const allServices: DiscoveredService[] = []; + + // Run discovery in parallel + const [dockerResult, traefikResult] = await Promise.all([ + config.dockerSocketPath + ? discoverDocker(config.dockerSocketPath) + : Promise.resolve({ services: [] as DiscoveredService[] }), + config.traefikApiUrl + ? discoverTraefik(config.traefikApiUrl) + : Promise.resolve({ services: [] as DiscoveredService[] }) + ]); + + if ('error' in dockerResult && dockerResult.error) { + errors.push(`Docker: ${dockerResult.error}`); + } + allServices.push(...dockerResult.services); + + if ('error' in traefikResult && traefikResult.error) { + errors.push(`Traefik: ${traefikResult.error}`); + } + allServices.push(...traefikResult.services); + + // Deduplicate by URL (prefer Traefik entries since they have frontend URLs) + const urlMap = new Map(); + for (const service of allServices) { + const normalizedUrl = service.url.replace(/\/+$/, '').toLowerCase(); + const existing = urlMap.get(normalizedUrl); + if (!existing || (service.source === 'traefik' && existing.source === 'docker')) { + urlMap.set(normalizedUrl, service); + } + } + + // Check which services are already registered as apps + const existingApps = await findAllApps(); + const existingUrls = new Set( + existingApps.map((app) => app.url.replace(/\/+$/, '').toLowerCase()) + ); + + const services = Array.from(urlMap.values()).map((service) => { + const normalizedUrl = service.url.replace(/\/+$/, '').toLowerCase(); + return { + ...service, + alreadyRegistered: existingUrls.has(normalizedUrl) + }; + }); + + return { services, errors }; +} diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts index 008345e..ce0d4d4 100644 --- a/src/lib/stores/theme.svelte.ts +++ b/src/lib/stores/theme.svelte.ts @@ -1,3 +1,5 @@ +import { broadcastThemeChange } from '$lib/utils/broadcastSync.js'; + const THEME_STORAGE_KEY = 'wal-theme-mode'; const PRIMARY_HUE_KEY = 'wal-primary-hue'; const PRIMARY_SAT_KEY = 'wal-primary-sat'; @@ -36,6 +38,7 @@ class ThemeStore { backgroundType = $state('mesh'); #systemPreference: 'dark' | 'light' = 'dark'; + #suppressBroadcast = false; resolvedMode = $derived<'dark' | 'light'>( this.mode === 'system' ? this.#systemPreference : this.mode @@ -98,6 +101,20 @@ class ThemeStore { html.style.setProperty('--primary-h', String(this.primaryHue)); html.style.setProperty('--primary-s', `${this.primarySaturation}%`); }); + + // Broadcast theme changes to other tabs + $effect(() => { + // Read all reactive values to track them + const snapshot = { + mode: this.mode, + primaryHue: this.primaryHue, + primarySaturation: this.primarySaturation, + backgroundType: this.backgroundType + }; + if (typeof window === 'undefined') return; + if (this.#suppressBroadcast) return; + broadcastThemeChange(snapshot); + }); } cycleMode() { @@ -119,6 +136,27 @@ class ThemeStore { this.primarySaturation = Math.max(0, Math.min(100, saturation)); } + /** + * Apply theme values received from another tab via BroadcastChannel. + * Suppresses re-broadcasting to avoid echo loops. + */ + applyFromBroadcast(values: { + mode: ThemeMode; + primaryHue: number; + primarySaturation: number; + backgroundType: BackgroundType; + }) { + this.#suppressBroadcast = true; + this.mode = values.mode; + this.primaryHue = values.primaryHue; + this.primarySaturation = values.primarySaturation; + this.backgroundType = values.backgroundType; + // Re-enable on next microtask so the effect reads suppressBroadcast=true + queueMicrotask(() => { + this.#suppressBroadcast = false; + }); + } + /** * Apply non-null server-stored user preferences over localStorage defaults. * Call from +layout.svelte when user data is available. diff --git a/src/lib/utils/broadcastSync.ts b/src/lib/utils/broadcastSync.ts new file mode 100644 index 0000000..feebe42 --- /dev/null +++ b/src/lib/utils/broadcastSync.ts @@ -0,0 +1,75 @@ +import type { ThemeMode, BackgroundType } from '$lib/stores/theme.svelte'; + +const CHANNEL_NAME = 'wal-sync'; + +export interface ThemeChangeMessage { + readonly type: 'theme-change'; + readonly payload: { + readonly mode: ThemeMode; + readonly primaryHue: number; + readonly primarySaturation: number; + readonly backgroundType: BackgroundType; + }; +} + +export interface DataChangeMessage { + readonly type: 'data-change'; + readonly payload: { + readonly entity: 'board' | 'app' | 'widget'; + }; +} + +export type SyncMessage = ThemeChangeMessage | DataChangeMessage; + +function getChannel(): BroadcastChannel | null { + if (typeof window === 'undefined') return null; + try { + return new BroadcastChannel(CHANNEL_NAME); + } catch { + return null; + } +} + +/** + * Broadcast a theme change to other tabs. + */ +export function broadcastThemeChange(theme: ThemeChangeMessage['payload']): void { + const channel = getChannel(); + if (!channel) return; + const message: ThemeChangeMessage = { type: 'theme-change', payload: theme }; + channel.postMessage(message); + channel.close(); +} + +/** + * Broadcast a data change (board/app/widget CRUD) to other tabs. + */ +export function broadcastDataChange(entity: 'board' | 'app' | 'widget'): void { + const channel = getChannel(); + if (!channel) return; + const message: DataChangeMessage = { type: 'data-change', payload: { entity } }; + channel.postMessage(message); + channel.close(); +} + +/** + * Listen for sync messages from other tabs. + * Returns a cleanup function to stop listening. + */ +export function onSyncMessage(callback: (msg: SyncMessage) => void): () => void { + const channel = getChannel(); + if (!channel) return () => {}; + + const handler = (event: MessageEvent) => { + if (event.data && typeof event.data.type === 'string') { + callback(event.data); + } + }; + + channel.addEventListener('message', handler); + + return () => { + channel.removeEventListener('message', handler); + channel.close(); + }; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3e1caaf..102cafe 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -10,6 +10,9 @@ import { ui } from '$lib/stores/ui.svelte'; import { search } from '$lib/stores/search.svelte'; import { locale as i18nLocale } from 'svelte-i18n'; + import { onSyncMessage } from '$lib/utils/broadcastSync.js'; + import { invalidateAll } from '$app/navigation'; + import { onDestroy } from 'svelte'; let { data, children }: { data: LayoutData; children: Snippet } = $props(); @@ -26,6 +29,17 @@ ui.initEffects(); search.initEffects(); + // Listen for cross-tab sync messages (theme changes & data invalidation) + const cleanupSync = onSyncMessage((msg) => { + if (msg.type === 'theme-change') { + theme.applyFromBroadcast(msg.payload); + } else if (msg.type === 'data-change') { + invalidateAll(); + } + }); + + onDestroy(cleanupSync); + // Pages that should NOT have the main layout (login, register) const noLayoutPaths = ['/login', '/register']; const showLayout = $derived(!noLayoutPaths.includes($page.url.pathname)); diff --git a/src/routes/admin/settings/+page.server.ts b/src/routes/admin/settings/+page.server.ts index 407f969..c17d6f3 100644 --- a/src/routes/admin/settings/+page.server.ts +++ b/src/routes/admin/settings/+page.server.ts @@ -34,7 +34,14 @@ export const load: PageServerLoad = async (event) => { zod(updateSystemSettingsSchema) ); - return { settings, form }; + return { + settings, + form, + discoveryConfig: { + dockerSocketPath: process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock', + traefikApiUrl: process.env.TRAEFIK_API_URL || '' + } + }; }; export const actions: Actions = { diff --git a/src/routes/admin/settings/+page.svelte b/src/routes/admin/settings/+page.svelte index f649544..8d1b9fb 100644 --- a/src/routes/admin/settings/+page.svelte +++ b/src/routes/admin/settings/+page.svelte @@ -3,8 +3,12 @@ import type { PageData } from './$types.js'; import SettingsForm from '$lib/components/admin/SettingsForm.svelte'; import ImportExportPanel from '$lib/components/admin/ImportExportPanel.svelte'; + import DiscoveryPanel from '$lib/components/admin/DiscoveryPanel.svelte'; let { data }: { data: PageData } = $props(); + + let dockerSocketPath = $state(data.discoveryConfig?.dockerSocketPath ?? '/var/run/docker.sock'); + let traefikApiUrl = $state(data.discoveryConfig?.traefikApiUrl ?? ''); @@ -17,7 +21,9 @@

{$t('admin.settings_description')}

- + + + diff --git a/src/routes/api/admin/discover/+server.ts b/src/routes/api/admin/discover/+server.ts new file mode 100644 index 0000000..a2363a2 --- /dev/null +++ b/src/routes/api/admin/discover/+server.ts @@ -0,0 +1,41 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAdmin } from '$lib/server/middleware/authorize.js'; +import { discoverAll, type DiscoveryConfig } from '$lib/server/services/discoveryService.js'; +import { success, error } from '$lib/server/utils/response.js'; + +/** + * POST /api/admin/discover — Scan Docker and Traefik for services. Admin only. + * + * Body: { dockerSocketPath?: string, traefikApiUrl?: string } + */ +export const POST: RequestHandler = async (event) => { + requireAdmin(event); + + let body: DiscoveryConfig; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + const config: DiscoveryConfig = { + dockerSocketPath: body.dockerSocketPath || undefined, + traefikApiUrl: body.traefikApiUrl || undefined + }; + + if (!config.dockerSocketPath && !config.traefikApiUrl) { + return json( + error('At least one discovery source must be configured (dockerSocketPath or traefikApiUrl)'), + { status: 400 } + ); + } + + try { + const result = await discoverAll(config); + return json(success(result)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Discovery scan failed'; + return json(error(message), { status: 500 }); + } +}; diff --git a/src/routes/api/admin/discover/approve/+server.ts b/src/routes/api/admin/discover/approve/+server.ts new file mode 100644 index 0000000..596d934 --- /dev/null +++ b/src/routes/api/admin/discover/approve/+server.ts @@ -0,0 +1,70 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAdmin } from '$lib/server/middleware/authorize.js'; +import { create } from '$lib/server/services/appService.js'; +import { success, error } from '$lib/server/utils/response.js'; + +interface ApproveServiceInput { + readonly name: string; + readonly url: string; + readonly source: 'docker' | 'traefik'; + readonly icon?: string; + readonly description?: string; +} + +interface ApproveBody { + readonly services: readonly ApproveServiceInput[]; +} + +/** + * POST /api/admin/discover/approve — Approve discovered services and create app entries. Admin only. + * + * Body: { services: DiscoveredService[] } + */ +export const POST: RequestHandler = async (event) => { + const user = requireAdmin(event); + + let body: ApproveBody; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + if (!body.services || !Array.isArray(body.services) || body.services.length === 0) { + return json(error('At least one service must be provided for approval'), { status: 400 }); + } + + const created: string[] = []; + const errors: string[] = []; + + for (const service of body.services) { + if (!service.name || !service.url) { + errors.push(`Skipped invalid service entry (missing name or url)`); + continue; + } + + try { + const app = await create({ + name: service.name, + url: service.url, + icon: service.icon, + description: service.description ?? `Discovered via ${service.source}`, + category: 'Discovered', + healthcheckEnabled: true, + createdById: user.id + }); + created.push(app.id); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + errors.push(`Failed to create "${service.name}": ${message}`); + } + } + + return json( + success({ + created: created.length, + errors + }) + ); +}; diff --git a/src/routes/api/apps/quick-add/+server.ts b/src/routes/api/apps/quick-add/+server.ts new file mode 100644 index 0000000..297b63c --- /dev/null +++ b/src/routes/api/apps/quick-add/+server.ts @@ -0,0 +1,71 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import * as appService from '$lib/server/services/appService.js'; +import { success, error } from '$lib/server/utils/response.js'; +import { z } from 'zod'; + +const quickAddSchema = z.object({ + url: z + .string() + .url('Invalid URL') + .refine( + (u) => u.startsWith('http://') || u.startsWith('https://'), + 'URL must use http or https protocol' + ), + name: z.string().min(1, 'Name is required').max(200), + description: z.string().max(1000).optional() +}); + +/** + * POST /api/apps/quick-add — Quick-add an app with sensible defaults. + * Accepts { url, name, description? }, creates app with healthcheck enabled + * and attempts to auto-detect a favicon icon from the URL's domain. + */ +export const POST: RequestHandler = async (event) => { + const user = requireAuth(event); + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + const parsed = quickAddSchema.safeParse(body); + if (!parsed.success) { + const messages = parsed.error.errors.map((e) => e.message).join(', '); + return json(error(messages), { status: 400 }); + } + + const { url, name, description } = parsed.data; + + // Attempt to derive a favicon URL from the domain + let faviconUrl: string | undefined; + try { + const parsedUrl = new URL(url); + faviconUrl = `${parsedUrl.origin}/favicon.ico`; + } catch { + // URL parsing failed — skip icon detection + } + + try { + const app = await appService.create({ + name, + url, + description, + icon: faviconUrl, + iconType: faviconUrl ? 'url' : 'lucide', + healthcheckEnabled: true, + healthcheckInterval: 300, + healthcheckMethod: 'GET', + healthcheckExpectedStatus: 200, + healthcheckTimeout: 5000, + createdById: user.id + }); + return json(success(app), { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create app'; + return json(error(message), { status: 500 }); + } +}; diff --git a/src/routes/apps/+page.svelte b/src/routes/apps/+page.svelte index b9315df..5491f1b 100644 --- a/src/routes/apps/+page.svelte +++ b/src/routes/apps/+page.svelte @@ -3,10 +3,21 @@ import type { PageData } from './$types.js'; import AppCard from '$lib/components/app/AppCard.svelte'; import AppForm from '$lib/components/app/AppForm.svelte'; + import { broadcastDataChange } from '$lib/utils/broadcastSync.js'; let { data }: { data: PageData } = $props(); let showForm = $state(false); + + // Track app count to detect CRUD changes and broadcast to other tabs + let previousAppCount = $state(data.apps.length); + $effect(() => { + const currentCount = data.apps.length; + if (currentCount !== previousAppCount) { + broadcastDataChange('app'); + previousAppCount = currentCount; + } + }); diff --git a/src/routes/apps/quick-add/+page.server.ts b/src/routes/apps/quick-add/+page.server.ts new file mode 100644 index 0000000..2f4cc18 --- /dev/null +++ b/src/routes/apps/quick-add/+page.server.ts @@ -0,0 +1,64 @@ +import type { Actions, PageServerLoad } from './$types.js'; +import { superValidate, setError } from 'sveltekit-superforms'; +import { zod } from '$lib/utils/zod-adapter.js'; +import { fail } from '@sveltejs/kit'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import * as appService from '$lib/server/services/appService.js'; +import { createAppSchema } from '$lib/utils/validators.js'; + +export const load: PageServerLoad = async (event) => { + requireAuth(event); + + const url = event.url.searchParams.get('url') ?? ''; + const name = event.url.searchParams.get('name') ?? ''; + + const form = await superValidate(zod(createAppSchema)); + + // Pre-fill from query params + if (url) form.data.url = url; + if (name) form.data.name = name; + + // Set quick-add defaults + form.data.healthcheckEnabled = true; + form.data.healthcheckInterval = 300; + form.data.healthcheckMethod = 'GET'; + form.data.healthcheckExpectedStatus = 200; + form.data.healthcheckTimeout = 5000; + + // Attempt to auto-detect favicon + if (url) { + try { + const parsedUrl = new URL(url); + form.data.icon = `${parsedUrl.origin}/favicon.ico`; + form.data.iconType = 'url'; + } catch { + // Invalid URL — skip icon detection + } + } + + return { form }; +}; + +export const actions: Actions = { + create: async (event) => { + const user = requireAuth(event); + + const form = await superValidate(event.request, zod(createAppSchema)); + + if (!form.valid) { + return fail(400, { form }); + } + + try { + await appService.create({ + ...form.data, + createdById: user.id + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create app'; + return setError(form, '', message); + } + + return { form, created: true }; + } +}; diff --git a/src/routes/apps/quick-add/+page.svelte b/src/routes/apps/quick-add/+page.svelte new file mode 100644 index 0000000..f579c05 --- /dev/null +++ b/src/routes/apps/quick-add/+page.svelte @@ -0,0 +1,58 @@ + + + + {$t('app.quick_add_title')} | {$t('app_name')} + + +
+

{$t('app.quick_add_title')}

+

{$t('app.quick_add_description')}

+ + {#if created} +
+

+ {$t('app.quick_add_success')} +

+
+ + {$t('app.quick_add_view_apps')} + + +
+
+ {:else} +
+ +
+ {/if} +
diff --git a/src/routes/boards/[boardId]/+page.svelte b/src/routes/boards/[boardId]/+page.svelte index 3f7a62e..c52f4e3 100644 --- a/src/routes/boards/[boardId]/+page.svelte +++ b/src/routes/boards/[boardId]/+page.svelte @@ -5,6 +5,7 @@ import Board from '$lib/components/board/Board.svelte'; import BoardHeader from '$lib/components/board/BoardHeader.svelte'; import BoardShareDialog from '$lib/components/board/BoardShareDialog.svelte'; + import { broadcastDataChange } from '$lib/utils/broadcastSync.js'; let { data }: { data: PageData } = $props(); @@ -20,6 +21,7 @@ body: JSON.stringify({ isGuestAccessible: value }) }); if (res.ok) { + broadcastDataChange('board'); await invalidateAll(); } else { guestToggleError = 'Failed to update guest access'; diff --git a/src/routes/offline/+page.svelte b/src/routes/offline/+page.svelte new file mode 100644 index 0000000..cd1a406 --- /dev/null +++ b/src/routes/offline/+page.svelte @@ -0,0 +1,38 @@ + + + + {$t('offline.title')} + + +
+
+ +
+ +
+

+ {$t('offline.title')} +

+

+ {$t('offline.description')} +

+
+ + +
diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index e5c90b4..93a54bc 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -2,6 +2,7 @@ import { t } from 'svelte-i18n'; import type { PageData } from './$types.js'; import ThemeCustomizer from '$lib/components/settings/ThemeCustomizer.svelte'; + import BookmarkletGenerator from '$lib/components/settings/BookmarkletGenerator.svelte'; let { data }: { data: PageData } = $props(); @@ -10,8 +11,10 @@ {$t('settings.title')} | {$t('app_name')}
-
+

{$t('settings.title')}

+ +
diff --git a/src/service-worker.ts b/src/service-worker.ts new file mode 100644 index 0000000..2be5451 --- /dev/null +++ b/src/service-worker.ts @@ -0,0 +1,131 @@ +/// +/// +/// +/// + +declare const self: ServiceWorkerGlobalScope; + +import { build, files, version } from '$service-worker'; + +const CACHE_NAME = `cache-${version}`; +const ASSETS = [...build, ...files]; + +const OFFLINE_URL = '/offline'; + +// Install: pre-cache all static assets and the offline fallback page +self.addEventListener('install', (event: ExtendableEvent) => { + event.waitUntil( + (async () => { + const cache = await caches.open(CACHE_NAME); + await cache.addAll(ASSETS); + // Cache offline fallback page + await cache.add(OFFLINE_URL); + await self.skipWaiting(); + })() + ); +}); + +// Activate: clean up old caches +self.addEventListener('activate', (event: ExtendableEvent) => { + event.waitUntil( + (async () => { + const keys = await caches.keys(); + const deletions = keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)); + await Promise.all(deletions); + await self.clients.claim(); + })() + ); +}); + +// Fetch: cache-first for static assets, network-first for API/pages +self.addEventListener('fetch', (event: FetchEvent) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== 'GET') return; + + // Skip cross-origin requests + if (url.origin !== self.location.origin) return; + + // API calls: network-first with cache fallback + if (url.pathname.startsWith('/api/')) { + event.respondWith(networkFirst(request)); + return; + } + + // Static assets (build artifacts + static files): cache-first + if (ASSETS.includes(url.pathname)) { + event.respondWith(cacheFirst(request)); + return; + } + + // Navigation requests (HTML pages): network-first with offline fallback + if (request.mode === 'navigate') { + event.respondWith(navigationHandler(request)); + return; + } + + // Everything else: network-first + event.respondWith(networkFirst(request)); +}); + +/** + * Cache-first strategy: serve from cache, fall back to network. + */ +async function cacheFirst(request: Request): Promise { + const cached = await caches.match(request); + if (cached) return cached; + + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + } + return response; + } catch { + return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); + } +} + +/** + * Network-first strategy: try network, fall back to cache. + */ +async function networkFirst(request: Request): Promise { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + } + return response; + } catch { + const cached = await caches.match(request); + if (cached) return cached; + return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); + } +} + +/** + * Navigation handler: network-first with offline fallback page. + */ +async function navigationHandler(request: Request): Promise { + try { + return await fetch(request); + } catch { + const cached = await caches.match(request); + if (cached) return cached; + + const offlinePage = await caches.match(OFFLINE_URL); + if (offlinePage) return offlinePage; + + return new Response('Offline', { + status: 503, + statusText: 'Service Unavailable', + headers: { 'Content-Type': 'text/html' } + }); + } +} diff --git a/static/icon.svg b/static/icon.svg new file mode 100644 index 0000000..2d0e427 --- /dev/null +++ b/static/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..262373c --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "Web App Launcher", + "short_name": "Launcher", + "start_url": "/", + "display": "standalone", + "theme_color": "#6366f1", + "background_color": "#0a0a0a", + "icons": [ + { + "src": "/icon.svg", + "sizes": "any", + "type": "image/svg+xml" + }, + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} From 7d8a8fb0fc4126a1da41a39ff0ece198565c58eb Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 25 Mar 2026 01:12:11 +0300 Subject: [PATCH 4/5] feat(phase3): phase 7 - integration & polish Fix all build/type/lint errors, write 46 new tests (222 total across 20 files), regenerate Prisma client, update seed with user preferences. Fix SvelteSet usage, add {#each} keys, clean unused imports. --- plans/phase-3-advanced-features/CONTEXT.md | 9 +- plans/phase-3-advanced-features/PLAN.md | 4 +- .../phase-7-integration.md | 32 ++- prisma/seed.ts | 24 +- .../components/admin/DiscoveryPanel.svelte | 29 +- src/lib/components/app/SparklineChart.svelte | 2 +- .../settings/ThemeCustomizer.svelte | 3 +- src/lib/components/widget/AppWidget.svelte | 1 - .../__tests__/discoveryService.test.ts | 265 ++++++++++++++++++ .../services/__tests__/exportService.test.ts | 186 ++++++++++++ .../services/__tests__/importService.test.ts | 222 +++++++++++++++ src/lib/utils/__tests__/broadcastSync.test.ts | 137 +++++++++ .../apps/quick-add/__tests__/quickAdd.test.ts | 152 ++++++++++ .../preferences/__tests__/preferences.test.ts | 191 +++++++++++++ 14 files changed, 1223 insertions(+), 34 deletions(-) create mode 100644 src/lib/server/services/__tests__/discoveryService.test.ts create mode 100644 src/lib/server/services/__tests__/exportService.test.ts create mode 100644 src/lib/server/services/__tests__/importService.test.ts create mode 100644 src/lib/utils/__tests__/broadcastSync.test.ts create mode 100644 src/routes/api/apps/quick-add/__tests__/quickAdd.test.ts create mode 100644 src/routes/api/users/me/preferences/__tests__/preferences.test.ts diff --git a/plans/phase-3-advanced-features/CONTEXT.md b/plans/phase-3-advanced-features/CONTEXT.md index 914ad53..31c2574 100644 --- a/plans/phase-3-advanced-features/CONTEXT.md +++ b/plans/phase-3-advanced-features/CONTEXT.md @@ -1,7 +1,7 @@ # Feature Context: Phase 3 — Advanced Features ## Current State -Phase 2 is complete and merged. 176 tests, full build passes. Phase 3 in progress. Phase 1 (Import/Export), Phase 2 (Sparklines), and Phase 3 (User Theme Overrides) are complete. +Phase 7 (Integration & Polish) is complete. 222 tests across 20 test files, full build passes, `npm run check` 0 errors, `npm run lint` 0 errors. All phases 1-7 are done. ### Phase 1 (Import/Export) Summary exportService, importService, admin API endpoints, ImportExportPanel UI, Zod validation schema, i18n EN/RU translations. @@ -23,6 +23,13 @@ exportService, importService, admin API endpoints, ImportExportPanel UI, Zod val - Header user menu includes "Settings" link - i18n keys: `settings.title`, `settings.theme`, `settings.primary_color`, `settings.hue`, `settings.saturation`, `settings.background`, `settings.language`, `settings.save`, `settings.saving`, `settings.saved` (EN/RU) +### Phase 7 (Integration & Polish) Summary +- Prisma client regenerated with user preference fields +- Fixed lint errors: SvelteSet for reactive Set in DiscoveryPanel, `{#each}` keys in DiscoveryPanel/SparklineChart, unused vars in ThemeCustomizer/AppWidget +- 46 new tests: exportService (4), importService (9), discoveryService (10), preferences API (11), quick-add API (8), broadcastSync (4) +- Seed script updated: user preferences on admin/regular user, quick-add style Wiki.js app +- Final state: 222 tests, 0 build errors, 0 type errors, 0 lint errors + ## Cross-Phase Dependencies - Phases 1-3 are independent (import/export, sparklines, user themes) - Phase 4 (PWA) is independent diff --git a/plans/phase-3-advanced-features/PLAN.md b/plans/phase-3-advanced-features/PLAN.md index 895fe97..5136c90 100644 --- a/plans/phase-3-advanced-features/PLAN.md +++ b/plans/phase-3-advanced-features/PLAN.md @@ -25,7 +25,7 @@ Add import/export, ping history sparklines, user theme overrides, PWA support, D - [ ] Phase 4: PWA Support [frontend] → [subplan](./phase-4-pwa.md) - [ ] Phase 5: Auto-Discovery Docker/Traefik [backend] → [subplan](./phase-5-autodiscovery.md) - [ ] Phase 6: Bookmarklet & Multi-Tab Sync [fullstack] → [subplan](./phase-6-bookmarklet-sync.md) -- [ ] Phase 7: Integration & Polish [fullstack] → [subplan](./phase-7-integration.md) +- [x] Phase 7: Integration & Polish [fullstack] → [subplan](./phase-7-integration.md) ## Phase Progress Log @@ -37,7 +37,7 @@ Add import/export, ping history sparklines, user theme overrides, PWA support, D | Phase 4: PWA | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 5: Auto-Discovery | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 6: Bookmarklet/Sync | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | -| Phase 7: Integration | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 7: Integration | fullstack | ✅ Complete | ✅ | ✅ | ⬜ | ## Final Review - [ ] Comprehensive code review diff --git a/plans/phase-3-advanced-features/phase-7-integration.md b/plans/phase-3-advanced-features/phase-7-integration.md index 081d042..d659fc3 100644 --- a/plans/phase-3-advanced-features/phase-7-integration.md +++ b/plans/phase-3-advanced-features/phase-7-integration.md @@ -1,21 +1,29 @@ # Phase 7: Integration & Polish -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack ## Tasks -- [ ] Task 1: Fix all TypeScript/build errors -- [ ] Task 2: Verify `npm run build` succeeds -- [ ] Task 3: Verify `npm run check` passes -- [ ] Task 4: Verify `npm run lint` passes -- [ ] Task 5: Write tests for export/import services -- [ ] Task 6: Write tests for discovery service -- [ ] Task 7: Write tests for user preferences API -- [ ] Task 8: Write tests for quick-add API -- [ ] Task 9: Update seed script with sample data -- [ ] Task 10: Verify all existing tests pass -- [ ] Task 11: Run Prisma migrations +- [x] Task 1: Fix all TypeScript/build errors +- [x] Task 2: Verify `npm run build` succeeds +- [x] Task 3: Verify `npm run check` passes (0 errors, warnings only) +- [x] Task 4: Verify `npm run lint` passes (0 errors) +- [x] Task 5: Write tests for export/import services +- [x] Task 6: Write tests for discovery service (mocked Docker/Traefik) +- [x] Task 7: Write tests for user preferences API +- [x] Task 8: Write tests for quick-add API +- [x] Task 9: Write tests for broadcastSync utility +- [x] Task 10: Update seed script with sample data (user preferences, quick-add style app) +- [x] Task 11: Run Prisma generate (migrations already applied) +- [x] Task 12: Verify all 222 tests pass across 20 test files + +## Changes Made + +- `prisma generate` — regenerated client with user preference fields +- Fixed lint: SvelteSet for reactive Set in DiscoveryPanel, `{#each}` keys, unused vars +- New tests: exportService (4), importService (9), discoveryService (10), preferences API (11), quick-add API (8), broadcastSync (4) = 46 new tests +- Updated seed.ts: user preferences on admin/regular user, quick-add style Wiki.js app ## Handoff diff --git a/prisma/seed.ts b/prisma/seed.ts index f5164cf..61b5e3c 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -36,7 +36,12 @@ async function main() { password: adminPassword, displayName: 'Administrator', role: 'admin', - authProvider: 'local' + authProvider: 'local', + themeMode: 'dark', + primaryHue: 240, + primarySaturation: 80, + backgroundType: 'aurora', + locale: 'en' } }); console.log(' Created admin user:', admin.email); @@ -51,7 +56,12 @@ async function main() { password: userPassword, displayName: 'Demo User', role: 'user', - authProvider: 'local' + authProvider: 'local', + themeMode: 'light', + primaryHue: 150, + primarySaturation: 60, + backgroundType: 'mesh', + locale: 'ru' } }); console.log(' Created regular user:', regularUser.email); @@ -168,6 +178,16 @@ async function main() { category: 'Network', tags: 'dns,adblock,network,privacy', healthcheckEnabled: true + }, + { + name: 'Wiki.js', + url: 'http://wiki.local:3000', + icon: 'http://wiki.local:3000/favicon.ico', + iconType: 'url', + description: 'Quick-added wiki service (demonstrates favicon URL icon)', + category: 'Productivity', + tags: 'wiki,docs,knowledge', + healthcheckEnabled: true } ]; diff --git a/src/lib/components/admin/DiscoveryPanel.svelte b/src/lib/components/admin/DiscoveryPanel.svelte index 19cfe67..205dd1b 100644 --- a/src/lib/components/admin/DiscoveryPanel.svelte +++ b/src/lib/components/admin/DiscoveryPanel.svelte @@ -1,5 +1,6 @@