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
@@ -13,7 +13,7 @@ import aiohttp
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import ServiceProvider, User
from ..services import make_immich_provider, make_gitea_provider, make_planka_provider
from ..services import make_immich_provider, make_gitea_provider, make_planka_provider, make_nut_provider
_LOGGER = logging.getLogger(__name__)
@@ -69,11 +69,19 @@ class SchedulerProviderConfig(BaseModel):
pass
class NutProviderConfig(BaseModel):
host: str
port: int = 3493
username: str | None = None
password: str | None = None
_PROVIDER_CONFIG_MODELS: dict[str, type[BaseModel]] = {
"immich": ImmichProviderConfig,
"gitea": GiteaProviderConfig,
"planka": PlankaProviderConfig,
"scheduler": SchedulerProviderConfig,
"nut": NutProviderConfig,
}
@@ -163,6 +171,17 @@ async def create_provider(
detail=test_result.get("message", "Cannot connect to Planka"),
)
elif body.type == "nut":
nut = make_nut_provider(ServiceProvider(
id=0, user_id=0, type="nut", name=body.name, config=body.config,
))
test_result = await nut.test_connection()
if not test_result.get("ok"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=test_result.get("message", "Cannot connect to NUT server"),
)
# Scheduler: no validation needed (virtual provider)
provider = ServiceProvider(
@@ -297,6 +316,14 @@ async def update_provider(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Connection error: {err}",
)
elif config_changed and provider.type == "nut":
nut = make_nut_provider(provider)
test_result = await nut.test_connection()
if not test_result.get("ok"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=test_result.get("message", "Cannot connect to NUT server"),
)
session.add(provider)
await session.commit()
@@ -349,6 +376,10 @@ async def test_provider(
if provider.type == "scheduler":
return {"ok": True, "message": "Virtual provider — always available"}
if provider.type == "nut":
nut = make_nut_provider(provider)
return await nut.test_connection()
return {"ok": False, "message": f"Unknown provider type: {provider.type}"}
@@ -417,6 +448,10 @@ async def list_collections(
planka = make_planka_provider(http_session, provider)
return await planka.list_collections()
if provider.type == "nut":
nut = make_nut_provider(provider)
return await nut.list_collections()
return []
@@ -475,7 +510,7 @@ def _provider_response(p: ServiceProvider) -> dict:
"""Build a safe response dict for a provider."""
config = dict(p.config)
# Mask sensitive fields
for secret_field in ("api_key", "api_token", "webhook_secret"):
for secret_field in ("api_key", "api_token", "webhook_secret", "password"):
if secret_field in config:
key = config[secret_field]
config[secret_field] = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"