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:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user