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:
@@ -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 "***"
|
||||
|
||||
@@ -249,6 +249,138 @@ async def get_template_variables():
|
||||
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
|
||||
"asset_fields": asset_fields,
|
||||
},
|
||||
# --- Gitea slots ---
|
||||
**_gitea_variables(),
|
||||
# --- Planka slots ---
|
||||
**_planka_variables(),
|
||||
# --- NUT (UPS) slots ---
|
||||
**_nut_variables(),
|
||||
# --- Scheduler slots ---
|
||||
"message_scheduled_message": {
|
||||
"description": "Notification for scheduled message events",
|
||||
"variables": {
|
||||
"tracker_name": "Name of the tracker that fired",
|
||||
"fire_count": "How many times this tracker has fired",
|
||||
"current_date": "Current date (formatted)",
|
||||
"current_time": "Current time (formatted)",
|
||||
"current_datetime": "Current date and time (formatted)",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _gitea_variables() -> dict:
|
||||
common = {
|
||||
"sender": "Username who triggered the event",
|
||||
"sender_name": "Display name of the sender",
|
||||
"repo_name": "Repository full name (owner/repo)",
|
||||
"repo_url": "Repository URL",
|
||||
}
|
||||
return {
|
||||
"message_push": {
|
||||
"description": "Code pushed to repository",
|
||||
"variables": {**common, "branch": "Branch name", "commit_count": "Number of commits",
|
||||
"compare_url": "Comparison URL", "commits": "List of commit dicts"},
|
||||
},
|
||||
"message_issue_opened": {
|
||||
"description": "Issue opened",
|
||||
"variables": {**common, "issue_number": "Issue number", "issue_title": "Issue title",
|
||||
"issue_url": "Issue URL", "issue_body": "Issue body text", "issue_labels": "Labels list"},
|
||||
},
|
||||
"message_issue_closed": {
|
||||
"description": "Issue closed",
|
||||
"variables": {**common, "issue_number": "Issue number", "issue_title": "Issue title",
|
||||
"issue_url": "Issue URL", "issue_state": "Issue state"},
|
||||
},
|
||||
"message_issue_commented": {
|
||||
"description": "Comment on issue",
|
||||
"variables": {**common, "issue_number": "Issue number", "issue_title": "Issue title",
|
||||
"issue_url": "Issue URL", "comment_body": "Comment text",
|
||||
"comment_url": "Comment URL", "comment_author": "Comment author"},
|
||||
},
|
||||
"message_pr_opened": {
|
||||
"description": "Pull request opened",
|
||||
"variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
|
||||
"pr_url": "PR URL", "pr_body": "PR body text",
|
||||
"pr_base": "Base branch", "pr_head": "Head branch", "pr_labels": "Labels list"},
|
||||
},
|
||||
"message_pr_closed": {
|
||||
"description": "Pull request closed",
|
||||
"variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
|
||||
"pr_url": "PR URL", "pr_state": "PR state", "pr_merged": "Whether PR was merged"},
|
||||
},
|
||||
"message_pr_merged": {
|
||||
"description": "Pull request merged",
|
||||
"variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
|
||||
"pr_url": "PR URL", "pr_base": "Base branch", "pr_head": "Head branch"},
|
||||
},
|
||||
"message_pr_commented": {
|
||||
"description": "Comment on pull request",
|
||||
"variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
|
||||
"pr_url": "PR URL", "comment_body": "Comment text",
|
||||
"comment_url": "Comment URL", "comment_author": "Comment author"},
|
||||
},
|
||||
"message_release_published": {
|
||||
"description": "Release published",
|
||||
"variables": {**common, "release_tag": "Release tag", "release_name": "Release name",
|
||||
"release_url": "Release URL", "release_body": "Release notes",
|
||||
"release_draft": "Is draft (boolean)", "release_prerelease": "Is prerelease (boolean)"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _planka_variables() -> dict:
|
||||
common = {
|
||||
"sender": "Username who triggered the event",
|
||||
"sender_name": "Display name of the sender",
|
||||
"board_name": "Board name",
|
||||
"board_id": "Board ID",
|
||||
"board_url": "Board URL",
|
||||
}
|
||||
card = {**common, "card_name": "Card name", "card_id": "Card ID", "card_url": "Card URL"}
|
||||
return {
|
||||
"message_card_created": {"description": "Card created", "variables": {**card, "list_name": "List name", "card_description": "Card description"}},
|
||||
"message_card_updated": {"description": "Card updated", "variables": {**card, "card_description": "Card description", "card_due_date": "Due date"}},
|
||||
"message_card_moved": {"description": "Card moved between lists", "variables": {**card, "old_list_name": "Previous list", "new_list_name": "New list"}},
|
||||
"message_card_deleted": {"description": "Card deleted", "variables": card},
|
||||
"message_card_commented": {"description": "Comment added to card", "variables": {**card, "comment_text": "Comment text"}},
|
||||
"message_comment_updated": {"description": "Comment updated", "variables": {**card, "comment_text": "Updated comment text"}},
|
||||
"message_board_created": {"description": "Board created", "variables": common},
|
||||
"message_board_updated": {"description": "Board updated", "variables": common},
|
||||
"message_board_deleted": {"description": "Board deleted", "variables": common},
|
||||
"message_list_created": {"description": "List created", "variables": {**common, "list_name": "List name"}},
|
||||
"message_list_updated": {"description": "List updated", "variables": {**common, "list_name": "List name"}},
|
||||
"message_list_deleted": {"description": "List deleted", "variables": {**common, "list_name": "List name"}},
|
||||
"message_attachment_created": {"description": "Attachment added", "variables": {**card, "attachment_name": "Attachment filename"}},
|
||||
"message_card_label_added": {"description": "Label added to card", "variables": {**card, "label_name": "Label name", "label_color": "Label color"}},
|
||||
"message_task_completed": {"description": "Task completed", "variables": {**card, "task_name": "Task name", "task_completed": "Completed (boolean)"}},
|
||||
}
|
||||
|
||||
|
||||
def _nut_variables() -> dict:
|
||||
common = {
|
||||
"ups_name": "UPS device name",
|
||||
"ups_model": "UPS hardware model",
|
||||
"ups_manufacturer": "UPS manufacturer",
|
||||
"battery_charge": "Battery charge percentage",
|
||||
"battery_runtime": "Estimated runtime (formatted)",
|
||||
"battery_runtime_seconds": "Estimated runtime in seconds",
|
||||
"ups_load": "UPS load percentage",
|
||||
"ups_status": "Raw status flags (e.g. OL, OB, LB)",
|
||||
"input_voltage": "Input voltage",
|
||||
"output_voltage": "Output voltage",
|
||||
"event_description": "Human-readable event description",
|
||||
"previous_status": "Previous UPS status flags",
|
||||
}
|
||||
return {
|
||||
"message_ups_online": {"description": "UPS back on mains power", "variables": common},
|
||||
"message_ups_on_battery": {"description": "UPS switched to battery", "variables": common},
|
||||
"message_ups_low_battery": {"description": "Battery critically low", "variables": common},
|
||||
"message_ups_battery_restored": {"description": "Battery charge recovered", "variables": common},
|
||||
"message_ups_comms_lost": {"description": "Communication with UPS lost", "variables": {"ups_name": common["ups_name"], "previous_status": common["previous_status"], "event_description": common["event_description"]}},
|
||||
"message_ups_comms_restored": {"description": "Communication restored", "variables": common},
|
||||
"message_ups_replace_battery": {"description": "Battery needs replacement", "variables": common},
|
||||
"message_ups_overload": {"description": "UPS load exceeded capacity", "variables": common},
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user