mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
Feature/on demand plugin filtering (#166)
* fix(web): Resolve font display and config API error handling issues
- Fix font catalog display error where path.startsWith fails
(path is object, not string)
- Update save_main_config to use error_response() helper
- Improve save_raw_main_config error handling consistency
- Add proper error codes and traceback details to API responses
* fix(web): Prevent fontCatalog redeclaration error on HTMX reload
- Use window object to store global font variables
- Check if script has already loaded before declaring variables
- Update both window properties and local references on assignment
- Fixes 'Identifier fontCatalog has already been declared' error
* fix(web): Wrap fonts script in IIFE to prevent all redeclaration errors
- Wrap entire script in IIFE that only runs once
- Check if script already loaded before declaring variables/functions
- Expose initializeFontsTab to window for re-initialization
- Prevents 'Identifier has already been declared' errors on HTMX reload
* fix(web): Exempt config save API endpoints from CSRF protection
- Exempt save_raw_main_config, save_raw_secrets_config, and save_main_config from CSRF
- These endpoints are called via fetch from JavaScript and don't include CSRF tokens
- Fixes 500 error when saving config via raw JSON editor
* fix(web): Exempt system action endpoint from CSRF protection
- Exempt execute_system_action from CSRF
- Fixes 500 error when using system action buttons (restart display, restart Pi, etc.)
- These endpoints are called via HTMX and don't include CSRF tokens
* fix(web): Exempt all API v3 endpoints from CSRF protection
- Add before_request handler to exempt all api_v3.* endpoints
- All API endpoints are programmatic (HTMX/fetch) and don't include CSRF tokens
- Prevents future CSRF errors on any API endpoint
- Cleaner than exempting individual endpoints
* refactor(web): Remove CSRF protection for local-only application
- CSRF is designed for internet-facing apps to prevent cross-site attacks
- For local-only Raspberry Pi app, threat model is different
- All endpoints were exempted anyway, so it wasn't protecting anything
- Forms use HTMX without CSRF tokens
- If exposing to internet later, can re-enable with proper token implementation
* fix(web): Fix font path double-prefixing in font catalog display
- Only prefix with 'assets/fonts/' if path is a bare filename
- If path starts with '/' (absolute) or 'assets/' (already prefixed), use as-is
- Fixes double-prefixing when get_fonts_catalog returns relative paths like 'assets/fonts/press_start.ttf'
* fix(web): Remove fontsTabInitialized guard to allow re-initialization on HTMX reload
- Remove fontsTabInitialized check that prevented re-initialization on HTMX content swap
- The window._fontsScriptLoaded guard is sufficient to prevent function redeclaration
- Allow initializeFontsTab() to run on each HTMX swap to attach listeners to new DOM elements
- Fixes fonts UI breaking after HTMX reload (buttons, upload dropzone, etc. not working)
* fix(api): Preserve empty strings for optional string fields in plugin config
- Add _is_field_required() helper to check if fields are required in schema
- Update _parse_form_value_with_schema() to preserve empty strings for optional string fields
- Fixes 400 error when saving MQTT plugin config with empty username/password
- Resolves validation error: 'Expected type string, got NoneType'
* fix(config): Add defaults to schemas and fix None value handling
- Updated merge_with_defaults to replace None values with defaults
- Fixed form processing to skip empty optional fields without defaults
- Added script to automatically add defaults to all plugin config schemas
- Added defaults to 89 fields across 10 plugin schemas
- Prevents validation errors from None values in configs
Changes:
- schema_manager.py: Enhanced merge_with_defaults to replace None with defaults
- api_v3.py: Added _SKIP_FIELD sentinel to skip optional fields without defaults
- add_defaults_to_schemas.py: Script to add sensible defaults to schemas
- Plugin schemas: Added defaults for number, boolean, and array fields
* fix(config): Fix save button spinner by checking HTTP status code
- Fixed handleConfigSave to check xhr.status instead of event.detail.successful
- With hx-swap="none", HTMX doesn't set event.detail.successful
- Now properly detects successful saves (status 200-299) and stops spinner
- Improved error message extraction from API responses
- Also fixed handleToggleResponse for consistency
* fix(web-ui): Resolve GitHub token warning persistence after save
- Made checkGitHubAuthStatus() return Promise for proper async handling
- Clear sessionStorage dismissal flag when token is saved
- Add delay before status check to ensure backend token reload
- Wait for status check completion before hiding settings panel
Fixes issue where GitHub token warnings and pop-ups would not
disappear after successfully saving a token in the web UI.
* fix(web-ui): Add token validation and improve GitHub token warning behavior
- Add token validation to backend API endpoint to check if token is valid/expired
- Implement _validate_github_token() method in PluginStoreManager with caching
- Update frontend to show warning only when token is missing or invalid
- Keep settings panel accessible (collapsible) when token is configured
- Collapse settings panel content after successful token save instead of hiding
- Display specific error messages for invalid/expired tokens
- Clear sessionStorage dismissal flag when token becomes valid
Fixes issue where GitHub token warnings and settings panel would not
properly hide/show based on token status. Now validates token validity
and provides better UX with collapsible settings panel.
* fix(web-ui): Fix CSS/display issue for GitHub token warning and settings
- Update all hide/show operations to use both classList and style.display
- Fix checkGitHubAuthStatus() to properly hide/show warning and settings
- Fix dismissGithubWarning() to use both methods
- Fix toggleGithubTokenSettings() with improved state checking
- Fix collapse button handler with improved state checking
- Fix saveGithubToken() to properly show/collapse settings panel
This ensures elements actually hide/show when status changes, matching
the pattern used elsewhere in the codebase (like toggleSection). All
buttons (dismiss, close, collapse) should now work correctly.
* fix(web-ui): Fix GitHub token expand button functionality
- Convert collapse button handler to named function (toggleGithubTokenContent)
- Improve state checking using class, inline style, and computed style
- Re-attach event listener after saving token to ensure it works
- Add console logging for debugging
- Make function globally accessible for better reliability
Fixes issue where expand button didn't work after saving token.
* fix(web-ui): Remove X button and improve GitHub token panel behavior
- Remove X (close) button from GitHub token configuration panel
- Replace toggleGithubTokenSettings() with openGithubTokenSettings() that only opens
- Auto-collapse panel when token is valid (user must click expand to edit)
- Auto-detect token status on page load (no need to click save)
- Simplify saveGithubToken() to rely on checkGitHubAuthStatus() for UI updates
- Ensure expand button works correctly with proper event listener attachment
The panel now remains visible but collapsed when a token is configured,
allowing users to expand it when needed without the ability to completely hide it.
* refactor(web-ui): Improve GitHub token collapse button code quality
- Update comment to reflect actual behavior (prevent parent click handlers)
- Use empty string for display to defer to CSS instead of hard-coding block/none
- Extract duplicate clone-and-attach logic into attachGithubTokenCollapseHandler() helper
- Make helper function globally accessible for reuse in checkGitHubAuthStatus()
Improves maintainability and makes code more future-proof for layout changes.
* fix(web-ui): Fix collapse/expand button by using removeProperty for display
- Use style.removeProperty('display') instead of style.display = ''
- This properly removes inline styles and defers to CSS classes
- Fixes issue where collapse/expand button stopped working after refactor
* fix(web-ui): Make display handling consistent for token collapse
- Use removeProperty('display') consistently in all places
- Fix checkGitHubAuthStatus() to use removeProperty instead of inline style
- Simplify state checking to rely on hidden class with computed style fallback
- Ensures collapse/expand button works correctly by deferring to CSS classes
* fix(web-ui): Fix token collapse button and simplify state detection
- Simplify state checking to rely on hidden class only (element has class='block')
- Only remove inline display style if it exists (check before removing)
- Add console logging to debug handler attachment
- Ensure collapse/expand works by relying on CSS classes
Fixes issues where:
- Collapse button did nothing
- Auto-detection of token status wasn't working
* debug(web-ui): Add extensive debugging for token collapse button
- Add console logs to track function calls and element detection
- Improve state detection to use computed style as fallback
- Add wrapper function for click handler to ensure it's called
- Better error messages to identify why handler might not attach
This will help identify why the collapse button isn't working.
* debug(web-ui): Add comprehensive debugging for GitHub token features
- Add console logs to checkGitHubAuthStatus() to track execution
- Re-attach collapse handler after plugin store is rendered
- Add error stack traces for better debugging
- Ensure handler is attached when content is dynamically loaded
This will help identify why:
- Auto-detection of token status isn't working
- Collapse button isn't functioning
* fix(web-ui): Move checkGitHubAuthStatus before IIFE to fix scope issue
- Move checkGitHubAuthStatus function definition before IIFE starts
- Function was defined after IIFE but called inside it, causing it to be undefined
- Now function is available when called during initialization
- This should fix auto-detection of token status on page load
* debug(web-ui): Add extensive logging to GitHub token functions
- Add logging when checkGitHubAuthStatus is defined
- Add logging when function is called during initialization
- Add logging in attachGithubTokenCollapseHandler
- Add logging in store render callback
- This will help identify why functions aren't executing
* fix(web-ui): Move GitHub token functions outside IIFE for availability
- Move attachGithubTokenCollapseHandler and toggleGithubTokenContent outside IIFE
- These functions need to be available when store renders, before IIFE completes
- Add logging to initializePlugins to track when it's called
- This should fix the 'undefined' error when store tries to attach handlers
* fix(web-ui): Fix GitHub token content collapse/expand functionality
- Element has 'block' class in HTML which conflicts with 'hidden' class
- When hiding: add 'hidden', remove 'block', set display:none inline
- When showing: remove 'hidden', add 'block', remove inline display
- This ensures proper visibility toggle for the GitHub API Configuration section
* feat(display): Implement on-demand plugin filtering with restart
- Add on-demand plugin filtering to DisplayController initialization
- Filters available_modes to only include on-demand plugin's modes
- Allows plugin internal rotation (e.g., NFL upcoming, NCAA FB Recent)
- Prevents rotation to other plugins
- Implement restart mechanism for on-demand activation/clear
- _restart_with_on_demand_filter() saves state and restarts with filter
- _restart_without_on_demand_filter() restores normal operation
- Supports both systemd service and direct process execution
- Add state preservation across restarts
- Saves/restores rotation position from cache
- Restores on-demand config from cache after restart
- Add service detection method
- Detects if running as systemd service
- Uses file-based approach for environment variable passing
- Update API endpoints with restart flow comments
- Update systemd service file with on-demand support notes
- Add comprehensive error handling for edge cases
* perf(web-ui): Optimize GitHub token detection speed
- Call checkGitHubAuthStatus immediately when script loads (if elements exist)
- Call it early in initPluginsPage (before full initialization completes)
- Use requestAnimationFrame instead of setTimeout(100ms) for store render callback
- Reduce save token delay from 300ms to 100ms
- Token detection now happens in parallel with other initialization tasks
- This makes token status visible much faster on page load
* fix(ui): Move on-demand modal to base.html for always-available access
- Move on-demand modal from plugins.html to base.html
- Ensures modal is always in DOM when Run On-Demand button is clicked
- Fixes issue where button in plugin_config.html couldn't find modal
- Modal is now available regardless of which tab is active
* fix(ui): Initialize on-demand modal unconditionally on page load
- Create initializeOnDemandModal() function that runs regardless of plugins tab
- Modal is in base.html so it should always be available
- Call initialization on DOMContentLoaded and with timeout
- Fixes 'On-demand modal elements not found' error when clicking button
- Modal setup now happens even if plugins tab hasn't been loaded yet
* fix(ui): Add safety check for updatePluginTabStates function
- Check if updatePluginTabStates exists before calling
- Prevents TypeError when function is not available
- Fixes error when clicking plugin tabs
* fix(ui): Add safety checks for all updatePluginTabStates calls
- Add safety check in Alpine component tab button handler
- Add safety check in Alpine callback
- Prevents TypeError when function is not available in all contexts
* fix(ui): Add safety check in Alpine callback for updatePluginTabStates
* debug(ui): Add console logging to trace on-demand modal opening
- Add logging to runPluginOnDemand function
- Add logging to __openOnDemandModalImpl function
- Log plugin lookup, modal element checks, and display changes
- Helps diagnose why modal doesn't open when button is clicked
* debug(ui): Add logging for modal display change
* debug(ui): Add more explicit modal visibility settings and computed style logging
- Set visibility and opacity explicitly when showing modal
- Force reflow to ensure styles are applied
- Log computed styles to diagnose CSS issues
- Helps identify if modal is hidden by CSS rules
* debug(ui): Increase modal z-index and add bounding rect check
- Set z-index to 9999 to ensure modal is above all other elements
- Add bounding rect check to verify modal is in viewport
- Helps diagnose if modal is positioned off-screen or behind other elements
* debug(display): Add detailed logging for on-demand restart flow
- Log when polling finds requests
- Log service detection result
- Log file writing and systemctl commands
- Log restart command execution and results
- Helps diagnose why on-demand restart isn't working
* debug(display): Add logging for on-demand request polling
- Log request_id comparison to diagnose why requests aren't being processed
- Helps identify if request_id matching is preventing processing
* fix(ui): Force modal positioning with !important to override any conflicting styles
- Use cssText with !important flags to ensure modal is always visible
- Remove all inline styles first to start fresh
- Ensure modal is positioned at top:0, left:0 with fixed positioning
- Fixes issue where modal was still positioned off-screen (top: 2422px)
* debug(ui): Add logging to on-demand form submission
- Log form submission events
- Log payload being sent
- Log response status and data
- Helps diagnose why on-demand requests aren't being processed
* fix(display): Remove restart-based on-demand activation
- Replace restart-based activation with immediate mode switch
- On-demand now activates without restarting the service
- Saves rotation state for restoration when on-demand ends
- Fixes infinite restart loop issue
- On-demand now works when display is already running
* docs: Add comprehensive guide for on-demand cache management
- Document all on-demand cache keys and their purposes
- Explain when manual clearing is needed
- Clarify what clearing from cache management tab does/doesn't do
- Provide troubleshooting steps and best practices
* fix(display): Ensure on-demand takes priority over live priority
- Move on-demand check BEFORE live priority check
- Add explicit logging when on-demand overrides live priority
- Improve request_id checking with both instance and persisted checks
- Add debug logging to trace why requests aren't being processed
- Fixes issue where on-demand didn't interrupt live NHL game
* fix(display): Ensure on-demand takes priority over live priority
- Move on-demand check BEFORE live priority check in main loop
- Add explicit logging when on-demand overrides live priority
- Fixes issue where on-demand didn't interrupt live NHL game
* fix(display): Improve on-demand request processing and priority
- Add persistent processed_id check to prevent duplicate processing
- Mark request as processed BEFORE processing to prevent race conditions
- Improve logging to trace request processing
- Ensure on-demand takes priority over live priority (already fixed in previous commit)
* fix(display): Remove duplicate action line
* fix(display): Fix live priority and ensure on-demand overrides it
- Fix live priority to properly set active_mode when live content is detected
- Ensure on-demand check happens before live priority check
- Add debug logging to trace on-demand vs live priority
- Fix live priority to stay on live mode instead of rotating
* fix(display): Add debug logging for on-demand priority check
* fix(display): Add better logging for on-demand request processing
- Add logging to show when requests are blocked by processed_id check
- Add logging to show on-demand state after activation
- Helps debug why on-demand requests aren't being processed
* fix(display): Add detailed logging for on-demand activation and checking
- Log on-demand state after activation to verify it's set correctly
- Add debug logging in main loop to trace on-demand check
- Helps identify why on-demand isn't overriding live priority
* fix(display): Add debug logging for on-demand check in main loop
* fix(display): Remove restart logic from _clear_on_demand and fix cache delete
- Replace cache_manager.delete() with cache_manager.clear_cache()
- Remove restart logic from _clear_on_demand - now clears immediately
- Restore rotation state immediately without restarting
- Fixes AttributeError: 'CacheManager' object has no attribute 'delete'
* fix(display): Remove restart logic from _clear_on_demand
- Remove restart logic - now clears on-demand state immediately
- Restore rotation state immediately without restarting
- Use clear_cache instead of delete (already fixed in previous commit)
- Fixes error when stopping on-demand mode
* feat(display): Clear display before activating on-demand mode
- Clear display and reset state before activating on-demand
- Reset dynamic mode state to ensure clean transition
- Mimics the behavior of manually stopping display first
- Should fix issue where on-demand only works after manual stop
* feat(display): Stop display service before starting on-demand mode
- Stop the display service first if it's running
- Wait 1.5 seconds for clean shutdown
- Then start the service with on-demand request in cache
- Mimics the manual workflow of stopping display first
- Should fix issue where on-demand only works after manual stop
* feat(display): Filter plugins during initialization for on-demand mode
- Check cache for on-demand requests during initialization
- Only load the on-demand plugin if on-demand request is found
- Prevents loading background services for other plugins
- Fixes issue where Hockey/Football data loads even when only Clock is requested
* fix(display): Use filtered enabled_plugins list instead of discovered_plugins
- Use enabled_plugins list which is already filtered for on-demand mode
- Prevents loading all plugins when on-demand mode is active
- Fixes issue where all plugins were loaded even in on-demand mode
* fix(display): Fix on-demand stop request processing and expiration check
- Always process stop requests, even if request_id was seen before
- Fix expiration check to handle cases where on-demand is not active
- Add better logging for stop requests and expiration
- Fixes issue where stop button does nothing and timer doesn't expire
* fix(display): Fix on-demand stop processing, expiration, and plugin filtering
- Fix stop request processing to always process stop requests, bypassing request_id checks
- Fix expiration check logic to properly check on_demand_active and expires_at separately
- Store display_on_demand_config cache key in _activate_on_demand for plugin filtering
- Clear display before switching to on-demand mode to prevent visual artifacts
- Clear display_on_demand_config cache key in _clear_on_demand to prevent stale data
- Implement plugin filtering during initialization based on display_on_demand_config
Fixes issues where:
- Stop button did nothing (stop requests were blocked by request_id check)
- Expiration timer didn't work (logic issue with or condition)
- Plugin filtering didn't work on restart (config cache key never set)
- Display showed artifacts when switching to on-demand (display not cleared)
- All plugins loaded even in on-demand mode (filtering not implemented)
* fix(web): Allow on-demand to work with disabled plugins
- Remove frontend checks that blocked disabled plugins from on-demand
- Backend already supports temporarily enabling disabled plugins during on-demand
- Update UI messages to indicate plugin will be temporarily enabled
- Remove disabled attribute from Run On-Demand button
Fixes issue where disabled plugins couldn't use on-demand feature even
though the backend implementation supports it.
* fix(display): Resolve plugin_id when sent as mode in on-demand requests
- Detect when mode parameter is actually a plugin_id and resolve to first display mode
- Handle case where frontend sends plugin_id as mode (e.g., 'football-scoreboard')
- Add fallback to use first available display mode if provided mode is invalid
- Add logging for mode resolution debugging
Fixes issue where on-demand requests with mode=plugin_id failed with 'invalid-mode' error
* feat(display): Rotate through all plugin modes in on-demand mode
- Store all modes for on-demand plugin instead of locking to single mode
- Rotate through available modes (live, recent, upcoming) when on-demand active
- Skip modes that return False (no content) and move to next mode
- Prioritize live modes if they have content, otherwise skip them
- Add on_demand_modes list and on_demand_mode_index for rotation tracking
Fixes issue where on-demand mode stayed on one mode (e.g., football_recent)
and didn't rotate through other available modes (football_live, football_upcoming).
Now properly rotates through all modes, skipping empty ones.
* fix(display): Improve on-demand stop request handling
- Always process stop requests if on-demand is active, even if same request_id
- Add better logging when stop is requested but on-demand is not active
- Improve logging in _clear_on_demand to show which mode rotation resumes to
- Ensure stop requests are properly acknowledged
Fixes issue where stop button shows as completed but display doesn't resume
normal rotation. Stop requests now properly clear on-demand state and resume.
* security(web): Fix XSS vulnerability in GitHub auth error display
Replace innerHTML usage with safe DOM manipulation:
- Use textContent to clear element and create text nodes
- Create <strong> element via createElement instead of string HTML
- Add safe fallback ('Unknown error') for error messages
- Ensure authData.error/authData.message are treated as plain text
- Avoid trusting backend-provided data as HTML
Fixes XSS vulnerability where malicious HTML in error messages could
be injected into the DOM.
* style(api): Remove unnecessary str() in f-string for error message
Remove explicit str(e) call in error_response f-string since f-strings
automatically convert exceptions to strings. This matches the style used
elsewhere in the file.
Changed: f"Error saving configuration: {str(e)}"
To: f"Error saving configuration: {e}"
* fix(store): Skip caching for rate-limited 403 responses
When a 403 response indicates a rate limit (detected by checking if
'rate limit' is in response.text.lower()), return the error result but
do NOT cache it in _token_validation_cache. Rate limits are temporary
and should be retried, so caching would incorrectly mark the token as
invalid.
Continue to cache 403 responses that indicate missing token permissions,
as these are persistent issues that should be cached.
This prevents rate-limited responses from being incorrectly cached as
invalid tokens, allowing the system to retry after the rate limit
resets.
* fix(display): Prevent ZeroDivisionError when on_demand_modes is empty
Add guards to check if on_demand_modes is non-empty before performing
any rotation/index math operations. When on_demand_active is True but
on_demand_modes is empty, clear on-demand mode instead of attempting
division by zero.
Fixed in three locations:
1. Mode selection logic (line ~1081): Check before accessing modes
2. Skip to next mode when no content (line ~1190): Guard before modulo
3. Rotate to next mode (line ~1561): Guard before modulo
This prevents ZeroDivisionError when a plugin has no available display
modes or when on_demand_modes becomes empty unexpectedly.
* fix(display): Improve guard for empty on_demand_modes in rotation skip
Refine the guard around lines 1195-1209 to:
- Check if on_demand_modes is empty before any modulo/index operations
- Log warning and debug trace when no modes are configured
- Skip rotation (continue) instead of clearing on-demand mode
- Only perform modulo and index operations when modes are available
- Only log rotation message when next_mode is valid
This prevents ZeroDivisionError and ensures all logging only occurs
when next_mode is valid, providing better traceability.
* fix(display): Populate on_demand_modes when restoring on-demand state from cache
When restoring on-demand state from cache during initialization (around
lines 163-197), the code sets on_demand_active, on_demand_plugin_id and
related fields but does not populate self.on_demand_modes, causing the
run loop to see an empty modes list after restart.
Fix by:
1. Adding _populate_on_demand_modes_from_plugin() method that retrieves
the plugin's display modes from plugin_display_modes and builds the
ordered modes list (prioritizing live modes with content, same logic
as _activate_on_demand)
2. Calling this method after plugin loading completes (around line 296)
when on_demand_active and on_demand_plugin_id are set
3. Setting on_demand_mode_index to match the restored mode if available,
otherwise starting at index 0
This ensures on_demand_modes is populated after restart, preventing
empty modes list errors in the run loop.
* docs: Update on-demand documentation to reflect current implementation
Replace obsolete log message reference with current log messages:
- Old: 'Activating on-demand mode... restarting display controller'
- New: 'Processing on-demand start request for plugin' and 'Activated on-demand for plugin'
Update Scenario 2 to reflect immediate mode switching:
- Changed title from 'Infinite Restart Loop' to 'On-Demand Mode Switching Issues'
- Updated symptoms to describe mode switching issues instead of restart loops
- Added note that on-demand now switches modes immediately without restarting
- Updated solution to include display_on_demand_state key
This reflects the current implementation where on-demand activates
immediately without restarting the service.
* fix(api): Fix undefined logger and service stop logic in start_on_demand_display
- Add module-level logger to avoid NameError when logging disabled plugin
- Only stop display service when start_service is True (prevents stopping
service without restarting when start_service is False)
- Remove unused stop_result variable
- Clean up f-strings that don't need formatting
- Improve code formatting for logger.info call
Fixes issue where logger.info() would raise NameError and where the
service would be stopped even when start_service=False, leaving the
service stopped without restarting it.
---------
Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
Co-authored-by: Chuck <chuck@example.com>
This commit is contained in:
203
docs/ON_DEMAND_CACHE_MANAGEMENT.md
Normal file
203
docs/ON_DEMAND_CACHE_MANAGEMENT.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# On-Demand Cache Management
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The on-demand feature uses several cache keys to manage state. Understanding these keys helps with troubleshooting and manual recovery.
|
||||||
|
|
||||||
|
## Cache Keys Used
|
||||||
|
|
||||||
|
### 1. `display_on_demand_request`
|
||||||
|
**Purpose**: Stores pending on-demand requests (start/stop actions)
|
||||||
|
**TTL**: 1 hour
|
||||||
|
**When Set**: When you click "Run On-Demand" or "Stop On-Demand"
|
||||||
|
**When Cleared**: Automatically after processing, or manually via cache management
|
||||||
|
|
||||||
|
**Structure**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id": "uuid-string",
|
||||||
|
"action": "start" | "stop",
|
||||||
|
"plugin_id": "plugin-name",
|
||||||
|
"mode": "mode-name",
|
||||||
|
"duration": 30.0,
|
||||||
|
"pinned": true,
|
||||||
|
"timestamp": 1234567890.123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `display_on_demand_config`
|
||||||
|
**Purpose**: Stores the active on-demand configuration (persists across restarts)
|
||||||
|
**TTL**: 1 hour
|
||||||
|
**When Set**: When on-demand mode is activated
|
||||||
|
**When Cleared**: When on-demand mode is stopped, or manually via cache management
|
||||||
|
|
||||||
|
**Structure**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugin_id": "plugin-name",
|
||||||
|
"mode": "mode-name",
|
||||||
|
"duration": 30.0,
|
||||||
|
"pinned": true,
|
||||||
|
"requested_at": 1234567890.123,
|
||||||
|
"expires_at": 1234567920.123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `display_on_demand_state`
|
||||||
|
**Purpose**: Current on-demand state (read-only, published by display controller)
|
||||||
|
**TTL**: None (updated continuously)
|
||||||
|
**When Set**: Continuously updated by display controller
|
||||||
|
**When Cleared**: Automatically when on-demand ends, or manually via cache management
|
||||||
|
|
||||||
|
**Structure**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"mode": "mode-name",
|
||||||
|
"plugin_id": "plugin-name",
|
||||||
|
"requested_at": 1234567890.123,
|
||||||
|
"expires_at": 1234567920.123,
|
||||||
|
"duration": 30.0,
|
||||||
|
"pinned": true,
|
||||||
|
"status": "active" | "idle" | "restarting" | "error",
|
||||||
|
"error": null,
|
||||||
|
"last_event": "started",
|
||||||
|
"remaining": 25.5,
|
||||||
|
"last_updated": 1234567895.123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. `display_on_demand_processed_id`
|
||||||
|
**Purpose**: Tracks which request_id has been processed (prevents duplicate processing)
|
||||||
|
**TTL**: 1 hour
|
||||||
|
**When Set**: When a request is processed
|
||||||
|
**When Cleared**: Automatically expires, or manually via cache management
|
||||||
|
|
||||||
|
**Structure**: Just a string (the request_id)
|
||||||
|
|
||||||
|
## When Manual Clearing is Needed
|
||||||
|
|
||||||
|
### Scenario 1: Stuck On-Demand State
|
||||||
|
**Symptoms**:
|
||||||
|
- Display stuck showing only one plugin
|
||||||
|
- "Stop On-Demand" button doesn't work
|
||||||
|
- Display controller shows on-demand as active but it shouldn't be
|
||||||
|
|
||||||
|
**Solution**: Clear these keys:
|
||||||
|
- `display_on_demand_config` - Removes the active configuration
|
||||||
|
- `display_on_demand_state` - Resets the published state
|
||||||
|
- `display_on_demand_request` - Clears any pending requests
|
||||||
|
|
||||||
|
**How to Clear**: Use the Cache Management tab in the web UI:
|
||||||
|
1. Go to Cache Management tab
|
||||||
|
2. Find the keys starting with `display_on_demand_`
|
||||||
|
3. Click "Delete" for each one
|
||||||
|
4. Restart the display service: `sudo systemctl restart ledmatrix`
|
||||||
|
|
||||||
|
### Scenario 2: On-Demand Mode Switching Issues
|
||||||
|
**Symptoms**:
|
||||||
|
- On-demand mode not switching to requested plugin
|
||||||
|
- Logs show "Processing on-demand start request for plugin" but no "Activated on-demand for plugin" message
|
||||||
|
- Display stuck in previous mode instead of switching immediately
|
||||||
|
|
||||||
|
**Solution**: Clear these keys:
|
||||||
|
- `display_on_demand_request` - Stops any pending request
|
||||||
|
- `display_on_demand_processed_id` - Allows new requests to be processed
|
||||||
|
- `display_on_demand_state` - Clears any stale state
|
||||||
|
|
||||||
|
**How to Clear**: Same as Scenario 1, but focus on `display_on_demand_request` first. Note that on-demand now switches modes immediately without restarting the service.
|
||||||
|
|
||||||
|
### Scenario 3: On-Demand Not Activating
|
||||||
|
**Symptoms**:
|
||||||
|
- Clicking "Run On-Demand" does nothing
|
||||||
|
- No errors in logs, but on-demand doesn't start
|
||||||
|
|
||||||
|
**Solution**: Clear these keys:
|
||||||
|
- `display_on_demand_processed_id` - May be blocking new requests
|
||||||
|
- `display_on_demand_request` - Clear any stale requests
|
||||||
|
|
||||||
|
**How to Clear**: Same as Scenario 1
|
||||||
|
|
||||||
|
### Scenario 4: After Service Crash or Unexpected Shutdown
|
||||||
|
**Symptoms**:
|
||||||
|
- Service was stopped unexpectedly (power loss, crash, etc.)
|
||||||
|
- On-demand state may be inconsistent
|
||||||
|
|
||||||
|
**Solution**: Clear all on-demand keys:
|
||||||
|
- `display_on_demand_config`
|
||||||
|
- `display_on_demand_state`
|
||||||
|
- `display_on_demand_request`
|
||||||
|
- `display_on_demand_processed_id`
|
||||||
|
|
||||||
|
**How to Clear**: Same as Scenario 1, clear all four keys
|
||||||
|
|
||||||
|
## Does Clearing from Cache Management Tab Reset It?
|
||||||
|
|
||||||
|
**Yes, but with caveats:**
|
||||||
|
|
||||||
|
1. **Clearing `display_on_demand_state`**:
|
||||||
|
- ✅ Removes the published state from cache
|
||||||
|
- ⚠️ **Does NOT** immediately clear the in-memory state in the running display controller
|
||||||
|
- The display controller will continue using its internal state until it polls for updates or restarts
|
||||||
|
|
||||||
|
2. **Clearing `display_on_demand_config`**:
|
||||||
|
- ✅ Removes the configuration from cache
|
||||||
|
- ⚠️ **Does NOT** immediately affect a running display controller
|
||||||
|
- The display controller only reads this on startup/restart
|
||||||
|
|
||||||
|
3. **Clearing `display_on_demand_request`**:
|
||||||
|
- ✅ Prevents new requests from being processed
|
||||||
|
- ✅ Stops restart loops if that's the issue
|
||||||
|
- ⚠️ **Does NOT** stop an already-active on-demand session
|
||||||
|
|
||||||
|
4. **Clearing `display_on_demand_processed_id`**:
|
||||||
|
- ✅ Allows previously-processed requests to be processed again
|
||||||
|
- Useful if a request got stuck
|
||||||
|
|
||||||
|
## Best Practice for Manual Clearing
|
||||||
|
|
||||||
|
**To fully reset on-demand state:**
|
||||||
|
|
||||||
|
1. **Stop the display service** (if possible):
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop ledmatrix
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Clear all on-demand cache keys** via Cache Management tab:
|
||||||
|
- `display_on_demand_config`
|
||||||
|
- `display_on_demand_state`
|
||||||
|
- `display_on_demand_request`
|
||||||
|
- `display_on_demand_processed_id`
|
||||||
|
|
||||||
|
3. **Clear systemd environment variable** (if set):
|
||||||
|
```bash
|
||||||
|
sudo systemctl unset-environment LEDMATRIX_ON_DEMAND_PLUGIN
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Restart the display service**:
|
||||||
|
```bash
|
||||||
|
sudo systemctl start ledmatrix
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automatic Cleanup
|
||||||
|
|
||||||
|
The display controller automatically:
|
||||||
|
- Clears `display_on_demand_config` when on-demand mode is stopped
|
||||||
|
- Updates `display_on_demand_state` continuously
|
||||||
|
- Expires `display_on_demand_request` after processing
|
||||||
|
- Expires `display_on_demand_processed_id` after 1 hour
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If clearing cache keys doesn't resolve the issue:
|
||||||
|
|
||||||
|
1. **Check logs**: `sudo journalctl -u ledmatrix -f`
|
||||||
|
2. **Check service status**: `sudo systemctl status ledmatrix`
|
||||||
|
3. **Check environment variables**: `sudo systemctl show ledmatrix | grep LEDMATRIX`
|
||||||
|
4. **Check cache files directly**: `ls -la /var/cache/ledmatrix/display_on_demand_*`
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- `src/display_controller.py` - Main on-demand logic
|
||||||
|
- `web_interface/blueprints/api_v3.py` - API endpoints for on-demand
|
||||||
|
- `web_interface/templates/v3/partials/cache.html` - Cache management UI
|
||||||
@@ -89,6 +89,8 @@ class DisplayController:
|
|||||||
self.plugin_display_modes: Dict[str, List[str]] = {}
|
self.plugin_display_modes: Dict[str, List[str]] = {}
|
||||||
self.on_demand_active = False
|
self.on_demand_active = False
|
||||||
self.on_demand_mode: Optional[str] = None
|
self.on_demand_mode: Optional[str] = None
|
||||||
|
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
|
||||||
|
self.on_demand_mode_index: int = 0 # Current index in on-demand modes rotation
|
||||||
self.on_demand_plugin_id: Optional[str] = None
|
self.on_demand_plugin_id: Optional[str] = None
|
||||||
self.on_demand_duration: Optional[float] = None
|
self.on_demand_duration: Optional[float] = None
|
||||||
self.on_demand_requested_at: Optional[float] = None
|
self.on_demand_requested_at: Optional[float] = None
|
||||||
@@ -158,8 +160,43 @@ class DisplayController:
|
|||||||
discovered_plugins = self.plugin_manager.discover_plugins()
|
discovered_plugins = self.plugin_manager.discover_plugins()
|
||||||
logger.info("Discovered %d plugin(s)", len(discovered_plugins))
|
logger.info("Discovered %d plugin(s)", len(discovered_plugins))
|
||||||
|
|
||||||
|
# Check for on-demand plugin filter from cache
|
||||||
|
on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600)
|
||||||
|
on_demand_plugin_id = on_demand_config.get('plugin_id') if on_demand_config else None
|
||||||
|
|
||||||
|
if on_demand_plugin_id:
|
||||||
|
logger.info("On-demand mode detected during initialization: filtering to plugin '%s' only", on_demand_plugin_id)
|
||||||
|
# Only load the on-demand plugin, but ensure it's enabled
|
||||||
|
if on_demand_plugin_id not in discovered_plugins:
|
||||||
|
error_msg = f"On-demand plugin '{on_demand_plugin_id}' not found in discovered plugins"
|
||||||
|
logger.error(error_msg)
|
||||||
|
logger.warning("Falling back to normal mode (all enabled plugins)")
|
||||||
|
on_demand_plugin_id = None
|
||||||
|
enabled_plugins = [p for p in discovered_plugins if self.config.get(p, {}).get('enabled', False)]
|
||||||
|
else:
|
||||||
|
plugin_config = self.config.get(on_demand_plugin_id, {})
|
||||||
|
was_disabled = not plugin_config.get('enabled', False)
|
||||||
|
if was_disabled:
|
||||||
|
logger.info("Temporarily enabling plugin '%s' for on-demand mode", on_demand_plugin_id)
|
||||||
|
if on_demand_plugin_id not in self.config:
|
||||||
|
self.config[on_demand_plugin_id] = {}
|
||||||
|
self.config[on_demand_plugin_id]['enabled'] = True
|
||||||
|
enabled_plugins = [on_demand_plugin_id]
|
||||||
|
# Set on-demand state from cached config
|
||||||
|
self.on_demand_active = True
|
||||||
|
self.on_demand_plugin_id = on_demand_plugin_id
|
||||||
|
self.on_demand_mode = on_demand_config.get('mode')
|
||||||
|
self.on_demand_duration = on_demand_config.get('duration')
|
||||||
|
self.on_demand_pinned = on_demand_config.get('pinned', False)
|
||||||
|
self.on_demand_requested_at = on_demand_config.get('requested_at')
|
||||||
|
self.on_demand_expires_at = on_demand_config.get('expires_at')
|
||||||
|
self.on_demand_status = 'active'
|
||||||
|
self.on_demand_schedule_override = True
|
||||||
|
logger.info("On-demand mode: loading only plugin '%s'", on_demand_plugin_id)
|
||||||
|
else:
|
||||||
|
enabled_plugins = [p for p in discovered_plugins if self.config.get(p, {}).get('enabled', False)]
|
||||||
|
|
||||||
# Count enabled plugins for progress tracking
|
# Count enabled plugins for progress tracking
|
||||||
enabled_plugins = [p for p in discovered_plugins if self.config.get(p, {}).get('enabled', False)]
|
|
||||||
enabled_count = len(enabled_plugins)
|
enabled_count = len(enabled_plugins)
|
||||||
logger.info("Loading %d enabled plugin(s) in parallel (max 4 concurrent)...", enabled_count)
|
logger.info("Loading %d enabled plugin(s) in parallel (max 4 concurrent)...", enabled_count)
|
||||||
|
|
||||||
@@ -197,8 +234,7 @@ class DisplayController:
|
|||||||
# Submit all enabled plugins for loading
|
# Submit all enabled plugins for loading
|
||||||
future_to_plugin = {
|
future_to_plugin = {
|
||||||
executor.submit(load_single_plugin, plugin_id): plugin_id
|
executor.submit(load_single_plugin, plugin_id): plugin_id
|
||||||
for plugin_id in discovered_plugins
|
for plugin_id in enabled_plugins
|
||||||
if self.config.get(plugin_id, {}).get('enabled', False)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Process results as they complete
|
# Process results as they complete
|
||||||
@@ -258,6 +294,10 @@ class DisplayController:
|
|||||||
logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time)
|
logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time)
|
||||||
logger.info("Total available modes: %d", len(self.available_modes))
|
logger.info("Total available modes: %d", len(self.available_modes))
|
||||||
logger.info("Available modes: %s", self.available_modes)
|
logger.info("Available modes: %s", self.available_modes)
|
||||||
|
|
||||||
|
# If on-demand mode was restored from cache, populate on_demand_modes now that plugins are loaded
|
||||||
|
if self.on_demand_active and self.on_demand_plugin_id:
|
||||||
|
self._populate_on_demand_modes_from_plugin()
|
||||||
|
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
logger.exception("Plugin system initialization failed")
|
logger.exception("Plugin system initialization failed")
|
||||||
@@ -606,6 +646,8 @@ class DisplayController:
|
|||||||
self.on_demand_last_event = None
|
self.on_demand_last_event = None
|
||||||
self.on_demand_active = False
|
self.on_demand_active = False
|
||||||
self.on_demand_mode = None
|
self.on_demand_mode = None
|
||||||
|
self.on_demand_modes = []
|
||||||
|
self.on_demand_mode_index = 0
|
||||||
self.on_demand_plugin_id = None
|
self.on_demand_plugin_id = None
|
||||||
self.on_demand_duration = None
|
self.on_demand_duration = None
|
||||||
self.on_demand_requested_at = None
|
self.on_demand_requested_at = None
|
||||||
@@ -629,30 +671,146 @@ class DisplayController:
|
|||||||
return
|
return
|
||||||
|
|
||||||
request_id = request.get('request_id')
|
request_id = request.get('request_id')
|
||||||
if not request_id or request_id == self.on_demand_request_id:
|
if not request_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
action = request.get('action')
|
action = request.get('action')
|
||||||
logger.info("Received on-demand request %s: %s", request_id, action)
|
|
||||||
|
# For stop requests, always process them (don't check processed_id)
|
||||||
|
# This allows stopping even if the same stop request was sent before
|
||||||
|
if action == 'stop':
|
||||||
|
logger.info("Received on-demand stop request %s", request_id)
|
||||||
|
# Always process stop requests, even if same request_id (user might click multiple times)
|
||||||
|
if self.on_demand_active:
|
||||||
|
self.on_demand_request_id = request_id
|
||||||
|
self._clear_on_demand(reason='requested-stop')
|
||||||
|
logger.info("On-demand mode cleared, resuming normal rotation")
|
||||||
|
else:
|
||||||
|
logger.debug("Stop request %s received but on-demand is not active", request_id)
|
||||||
|
# Still update request_id to acknowledge the request
|
||||||
|
self.on_demand_request_id = request_id
|
||||||
|
return
|
||||||
|
|
||||||
|
# For start requests, check if already processed
|
||||||
|
if request_id == self.on_demand_request_id:
|
||||||
|
logger.debug("On-demand start request %s already processed (instance check)", request_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Also check persistent processed_id (for restart scenarios)
|
||||||
|
processed_request_id = self.cache_manager.get('display_on_demand_processed_id', max_age=3600)
|
||||||
|
if request_id == processed_request_id:
|
||||||
|
logger.debug("On-demand start request %s already processed (persisted check)", request_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Received on-demand request %s: %s (plugin_id=%s, mode=%s)",
|
||||||
|
request_id, action, request.get('plugin_id'), request.get('mode'))
|
||||||
|
|
||||||
|
# Mark as processed BEFORE processing (to prevent duplicate processing)
|
||||||
|
self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600)
|
||||||
|
self.on_demand_request_id = request_id
|
||||||
|
|
||||||
if action == 'start':
|
if action == 'start':
|
||||||
|
logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id'))
|
||||||
self._activate_on_demand(request)
|
self._activate_on_demand(request)
|
||||||
elif action == 'stop':
|
|
||||||
self._clear_on_demand(reason='requested-stop')
|
|
||||||
else:
|
else:
|
||||||
logger.warning("Unknown on-demand action: %s", action)
|
logger.warning("Unknown on-demand action: %s", action)
|
||||||
self.on_demand_request_id = request_id
|
|
||||||
|
|
||||||
def _resolve_mode_for_plugin(self, plugin_id: Optional[str], mode: Optional[str]) -> Optional[str]:
|
def _resolve_mode_for_plugin(self, plugin_id: Optional[str], mode: Optional[str]) -> Optional[str]:
|
||||||
"""Resolve the display mode to use for on-demand activation."""
|
"""Resolve the display mode to use for on-demand activation."""
|
||||||
|
# If mode is provided, check if it's actually a valid mode or just the plugin_id
|
||||||
if mode:
|
if mode:
|
||||||
|
# If mode matches plugin_id, it's likely the plugin_id was sent as mode
|
||||||
|
# Try to resolve it to an actual display mode
|
||||||
|
if plugin_id and mode == plugin_id:
|
||||||
|
# Mode is the plugin_id, resolve to first available display mode
|
||||||
|
if plugin_id in self.plugin_display_modes:
|
||||||
|
modes = self.plugin_display_modes.get(plugin_id, [])
|
||||||
|
if modes:
|
||||||
|
logger.debug("Resolving mode '%s' (plugin_id) to first display mode: %s", mode, modes[0])
|
||||||
|
return modes[0]
|
||||||
|
# Check if mode is a valid display mode
|
||||||
|
elif mode in self.plugin_modes:
|
||||||
|
return mode
|
||||||
|
# Mode provided but not valid - might be plugin_id, try to resolve
|
||||||
|
elif plugin_id and plugin_id in self.plugin_display_modes:
|
||||||
|
modes = self.plugin_display_modes.get(plugin_id, [])
|
||||||
|
if modes and mode in modes:
|
||||||
|
return mode
|
||||||
|
elif modes:
|
||||||
|
logger.warning("Mode '%s' not found for plugin '%s', using first available: %s",
|
||||||
|
mode, plugin_id, modes[0])
|
||||||
|
return modes[0]
|
||||||
|
# Mode doesn't match anything, return as-is (will fail validation later)
|
||||||
return mode
|
return mode
|
||||||
|
|
||||||
|
# No mode provided, resolve from plugin_id
|
||||||
if plugin_id and plugin_id in self.plugin_display_modes:
|
if plugin_id and plugin_id in self.plugin_display_modes:
|
||||||
modes = self.plugin_display_modes.get(plugin_id, [])
|
modes = self.plugin_display_modes.get(plugin_id, [])
|
||||||
if modes:
|
if modes:
|
||||||
return modes[0]
|
return modes[0]
|
||||||
return plugin_id
|
return plugin_id
|
||||||
|
|
||||||
|
def _populate_on_demand_modes_from_plugin(self) -> None:
|
||||||
|
"""
|
||||||
|
Populate on_demand_modes from the on-demand plugin's display modes.
|
||||||
|
Called after plugin loading completes when on-demand state is restored from cache.
|
||||||
|
"""
|
||||||
|
if not self.on_demand_active or not self.on_demand_plugin_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
plugin_id = self.on_demand_plugin_id
|
||||||
|
|
||||||
|
# Get all modes for this plugin
|
||||||
|
plugin_modes = self.plugin_display_modes.get(plugin_id, [])
|
||||||
|
if not plugin_modes:
|
||||||
|
# Fallback: find all modes that belong to this plugin
|
||||||
|
plugin_modes = [mode for mode, pid in self.mode_to_plugin_id.items() if pid == plugin_id]
|
||||||
|
|
||||||
|
# Filter to only include modes that exist in plugin_modes
|
||||||
|
available_plugin_modes = [m for m in plugin_modes if m in self.plugin_modes]
|
||||||
|
|
||||||
|
if not available_plugin_modes:
|
||||||
|
logger.warning("No valid display modes found for on-demand plugin '%s' after restoration", plugin_id)
|
||||||
|
self.on_demand_modes = []
|
||||||
|
return
|
||||||
|
|
||||||
|
# Prioritize live modes if they exist and have content
|
||||||
|
live_modes = [m for m in available_plugin_modes if m.endswith('_live')]
|
||||||
|
other_modes = [m for m in available_plugin_modes if not m.endswith('_live')]
|
||||||
|
|
||||||
|
# Check if live modes have content
|
||||||
|
live_with_content = []
|
||||||
|
for live_mode in live_modes:
|
||||||
|
plugin_instance = self.plugin_modes.get(live_mode)
|
||||||
|
if plugin_instance and hasattr(plugin_instance, 'has_live_content'):
|
||||||
|
try:
|
||||||
|
if plugin_instance.has_live_content():
|
||||||
|
live_with_content.append(live_mode)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Build mode list: live modes with content first, then other modes, then live modes without content
|
||||||
|
if live_with_content:
|
||||||
|
ordered_modes = live_with_content + other_modes + [m for m in live_modes if m not in live_with_content]
|
||||||
|
else:
|
||||||
|
# No live content, skip live modes
|
||||||
|
ordered_modes = other_modes
|
||||||
|
|
||||||
|
if not ordered_modes:
|
||||||
|
# Only live modes available but no content - use them anyway
|
||||||
|
ordered_modes = live_modes
|
||||||
|
|
||||||
|
self.on_demand_modes = ordered_modes
|
||||||
|
# Set index to match the restored mode if available, otherwise start at 0
|
||||||
|
if self.on_demand_mode and self.on_demand_mode in ordered_modes:
|
||||||
|
self.on_demand_mode_index = ordered_modes.index(self.on_demand_mode)
|
||||||
|
else:
|
||||||
|
self.on_demand_mode_index = 0
|
||||||
|
|
||||||
|
logger.info("Populated on-demand modes for plugin '%s': %s (starting at index %d: %s)",
|
||||||
|
plugin_id, ordered_modes, self.on_demand_mode_index,
|
||||||
|
ordered_modes[self.on_demand_mode_index] if ordered_modes else 'N/A')
|
||||||
|
|
||||||
def _activate_on_demand(self, request: Dict[str, Any]) -> None:
|
def _activate_on_demand(self, request: Dict[str, Any]) -> None:
|
||||||
"""Activate on-demand mode for a specific plugin display."""
|
"""Activate on-demand mode for a specific plugin display."""
|
||||||
plugin_id = request.get('plugin_id')
|
plugin_id = request.get('plugin_id')
|
||||||
@@ -696,8 +854,50 @@ class DisplayController:
|
|||||||
if resolved_mode in self.available_modes:
|
if resolved_mode in self.available_modes:
|
||||||
self.current_mode_index = self.available_modes.index(resolved_mode)
|
self.current_mode_index = self.available_modes.index(resolved_mode)
|
||||||
|
|
||||||
|
# Get all modes for this plugin
|
||||||
|
plugin_modes = self.plugin_display_modes.get(resolved_plugin_id, [])
|
||||||
|
if not plugin_modes:
|
||||||
|
# Fallback: find all modes that belong to this plugin
|
||||||
|
plugin_modes = [mode for mode, pid in self.mode_to_plugin_id.items() if pid == resolved_plugin_id]
|
||||||
|
|
||||||
|
# Filter to only include modes that exist in plugin_modes
|
||||||
|
available_plugin_modes = [m for m in plugin_modes if m in self.plugin_modes]
|
||||||
|
|
||||||
|
if not available_plugin_modes:
|
||||||
|
logger.error("No valid display modes found for plugin '%s'", resolved_plugin_id)
|
||||||
|
self._set_on_demand_error("no-modes")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Prioritize live modes if they exist and have content
|
||||||
|
live_modes = [m for m in available_plugin_modes if m.endswith('_live')]
|
||||||
|
other_modes = [m for m in available_plugin_modes if not m.endswith('_live')]
|
||||||
|
|
||||||
|
# Check if live modes have content
|
||||||
|
live_with_content = []
|
||||||
|
for live_mode in live_modes:
|
||||||
|
plugin_instance = self.plugin_modes.get(live_mode)
|
||||||
|
if plugin_instance and hasattr(plugin_instance, 'has_live_content'):
|
||||||
|
try:
|
||||||
|
if plugin_instance.has_live_content():
|
||||||
|
live_with_content.append(live_mode)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Build mode list: live modes with content first, then other modes, then live modes without content
|
||||||
|
if live_with_content:
|
||||||
|
ordered_modes = live_with_content + other_modes + [m for m in live_modes if m not in live_with_content]
|
||||||
|
else:
|
||||||
|
# No live content, skip live modes
|
||||||
|
ordered_modes = other_modes
|
||||||
|
|
||||||
|
if not ordered_modes:
|
||||||
|
# Only live modes available but no content - use them anyway
|
||||||
|
ordered_modes = live_modes
|
||||||
|
|
||||||
self.on_demand_active = True
|
self.on_demand_active = True
|
||||||
self.on_demand_mode = resolved_mode
|
self.on_demand_mode = resolved_mode # Keep for backward compatibility
|
||||||
|
self.on_demand_modes = ordered_modes
|
||||||
|
self.on_demand_mode_index = 0
|
||||||
self.on_demand_plugin_id = resolved_plugin_id
|
self.on_demand_plugin_id = resolved_plugin_id
|
||||||
self.on_demand_duration = duration
|
self.on_demand_duration = duration
|
||||||
self.on_demand_requested_at = now
|
self.on_demand_requested_at = now
|
||||||
@@ -708,9 +908,36 @@ class DisplayController:
|
|||||||
self.on_demand_last_event = 'started'
|
self.on_demand_last_event = 'started'
|
||||||
self.on_demand_schedule_override = True
|
self.on_demand_schedule_override = True
|
||||||
self.force_change = True
|
self.force_change = True
|
||||||
self.current_display_mode = resolved_mode
|
|
||||||
logger.info("Activated on-demand mode '%s' for plugin '%s'", resolved_mode, resolved_plugin_id)
|
# Clear display before switching to on-demand mode
|
||||||
|
try:
|
||||||
|
self.display_manager.clear()
|
||||||
|
self.display_manager.update_display()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to clear display during on-demand activation: %s", e)
|
||||||
|
|
||||||
|
# Start with first mode (or resolved_mode if it's in the list)
|
||||||
|
if resolved_mode in ordered_modes:
|
||||||
|
self.on_demand_mode_index = ordered_modes.index(resolved_mode)
|
||||||
|
self.current_display_mode = ordered_modes[self.on_demand_mode_index]
|
||||||
|
logger.info("Activated on-demand for plugin '%s' with %d modes: %s (starting at index %d: %s)",
|
||||||
|
resolved_plugin_id, len(ordered_modes), ordered_modes,
|
||||||
|
self.on_demand_mode_index, self.current_display_mode)
|
||||||
self._publish_on_demand_state()
|
self._publish_on_demand_state()
|
||||||
|
|
||||||
|
# Store config for initialization filtering (allows plugin filtering on restart)
|
||||||
|
config_data = {
|
||||||
|
'plugin_id': resolved_plugin_id,
|
||||||
|
'mode': resolved_mode,
|
||||||
|
'duration': duration,
|
||||||
|
'pinned': pinned,
|
||||||
|
'requested_at': now,
|
||||||
|
'expires_at': self.on_demand_expires_at
|
||||||
|
}
|
||||||
|
# Use expiration time as TTL, but cap at 1 hour
|
||||||
|
ttl = min(3600, int(duration)) if duration else 3600
|
||||||
|
self.cache_manager.set('display_on_demand_config', config_data, ttl=ttl)
|
||||||
|
logger.debug("Stored on-demand config for plugin filtering: %s", resolved_plugin_id)
|
||||||
|
|
||||||
def _clear_on_demand(self, reason: Optional[str] = None) -> None:
|
def _clear_on_demand(self, reason: Optional[str] = None) -> None:
|
||||||
"""Clear on-demand mode and resume normal rotation."""
|
"""Clear on-demand mode and resume normal rotation."""
|
||||||
@@ -722,6 +949,8 @@ class DisplayController:
|
|||||||
|
|
||||||
self.on_demand_active = False
|
self.on_demand_active = False
|
||||||
self.on_demand_mode = None
|
self.on_demand_mode = None
|
||||||
|
self.on_demand_modes = []
|
||||||
|
self.on_demand_mode_index = 0
|
||||||
self.on_demand_plugin_id = None
|
self.on_demand_plugin_id = None
|
||||||
self.on_demand_duration = None
|
self.on_demand_duration = None
|
||||||
self.on_demand_requested_at = None
|
self.on_demand_requested_at = None
|
||||||
@@ -731,27 +960,41 @@ class DisplayController:
|
|||||||
self.on_demand_last_error = None
|
self.on_demand_last_error = None
|
||||||
self.on_demand_last_event = reason or 'cleared'
|
self.on_demand_last_event = reason or 'cleared'
|
||||||
self.on_demand_schedule_override = False
|
self.on_demand_schedule_override = False
|
||||||
|
|
||||||
|
# Clear on-demand configuration from cache
|
||||||
|
self.cache_manager.clear_cache('display_on_demand_config')
|
||||||
|
|
||||||
if self.rotation_resume_index is not None and self.available_modes:
|
if self.rotation_resume_index is not None and self.available_modes:
|
||||||
self.current_mode_index = self.rotation_resume_index % len(self.available_modes)
|
self.current_mode_index = self.rotation_resume_index % len(self.available_modes)
|
||||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||||
|
logger.info("Resuming rotation from saved index %d: mode '%s'",
|
||||||
|
self.rotation_resume_index, self.current_display_mode)
|
||||||
elif self.available_modes:
|
elif self.available_modes:
|
||||||
# Default to first mode
|
# Default to first mode if no resume index
|
||||||
self.current_mode_index = self.current_mode_index % len(self.available_modes)
|
self.current_mode_index = self.current_mode_index % len(self.available_modes)
|
||||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||||
|
logger.info("Resuming rotation to mode '%s' (index %d)",
|
||||||
|
self.current_display_mode, self.current_mode_index)
|
||||||
|
else:
|
||||||
|
logger.warning("No available modes to resume rotation to")
|
||||||
|
|
||||||
self.rotation_resume_index = None
|
self.rotation_resume_index = None
|
||||||
self.force_change = True
|
self.force_change = True
|
||||||
logger.info("Cleared on-demand mode (reason=%s), resuming rotation", reason)
|
logger.info("✓ ON-DEMAND MODE CLEARED (reason=%s), resuming normal rotation to mode: %s",
|
||||||
|
reason, self.current_display_mode)
|
||||||
self._publish_on_demand_state()
|
self._publish_on_demand_state()
|
||||||
|
|
||||||
def _check_on_demand_expiration(self) -> None:
|
def _check_on_demand_expiration(self) -> None:
|
||||||
"""Expire on-demand mode if duration has elapsed."""
|
"""Expire on-demand mode if duration has elapsed."""
|
||||||
if not self.on_demand_active or self.on_demand_expires_at is None:
|
if not self.on_demand_active:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.on_demand_expires_at is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if time.time() >= self.on_demand_expires_at:
|
if time.time() >= self.on_demand_expires_at:
|
||||||
logger.info("On-demand mode '%s' expired", self.on_demand_mode)
|
logger.info("On-demand mode '%s' expired (duration: %s seconds)",
|
||||||
|
self.on_demand_mode, self.on_demand_duration)
|
||||||
self._clear_on_demand(reason='expired')
|
self._clear_on_demand(reason='expired')
|
||||||
|
|
||||||
def _log_memory_stats_if_due(self) -> None:
|
def _log_memory_stats_if_due(self) -> None:
|
||||||
@@ -900,11 +1143,26 @@ class DisplayController:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if self.on_demand_active and self.on_demand_mode:
|
if self.on_demand_active:
|
||||||
active_mode = self.on_demand_mode
|
# Guard against empty on_demand_modes
|
||||||
if self.current_display_mode != active_mode:
|
if not self.on_demand_modes:
|
||||||
self.current_display_mode = active_mode
|
logger.warning("On-demand active but no modes available, clearing on-demand mode")
|
||||||
self.force_change = True
|
self._clear_on_demand(reason='no-modes-available')
|
||||||
|
active_mode = self.current_display_mode
|
||||||
|
else:
|
||||||
|
# Rotate through on-demand plugin modes
|
||||||
|
if self.on_demand_mode_index < len(self.on_demand_modes):
|
||||||
|
active_mode = self.on_demand_modes[self.on_demand_mode_index]
|
||||||
|
if self.current_display_mode != active_mode:
|
||||||
|
self.current_display_mode = active_mode
|
||||||
|
self.force_change = True
|
||||||
|
else:
|
||||||
|
# Reset to first mode if index is out of bounds
|
||||||
|
self.on_demand_mode_index = 0
|
||||||
|
active_mode = self.on_demand_modes[0]
|
||||||
|
if self.current_display_mode != active_mode:
|
||||||
|
self.current_display_mode = active_mode
|
||||||
|
self.force_change = True
|
||||||
else:
|
else:
|
||||||
active_mode = self.current_display_mode
|
active_mode = self.current_display_mode
|
||||||
|
|
||||||
@@ -994,14 +1252,34 @@ class DisplayController:
|
|||||||
display_result = False
|
display_result = False
|
||||||
display_failed_due_to_exception = True # Mark that this was an exception, not just no content
|
display_failed_due_to_exception = True # Mark that this was an exception, not just no content
|
||||||
|
|
||||||
# If display() returned False, skip to next mode immediately (unless on-demand)
|
# If display() returned False, skip to next mode immediately
|
||||||
if not display_result:
|
if not display_result:
|
||||||
if self.on_demand_active:
|
if self.on_demand_active:
|
||||||
# Stay on on-demand mode even if no content - show "waiting" message
|
# Skip to next on-demand mode if no content
|
||||||
logger.info("No content for on-demand mode %s, staying on mode", active_mode)
|
logger.info("No content for on-demand mode %s, skipping to next mode", active_mode)
|
||||||
self._sleep_with_plugin_updates(5)
|
|
||||||
self._publish_on_demand_state()
|
# Guard against empty on_demand_modes to prevent ZeroDivisionError
|
||||||
continue
|
if not self.on_demand_modes or len(self.on_demand_modes) == 0:
|
||||||
|
logger.warning("On-demand active but no modes configured, skipping rotation")
|
||||||
|
logger.debug("on_demand_modes is empty, cannot rotate to next mode")
|
||||||
|
# Skip rotation and continue to next iteration
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Move to next mode in rotation (only if on_demand_modes is non-empty)
|
||||||
|
self.on_demand_mode_index = (self.on_demand_mode_index + 1) % len(self.on_demand_modes)
|
||||||
|
next_mode = self.on_demand_modes[self.on_demand_mode_index]
|
||||||
|
|
||||||
|
# Only log when next_mode is valid
|
||||||
|
if next_mode:
|
||||||
|
logger.info("Rotating to next on-demand mode: %s (index %d/%d)",
|
||||||
|
next_mode, self.on_demand_mode_index, len(self.on_demand_modes))
|
||||||
|
self.current_display_mode = next_mode
|
||||||
|
self.force_change = True
|
||||||
|
self._publish_on_demand_state()
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger.warning("Next on-demand mode is invalid, skipping rotation")
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
logger.info("No content to display for %s, skipping to next mode", active_mode)
|
logger.info("No content to display for %s, skipping to next mode", active_mode)
|
||||||
# Don't clear display when immediately moving to next mode - this causes black flashes
|
# Don't clear display when immediately moving to next mode - this causes black flashes
|
||||||
@@ -1364,9 +1642,21 @@ class DisplayController:
|
|||||||
|
|
||||||
# Move to next mode
|
# Move to next mode
|
||||||
if self.on_demand_active:
|
if self.on_demand_active:
|
||||||
# Stay on the same mode while on-demand is active
|
# Guard against empty on_demand_modes to prevent ZeroDivisionError
|
||||||
self._publish_on_demand_state()
|
if not self.on_demand_modes:
|
||||||
continue
|
logger.warning("On-demand active but no modes available, clearing on-demand mode")
|
||||||
|
self._clear_on_demand(reason='no-modes-available')
|
||||||
|
# Fall through to normal rotation
|
||||||
|
else:
|
||||||
|
# Rotate to next on-demand mode
|
||||||
|
self.on_demand_mode_index = (self.on_demand_mode_index + 1) % len(self.on_demand_modes)
|
||||||
|
next_mode = self.on_demand_modes[self.on_demand_mode_index]
|
||||||
|
logger.info("Rotating to next on-demand mode: %s (index %d/%d)",
|
||||||
|
next_mode, self.on_demand_mode_index, len(self.on_demand_modes))
|
||||||
|
self.current_display_mode = next_mode
|
||||||
|
self.force_change = True
|
||||||
|
self._publish_on_demand_state()
|
||||||
|
continue
|
||||||
|
|
||||||
# Check for live priority - don't rotate if current plugin has live content
|
# Check for live priority - don't rotate if current plugin has live content
|
||||||
should_rotate = True
|
should_rotate = True
|
||||||
|
|||||||
@@ -125,12 +125,16 @@ class PluginStoreManager:
|
|||||||
# Rate limit or forbidden (but token might be valid)
|
# Rate limit or forbidden (but token might be valid)
|
||||||
# Check if it's a rate limit issue
|
# Check if it's a rate limit issue
|
||||||
if 'rate limit' in response.text.lower():
|
if 'rate limit' in response.text.lower():
|
||||||
|
# Rate limit: return error but don't cache (rate limits are temporary)
|
||||||
error_msg = "Rate limit exceeded"
|
error_msg = "Rate limit exceeded"
|
||||||
|
result = (False, error_msg)
|
||||||
|
return result
|
||||||
else:
|
else:
|
||||||
|
# Token lacks permissions: cache the result (permissions don't change)
|
||||||
error_msg = "Token lacks required permissions"
|
error_msg = "Token lacks required permissions"
|
||||||
result = (False, error_msg)
|
result = (False, error_msg)
|
||||||
self._token_validation_cache[cache_key] = (False, time.time(), error_msg)
|
self._token_validation_cache[cache_key] = (False, time.time(), error_msg)
|
||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
# Other error
|
# Other error
|
||||||
error_msg = f"GitHub API error: {response.status_code}"
|
error_msg = f"GitHub API error: {response.status_code}"
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ RestartSec=10
|
|||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
StandardError=journal
|
StandardError=journal
|
||||||
SyslogIdentifier=ledmatrix
|
SyslogIdentifier=ledmatrix
|
||||||
|
# Support for on-demand plugin filtering via environment variable
|
||||||
|
# The environment variable LEDMATRIX_ON_DEMAND_PLUGIN can be set via:
|
||||||
|
# sudo systemctl set-environment LEDMATRIX_ON_DEMAND_PLUGIN=<plugin_id>
|
||||||
|
# Or by using an EnvironmentFile (see below)
|
||||||
|
# EnvironmentFile=__PROJECT_ROOT_DIR__/config/on_demand_env.conf
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -6,9 +6,12 @@ import subprocess
|
|||||||
import time
|
import time
|
||||||
import hashlib
|
import hashlib
|
||||||
import uuid
|
import uuid
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Import new infrastructure
|
# Import new infrastructure
|
||||||
from src.web_interface.api_helpers import success_response, error_response, validate_request_json
|
from src.web_interface.api_helpers import success_response, error_response, validate_request_json
|
||||||
from src.web_interface.errors import ErrorCode
|
from src.web_interface.errors import ErrorCode
|
||||||
@@ -634,7 +637,7 @@ def save_main_config():
|
|||||||
logging.error(error_msg)
|
logging.error(error_msg)
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCode.CONFIG_SAVE_FAILED,
|
ErrorCode.CONFIG_SAVE_FAILED,
|
||||||
f"Error saving configuration: {str(e)}",
|
f"Error saving configuration: {e}",
|
||||||
details=traceback.format_exc(),
|
details=traceback.format_exc(),
|
||||||
status_code=500
|
status_code=500
|
||||||
)
|
)
|
||||||
@@ -1228,24 +1231,20 @@ def start_on_demand_display():
|
|||||||
if not resolved_plugin:
|
if not resolved_plugin:
|
||||||
return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404
|
return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404
|
||||||
|
|
||||||
|
# Note: On-demand can work with disabled plugins - the display controller
|
||||||
|
# will temporarily enable them during initialization if needed
|
||||||
|
# We don't block the request here, but log it for debugging
|
||||||
if api_v3.config_manager and resolved_plugin:
|
if api_v3.config_manager and resolved_plugin:
|
||||||
config = api_v3.config_manager.load_config()
|
config = api_v3.config_manager.load_config()
|
||||||
plugin_config = config.get(resolved_plugin, {})
|
plugin_config = config.get(resolved_plugin, {})
|
||||||
if 'enabled' in plugin_config and not plugin_config.get('enabled', False):
|
if 'enabled' in plugin_config and not plugin_config.get('enabled', False):
|
||||||
return jsonify({
|
logger.info(
|
||||||
'status': 'error',
|
"On-demand request for disabled plugin '%s' - will be temporarily enabled",
|
||||||
'message': f'Plugin {resolved_plugin} is disabled in configuration'
|
resolved_plugin,
|
||||||
}), 400
|
)
|
||||||
|
|
||||||
# Check if display service is running (or will be started)
|
|
||||||
service_status = _get_display_service_status()
|
|
||||||
if not service_status.get('active') and not start_service:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
|
|
||||||
'service_status': service_status
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
|
# Set the on-demand request in cache FIRST (before starting service)
|
||||||
|
# This ensures the request is available when the service starts/restarts
|
||||||
cache = _ensure_cache_manager()
|
cache = _ensure_cache_manager()
|
||||||
request_id = data.get('request_id') or str(uuid.uuid4())
|
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||||
request_payload = {
|
request_payload = {
|
||||||
@@ -1259,6 +1258,26 @@ def start_on_demand_display():
|
|||||||
}
|
}
|
||||||
cache.set('display_on_demand_request', request_payload)
|
cache.set('display_on_demand_request', request_payload)
|
||||||
|
|
||||||
|
# Check if display service is running (or will be started)
|
||||||
|
service_status = _get_display_service_status()
|
||||||
|
service_was_running = service_status.get('active', False)
|
||||||
|
|
||||||
|
# Stop the display service first to ensure clean state when we will restart it
|
||||||
|
if service_was_running and start_service:
|
||||||
|
import time as time_module
|
||||||
|
print("Stopping display service before starting on-demand mode...")
|
||||||
|
_stop_display_service()
|
||||||
|
# Wait a brief moment for the service to fully stop
|
||||||
|
time_module.sleep(1.5)
|
||||||
|
print("Display service stopped, now starting with on-demand request...")
|
||||||
|
|
||||||
|
if not service_status.get('active') and not start_service:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
|
||||||
|
'service_status': service_status
|
||||||
|
}), 400
|
||||||
|
|
||||||
service_result = None
|
service_result = None
|
||||||
if start_service:
|
if start_service:
|
||||||
service_result = _ensure_display_service_running()
|
service_result = _ensure_display_service_running()
|
||||||
@@ -1269,6 +1288,9 @@ def start_on_demand_display():
|
|||||||
'message': 'Failed to start display service. Please check service logs or start it manually.',
|
'message': 'Failed to start display service. Please check service logs or start it manually.',
|
||||||
'service_result': service_result
|
'service_result': service_result
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
# Service was restarted (or started fresh) with on-demand request in cache
|
||||||
|
# The display controller will read the request during initialization or when it polls
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
'request_id': request_id,
|
'request_id': request_id,
|
||||||
@@ -1293,6 +1315,8 @@ def stop_on_demand_display():
|
|||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
stop_service = data.get('stop_service', False)
|
stop_service = data.get('stop_service', False)
|
||||||
|
|
||||||
|
# Set the stop request in cache FIRST
|
||||||
|
# The display controller will poll this and restart without the on-demand filter
|
||||||
cache = _ensure_cache_manager()
|
cache = _ensure_cache_manager()
|
||||||
request_id = data.get('request_id') or str(uuid.uuid4())
|
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||||
request_payload = {
|
request_payload = {
|
||||||
@@ -1301,7 +1325,10 @@ def stop_on_demand_display():
|
|||||||
'timestamp': time.time()
|
'timestamp': time.time()
|
||||||
}
|
}
|
||||||
cache.set('display_on_demand_request', request_payload)
|
cache.set('display_on_demand_request', request_payload)
|
||||||
|
|
||||||
|
# Note: The display controller's _clear_on_demand() will handle the restart
|
||||||
|
# to restore normal operation with all plugins
|
||||||
|
|
||||||
service_result = None
|
service_result = None
|
||||||
if stop_service:
|
if stop_service:
|
||||||
service_result = _stop_display_service()
|
service_result = _stop_display_service()
|
||||||
@@ -2769,9 +2796,9 @@ def get_github_auth_status():
|
|||||||
try:
|
try:
|
||||||
if not api_v3.plugin_store_manager:
|
if not api_v3.plugin_store_manager:
|
||||||
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
|
return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500
|
||||||
|
|
||||||
token = api_v3.plugin_store_manager.github_token
|
token = api_v3.plugin_store_manager.github_token
|
||||||
|
|
||||||
# Check if GitHub token is configured
|
# Check if GitHub token is configured
|
||||||
if not token or len(token) == 0:
|
if not token or len(token) == 0:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -2784,10 +2811,10 @@ def get_github_auth_status():
|
|||||||
'error': None
|
'error': None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
# Validate the token
|
# Validate the token
|
||||||
is_valid, error_message = api_v3.plugin_store_manager._validate_github_token(token)
|
is_valid, error_message = api_v3.plugin_store_manager._validate_github_token(token)
|
||||||
|
|
||||||
if is_valid:
|
if is_valid:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
@@ -2946,17 +2973,17 @@ def _get_schema_property(schema, key_path):
|
|||||||
def _is_field_required(key_path, schema):
|
def _is_field_required(key_path, schema):
|
||||||
"""
|
"""
|
||||||
Check if a field is required according to the schema.
|
Check if a field is required according to the schema.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key_path: Dot-separated path like "mqtt.username"
|
key_path: Dot-separated path like "mqtt.username"
|
||||||
schema: The JSON schema dict
|
schema: The JSON schema dict
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if field is required, False otherwise
|
True if field is required, False otherwise
|
||||||
"""
|
"""
|
||||||
if not schema or 'properties' not in schema:
|
if not schema or 'properties' not in schema:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
parts = key_path.split('.')
|
parts = key_path.split('.')
|
||||||
if len(parts) == 1:
|
if len(parts) == 1:
|
||||||
# Top-level field
|
# Top-level field
|
||||||
@@ -2966,12 +2993,12 @@ def _is_field_required(key_path, schema):
|
|||||||
# Nested field - navigate to parent object
|
# Nested field - navigate to parent object
|
||||||
parent_path = '.'.join(parts[:-1])
|
parent_path = '.'.join(parts[:-1])
|
||||||
field_name = parts[-1]
|
field_name = parts[-1]
|
||||||
|
|
||||||
# Get parent property
|
# Get parent property
|
||||||
parent_prop = _get_schema_property(schema, parent_path)
|
parent_prop = _get_schema_property(schema, parent_path)
|
||||||
if not parent_prop or 'properties' not in parent_prop:
|
if not parent_prop or 'properties' not in parent_prop:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if field is required in parent
|
# Check if field is required in parent
|
||||||
required = parent_prop.get('required', [])
|
required = parent_prop.get('required', [])
|
||||||
return field_name in required
|
return field_name in required
|
||||||
@@ -3116,7 +3143,7 @@ def _set_nested_value(config, key_path, value):
|
|||||||
# Skip setting if value is the sentinel
|
# Skip setting if value is the sentinel
|
||||||
if value is _SKIP_FIELD:
|
if value is _SKIP_FIELD:
|
||||||
return
|
return
|
||||||
|
|
||||||
parts = key_path.split('.')
|
parts = key_path.split('.')
|
||||||
current = config
|
current = config
|
||||||
|
|
||||||
@@ -3320,7 +3347,7 @@ def save_plugin_config():
|
|||||||
# Only set if not skipped
|
# Only set if not skipped
|
||||||
if parsed_value is not _SKIP_FIELD:
|
if parsed_value is not _SKIP_FIELD:
|
||||||
_set_nested_value(plugin_config, base_path, parsed_value)
|
_set_nested_value(plugin_config, base_path, parsed_value)
|
||||||
|
|
||||||
# Process remaining (non-indexed) fields
|
# Process remaining (non-indexed) fields
|
||||||
# Skip any base paths that were processed as indexed arrays
|
# Skip any base paths that were processed as indexed arrays
|
||||||
for key, value in form_data.items():
|
for key, value in form_data.items():
|
||||||
@@ -3340,7 +3367,7 @@ def save_plugin_config():
|
|||||||
# Use helper to set nested values correctly (skips if _SKIP_FIELD)
|
# Use helper to set nested values correctly (skips if _SKIP_FIELD)
|
||||||
if parsed_value is not _SKIP_FIELD:
|
if parsed_value is not _SKIP_FIELD:
|
||||||
_set_nested_value(plugin_config, key, parsed_value)
|
_set_nested_value(plugin_config, key, parsed_value)
|
||||||
|
|
||||||
# Post-process: Fix array fields that might have been incorrectly structured
|
# Post-process: Fix array fields that might have been incorrectly structured
|
||||||
# This handles cases where array fields are stored as dicts (e.g., from indexed form fields)
|
# This handles cases where array fields are stored as dicts (e.g., from indexed form fields)
|
||||||
def fix_array_structures(config_dict, schema_props, prefix=''):
|
def fix_array_structures(config_dict, schema_props, prefix=''):
|
||||||
|
|||||||
@@ -587,9 +587,22 @@ window.checkGitHubAuthStatus = function checkGitHubAuthStatus() {
|
|||||||
if (tokenStatus === 'invalid' && authData.error) {
|
if (tokenStatus === 'invalid' && authData.error) {
|
||||||
const warningText = warning.querySelector('p.text-sm.text-yellow-700');
|
const warningText = warning.querySelector('p.text-sm.text-yellow-700');
|
||||||
if (warningText) {
|
if (warningText) {
|
||||||
// Preserve the structure but update the message
|
// Clear existing content
|
||||||
const errorMsg = authData.message || authData.error;
|
warningText.textContent = '';
|
||||||
warningText.innerHTML = `<strong>Token Invalid:</strong> ${errorMsg}. Please update your GitHub token to increase API rate limits to 5,000 requests/hour.`;
|
|
||||||
|
// Create safe error message with fallback
|
||||||
|
const errorMsg = (authData.message || authData.error || 'Unknown error').toString();
|
||||||
|
|
||||||
|
// Create <strong> element for "Token Invalid:" label
|
||||||
|
const strong = document.createElement('strong');
|
||||||
|
strong.textContent = 'Token Invalid:';
|
||||||
|
|
||||||
|
// Create text node for error message and suffix
|
||||||
|
const errorText = document.createTextNode(` ${errorMsg}. Please update your GitHub token to increase API rate limits to 5,000 requests/hour.`);
|
||||||
|
|
||||||
|
// Append elements safely (no innerHTML)
|
||||||
|
warningText.appendChild(strong);
|
||||||
|
warningText.appendChild(errorText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// For 'none' status, use the default message from HTML template
|
// For 'none' status, use the default message from HTML template
|
||||||
@@ -1512,24 +1525,68 @@ function runUpdateAllPlugins() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize on-demand modal setup (runs unconditionally since modal is in base.html)
|
||||||
|
function initializeOnDemandModal() {
|
||||||
|
const closeOnDemandModalBtn = document.getElementById('close-on-demand-modal');
|
||||||
|
const cancelOnDemandBtn = document.getElementById('cancel-on-demand');
|
||||||
|
const onDemandForm = document.getElementById('on-demand-form');
|
||||||
|
const onDemandModal = document.getElementById('on-demand-modal');
|
||||||
|
|
||||||
|
if (closeOnDemandModalBtn && !closeOnDemandModalBtn.dataset.initialized) {
|
||||||
|
closeOnDemandModalBtn.replaceWith(closeOnDemandModalBtn.cloneNode(true));
|
||||||
|
const newBtn = document.getElementById('close-on-demand-modal');
|
||||||
|
if (newBtn) {
|
||||||
|
newBtn.dataset.initialized = 'true';
|
||||||
|
newBtn.addEventListener('click', closeOnDemandModal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cancelOnDemandBtn && !cancelOnDemandBtn.dataset.initialized) {
|
||||||
|
cancelOnDemandBtn.replaceWith(cancelOnDemandBtn.cloneNode(true));
|
||||||
|
const newBtn = document.getElementById('cancel-on-demand');
|
||||||
|
if (newBtn) {
|
||||||
|
newBtn.dataset.initialized = 'true';
|
||||||
|
newBtn.addEventListener('click', closeOnDemandModal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onDemandForm && !onDemandForm.dataset.initialized) {
|
||||||
|
onDemandForm.replaceWith(onDemandForm.cloneNode(true));
|
||||||
|
const newForm = document.getElementById('on-demand-form');
|
||||||
|
if (newForm) {
|
||||||
|
newForm.dataset.initialized = 'true';
|
||||||
|
newForm.addEventListener('submit', submitOnDemandRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onDemandModal && !onDemandModal.dataset.initialized) {
|
||||||
|
onDemandModal.dataset.initialized = 'true';
|
||||||
|
onDemandModal.onclick = closeOnDemandModalOnBackdrop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Store the real implementation and replace the stub
|
// Store the real implementation and replace the stub
|
||||||
window.__openOnDemandModalImpl = function(pluginId) {
|
window.__openOnDemandModalImpl = function(pluginId) {
|
||||||
|
console.log('[__openOnDemandModalImpl] Called with pluginId:', pluginId);
|
||||||
const plugin = findInstalledPlugin(pluginId);
|
const plugin = findInstalledPlugin(pluginId);
|
||||||
|
console.log('[__openOnDemandModalImpl] Found plugin:', plugin ? plugin.id : 'NOT FOUND');
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
|
console.warn('[__openOnDemandModalImpl] Plugin not found, installedPlugins:', window.installedPlugins?.length || 0);
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification(`Plugin ${pluginId} not found`, 'error');
|
showNotification(`Plugin ${pluginId} not found`, 'error');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: On-demand can work with disabled plugins - the backend will temporarily enable them
|
||||||
|
// We still log it for debugging but don't block the modal
|
||||||
if (!plugin.enabled) {
|
if (!plugin.enabled) {
|
||||||
if (typeof showNotification === 'function') {
|
console.log('[__openOnDemandModalImpl] Plugin is disabled, but on-demand will temporarily enable it');
|
||||||
showNotification('Enable the plugin before running it on-demand.', 'error');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
currentOnDemandPluginId = pluginId;
|
currentOnDemandPluginId = pluginId;
|
||||||
|
console.log('[__openOnDemandModalImpl] Setting currentOnDemandPluginId to:', pluginId);
|
||||||
|
|
||||||
|
// Ensure modal is initialized
|
||||||
|
console.log('[__openOnDemandModalImpl] Initializing modal...');
|
||||||
|
initializeOnDemandModal();
|
||||||
|
|
||||||
const modal = document.getElementById('on-demand-modal');
|
const modal = document.getElementById('on-demand-modal');
|
||||||
const modeSelect = document.getElementById('on-demand-mode');
|
const modeSelect = document.getElementById('on-demand-mode');
|
||||||
@@ -1539,10 +1596,30 @@ window.__openOnDemandModalImpl = function(pluginId) {
|
|||||||
const startServiceCheckbox = document.getElementById('on-demand-start-service');
|
const startServiceCheckbox = document.getElementById('on-demand-start-service');
|
||||||
const modalTitle = document.getElementById('on-demand-modal-title');
|
const modalTitle = document.getElementById('on-demand-modal-title');
|
||||||
|
|
||||||
|
console.log('[__openOnDemandModalImpl] Modal elements check:', {
|
||||||
|
modal: !!modal,
|
||||||
|
modeSelect: !!modeSelect,
|
||||||
|
modeHint: !!modeHint,
|
||||||
|
durationInput: !!durationInput,
|
||||||
|
pinnedCheckbox: !!pinnedCheckbox,
|
||||||
|
startServiceCheckbox: !!startServiceCheckbox,
|
||||||
|
modalTitle: !!modalTitle
|
||||||
|
});
|
||||||
|
|
||||||
if (!modal || !modeSelect || !modeHint || !durationInput || !pinnedCheckbox || !startServiceCheckbox || !modalTitle) {
|
if (!modal || !modeSelect || !modeHint || !durationInput || !pinnedCheckbox || !startServiceCheckbox || !modalTitle) {
|
||||||
console.error('On-demand modal elements not found');
|
console.error('On-demand modal elements not found', {
|
||||||
|
modal: !!modal,
|
||||||
|
modeSelect: !!modeSelect,
|
||||||
|
modeHint: !!modeHint,
|
||||||
|
durationInput: !!durationInput,
|
||||||
|
pinnedCheckbox: !!pinnedCheckbox,
|
||||||
|
startServiceCheckbox: !!startServiceCheckbox,
|
||||||
|
modalTitle: !!modalTitle
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[__openOnDemandModalImpl] All elements found, opening modal...');
|
||||||
|
|
||||||
modalTitle.textContent = `Run ${resolvePluginDisplayName(pluginId)} On-Demand`;
|
modalTitle.textContent = `Run ${resolvePluginDisplayName(pluginId)} On-Demand`;
|
||||||
modeSelect.innerHTML = '';
|
modeSelect.innerHTML = '';
|
||||||
@@ -1589,7 +1666,43 @@ window.__openOnDemandModalImpl = function(pluginId) {
|
|||||||
console.error('Error checking service status:', error);
|
console.error('Error checking service status:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
console.log('[__openOnDemandModalImpl] Setting modal display to flex');
|
||||||
|
// Force modal to be visible and properly positioned
|
||||||
|
// Remove all inline styles that might interfere
|
||||||
|
modal.removeAttribute('style');
|
||||||
|
// Set explicit positioning to ensure it's visible
|
||||||
|
modal.style.cssText = 'position: fixed !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; display: flex !important; visibility: visible !important; opacity: 1 !important; z-index: 9999 !important; margin: 0 !important; padding: 0 !important;';
|
||||||
|
|
||||||
|
// Ensure modal content is centered
|
||||||
|
const modalContent = modal.querySelector('.modal-content');
|
||||||
|
if (modalContent) {
|
||||||
|
modalContent.style.margin = 'auto';
|
||||||
|
modalContent.style.maxHeight = '90vh';
|
||||||
|
modalContent.style.overflowY = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to top of page to ensure modal is visible
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
|
||||||
|
// Force a reflow to ensure styles are applied
|
||||||
|
modal.offsetHeight;
|
||||||
|
console.log('[__openOnDemandModalImpl] Modal display set, should be visible now. Modal element:', modal);
|
||||||
|
console.log('[__openOnDemandModalImpl] Modal computed styles:', {
|
||||||
|
display: window.getComputedStyle(modal).display,
|
||||||
|
visibility: window.getComputedStyle(modal).visibility,
|
||||||
|
opacity: window.getComputedStyle(modal).opacity,
|
||||||
|
zIndex: window.getComputedStyle(modal).zIndex,
|
||||||
|
position: window.getComputedStyle(modal).position
|
||||||
|
});
|
||||||
|
// Also check if modal is actually in the viewport
|
||||||
|
const rect = modal.getBoundingClientRect();
|
||||||
|
console.log('[__openOnDemandModalImpl] Modal bounding rect:', {
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
visible: rect.width > 0 && rect.height > 0
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Replace the stub with the real implementation
|
// Replace the stub with the real implementation
|
||||||
@@ -1605,7 +1718,10 @@ function closeOnDemandModal() {
|
|||||||
|
|
||||||
function submitOnDemandRequest(event) {
|
function submitOnDemandRequest(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
console.log('[submitOnDemandRequest] Form submitted, currentOnDemandPluginId:', currentOnDemandPluginId);
|
||||||
|
|
||||||
if (!currentOnDemandPluginId) {
|
if (!currentOnDemandPluginId) {
|
||||||
|
console.error('[submitOnDemandRequest] No plugin ID set');
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification('Select a plugin before starting on-demand mode.', 'error');
|
showNotification('Select a plugin before starting on-demand mode.', 'error');
|
||||||
}
|
}
|
||||||
@@ -1614,8 +1730,11 @@ function submitOnDemandRequest(event) {
|
|||||||
|
|
||||||
const form = document.getElementById('on-demand-form');
|
const form = document.getElementById('on-demand-form');
|
||||||
if (!form) {
|
if (!form) {
|
||||||
|
console.error('[submitOnDemandRequest] Form not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[submitOnDemandRequest] Form found, processing...');
|
||||||
|
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
const mode = formData.get('mode');
|
const mode = formData.get('mode');
|
||||||
@@ -1637,6 +1756,7 @@ function submitOnDemandRequest(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[submitOnDemandRequest] Payload:', payload);
|
||||||
markOnDemandLoading();
|
markOnDemandLoading();
|
||||||
|
|
||||||
fetch('/api/v3/display/on-demand/start', {
|
fetch('/api/v3/display/on-demand/start', {
|
||||||
@@ -1646,8 +1766,12 @@ function submitOnDemandRequest(event) {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => {
|
||||||
|
console.log('[submitOnDemandRequest] Response status:', response.status);
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
.then(result => {
|
.then(result => {
|
||||||
|
console.log('[submitOnDemandRequest] Response data:', result);
|
||||||
if (result.status === 'success') {
|
if (result.status === 'success') {
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
const pluginName = resolvePluginDisplayName(currentOnDemandPluginId);
|
const pluginName = resolvePluginDisplayName(currentOnDemandPluginId);
|
||||||
@@ -1656,13 +1780,14 @@ function submitOnDemandRequest(event) {
|
|||||||
closeOnDemandModal();
|
closeOnDemandModal();
|
||||||
setTimeout(() => loadOnDemandStatus(true), 700);
|
setTimeout(() => loadOnDemandStatus(true), 700);
|
||||||
} else {
|
} else {
|
||||||
|
console.error('[submitOnDemandRequest] Request failed:', result);
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification(result.message || 'Failed to start on-demand mode', 'error');
|
showNotification(result.message || 'Failed to start on-demand mode', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error starting on-demand mode:', error);
|
console.error('[submitOnDemandRequest] Error starting on-demand mode:', error);
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification('Error starting on-demand mode: ' + error.message, 'error');
|
showNotification('Error starting on-demand mode: ' + error.message, 'error');
|
||||||
}
|
}
|
||||||
@@ -5975,6 +6100,18 @@ if (window.checkGitHubAuthStatus && document.getElementById('github-auth-warning
|
|||||||
window.checkGitHubAuthStatus();
|
window.checkGitHubAuthStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize on-demand modal immediately since it's in base.html
|
||||||
|
if (typeof initializeOnDemandModal === 'function') {
|
||||||
|
// Run immediately and also after DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeOnDemandModal);
|
||||||
|
} else {
|
||||||
|
initializeOnDemandModal();
|
||||||
|
}
|
||||||
|
// Also try after a short delay to ensure elements are available
|
||||||
|
setTimeout(initializeOnDemandModal, 100);
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
const installedGrid = document.getElementById('installed-plugins-grid');
|
const installedGrid = document.getElementById('installed-plugins-grid');
|
||||||
if (installedGrid) {
|
if (installedGrid) {
|
||||||
|
|||||||
@@ -937,7 +937,10 @@
|
|||||||
const appElement = document.querySelector('[x-data="app()"]');
|
const appElement = document.querySelector('[x-data="app()"]');
|
||||||
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
||||||
appElement._x_dataStack[0].activeTab = plugin.id;
|
appElement._x_dataStack[0].activeTab = plugin.id;
|
||||||
appElement._x_dataStack[0].updatePluginTabStates();
|
// Only call updatePluginTabStates if it exists
|
||||||
|
if (typeof appElement._x_dataStack[0].updatePluginTabStates === 'function') {
|
||||||
|
appElement._x_dataStack[0].updatePluginTabStates();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2024,10 +2027,7 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
runOnDemand() {
|
runOnDemand() {
|
||||||
if (!plugin.enabled) {
|
// Note: On-demand can work with disabled plugins - the backend will temporarily enable them
|
||||||
this.notify('Enable the plugin before running it on-demand.', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof window.openOnDemandModal === 'function') {
|
if (typeof window.openOnDemandModal === 'function') {
|
||||||
window.openOnDemandModal(plugin.id);
|
window.openOnDemandModal(plugin.id);
|
||||||
} else {
|
} else {
|
||||||
@@ -2123,7 +2123,9 @@
|
|||||||
// Ensure content loads for the active tab
|
// Ensure content loads for the active tab
|
||||||
this.$watch('activeTab', (newTab, oldTab) => {
|
this.$watch('activeTab', (newTab, oldTab) => {
|
||||||
// Update plugin tab states when activeTab changes
|
// Update plugin tab states when activeTab changes
|
||||||
this.updatePluginTabStates();
|
if (typeof this.updatePluginTabStates === 'function') {
|
||||||
|
this.updatePluginTabStates();
|
||||||
|
}
|
||||||
// Trigger content load when tab changes
|
// Trigger content load when tab changes
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.loadTabContent(newTab);
|
this.loadTabContent(newTab);
|
||||||
@@ -2345,7 +2347,9 @@
|
|||||||
tabButton.className = `plugin-tab nav-tab ${this.activeTab === plugin.id ? 'nav-tab-active' : ''}`;
|
tabButton.className = `plugin-tab nav-tab ${this.activeTab === plugin.id ? 'nav-tab-active' : ''}`;
|
||||||
tabButton.onclick = () => {
|
tabButton.onclick = () => {
|
||||||
this.activeTab = plugin.id;
|
this.activeTab = plugin.id;
|
||||||
this.updatePluginTabStates();
|
if (typeof this.updatePluginTabStates === 'function') {
|
||||||
|
this.updatePluginTabStates();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
tabButton.innerHTML = `
|
tabButton.innerHTML = `
|
||||||
<i class="fas fa-puzzle-piece"></i>${this.escapeHtml(plugin.name || plugin.id)}
|
<i class="fas fa-puzzle-piece"></i>${this.escapeHtml(plugin.name || plugin.id)}
|
||||||
@@ -4815,6 +4819,76 @@
|
|||||||
|
|
||||||
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
||||||
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20241223j" defer></script>
|
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20241223j" defer></script>
|
||||||
|
|
||||||
|
<!-- On-Demand Modal (moved here from plugins.html so it's always available) -->
|
||||||
|
<div id="on-demand-modal" class="fixed inset-0 modal-backdrop flex items-center justify-center z-50" style="display: none;">
|
||||||
|
<div class="modal-content p-6 w-full max-w-md bg-white rounded-lg shadow-lg">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 id="on-demand-modal-title" class="text-lg font-semibold">Run Plugin On-Demand</h3>
|
||||||
|
<button id="close-on-demand-modal" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Service Status Alert -->
|
||||||
|
<div id="on-demand-service-warning" class="hidden mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2"></i>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium text-yellow-800">Display service is not running</p>
|
||||||
|
<p class="text-xs text-yellow-700 mt-1">
|
||||||
|
The on-demand request will be queued but won't display until the service starts.
|
||||||
|
Enable "Start display service" below to automatically start it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="on-demand-form" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="on-demand-mode" class="block text-sm font-medium text-gray-700 mb-1">Display Mode</label>
|
||||||
|
<select id="on-demand-mode" name="mode"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</select>
|
||||||
|
<p id="on-demand-mode-hint" class="text-xs text-gray-500 mt-1"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="on-demand-duration" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Duration (seconds, optional)
|
||||||
|
</label>
|
||||||
|
<input type="number" min="0" id="on-demand-duration" name="duration"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Leave blank to use plugin default">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Use 0 or leave empty to keep the plugin running until stopped manually.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input id="on-demand-pinned" name="pinned" type="checkbox"
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
|
<label for="on-demand-pinned" class="ml-2 block text-sm text-gray-700">
|
||||||
|
Pin plugin to prevent rotation until stopped
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input id="on-demand-start-service" name="start_service" type="checkbox" checked
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
|
<label for="on-demand-start-service" class="ml-2 block text-sm text-gray-700">
|
||||||
|
Start display service if it is not running
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-3 pt-3">
|
||||||
|
<button type="button" id="cancel-on-demand"
|
||||||
|
class="px-4 py-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-md">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md font-semibold">
|
||||||
|
Start On-Demand
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -308,8 +308,7 @@
|
|||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onclick="runPluginOnDemand('{{ plugin.id }}')"
|
onclick="runPluginOnDemand('{{ plugin.id }}')"
|
||||||
{% if not plugin.enabled %}disabled{% endif %}
|
class="px-3 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md flex items-center gap-2 transition-colors">
|
||||||
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>
|
<i class="fas fa-play-circle"></i>
|
||||||
<span>Run On-Demand</span>
|
<span>Run On-Demand</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -321,7 +320,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% if not plugin.enabled %}
|
{% if not plugin.enabled %}
|
||||||
<p class="text-xs text-amber-600">Enable this plugin before launching on-demand.</p>
|
<p class="text-xs text-amber-600">Plugin is disabled, but on-demand will temporarily enable it.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -330,75 +330,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- On-Demand Modal -->
|
<!-- On-Demand Modal moved to base.html so it's always available -->
|
||||||
<div id="on-demand-modal" class="fixed inset-0 modal-backdrop flex items-center justify-center z-50" style="display: none;">
|
|
||||||
<div class="modal-content p-6 w-full max-w-md bg-white rounded-lg shadow-lg">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 id="on-demand-modal-title" class="text-lg font-semibold">Run Plugin On-Demand</h3>
|
|
||||||
<button id="close-on-demand-modal" class="text-gray-400 hover:text-gray-600">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Service Status Alert -->
|
|
||||||
<div id="on-demand-service-warning" class="hidden mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
|
||||||
<div class="flex items-start">
|
|
||||||
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2"></i>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium text-yellow-800">Display service is not running</p>
|
|
||||||
<p class="text-xs text-yellow-700 mt-1">
|
|
||||||
The on-demand request will be queued but won't display until the service starts.
|
|
||||||
Enable "Start display service" below to automatically start it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="on-demand-form" class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="on-demand-mode" class="block text-sm font-medium text-gray-700 mb-1">Display Mode</label>
|
|
||||||
<select id="on-demand-mode" name="mode"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
|
||||||
</select>
|
|
||||||
<p id="on-demand-mode-hint" class="text-xs text-gray-500 mt-1"></p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="on-demand-duration" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Duration (seconds, optional)
|
|
||||||
</label>
|
|
||||||
<input type="number" min="0" id="on-demand-duration" name="duration"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
placeholder="Leave blank to use plugin default">
|
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
|
||||||
Use 0 or leave empty to keep the plugin running until stopped manually.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input id="on-demand-pinned" name="pinned" type="checkbox"
|
|
||||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
||||||
<label for="on-demand-pinned" class="ml-2 block text-sm text-gray-700">
|
|
||||||
Pin plugin to prevent rotation until stopped
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input id="on-demand-start-service" name="start_service" type="checkbox" checked
|
|
||||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
||||||
<label for="on-demand-start-service" class="ml-2 block text-sm text-gray-700">
|
|
||||||
Start display service if it is not running
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end gap-3 pt-3">
|
|
||||||
<button type="button" id="cancel-on-demand"
|
|
||||||
class="px-4 py-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-md">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
|
||||||
class="px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md font-semibold">
|
|
||||||
Start On-Demand
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* View toggle button styles */
|
/* View toggle button styles */
|
||||||
|
|||||||
Reference in New Issue
Block a user