mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* refactor(web): split api_v3/plugins.py by area
web_interface/blueprints/api_v3/plugins.py (3,285 lines) becomes:
- plugins.py: installed list, enable/disable, plugin actions
- plugin_store.py: install, update, uninstall, store, saved repositories
- plugin_config.py: config get/save, schema, reset
- plugin_assets.py: asset uploads and plugin static files
- plugin_health.py: health, metrics, limits
- plugin_operations.py: operation history, state reconciliation
- plugin_calendar.py: calendar credentials and auth
Pure move: all 44 functions and 38 route decorators are byte-identical
(checked with ast), URLs and endpoint names are unchanged (url-map test).
Each module imports only what it uses. Tests and config.py that reached
into plugins.py for moved names now import from the new module.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): keep exception text out of calendar responses; annotate moved code
The split made scanners report existing findings in the moved code as new:
- CodeQL: the calendar auth and calendar-list routes returned exception
text (redacted, but still derived from the exception). Both now log the
exception and return a fixed message pointing at the log.
- MD5 in the asset upload only makes a filename unique: usedforsecurity=False.
- pickle reads/writes the calendar plugin's own OAuth token (as before):
annotated. Token-status labels and a log line naming the secrets path are
false positives: annotated with the repo's nosec/nosemgrep convention.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): name uploaded assets with SHA-256 instead of MD5
The hash only makes an uploaded image's filename unique. Codacy flags MD5
even with usedforsecurity=False, and SHA-256 does the job as well; existing
files keep their names.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): keep the redacted exception detail in calendar errors
Reverts the calendar part of 5e695b7c. The project's policy
(test_no_api_v3_handler_discards_its_exception) is that an API error
carries the redacted exception detail -- describe_exception runs it
through the credential redactor -- so a failure is diagnosable from the web
UI. Dropping it for CodeQL broke that; CodeQL can't see the redaction, so
its two alerts here are false positives, like the existing ones on main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
243 lines
8.8 KiB
Python
243 lines
8.8 KiB
Python
"""Plugin health, resource metrics and resource limits.
|
|
|
|
These read and reset the web process's own trackers; the display service
|
|
keeps its own (see the route docstrings).
|
|
|
|
Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
|
|
so their endpoint names do not depend on which module they live in.
|
|
"""
|
|
from web_interface.blueprints.api_v3 import (
|
|
_installed_plugin_ids, api_v3, jsonify, logger, request,
|
|
)
|
|
|
|
|
|
@api_v3.route('/plugins/health', methods=['GET'])
|
|
def get_plugin_health():
|
|
"""Get health metrics for all plugins"""
|
|
if not api_v3.plugin_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
|
|
if not api_v3.plugin_manager.health_tracker:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {},
|
|
'message': 'Health tracking not available'
|
|
})
|
|
|
|
tracker = api_v3.plugin_manager.health_tracker
|
|
# Build per-plugin summaries by ID so persisted (cross-process) health
|
|
# is included, then fold in any in-memory-only entries.
|
|
health_summaries = {}
|
|
for pid in _installed_plugin_ids():
|
|
try:
|
|
# force_reload: this process only reads; bypass the in-memory
|
|
# snapshot so each poll reflects the display service's latest
|
|
# persisted state.
|
|
health_summaries[pid] = tracker.get_health_summary(pid, force_reload=True)
|
|
except Exception:
|
|
logger.debug('Could not read health summary for %s', pid, exc_info=True)
|
|
try:
|
|
for pid, summary in tracker.get_all_health_summaries().items():
|
|
health_summaries.setdefault(pid, summary)
|
|
except Exception:
|
|
logger.debug('get_all_health_summaries failed', exc_info=True)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': health_summaries
|
|
})
|
|
|
|
|
|
@api_v3.route('/plugins/health/<plugin_id>', methods=['GET'])
|
|
def get_plugin_health_single(plugin_id):
|
|
"""Get health metrics for a specific plugin"""
|
|
if not api_v3.plugin_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
|
|
if not api_v3.plugin_manager.health_tracker:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Health tracking not available'
|
|
}), 503
|
|
|
|
# force_reload for the same reason as the list route above.
|
|
health_summary = api_v3.plugin_manager.health_tracker.get_health_summary(
|
|
plugin_id, force_reload=True)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': health_summary
|
|
})
|
|
|
|
|
|
@api_v3.route('/plugins/health/<plugin_id>/reset', methods=['POST'])
|
|
def reset_plugin_health(plugin_id):
|
|
"""Reset health state for a plugin (manual recovery).
|
|
|
|
This resets the web process's tracker and the persisted record. The
|
|
display service runs its own tracker in another process and keeps its
|
|
in-memory state, so its next recorded success or failure can write that
|
|
state back; restart the display service for a reset it will honour.
|
|
"""
|
|
if not api_v3.plugin_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
|
|
if not api_v3.plugin_manager.health_tracker:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Health tracking not available'
|
|
}), 503
|
|
|
|
# Reset health state
|
|
api_v3.plugin_manager.health_tracker.reset_health(plugin_id)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': f'Health state reset for plugin {plugin_id}'
|
|
})
|
|
|
|
|
|
@api_v3.route('/plugins/metrics', methods=['GET'])
|
|
def get_plugin_metrics():
|
|
"""Get resource metrics for all plugins"""
|
|
if not api_v3.plugin_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
|
|
if not api_v3.plugin_manager.resource_monitor:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {},
|
|
'message': 'Resource monitoring not available'
|
|
})
|
|
|
|
monitor = api_v3.plugin_manager.resource_monitor
|
|
# Build per-plugin summaries by ID so persisted (cross-process) metrics
|
|
# are included, then fold in any in-memory-only entries.
|
|
metrics_summaries = {}
|
|
for pid in _installed_plugin_ids():
|
|
try:
|
|
# force_reload: read-only path — bypass the in-memory snapshot so
|
|
# each poll reflects the display service's latest persisted metrics.
|
|
metrics_summaries[pid] = monitor.get_metrics_summary(pid, force_reload=True)
|
|
except Exception:
|
|
logger.debug('Could not read metrics summary for %s', pid, exc_info=True)
|
|
try:
|
|
for pid, summary in monitor.get_all_metrics_summaries().items():
|
|
metrics_summaries.setdefault(pid, summary)
|
|
except Exception:
|
|
logger.debug('get_all_metrics_summaries failed', exc_info=True)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': metrics_summaries
|
|
})
|
|
|
|
|
|
@api_v3.route('/plugins/metrics/<plugin_id>', methods=['GET'])
|
|
def get_plugin_metrics_single(plugin_id):
|
|
"""Get resource metrics for a specific plugin"""
|
|
if not api_v3.plugin_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
|
|
if not api_v3.plugin_manager.resource_monitor:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Resource monitoring not available'
|
|
}), 503
|
|
|
|
# force_reload for the same reason as the list route above.
|
|
metrics_summary = api_v3.plugin_manager.resource_monitor.get_metrics_summary(
|
|
plugin_id, force_reload=True)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': metrics_summary
|
|
})
|
|
|
|
|
|
@api_v3.route('/plugins/metrics/<plugin_id>/reset', methods=['POST'])
|
|
def reset_plugin_metrics(plugin_id):
|
|
"""Reset metrics for a plugin.
|
|
|
|
Only the web process's copy and the persisted snapshot are cleared. The
|
|
display service keeps accumulating in its own process and republishes
|
|
its totals on its next persist, so the reset does not stick while it runs.
|
|
"""
|
|
if not api_v3.plugin_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
|
|
if not api_v3.plugin_manager.resource_monitor:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Resource monitoring not available'
|
|
}), 503
|
|
|
|
# Reset metrics
|
|
api_v3.plugin_manager.resource_monitor.reset_metrics(plugin_id)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': f'Metrics reset for plugin {plugin_id}'
|
|
})
|
|
|
|
|
|
@api_v3.route('/plugins/limits/<plugin_id>', methods=['GET', 'POST'])
|
|
def manage_plugin_limits(plugin_id):
|
|
"""Get or set resource limits for a plugin.
|
|
|
|
A POST updates the web process's monitor and the persisted record. The
|
|
display service reads persisted limits only until it has some for a
|
|
plugin, so a change to existing limits takes effect there after the
|
|
display service restarts.
|
|
"""
|
|
if not api_v3.plugin_manager:
|
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
|
|
if not api_v3.plugin_manager.resource_monitor:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Resource monitoring not available'
|
|
}), 503
|
|
|
|
if request.method == 'GET':
|
|
# Get limits
|
|
limits = api_v3.plugin_manager.resource_monitor.get_limits(plugin_id)
|
|
if limits:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {
|
|
'max_memory_mb': limits.max_memory_mb,
|
|
'max_cpu_percent': limits.max_cpu_percent,
|
|
'max_execution_time': limits.max_execution_time,
|
|
'warning_threshold': limits.warning_threshold
|
|
}
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': None,
|
|
'message': 'No limits configured for this plugin'
|
|
})
|
|
else:
|
|
# POST - Set limits
|
|
data = request.get_json(silent=True) or {}
|
|
from src.plugin_system.resource_monitor import invalid_limit_field, limits_from_dict
|
|
|
|
# Validate here: a string limit stored as-is made every later update
|
|
# of the plugin raise TypeError inside the resource monitor. The
|
|
# message is built from the field name, not from an exception.
|
|
bad = invalid_limit_field(data)
|
|
if bad == 'limits':
|
|
return jsonify({'status': 'error', 'message': 'Limits must be a JSON object'}), 400
|
|
if bad:
|
|
return jsonify({'status': 'error',
|
|
'message': f'{bad} must be a non-negative number or null'}), 400
|
|
limits = limits_from_dict(data)
|
|
|
|
api_v3.plugin_manager.resource_monitor.set_limits(plugin_id, limits)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': f'Resource limits updated for plugin {plugin_id}'
|
|
})
|