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
@@ -130,7 +130,7 @@
<input
bind:this={inputEl}
bind:value={query}
placeholder={placeholder}
placeholder={selected ? selected.label : placeholder}
class="ep-input"
type="text"
autocomplete="off"
+3
View File
@@ -12,6 +12,7 @@ const PROVIDER_TYPE_ICONS: Record<string, string> = {
gitea: 'mdiGit',
planka: 'mdiViewDashboard',
scheduler: 'mdiClockOutline',
nut: 'mdiBatteryCharging80',
};
/** Get the default icon for a provider, falling back by type then generic. */
@@ -118,6 +119,7 @@ export const providerTypeFilterItems = (): GridItem[] => [
{ value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') },
{ value: 'planka', icon: 'mdiViewDashboard', label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') },
{ value: 'scheduler', icon: 'mdiClockOutline', label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') },
{ value: 'nut', icon: PROVIDER_TYPE_ICONS.nut, label: t('providers.typeNut'), desc: t('gridDesc.providerNut') },
];
// --- Provider type ---
@@ -127,4 +129,5 @@ export const providerTypeItems = (): GridItem[] => [
{ value: 'gitea', icon: PROVIDER_TYPE_ICONS.gitea, label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') },
{ value: 'planka', icon: PROVIDER_TYPE_ICONS.planka, label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') },
{ value: 'scheduler', icon: PROVIDER_TYPE_ICONS.scheduler, label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') },
{ value: 'nut', icon: PROVIDER_TYPE_ICONS.nut, label: t('providers.typeNut'), desc: t('gridDesc.providerNut') },
];
+16 -1
View File
@@ -111,6 +111,7 @@
"typeGitea": "Gitea",
"typePlanka": "Planka",
"typeScheduler": "Scheduler",
"typeNut": "NUT (UPS)",
"loadError": "Failed to load providers.",
"externalDomain": "External Domain",
"optional": "optional",
@@ -127,6 +128,13 @@
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
"webhookUrl": "Webhook URL",
"webhookUrlHint": "Set this as the Target URL in Gitea webhook settings (relative to your bridge host).",
"nutHost": "NUT Server Host",
"nutHostPlaceholder": "192.168.1.100 or ups.local",
"nutPort": "NUT Server Port",
"nutUsername": "Username",
"nutPassword": "Password",
"nutUsernameHint": "Optional — only needed if upsd requires authentication",
"nutPasswordHint": "Optional — upsd user password",
"testAndSave": "Test & Save",
"saveWithoutTest": "Save without testing"
},
@@ -141,6 +149,12 @@
"selectServer": "Select provider...",
"albums": "Albums",
"selectAlbums": "Select albums...",
"repositories": "Repositories",
"selectRepositories": "Select repositories...",
"boards": "Boards",
"selectBoards": "Select boards...",
"upsDevices": "UPS Devices",
"selectUpsDevices": "Select UPS devices...",
"eventTypes": "Event Types",
"notificationTargets": "Notification Targets",
"scanInterval": "Scan Interval (seconds)",
@@ -826,7 +840,8 @@
"providerImmich": "Self-hosted photo server",
"providerGitea": "Self-hosted Git service",
"providerPlanka": "Self-hosted Kanban board",
"providerScheduler": "Time-based scheduled messages"
"providerScheduler": "Time-based scheduled messages",
"providerNut": "Network UPS monitoring"
},
"error": {
"notFound": "Page not found",
+16 -1
View File
@@ -111,6 +111,7 @@
"typeGitea": "Gitea",
"typePlanka": "Planka",
"typeScheduler": "Планировщик",
"typeNut": "NUT (ИБП)",
"loadError": "Не удалось загрузить провайдеры.",
"externalDomain": "Внешний домен",
"optional": "необязательно",
@@ -127,6 +128,13 @@
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
"webhookUrl": "URL вебхука",
"webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea (относительно хоста bridge).",
"nutHost": "Хост NUT-сервера",
"nutHostPlaceholder": "192.168.1.100 или ups.local",
"nutPort": "Порт NUT-сервера",
"nutUsername": "Имя пользователя",
"nutPassword": "Пароль",
"nutUsernameHint": "Необязательно — только если upsd требует аутентификации",
"nutPasswordHint": "Необязательно — пароль пользователя upsd",
"testAndSave": "Проверить и сохранить",
"saveWithoutTest": "Сохранить без проверки"
},
@@ -141,6 +149,12 @@
"selectServer": "Выберите провайдер...",
"albums": "Альбомы",
"selectAlbums": "Выберите альбомы...",
"repositories": "Репозитории",
"selectRepositories": "Выберите репозитории...",
"boards": "Доски",
"selectBoards": "Выберите доски...",
"upsDevices": "ИБП устройства",
"selectUpsDevices": "Выберите ИБП...",
"eventTypes": "Типы событий",
"notificationTargets": "Получатели уведомлений",
"scanInterval": "Интервал проверки (секунды)",
@@ -826,7 +840,8 @@
"providerImmich": "Фотосервер для самостоятельного размещения",
"providerGitea": "Git-сервер для самостоятельного размещения",
"providerPlanka": "Канбан-доска для самостоятельного размещения",
"providerScheduler": "Запланированные сообщения по расписанию"
"providerScheduler": "Запланированные сообщения по расписанию",
"providerNut": "Мониторинг ИБП через NUT"
},
"error": {
"notFound": "Страница не найдена",
@@ -26,7 +26,15 @@ function loadFromStorage(): void {
loadFromStorage();
export const globalProviderFilter = {
get id() { return _providerId; },
get id() {
// If providers are loaded and the stored ID doesn't match any, auto-clear
if (_providerId != null && providersCache.items.length > 0 &&
!providersCache.items.some(p => p.id === _providerId)) {
globalProviderFilter.clear();
return null;
}
return _providerId;
},
get initialized() { return _initialized; },
set(id: number | null) {
@@ -46,8 +54,9 @@ export const globalProviderFilter = {
/** The currently selected provider object (reactive). */
get provider() {
if (_providerId == null) return null;
return providersCache.items.find(p => p.id === _providerId) ?? null;
const id = this.id; // triggers stale-ID auto-clear
if (id == null) return null;
return providersCache.items.find(p => p.id === id) ?? null;
},
/** The provider type string, or null. */
+8
View File
@@ -42,6 +42,14 @@
globalProviderFilter.set(v === 0 ? null : v);
});
// Sync store → filter value (handles auto-clear of stale IDs)
$effect(() => {
const storeId = globalProviderFilter.id;
if (storeId === null && providerFilterValue !== 0) {
providerFilterValue = 0;
}
});
let showPasswordForm = $state(false);
let redirecting = $state(false);
let openSearch: (() => void) | undefined;
@@ -67,9 +67,9 @@
const defaultForm = () => ({
name: '',
icon: '',
provider_type: 'immich',
enabled_commands: ['help', 'status', 'albums', 'events', 'latest', 'random', 'favorites', 'summary', 'memory'] as string[],
response_mode: 'media',
provider_type: '',
enabled_commands: [] as string[],
response_mode: 'text',
default_count: 5,
rate_limits: { search: 30, default: 10 },
command_template_config_id: null as number | null,
@@ -100,7 +100,7 @@
form = {
name: cfg.name,
icon: cfg.icon || '',
provider_type: cfg.provider_type || 'immich',
provider_type: cfg.provider_type || '',
enabled_commands: [...(cfg.enabled_commands || [])],
response_mode: cfg.response_mode || 'media',
default_count: cfg.default_count ?? 5,
@@ -68,7 +68,7 @@
);
const defaultForm = () => ({
provider_type: 'immich',
provider_type: '',
name: '',
description: '',
icon: '',
@@ -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}
+37 -6
View File
@@ -27,7 +27,7 @@
));
let showForm = $state(false);
let editing = $state<number | null>(null);
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' });
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '', nut_host: '', nut_port: 3493, nut_username: '', nut_password: '' });
let nameManuallyEdited = $state(false);
let error = $state('');
let loadError = $state('');
@@ -36,7 +36,7 @@
let confirmDelete = $state<ServiceProvider | null>(null);
const providerDefaultNames: Record<string, string> = {
immich: 'Immich', gitea: 'Gitea', planka: 'Planka', scheduler: 'Scheduler',
immich: 'Immich', gitea: 'Gitea', planka: 'Planka', scheduler: 'Scheduler', nut: 'NUT',
};
// Auto-update name when provider type changes (unless user manually edited)
@@ -67,7 +67,7 @@
}
function openNew() {
form = { name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' };
form = { name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '', nut_host: '', nut_port: 3493, nut_username: '', nut_password: '' };
nameManuallyEdited = false;
editing = null; showForm = true;
}
@@ -77,6 +77,8 @@
name: p.name, type: p.type, url: cfg.url || '',
api_key: '', api_token: '', webhook_secret: '',
external_domain: cfg.external_domain || '', icon: p.icon || '',
nut_host: cfg.host || '', nut_port: cfg.port || 3493,
nut_username: '', nut_password: '',
};
nameManuallyEdited = true;
editing = p.id; showForm = true;
@@ -85,7 +87,14 @@
async function save(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true;
try {
const config: any = { url: form.url };
let config: any;
if (form.type === 'nut') {
config = { host: form.nut_host, port: form.nut_port || 3493 };
if (form.nut_username) config.username = form.nut_username;
if (form.nut_password) config.password = form.nut_password;
} else {
config = { url: form.url };
}
if (form.type === 'immich') {
if (form.api_key) config.api_key = form.api_key;
if (form.external_domain) config.external_domain = form.external_domain;
@@ -110,7 +119,8 @@
const hasConfigChange = form.url !== (providers.find(p => p.id === editing)?.config?.url || '') ||
(form.type === 'immich' && (form.api_key || form.external_domain !== (providers.find(p => p.id === editing)?.config?.external_domain || ''))) ||
(form.type === 'gitea' && (form.api_token || form.webhook_secret)) ||
(form.type === 'planka' && (form.api_key || form.webhook_secret));
(form.type === 'planka' && (form.api_key || form.webhook_secret)) ||
(form.type === 'nut');
const body: any = { name: form.name, icon: form.icon };
if (hasConfigChange) body.config = config;
await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
@@ -175,7 +185,7 @@
<input id="prv-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if form.type !== 'scheduler'}
{#if form.type !== 'scheduler' && form.type !== 'nut'}
<div>
<label for="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
<input id="prv-url" bind:value={form.url} required placeholder={form.type === 'gitea' ? 'https://gitea.example.com' : form.type === 'planka' ? 'https://planka.example.com' : t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
@@ -226,6 +236,25 @@
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.plankaWebhookUrlHint')}</p>
</div>
{/if}
{:else if form.type === 'nut'}
<div>
<label for="prv-nut-host" class="block text-sm font-medium mb-1">{t('providers.nutHost')}</label>
<input id="prv-nut-host" bind:value={form.nut_host} required placeholder={t('providers.nutHostPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="prv-nut-port" class="block text-sm font-medium mb-1">{t('providers.nutPort')}</label>
<input id="prv-nut-port" bind:value={form.nut_port} type="number" min="1" max="65535" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="prv-nut-user" class="block text-sm font-medium mb-1">{t('providers.nutUsername')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
<input id="prv-nut-user" bind:value={form.nut_username} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.nutUsernameHint')}</p>
</div>
<div>
<label for="prv-nut-pass" class="block text-sm font-medium mb-1">{t('providers.nutPassword')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
<input id="prv-nut-pass" bind:value={form.nut_password} type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.nutPasswordHint')}</p>
</div>
{/if}
<button type="submit" disabled={submitting}
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
@@ -266,6 +295,8 @@
</div>
{#if provider.config?.url}
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline">{provider.config.url}</a>
{:else if provider.config?.host}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
{/if}
{#if provider.type === 'gitea'}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">/api/webhooks/gitea/{provider.id}</span></p>
@@ -124,7 +124,7 @@
}
const defaultForm = () => ({
provider_type: 'immich', name: '', description: '', icon: '',
provider_type: '', name: '', description: '', icon: '',
slots: {} as Record<string, Record<string, string>>,
date_format: '%d.%m.%Y, %H:%M UTC',
date_only_format: '%d.%m.%Y',
@@ -35,7 +35,7 @@
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
const defaultForm = () => ({
provider_type: 'immich', name: '', icon: '',
provider_type: '', name: '', icon: '',
// Immich event tracking
track_assets_added: true, track_assets_removed: false,
track_collection_renamed: true, track_collection_deleted: true, track_sharing_changed: false,