mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 10:36:35 +00:00
* fix(errors): serve /api/v3/errors/* from the display service's aggregator The error aggregator is a per-process singleton and only the display service runs plugins, so only its aggregator records anything. The routes read the web process's own, empty one and always reported no errors. The display service now publishes a bounded snapshot of its aggregator to the shared cache (plugin_error_snapshot) from a daemon thread: at most once every 10 s and only when something changed, never raising into the caller. The routes read it and keep their response shapes, adding snapshot_available, generated_at and clear_pending; exception text has credentials redacted. POST /errors/clear writes a clear request (plugin_error_clear_request) that the display applies on its next 5 s tick via the new clear_before(), which keeps errors recorded after the cutoff and rebuilds the counts. Until the snapshot acknowledges the request, reads hide everything before the cutoff, so a snapshot written just before the click cannot bring errors back. Adds "all": true; cleared_count is null when only the display can know it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): show plugin errors in the Logs tab A compact panel under the log viewer: per-plugin error counts, repeating errors (type, count, affected plugins, a sample message, last seen) and a Clear button, with empty states for "no errors" and "display service hasn't reported yet". Polls every 15 s while the tab is active; all text goes through escapeHtml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: describe where plugin error reports come from and how clear works Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(errors): redact the published snapshot before clipping it Keeping only a traceback's tail (or clipping a message) could cut an `api_key=` marker off while keeping the secret after it, and the web side's redaction would then have nothing to match. The display now redacts every free-text field of the snapshot first. The patterns move to a Flask-free src/redaction.py so the display service can use them; redact_text in the web error handler uses the same function, unchanged in behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
4.1 KiB
Python
142 lines
4.1 KiB
Python
"""
|
|
Centralized error handling for web interface.
|
|
|
|
Provides helpers for consistent error responses across API endpoints.
|
|
"""
|
|
|
|
from typing import Any, Optional
|
|
from flask import jsonify
|
|
|
|
from src.web_interface.errors import (
|
|
WebInterfaceError, ErrorCode, ErrorCategory
|
|
)
|
|
from src.logging_config import get_logger
|
|
from src.redaction import redact_credentials
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
# Long enough for an errno string with a path, short enough not to dump a
|
|
# parser's worth of context into a JSON field.
|
|
_MAX_DETAIL_LENGTH = 400
|
|
|
|
|
|
def describe_exception(exc: BaseException,
|
|
max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
|
"""
|
|
One-line, safe-to-return description of an exception.
|
|
|
|
The generic "an error occurred; see logs for details" tells a user nothing
|
|
and, when the failure is bad enough, the logs are unreachable too: a device
|
|
whose storage was failing returned that message from every endpoint
|
|
*including* the log viewer, because journalctl could not be executed. The
|
|
underlying `[Errno 5] Input/output error` named the fault immediately.
|
|
|
|
Returns "TypeName: message", credentials redacted and length capped. The
|
|
type alone is worth carrying -- a bare PermissionError says more than any
|
|
generic sentence.
|
|
|
|
Args:
|
|
exc: The exception to describe
|
|
max_length: Truncate beyond this many characters
|
|
|
|
Returns:
|
|
A single-line description, never empty
|
|
"""
|
|
message = str(exc).strip()
|
|
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
|
|
return redact_text(text, max_length)
|
|
|
|
|
|
def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
|
"""Make arbitrary text safe to hand back over HTTP.
|
|
|
|
Split out of describe_exception because exceptions are not the only thing
|
|
worth returning: a subprocess's stderr, or a message a helper script
|
|
printed, is just as useful to a user and just as capable of carrying a
|
|
token or a password in it.
|
|
|
|
Args:
|
|
text: The text to redact
|
|
max_length: Truncate beyond this many characters
|
|
|
|
Returns:
|
|
A single line, credentials replaced, length capped.
|
|
"""
|
|
text = redact_credentials(text)
|
|
# Collapse newlines/tabs so the detail stays one line in a JSON field.
|
|
text = ' '.join(text.split())
|
|
if len(text) > max_length:
|
|
text = text[:max_length - 1].rstrip() + '…'
|
|
return text
|
|
|
|
|
|
def create_error_response(
|
|
error_code: ErrorCode,
|
|
message: str,
|
|
details: Optional[str] = None,
|
|
context: Optional[dict] = None,
|
|
suggested_fixes: Optional[list] = None,
|
|
status_code: int = 500
|
|
) -> tuple:
|
|
"""
|
|
Create a standardized error response.
|
|
|
|
Args:
|
|
error_code: Error code
|
|
message: Error message
|
|
details: Optional detailed error information
|
|
context: Optional context dictionary
|
|
suggested_fixes: Optional list of suggested fixes
|
|
status_code: HTTP status code
|
|
|
|
Returns:
|
|
Tuple of (jsonify response, status_code)
|
|
"""
|
|
error = WebInterfaceError(
|
|
error_code=error_code,
|
|
message=message,
|
|
details=details,
|
|
context=context or {},
|
|
suggested_fixes=suggested_fixes
|
|
)
|
|
|
|
return jsonify(error.to_dict()), status_code
|
|
|
|
|
|
def create_success_response(
|
|
data: Any = None,
|
|
message: Optional[str] = None,
|
|
metadata: Optional[dict] = None
|
|
) -> dict:
|
|
"""
|
|
Create a standardized success response.
|
|
|
|
Args:
|
|
data: Response data
|
|
message: Optional success message
|
|
metadata: Optional metadata (timing, version, etc.)
|
|
|
|
Returns:
|
|
Dictionary for jsonify
|
|
"""
|
|
response = {
|
|
"status": "success"
|
|
}
|
|
|
|
# All three use `is not None` rather than truthiness: "" and {} are
|
|
# values a caller chose to send, and dropping them silently would make
|
|
# the response shape depend on the data.
|
|
if data is not None:
|
|
response["data"] = data
|
|
|
|
if message is not None:
|
|
response["message"] = message
|
|
|
|
if metadata is not None:
|
|
response["metadata"] = metadata
|
|
|
|
return response
|
|
|