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:
@@ -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"
|
||||
|
||||
@@ -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') },
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user