feat: NUT (Network UPS Tools) service provider + provider-agnostic UI

Add full NUT support as a polling-based service provider:
- Async TCP client for upsd protocol (port 3493, configurable)
- 8 event types: online, on_battery, low_battery, battery_restored,
  comms_lost, comms_restored, replace_battery, overload
- 3 bot commands: /status, /devices, /battery
- 38 Jinja2 templates (EN+RU notification + command templates)
- Database: tracking config fields, migration, seeds
- Frontend: provider form with host/port/credentials, grid items, i18n

Provider-agnostic UI improvements:
- Remove hardcoded 'immich' defaults from all config forms
- Dynamic collection labels per provider type (Albums/Repos/Boards/UPS Devices)
- Capability-driven test types instead of provider type checks
- Template variable helpers for all providers (was Immich-only)
- Guard Immich-only shared link check to Immich providers
- Auto-clear stale global provider filter from localStorage
- EntitySelect search placeholder shows current selection
- Fix noneLabel in linked target config selectors

New CLAUDE.md rule #8: no provider-specific hardcoding
This commit is contained in:
2026-03-23 23:23:58 +03:00
parent c451f3dd72
commit 68ac13b452
73 changed files with 1385 additions and 45 deletions
@@ -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<string, any> = $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<string | null>(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<string, { key: string; icon: string; labelKey: string; requiredSlot?: string }> = {
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<number | null>(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<Tracker[]>('/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 }[] = [];
@@ -85,12 +85,12 @@
<div class="flex items-center gap-2 flex-wrap justify-end">
<div class="min-w-[140px]">
<EntitySelect items={trackingConfigItems} value={tt.tracking_config_id}
placeholder={t('trackingConfig.title')} size="sm" allowNone noneLabel={t('trackingConfig.title')}
placeholder={t('trackingConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
</div>
<div class="min-w-[140px]">
<EntitySelect items={templateConfigItems} value={tt.template_config_id}
placeholder={t('templateConfig.title')} size="sm" allowNone noneLabel={t('templateConfig.title')}
placeholder={t('templateConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
</div>
<div class="relative">
@@ -118,12 +118,12 @@
</div>
<div class="min-w-[140px]">
<EntitySelect items={trackingConfigItems} value={newLinkTrackingConfigId || null}
placeholder={t('trackingConfig.title')} size="sm" allowNone noneLabel={t('trackingConfig.title')}
placeholder={t('trackingConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
onselect={(v) => onchangeNewTrackingConfig(Number(v) || 0)} />
</div>
<div class="min-w-[140px]">
<EntitySelect items={templateConfigItems} value={newLinkTemplateConfigId || null}
placeholder={t('templateConfig.title')} size="sm" allowNone noneLabel={t('templateConfig.title')}
placeholder={t('templateConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
onselect={(v) => onchangeNewTemplateConfig(Number(v) || 0)} />
</div>
<button onclick={onaddLink}
@@ -46,7 +46,16 @@
}: Props = $props();
let isScheduler = $derived(providerType === 'scheduler');
let isWebhook = $derived(providerType === 'gitea');
let isWebhook = $derived(providerType === 'gitea' || providerType === 'planka');
// Collection label/icon/desc per provider type
const collectionMeta: Record<string, { label: string; icon: string; placeholder: string; desc: (col: any) => string }> = {
immich: { label: t('notificationTracker.albums'), icon: 'mdiImageMultiple', placeholder: t('notificationTracker.selectAlbums'), desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets` },
gitea: { label: t('notificationTracker.repositories'), icon: 'mdiGit', placeholder: t('notificationTracker.selectRepositories'), desc: () => '' },
planka: { label: t('notificationTracker.boards'), icon: 'mdiViewDashboard', placeholder: t('notificationTracker.selectBoards'), desc: () => '' },
nut: { label: t('notificationTracker.upsDevices'), icon: 'mdiBatteryCharging80', placeholder: t('notificationTracker.selectUpsDevices'), desc: (col) => col.description || '' },
};
let colMeta = $derived(collectionMeta[providerType] || { label: t('notificationTracker.albums'), icon: 'mdiServer', placeholder: t('notificationTracker.selectAlbums'), desc: () => '' });
// Custom variable management for scheduler
function addVariable() {
@@ -92,16 +101,16 @@
</div>
{#if !isScheduler && collections.length > 0}
<div>
<label class="block text-sm font-medium mb-1">{t('notificationTracker.albums')}</label>
<label class="block text-sm font-medium mb-1">{colMeta.label}</label>
<MultiEntitySelect
items={collections.map(col => ({
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}
/>
</div>
{/if}