mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
fix(plugins): Resolve plugin action button errors and config save permission issues (#162)
* fix(plugins): Resolve plugin ID determination error in action buttons
- Fix server-side template parameter order for executePluginAction
- Add data-plugin-id attributes to action buttons in all templates
- Enhance executePluginAction with comprehensive fallback logic
- Support retrieving pluginId from DOM, Alpine context, and config state
- Fixes 'Unable to determine plugin ID' error for Spotify/YouTube auth
* fix(plugins): Add missing button IDs and status divs in server-side action template
- Add action-{id}-{index} IDs to action buttons
- Add action-status-{id}-{index} status divs for each action
- Match client-side template structure for consistency
- Fixes 'Action elements not found' error
* fix(api): Fix indentation error in execute_plugin_action function
- Fix incorrect else block indentation that caused 500 errors
- Correct indentation for OAuth flow and simple script execution paths
- Resolves syntax error preventing plugin actions from executing
* fix(api): Improve error handling for plugin actions and config saves
- Add better JSON parsing error handling with request details
- Add detailed permission error messages for secrets file saves
- Include file path and permission status in error responses
- Helps diagnose 400 errors on action execution and 500 errors on config saves
* fix(api): Add detailed permission error handling for secrets config saves
- Add PermissionError-specific handling with permission checks
- Include directory and file permission status in error logs
- Provide more helpful error messages with file paths
- Helps diagnose permission issues when saving config_secrets.json
* fix(config): Add permission check and actionable error message for config saves
- Check file writability before attempting write
- Show file owner and current permissions in error message
- Provide exact command to fix permissions (chown + chmod)
- Helps diagnose and resolve permission issues with config_secrets.json
* fix(config): Preserve detailed permission error messages
- Handle PermissionError separately to preserve detailed error messages
- Ensure actionable permission fix commands are included in error response
- Prevents detailed error messages from being lost in exception chain
* fix(config): Remove overly strict pre-write permission check
- Remove pre-write file existence/writability check that was blocking valid writes
- Let actual file write operation determine success/failure
- Provide detailed error messages only when write actually fails
- Fixes regression where config_secrets.json saves were blocked unnecessarily
* fix(config): Use atomic writes for config_secrets.json to handle permission issues
- Write to temp file first, then atomically move to final location
- Works even when existing file isn't writable (as long as directory is writable)
- Matches pattern used elsewhere in codebase (disk_cache, atomic_manager)
- Fixes permission errors when saving secrets configuration
* chore: Update music plugin submodule to include live_priority fix
* fix(plugins): Improve plugin ID determination in dynamic button generation
- Update generateFormFromSchema to pass currentPluginConfig?.pluginId and add data attributes
- Update generateSimpleConfigForm to pass currentPluginConfig?.pluginId and add data attributes
- Scope fallback 6 DOM lookup to button context instead of document-wide search
- Ensures correct plugin tab selection when multiple plugins are present
- Maintains existing try/catch error handling and logging
---------
Co-authored-by: Chuck <chuck@example.com>
This commit is contained in:
Submodule plugins/ledmatrix-music updated: a0ba19d280...3bcae7bf84
@@ -438,11 +438,52 @@ class ConfigManager:
|
|||||||
path_obj = Path(path_to_save)
|
path_obj = Path(path_to_save)
|
||||||
ensure_directory_permissions(path_obj.parent, get_config_dir_mode())
|
ensure_directory_permissions(path_obj.parent, get_config_dir_mode())
|
||||||
|
|
||||||
with open(path_to_save, 'w') as f:
|
# Use atomic write: write to temp file first, then move atomically
|
||||||
json.dump(data, f, indent=4)
|
# This works even if the existing file isn't writable (as long as directory is writable)
|
||||||
|
import tempfile
|
||||||
|
file_mode = get_config_file_mode(path_obj)
|
||||||
|
|
||||||
# Set proper file permissions after writing
|
# Create temp file in same directory to ensure atomic move works
|
||||||
ensure_file_permissions(path_obj, get_config_file_mode(path_obj))
|
temp_fd, temp_path = tempfile.mkstemp(
|
||||||
|
suffix='.json',
|
||||||
|
dir=str(path_obj.parent),
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Write to temp file
|
||||||
|
with os.fdopen(temp_fd, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, indent=4)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
|
||||||
|
# Set permissions on temp file before moving
|
||||||
|
try:
|
||||||
|
os.chmod(temp_path, file_mode)
|
||||||
|
except OSError:
|
||||||
|
pass # Non-critical if chmod fails
|
||||||
|
|
||||||
|
# Atomically move temp file to final location
|
||||||
|
# This works even if target file exists and isn't writable
|
||||||
|
os.replace(temp_path, str(path_obj))
|
||||||
|
temp_path = None # Mark as moved so we don't try to clean it up
|
||||||
|
|
||||||
|
# Ensure final file has correct permissions
|
||||||
|
try:
|
||||||
|
ensure_file_permissions(path_obj, file_mode)
|
||||||
|
except OSError as perm_error:
|
||||||
|
# If we can't set permissions but file was written, log warning but don't fail
|
||||||
|
self.logger.warning(
|
||||||
|
f"File {path_to_save} was written successfully but could not set permissions: {perm_error}. "
|
||||||
|
f"This may cause issues if the file needs to be accessible by other users."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Clean up temp file if it still exists (move failed)
|
||||||
|
if temp_path and os.path.exists(temp_path):
|
||||||
|
try:
|
||||||
|
os.remove(temp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
self.logger.info(f"{file_type.capitalize()} configuration successfully saved to {os.path.abspath(path_to_save)}")
|
self.logger.info(f"{file_type.capitalize()} configuration successfully saved to {os.path.abspath(path_to_save)}")
|
||||||
|
|
||||||
@@ -461,8 +502,43 @@ class ConfigManager:
|
|||||||
f"The file on disk is valid, but in-memory config may be stale."
|
f"The file on disk is valid, but in-memory config may be stale."
|
||||||
)
|
)
|
||||||
|
|
||||||
except (IOError, OSError, PermissionError) as e:
|
except PermissionError as e:
|
||||||
error_msg = f"Error writing {file_type} configuration to file {os.path.abspath(path_to_save)}"
|
# Provide helpful error message with fix instructions
|
||||||
|
import stat
|
||||||
|
try:
|
||||||
|
import pwd
|
||||||
|
if path_obj.exists():
|
||||||
|
file_stat = path_obj.stat()
|
||||||
|
current_mode = stat.filemode(file_stat.st_mode)
|
||||||
|
try:
|
||||||
|
file_owner = pwd.getpwuid(file_stat.st_uid).pw_name
|
||||||
|
except (ImportError, KeyError):
|
||||||
|
file_owner = f"UID {file_stat.st_uid}"
|
||||||
|
error_msg = (
|
||||||
|
f"Cannot write to {file_type} configuration file {os.path.abspath(path_to_save)}. "
|
||||||
|
f"File is owned by {file_owner} with permissions {current_mode}. "
|
||||||
|
f"To fix, run: sudo chown $USER:$(id -gn) {path_to_save} && sudo chmod 664 {path_to_save}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# File doesn't exist - check directory permissions
|
||||||
|
dir_stat = path_obj.parent.stat()
|
||||||
|
dir_mode = stat.filemode(dir_stat.st_mode)
|
||||||
|
try:
|
||||||
|
dir_owner = pwd.getpwuid(dir_stat.st_uid).pw_name
|
||||||
|
except (ImportError, KeyError):
|
||||||
|
dir_owner = f"UID {dir_stat.st_uid}"
|
||||||
|
error_msg = (
|
||||||
|
f"Cannot create {file_type} configuration file {os.path.abspath(path_to_save)}. "
|
||||||
|
f"Directory is owned by {dir_owner} with permissions {dir_mode}. "
|
||||||
|
f"To fix, run: sudo chown $USER:$(id -gn) {path_obj.parent} && sudo chmod 775 {path_obj.parent}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Fallback to generic message if we can't get file info
|
||||||
|
error_msg = f"Error writing {file_type} configuration to file {os.path.abspath(path_to_save)}: {str(e)}"
|
||||||
|
self.logger.error(error_msg, exc_info=True)
|
||||||
|
raise ConfigError(error_msg, config_path=path_to_save) from e
|
||||||
|
except (IOError, OSError) as e:
|
||||||
|
error_msg = f"Error writing {file_type} configuration to file {os.path.abspath(path_to_save)}: {str(e)}"
|
||||||
self.logger.error(error_msg, exc_info=True)
|
self.logger.error(error_msg, exc_info=True)
|
||||||
raise ConfigError(error_msg, config_path=path_to_save) from e
|
raise ConfigError(error_msg, config_path=path_to_save) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -3979,15 +3979,42 @@ def save_plugin_config():
|
|||||||
# Save secrets file
|
# Save secrets file
|
||||||
try:
|
try:
|
||||||
api_v3.config_manager.save_raw_file_content('secrets', current_secrets)
|
api_v3.config_manager.save_raw_file_content('secrets', current_secrets)
|
||||||
|
except PermissionError as e:
|
||||||
|
# Log the error with more details
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
secrets_path = api_v3.config_manager.secrets_path
|
||||||
|
secrets_dir = os.path.dirname(secrets_path) if secrets_path else None
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
dir_readable = os.access(secrets_dir, os.R_OK) if secrets_dir and os.path.exists(secrets_dir) else False
|
||||||
|
dir_writable = os.access(secrets_dir, os.W_OK) if secrets_dir and os.path.exists(secrets_dir) else False
|
||||||
|
file_writable = os.access(secrets_path, os.W_OK) if secrets_path and os.path.exists(secrets_path) else False
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
f"Permission error saving secrets config for {plugin_id}: {e}\n"
|
||||||
|
f"Secrets path: {secrets_path}\n"
|
||||||
|
f"Directory readable: {dir_readable}, writable: {dir_writable}\n"
|
||||||
|
f"File writable: {file_writable}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
return error_response(
|
||||||
|
ErrorCode.CONFIG_SAVE_FAILED,
|
||||||
|
f"Failed to save secrets configuration: Permission denied. Check file permissions on {secrets_path}",
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log the error but don't fail the entire config save
|
# Log the error but don't fail the entire config save
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
secrets_path = api_v3.config_manager.secrets_path
|
||||||
logger.error(f"Error saving secrets config for {plugin_id}: {e}", exc_info=True)
|
logger.error(f"Error saving secrets config for {plugin_id}: {e}", exc_info=True)
|
||||||
# Return error response
|
# Return error response with more context
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCode.CONFIG_SAVE_FAILED,
|
ErrorCode.CONFIG_SAVE_FAILED,
|
||||||
f"Failed to save secrets configuration: {str(e)}",
|
f"Failed to save secrets configuration: {str(e)} (config_path={secrets_path})",
|
||||||
status_code=500
|
status_code=500
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -4225,13 +4252,30 @@ def reset_plugin_config():
|
|||||||
def execute_plugin_action():
|
def execute_plugin_action():
|
||||||
"""Execute a plugin-defined action (e.g., authentication)"""
|
"""Execute a plugin-defined action (e.g., authentication)"""
|
||||||
try:
|
try:
|
||||||
data = request.get_json() or {}
|
# Try to get JSON data, with better error handling
|
||||||
|
try:
|
||||||
|
data = request.get_json(force=True) or {}
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Error parsing JSON in execute_plugin_action: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid JSON in request: {str(e)}',
|
||||||
|
'content_type': request.content_type,
|
||||||
|
'data': request.data.decode('utf-8', errors='ignore')[:200]
|
||||||
|
}), 400
|
||||||
|
|
||||||
plugin_id = data.get('plugin_id')
|
plugin_id = data.get('plugin_id')
|
||||||
action_id = data.get('action_id')
|
action_id = data.get('action_id')
|
||||||
action_params = data.get('params', {})
|
action_params = data.get('params', {})
|
||||||
|
|
||||||
if not plugin_id or not action_id:
|
if not plugin_id or not action_id:
|
||||||
return jsonify({'status': 'error', 'message': 'plugin_id and action_id required'}), 400
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'plugin_id and action_id required',
|
||||||
|
'received': {'plugin_id': plugin_id, 'action_id': action_id, 'has_params': bool(action_params)}
|
||||||
|
}), 400
|
||||||
|
|
||||||
# Get plugin directory
|
# Get plugin directory
|
||||||
if api_v3.plugin_manager:
|
if api_v3.plugin_manager:
|
||||||
@@ -4339,16 +4383,16 @@ sys.exit(proc.returncode)
|
|||||||
if os.path.exists(wrapper_path):
|
if os.path.exists(wrapper_path):
|
||||||
os.unlink(wrapper_path)
|
os.unlink(wrapper_path)
|
||||||
return jsonify({'status': 'error', 'message': 'Action timed out'}), 408
|
return jsonify({'status': 'error', 'message': 'Action timed out'}), 408
|
||||||
else:
|
else:
|
||||||
# Regular script execution - pass params via stdin if provided
|
# Regular script execution - pass params via stdin if provided
|
||||||
if action_params:
|
if action_params:
|
||||||
# Pass params as JSON via stdin
|
# Pass params as JSON via stdin
|
||||||
import tempfile
|
import tempfile
|
||||||
import json as json_lib
|
import json as json_lib
|
||||||
|
|
||||||
params_json = json_lib.dumps(action_params)
|
params_json = json_lib.dumps(action_params)
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper:
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper:
|
||||||
wrapper.write(f'''import sys
|
wrapper.write(f'''import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
@@ -4372,139 +4416,139 @@ stdout, _ = proc.communicate(input=json.dumps(params), timeout=120)
|
|||||||
print(stdout)
|
print(stdout)
|
||||||
sys.exit(proc.returncode)
|
sys.exit(proc.returncode)
|
||||||
''')
|
''')
|
||||||
wrapper_path = wrapper.name
|
wrapper_path = wrapper.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['python3', wrapper_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
env=env
|
||||||
|
)
|
||||||
|
os.unlink(wrapper_path)
|
||||||
|
|
||||||
|
# Try to parse output as JSON
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
output_data = json.loads(result.stdout)
|
||||||
['python3', wrapper_path],
|
if result.returncode == 0:
|
||||||
capture_output=True,
|
return jsonify(output_data)
|
||||||
text=True,
|
else:
|
||||||
timeout=120,
|
|
||||||
env=env
|
|
||||||
)
|
|
||||||
os.unlink(wrapper_path)
|
|
||||||
|
|
||||||
# Try to parse output as JSON
|
|
||||||
try:
|
|
||||||
output_data = json.loads(result.stdout)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return jsonify(output_data)
|
|
||||||
else:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': output_data.get('message', action_def.get('error_message', 'Action failed')),
|
|
||||||
'output': result.stdout + result.stderr
|
|
||||||
}), 400
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Output is not JSON, return as text
|
|
||||||
if result.returncode == 0:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': action_def.get('success_message', 'Action completed successfully'),
|
|
||||||
'output': result.stdout
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': action_def.get('error_message', 'Action failed'),
|
|
||||||
'output': result.stdout + result.stderr
|
|
||||||
}), 400
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
if os.path.exists(wrapper_path):
|
|
||||||
os.unlink(wrapper_path)
|
|
||||||
return jsonify({'status': 'error', 'message': 'Action timed out'}), 408
|
|
||||||
else:
|
|
||||||
# No params - check for OAuth flow first, then run script normally
|
|
||||||
# Step 1: Get initial data (like auth URL)
|
|
||||||
# For OAuth flows, we might need to import the script as a module
|
|
||||||
if action_def.get('oauth_flow'):
|
|
||||||
# Import script as module to get auth URL
|
|
||||||
import sys
|
|
||||||
import importlib.util
|
|
||||||
|
|
||||||
spec = importlib.util.spec_from_file_location("plugin_action", script_file)
|
|
||||||
action_module = importlib.util.module_from_spec(spec)
|
|
||||||
sys.modules["plugin_action"] = action_module
|
|
||||||
|
|
||||||
try:
|
|
||||||
spec.loader.exec_module(action_module)
|
|
||||||
|
|
||||||
# Try to get auth URL using common patterns
|
|
||||||
auth_url = None
|
|
||||||
if hasattr(action_module, 'get_auth_url'):
|
|
||||||
auth_url = action_module.get_auth_url()
|
|
||||||
elif hasattr(action_module, 'load_spotify_credentials'):
|
|
||||||
# Spotify-specific pattern
|
|
||||||
client_id, client_secret, redirect_uri = action_module.load_spotify_credentials()
|
|
||||||
if all([client_id, client_secret, redirect_uri]):
|
|
||||||
from spotipy.oauth2 import SpotifyOAuth
|
|
||||||
sp_oauth = SpotifyOAuth(
|
|
||||||
client_id=client_id,
|
|
||||||
client_secret=client_secret,
|
|
||||||
redirect_uri=redirect_uri,
|
|
||||||
scope=getattr(action_module, 'SCOPE', ''),
|
|
||||||
cache_path=getattr(action_module, 'SPOTIFY_AUTH_CACHE_PATH', None),
|
|
||||||
open_browser=False
|
|
||||||
)
|
|
||||||
auth_url = sp_oauth.get_authorize_url()
|
|
||||||
|
|
||||||
if auth_url:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': action_def.get('step1_message', 'Authorization URL generated'),
|
|
||||||
'auth_url': auth_url,
|
|
||||||
'requires_step2': True
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Could not generate authorization URL'
|
|
||||||
}), 400
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
error_details = traceback.format_exc()
|
|
||||||
print(f"Error executing action step 1: {e}")
|
|
||||||
print(error_details)
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'Error executing action: {str(e)}'
|
'message': output_data.get('message', action_def.get('error_message', 'Action failed')),
|
||||||
}), 500
|
'output': result.stdout + result.stderr
|
||||||
else:
|
}), 400
|
||||||
# Simple script execution
|
except json.JSONDecodeError:
|
||||||
result = subprocess.run(
|
# Output is not JSON, return as text
|
||||||
['python3', str(script_file)],
|
if result.returncode == 0:
|
||||||
capture_output=True,
|
return jsonify({
|
||||||
text=True,
|
'status': 'success',
|
||||||
timeout=60,
|
'message': action_def.get('success_message', 'Action completed successfully'),
|
||||||
env=env
|
'output': result.stdout
|
||||||
)
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': action_def.get('error_message', 'Action failed'),
|
||||||
|
'output': result.stdout + result.stderr
|
||||||
|
}), 400
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
if os.path.exists(wrapper_path):
|
||||||
|
os.unlink(wrapper_path)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Action timed out'}), 408
|
||||||
|
else:
|
||||||
|
# No params - check for OAuth flow first, then run script normally
|
||||||
|
# Step 1: Get initial data (like auth URL)
|
||||||
|
# For OAuth flows, we might need to import the script as a module
|
||||||
|
if action_def.get('oauth_flow'):
|
||||||
|
# Import script as module to get auth URL
|
||||||
|
import sys
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
# Try to parse output as JSON
|
spec = importlib.util.spec_from_file_location("plugin_action", script_file)
|
||||||
try:
|
action_module = importlib.util.module_from_spec(spec)
|
||||||
import json as json_module
|
sys.modules["plugin_action"] = action_module
|
||||||
output_data = json_module.loads(result.stdout)
|
|
||||||
if result.returncode == 0:
|
try:
|
||||||
return jsonify(output_data)
|
spec.loader.exec_module(action_module)
|
||||||
else:
|
|
||||||
return jsonify({
|
# Try to get auth URL using common patterns
|
||||||
'status': 'error',
|
auth_url = None
|
||||||
'message': output_data.get('message', action_def.get('error_message', 'Action failed')),
|
if hasattr(action_module, 'get_auth_url'):
|
||||||
'output': result.stdout + result.stderr
|
auth_url = action_module.get_auth_url()
|
||||||
}), 400
|
elif hasattr(action_module, 'load_spotify_credentials'):
|
||||||
except json.JSONDecodeError:
|
# Spotify-specific pattern
|
||||||
# Output is not JSON, return as text
|
client_id, client_secret, redirect_uri = action_module.load_spotify_credentials()
|
||||||
if result.returncode == 0:
|
if all([client_id, client_secret, redirect_uri]):
|
||||||
return jsonify({
|
from spotipy.oauth2 import SpotifyOAuth
|
||||||
'status': 'success',
|
sp_oauth = SpotifyOAuth(
|
||||||
'message': action_def.get('success_message', 'Action completed successfully'),
|
client_id=client_id,
|
||||||
'output': result.stdout
|
client_secret=client_secret,
|
||||||
})
|
redirect_uri=redirect_uri,
|
||||||
else:
|
scope=getattr(action_module, 'SCOPE', ''),
|
||||||
return jsonify({
|
cache_path=getattr(action_module, 'SPOTIFY_AUTH_CACHE_PATH', None),
|
||||||
'status': 'error',
|
open_browser=False
|
||||||
'message': action_def.get('error_message', 'Action failed'),
|
)
|
||||||
'output': result.stdout + result.stderr
|
auth_url = sp_oauth.get_authorize_url()
|
||||||
}), 400
|
|
||||||
|
if auth_url:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': action_def.get('step1_message', 'Authorization URL generated'),
|
||||||
|
'auth_url': auth_url,
|
||||||
|
'requires_step2': True
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Could not generate authorization URL'
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
error_details = traceback.format_exc()
|
||||||
|
print(f"Error executing action step 1: {e}")
|
||||||
|
print(error_details)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Error executing action: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
else:
|
||||||
|
# Simple script execution
|
||||||
|
result = subprocess.run(
|
||||||
|
['python3', str(script_file)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
env=env
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to parse output as JSON
|
||||||
|
try:
|
||||||
|
import json as json_module
|
||||||
|
output_data = json_module.loads(result.stdout)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return jsonify(output_data)
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': output_data.get('message', action_def.get('error_message', 'Action failed')),
|
||||||
|
'output': result.stdout + result.stderr
|
||||||
|
}), 400
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Output is not JSON, return as text
|
||||||
|
if result.returncode == 0:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': action_def.get('success_message', 'Action completed successfully'),
|
||||||
|
'output': result.stdout
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': action_def.get('error_message', 'Action failed'),
|
||||||
|
'output': result.stdout + result.stderr
|
||||||
|
}), 400
|
||||||
|
|
||||||
elif action_type == 'endpoint':
|
elif action_type == 'endpoint':
|
||||||
# Call a plugin-defined HTTP endpoint (future feature)
|
# Call a plugin-defined HTTP endpoint (future feature)
|
||||||
|
|||||||
@@ -2872,7 +2872,9 @@ function generateFormFromSchema(schema, config, webUiActions = []) {
|
|||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
id="${actionId}"
|
id="${actionId}"
|
||||||
onclick="executePluginAction('${action.id}', ${index})"
|
onclick="executePluginAction('${action.id}', ${index}, '${window.currentPluginConfig?.pluginId || ''}')"
|
||||||
|
data-plugin-id="${window.currentPluginConfig?.pluginId || ''}"
|
||||||
|
data-action-id="${action.id}"
|
||||||
class="btn ${colors.btn} text-white px-4 py-2 rounded-md whitespace-nowrap">
|
class="btn ${colors.btn} text-white px-4 py-2 rounded-md whitespace-nowrap">
|
||||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
||||||
</button>
|
</button>
|
||||||
@@ -3196,7 +3198,9 @@ function generateSimpleConfigForm(config, webUiActions = []) {
|
|||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
id="${actionId}"
|
id="${actionId}"
|
||||||
onclick="executePluginAction('${action.id}', ${index})"
|
onclick="executePluginAction('${action.id}', ${index}, '${window.currentPluginConfig?.pluginId || ''}')"
|
||||||
|
data-plugin-id="${window.currentPluginConfig?.pluginId || ''}"
|
||||||
|
data-action-id="${action.id}"
|
||||||
class="btn ${colors.btn} text-white px-4 py-2 rounded-md">
|
class="btn ${colors.btn} text-white px-4 py-2 rounded-md">
|
||||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
||||||
</button>
|
</button>
|
||||||
@@ -3546,37 +3550,137 @@ window.closePluginConfigModal = function() {
|
|||||||
|
|
||||||
// Generic Plugin Action Handler
|
// Generic Plugin Action Handler
|
||||||
window.executePluginAction = function(actionId, actionIndex, pluginIdParam = null) {
|
window.executePluginAction = function(actionId, actionIndex, pluginIdParam = null) {
|
||||||
// Get plugin ID from parameter, currentPluginConfig, or try to find from context
|
console.log('[DEBUG] executePluginAction called - actionId:', actionId, 'actionIndex:', actionIndex, 'pluginIdParam:', pluginIdParam);
|
||||||
let pluginId = pluginIdParam || currentPluginConfig?.pluginId;
|
|
||||||
|
|
||||||
// If still no pluginId, try to find it from the button's context or Alpine.js
|
// Construct button ID first (we have actionId and actionIndex)
|
||||||
if (!pluginId) {
|
const actionIdFull = `action-${actionId}-${actionIndex}`;
|
||||||
// Try to get from Alpine.js context if we're in a plugin tab
|
const statusId = `action-status-${actionId}-${actionIndex}`;
|
||||||
if (window.Alpine && document.querySelector('[x-data*="plugin"]')) {
|
const btn = document.getElementById(actionIdFull);
|
||||||
const pluginTab = document.querySelector(`[x-show*="activeTab === plugin.id"]`);
|
const statusDiv = document.getElementById(statusId);
|
||||||
if (pluginTab) {
|
|
||||||
const pluginData = Alpine.$data(pluginTab.closest('[x-data]'));
|
// Get plugin ID from multiple sources with comprehensive fallback logic
|
||||||
if (pluginData && pluginData.plugin) {
|
let pluginId = pluginIdParam;
|
||||||
pluginId = pluginData.plugin.id;
|
|
||||||
}
|
// Fallback 1: Try to get from button's data-plugin-id attribute
|
||||||
|
if (!pluginId && btn) {
|
||||||
|
pluginId = btn.getAttribute('data-plugin-id');
|
||||||
|
if (pluginId) {
|
||||||
|
console.log('[DEBUG] Got pluginId from button data attribute:', pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback 2: Try to get from closest parent with data-plugin-id
|
||||||
|
if (!pluginId && btn) {
|
||||||
|
const parentWithPluginId = btn.closest('[data-plugin-id]');
|
||||||
|
if (parentWithPluginId) {
|
||||||
|
pluginId = parentWithPluginId.getAttribute('data-plugin-id');
|
||||||
|
if (pluginId) {
|
||||||
|
console.log('[DEBUG] Got pluginId from parent element:', pluginId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback 3: Try to get from plugin-config-container or plugin-config-tab
|
||||||
|
if (!pluginId && btn) {
|
||||||
|
const container = btn.closest('.plugin-config-container, .plugin-config-tab, [id^="plugin-config-"]');
|
||||||
|
if (container) {
|
||||||
|
// Try data-plugin-id first
|
||||||
|
pluginId = container.getAttribute('data-plugin-id');
|
||||||
|
if (!pluginId) {
|
||||||
|
// Try to extract from ID like "plugin-config-{pluginId}"
|
||||||
|
const idMatch = container.id.match(/plugin-config-(.+)/);
|
||||||
|
if (idMatch) {
|
||||||
|
pluginId = idMatch[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pluginId) {
|
||||||
|
console.log('[DEBUG] Got pluginId from container:', pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback 4: Try to get from currentPluginConfig
|
||||||
if (!pluginId) {
|
if (!pluginId) {
|
||||||
console.error('No plugin ID available. actionId:', actionId, 'actionIndex:', actionIndex);
|
pluginId = currentPluginConfig?.pluginId;
|
||||||
|
if (pluginId) {
|
||||||
|
console.log('[DEBUG] Got pluginId from currentPluginConfig:', pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback 5: Try to get from Alpine.js context (activeTab)
|
||||||
|
if (!pluginId && window.Alpine) {
|
||||||
|
try {
|
||||||
|
const appElement = document.querySelector('[x-data="app()"]');
|
||||||
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
||||||
|
const appData = appElement._x_dataStack[0];
|
||||||
|
if (appData.activeTab && appData.activeTab !== 'overview' && appData.activeTab !== 'plugins' && appData.activeTab !== 'wifi') {
|
||||||
|
pluginId = appData.activeTab;
|
||||||
|
console.log('[DEBUG] Got pluginId from Alpine activeTab:', pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[DEBUG] Error accessing Alpine context:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback 6: Try to find from plugin tab elements (scoped to button context)
|
||||||
|
if (!pluginId && btn) {
|
||||||
|
try {
|
||||||
|
// Search within the button's Alpine.js context (closest x-data element)
|
||||||
|
const buttonContext = btn.closest('[x-data]');
|
||||||
|
if (buttonContext) {
|
||||||
|
const pluginTab = buttonContext.querySelector('[x-show*="activeTab === plugin.id"]');
|
||||||
|
if (pluginTab && window.Alpine) {
|
||||||
|
try {
|
||||||
|
const pluginData = Alpine.$data(buttonContext);
|
||||||
|
if (pluginData && pluginData.plugin) {
|
||||||
|
pluginId = pluginData.plugin.id;
|
||||||
|
if (pluginId) {
|
||||||
|
console.log('[DEBUG] Got pluginId from Alpine plugin data (scoped to button context):', pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[DEBUG] Error accessing Alpine plugin data:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If not found in button context, try container element
|
||||||
|
if (!pluginId) {
|
||||||
|
const container = btn.closest('.plugin-config-container, .plugin-config-tab, [id^="plugin-config-"]');
|
||||||
|
if (container) {
|
||||||
|
const containerContext = container.querySelector('[x-show*="activeTab === plugin.id"]');
|
||||||
|
if (containerContext && window.Alpine) {
|
||||||
|
try {
|
||||||
|
const containerData = Alpine.$data(container.closest('[x-data]'));
|
||||||
|
if (containerData && containerData.plugin) {
|
||||||
|
pluginId = containerData.plugin.id;
|
||||||
|
if (pluginId) {
|
||||||
|
console.log('[DEBUG] Got pluginId from Alpine plugin data (scoped to container):', pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[DEBUG] Error accessing Alpine plugin data from container:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[DEBUG] Error in fallback 6 DOM lookup:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final check - if still no pluginId, show error
|
||||||
|
if (!pluginId) {
|
||||||
|
console.error('No plugin ID available after all fallbacks. actionId:', actionId, 'actionIndex:', actionIndex);
|
||||||
|
console.error('[DEBUG] Button found:', !!btn);
|
||||||
|
console.error('[DEBUG] currentPluginConfig:', currentPluginConfig);
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification('Unable to determine plugin ID. Please refresh the page.', 'error');
|
showNotification('Unable to determine plugin ID. Please refresh the page.', 'error');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[DEBUG] executePluginAction - pluginId:', pluginId, 'actionId:', actionId, 'actionIndex:', actionIndex);
|
console.log('[DEBUG] executePluginAction - Final pluginId:', pluginId, 'actionId:', actionId, 'actionIndex:', actionIndex);
|
||||||
|
|
||||||
const actionIdFull = `action-${actionId}-${actionIndex}`;
|
|
||||||
const statusId = `action-status-${actionId}-${actionIndex}`;
|
|
||||||
const btn = document.getElementById(actionIdFull);
|
|
||||||
const statusDiv = document.getElementById(statusId);
|
|
||||||
|
|
||||||
if (!btn || !statusDiv) {
|
if (!btn || !statusDiv) {
|
||||||
console.error(`Action elements not found: ${actionIdFull}`);
|
console.error(`Action elements not found: ${actionIdFull}`);
|
||||||
|
|||||||
@@ -2872,6 +2872,8 @@
|
|||||||
const statusId = `action-status-${action.id}-${index}`;
|
const statusId = `action-status-${action.id}-${index}`;
|
||||||
const bgColor = action.color || 'blue';
|
const bgColor = action.color || 'blue';
|
||||||
const colors = colorMap[bgColor] || colorMap['blue'];
|
const colors = colorMap[bgColor] || colorMap['blue'];
|
||||||
|
// Ensure pluginId is valid for template interpolation
|
||||||
|
const safePluginId = pluginId || '';
|
||||||
|
|
||||||
formHtml += `
|
formHtml += `
|
||||||
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
||||||
@@ -2884,7 +2886,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
id="${actionId}"
|
id="${actionId}"
|
||||||
onclick="executePluginAction('${action.id}', ${index}, '${pluginId}')"
|
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
|
||||||
|
data-plugin-id="${safePluginId}"
|
||||||
|
data-action-id="${action.id}"
|
||||||
class="btn ${colors.btn} text-white px-4 py-2 rounded-md whitespace-nowrap">
|
class="btn ${colors.btn} text-white px-4 py-2 rounded-md whitespace-nowrap">
|
||||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
||||||
</button>
|
</button>
|
||||||
@@ -2924,6 +2928,8 @@
|
|||||||
const statusId = `action-status-${action.id}-${index}`;
|
const statusId = `action-status-${action.id}-${index}`;
|
||||||
const bgColor = action.color || 'blue';
|
const bgColor = action.color || 'blue';
|
||||||
const colors = colorMap[bgColor] || colorMap['blue'];
|
const colors = colorMap[bgColor] || colorMap['blue'];
|
||||||
|
// Ensure pluginId is valid for template interpolation
|
||||||
|
const safePluginId = pluginId || '';
|
||||||
actionsHtml += `
|
actionsHtml += `
|
||||||
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -2935,7 +2941,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
id="${actionId}"
|
id="${actionId}"
|
||||||
onclick="executePluginAction('${action.id}', ${index}, '${pluginId}')"
|
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
|
||||||
|
data-plugin-id="${safePluginId}"
|
||||||
|
data-action-id="${action.id}"
|
||||||
class="btn ${colors.btn} text-white px-4 py-2 rounded-md">
|
class="btn ${colors.btn} text-white px-4 py-2 rounded-md">
|
||||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -283,14 +283,64 @@
|
|||||||
{% if web_ui_actions %}
|
{% if web_ui_actions %}
|
||||||
<div class="mt-6 pt-4 border-t border-gray-200">
|
<div class="mt-6 pt-4 border-t border-gray-200">
|
||||||
<h3 class="text-md font-medium text-gray-900 mb-3">Plugin Actions</h3>
|
<h3 class="text-md font-medium text-gray-900 mb-3">Plugin Actions</h3>
|
||||||
<div class="flex flex-wrap gap-2">
|
{% if web_ui_actions[0].section_description %}
|
||||||
|
<p class="text-sm text-gray-600 mb-4">{{ web_ui_actions[0].section_description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="space-y-3">
|
||||||
{% for action in web_ui_actions %}
|
{% for action in web_ui_actions %}
|
||||||
<button type="button"
|
{% set action_id = "action-" ~ action.id ~ "-" ~ loop.index0 %}
|
||||||
onclick="executePluginAction('{{ plugin.id }}', '{{ action.id }}')"
|
{% set status_id = "action-status-" ~ action.id ~ "-" ~ loop.index0 %}
|
||||||
class="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-700 text-white rounded-md flex items-center gap-2 transition-colors">
|
{% set bg_color = action.color or 'blue' %}
|
||||||
{% if action.icon %}<i class="{{ action.icon }}"></i>{% endif %}
|
{% if bg_color == 'green' %}
|
||||||
{{ action.label or action.id }}
|
{% set bg_class = 'bg-green-50' %}
|
||||||
</button>
|
{% set border_class = 'border-green-200' %}
|
||||||
|
{% set text_class = 'text-green-900' %}
|
||||||
|
{% set text_light_class = 'text-green-700' %}
|
||||||
|
{% set btn_class = 'bg-green-600 hover:bg-green-700' %}
|
||||||
|
{% elif bg_color == 'red' %}
|
||||||
|
{% set bg_class = 'bg-red-50' %}
|
||||||
|
{% set border_class = 'border-red-200' %}
|
||||||
|
{% set text_class = 'text-red-900' %}
|
||||||
|
{% set text_light_class = 'text-red-700' %}
|
||||||
|
{% set btn_class = 'bg-red-600 hover:bg-red-700' %}
|
||||||
|
{% elif bg_color == 'yellow' %}
|
||||||
|
{% set bg_class = 'bg-yellow-50' %}
|
||||||
|
{% set border_class = 'border-yellow-200' %}
|
||||||
|
{% set text_class = 'text-yellow-900' %}
|
||||||
|
{% set text_light_class = 'text-yellow-700' %}
|
||||||
|
{% set btn_class = 'bg-yellow-600 hover:bg-yellow-700' %}
|
||||||
|
{% elif bg_color == 'purple' %}
|
||||||
|
{% set bg_class = 'bg-purple-50' %}
|
||||||
|
{% set border_class = 'border-purple-200' %}
|
||||||
|
{% set text_class = 'text-purple-900' %}
|
||||||
|
{% set text_light_class = 'text-purple-700' %}
|
||||||
|
{% set btn_class = 'bg-purple-600 hover:bg-purple-700' %}
|
||||||
|
{% else %}
|
||||||
|
{% set bg_class = 'bg-blue-50' %}
|
||||||
|
{% set border_class = 'border-blue-200' %}
|
||||||
|
{% set text_class = 'text-blue-900' %}
|
||||||
|
{% set text_light_class = 'text-blue-700' %}
|
||||||
|
{% set btn_class = 'bg-blue-600 hover:bg-blue-700' %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="{{ bg_class }} border {{ border_class }} rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="font-medium {{ text_class }} mb-1">
|
||||||
|
{% if action.icon %}<i class="{{ action.icon }} mr-2"></i>{% endif %}{{ action.title or action.id }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm {{ text_light_class }}">{{ action.description or '' }}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
id="{{ action_id }}"
|
||||||
|
onclick="executePluginAction('{{ action.id }}', {{ loop.index0 }}, '{{ plugin.id }}')"
|
||||||
|
data-plugin-id="{{ plugin.id }}"
|
||||||
|
data-action-id="{{ action.id }}"
|
||||||
|
class="btn {{ btn_class }} text-white px-4 py-2 rounded-md whitespace-nowrap">
|
||||||
|
{% if action.icon %}<i class="{{ action.icon }} mr-2"></i>{% endif %}{{ action.button_text or action.title or action.id }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="{{ status_id }}" class="mt-3 hidden"></div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user