feat: Phase 3 — Advanced Features
- Import/Export: JSON export/import with conflict resolution (skip/overwrite) - Ping history sparklines: 24h bar charts on app widgets with uptime % - User theme overrides: per-user preferences (hue, saturation, mode, bg, locale) - PWA: service worker, manifest, offline page, install prompt - Auto-discovery: Docker socket + Traefik API scanning with approval UI - Quick-add bookmarklet: popup-based add from any page - Multi-tab sync: BroadcastChannel for theme + data changes - Security: execFile for Docker commands, Zod on all API inputs, import limits - 222 tests across 20 test files, all passing
This commit is contained in:
@@ -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"
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Feature Context: Phase 3 — Advanced Features
|
||||
|
||||
## Current State
|
||||
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.
|
||||
|
||||
### 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)
|
||||
|
||||
### 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
|
||||
- Phase 5 (auto-discovery) is independent
|
||||
- Phase 6 (bookmarklet/sync) depends on existing API
|
||||
- Phase 7 (integration) depends on all prior phases
|
||||
@@ -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
|
||||
|
||||
- [x] Phase 1: Import/Export [fullstack] → [subplan](./phase-1-import-export.md)
|
||||
- [ ] Phase 2: Ping History Sparklines [fullstack] → [subplan](./phase-2-sparklines.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)
|
||||
- [x] Phase 7: Integration & Polish [fullstack] → [subplan](./phase-7-integration.md)
|
||||
|
||||
## Phase Progress Log
|
||||
|
||||
| Phase | Domain | Status | Review | Build | Committed |
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| 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 | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 7: Integration | fullstack | ✅ Complete | ✅ | ✅ | ⬜ |
|
||||
|
||||
## Final Review
|
||||
- [ ] Comprehensive code review
|
||||
- [ ] Full build passes
|
||||
- [ ] Full test suite passes
|
||||
- [ ] Merged to `master`
|
||||
@@ -0,0 +1,19 @@
|
||||
# Phase 1: Import/Export
|
||||
|
||||
**Status:** ✅ Done
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Tasks
|
||||
- [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.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Phase 2: Ping History Sparklines
|
||||
|
||||
**Status:** ✅ Complete
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Tasks
|
||||
|
||||
- [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.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Phase 3: User Theme Overrides
|
||||
|
||||
**Status:** ✅ Complete
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Tasks
|
||||
- [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.
|
||||
@@ -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
|
||||
<!-- Filled in after completion -->
|
||||
@@ -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
|
||||
<!-- Filled in after completion -->
|
||||
@@ -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
|
||||
<!-- Filled in after completion -->
|
||||
@@ -0,0 +1,29 @@
|
||||
# Phase 7: Integration & Polish
|
||||
|
||||
**Status:** ✅ Complete
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Tasks
|
||||
- [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
|
||||
<!-- Final phase -->
|
||||
@@ -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;
|
||||
@@ -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[]
|
||||
|
||||
+22
-2
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Launcher" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/icon.svg" />
|
||||
<script>
|
||||
// Inline script to prevent FOUC — set theme class before first paint
|
||||
(function () {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface DiscoveredService {
|
||||
name: string;
|
||||
url: string;
|
||||
source: 'docker' | 'traefik';
|
||||
icon?: string;
|
||||
description?: string;
|
||||
alreadyRegistered: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
dockerSocketPath = $bindable('/var/run/docker.sock'),
|
||||
traefikApiUrl = $bindable('')
|
||||
}: {
|
||||
dockerSocketPath?: string;
|
||||
traefikApiUrl?: string;
|
||||
} = $props();
|
||||
|
||||
let scanning = $state(false);
|
||||
let approving = $state(false);
|
||||
let services = $state<DiscoveredService[]>([]);
|
||||
let scanErrors = $state<string[]>([]);
|
||||
let selected = new SvelteSet<number>();
|
||||
let statusMessage = $state('');
|
||||
let statusType: 'success' | 'error' | '' = $state('');
|
||||
|
||||
function clearStatus() {
|
||||
statusMessage = '';
|
||||
statusType = '';
|
||||
}
|
||||
|
||||
async function handleScan() {
|
||||
clearStatus();
|
||||
scanning = true;
|
||||
services = [];
|
||||
scanErrors = [];
|
||||
selected.clear();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/discover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
dockerSocketPath: dockerSocketPath || undefined,
|
||||
traefikApiUrl: traefikApiUrl || undefined
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Discovery scan failed');
|
||||
}
|
||||
|
||||
services = result.data.services;
|
||||
scanErrors = result.data.errors;
|
||||
|
||||
if (services.length === 0) {
|
||||
statusMessage = $t('admin.discovery_no_results');
|
||||
statusType = 'error';
|
||||
}
|
||||
} catch (err) {
|
||||
statusMessage = err instanceof Error ? err.message : 'Scan failed';
|
||||
statusType = 'error';
|
||||
} finally {
|
||||
scanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(index: number) {
|
||||
if (selected.has(index)) {
|
||||
selected.delete(index);
|
||||
} else {
|
||||
selected.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const selectableIndices = services
|
||||
.map((s, i) => (s.alreadyRegistered ? -1 : i))
|
||||
.filter((i) => i >= 0);
|
||||
|
||||
const allSelected = selected.size === selectableIndices.length;
|
||||
selected.clear();
|
||||
if (!allSelected) {
|
||||
for (const idx of selectableIndices) {
|
||||
selected.add(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (selected.size === 0) return;
|
||||
|
||||
clearStatus();
|
||||
approving = true;
|
||||
|
||||
const toApprove = Array.from(selected).map((i) => services[i]);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/discover/approve', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ services: toApprove })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Approval failed');
|
||||
}
|
||||
|
||||
const { created, errors: approveErrors } = result.data;
|
||||
const parts: string[] = [];
|
||||
if (created > 0) parts.push(`${created} app(s) created`);
|
||||
if (approveErrors.length > 0) parts.push(approveErrors.join('; '));
|
||||
|
||||
statusMessage = `${$t('admin.discovery_approve')}: ${parts.join('. ')}`;
|
||||
statusType = approveErrors.length > 0 ? 'error' : 'success';
|
||||
|
||||
// Mark approved services as registered
|
||||
services = services.map((s, i) =>
|
||||
selected.has(i) ? { ...s, alreadyRegistered: true } : s
|
||||
);
|
||||
selected.clear();
|
||||
} catch (err) {
|
||||
statusMessage = err instanceof Error ? err.message : 'Approval failed';
|
||||
statusType = 'error';
|
||||
} finally {
|
||||
approving = false;
|
||||
}
|
||||
}
|
||||
|
||||
const selectableCount = $derived(services.filter((s) => !s.alreadyRegistered).length);
|
||||
</script>
|
||||
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.discovery_title')}</h2>
|
||||
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.discovery_description')}</p>
|
||||
|
||||
<!-- Scan Button -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleScan}
|
||||
disabled={scanning || (!dockerSocketPath && !traefikApiUrl)}
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{scanning ? $t('admin.discovery_scanning') : $t('admin.discovery_scan')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scan Errors -->
|
||||
{#if scanErrors.length > 0}
|
||||
<div class="mb-4 rounded-md bg-yellow-100 p-3 text-sm text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
||||
{#each scanErrors as scanError, idx (idx)}
|
||||
<p>{scanError}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Results Table -->
|
||||
{#if services.length > 0}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="px-2 py-2 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.size === selectableCount && selectableCount > 0}
|
||||
onchange={toggleSelectAll}
|
||||
disabled={selectableCount === 0}
|
||||
class="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('common.name')}</th>
|
||||
<th class="px-2 py-2 text-left font-medium text-muted-foreground">URL</th>
|
||||
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('admin.discovery_source')}</th>
|
||||
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('admin.discovery_status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each services as service, i (service.url)}
|
||||
<tr class="border-b border-border/50 hover:bg-muted/50">
|
||||
<td class="px-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(i)}
|
||||
onchange={() => toggleSelect(i)}
|
||||
disabled={service.alreadyRegistered}
|
||||
class="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-2 py-2 font-medium text-foreground">{service.name}</td>
|
||||
<td class="px-2 py-2">
|
||||
<a href={service.url} target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">
|
||||
{service.url}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{service.source === 'docker'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
}"
|
||||
>
|
||||
{service.source === 'docker' ? $t('admin.discovery_source_docker') : $t('admin.discovery_source_traefik')}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
{#if service.alreadyRegistered}
|
||||
<span class="text-xs text-muted-foreground">{$t('admin.discovery_already_registered')}</span>
|
||||
{:else}
|
||||
<span class="text-xs font-medium text-green-600 dark:text-green-400">{$t('admin.discovery_new')}</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Approve button -->
|
||||
{#if selectableCount > 0}
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleApprove}
|
||||
disabled={approving || selected.size === 0}
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{approving ? $t('admin.discovery_approving') : $t('admin.discovery_approve')} ({selected.size})
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Status Message -->
|
||||
{#if statusMessage}
|
||||
<div class="mt-4 rounded-md p-3 text-sm {statusType === 'success' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}">
|
||||
{statusMessage}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type ImportMode = 'skip' | 'overwrite';
|
||||
|
||||
let importMode: ImportMode = $state('skip');
|
||||
let fileInput: HTMLInputElement | undefined = $state();
|
||||
let previewData: string = $state('');
|
||||
let parsedData: unknown = $state(null);
|
||||
let importing = $state(false);
|
||||
let exporting = $state(false);
|
||||
let statusMessage = $state('');
|
||||
let statusType: 'success' | 'error' | '' = $state('');
|
||||
|
||||
function clearStatus() {
|
||||
statusMessage = '';
|
||||
statusType = '';
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
clearStatus();
|
||||
exporting = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/export');
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Export failed');
|
||||
}
|
||||
|
||||
const disposition = response.headers.get('Content-Disposition');
|
||||
const filenameMatch = disposition?.match(/filename="(.+)"/);
|
||||
const filename = filenameMatch?.[1] ?? 'export.json';
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
statusMessage = $t('admin.export_success');
|
||||
statusType = 'success';
|
||||
} catch (err) {
|
||||
statusMessage = err instanceof Error ? err.message : 'Export failed';
|
||||
statusType = 'error';
|
||||
} finally {
|
||||
exporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
clearStatus();
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
previewData = '';
|
||||
parsedData = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
parsedData = data;
|
||||
previewData = JSON.stringify(data, null, 2);
|
||||
} catch {
|
||||
previewData = '';
|
||||
parsedData = null;
|
||||
statusMessage = $t('admin.import_invalid_json');
|
||||
statusType = 'error';
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!parsedData) return;
|
||||
|
||||
clearStatus();
|
||||
importing = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: parsedData, mode: importMode })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Import failed');
|
||||
}
|
||||
|
||||
const d = result.data;
|
||||
const parts: string[] = [];
|
||||
if (d.apps.created > 0) parts.push(`Apps: +${d.apps.created}`);
|
||||
if (d.apps.updated > 0) parts.push(`Apps updated: ${d.apps.updated}`);
|
||||
if (d.apps.skipped > 0) parts.push(`Apps skipped: ${d.apps.skipped}`);
|
||||
if (d.boards.created > 0) parts.push(`Boards: +${d.boards.created}`);
|
||||
if (d.boards.updated > 0) parts.push(`Boards updated: ${d.boards.updated}`);
|
||||
if (d.boards.skipped > 0) parts.push(`Boards skipped: ${d.boards.skipped}`);
|
||||
if (d.groups.created > 0) parts.push(`Groups: +${d.groups.created}`);
|
||||
if (d.groups.updated > 0) parts.push(`Groups updated: ${d.groups.updated}`);
|
||||
if (d.groups.skipped > 0) parts.push(`Groups skipped: ${d.groups.skipped}`);
|
||||
if (d.settingsUpdated) parts.push('Settings updated');
|
||||
|
||||
statusMessage = `${$t('admin.import_success')} ${parts.join(', ')}`;
|
||||
statusType = 'success';
|
||||
|
||||
// Reset
|
||||
previewData = '';
|
||||
parsedData = null;
|
||||
if (fileInput) fileInput.value = '';
|
||||
} catch (err) {
|
||||
statusMessage = err instanceof Error ? err.message : 'Import failed';
|
||||
statusType = 'error';
|
||||
} finally {
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.import_export_title')}</h2>
|
||||
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.import_export_description')}</p>
|
||||
|
||||
<!-- Export -->
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-2 text-sm font-medium text-foreground">{$t('admin.export_section')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleExport}
|
||||
disabled={exporting}
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{exporting ? $t('admin.export_exporting') : $t('admin.export_button')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="my-6 border-t border-border"></div>
|
||||
|
||||
<!-- Import -->
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-medium text-foreground">{$t('admin.import_section')}</h3>
|
||||
|
||||
<!-- File input -->
|
||||
<div class="mb-4">
|
||||
<label for="import-file" class="mb-1 block text-sm text-muted-foreground">
|
||||
{$t('admin.import_select_file')}
|
||||
</label>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
id="import-file"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onchange={handleFileSelect}
|
||||
class="block w-full text-sm text-foreground file:mr-4 file:rounded-md file:border file:border-border file:bg-background file:px-4 file:py-2 file:text-sm file:font-medium file:text-foreground hover:file:bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
{#if previewData}
|
||||
<div class="mb-4">
|
||||
<label class="mb-1 block text-sm font-medium text-foreground">{$t('admin.import_preview')}</label>
|
||||
<pre class="max-h-64 overflow-auto rounded-md border border-border bg-background p-3 font-mono text-xs text-foreground">{previewData}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Mode selector -->
|
||||
{#if parsedData}
|
||||
<div class="mb-4">
|
||||
<label for="import-mode" class="mb-1 block text-sm font-medium text-foreground">
|
||||
{$t('admin.import_mode_label')}
|
||||
</label>
|
||||
<select
|
||||
id="import-mode"
|
||||
bind:value={importMode}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground sm:w-auto"
|
||||
>
|
||||
<option value="skip">{$t('admin.import_mode_skip')}</option>
|
||||
<option value="overwrite">{$t('admin.import_mode_overwrite')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleImport}
|
||||
disabled={importing}
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{importing ? $t('admin.import_importing') : $t('admin.import_button')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
{#if statusMessage}
|
||||
<div class="mt-4 rounded-md p-3 text-sm {statusType === 'success' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}">
|
||||
{statusMessage}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -4,7 +4,15 @@
|
||||
import type { updateSystemSettingsSchema } from '$lib/utils/validators.js';
|
||||
import type { z } from 'zod';
|
||||
|
||||
let { form: formData }: { form: SuperValidated<z.infer<typeof updateSystemSettingsSchema>> } = $props();
|
||||
let {
|
||||
form: formData,
|
||||
dockerSocketPath = $bindable('/var/run/docker.sock'),
|
||||
traefikApiUrl = $bindable('')
|
||||
}: {
|
||||
form: SuperValidated<z.infer<typeof updateSystemSettingsSchema>>;
|
||||
dockerSocketPath?: string;
|
||||
traefikApiUrl?: string;
|
||||
} = $props();
|
||||
|
||||
const { form, errors, enhance, delayed } = superForm(formData);
|
||||
|
||||
@@ -186,6 +194,36 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Service Discovery Configuration -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.discovery_config')}</h2>
|
||||
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.discovery_config_description')}</p>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="dockerSocketPath" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.discovery_docker_socket')}</label>
|
||||
<input
|
||||
id="dockerSocketPath"
|
||||
type="text"
|
||||
bind:value={dockerSocketPath}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder="/var/run/docker.sock"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{$t('admin.discovery_docker_socket_hint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="traefikApiUrl" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.discovery_traefik_url')}</label>
|
||||
<input
|
||||
id="traefikApiUrl"
|
||||
type="url"
|
||||
bind:value={traefikApiUrl}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder="http://traefik:8080"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{$t('admin.discovery_traefik_url_hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if $errors._errors}
|
||||
<p class="text-sm text-destructive">{$errors._errors}</p>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import AppHealthBadge from './AppHealthBadge.svelte';
|
||||
import SparklineChart from './SparklineChart.svelte';
|
||||
|
||||
interface AppWithStatus {
|
||||
id: string;
|
||||
@@ -12,14 +15,40 @@
|
||||
statuses: Array<{ status: string; responseTime: number | null; checkedAt: string | Date }>;
|
||||
}
|
||||
|
||||
interface StatusPoint {
|
||||
status: string;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
app: AppWithStatus;
|
||||
}
|
||||
|
||||
let { app }: Props = $props();
|
||||
|
||||
let historyData: StatusPoint[] = $state([]);
|
||||
let uptimePercent: number | null = $state(null);
|
||||
let historyLoading = $state(true);
|
||||
|
||||
const currentStatus = $derived(app.statuses?.[0]?.status ?? 'unknown');
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/apps/${app.id}/history`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.success && json.data) {
|
||||
historyData = json.data.history ?? [];
|
||||
uptimePercent = json.data.uptimePercent ?? null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — sparkline is non-critical
|
||||
} finally {
|
||||
historyLoading = false;
|
||||
}
|
||||
});
|
||||
|
||||
const iconDisplay = $derived.by(() => {
|
||||
if (!app.icon) return null;
|
||||
|
||||
@@ -75,6 +104,18 @@
|
||||
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">{app.description}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Sparkline -->
|
||||
{#if historyLoading}
|
||||
<div class="mt-2 h-5 w-20 animate-pulse rounded bg-muted"></div>
|
||||
{:else if historyData.length > 0}
|
||||
<div class="mt-2 flex items-center gap-1.5">
|
||||
<SparklineChart data={historyData} />
|
||||
{#if uptimePercent !== null}
|
||||
<span class="text-[10px] text-muted-foreground">{uptimePercent}% {$t('app.uptime')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if app.category}
|
||||
<span
|
||||
class="mt-2 inline-block self-start rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
interface StatusPoint {
|
||||
status: string;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: StatusPoint[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
let { data, width = 80, height = 20 }: Props = $props();
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
online: '#22c55e',
|
||||
offline: '#ef4444',
|
||||
degraded: '#eab308',
|
||||
unknown: '#6b7280'
|
||||
};
|
||||
|
||||
const barWidth = $derived(data.length > 0 ? Math.max(1, (width - 2) / data.length) : 1);
|
||||
|
||||
const bars = $derived(
|
||||
data.map((point, i) => ({
|
||||
x: 1 + i * barWidth,
|
||||
color: STATUS_COLORS[point.status] ?? STATUS_COLORS.unknown
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<svg
|
||||
{width}
|
||||
{height}
|
||||
viewBox="0 0 {width} {height}"
|
||||
role="img"
|
||||
aria-label="Status history sparkline"
|
||||
class="inline-block align-middle"
|
||||
>
|
||||
{#each bars as bar, idx (idx)}
|
||||
<rect
|
||||
x={bar.x}
|
||||
y={2}
|
||||
width={Math.max(0.5, barWidth - 0.5)}
|
||||
height={height - 4}
|
||||
fill={bar.color}
|
||||
rx="0.5"
|
||||
opacity="0.85"
|
||||
/>
|
||||
{/each}
|
||||
</svg>
|
||||
@@ -161,6 +161,27 @@
|
||||
<p class="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/settings"
|
||||
onclick={() => (showUserMenu = false)}
|
||||
class="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
{$t('settings.title')}
|
||||
</a>
|
||||
|
||||
<form method="POST" action="/auth/logout">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { browser } from '$app/environment';
|
||||
import { Download, X } from 'lucide-svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const DISMISS_KEY = 'wal-install-prompt-dismissed';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
let deferredPrompt: BeforeInstallPromptEvent | null = $state(null);
|
||||
let dismissed = $state(false);
|
||||
let installed = $state(false);
|
||||
|
||||
const visible = $derived(deferredPrompt !== null && !dismissed && !installed);
|
||||
|
||||
function isDismissed(): boolean {
|
||||
if (!browser) return false;
|
||||
try {
|
||||
return localStorage.getItem(DISMISS_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
dismissed = true;
|
||||
if (browser) {
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, 'true');
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function install() {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
await deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
installed = true;
|
||||
}
|
||||
deferredPrompt = null;
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
dismissed = isDismissed();
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e: Event) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e as BeforeInstallPromptEvent;
|
||||
});
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
installed = true;
|
||||
deferredPrompt = null;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="fixed bottom-4 left-4 right-4 z-50 mx-auto flex max-w-lg items-center gap-3 rounded-xl border border-border bg-card p-4 shadow-lg"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Download class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{$t('install.title')}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{$t('install.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={install}
|
||||
class="shrink-0 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{$t('install.button')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={dismiss}
|
||||
class="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label={$t('install.dismiss')}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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 @@
|
||||
|
||||
<!-- Search Dialog (modal, z-50) -->
|
||||
<SearchDialog />
|
||||
|
||||
<!-- PWA Install Prompt -->
|
||||
<InstallPrompt />
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let origin = $state('');
|
||||
let bookmarkletCode = $state('');
|
||||
let bookmarkletHref = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
origin = window.location.origin;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!origin) return;
|
||||
// The bookmarklet opens the quick-add page with URL and title pre-filled
|
||||
const code = `javascript:void(window.open('${origin}/apps/quick-add?url='+encodeURIComponent(location.href)+'&name='+encodeURIComponent(document.title),'_blank','width=600,height=500'))`;
|
||||
bookmarkletCode = code;
|
||||
bookmarkletHref = code;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<h2 class="mb-2 text-lg font-semibold text-card-foreground">
|
||||
{$t('settings.bookmarklet_title')}
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
{$t('settings.bookmarklet_instructions')}
|
||||
</p>
|
||||
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<a
|
||||
href={bookmarkletHref}
|
||||
class="inline-flex items-center gap-2 rounded-lg border-2 border-dashed border-primary/50 bg-primary/10 px-4 py-2 text-sm font-medium text-primary transition-colors hover:border-primary hover:bg-primary/20"
|
||||
onclick={(e) => { e.preventDefault(); }}
|
||||
draggable="true"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
|
||||
</svg>
|
||||
{$t('settings.bookmarklet_drag')}
|
||||
</a>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{$t('settings.bookmarklet_drag_hint')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground">
|
||||
{$t('settings.bookmarklet_show_code')}
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-x-auto rounded-md border border-border bg-background p-3 text-xs text-foreground"><code>{bookmarkletCode}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
@@ -0,0 +1,239 @@
|
||||
<script lang="ts">
|
||||
import { t, locale as i18nLocale } from 'svelte-i18n';
|
||||
import { theme, type ThemeMode, type BackgroundType } from '$lib/stores/theme.svelte.js';
|
||||
|
||||
interface UserPreferences {
|
||||
themeMode: string | null;
|
||||
primaryHue: number | null;
|
||||
primarySaturation: number | null;
|
||||
backgroundType: string | null;
|
||||
locale: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
preferences: UserPreferences;
|
||||
}
|
||||
|
||||
let { preferences: _preferences }: Props = $props();
|
||||
void _preferences; // available for future use
|
||||
|
||||
let saving = $state(false);
|
||||
let saved = $state(false);
|
||||
let errorMessage = $state('');
|
||||
|
||||
const themeModes: { value: ThemeMode; labelKey: string }[] = [
|
||||
{ value: 'dark', labelKey: 'theme.dark' },
|
||||
{ value: 'light', labelKey: 'theme.light' },
|
||||
{ value: 'system', labelKey: 'theme.system' }
|
||||
];
|
||||
|
||||
const bgOptions: { value: BackgroundType; labelKey: string }[] = [
|
||||
{ value: 'none', labelKey: 'bg.none' },
|
||||
{ value: 'mesh', labelKey: 'bg.mesh' },
|
||||
{ value: 'particles', labelKey: 'bg.particles' },
|
||||
{ value: 'aurora', labelKey: 'bg.aurora' }
|
||||
];
|
||||
|
||||
const localeOptions = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'ru', label: 'Русский' }
|
||||
];
|
||||
|
||||
// Generate hue gradient CSS for the hue slider track
|
||||
const hueGradient = $derived.by(() => {
|
||||
const stops = Array.from({ length: 13 }, (_, i) => {
|
||||
const h = i * 30;
|
||||
return `hsl(${h}, ${theme.primarySaturation}%, 50%)`;
|
||||
});
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
});
|
||||
|
||||
// Generate saturation gradient for the saturation slider track
|
||||
const satGradient = $derived(
|
||||
`linear-gradient(to right, hsl(${theme.primaryHue}, 0%, 50%), hsl(${theme.primaryHue}, 100%, 50%))`
|
||||
);
|
||||
|
||||
// Color preview
|
||||
const previewColor = $derived(
|
||||
`hsl(${theme.primaryHue}, ${theme.primarySaturation}%, 50%)`
|
||||
);
|
||||
|
||||
function setMode(mode: ThemeMode) {
|
||||
theme.setMode(mode);
|
||||
}
|
||||
|
||||
function setBackground(bg: BackgroundType) {
|
||||
theme.setBackground(bg);
|
||||
}
|
||||
|
||||
function setLocale(loc: string) {
|
||||
i18nLocale.set(loc);
|
||||
}
|
||||
|
||||
async function savePreferences() {
|
||||
saving = true;
|
||||
saved = false;
|
||||
errorMessage = '';
|
||||
|
||||
try {
|
||||
const body = {
|
||||
themeMode: theme.mode,
|
||||
primaryHue: theme.primaryHue,
|
||||
primarySaturation: theme.primarySaturation,
|
||||
backgroundType: theme.backgroundType,
|
||||
locale: $i18nLocale ?? 'en'
|
||||
};
|
||||
|
||||
const res = await fetch('/api/users/me/preferences', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
errorMessage = data.error ?? 'Failed to save preferences';
|
||||
return;
|
||||
}
|
||||
|
||||
saved = true;
|
||||
setTimeout(() => {
|
||||
saved = false;
|
||||
}, 2000);
|
||||
} catch {
|
||||
errorMessage = 'Network error';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Theme Mode -->
|
||||
<section>
|
||||
<h2 class="mb-3 text-lg font-semibold text-foreground">{$t('settings.theme')}</h2>
|
||||
<div class="flex gap-1 rounded-lg border border-border bg-muted/50 p-1">
|
||||
{#each themeModes as opt (opt.value)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setMode(opt.value)}
|
||||
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {theme.mode === opt.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
{$t(opt.labelKey)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Primary Color -->
|
||||
<section>
|
||||
<h2 class="mb-3 text-lg font-semibold text-foreground">{$t('settings.primary_color')}</h2>
|
||||
|
||||
<!-- Color preview -->
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div
|
||||
class="h-10 w-10 rounded-lg border border-border"
|
||||
style="background-color: {previewColor};"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
HSL({theme.primaryHue}, {theme.primarySaturation}%, 50%)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Hue slider -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-1.5 block text-sm text-muted-foreground">{$t('settings.hue')}</label>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="h-3 w-full rounded-full"
|
||||
style="background: {hueGradient};"
|
||||
></div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="360"
|
||||
step="1"
|
||||
bind:value={theme.primaryHue}
|
||||
class="absolute inset-0 h-3 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-current [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:shadow-md"
|
||||
style="color: {previewColor};"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saturation slider -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm text-muted-foreground">{$t('settings.saturation')}</label>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="h-3 w-full rounded-full"
|
||||
style="background: {satGradient};"
|
||||
></div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
bind:value={theme.primarySaturation}
|
||||
class="absolute inset-0 h-3 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-current [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:shadow-md"
|
||||
style="color: {previewColor};"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Background -->
|
||||
<section>
|
||||
<h2 class="mb-3 text-lg font-semibold text-foreground">{$t('settings.background')}</h2>
|
||||
<div class="flex gap-1 rounded-lg border border-border bg-muted/50 p-1">
|
||||
{#each bgOptions as opt (opt.value)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setBackground(opt.value)}
|
||||
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {theme.backgroundType === opt.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
{$t(opt.labelKey)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Locale -->
|
||||
<section>
|
||||
<h2 class="mb-3 text-lg font-semibold text-foreground">{$t('settings.language')}</h2>
|
||||
<div class="flex gap-1 rounded-lg border border-border bg-muted/50 p-1">
|
||||
{#each localeOptions as opt (opt.value)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLocale(opt.value)}
|
||||
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {$i18nLocale === opt.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Save button -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={savePreferences}
|
||||
disabled={saving}
|
||||
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? $t('settings.saving') : $t('settings.save')}
|
||||
</button>
|
||||
{#if saved}
|
||||
<span class="text-sm text-green-500">{$t('settings.saved')}</span>
|
||||
{/if}
|
||||
{#if errorMessage}
|
||||
<span class="text-sm text-destructive">{errorMessage}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import AppHealthBadge from '$lib/components/app/AppHealthBadge.svelte';
|
||||
import SparklineChart from '$lib/components/app/SparklineChart.svelte';
|
||||
|
||||
interface AppData {
|
||||
id: string;
|
||||
@@ -11,12 +13,21 @@
|
||||
statuses: Array<{ status: string; responseTime: number | null }>;
|
||||
}
|
||||
|
||||
interface StatusPoint {
|
||||
status: string;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
app: AppData;
|
||||
}
|
||||
|
||||
let { app }: Props = $props();
|
||||
|
||||
let historyData: StatusPoint[] = $state([]);
|
||||
let uptimePercent: number | null = $state(null);
|
||||
let historyLoading = $state(true);
|
||||
|
||||
const latestStatus = $derived(app.statuses[0]?.status ?? 'unknown');
|
||||
|
||||
const iconSrc = $derived.by(() => {
|
||||
@@ -33,6 +44,23 @@
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/apps/${app.id}/history`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.success && json.data) {
|
||||
historyData = json.data.history ?? [];
|
||||
uptimePercent = json.data.uptimePercent ?? null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — sparkline is non-critical
|
||||
} finally {
|
||||
historyLoading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<a
|
||||
@@ -65,4 +93,16 @@
|
||||
|
||||
<!-- Status -->
|
||||
<AppHealthBadge status={latestStatus} />
|
||||
|
||||
<!-- Sparkline -->
|
||||
{#if historyLoading}
|
||||
<div class="h-5 w-20 animate-pulse rounded bg-muted"></div>
|
||||
{:else if historyData.length > 0}
|
||||
<div class="flex items-center gap-1">
|
||||
<SparklineChart data={historyData} />
|
||||
{#if uptimePercent !== null}
|
||||
<span class="text-[10px] text-muted-foreground">{uptimePercent}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
+72
-1
@@ -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,43 @@
|
||||
"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",
|
||||
"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 +300,37 @@
|
||||
"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!",
|
||||
|
||||
"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"
|
||||
}
|
||||
|
||||
+294
-247
@@ -1,265 +1,312 @@
|
||||
{
|
||||
"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)",
|
||||
|
||||
"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.",
|
||||
|
||||
"search.placeholder": "\u041f\u043e\u0438\u0441\u043a \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439 \u0438 \u0434\u043e\u0441\u043e\u043a...",
|
||||
"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",
|
||||
"admin.import_export_title": "Импорт / Экспорт",
|
||||
"admin.import_export_description": "Экспортируйте все данные (приложения, доски, группы, настройки) в формате JSON или импортируйте из ранее экспортированного файла.",
|
||||
"admin.export_section": "Экспорт данных",
|
||||
"admin.export_button": "Экспорт JSON",
|
||||
"admin.export_exporting": "Экспорт...",
|
||||
"admin.export_success": "Экспорт успешно скачан.",
|
||||
"admin.import_section": "Импорт данных",
|
||||
"admin.import_select_file": "Выберите JSON-файл экспорта",
|
||||
"admin.import_preview": "Предпросмотр",
|
||||
"admin.import_mode_label": "Разрешение конфликтов",
|
||||
"admin.import_mode_skip": "Пропустить существующие (оставить текущие данные)",
|
||||
"admin.import_mode_overwrite": "Перезаписать существующие (заменить импортированными)",
|
||||
"admin.import_button": "Импортировать",
|
||||
"admin.import_importing": "Импорт...",
|
||||
"admin.import_success": "Импорт завершён.",
|
||||
"admin.import_invalid_json": "Выбранный файл не является корректным JSON.",
|
||||
"search.placeholder": "Поиск приложений и досок...",
|
||||
"search.trigger": "Поиск...",
|
||||
"search.min_chars": "Введите минимум 2 символа для поиска",
|
||||
"search.no_results": "Ничего не найдено по запросу «{query}»",
|
||||
"search.apps": "Приложения",
|
||||
"search.boards": "Доски",
|
||||
"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"
|
||||
"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": "Скрыть предложение установки"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock child_process for Docker discovery
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('node:util', () => ({
|
||||
promisify: (fn: unknown) => fn
|
||||
}));
|
||||
|
||||
// Mock appService for discoverAll
|
||||
vi.mock('../appService.js', () => ({
|
||||
findAll: vi.fn()
|
||||
}));
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { findAll as findAllApps } from '../appService.js';
|
||||
import { discoverDocker, discoverTraefik, discoverAll } from '../discoveryService.js';
|
||||
|
||||
const mockExecFile = execFile as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockFindAllApps = findAllApps as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('discoveryService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('discoverDocker', () => {
|
||||
it('returns services from running Docker containers', async () => {
|
||||
const containers = [
|
||||
{
|
||||
Id: 'abc123',
|
||||
Names: ['/gitea'],
|
||||
Image: 'gitea/gitea:latest',
|
||||
Ports: [{ IP: '0.0.0.0', PrivatePort: 3000, PublicPort: 3000, Type: 'tcp' }],
|
||||
Labels: { 'org.opencontainers.image.description': 'Self-hosted Git' },
|
||||
State: 'running'
|
||||
}
|
||||
];
|
||||
|
||||
mockExecFile.mockResolvedValue({ stdout: JSON.stringify(containers) });
|
||||
|
||||
const result = await discoverDocker('/var/run/docker.sock');
|
||||
|
||||
expect(result.services).toHaveLength(1);
|
||||
expect(result.services[0].name).toBe('gitea');
|
||||
expect(result.services[0].url).toBe('http://localhost:3000');
|
||||
expect(result.services[0].source).toBe('docker');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extracts URL from Traefik labels in Docker containers', async () => {
|
||||
const containers = [
|
||||
{
|
||||
Id: 'def456',
|
||||
Names: ['/myapp'],
|
||||
Image: 'myapp:latest',
|
||||
Ports: [],
|
||||
Labels: {
|
||||
'traefik.http.routers.myapp.rule': 'Host(`myapp.example.com`)',
|
||||
'traefik.http.routers.myapp.entrypoints': 'websecure'
|
||||
},
|
||||
State: 'running'
|
||||
}
|
||||
];
|
||||
|
||||
mockExecFile.mockResolvedValue({ stdout: JSON.stringify(containers) });
|
||||
|
||||
const result = await discoverDocker('/var/run/docker.sock');
|
||||
|
||||
expect(result.services).toHaveLength(1);
|
||||
expect(result.services[0].url).toBe('https://myapp.example.com');
|
||||
});
|
||||
|
||||
it('skips containers without accessible URLs', async () => {
|
||||
const containers = [
|
||||
{
|
||||
Id: 'nop123',
|
||||
Names: ['/background-worker'],
|
||||
Image: 'worker:latest',
|
||||
Ports: [],
|
||||
Labels: {},
|
||||
State: 'running'
|
||||
}
|
||||
];
|
||||
|
||||
mockExecFile.mockResolvedValue({ stdout: JSON.stringify(containers) });
|
||||
|
||||
const result = await discoverDocker('/var/run/docker.sock');
|
||||
|
||||
expect(result.services).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns error when Docker socket is inaccessible', async () => {
|
||||
mockExecFile.mockRejectedValue(new Error('connect ENOENT /var/run/docker.sock'));
|
||||
|
||||
const result = await discoverDocker('/var/run/docker.sock');
|
||||
|
||||
expect(result.services).toHaveLength(0);
|
||||
expect(result.error).toContain('ENOENT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverTraefik', () => {
|
||||
it('returns services from Traefik routers', async () => {
|
||||
const routers = [
|
||||
{
|
||||
name: 'myapp@docker',
|
||||
rule: 'Host(`myapp.example.com`)',
|
||||
service: 'myapp@docker',
|
||||
entryPoints: ['websecure']
|
||||
}
|
||||
];
|
||||
const services = [
|
||||
{
|
||||
name: 'myapp@docker',
|
||||
loadBalancer: { servers: [{ url: 'http://172.17.0.2:8080' }] }
|
||||
}
|
||||
];
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((url: string) => {
|
||||
if (url.includes('/api/http/routers')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(routers)
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(services)
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const result = await discoverTraefik('http://traefik.local:8080');
|
||||
|
||||
expect(result.services).toHaveLength(1);
|
||||
expect(result.services[0].name).toBe('myapp');
|
||||
expect(result.services[0].url).toBe('https://myapp.example.com');
|
||||
expect(result.services[0].source).toBe('traefik');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns error on Traefik API failure', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 500 })
|
||||
)
|
||||
);
|
||||
|
||||
const result = await discoverTraefik('http://traefik.local:8080');
|
||||
|
||||
expect(result.services).toHaveLength(0);
|
||||
expect(result.error).toContain('500');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns error when fetch throws', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.reject(new Error('Network error')))
|
||||
);
|
||||
|
||||
const result = await discoverTraefik('http://traefik.local:8080');
|
||||
|
||||
expect(result.services).toHaveLength(0);
|
||||
expect(result.error).toContain('Network error');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverAll', () => {
|
||||
it('marks already-registered services', async () => {
|
||||
mockExecFile.mockResolvedValue({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
Id: 'c1',
|
||||
Names: ['/gitea'],
|
||||
Image: 'gitea/gitea',
|
||||
Ports: [{ IP: '0.0.0.0', PrivatePort: 3000, PublicPort: 3000, Type: 'tcp' }],
|
||||
Labels: {},
|
||||
State: 'running'
|
||||
}
|
||||
])
|
||||
});
|
||||
|
||||
mockFindAllApps.mockResolvedValue([
|
||||
{ id: 'a1', name: 'Gitea', url: 'http://localhost:3000' }
|
||||
]);
|
||||
|
||||
const result = await discoverAll({ dockerSocketPath: '/var/run/docker.sock' });
|
||||
|
||||
expect(result.services).toHaveLength(1);
|
||||
expect(result.services[0].alreadyRegistered).toBe(true);
|
||||
});
|
||||
|
||||
it('deduplicates by URL preferring Traefik', async () => {
|
||||
mockExecFile.mockResolvedValue({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
Id: 'c1',
|
||||
Names: ['/app'],
|
||||
Image: 'app:latest',
|
||||
Ports: [],
|
||||
Labels: {
|
||||
'traefik.http.routers.app.rule': 'Host(`app.example.com`)'
|
||||
},
|
||||
State: 'running'
|
||||
}
|
||||
])
|
||||
});
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((url: string) => {
|
||||
if (url.includes('/api/http/routers')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
name: 'app@docker',
|
||||
rule: 'Host(`app.example.com`)',
|
||||
service: 'app@docker',
|
||||
entryPoints: ['web']
|
||||
}
|
||||
])
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||
})
|
||||
);
|
||||
|
||||
mockFindAllApps.mockResolvedValue([]);
|
||||
|
||||
const result = await discoverAll({
|
||||
dockerSocketPath: '/var/run/docker.sock',
|
||||
traefikApiUrl: 'http://traefik.local:8080'
|
||||
});
|
||||
|
||||
// Should deduplicate: both Docker (via label) and Traefik discover http://app.example.com
|
||||
const urls = result.services.map((s) => s.url);
|
||||
const unique = new Set(urls);
|
||||
expect(urls.length).toBe(unique.size);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns empty when no sources configured', async () => {
|
||||
mockFindAllApps.mockResolvedValue([]);
|
||||
|
||||
const result = await discoverAll({});
|
||||
|
||||
expect(result.services).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../prisma.js', () => ({
|
||||
prisma: {
|
||||
app: { findMany: vi.fn() },
|
||||
board: { findMany: vi.fn() },
|
||||
group: { findMany: vi.fn() },
|
||||
systemSettings: { upsert: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '../../prisma.js';
|
||||
import { exportAllData } from '../exportService.js';
|
||||
|
||||
const mockApp = prisma.app as unknown as { findMany: ReturnType<typeof vi.fn> };
|
||||
const mockBoard = prisma.board as unknown as { findMany: ReturnType<typeof vi.fn> };
|
||||
const mockGroup = prisma.group as unknown as { findMany: ReturnType<typeof vi.fn> };
|
||||
const mockSettings = prisma.systemSettings as unknown as {
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
describe('exportService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('exportAllData', () => {
|
||||
it('returns export data with version and timestamp', async () => {
|
||||
mockApp.findMany.mockResolvedValue([]);
|
||||
mockBoard.findMany.mockResolvedValue([]);
|
||||
mockGroup.findMany.mockResolvedValue([]);
|
||||
mockSettings.upsert.mockResolvedValue({
|
||||
authMode: 'local',
|
||||
registrationEnabled: true,
|
||||
defaultTheme: 'dark',
|
||||
defaultPrimaryColor: '#6366f1',
|
||||
healthcheckDefaults: '{}'
|
||||
});
|
||||
|
||||
const result = await exportAllData();
|
||||
|
||||
expect(result.version).toBe('1.0');
|
||||
expect(result.exportedAt).toBeTruthy();
|
||||
expect(result.apps).toEqual([]);
|
||||
expect(result.boards).toEqual([]);
|
||||
expect(result.groups).toEqual([]);
|
||||
expect(result.settings).toEqual({
|
||||
authMode: 'local',
|
||||
registrationEnabled: true,
|
||||
defaultTheme: 'dark',
|
||||
defaultPrimaryColor: '#6366f1',
|
||||
healthcheckDefaults: '{}'
|
||||
});
|
||||
});
|
||||
|
||||
it('maps apps to export format stripping internal fields', async () => {
|
||||
mockApp.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'Gitea',
|
||||
url: 'https://git.local',
|
||||
icon: 'gitea',
|
||||
iconType: 'simple',
|
||||
description: 'Self-hosted Git',
|
||||
category: 'dev',
|
||||
tags: 'git,code',
|
||||
healthcheckEnabled: true,
|
||||
healthcheckInterval: 300,
|
||||
healthcheckMethod: 'GET',
|
||||
healthcheckExpectedStatus: 200,
|
||||
healthcheckTimeout: 5000,
|
||||
createdById: 'u1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
]);
|
||||
mockBoard.findMany.mockResolvedValue([]);
|
||||
mockGroup.findMany.mockResolvedValue([]);
|
||||
mockSettings.upsert.mockResolvedValue({
|
||||
authMode: 'local',
|
||||
registrationEnabled: true,
|
||||
defaultTheme: 'dark',
|
||||
defaultPrimaryColor: '#6366f1',
|
||||
healthcheckDefaults: '{}'
|
||||
});
|
||||
|
||||
const result = await exportAllData();
|
||||
|
||||
expect(result.apps).toHaveLength(1);
|
||||
expect(result.apps[0]).toEqual({
|
||||
name: 'Gitea',
|
||||
url: 'https://git.local',
|
||||
icon: 'gitea',
|
||||
iconType: 'simple',
|
||||
description: 'Self-hosted Git',
|
||||
category: 'dev',
|
||||
tags: 'git,code',
|
||||
healthcheckEnabled: true,
|
||||
healthcheckInterval: 300,
|
||||
healthcheckMethod: 'GET',
|
||||
healthcheckExpectedStatus: 200,
|
||||
healthcheckTimeout: 5000
|
||||
});
|
||||
// Internal fields should not be present
|
||||
expect((result.apps[0] as unknown as Record<string, unknown>).id).toBeUndefined();
|
||||
expect((result.apps[0] as unknown as Record<string, unknown>).createdById).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps boards with nested sections and widgets', async () => {
|
||||
mockApp.findMany.mockResolvedValue([]);
|
||||
mockBoard.findMany.mockResolvedValue([
|
||||
{
|
||||
name: 'Dashboard',
|
||||
icon: null,
|
||||
description: 'Main board',
|
||||
isDefault: true,
|
||||
isGuestAccessible: false,
|
||||
backgroundConfig: null,
|
||||
sections: [
|
||||
{
|
||||
title: 'Apps',
|
||||
icon: null,
|
||||
order: 0,
|
||||
isExpandedByDefault: true,
|
||||
widgets: [
|
||||
{
|
||||
type: 'app',
|
||||
order: 0,
|
||||
config: '{}',
|
||||
app: { name: 'Gitea' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
mockGroup.findMany.mockResolvedValue([]);
|
||||
mockSettings.upsert.mockResolvedValue({
|
||||
authMode: 'local',
|
||||
registrationEnabled: true,
|
||||
defaultTheme: 'dark',
|
||||
defaultPrimaryColor: '#6366f1',
|
||||
healthcheckDefaults: '{}'
|
||||
});
|
||||
|
||||
const result = await exportAllData();
|
||||
|
||||
expect(result.boards).toHaveLength(1);
|
||||
expect(result.boards[0].name).toBe('Dashboard');
|
||||
expect(result.boards[0].sections).toHaveLength(1);
|
||||
expect(result.boards[0].sections[0].widgets).toHaveLength(1);
|
||||
expect(result.boards[0].sections[0].widgets[0].appName).toBe('Gitea');
|
||||
});
|
||||
|
||||
it('maps groups stripping internal fields', async () => {
|
||||
mockApp.findMany.mockResolvedValue([]);
|
||||
mockBoard.findMany.mockResolvedValue([]);
|
||||
mockGroup.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'g1',
|
||||
name: 'Admins',
|
||||
description: 'Admin users',
|
||||
isDefault: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
]);
|
||||
mockSettings.upsert.mockResolvedValue({
|
||||
authMode: 'local',
|
||||
registrationEnabled: true,
|
||||
defaultTheme: 'dark',
|
||||
defaultPrimaryColor: '#6366f1',
|
||||
healthcheckDefaults: '{}'
|
||||
});
|
||||
|
||||
const result = await exportAllData();
|
||||
|
||||
expect(result.groups).toHaveLength(1);
|
||||
expect(result.groups[0]).toEqual({
|
||||
name: 'Admins',
|
||||
description: 'Admin users',
|
||||
isDefault: false
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Build a transaction mock that mirrors prisma's nested structure
|
||||
const txMock = {
|
||||
app: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn() },
|
||||
group: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
|
||||
board: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn() },
|
||||
section: { create: vi.fn(), deleteMany: vi.fn() },
|
||||
widget: { create: vi.fn() },
|
||||
systemSettings: { upsert: vi.fn() }
|
||||
};
|
||||
|
||||
vi.mock('../../prisma.js', () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(async (fn: (tx: typeof txMock) => Promise<void>) => {
|
||||
await fn(txMock);
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
import { validateImportData, importData } from '../importService.js';
|
||||
import type { ExportData } from '../exportService.js';
|
||||
|
||||
function buildValidExportData(overrides: Partial<ExportData> = {}): ExportData {
|
||||
return {
|
||||
version: '1.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
apps: [],
|
||||
boards: [],
|
||||
groups: [],
|
||||
settings: {
|
||||
authMode: 'local',
|
||||
registrationEnabled: true,
|
||||
defaultTheme: 'dark',
|
||||
defaultPrimaryColor: '#6366f1',
|
||||
healthcheckDefaults: '{}'
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('importService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('validateImportData', () => {
|
||||
it('accepts valid export data', () => {
|
||||
const data = buildValidExportData();
|
||||
const result = validateImportData(data);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects data missing required fields', () => {
|
||||
const result = validateImportData({ version: '1.0' });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-object input', () => {
|
||||
const result = validateImportData('not an object');
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importData', () => {
|
||||
it('creates new apps when none exist', async () => {
|
||||
const data = buildValidExportData({
|
||||
apps: [
|
||||
{
|
||||
name: 'TestApp',
|
||||
url: 'https://test.local',
|
||||
icon: null,
|
||||
iconType: 'lucide',
|
||||
description: null,
|
||||
category: null,
|
||||
tags: '',
|
||||
healthcheckEnabled: false,
|
||||
healthcheckInterval: 300,
|
||||
healthcheckMethod: 'GET',
|
||||
healthcheckExpectedStatus: 200,
|
||||
healthcheckTimeout: 5000
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
txMock.app.findFirst.mockResolvedValue(null);
|
||||
txMock.app.create.mockResolvedValue({ id: 'new-app-id', name: 'TestApp' });
|
||||
|
||||
const result = await importData(data, 'skip');
|
||||
|
||||
expect(result.apps.created).toBe(1);
|
||||
expect(result.apps.skipped).toBe(0);
|
||||
expect(txMock.app.create).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('skips existing apps in skip mode', async () => {
|
||||
const data = buildValidExportData({
|
||||
apps: [
|
||||
{
|
||||
name: 'Existing',
|
||||
url: 'https://existing.local',
|
||||
icon: null,
|
||||
iconType: 'lucide',
|
||||
description: null,
|
||||
category: null,
|
||||
tags: '',
|
||||
healthcheckEnabled: false,
|
||||
healthcheckInterval: 300,
|
||||
healthcheckMethod: 'GET',
|
||||
healthcheckExpectedStatus: 200,
|
||||
healthcheckTimeout: 5000
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
txMock.app.findFirst.mockResolvedValue({ id: 'existing-id', name: 'Existing' });
|
||||
|
||||
const result = await importData(data, 'skip');
|
||||
|
||||
expect(result.apps.skipped).toBe(1);
|
||||
expect(result.apps.created).toBe(0);
|
||||
expect(txMock.app.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('overwrites existing apps in overwrite mode', async () => {
|
||||
const data = buildValidExportData({
|
||||
apps: [
|
||||
{
|
||||
name: 'Existing',
|
||||
url: 'https://updated.local',
|
||||
icon: null,
|
||||
iconType: 'lucide',
|
||||
description: 'updated',
|
||||
category: null,
|
||||
tags: '',
|
||||
healthcheckEnabled: true,
|
||||
healthcheckInterval: 60,
|
||||
healthcheckMethod: 'GET',
|
||||
healthcheckExpectedStatus: 200,
|
||||
healthcheckTimeout: 5000
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
txMock.app.findFirst.mockResolvedValue({ id: 'existing-id', name: 'Existing' });
|
||||
|
||||
const result = await importData(data, 'overwrite');
|
||||
|
||||
expect(result.apps.updated).toBe(1);
|
||||
expect(txMock.app.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('creates new groups', async () => {
|
||||
const data = buildValidExportData({
|
||||
groups: [{ name: 'NewGroup', description: 'A group', isDefault: false }]
|
||||
});
|
||||
|
||||
txMock.group.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await importData(data, 'skip');
|
||||
|
||||
expect(result.groups.created).toBe(1);
|
||||
expect(txMock.group.create).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('creates boards with sections and widgets', async () => {
|
||||
const data = buildValidExportData({
|
||||
boards: [
|
||||
{
|
||||
name: 'Board1',
|
||||
icon: null,
|
||||
description: null,
|
||||
isDefault: false,
|
||||
isGuestAccessible: false,
|
||||
backgroundConfig: null,
|
||||
sections: [
|
||||
{
|
||||
title: 'Section1',
|
||||
icon: null,
|
||||
order: 0,
|
||||
isExpandedByDefault: true,
|
||||
widgets: [
|
||||
{ type: 'note', order: 0, config: '{}', appName: null }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
txMock.board.findFirst.mockResolvedValue(null);
|
||||
txMock.board.create.mockResolvedValue({ id: 'board-id' });
|
||||
txMock.section.create.mockResolvedValue({ id: 'section-id' });
|
||||
|
||||
const result = await importData(data, 'skip');
|
||||
|
||||
expect(result.boards.created).toBe(1);
|
||||
expect(txMock.section.create).toHaveBeenCalledOnce();
|
||||
expect(txMock.widget.create).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('imports settings when provided', async () => {
|
||||
const data = buildValidExportData({
|
||||
settings: {
|
||||
authMode: 'both',
|
||||
registrationEnabled: false,
|
||||
defaultTheme: 'light',
|
||||
defaultPrimaryColor: '#ff0000',
|
||||
healthcheckDefaults: '{}'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await importData(data, 'skip');
|
||||
|
||||
expect(result.settingsUpdated).toBe(true);
|
||||
expect(txMock.systemSettings.upsert).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { findAll as findAllApps } from './appService.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// --- 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<string, string>;
|
||||
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, string>): 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;
|
||||
}> {
|
||||
if (!/^[\w/.:-]+$/.test(socketPath)) {
|
||||
return { services: [], error: 'Invalid Docker socket path' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Use execFile (no shell) to avoid command injection
|
||||
const { stdout } = await execFileAsync(
|
||||
'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<string, string>();
|
||||
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<DiscoveryResult> {
|
||||
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<string, DiscoveredService>();
|
||||
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 };
|
||||
}
|
||||
@@ -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<ExportApp>;
|
||||
readonly boards: ReadonlyArray<ExportBoard>;
|
||||
readonly groups: ReadonlyArray<ExportGroup>;
|
||||
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<ExportWidget>;
|
||||
}
|
||||
|
||||
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<ExportSection>;
|
||||
}
|
||||
|
||||
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<ExportData> {
|
||||
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<ExportApp> = 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<ExportBoard> = 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<ExportGroup> = 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
|
||||
};
|
||||
}
|
||||
@@ -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<readonly HealthcheckResult[]> {
|
||||
|
||||
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<void> {
|
||||
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) }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ImportResult> {
|
||||
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<string, string>();
|
||||
|
||||
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<string, unknown> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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<BackgroundType>('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() {
|
||||
@@ -118,6 +135,51 @@ class ThemeStore {
|
||||
this.primaryHue = Math.max(0, Math.min(360, hue));
|
||||
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;
|
||||
// Use setTimeout to ensure all Svelte 5 effects have fired before re-enabling broadcast
|
||||
setTimeout(() => {
|
||||
this.#suppressBroadcast = false;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock BroadcastChannel in Node environment
|
||||
class MockBroadcastChannel {
|
||||
static instances: MockBroadcastChannel[] = [];
|
||||
name: string;
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null;
|
||||
private listeners: Array<{ type: string; handler: (event: { data: unknown }) => void }> = [];
|
||||
closed = false;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
MockBroadcastChannel.instances.push(this);
|
||||
}
|
||||
|
||||
postMessage(data: unknown) {
|
||||
// Broadcast to other instances with same name
|
||||
for (const instance of MockBroadcastChannel.instances) {
|
||||
if (instance !== this && instance.name === this.name && !instance.closed) {
|
||||
for (const listener of instance.listeners) {
|
||||
if (listener.type === 'message') {
|
||||
listener.handler({ data });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener(type: string, handler: (event: { data: unknown }) => void) {
|
||||
this.listeners.push({ type, handler });
|
||||
}
|
||||
|
||||
removeEventListener(type: string, handler: (event: { data: unknown }) => void) {
|
||||
this.listeners = this.listeners.filter(
|
||||
(l) => !(l.type === type && l.handler === handler)
|
||||
);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
static reset() {
|
||||
MockBroadcastChannel.instances = [];
|
||||
}
|
||||
}
|
||||
|
||||
describe('broadcastSync', () => {
|
||||
beforeEach(() => {
|
||||
MockBroadcastChannel.reset();
|
||||
vi.stubGlobal('window', {});
|
||||
vi.stubGlobal('BroadcastChannel', MockBroadcastChannel);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('broadcastThemeChange sends theme-change message', async () => {
|
||||
const { broadcastThemeChange } = await import('$lib/utils/broadcastSync.js');
|
||||
|
||||
const spy = vi.fn();
|
||||
const channel = new MockBroadcastChannel('wal-sync');
|
||||
channel.addEventListener('message', (event: { data: unknown }) => {
|
||||
spy(event.data);
|
||||
});
|
||||
|
||||
broadcastThemeChange({
|
||||
mode: 'dark',
|
||||
primaryHue: 240,
|
||||
primarySaturation: 80,
|
||||
backgroundType: 'none'
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
type: 'theme-change',
|
||||
payload: {
|
||||
mode: 'dark',
|
||||
primaryHue: 240,
|
||||
primarySaturation: 80,
|
||||
backgroundType: 'none'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcastDataChange sends data-change message', async () => {
|
||||
const { broadcastDataChange } = await import('$lib/utils/broadcastSync.js');
|
||||
|
||||
const spy = vi.fn();
|
||||
const channel = new MockBroadcastChannel('wal-sync');
|
||||
channel.addEventListener('message', (event: { data: unknown }) => {
|
||||
spy(event.data);
|
||||
});
|
||||
|
||||
broadcastDataChange('app');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
type: 'data-change',
|
||||
payload: { entity: 'app' }
|
||||
});
|
||||
});
|
||||
|
||||
it('onSyncMessage registers a listener and returns cleanup', async () => {
|
||||
const { onSyncMessage } = await import('$lib/utils/broadcastSync.js');
|
||||
|
||||
const callback = vi.fn();
|
||||
const cleanup = onSyncMessage(callback);
|
||||
|
||||
// Simulate a message from another tab
|
||||
const sender = new MockBroadcastChannel('wal-sync');
|
||||
sender.postMessage({ type: 'data-change', payload: { entity: 'board' } });
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
type: 'data-change',
|
||||
payload: { entity: 'board' }
|
||||
});
|
||||
|
||||
// Cleanup should work
|
||||
cleanup();
|
||||
expect(typeof cleanup).toBe('function');
|
||||
});
|
||||
|
||||
it('returns no-op cleanup when window is undefined', async () => {
|
||||
vi.stubGlobal('window', undefined);
|
||||
vi.resetModules();
|
||||
|
||||
const { onSyncMessage } = await import('$lib/utils/broadcastSync.js');
|
||||
|
||||
const callback = vi.fn();
|
||||
const cleanup = onSyncMessage(callback);
|
||||
|
||||
expect(typeof cleanup).toBe('function');
|
||||
cleanup(); // Should not throw
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
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;
|
||||
|
||||
/** Singleton channel instance, lazily created and reused for all sends and listens. */
|
||||
let singletonChannel: BroadcastChannel | null = null;
|
||||
|
||||
function getChannel(): BroadcastChannel | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
if (singletonChannel) return singletonChannel;
|
||||
try {
|
||||
singletonChannel = new BroadcastChannel(CHANNEL_NAME);
|
||||
return singletonChannel;
|
||||
} 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<SyncMessage>) => {
|
||||
if (event.data && typeof event.data.type === 'string') {
|
||||
callback(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
channel.addEventListener('message', handler);
|
||||
|
||||
return () => {
|
||||
channel.removeEventListener('message', handler);
|
||||
};
|
||||
}
|
||||
@@ -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).max(1000),
|
||||
boards: z.array(importBoardSchema).max(100),
|
||||
groups: z.array(importGroupSchema).max(100),
|
||||
settings: importSettingsSchema
|
||||
});
|
||||
|
||||
// --- System Settings ---
|
||||
|
||||
export const updateSystemSettingsSchema = z.object({
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,14 +9,37 @@
|
||||
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';
|
||||
import { onSyncMessage } from '$lib/utils/broadcastSync.js';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
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();
|
||||
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));
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -2,19 +2,28 @@
|
||||
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';
|
||||
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 ?? '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('admin.system_settings')} — {$t('admin.panel')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<div class="space-y-8">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-card-foreground">{$t('admin.system_settings')}</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{$t('admin.settings_description')}</p>
|
||||
</div>
|
||||
|
||||
<SettingsForm form={data.form} />
|
||||
<SettingsForm form={data.form} bind:dockerSocketPath bind:traefikApiUrl />
|
||||
|
||||
<DiscoveryPanel bind:dockerSocketPath bind:traefikApiUrl />
|
||||
|
||||
<ImportExportPanel />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
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';
|
||||
|
||||
const discoverConfigSchema = z.object({
|
||||
dockerSocketPath: z.string().regex(/^[\w/.:-]+$/).optional(),
|
||||
traefikApiUrl: z.string().url().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 rawBody: unknown;
|
||||
try {
|
||||
rawBody = await event.request.json();
|
||||
} catch {
|
||||
return json(error('Invalid JSON body'), { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = discoverConfigSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return json(error(`Validation failed: ${parsed.error.issues.map((i) => i.message).join(', ')}`), { status: 400 });
|
||||
}
|
||||
|
||||
const config: DiscoveryConfig = {
|
||||
dockerSocketPath: parsed.data.dockerSocketPath || undefined,
|
||||
traefikApiUrl: parsed.data.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 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
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';
|
||||
|
||||
const approveSchema = z.object({
|
||||
services: z.array(z.object({
|
||||
name: z.string().min(1),
|
||||
url: z.string().url(),
|
||||
source: z.enum(['docker', 'traefik']),
|
||||
icon: z.string().optional(),
|
||||
description: z.string().optional()
|
||||
})).min(1).max(100)
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 rawBody: unknown;
|
||||
try {
|
||||
rawBody = await event.request.json();
|
||||
} catch {
|
||||
return json(error('Invalid JSON body'), { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = approveSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return json(error(`Validation failed: ${parsed.error.issues.map((i) => i.message).join(', ')}`), { status: 400 });
|
||||
}
|
||||
|
||||
const body = parsed.data;
|
||||
|
||||
const created: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const service of body.services) {
|
||||
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
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/server/services/appService.js', () => ({
|
||||
create: vi.fn()
|
||||
}));
|
||||
|
||||
import * as appService from '$lib/server/services/appService.js';
|
||||
import { POST } from '../+server.js';
|
||||
|
||||
const mockCreate = appService.create as ReturnType<typeof vi.fn>;
|
||||
|
||||
function createMockEvent(
|
||||
overrides: {
|
||||
user?: { id: string; role: string } | null;
|
||||
body?: unknown;
|
||||
jsonThrows?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const { user = { id: 'u1', role: 'user' }, body = {}, jsonThrows = false } = overrides;
|
||||
|
||||
return {
|
||||
locals: { user },
|
||||
request: {
|
||||
json: jsonThrows
|
||||
? vi.fn().mockRejectedValue(new Error('Invalid JSON'))
|
||||
: vi.fn().mockResolvedValue(body)
|
||||
}
|
||||
} as unknown as Parameters<typeof POST>[0];
|
||||
}
|
||||
|
||||
describe('Quick-Add API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('POST /api/apps/quick-add', () => {
|
||||
it('creates app with valid URL and name', async () => {
|
||||
const createdApp = {
|
||||
id: 'app1',
|
||||
name: 'My App',
|
||||
url: 'https://myapp.example.com',
|
||||
icon: 'https://myapp.example.com/favicon.ico',
|
||||
iconType: 'url'
|
||||
};
|
||||
mockCreate.mockResolvedValue(createdApp);
|
||||
|
||||
const response = await POST(
|
||||
createMockEvent({
|
||||
body: { url: 'https://myapp.example.com', name: 'My App' }
|
||||
})
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data).toEqual(createdApp);
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'My App',
|
||||
url: 'https://myapp.example.com',
|
||||
icon: 'https://myapp.example.com/favicon.ico',
|
||||
iconType: 'url',
|
||||
healthcheckEnabled: true,
|
||||
createdById: 'u1'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('derives favicon URL from app URL', async () => {
|
||||
mockCreate.mockResolvedValue({ id: 'app2' });
|
||||
|
||||
await POST(
|
||||
createMockEvent({
|
||||
body: { url: 'https://git.example.com/repos', name: 'Gitea' }
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
icon: 'https://git.example.com/favicon.ico'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid URL', async () => {
|
||||
const response = await POST(
|
||||
createMockEvent({
|
||||
body: { url: 'not-a-url', name: 'Bad App' }
|
||||
})
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing name', async () => {
|
||||
const response = await POST(
|
||||
createMockEvent({
|
||||
body: { url: 'https://example.com' }
|
||||
})
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-http URLs', async () => {
|
||||
const response = await POST(
|
||||
createMockEvent({
|
||||
body: { url: 'ftp://files.example.com', name: 'FTP Server' }
|
||||
})
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid JSON body', async () => {
|
||||
const response = await POST(createMockEvent({ jsonThrows: true }));
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 500 when service throws', async () => {
|
||||
mockCreate.mockRejectedValue(new Error('DB error'));
|
||||
|
||||
const response = await POST(
|
||||
createMockEvent({
|
||||
body: { url: 'https://example.com', name: 'Failing App' }
|
||||
})
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
|
||||
it('redirects when not authenticated', async () => {
|
||||
try {
|
||||
await POST(createMockEvent({ user: null }));
|
||||
expect.unreachable('Should have thrown redirect');
|
||||
} catch (e) {
|
||||
expect(e).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
// 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 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/server/prisma.js', () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn()
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/server/prisma.js';
|
||||
import { GET, PATCH } from '../+server.js';
|
||||
|
||||
const mockUser = prisma.user as unknown as {
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createMockEvent(
|
||||
overrides: {
|
||||
user?: { id: string; role: string } | null;
|
||||
body?: unknown;
|
||||
} = {}
|
||||
) {
|
||||
const { user = { id: 'u1', role: 'user' }, body = {} } = overrides;
|
||||
|
||||
return {
|
||||
locals: { user },
|
||||
request: {
|
||||
json: vi.fn().mockResolvedValue(body)
|
||||
}
|
||||
} as unknown as Parameters<typeof GET>[0];
|
||||
}
|
||||
|
||||
describe('User Preferences API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/users/me/preferences', () => {
|
||||
it('returns preferences for authenticated user', async () => {
|
||||
const prefs = {
|
||||
themeMode: 'dark',
|
||||
primaryHue: 240,
|
||||
primarySaturation: 80,
|
||||
backgroundType: 'none',
|
||||
locale: 'en'
|
||||
};
|
||||
mockUser.findUnique.mockResolvedValue(prefs);
|
||||
|
||||
const response = await GET(createMockEvent());
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data).toEqual(prefs);
|
||||
});
|
||||
|
||||
it('returns 404 when user not found', async () => {
|
||||
mockUser.findUnique.mockResolvedValue(null);
|
||||
|
||||
const response = await GET(createMockEvent());
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
|
||||
it('redirects when not authenticated', async () => {
|
||||
try {
|
||||
await GET(createMockEvent({ user: null }));
|
||||
expect.unreachable('Should have thrown redirect');
|
||||
} catch (e) {
|
||||
// SvelteKit redirect is thrown as an object with status and location
|
||||
expect(e).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/users/me/preferences', () => {
|
||||
it('updates theme preferences', async () => {
|
||||
const updatedPrefs = {
|
||||
themeMode: 'light',
|
||||
primaryHue: 120,
|
||||
primarySaturation: 60,
|
||||
backgroundType: 'mesh',
|
||||
locale: 'ru'
|
||||
};
|
||||
mockUser.update.mockResolvedValue(updatedPrefs);
|
||||
|
||||
const response = await PATCH(
|
||||
createMockEvent({
|
||||
body: {
|
||||
themeMode: 'light',
|
||||
primaryHue: 120,
|
||||
primarySaturation: 60,
|
||||
backgroundType: 'mesh',
|
||||
locale: 'ru'
|
||||
}
|
||||
})
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data).toEqual(updatedPrefs);
|
||||
});
|
||||
|
||||
it('rejects invalid themeMode', async () => {
|
||||
const response = await PATCH(
|
||||
createMockEvent({ body: { themeMode: 'invalid' } })
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toContain('themeMode');
|
||||
});
|
||||
|
||||
it('rejects primaryHue out of range', async () => {
|
||||
const response = await PATCH(
|
||||
createMockEvent({ body: { primaryHue: 500 } })
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects primarySaturation out of range', async () => {
|
||||
const response = await PATCH(
|
||||
createMockEvent({ body: { primarySaturation: -10 } })
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid backgroundType', async () => {
|
||||
const response = await PATCH(
|
||||
createMockEvent({ body: { backgroundType: 'invalid' } })
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid locale', async () => {
|
||||
const response = await PATCH(
|
||||
createMockEvent({ body: { locale: 'fr' } })
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects request with no valid fields', async () => {
|
||||
const response = await PATCH(
|
||||
createMockEvent({ body: { unknownField: 'value' } })
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
|
||||
it('allows null values to reset preferences', async () => {
|
||||
mockUser.update.mockResolvedValue({
|
||||
themeMode: null,
|
||||
primaryHue: null,
|
||||
primarySaturation: null,
|
||||
backgroundType: null,
|
||||
locale: null
|
||||
});
|
||||
|
||||
const response = await PATCH(
|
||||
createMockEvent({
|
||||
body: { themeMode: null, primaryHue: null }
|
||||
})
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types.js';
|
||||
import AppForm from '$lib/components/app/AppForm.svelte';
|
||||
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// If successfully created, broadcast the change and offer to close
|
||||
let created = $derived('created' in ($page.form ?? {}) && $page.form?.created === true);
|
||||
|
||||
$effect(() => {
|
||||
if (created) {
|
||||
broadcastDataChange('app');
|
||||
}
|
||||
});
|
||||
|
||||
function closeWindow() {
|
||||
window.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('app.quick_add_title')} | {$t('app_name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||
<h1 class="mb-2 text-2xl font-bold text-foreground">{$t('app.quick_add_title')}</h1>
|
||||
<p class="mb-6 text-sm text-muted-foreground">{$t('app.quick_add_description')}</p>
|
||||
|
||||
{#if created}
|
||||
<div class="mb-6 rounded-lg border border-green-500/30 bg-green-500/10 p-4">
|
||||
<p class="text-sm font-medium text-green-700 dark:text-green-300">
|
||||
{$t('app.quick_add_success')}
|
||||
</p>
|
||||
<div class="mt-3 flex gap-3">
|
||||
<a
|
||||
href="/apps"
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{$t('app.quick_add_view_apps')}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeWindow}
|
||||
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-accent"
|
||||
>
|
||||
{$t('app.quick_add_close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<AppForm form={data.form} action="?/create" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { WifiOff, RefreshCw } from 'lucide-svelte';
|
||||
|
||||
function retry() {
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('offline.title')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-[60vh] flex-col items-center justify-center gap-6 px-4 text-center">
|
||||
<div
|
||||
class="flex h-20 w-20 items-center justify-center rounded-full bg-muted"
|
||||
>
|
||||
<WifiOff class="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-2xl font-bold text-foreground">
|
||||
{$t('offline.title')}
|
||||
</h1>
|
||||
<p class="max-w-md text-muted-foreground">
|
||||
{$t('offline.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={retry}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
{$t('offline.retry')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
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();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('settings.title')} | {$t('app_name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl space-y-6 px-4 py-8">
|
||||
<h1 class="mb-6 text-2xl font-bold text-foreground">{$t('settings.title')}</h1>
|
||||
|
||||
<ThemeCustomizer preferences={data.preferences} />
|
||||
|
||||
<BookmarkletGenerator />
|
||||
</div>
|
||||
@@ -0,0 +1,138 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
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;
|
||||
|
||||
// Sensitive API paths: never cache, always go to network
|
||||
const sensitiveApiPrefixes = ['/api/users/', '/api/admin/', '/api/auth/'];
|
||||
if (sensitiveApiPrefixes.some((prefix) => url.pathname.startsWith(prefix))) {
|
||||
event.respondWith(fetch(request));
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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' }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="64" fill="#6366f1"/>
|
||||
<rect x="64" y="64" width="160" height="160" rx="16" fill="white"/>
|
||||
<rect x="288" y="64" width="160" height="160" rx="16" fill="white"/>
|
||||
<rect x="64" y="288" width="160" height="160" rx="16" fill="white"/>
|
||||
<rect x="288" y="288" width="160" height="160" rx="16" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 412 B |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user