Files
LEDMatrix/web_interface/blueprints/pages_v3.py
Chuck a5c10d6f78 fix(web-ui): Fix file upload widget and plugin action buttons (#165)
* 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>
2025-12-30 19:04:21 -05:00

425 lines
19 KiB
Python

from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
import json
from pathlib import Path
# Will be initialized when blueprint is registered
config_manager = None
plugin_manager = None
plugin_store_manager = None
pages_v3 = Blueprint('pages_v3', __name__)
@pages_v3.route('/')
def index():
"""Main v3 interface page"""
try:
if pages_v3.config_manager:
# Load configuration data
main_config = pages_v3.config_manager.load_config()
schedule_config = main_config.get('schedule', {})
# Get raw config files for JSON editor
main_config_data = pages_v3.config_manager.get_raw_file_content('main')
secrets_config_data = pages_v3.config_manager.get_raw_file_content('secrets')
main_config_json = json.dumps(main_config_data, indent=4)
secrets_config_json = json.dumps(secrets_config_data, indent=4)
else:
raise Exception("Config manager not initialized")
except Exception as e:
flash(f"Error loading configuration: {e}", "error")
schedule_config = {}
main_config_json = "{}"
secrets_config_json = "{}"
main_config_data = {}
secrets_config_data = {}
main_config_path = ""
secrets_config_path = ""
return render_template('v3/index.html',
schedule_config=schedule_config,
main_config_json=main_config_json,
secrets_config_json=secrets_config_json,
main_config_path=pages_v3.config_manager.get_config_path() if pages_v3.config_manager else "",
secrets_config_path=pages_v3.config_manager.get_secrets_path() if pages_v3.config_manager else "",
main_config=main_config_data,
secrets_config=secrets_config_data)
@pages_v3.route('/partials/<partial_name>')
def load_partial(partial_name):
"""Load HTMX partials dynamically"""
try:
# Map partial names to specific data loading
if partial_name == 'overview':
return _load_overview_partial()
elif partial_name == 'general':
return _load_general_partial()
elif partial_name == 'display':
return _load_display_partial()
elif partial_name == 'durations':
return _load_durations_partial()
elif partial_name == 'schedule':
return _load_schedule_partial()
elif partial_name == 'weather':
return _load_weather_partial()
elif partial_name == 'stocks':
return _load_stocks_partial()
elif partial_name == 'plugins':
return _load_plugins_partial()
elif partial_name == 'fonts':
return _load_fonts_partial()
elif partial_name == 'logs':
return _load_logs_partial()
elif partial_name == 'raw-json':
return _load_raw_json_partial()
elif partial_name == 'wifi':
return _load_wifi_partial()
elif partial_name == 'cache':
return _load_cache_partial()
elif partial_name == 'operation-history':
return _load_operation_history_partial()
else:
return f"Partial '{partial_name}' not found", 404
except Exception as e:
return f"Error loading partial '{partial_name}': {str(e)}", 500
@pages_v3.route('/partials/plugin-config/<plugin_id>')
def load_plugin_config_partial(plugin_id):
"""Load plugin configuration partial via HTMX - server-side rendered form"""
try:
return _load_plugin_config_partial(plugin_id)
except Exception as e:
return f'<div class="text-red-500 p-4">Error loading plugin config: {str(e)}</div>', 500
def _load_overview_partial():
"""Load overview partial with system stats"""
try:
if pages_v3.config_manager:
main_config = pages_v3.config_manager.load_config()
# This would be populated with real system stats via SSE
return render_template('v3/partials/overview.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
def _load_general_partial():
"""Load general settings partial"""
try:
if pages_v3.config_manager:
main_config = pages_v3.config_manager.load_config()
return render_template('v3/partials/general.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
def _load_display_partial():
"""Load display settings partial"""
try:
if pages_v3.config_manager:
main_config = pages_v3.config_manager.load_config()
return render_template('v3/partials/display.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
def _load_durations_partial():
"""Load display durations partial"""
try:
if pages_v3.config_manager:
main_config = pages_v3.config_manager.load_config()
return render_template('v3/partials/durations.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
def _load_schedule_partial():
"""Load schedule settings partial"""
try:
if pages_v3.config_manager:
main_config = pages_v3.config_manager.load_config()
schedule_config = main_config.get('schedule', {})
return render_template('v3/partials/schedule.html',
schedule_config=schedule_config)
except Exception as e:
return f"Error: {str(e)}", 500
def _load_weather_partial():
"""Load weather configuration partial"""
try:
if pages_v3.config_manager:
main_config = pages_v3.config_manager.load_config()
return render_template('v3/partials/weather.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
def _load_stocks_partial():
"""Load stocks configuration partial"""
try:
if pages_v3.config_manager:
main_config = pages_v3.config_manager.load_config()
return render_template('v3/partials/stocks.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
def _load_plugins_partial():
"""Load plugins management partial"""
try:
import json
from pathlib import Path
# Load plugin data from the plugin system
plugins_data = []
# Get installed plugins if managers are available
if pages_v3.plugin_manager and pages_v3.plugin_store_manager:
try:
# Get all installed plugin info
all_plugin_info = pages_v3.plugin_manager.get_all_plugin_info()
# Format for the web interface
for plugin_info in all_plugin_info:
plugin_id = plugin_info.get('id')
# Re-read manifest from disk to ensure we have the latest metadata
manifest_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
if manifest_path.exists():
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
fresh_manifest = json.load(f)
# Update plugin_info with fresh manifest data
plugin_info.update(fresh_manifest)
except Exception as e:
# If we can't read the fresh manifest, use the cached one
print(f"Warning: Could not read fresh manifest for {plugin_id}: {e}")
# Get enabled status from config (source of truth)
# Read from config file first, fall back to plugin instance if config doesn't have the key
enabled = None
if pages_v3.config_manager:
full_config = pages_v3.config_manager.load_config()
plugin_config = full_config.get(plugin_id, {})
# Check if 'enabled' key exists in config (even if False)
if 'enabled' in plugin_config:
enabled = bool(plugin_config['enabled'])
# Fallback to plugin instance if config doesn't have enabled key
if enabled is None:
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
if plugin_instance:
enabled = plugin_instance.enabled
else:
# Default to True if no config key and plugin not loaded (matches BasePlugin default)
enabled = True
# Get verified status from store registry
store_info = pages_v3.plugin_store_manager.get_plugin_info(plugin_id)
verified = store_info.get('verified', False) if store_info else False
last_updated = plugin_info.get('last_updated')
last_commit = plugin_info.get('last_commit') or plugin_info.get('last_commit_sha')
branch = plugin_info.get('branch')
if store_info:
last_updated = last_updated or store_info.get('last_updated') or store_info.get('last_updated_iso')
last_commit = last_commit or store_info.get('last_commit') or store_info.get('last_commit_sha')
branch = branch or store_info.get('branch') or store_info.get('default_branch')
plugins_data.append({
'id': plugin_id,
'name': plugin_info.get('name', plugin_id),
'author': plugin_info.get('author', 'Unknown'),
'category': plugin_info.get('category', 'General'),
'description': plugin_info.get('description', 'No description available'),
'tags': plugin_info.get('tags', []),
'enabled': enabled,
'verified': verified,
'loaded': plugin_info.get('loaded', False),
'last_updated': last_updated,
'last_commit': last_commit,
'branch': branch
})
except Exception as e:
print(f"Error loading plugin data: {e}")
return render_template('v3/partials/plugins.html',
plugins=plugins_data)
except Exception as e:
return f"Error: {str(e)}", 500
def _load_fonts_partial():
"""Load fonts management partial"""
try:
# This would load font data from the font system
fonts_data = {} # Placeholder for font data
return render_template('v3/partials/fonts.html',
fonts=fonts_data)
except Exception as e:
return f"Error: {str(e)}", 500
def _load_logs_partial():
"""Load logs viewer partial"""
try:
return render_template('v3/partials/logs.html')
except Exception as e:
return f"Error: {str(e)}", 500
def _load_raw_json_partial():
"""Load raw JSON editor partial"""
try:
if pages_v3.config_manager:
main_config_data = pages_v3.config_manager.get_raw_file_content('main')
secrets_config_data = pages_v3.config_manager.get_raw_file_content('secrets')
main_config_json = json.dumps(main_config_data, indent=4)
secrets_config_json = json.dumps(secrets_config_data, indent=4)
return render_template('v3/partials/raw_json.html',
main_config_json=main_config_json,
secrets_config_json=secrets_config_json,
main_config_path=pages_v3.config_manager.get_config_path(),
secrets_config_path=pages_v3.config_manager.get_secrets_path())
except Exception as e:
return f"Error: {str(e)}", 500
def _load_wifi_partial():
"""Load WiFi setup partial"""
try:
return render_template('v3/partials/wifi.html')
except Exception as e:
return f"Error: {str(e)}", 500
def _load_cache_partial():
"""Load cache management partial"""
try:
return render_template('v3/partials/cache.html')
except Exception as e:
return f"Error: {str(e)}", 500
def _load_operation_history_partial():
"""Load operation history partial"""
try:
return render_template('v3/partials/operation_history.html')
except Exception as e:
return f"Error: {str(e)}", 500
def _load_plugin_config_partial(plugin_id):
"""
Load plugin configuration partial - server-side rendered form.
This replaces the client-side generateConfigForm() JavaScript.
"""
try:
if not pages_v3.plugin_manager:
return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500
# Try to get plugin info first
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
# If not found, re-discover plugins (handles plugins added after startup)
if not plugin_info:
pages_v3.plugin_manager.discover_plugins()
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
if not plugin_info:
return f'<div class="text-red-500 p-4">Plugin "{plugin_id}" not found</div>', 404
# Get plugin instance (may be None if not loaded)
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
# Get plugin configuration from config file
config = {}
if pages_v3.config_manager:
full_config = pages_v3.config_manager.load_config()
config = full_config.get(plugin_id, {})
# Load uploaded images from metadata file if images field exists in schema
# This ensures uploaded images appear even if config hasn't been saved yet
schema_path_temp = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
if schema_path_temp.exists():
try:
with open(schema_path_temp, 'r', encoding='utf-8') as f:
temp_schema = json.load(f)
# Check if schema has an images field with x-widget: file-upload
if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
# Load metadata file
# Get PROJECT_ROOT relative to this file
project_root = Path(__file__).parent.parent.parent
metadata_file = project_root / 'assets' / 'plugins' / plugin_id / 'uploads' / '.metadata.json'
if metadata_file.exists():
try:
with open(metadata_file, 'r', encoding='utf-8') as mf:
metadata = json.load(mf)
# Convert metadata dict to list of image objects
images_from_metadata = list(metadata.values())
# Only use metadata images if config doesn't have images or config images is empty
if not config.get('images') or len(config.get('images', [])) == 0:
config['images'] = images_from_metadata
else:
# Merge: add metadata images that aren't already in config
config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')}
new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids]
if new_images:
config['images'] = config.get('images', []) + new_images
except Exception as e:
print(f"Warning: Could not load metadata for {plugin_id}: {e}")
except Exception:
pass # Will load schema properly below
# Get plugin schema
schema = {}
schema_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
if schema_path.exists():
try:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
except Exception as e:
print(f"Warning: Could not load schema for {plugin_id}: {e}")
# Get web UI actions from plugin manifest
web_ui_actions = []
manifest_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
if manifest_path.exists():
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
web_ui_actions = manifest.get('web_ui_actions', [])
except Exception as e:
print(f"Warning: Could not load manifest for {plugin_id}: {e}")
# Determine enabled status
enabled = config.get('enabled', True)
if plugin_instance:
enabled = plugin_instance.enabled
# Build plugin data for template
plugin_data = {
'id': plugin_id,
'name': plugin_info.get('name', plugin_id),
'author': plugin_info.get('author', 'Unknown'),
'version': plugin_info.get('version', ''),
'description': plugin_info.get('description', ''),
'category': plugin_info.get('category', 'General'),
'tags': plugin_info.get('tags', []),
'enabled': enabled,
'last_commit': plugin_info.get('last_commit') or plugin_info.get('last_commit_sha', ''),
'branch': plugin_info.get('branch', ''),
}
return render_template(
'v3/partials/plugin_config.html',
plugin=plugin_data,
config=config,
schema=schema,
web_ui_actions=web_ui_actions
)
except Exception as e:
import traceback
traceback.print_exc()
return f'<div class="text-red-500 p-4">Error loading plugin config: {str(e)}</div>', 500