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