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
* chore: Update music plugin submodule to fix has_live_priority enabled attribute
* chore: Update music plugin submodule - remove redundant music_priority_mode
* fix(web-ui): Fix file upload widget detection for nested plugin properties
- Added helper function to get schema properties by full key path
- Enhanced x-widget detection to check both property object and schema directly
- Improved upload config retrieval with fallback to schema
- Added debug logging for file-upload widget detection
- Fixes issue where static-image plugin file upload widget was not rendering
The file upload widget was not being detected for nested properties like
image_config.images because the x-widget attribute wasn't being checked
in the schema directly. This fix ensures the widget is properly detected
and rendered even when nested deep in the configuration structure.
* fix(web-ui): Improve file upload widget detection with direct schema fallback
- Fixed getSchemaProperty helper function to correctly navigate nested paths
- Added direct schema lookup fallback for image_config.images path
- Enhanced debug logging to diagnose widget detection issues
- Simplified widget detection logic while maintaining robustness
* fix(web-ui): Add aggressive schema lookup for file-upload widget detection
- Always try direct schema navigation for image_config.images
- Added general direct lookup fallback if getSchemaProperty fails
- Enhanced debug logging with schema existence checks
- Prioritize schema lookup over prop object for x-widget detection
* fix(web-ui): Add direct check for top-level images field in file upload detection
- Added specific check for top-level 'images' field (flattened schema)
- Enhanced debug logging to show all x-widget detection attempts
- Improved widget detection to check prop object more thoroughly
* fix(web-ui): Prioritize prop object for x-widget detection
- Check prop object first (should have x-widget from schema)
- Then fall back to schema lookup
- Enhanced debug logging to show all detection attempts
* fix(web-ui): Add aggressive direct detection for images file upload widget
- Added direct check for 'images' field in schema.properties.images
- Multiple fallback detection methods (direct, prop object, schema lookup)
- Simplified logic to explicitly check for file-upload widget
- Enhanced debug logging to show detection path
* fix(web-ui): Add file upload widget support to server-side Jinja2 template
- Added check for x-widget: file-upload in array field rendering
- Renders file upload drop zone with drag-and-drop support
- Displays uploaded images list with delete and schedule buttons
- Falls back to comma-separated text input for regular arrays
- Fixes file upload widget not appearing in static-image plugin
* feat(web-ui): Add route to serve plugin asset files from assets directory
- Added /assets/plugins/<plugin_id>/uploads/<filename> route
- Serves uploaded images and other assets with proper content types
- Includes security checks to prevent directory traversal
- Fixes 404 errors when displaying uploaded plugin images
* fix(web-ui): Fix import for send_from_directory in plugin assets route
* feat(web-ui): Load uploaded images from metadata file when rendering config form
- Populates images field from .metadata.json if not in config
- Ensures uploaded images appear in form even before config is saved
- Merges metadata images with existing config images to avoid duplicates
* fix(web-ui): Fix PROJECT_ROOT reference in image metadata loading
* docs(web-ui): Add reminder to save configuration after file upload
- Added informational note below upload widget
- Reminds users to save config after uploading files
- Uses amber color and info icon for visibility
* fix(web-ui): Move plugin asset serving route to main app
- Moved /assets/plugins/... route from api_v3 blueprint to main app
- Blueprint has /api/v3 prefix, but route needs to be at /assets/...
- Fixes 404 errors when trying to display uploaded images
- Route must be on main app for correct URL path
* security(web-ui): Fix path containment check in plugin asset serving
- Replace string startswith() with proper path resolution using os.path.commonpath()
- Prevents prefix-based directory traversal bypasses
- Uses resolved absolute paths to ensure true path containment
- Handles ValueError for cross-drive paths (Windows compatibility)
* security(web-ui): Remove traceback exposure from plugin asset serving errors
- Return generic error message instead of full traceback in production
- Log exceptions server-side using app.logger.exception()
- Only include detailed error information when app.debug is True
- Prevents leaking internal implementation details to clients
* fix(web-ui): Assign currentPluginConfig to window for template access
- Assign currentPluginConfig to window.currentPluginConfig when building the object
- Fixes empty pluginId in template interpolation for plugin action buttons
- Ensures window.currentPluginConfig?.pluginId is available in onclick handlers
- Prevents executePluginAction from receiving empty pluginId parameter
* chore: Update music plugin submodule to include .gitignore
---------
Co-authored-by: Chuck <chuck@example.com>
474 lines
28 KiB
HTML
474 lines
28 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 - check if it's a file upload widget #}
|
|
{% elif field_type == 'array' %}
|
|
{% set x_widget = prop.get('x-widget') or prop.get('x_widget') %}
|
|
{% if x_widget == 'file-upload' %}
|
|
{# File upload widget for arrays #}
|
|
{% set upload_config = prop.get('x-upload-config') or {} %}
|
|
{% set max_files = upload_config.get('max_files', 10) %}
|
|
{% set allowed_types = upload_config.get('allowed_types', ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']) %}
|
|
{% set max_size_mb = upload_config.get('max_size_mb', 5) %}
|
|
{% set plugin_id_from_config = upload_config.get('plugin_id', plugin_id) %}
|
|
{% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %}
|
|
|
|
<div id="{{ field_id }}_upload_widget" class="mt-1">
|
|
<!-- File Upload Drop Zone -->
|
|
<div id="{{ field_id }}_drop_zone"
|
|
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
|
ondrop="window.handleFileDrop(event, this.dataset.fieldId)"
|
|
ondragover="event.preventDefault()"
|
|
data-field-id="{{ field_id }}"
|
|
onclick="document.getElementById('{{ field_id }}_file_input').click()">
|
|
<input type="file"
|
|
id="{{ field_id }}_file_input"
|
|
multiple
|
|
accept="{{ allowed_types|join(',') }}"
|
|
style="display: none;"
|
|
data-field-id="{{ field_id }}"
|
|
onchange="window.handleFileSelect(event, this.dataset.fieldId)">
|
|
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
|
|
<p class="text-sm text-gray-600">Drag and drop images here or click to browse</p>
|
|
<p class="text-xs text-gray-500 mt-1">Max {{ max_files }} files, {{ max_size_mb }}MB each (PNG, JPG, GIF, BMP)</p>
|
|
</div>
|
|
<p class="text-xs text-amber-600 mt-2 flex items-center">
|
|
<i class="fas fa-info-circle mr-1"></i>
|
|
Remember to save configuration after upload
|
|
</p>
|
|
|
|
<!-- Uploaded Images List -->
|
|
<div id="{{ field_id }}_image_list" class="mt-4 space-y-2">
|
|
{% for img in array_value %}
|
|
{% set img_id = img.get('id', loop.index0) %}
|
|
{% set img_schedule = img.get('schedule', {}) %}
|
|
{% set has_schedule = img_schedule.get('enabled', false) and img_schedule.get('mode') and img_schedule.get('mode') != 'always' %}
|
|
<div id="img_{{ img_id|string|replace('.', '_')|replace('-', '_') }}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center space-x-3 flex-1">
|
|
<img src="/{{ img.get('path', '') }}"
|
|
alt="{{ img.get('filename', '') }}"
|
|
class="w-16 h-16 object-cover rounded"
|
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
|
<div style="display:none;" class="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
|
|
<i class="fas fa-image text-gray-400"></i>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium text-gray-900 truncate">{{ img.get('original_filename') or img.get('filename', 'Image') }}</p>
|
|
<p class="text-xs text-gray-500">
|
|
{% if img.get('size') %}{{ (img.get('size') / 1024)|round }} KB{% endif %}
|
|
{% if img.get('uploaded_at') %} • {{ img.get('uploaded_at') }}{% endif %}
|
|
</p>
|
|
{% if has_schedule %}
|
|
<p class="text-xs text-blue-600 mt-1">
|
|
<i class="fas fa-clock mr-1"></i>Scheduled
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-2 ml-4">
|
|
<button type="button"
|
|
data-field-id="{{ field_id }}"
|
|
data-image-id="{{ img_id }}"
|
|
data-image-idx="{{ loop.index0 }}"
|
|
onclick="window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId || null, parseInt(this.dataset.imageIdx))"
|
|
class="text-blue-600 hover:text-blue-800 p-2"
|
|
title="Schedule this image">
|
|
<i class="fas fa-calendar-alt"></i>
|
|
</button>
|
|
<button type="button"
|
|
data-field-id="{{ field_id }}"
|
|
data-image-id="{{ img_id }}"
|
|
data-plugin-id="{{ plugin_id_from_config }}"
|
|
onclick="window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId)"
|
|
class="text-red-600 hover:text-red-800 p-2"
|
|
title="Delete image">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="schedule_{{ img_id|string|replace('.', '_')|replace('-', '_') }}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<!-- Hidden input to store image data -->
|
|
<input type="hidden" id="{{ field_id }}_images_data" name="{{ full_key }}" value='{{ array_value|tojson|safe }}'>
|
|
</div>
|
|
{% else %}
|
|
{# Regular array input (comma-separated) #}
|
|
{% 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>
|
|
{% endif %}
|
|
|
|
{# 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>
|
|
|