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
@@ -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}