librelad 3bc91eef55 refactor(tools): modular per-app tools convention (containers/<app>/tools/) + migrate adguard
Establish the self-contained tools convention and prove it on a core app:
- discovery now reads containers/<app>/tools/<app>.tools.json (the tools/ subfolder);
  tool functions live at containers/<app>/tools/*.sh, auto-sourced by the container
  scan (depth 3) — no scripts/app/ entry, no array regen.
- adguard migrated: its 2 Tools-tab actions (reset_password, apply_dns_updater) moved
  to containers/adguard/tools/ + tools/adguard.tools.json, and dropped from the
  central webui_tools.sh heredoc. adguard_auth.sh stays in scripts/app/ — it's a logic
  helper, NOT a tool (the key distinction: only DECLARED tools move).

Central + per-app styles coexist (pihole etc. still central), so the remaining apps
can migrate one at a time with nothing breaking. Verified: heredoc valid sans adguard,
per-app merge re-adds adguard's 2 tools, scripts array dropped the moved fns.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-25 22:30:49 +01:00

425 lines
21 KiB
Bash
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
# Single source of truth for the Tools tab. The frontend reads
# /data/apps/generated/apps-tools.json — this generator emits it.
#
# To add a tool:
# 1. Add an entry under that app's "tools" array below.
# 2. Drop a one-function file at scripts/app/containers/<app>/<app>_<tool_id>.sh
# with `app<App><PascalCase(toolId)>()` defined inside.
# 3. Re-run the WebUI updater (or call this function directly) to
# regenerate apps-tools.json.
#
# Tool entry schema (matches what tools-manager.js expects):
# {
# id: "<unique within the app>", # passed to dispatcher
# label: "Reset User Password", # button + modal heading
# description: "One-line summary", # shown on card + modal
# icon: "🔑", # any emoji
# destructive: false, # red button if true
# confirm: "Are you sure?", # forces a modal even
# # when fields is empty
# fields: [] # see field schema below
# }
#
# Field schema (each form input the modal collects):
# { name, label, type: text|password|number|select|checkbox|textarea,
# default, placeholder, required, options (for select), min, max }
webuiGenerateAppsToolsConfig() {
# The WebUI task service is a long-lived bash process that sources
# this file once at startup. When the auto-updater rewrites this
# script on disk (e.g. to add a new app's tools entry), the function
# in memory stays stale until the service restarts — producing
# apps-tools.json without the new entries. Re-source ourselves on
# every call so heredoc edits take effect immediately, then dispatch
# to the freshly-loaded version. The guard prevents infinite
# recursion.
# Skip the reload if the file is missing right now — happens briefly
# during update.sh quick (find -delete + cp). Fall through to the
# in-memory version (one cycle stale at worst).
if [[ -z "$_WEBUI_TOOLS_RELOADED" && -f "${BASH_SOURCE[0]}" ]]; then
local _WEBUI_TOOLS_RELOADED=1
source "${BASH_SOURCE[0]}"
webuiGenerateAppsToolsConfig "$@"
return $?
fi
local output_file="${containers_dir}libreportal/frontend/data/apps/generated/apps-tools.json"
local tmp="$(mktemp)"
runFileOp mkdir -p "$(dirname "$output_file")"
# Heredoc carries the JSON literal verbatim — much easier to edit
# than escaping nested quotes through jq -n.
cat > "$tmp" <<'JSON'
{
"apps": {
"pihole": {
"tools": [
{
"id": "apply_dns_updater",
"label": "Apply DNS Updater",
"description": "Rewrite this server's /etc/resolv.conf to use Pi-hole as its DNS resolver right now. Same action that runs automatically when the global DNS Updater requirement is enabled.",
"icon": "🌐",
"fields": []
}
]
},
"dashy": {
"tools": [
{
"id": "manage_shortcuts",
"label": "Manage Shortcuts",
"description": "Pick which service URLs appear as shortcuts on the Dashy dashboard.",
"icon": "🧩",
"fields": [
{
"name": "selected",
"label": "URLs to show on the dashboard",
"type": "app_urls_multi",
"prefillFromCfgKey": "CFG_DASHY_SHORTCUTS",
"excludeApps": ["dashy"]
}
]
}
]
},
"bookstack": {
"tools": [
{
"id": "reset_password",
"category": "users",
"label": "Reset User Password",
"description": "Reset an existing Bookstack user's password. Leave the password field blank to generate a random one — it is shown in the task log.",
"icon": "🔑",
"fields": [
{ "name": "email", "label": "User email", "type": "text", "placeholder": "user@example.com", "required": true },
{ "name": "password", "label": "New password (leave blank to generate)", "type": "password", "placeholder": "Leave blank for random" }
]
},
{
"id": "create_account",
"category": "users",
"label": "Create User Account",
"description": "Create a new Bookstack user. Tick \"Make admin\" to grant full admin rights; otherwise the new user gets the default registration role. Leave the password blank to generate a random one.",
"icon": "👤",
"fields": [
{ "name": "email", "label": "Email", "type": "text", "placeholder": "user@example.com", "required": true },
{ "name": "name", "label": "Display name", "type": "text", "required": true },
{ "name": "password", "label": "Password (leave blank to generate)", "type": "password", "placeholder": "Leave blank for random" },
{ "name": "admin", "label": "Make admin", "type": "checkbox", "default": false }
]
},
{ "id": "list_users", "category": "users", "label": "List Users", "description": "Show every Bookstack user with their roles.", "icon": "📋", "fields": [] },
{
"id": "delete_user", "category": "users", "label": "Delete User Account",
"description": "Permanently delete a user account.", "icon": "🗑",
"destructive": true, "confirm": "This cannot be undone.",
"fields": [
{ "name": "email", "label": "User email", "type": "text", "required": true }
]
},
{
"id": "set_admin", "category": "users", "label": "Set Admin Status",
"description": "Promote a user to admin or demote them to a normal user.", "icon": "👑",
"fields": [
{ "name": "email", "label": "User email", "type": "text", "required": true },
{ "name": "admin", "label": "Make admin", "type": "checkbox", "default": false }
]
}
]
},
"focalboard": {
"tools": [
{
"id": "reset_password",
"category": "users",
"label": "Reset User Password",
"description": "Reset an existing Focalboard user's password.",
"icon": "🔑",
"fields": [
{ "name": "username", "label": "Username", "type": "text", "required": true },
{ "name": "password", "label": "New password (leave blank to generate)", "type": "password", "placeholder": "Leave blank for random" }
]
},
{
"id": "create_account",
"category": "users",
"label": "Create User Account",
"description": "Create a new Focalboard user. Leave password blank to generate one.",
"icon": "👤",
"fields": [
{ "name": "username", "label": "Username", "type": "text", "required": true },
{ "name": "email", "label": "Email", "type": "text", "required": true },
{ "name": "password", "label": "Password (leave blank to generate)", "type": "password" },
{ "name": "admin", "label": "Make admin", "type": "checkbox", "default": false }
]
},
{
"id": "list_users",
"category": "users",
"label": "List Users",
"description": "Show every Focalboard user account.",
"icon": "📋",
"fields": []
},
{
"id": "delete_user", "category": "users", "label": "Delete User Account",
"description": "Permanently delete a user account.", "icon": "🗑",
"destructive": true, "confirm": "This cannot be undone.",
"fields": [
{ "name": "username", "label": "Username", "type": "text", "required": true }
]
}
]
},
"mattermost": {
"tools": [
{
"id": "reset_password", "category": "users", "label": "Reset User Password",
"description": "Reset a Mattermost user's password via mmctl.", "icon": "🔑",
"fields": [
{ "name": "username", "label": "Username or Email", "type": "text", "required": true },
{ "name": "password", "label": "New password (leave blank to generate)", "type": "password" }
]
},
{
"id": "create_account", "category": "users", "label": "Create User Account",
"description": "Create a new Mattermost user via mmctl.", "icon": "👤",
"fields": [
{ "name": "username", "label": "Username", "type": "text", "required": true },
{ "name": "email", "label": "Email", "type": "text", "required": true },
{ "name": "password", "label": "Password (leave blank to generate)", "type": "password" },
{ "name": "admin", "label": "Make system admin", "type": "checkbox", "default": false }
]
},
{ "id": "list_users", "category": "users", "label": "List Users", "description": "Show every Mattermost user account.", "icon": "📋", "fields": [] },
{
"id": "delete_user", "category": "users", "label": "Delete User Account",
"description": "Permanently delete a user account.", "icon": "🗑",
"destructive": true, "confirm": "This cannot be undone.",
"fields": [
{ "name": "username", "label": "Username/Email", "type": "text", "required": true }
]
},
{
"id": "set_admin", "category": "users", "label": "Set Admin Status",
"description": "Promote a user to admin or demote them to a normal user.", "icon": "👑",
"fields": [
{ "name": "username", "label": "Username/Email", "type": "text", "required": true },
{ "name": "admin", "label": "Make admin", "type": "checkbox", "default": false }
]
}
]
},
"invidious": {
"tools": [
{
"id": "reset_password", "category": "users", "label": "Reset User Password",
"description": "Reset an Invidious user's password (Postgres bcrypt update).", "icon": "🔑",
"fields": [
{ "name": "email", "label": "Email", "type": "text", "required": true },
{ "name": "password", "label": "New password (leave blank to generate)", "type": "password" }
]
},
{
"id": "create_account", "category": "users", "label": "Create User Account",
"description": "Create a new Invidious user (Invidious uses email as username).", "icon": "👤",
"fields": [
{ "name": "email", "label": "Email", "type": "text", "required": true },
{ "name": "password", "label": "Password (leave blank to generate)", "type": "password" },
{ "name": "admin", "label": "Make admin", "type": "checkbox", "default": false }
]
},
{ "id": "list_users", "category": "users", "label": "List Users", "description": "Show every Invidious user account.", "icon": "📋", "fields": [] },
{
"id": "delete_user", "category": "users", "label": "Delete User Account",
"description": "Permanently delete a user account.", "icon": "🗑",
"destructive": true, "confirm": "This cannot be undone.",
"fields": [
{ "name": "email", "label": "Email", "type": "text", "required": true }
]
}
]
},
"gitea": {
"tools": [
{
"id": "reset_password",
"category": "users",
"label": "Reset User Password",
"description": "Reset a Gitea user's password using the built-in `gitea admin user change-password` CLI. Leave the password blank to generate a random one.",
"icon": "🔑",
"fields": [
{ "name": "username", "label": "Username", "type": "text", "required": true },
{ "name": "password", "label": "New password (leave blank to generate)", "type": "password", "placeholder": "Leave blank for random" }
]
},
{
"id": "create_account",
"category": "users",
"label": "Create User Account",
"description": "Create a new Gitea user via the built-in `gitea admin user create` CLI. Tick \"Make admin\" for full admin rights. Leave the password blank to generate a random one. The account starts ready to log in (no forced password change).",
"icon": "👤",
"fields": [
{ "name": "username", "label": "Username", "type": "text", "required": true },
{ "name": "email", "label": "Email", "type": "text", "placeholder": "user@example.com", "required": true },
{ "name": "password", "label": "Password (leave blank to generate)", "type": "password", "placeholder": "Leave blank for random" },
{ "name": "admin", "label": "Make admin", "type": "checkbox", "default": false }
]
},
{ "id": "list_users", "category": "users", "label": "List Users", "description": "Show every Gitea user account.", "icon": "📋", "fields": [] },
{
"id": "delete_user", "category": "users", "label": "Delete User Account",
"description": "Permanently delete a user account.", "icon": "🗑",
"destructive": true, "confirm": "This cannot be undone.",
"fields": [
{ "name": "username", "label": "Username", "type": "text", "required": true }
]
},
{
"id": "set_admin", "category": "users", "label": "Set Admin Status",
"description": "Promote a user to admin or demote them to a normal user.", "icon": "👑",
"fields": [
{ "name": "username", "label": "Username", "type": "text", "required": true },
{ "name": "admin", "label": "Make admin", "type": "checkbox", "default": false }
]
}
]
},
"gluetun": {
"tools": [
{
"id": "refresh_providers",
"label": "Refresh VPN Providers",
"description": "Re-scan VPN providers and country lists, regenerating the snapshot used by the country picker.",
"icon": "🔄",
"fields": []
}
]
},
"traefik": {
"tools": [
{
"id": "reset_password",
"label": "Reset Dashboard Login",
"description": "Reset the Traefik dashboard credentials (CFG_TRAEFIK_USER / CFG_TRAEFIK_PASS) and regenerate the htpasswd entry in protectionauth.yml. Leave fields blank to keep the current username and/or generate a new random password.",
"icon": "🔑",
"fields": [
{ "name": "username", "label": "Username (leave blank to keep current)", "type": "text", "placeholder": "admin" },
{ "name": "password", "label": "New password (leave blank to generate)", "type": "password", "placeholder": "Leave blank for random" }
]
}
]
},
"nextcloud": {
"tools": [
{
"id": "reset_password",
"category": "users",
"label": "Reset User Password",
"description": "Reset an existing Nextcloud user's password. Leave the password field blank to generate a random one — it is shown in the task log.",
"icon": "🔑",
"fields": [
{ "name": "username", "label": "Username", "type": "text", "placeholder": "alice", "required": true },
{ "name": "password", "label": "New password (leave blank to generate)", "type": "password", "placeholder": "Leave blank for random" }
]
},
{
"id": "create_account",
"category": "users",
"label": "Create User Account",
"description": "Create a new Nextcloud user. Tick \"Make admin\" to add them to the admin group. Leave the password blank to generate a random one.",
"icon": "👤",
"fields": [
{ "name": "username", "label": "Username", "type": "text", "placeholder": "alice", "required": true },
{ "name": "display_name", "label": "Display name", "type": "text", "placeholder": "Alice Smith" },
{ "name": "password", "label": "Password (leave blank to generate)", "type": "password", "placeholder": "Leave blank for random" },
{ "name": "admin", "label": "Make admin", "type": "checkbox", "default": false }
]
},
{ "id": "list_users", "category": "users", "label": "List Users", "description": "Show every Nextcloud user with their display name and admin flag.", "icon": "📋", "fields": [] },
{
"id": "delete_user", "category": "users", "label": "Delete User Account",
"description": "Permanently delete a user and all their files.", "icon": "🗑",
"destructive": true, "confirm": "This cannot be undone. The user's files will be removed.",
"fields": [
{ "name": "username", "label": "Username", "type": "text", "required": true }
]
},
{
"id": "set_admin", "category": "users", "label": "Set Admin Status",
"description": "Add a user to (or remove from) the admin group.", "icon": "👑",
"fields": [
{ "name": "username", "label": "Username", "type": "text", "required": true },
{ "name": "admin", "label": "Make admin", "type": "checkbox", "default": false }
]
},
{
"id": "toggle_maintenance", "category": "maintenance", "label": "Toggle Maintenance Mode",
"description": "Lock all users out and show a maintenance notice — required before running upgrades or repairs from the CLI.", "icon": "🚧",
"fields": [
{ "name": "enable", "label": "Enable maintenance mode", "type": "checkbox", "default": true }
]
},
{
"id": "rescan_files", "category": "maintenance", "label": "Rescan Files",
"description": "Re-index Nextcloud's file metadata. Run this after files were added or removed on disk outside Nextcloud (rsync, restore, manual copy). Leave the username blank to scan every user.", "icon": "🔄",
"fields": [
{ "name": "username", "label": "Username (blank = all users)", "type": "text", "placeholder": "blank for all" }
]
},
{
"id": "add_trusted_domain", "category": "system", "label": "Add Trusted Domain",
"description": "Append a hostname to Nextcloud's trusted_domains list so requests to that host are accepted.", "icon": "🌐",
"fields": [
{ "name": "domain", "label": "Domain", "type": "text", "placeholder": "cloud.example.org", "required": true }
]
},
{
"id": "system_status", "category": "system", "label": "System Status",
"description": "Show Nextcloud's version, install state, and maintenance flag.", "icon": "",
"fields": []
},
{
"id": "tail_logs", "category": "system", "label": "Tail Logs",
"description": "Show the most recent lines of nextcloud.log.", "icon": "📜",
"fields": [
{ "name": "lines", "label": "Lines", "type": "number", "default": 100, "min": 10, "max": 1000 }
]
}
]
}
}
}
JSON
# Merge per-app tool declarations so a DROPPED-IN app (e.g. from LibrePortal-Infra)
# registers its own Tools tab actions without editing this file. Each app may ship
# containers/<app>/<app>.tools.json = { "tools": [ … ] } (same schema as above);
# it sets .apps[<app>]. Core apps declared in the heredoc need no such file.
if command -v jq >/dev/null 2>&1; then
local _tj _app
for _tj in "${install_containers_dir}"*/tools/*.tools.json; do
[[ -f "$_tj" ]] || continue
_app="$(basename "$(dirname "$(dirname "$_tj")")")" # …/<app>/tools/<x>.tools.json
if jq -e . "$_tj" >/dev/null 2>&1; then
jq --arg app "$_app" --slurpfile t "$_tj" '.apps[$app] = $t[0]' "$tmp" > "$tmp.m" && mv "$tmp.m" "$tmp"
else
isNotice "Skipping invalid tools file: $_tj"
fi
done
fi
if command -v jq >/dev/null 2>&1; then
if ! jq . "$tmp" >/dev/null 2>&1; then
isNotice "Generated apps-tools.json failed JSON validation; keeping existing file."
rm -f "$tmp"
return 0
fi
fi
runFileWrite "$output_file" < "$tmp"; rm -f "$tmp"
isSuccessful "Generated apps-tools.json ($(jq '[.apps[].tools | length] | add' "$output_file" 2>/dev/null || echo "?") tool(s) across $(jq '.apps | length' "$output_file" 2>/dev/null || echo "?") app(s))."
}