mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-05-25 21:43:32 +00:00
The operation history UI was reading from the wrong data source (operation_queue instead of operation_history), install/update records lacked version details, toggle operations used a type name that didn't match UI filters, and the Clear History button was non-functional. - Switch GET /plugins/operation/history to read from OperationHistory audit log with return type hint and targeted exception handling - Add DELETE /plugins/operation/history endpoint; wire up Clear button - Add _get_plugin_version helper with specific exception handling (FileNotFoundError, PermissionError, json.JSONDecodeError) and structured logging with plugin_id/path context - Record plugin version, branch, and commit details on install/update - Record install failures in the direct (non-queue) code path - Replace "toggle" operation type with "enable"/"disable" - Add normalizeStatus() in JS to map completed→success, error→failed so status filter works regardless of server-side convention - Truncate commit SHAs to 7 chars in details display - Fix HTML filter options, operation type colors, duplicate JS init Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
216 lines
6.6 KiB
Python
216 lines
6.6 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.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,
|
|
lazy_load: bool = False
|
|
):
|
|
"""
|
|
Initialize operation history.
|
|
|
|
Args:
|
|
history_file: Path to file for persisting history
|
|
max_records: Maximum number of records to keep
|
|
lazy_load: If True, defer loading history file until first access
|
|
"""
|
|
self.logger = get_logger(__name__)
|
|
self.history_file = Path(history_file) if history_file else None
|
|
self.max_records = max_records
|
|
self._lazy_load = lazy_load
|
|
self._history_loaded = False
|
|
|
|
# In-memory history
|
|
self._history: List[OperationRecord] = []
|
|
self._lock = threading.RLock()
|
|
|
|
# Load history from file if it exists (unless lazy loading)
|
|
if not self._lazy_load and self.history_file and self.history_file.exists():
|
|
self._load_history()
|
|
self._history_loaded = True
|
|
|
|
def _ensure_loaded(self) -> None:
|
|
"""Ensure history is loaded (for lazy loading)."""
|
|
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",
|
|
user: Optional[str] = None,
|
|
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
|
|
user: User who performed operation
|
|
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,
|
|
user=user,
|
|
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:
|
|
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
|
|
with open(self.history_file, 'w') as f:
|
|
json.dump(history_data, f, 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)
|
|
|