diff --git a/.claude/docs/frontend-architecture.md b/.claude/docs/frontend-architecture.md index 9a3af57..58546be 100644 --- a/.claude/docs/frontend-architecture.md +++ b/.claude/docs/frontend-architecture.md @@ -43,6 +43,14 @@ Shared entities use a `$state`-based cache layer in `frontend/src/lib/stores/`: 3. Add `fooCache.clear()` to `clearAllCaches()` 4. In page components: replace `let foo = $state([])` with `let foo = $derived(fooCache.items)` and replace `api('/foo')` with `fooCache.fetch()` +## Provider-Aware UI + +**IMPORTANT**: UI labels for collections, template variables, and icons MUST be dynamic per provider type — never hardcode Immich-specific terms like "Albums" or `mdiImageMultiple` where other providers will appear. + +- **TrackerForm** (`TrackerForm.svelte`): Uses `collectionMeta` lookup by `providerType` for collection label, icon, placeholder, and description. +- **Template variables** (`/api/template-configs/variables`): Must return variable definitions for ALL provider types (Immich, Gitea, Planka, NUT, Scheduler), not just Immich. When adding a new provider, add its slot variables to `__variables()` in `template_configs.py`. +- **Grid items** (`grid-items.ts`): New provider types must be added to BOTH `providerTypeItems` AND `providerTypeFilterItems`. + ## UI Conventions ### Selector Placeholders diff --git a/CLAUDE.md b/CLAUDE.md index c36b6f5..a64d7ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,3 +25,10 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the - Command slot mapping in `packages/core/src/notify_bridge_core/templates/command_defaults/loader.py` (`PROVIDER_COMMAND_SLOTS`) - Provider capabilities in `packages/core/src/notify_bridge_core/providers/capabilities.py` - Seed functions in `packages/server/src/notify_bridge_server/database/seeds.py` (notification templates, command templates, tracking configs, command configs) + - Template variable definitions in `packages/server/src/notify_bridge_server/api/template_configs.py` (`get_template_variables()`) +8. **No provider-specific hardcoding** — UI labels, icons, form defaults, and feature checks MUST be provider-agnostic. NEVER hardcode a specific provider type (e.g. `'immich'`) where multiple providers could appear: + - Form defaults: use `provider_type: ''` (empty), not `'immich'` + - Collection labels: use the `collectionMeta` lookup in `TrackerForm.svelte`, not hardcoded "Albums" + - Feature gating: check `capabilities.notification_slots` or `capabilities.commands`, not `provider.type === 'immich'` + - Provider-specific API calls (e.g. `/albums/.../shared-links`): guard with a provider type check + - Template variable helpers: ALL provider types must have entries in `get_template_variables()` diff --git a/frontend/src/lib/components/EntitySelect.svelte b/frontend/src/lib/components/EntitySelect.svelte index 9344a59..49b8ec4 100644 --- a/frontend/src/lib/components/EntitySelect.svelte +++ b/frontend/src/lib/components/EntitySelect.svelte @@ -130,7 +130,7 @@ = { gitea: 'mdiGit', planka: 'mdiViewDashboard', scheduler: 'mdiClockOutline', + nut: 'mdiBatteryCharging80', }; /** Get the default icon for a provider, falling back by type then generic. */ @@ -118,6 +119,7 @@ export const providerTypeFilterItems = (): GridItem[] => [ { value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') }, { value: 'planka', icon: 'mdiViewDashboard', label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') }, { value: 'scheduler', icon: 'mdiClockOutline', label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') }, + { value: 'nut', icon: PROVIDER_TYPE_ICONS.nut, label: t('providers.typeNut'), desc: t('gridDesc.providerNut') }, ]; // --- Provider type --- @@ -127,4 +129,5 @@ export const providerTypeItems = (): GridItem[] => [ { value: 'gitea', icon: PROVIDER_TYPE_ICONS.gitea, label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') }, { value: 'planka', icon: PROVIDER_TYPE_ICONS.planka, label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') }, { value: 'scheduler', icon: PROVIDER_TYPE_ICONS.scheduler, label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') }, + { value: 'nut', icon: PROVIDER_TYPE_ICONS.nut, label: t('providers.typeNut'), desc: t('gridDesc.providerNut') }, ]; diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 2621660..0bfa4d1 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -111,6 +111,7 @@ "typeGitea": "Gitea", "typePlanka": "Planka", "typeScheduler": "Scheduler", + "typeNut": "NUT (UPS)", "loadError": "Failed to load providers.", "externalDomain": "External Domain", "optional": "optional", @@ -127,6 +128,13 @@ "apiTokenHint": "Optional. Needed for connection testing and repository listing.", "webhookUrl": "Webhook URL", "webhookUrlHint": "Set this as the Target URL in Gitea webhook settings (relative to your bridge host).", + "nutHost": "NUT Server Host", + "nutHostPlaceholder": "192.168.1.100 or ups.local", + "nutPort": "NUT Server Port", + "nutUsername": "Username", + "nutPassword": "Password", + "nutUsernameHint": "Optional — only needed if upsd requires authentication", + "nutPasswordHint": "Optional — upsd user password", "testAndSave": "Test & Save", "saveWithoutTest": "Save without testing" }, @@ -141,6 +149,12 @@ "selectServer": "Select provider...", "albums": "Albums", "selectAlbums": "Select albums...", + "repositories": "Repositories", + "selectRepositories": "Select repositories...", + "boards": "Boards", + "selectBoards": "Select boards...", + "upsDevices": "UPS Devices", + "selectUpsDevices": "Select UPS devices...", "eventTypes": "Event Types", "notificationTargets": "Notification Targets", "scanInterval": "Scan Interval (seconds)", @@ -826,7 +840,8 @@ "providerImmich": "Self-hosted photo server", "providerGitea": "Self-hosted Git service", "providerPlanka": "Self-hosted Kanban board", - "providerScheduler": "Time-based scheduled messages" + "providerScheduler": "Time-based scheduled messages", + "providerNut": "Network UPS monitoring" }, "error": { "notFound": "Page not found", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index fbc8c92..7683907 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -111,6 +111,7 @@ "typeGitea": "Gitea", "typePlanka": "Planka", "typeScheduler": "Планировщик", + "typeNut": "NUT (ИБП)", "loadError": "Не удалось загрузить провайдеры.", "externalDomain": "Внешний домен", "optional": "необязательно", @@ -127,6 +128,13 @@ "apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.", "webhookUrl": "URL вебхука", "webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea (относительно хоста bridge).", + "nutHost": "Хост NUT-сервера", + "nutHostPlaceholder": "192.168.1.100 или ups.local", + "nutPort": "Порт NUT-сервера", + "nutUsername": "Имя пользователя", + "nutPassword": "Пароль", + "nutUsernameHint": "Необязательно — только если upsd требует аутентификации", + "nutPasswordHint": "Необязательно — пароль пользователя upsd", "testAndSave": "Проверить и сохранить", "saveWithoutTest": "Сохранить без проверки" }, @@ -141,6 +149,12 @@ "selectServer": "Выберите провайдер...", "albums": "Альбомы", "selectAlbums": "Выберите альбомы...", + "repositories": "Репозитории", + "selectRepositories": "Выберите репозитории...", + "boards": "Доски", + "selectBoards": "Выберите доски...", + "upsDevices": "ИБП устройства", + "selectUpsDevices": "Выберите ИБП...", "eventTypes": "Типы событий", "notificationTargets": "Получатели уведомлений", "scanInterval": "Интервал проверки (секунды)", @@ -826,7 +840,8 @@ "providerImmich": "Фотосервер для самостоятельного размещения", "providerGitea": "Git-сервер для самостоятельного размещения", "providerPlanka": "Канбан-доска для самостоятельного размещения", - "providerScheduler": "Запланированные сообщения по расписанию" + "providerScheduler": "Запланированные сообщения по расписанию", + "providerNut": "Мониторинг ИБП через NUT" }, "error": { "notFound": "Страница не найдена", diff --git a/frontend/src/lib/stores/provider-filter.svelte.ts b/frontend/src/lib/stores/provider-filter.svelte.ts index bbc8ded..f57d589 100644 --- a/frontend/src/lib/stores/provider-filter.svelte.ts +++ b/frontend/src/lib/stores/provider-filter.svelte.ts @@ -26,7 +26,15 @@ function loadFromStorage(): void { loadFromStorage(); export const globalProviderFilter = { - get id() { return _providerId; }, + get id() { + // If providers are loaded and the stored ID doesn't match any, auto-clear + if (_providerId != null && providersCache.items.length > 0 && + !providersCache.items.some(p => p.id === _providerId)) { + globalProviderFilter.clear(); + return null; + } + return _providerId; + }, get initialized() { return _initialized; }, set(id: number | null) { @@ -46,8 +54,9 @@ export const globalProviderFilter = { /** The currently selected provider object (reactive). */ get provider() { - if (_providerId == null) return null; - return providersCache.items.find(p => p.id === _providerId) ?? null; + const id = this.id; // triggers stale-ID auto-clear + if (id == null) return null; + return providersCache.items.find(p => p.id === id) ?? null; }, /** The provider type string, or null. */ diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 6a1a890..7570890 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -42,6 +42,14 @@ globalProviderFilter.set(v === 0 ? null : v); }); + // Sync store → filter value (handles auto-clear of stale IDs) + $effect(() => { + const storeId = globalProviderFilter.id; + if (storeId === null && providerFilterValue !== 0) { + providerFilterValue = 0; + } + }); + let showPasswordForm = $state(false); let redirecting = $state(false); let openSearch: (() => void) | undefined; diff --git a/frontend/src/routes/command-configs/+page.svelte b/frontend/src/routes/command-configs/+page.svelte index 3b4bc3b..29ab61f 100644 --- a/frontend/src/routes/command-configs/+page.svelte +++ b/frontend/src/routes/command-configs/+page.svelte @@ -67,9 +67,9 @@ const defaultForm = () => ({ name: '', icon: '', - provider_type: 'immich', - enabled_commands: ['help', 'status', 'albums', 'events', 'latest', 'random', 'favorites', 'summary', 'memory'] as string[], - response_mode: 'media', + provider_type: '', + enabled_commands: [] as string[], + response_mode: 'text', default_count: 5, rate_limits: { search: 30, default: 10 }, command_template_config_id: null as number | null, @@ -100,7 +100,7 @@ form = { name: cfg.name, icon: cfg.icon || '', - provider_type: cfg.provider_type || 'immich', + provider_type: cfg.provider_type || '', enabled_commands: [...(cfg.enabled_commands || [])], response_mode: cfg.response_mode || 'media', default_count: cfg.default_count ?? 5, diff --git a/frontend/src/routes/command-template-configs/+page.svelte b/frontend/src/routes/command-template-configs/+page.svelte index c04d05a..e9b60cb 100644 --- a/frontend/src/routes/command-template-configs/+page.svelte +++ b/frontend/src/routes/command-template-configs/+page.svelte @@ -68,7 +68,7 @@ ); const defaultForm = () => ({ - provider_type: 'immich', + provider_type: '', name: '', description: '', icon: '', diff --git a/frontend/src/routes/notification-trackers/+page.svelte b/frontend/src/routes/notification-trackers/+page.svelte index a5ac57e..2fec779 100644 --- a/frontend/src/routes/notification-trackers/+page.svelte +++ b/frontend/src/routes/notification-trackers/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { api } from '$lib/api'; import { t, getLocale } from '$lib/i18n'; - import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache } from '$lib/stores/caches.svelte'; + import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import Card from '$lib/components/Card.svelte'; import Loading from '$lib/components/Loading.svelte'; @@ -34,6 +34,7 @@ (!effectiveProviderId || t.provider_id === effectiveProviderId) )); let providers = $derived(providersCache.items); + let allCapabilities: Record = $derived(capabilitiesCache.items || {}); const providerItems = $derived(providers .filter(p => !globalProviderFilter.providerType || p.type === globalProviderFilter.providerType) .map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type }))); @@ -78,24 +79,30 @@ let testMenuOpen = $state(null); let testMenuStyle = $state(''); - const immichTestTypes = [ - { key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' }, - { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic' }, - { key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'notificationTracker.testScheduled' }, - { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory' }, - ]; - const defaultTestTypes = [ - { key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' }, - ]; + // Test types: basic is always available; periodic/scheduled/memory only for providers + // that have those notification slots in their capabilities + const allTestTypes: Record = { + basic: { key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' }, + periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message' }, + scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message' }, + memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message' }, + }; let testMenuTrackerId = $state(null); let testTypes = $derived.by(() => { - if (!testMenuTrackerId) return defaultTestTypes; + const base = [allTestTypes.basic]; + if (!testMenuTrackerId) return base; const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId); - if (!tracker) return defaultTestTypes; + if (!tracker) return base; const provider = providers.find(p => p.id === tracker.provider_id); - if (provider?.type === 'immich') return immichTestTypes; - return defaultTestTypes; + if (!provider) return base; + const caps = allCapabilities[provider.type]; + if (!caps) return base; + const slotNames = new Set((caps.notification_slots || []).map((s: any) => s.name)); + for (const tt of [allTestTypes.periodic, allTestTypes.scheduled, allTestTypes.memory]) { + if (tt.requiredSlot && slotNames.has(tt.requiredSlot)) base.push(tt); + } + return base; }); onMount(load); @@ -107,6 +114,7 @@ api('/notification-trackers'), providersCache.fetch(), targetsCache.fetch(), trackingConfigsCache.fetch(), templateConfigsCache.fetch(), + capabilitiesCache.fetch(), ]); } catch (err: any) { loadError = err.message || 'Failed to load data'; @@ -146,7 +154,7 @@ if (submitting) return; const newAlbumIds = form.collection_ids.filter(id => !previousCollectionIds.includes(id)); - if (newAlbumIds.length > 0 && form.provider_id) { + if (newAlbumIds.length > 0 && form.provider_id && selectedProviderType === 'immich') { linkCheckLoading = true; try { const missingAlbums: { id: string; name: string; issue: string }[] = []; diff --git a/frontend/src/routes/notification-trackers/LinkedTargetsSection.svelte b/frontend/src/routes/notification-trackers/LinkedTargetsSection.svelte index 5b39ffd..10c475a 100644 --- a/frontend/src/routes/notification-trackers/LinkedTargetsSection.svelte +++ b/frontend/src/routes/notification-trackers/LinkedTargetsSection.svelte @@ -85,12 +85,12 @@
onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
onupdateLink(tt, 'template_config_id', Number(v) || null)} />
@@ -118,12 +118,12 @@
onchangeNewTrackingConfig(Number(v) || 0)} />
onchangeNewTemplateConfig(Number(v) || 0)} />
{#if !isScheduler && collections.length > 0}
- + ({ value: col.id, label: col.albumName || col.name, - icon: 'mdiImageMultiple', - desc: `${col.assetCount ?? col.asset_count ?? 0} assets`, + icon: colMeta.icon, + desc: colMeta.desc(col), }))} bind:values={form.collection_ids} - placeholder={t('notificationTracker.selectAlbums')} + placeholder={colMeta.placeholder} />
{/if} diff --git a/frontend/src/routes/providers/+page.svelte b/frontend/src/routes/providers/+page.svelte index 31f7d2f..621334a 100644 --- a/frontend/src/routes/providers/+page.svelte +++ b/frontend/src/routes/providers/+page.svelte @@ -27,7 +27,7 @@ )); let showForm = $state(false); let editing = $state(null); - let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' }); + let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '', nut_host: '', nut_port: 3493, nut_username: '', nut_password: '' }); let nameManuallyEdited = $state(false); let error = $state(''); let loadError = $state(''); @@ -36,7 +36,7 @@ let confirmDelete = $state(null); const providerDefaultNames: Record = { - immich: 'Immich', gitea: 'Gitea', planka: 'Planka', scheduler: 'Scheduler', + immich: 'Immich', gitea: 'Gitea', planka: 'Planka', scheduler: 'Scheduler', nut: 'NUT', }; // Auto-update name when provider type changes (unless user manually edited) @@ -67,7 +67,7 @@ } function openNew() { - form = { name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' }; + form = { name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '', nut_host: '', nut_port: 3493, nut_username: '', nut_password: '' }; nameManuallyEdited = false; editing = null; showForm = true; } @@ -77,6 +77,8 @@ name: p.name, type: p.type, url: cfg.url || '', api_key: '', api_token: '', webhook_secret: '', external_domain: cfg.external_domain || '', icon: p.icon || '', + nut_host: cfg.host || '', nut_port: cfg.port || 3493, + nut_username: '', nut_password: '', }; nameManuallyEdited = true; editing = p.id; showForm = true; @@ -85,7 +87,14 @@ async function save(e: SubmitEvent) { e.preventDefault(); error = ''; submitting = true; try { - const config: any = { url: form.url }; + let config: any; + if (form.type === 'nut') { + config = { host: form.nut_host, port: form.nut_port || 3493 }; + if (form.nut_username) config.username = form.nut_username; + if (form.nut_password) config.password = form.nut_password; + } else { + config = { url: form.url }; + } if (form.type === 'immich') { if (form.api_key) config.api_key = form.api_key; if (form.external_domain) config.external_domain = form.external_domain; @@ -110,7 +119,8 @@ const hasConfigChange = form.url !== (providers.find(p => p.id === editing)?.config?.url || '') || (form.type === 'immich' && (form.api_key || form.external_domain !== (providers.find(p => p.id === editing)?.config?.external_domain || ''))) || (form.type === 'gitea' && (form.api_token || form.webhook_secret)) || - (form.type === 'planka' && (form.api_key || form.webhook_secret)); + (form.type === 'planka' && (form.api_key || form.webhook_secret)) || + (form.type === 'nut'); const body: any = { name: form.name, icon: form.icon }; if (hasConfigChange) body.config = config; await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify(body) }); @@ -175,7 +185,7 @@ nameManuallyEdited = true} required class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> - {#if form.type !== 'scheduler'} + {#if form.type !== 'scheduler' && form.type !== 'nut'}
@@ -226,6 +236,25 @@

{t('providers.plankaWebhookUrlHint')}

{/if} + {:else if form.type === 'nut'} +
+ + +
+
+ + +
+
+ + +

{t('providers.nutUsernameHint')}

+
+
+ + +

{t('providers.nutPasswordHint')}

+
{/if}