from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify import json import logging from pathlib import Path from src.web_interface.secret_helpers import mask_secret_fields logger = logging.getLogger(__name__) # 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/') 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/') 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'
Error loading plugin config: {str(e)}
', 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', {}) dim_schedule_config = main_config.get('dim_schedule', {}) # Get normal brightness for display in dim schedule UI normal_brightness = main_config.get('display', {}).get('hardware', {}).get('brightness', 90) return render_template('v3/partials/schedule.html', schedule_config=schedule_config, dim_schedule_config=dim_schedule_config, normal_brightness=normal_brightness) 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() # Load config once before the loop (not per-plugin) full_config = pages_v3.config_manager.load_config() if pages_v3.config_manager else {} # 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: 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 (no GitHub API calls needed) store_info = pages_v3.plugin_store_manager.get_registry_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 @pages_v3.route('/setup') def captive_setup(): """Lightweight captive portal setup page — self-contained, no frameworks.""" return render_template('v3/captive_setup.html') 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 '
Plugin manager not available
', 500 # Handle starlark app config (starlark:) if plugin_id.startswith('starlark:'): return _load_starlark_config_partial(plugin_id[len('starlark:'):]) # 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'
Plugin "{plugin_id}" not found
', 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}") # Mask secret fields before rendering template (fail closed — never leak secrets) schema_properties = schema.get('properties') if isinstance(schema, dict) else None if not isinstance(schema_properties, dict): return '
Error loading plugin config securely: schema unavailable.
', 500 config = mask_secret_fields(config, schema_properties) # 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'
Error loading plugin config: {str(e)}
', 500 def _load_starlark_config_partial(app_id): """Load configuration partial for a Starlark app.""" try: starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') if pages_v3.plugin_manager else None if starlark_plugin and hasattr(starlark_plugin, 'apps'): app = starlark_plugin.apps.get(app_id) if not app: return f'
Starlark app not found: {app_id}
', 404 return render_template( 'v3/partials/starlark_config.html', app_id=app_id, app_name=app.manifest.get('name', app_id), app_enabled=app.is_enabled(), render_interval=app.get_render_interval(), display_duration=app.get_display_duration(), config=app.config, schema=app.schema, has_frames=app.frames is not None, frame_count=len(app.frames) if app.frames else 0, last_render_time=app.last_render_time, ) # Standalone: read from manifest file manifest_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / 'manifest.json' if not manifest_file.exists(): return f'
Starlark app not found: {app_id}
', 404 with open(manifest_file, 'r') as f: manifest = json.load(f) app_data = manifest.get('apps', {}).get(app_id) if not app_data: return f'
Starlark app not found: {app_id}
', 404 # Load schema from schema.json if it exists schema = None schema_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'schema.json' if schema_file.exists(): try: with open(schema_file, 'r') as f: schema = json.load(f) except (OSError, json.JSONDecodeError) as e: logger.warning(f"[Pages V3] Could not load schema for {app_id}: {e}", exc_info=True) # Load config from config.json if it exists config = {} config_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'config.json' if config_file.exists(): try: with open(config_file, 'r') as f: config = json.load(f) except (OSError, json.JSONDecodeError) as e: logger.warning(f"[Pages V3] Could not load config for {app_id}: {e}", exc_info=True) return render_template( 'v3/partials/starlark_config.html', app_id=app_id, app_name=app_data.get('name', app_id), app_enabled=app_data.get('enabled', True), render_interval=app_data.get('render_interval', 300), display_duration=app_data.get('display_duration', 15), config=config, schema=schema, has_frames=False, frame_count=0, last_render_time=None, ) except Exception as e: logger.exception(f"[Pages V3] Error loading starlark config for {app_id}") return f'
Error loading starlark config: {str(e)}
', 500