mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-09 16:46:37 +00:00
* chore: remove dead code, deprecate unused plugin APIs (over-engineering audit) Whole-tree audit. Every symbol was checked against core, the plugin monorepo and all eight third-party plugins in plugins.json first. - Deprecate (removal 3.10.0) plugin-facing methods nothing calls: LogoDownloader bulk download, ConfigManager backup/secret wrappers, APIHelper extras, BackgroundDataService poll API, PluginManager / PluginStateManager info readers, and a few CacheManager, FontManager, BaseOddsManager, DynamicTeamResolver methods and PluginTestCase. plugin_api_usage.py learns their receiver names; DEPRECATIONS doc regenerated. - Remove core-internal dead code: CacheMetrics, Vegas status/stats plumbing, sync "new cycle" message (followers ignore unknown types), unused operation types, test-only PluginCatalog readers, IPC to_dict and ping, _parse_form_value, CacheStrategyProtocol, ErrorAggregator callbacks, duplicate web response helpers. - Web UI: drop never-mounted json-file-manager.js, the example widget, utils/error_handler.js, four uncalled PluginAPI methods, and 29 escapeHtml shims (call window.LEDEscape directly). Public globals, BaseWidget and widget names unchanged. - Remove six one-off scripts (owner decision) and the unused markupsafe and pytest-mock pins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): calendar picker error text goes in a text node, not innerHTML Same output as the escaped innerHTML it replaces; clears Codacy's XSS-pattern alerts on the line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
208 lines
6.4 KiB
Python
208 lines
6.4 KiB
Python
"""
|
|
Operation history and audit log.
|
|
|
|
Tracks all plugin operations and configuration changes for debugging and auditing.
|
|
"""
|
|
|
|
import json
|
|
import threading
|
|
from typing import Dict, Any, List, Optional
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from dataclasses import dataclass, asdict
|
|
|
|
from src.config_manager_atomic import atomic_write_text
|
|
from src.logging_config import get_logger
|
|
|
|
|
|
@dataclass
|
|
class OperationRecord:
|
|
"""Record of an operation."""
|
|
operation_id: str
|
|
operation_type: str
|
|
plugin_id: Optional[str]
|
|
timestamp: datetime
|
|
status: str
|
|
user: Optional[str] = None
|
|
details: Optional[Dict[str, Any]] = None
|
|
error: Optional[str] = None
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert to dictionary for serialization."""
|
|
result = asdict(self)
|
|
result['timestamp'] = self.timestamp.isoformat()
|
|
return result
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> 'OperationRecord':
|
|
"""Create from dictionary."""
|
|
if isinstance(data.get('timestamp'), str):
|
|
data['timestamp'] = datetime.fromisoformat(data['timestamp'])
|
|
return cls(**data)
|
|
|
|
|
|
class OperationHistory:
|
|
"""
|
|
Operation history and audit log manager.
|
|
|
|
Tracks all plugin operations and configuration changes.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
history_file: Optional[str] = None,
|
|
max_records: int = 1000
|
|
):
|
|
"""
|
|
Initialize operation history. The history file is read on first use,
|
|
not here, so constructing this costs the web app's startup nothing.
|
|
|
|
Args:
|
|
history_file: Path to file for persisting history
|
|
max_records: Maximum number of records to keep
|
|
"""
|
|
self.logger = get_logger(__name__)
|
|
self.history_file = Path(history_file) if history_file else None
|
|
self.max_records = max_records
|
|
self._history_loaded = False
|
|
|
|
# In-memory history
|
|
self._history: List[OperationRecord] = []
|
|
self._lock = threading.RLock()
|
|
|
|
def _ensure_loaded(self) -> None:
|
|
"""Load the history file on first use."""
|
|
if not self._history_loaded and self.history_file and self.history_file.exists():
|
|
self._load_history()
|
|
self._history_loaded = True
|
|
|
|
def record_operation(
|
|
self,
|
|
operation_type: str,
|
|
plugin_id: Optional[str] = None,
|
|
status: str = "completed",
|
|
details: Optional[Dict[str, Any]] = None,
|
|
error: Optional[str] = None,
|
|
operation_id: Optional[str] = None
|
|
) -> str:
|
|
"""
|
|
Record an operation in history.
|
|
|
|
Args:
|
|
operation_type: Type of operation (install, update, uninstall, etc.)
|
|
plugin_id: Plugin identifier
|
|
status: Operation status
|
|
details: Optional operation details
|
|
error: Optional error message
|
|
operation_id: Optional operation ID
|
|
|
|
Returns:
|
|
Operation record ID
|
|
"""
|
|
self._ensure_loaded()
|
|
import uuid
|
|
record_id = operation_id or str(uuid.uuid4())
|
|
|
|
record = OperationRecord(
|
|
operation_id=record_id,
|
|
operation_type=operation_type,
|
|
plugin_id=plugin_id,
|
|
timestamp=datetime.now(),
|
|
status=status,
|
|
details=details,
|
|
error=error
|
|
)
|
|
|
|
with self._lock:
|
|
self._history.append(record)
|
|
|
|
# Trim history if needed
|
|
if len(self._history) > self.max_records:
|
|
self._history = self._history[-self.max_records:]
|
|
|
|
# Save to file
|
|
self._save_history()
|
|
|
|
return record_id
|
|
|
|
def get_history(
|
|
self,
|
|
limit: int = 100,
|
|
plugin_id: Optional[str] = None,
|
|
operation_type: Optional[str] = None
|
|
) -> List[OperationRecord]:
|
|
"""
|
|
Get operation history.
|
|
|
|
Args:
|
|
limit: Maximum number of records to return
|
|
plugin_id: Optional filter by plugin ID
|
|
operation_type: Optional filter by operation type
|
|
|
|
Returns:
|
|
List of operation records, sorted by timestamp (newest first)
|
|
"""
|
|
self._ensure_loaded()
|
|
with self._lock:
|
|
history = self._history.copy()
|
|
|
|
# Apply filters
|
|
if plugin_id:
|
|
history = [r for r in history if r.plugin_id == plugin_id]
|
|
|
|
if operation_type:
|
|
history = [r for r in history if r.operation_type == operation_type]
|
|
|
|
# Sort by timestamp (newest first)
|
|
history.sort(key=lambda r: r.timestamp, reverse=True)
|
|
|
|
return history[:limit]
|
|
|
|
def clear_history(self) -> None:
|
|
"""Clear all operation history records."""
|
|
with self._lock:
|
|
self._history.clear()
|
|
self._save_history()
|
|
self.logger.info("Operation history cleared")
|
|
|
|
def _save_history(self) -> None:
|
|
"""Save history to file."""
|
|
if not self.history_file:
|
|
return
|
|
|
|
try:
|
|
# Held across the write, and written via a temp file, so two
|
|
# threads saving at once can't interleave or truncate the file.
|
|
with self._lock:
|
|
history_data = [record.to_dict() for record in self._history]
|
|
|
|
# Ensure directory exists
|
|
self.history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Write to file
|
|
atomic_write_text(self.history_file, json.dumps(history_data, indent=2))
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error saving operation history: {e}", exc_info=True)
|
|
|
|
def _load_history(self) -> None:
|
|
"""Load history from file."""
|
|
if not self.history_file or not self.history_file.exists():
|
|
return
|
|
|
|
try:
|
|
with open(self.history_file, 'r') as f:
|
|
history_data = json.load(f)
|
|
|
|
with self._lock:
|
|
self._history = [
|
|
OperationRecord.from_dict(record_data)
|
|
for record_data in history_data
|
|
]
|
|
|
|
self.logger.info(f"Loaded {len(self._history)} operation records from file")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error loading operation history: {e}", exc_info=True)
|
|
|