mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
* fix(plugins): Resolve plugin ID determination error in action buttons
- Fix server-side template parameter order for executePluginAction
- Add data-plugin-id attributes to action buttons in all templates
- Enhance executePluginAction with comprehensive fallback logic
- Support retrieving pluginId from DOM, Alpine context, and config state
- Fixes 'Unable to determine plugin ID' error for Spotify/YouTube auth
* fix(plugins): Add missing button IDs and status divs in server-side action template
- Add action-{id}-{index} IDs to action buttons
- Add action-status-{id}-{index} status divs for each action
- Match client-side template structure for consistency
- Fixes 'Action elements not found' error
* fix(api): Fix indentation error in execute_plugin_action function
- Fix incorrect else block indentation that caused 500 errors
- Correct indentation for OAuth flow and simple script execution paths
- Resolves syntax error preventing plugin actions from executing
* fix(api): Improve error handling for plugin actions and config saves
- Add better JSON parsing error handling with request details
- Add detailed permission error messages for secrets file saves
- Include file path and permission status in error responses
- Helps diagnose 400 errors on action execution and 500 errors on config saves
* fix(api): Add detailed permission error handling for secrets config saves
- Add PermissionError-specific handling with permission checks
- Include directory and file permission status in error logs
- Provide more helpful error messages with file paths
- Helps diagnose permission issues when saving config_secrets.json
* fix(config): Add permission check and actionable error message for config saves
- Check file writability before attempting write
- Show file owner and current permissions in error message
- Provide exact command to fix permissions (chown + chmod)
- Helps diagnose and resolve permission issues with config_secrets.json
* fix(config): Preserve detailed permission error messages
- Handle PermissionError separately to preserve detailed error messages
- Ensure actionable permission fix commands are included in error response
- Prevents detailed error messages from being lost in exception chain
* fix(config): Remove overly strict pre-write permission check
- Remove pre-write file existence/writability check that was blocking valid writes
- Let actual file write operation determine success/failure
- Provide detailed error messages only when write actually fails
- Fixes regression where config_secrets.json saves were blocked unnecessarily
* fix(config): Use atomic writes for config_secrets.json to handle permission issues
- Write to temp file first, then atomically move to final location
- Works even when existing file isn't writable (as long as directory is writable)
- Matches pattern used elsewhere in codebase (disk_cache, atomic_manager)
- Fixes permission errors when saving secrets configuration
* chore: Update music plugin submodule to include live_priority fix
* fix(plugins): Improve plugin ID determination in dynamic button generation
- Update generateFormFromSchema to pass currentPluginConfig?.pluginId and add data attributes
- Update generateSimpleConfigForm to pass currentPluginConfig?.pluginId and add data attributes
- Scope fallback 6 DOM lookup to button context instead of document-wide search
- Ensures correct plugin tab selection when multiple plugins are present
- Maintains existing try/catch error handling and logging
---------
Co-authored-by: Chuck <chuck@example.com>
379 lines
21 KiB
HTML
379 lines
21 KiB
HTML
{# Plugin Configuration Partial - Server-side rendered form #}
|
|
{# This template is loaded via HTMX when a plugin tab is clicked #}
|
|
|
|
{# ===== MACROS FOR FORM FIELD GENERATION ===== #}
|
|
|
|
{# Render a single form field based on schema type #}
|
|
{% macro render_field(key, prop, value, prefix='', plugin_id='') %}
|
|
{% set full_key = (prefix ~ '.' ~ key) if prefix else key %}
|
|
{% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
|
|
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
|
|
{% set description = prop.description if prop.description else '' %}
|
|
{% set field_type = prop.type if prop.type is string else (prop.type[0] if prop.type is iterable else 'string') %}
|
|
|
|
{# Handle nested objects recursively #}
|
|
{% if field_type == 'object' and prop.properties %}
|
|
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
|
|
{% else %}
|
|
<div class="form-group mb-4">
|
|
<label for="{{ field_id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
{{ label }}
|
|
</label>
|
|
|
|
{% if description %}
|
|
<p class="text-sm text-gray-500 mb-2">{{ description }}</p>
|
|
{% endif %}
|
|
|
|
{# Boolean checkbox #}
|
|
{% if field_type == 'boolean' %}
|
|
<label class="flex items-center cursor-pointer">
|
|
<input type="checkbox"
|
|
id="{{ field_id }}"
|
|
name="{{ full_key }}"
|
|
{% if value %}checked{% endif %}
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<span class="ml-2 text-sm text-gray-600">Enabled</span>
|
|
</label>
|
|
|
|
{# Enum dropdown #}
|
|
{% elif prop.enum %}
|
|
<select id="{{ field_id }}"
|
|
name="{{ full_key }}"
|
|
class="form-select w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black">
|
|
{% for option in prop.enum %}
|
|
<option value="{{ option }}" {% if value == option %}selected{% endif %}>
|
|
{{ option|replace('_', ' ')|title }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
|
|
{# Number input #}
|
|
{% elif field_type in ['number', 'integer'] %}
|
|
<input type="number"
|
|
id="{{ field_id }}"
|
|
name="{{ full_key }}"
|
|
value="{{ value if value is not none else (prop.default if prop.default is defined else '') }}"
|
|
{% if prop.minimum is defined %}min="{{ prop.minimum }}"{% endif %}
|
|
{% if prop.maximum is defined %}max="{{ prop.maximum }}"{% endif %}
|
|
{% if field_type == 'integer' %}step="1"{% else %}step="any"{% endif %}
|
|
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black placeholder:text-gray-500">
|
|
|
|
{# Array of strings (comma-separated) #}
|
|
{% elif field_type == 'array' %}
|
|
{% set array_value = value if value is not none else (prop.default if prop.default is defined else []) %}
|
|
<input type="text"
|
|
id="{{ field_id }}"
|
|
name="{{ full_key }}"
|
|
value="{{ array_value|join(', ') if array_value is iterable and array_value is not string else '' }}"
|
|
placeholder="Enter values separated by commas"
|
|
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black placeholder:text-gray-500">
|
|
<p class="text-xs text-gray-400 mt-1">Separate multiple values with commas</p>
|
|
|
|
{# Text input (default) #}
|
|
{% else %}
|
|
<input type="text"
|
|
id="{{ field_id }}"
|
|
name="{{ full_key }}"
|
|
value="{{ value if value is not none else (prop.default if prop.default is defined else '') }}"
|
|
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black placeholder:text-gray-500">
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
{% endmacro %}
|
|
|
|
{# Render a nested/collapsible section for object types #}
|
|
{% macro render_nested_section(key, prop, value, prefix='', plugin_id='') %}
|
|
{% set full_key = (prefix ~ '.' ~ key) if prefix else key %}
|
|
{% set section_id = (plugin_id ~ '-section-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
|
|
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
|
|
{% set description = prop.description if prop.description else '' %}
|
|
{% set nested_value = value if value else {} %}
|
|
|
|
<div class="nested-section border border-gray-300 rounded-lg mb-4">
|
|
<button type="button"
|
|
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg"
|
|
onclick="toggleSection('{{ section_id }}')">
|
|
<div class="flex-1">
|
|
<h4 class="font-semibold text-gray-900">{{ label }}</h4>
|
|
{% if description %}
|
|
<p class="text-sm text-gray-600 mt-1">{{ description }}</p>
|
|
{% endif %}
|
|
</div>
|
|
<i id="{{ section_id }}-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
|
</button>
|
|
<div id="{{ section_id }}" class="nested-content bg-gray-50 px-4 py-4 space-y-3 hidden" style="display: none;">
|
|
{% set property_order = prop['x-propertyOrder'] if 'x-propertyOrder' in prop else prop.properties.keys()|list %}
|
|
{% for nested_key in property_order %}
|
|
{% if nested_key in prop.properties %}
|
|
{% set nested_prop = prop.properties[nested_key] %}
|
|
{% set nested_val = nested_value[nested_key] if nested_key in nested_value else none %}
|
|
{{ render_field(nested_key, nested_prop, nested_val, full_key, plugin_id) }}
|
|
{% endif %}
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endmacro %}
|
|
|
|
{# ===== MAIN TEMPLATE ===== #}
|
|
|
|
<div class="plugin-config-container"
|
|
data-plugin-id="{{ plugin.id }}"
|
|
x-data="{ saving: false, saveError: null, saveSuccess: false }">
|
|
|
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-900">{{ plugin.name or plugin.id }}</h2>
|
|
<p class="mt-1 text-sm text-gray-600">{{ plugin.description or 'Plugin configuration' }}</p>
|
|
</div>
|
|
<div class="flex items-center space-x-4">
|
|
<label class="flex items-center cursor-pointer">
|
|
<input type="checkbox"
|
|
id="plugin-enabled-{{ plugin.id }}"
|
|
name="enabled"
|
|
value="true"
|
|
{% if plugin.enabled %}checked{% endif %}
|
|
hx-post="/api/v3/plugins/toggle?plugin_id={{ plugin.id }}"
|
|
hx-trigger="change"
|
|
hx-swap="none"
|
|
hx-vals='js:{enabled: document.getElementById("plugin-enabled-{{ plugin.id }}").checked ? "true" : "false"}'
|
|
hx-on::after-request="handleToggleResponse(event, '{{ plugin.id }}')"
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<span class="ml-2 text-sm {% if plugin.enabled %}text-green-600{% else %}text-gray-500{% endif %}">
|
|
{% if plugin.enabled %}Enabled{% else %}Disabled{% endif %}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<form id="plugin-config-form-{{ plugin.id }}"
|
|
hx-post="/api/v3/plugins/config?plugin_id={{ plugin.id }}"
|
|
hx-trigger="submit"
|
|
hx-swap="none"
|
|
hx-indicator="#save-indicator-{{ plugin.id }}"
|
|
hx-on::before-request="this.querySelector('[type=submit]').disabled = true"
|
|
hx-on::after-request="handleConfigSave(event, '{{ plugin.id }}')"
|
|
onsubmit="return validatePluginConfigForm(this, '{{ plugin.id }}');">
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{# Plugin Information Panel #}
|
|
<div class="bg-gray-50 rounded-lg p-4">
|
|
<h3 class="text-md font-medium text-gray-900 mb-3">Plugin Information</h3>
|
|
<dl class="space-y-2">
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">Name</dt>
|
|
<dd class="text-sm text-gray-900">{{ plugin.name or plugin.id }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">Author</dt>
|
|
<dd class="text-sm text-gray-900">{{ plugin.author or 'Unknown' }}</dd>
|
|
</div>
|
|
{% if plugin.version %}
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">Version</dt>
|
|
<dd class="text-sm text-gray-900">{{ plugin.version }}</dd>
|
|
</div>
|
|
{% endif %}
|
|
{% if plugin.last_commit %}
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">Commit</dt>
|
|
<dd class="text-sm text-gray-900 font-mono">
|
|
{{ plugin.last_commit[:7] if plugin.last_commit|length > 7 else plugin.last_commit }}
|
|
{% if plugin.branch %}
|
|
<span class="text-gray-500">({{ plugin.branch }})</span>
|
|
{% endif %}
|
|
</dd>
|
|
</div>
|
|
{% endif %}
|
|
{% if plugin.category %}
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">Category</dt>
|
|
<dd class="text-sm text-gray-900">{{ plugin.category }}</dd>
|
|
</div>
|
|
{% endif %}
|
|
{% if plugin.tags %}
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">Tags</dt>
|
|
<dd class="flex flex-wrap gap-1 mt-1">
|
|
{% for tag in plugin.tags %}
|
|
<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">{{ tag }}</span>
|
|
{% endfor %}
|
|
</dd>
|
|
</div>
|
|
{% endif %}
|
|
</dl>
|
|
|
|
{# On-Demand Controls #}
|
|
<div class="mt-4 pt-4 border-t border-gray-200 space-y-3">
|
|
<div class="flex items-center gap-2">
|
|
<i class="fas fa-bolt text-blue-500"></i>
|
|
<span class="text-sm font-semibold text-gray-900">On-Demand Controls</span>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button type="button"
|
|
onclick="runPluginOnDemand('{{ plugin.id }}')"
|
|
{% if not plugin.enabled %}disabled{% endif %}
|
|
class="px-3 py-2 text-sm bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-md flex items-center gap-2 transition-colors">
|
|
<i class="fas fa-play-circle"></i>
|
|
<span>Run On-Demand</span>
|
|
</button>
|
|
<button type="button"
|
|
onclick="stopOnDemand()"
|
|
class="px-3 py-2 text-sm bg-red-600 hover:bg-red-700 text-white rounded-md flex items-center gap-2 transition-colors">
|
|
<i class="fas fa-stop"></i>
|
|
<span>Stop On-Demand</span>
|
|
</button>
|
|
</div>
|
|
{% if not plugin.enabled %}
|
|
<p class="text-xs text-amber-600">Enable this plugin before launching on-demand.</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{# Configuration Form Panel #}
|
|
<div class="bg-gray-50 rounded-lg p-4">
|
|
<h3 class="text-md font-medium text-gray-900 mb-3">Configuration</h3>
|
|
<div class="space-y-4 max-h-96 overflow-y-auto pr-2">
|
|
{% if schema and schema.properties %}
|
|
{# Use property order if defined, otherwise use natural order #}
|
|
{# Skip 'enabled' field - it's handled by the header toggle #}
|
|
{% set property_order = schema['x-propertyOrder'] if 'x-propertyOrder' in schema else schema.properties.keys()|list %}
|
|
{% for key in property_order %}
|
|
{% if key in schema.properties and key != 'enabled' %}
|
|
{% set prop = schema.properties[key] %}
|
|
{% set value = config[key] if key in config else none %}
|
|
{{ render_field(key, prop, value, '', plugin.id) }}
|
|
{% endif %}
|
|
{% endfor %}
|
|
{% else %}
|
|
{# No schema - render simple form from config #}
|
|
{% if config %}
|
|
{% for key, value in config.items() %}
|
|
{% if key not in ['enabled'] %}
|
|
<div class="form-group mb-4">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
|
{{ key|replace('_', ' ')|title }}
|
|
</label>
|
|
{% if value is sameas true or value is sameas false %}
|
|
<label class="flex items-center cursor-pointer">
|
|
<input type="checkbox" name="{{ key }}" {% if value %}checked{% endif %}
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<span class="ml-2 text-sm text-gray-600">Enabled</span>
|
|
</label>
|
|
{% elif value is number %}
|
|
<input type="number" name="{{ key }}" value="{{ value }}"
|
|
class="form-input w-full rounded-md border-gray-300 bg-gray-900 text-white placeholder:text-gray-400">
|
|
{% else %}
|
|
<input type="text" name="{{ key }}" value="{{ value }}"
|
|
class="form-input w-full rounded-md border-gray-300 bg-gray-900 text-white placeholder:text-gray-400">
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
{% endfor %}
|
|
{% else %}
|
|
<p class="text-gray-500 text-sm">No configuration options available for this plugin.</p>
|
|
{% endif %}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# Web UI Actions (if any) #}
|
|
{% if web_ui_actions %}
|
|
<div class="mt-6 pt-4 border-t border-gray-200">
|
|
<h3 class="text-md font-medium text-gray-900 mb-3">Plugin Actions</h3>
|
|
{% if web_ui_actions[0].section_description %}
|
|
<p class="text-sm text-gray-600 mb-4">{{ web_ui_actions[0].section_description }}</p>
|
|
{% endif %}
|
|
<div class="space-y-3">
|
|
{% for action in web_ui_actions %}
|
|
{% set action_id = "action-" ~ action.id ~ "-" ~ loop.index0 %}
|
|
{% set status_id = "action-status-" ~ action.id ~ "-" ~ loop.index0 %}
|
|
{% set bg_color = action.color or 'blue' %}
|
|
{% if bg_color == 'green' %}
|
|
{% set bg_class = 'bg-green-50' %}
|
|
{% set border_class = 'border-green-200' %}
|
|
{% set text_class = 'text-green-900' %}
|
|
{% set text_light_class = 'text-green-700' %}
|
|
{% set btn_class = 'bg-green-600 hover:bg-green-700' %}
|
|
{% elif bg_color == 'red' %}
|
|
{% set bg_class = 'bg-red-50' %}
|
|
{% set border_class = 'border-red-200' %}
|
|
{% set text_class = 'text-red-900' %}
|
|
{% set text_light_class = 'text-red-700' %}
|
|
{% set btn_class = 'bg-red-600 hover:bg-red-700' %}
|
|
{% elif bg_color == 'yellow' %}
|
|
{% set bg_class = 'bg-yellow-50' %}
|
|
{% set border_class = 'border-yellow-200' %}
|
|
{% set text_class = 'text-yellow-900' %}
|
|
{% set text_light_class = 'text-yellow-700' %}
|
|
{% set btn_class = 'bg-yellow-600 hover:bg-yellow-700' %}
|
|
{% elif bg_color == 'purple' %}
|
|
{% set bg_class = 'bg-purple-50' %}
|
|
{% set border_class = 'border-purple-200' %}
|
|
{% set text_class = 'text-purple-900' %}
|
|
{% set text_light_class = 'text-purple-700' %}
|
|
{% set btn_class = 'bg-purple-600 hover:bg-purple-700' %}
|
|
{% else %}
|
|
{% set bg_class = 'bg-blue-50' %}
|
|
{% set border_class = 'border-blue-200' %}
|
|
{% set text_class = 'text-blue-900' %}
|
|
{% set text_light_class = 'text-blue-700' %}
|
|
{% set btn_class = 'bg-blue-600 hover:bg-blue-700' %}
|
|
{% endif %}
|
|
<div class="{{ bg_class }} border {{ border_class }} rounded-lg p-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex-1">
|
|
<h4 class="font-medium {{ text_class }} mb-1">
|
|
{% if action.icon %}<i class="{{ action.icon }} mr-2"></i>{% endif %}{{ action.title or action.id }}
|
|
</h4>
|
|
<p class="text-sm {{ text_light_class }}">{{ action.description or '' }}</p>
|
|
</div>
|
|
<button type="button"
|
|
id="{{ action_id }}"
|
|
onclick="executePluginAction('{{ action.id }}', {{ loop.index0 }}, '{{ plugin.id }}')"
|
|
data-plugin-id="{{ plugin.id }}"
|
|
data-action-id="{{ action.id }}"
|
|
class="btn {{ btn_class }} text-white px-4 py-2 rounded-md whitespace-nowrap">
|
|
{% if action.icon %}<i class="{{ action.icon }} mr-2"></i>{% endif %}{{ action.button_text or action.title or action.id }}
|
|
</button>
|
|
</div>
|
|
<div id="{{ status_id }}" class="mt-3 hidden"></div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# Action Buttons #}
|
|
<div class="flex justify-end space-x-3 mt-6 pt-6 border-t border-gray-200">
|
|
<button type="button"
|
|
onclick="refreshPluginConfig('{{ plugin.id }}')"
|
|
class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md">
|
|
<i class="fas fa-sync-alt mr-2"></i>Refresh
|
|
</button>
|
|
<button type="button"
|
|
hx-post="/api/v3/plugins/update?plugin_id={{ plugin.id }}"
|
|
hx-swap="none"
|
|
hx-on::after-request="handlePluginUpdate(event, '{{ plugin.id }}')"
|
|
class="btn bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-md">
|
|
<i class="fas fa-download mr-2"></i>Update
|
|
</button>
|
|
<button type="button"
|
|
onclick="if(confirm('Are you sure you want to uninstall {{ plugin.name or plugin.id }}?')) uninstallPlugin('{{ plugin.id }}')"
|
|
class="btn bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md">
|
|
<i class="fas fa-trash mr-2"></i>Uninstall
|
|
</button>
|
|
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md flex items-center">
|
|
<span id="save-indicator-{{ plugin.id }}" class="htmx-indicator mr-2">
|
|
<i class="fas fa-spinner fa-spin"></i>
|
|
</span>
|
|
<i class="fas fa-save mr-2 save-icon"></i>
|
|
<span>Save Configuration</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|