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
@@ -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},
}