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 }[] = [];