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:
@@ -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 }[] = [];
|
||||
|
||||
Reference in New Issue
Block a user