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