mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 17:16:36 +00:00
Merge origin/main into claude/remove-skins-and-base-classes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,23 @@ floor on the release that ships them):
|
|||||||
longer set `Accept-Encoding: ... br` by hand (brotli is not installed, so a
|
longer set `Accept-Encoding: ... br` by hand (brotli is not installed, so a
|
||||||
`br` response could not be decoded); requests picks the encodings.
|
`br` response could not be decoded); requests picks the encodings.
|
||||||
|
|
||||||
|
### Plugin error reporting
|
||||||
|
|
||||||
|
- `/api/v3/errors/summary` and `/api/v3/errors/plugin/<id>` report the errors
|
||||||
|
the display service recorded. They used to read the web process's own error
|
||||||
|
aggregator, which never records anything, so they always answered "no
|
||||||
|
errors". The display service now publishes a bounded snapshot to the shared
|
||||||
|
cache (`plugin_error_snapshot`, at most every 10 seconds and only on change;
|
||||||
|
`src/error_aggregator.py`, started from `DisplayController.__init__`).
|
||||||
|
Responses keep their shape and add `snapshot_available`, `generated_at` and
|
||||||
|
`clear_pending`; exception text has credentials redacted.
|
||||||
|
- `POST /api/v3/errors/clear` records a request (`plugin_error_clear_request`)
|
||||||
|
the display service applies within about 5 seconds; reads hide the cleared
|
||||||
|
errors at once. It accepts `"all": true`, and `cleared_count` can be `null`
|
||||||
|
when the count is only known to the display service.
|
||||||
|
- The Logs tab has a **Plugin errors** panel: per-plugin counts, repeating
|
||||||
|
errors and a Clear button.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- **The skin system.** Skins never rendered with the current scoreboard
|
- **The skin system.** Skins never rendered with the current scoreboard
|
||||||
|
|||||||
@@ -146,7 +146,10 @@ def display(self, force_clear: bool = False) -> bool:
|
|||||||
|
|
||||||
## Error Aggregation
|
## Error Aggregation
|
||||||
|
|
||||||
LEDMatrix automatically tracks plugin errors. Access error data via the API:
|
LEDMatrix automatically tracks plugin errors: every exception or timeout from
|
||||||
|
a plugin's `update()` or `display()` is recorded by the display service,
|
||||||
|
which runs the plugins. See them in the web interface under **Logs → Plugin
|
||||||
|
errors**, or through the API:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Get error summary
|
# Get error summary
|
||||||
@@ -155,10 +158,21 @@ curl http://localhost:5000/api/v3/errors/summary
|
|||||||
# Get plugin-specific health
|
# Get plugin-specific health
|
||||||
curl http://localhost:5000/api/v3/errors/plugin/my-plugin
|
curl http://localhost:5000/api/v3/errors/plugin/my-plugin
|
||||||
|
|
||||||
# Clear old errors
|
# Clear errors older than 24 hours (the default), or all of them
|
||||||
curl -X POST http://localhost:5000/api/v3/errors/clear
|
curl -X POST http://localhost:5000/api/v3/errors/clear
|
||||||
|
curl -X POST -H 'Content-Type: application/json' -d '{"all": true}' \
|
||||||
|
http://localhost:5000/api/v3/errors/clear
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The web interface is a separate process, so it reads a snapshot the display
|
||||||
|
service writes to the shared cache directory (`plugin_error_snapshot`): at most
|
||||||
|
every 10 seconds, and only when something changed. Expect the numbers to lag
|
||||||
|
by up to about 15 seconds, and to start from zero when the display service
|
||||||
|
restarts. `snapshot_available` is `false` until the display service has
|
||||||
|
reported. A clear is a request the display service applies within about 5
|
||||||
|
seconds; the API hides the cleared errors immediately. Details and response
|
||||||
|
shapes: [REST API reference](REST_API_REFERENCE.md#error-tracking).
|
||||||
|
|
||||||
### Error Patterns
|
### Error Patterns
|
||||||
|
|
||||||
When the same error occurs repeatedly (5+ times in 60 minutes), it's detected as a pattern and logged as a warning. This helps identify systemic issues.
|
When the same error occurs repeatedly (5+ times in 60 minutes), it's detected as a pattern and logged as a warning. This helps identify systemic issues.
|
||||||
|
|||||||
@@ -1814,25 +1814,73 @@ The last 100 journal lines for `ledmatrix.service` and
|
|||||||
|
|
||||||
## Error tracking
|
## Error tracking
|
||||||
|
|
||||||
|
Plugin errors are recorded by the display service (`ledmatrix.service`),
|
||||||
|
which runs the plugins. It publishes a snapshot to the shared cache directory
|
||||||
|
(`plugin_error_snapshot`) at most every 10 seconds, and only when something
|
||||||
|
changed, so these endpoints lag the display by up to about 15 seconds. The
|
||||||
|
counts cover the display service's current run: they start at zero when it
|
||||||
|
restarts. Error messages and stack traces have credentials redacted, and
|
||||||
|
messages, traces and context values are truncated in the snapshot.
|
||||||
|
|
||||||
|
Every response below adds three fields to the shape it always had:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `snapshot_available` | `false` until the display service has reported (for example, it is not running). Counts are then zero. |
|
||||||
|
| `generated_at` | When the display service produced the snapshot (ISO, the Pi's local time), or `null`. |
|
||||||
|
| `clear_pending` | A clear has been requested and the display service has not applied it yet. |
|
||||||
|
|
||||||
### Get Error Summary
|
### Get Error Summary
|
||||||
|
|
||||||
**GET** `/api/v3/errors/summary`
|
**GET** `/api/v3/errors/summary`
|
||||||
|
|
||||||
Aggregated counts, detected patterns and recent errors across plugins and
|
Aggregated counts, detected patterns and recent errors (the last 20).
|
||||||
core components.
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"session_start": "2026-09-23T09:40:02.118000",
|
||||||
|
"total_errors": 13,
|
||||||
|
"error_rate_per_hour": 41.2,
|
||||||
|
"error_counts_by_type": {"ConnectionError": 12, "ValueError": 1},
|
||||||
|
"plugin_error_counts": {"weather": {"ConnectionError": 12}, "stocks": {"ValueError": 1}},
|
||||||
|
"active_patterns": {
|
||||||
|
"ConnectionError": {
|
||||||
|
"error_type": "ConnectionError", "count": 12,
|
||||||
|
"first_seen": "2026-09-23T09:41:10.500000", "last_seen": "2026-09-23T09:58:36.020000",
|
||||||
|
"affected_plugins": ["weather"], "sample_messages": ["Read timed out."],
|
||||||
|
"severity": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recent_errors": [
|
||||||
|
{"error_type": "ValueError", "message": "could not parse price",
|
||||||
|
"timestamp": "2026-09-23T09:58:36.100000", "context": {},
|
||||||
|
"plugin_id": "stocks", "operation": "update", "stack_trace": "Traceback ..."}
|
||||||
|
],
|
||||||
|
"generated_at": "2026-09-23T09:58:40.000000",
|
||||||
|
"snapshot_available": true,
|
||||||
|
"clear_pending": false
|
||||||
|
},
|
||||||
|
"message": "Error summary retrieved"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Get Plugin Errors
|
### Get Plugin Errors
|
||||||
|
|
||||||
**GET** `/api/v3/errors/plugin/<plugin_id>`
|
**GET** `/api/v3/errors/plugin/<plugin_id>`
|
||||||
|
|
||||||
Error health and statistics for one plugin.
|
Error health and statistics for one plugin: `plugin_id`, `status`
|
||||||
|
(`healthy`, `degraded` or `unhealthy`), `total_errors`, `error_types`,
|
||||||
|
`recent_error_count`, `last_error` (a `recent_errors` entry or `null`), plus
|
||||||
|
the three fields above. A plugin with no recorded errors is `healthy`.
|
||||||
|
|
||||||
### Clear Errors
|
### Clear Errors
|
||||||
|
|
||||||
**POST** `/api/v3/errors/clear`
|
**POST** `/api/v3/errors/clear`
|
||||||
|
|
||||||
Clear error records older than `max_age_hours` (default 24, 1-8760).
|
Clear error records older than `max_age_hours` (default 24, 1-8760), or every
|
||||||
Returns `data.cleared_count`.
|
error with `"all": true` (`max_age_hours` is then ignored).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1840,6 +1888,32 @@ Returns `data.cleared_count`.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The clear is asynchronous. The web interface records a request
|
||||||
|
(`plugin_error_clear_request` in the shared cache), and the display service
|
||||||
|
applies it within about 5 seconds, rebuilding its counts from the errors it
|
||||||
|
keeps and republishing. Reads hide the cleared errors from the moment the
|
||||||
|
request is recorded. Until the display service applies an age-based clear,
|
||||||
|
`recent_errors` and `active_patterns` are already filtered but the counts
|
||||||
|
are the old ones, and `clear_pending` is `true`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"cleared_count": 13,
|
||||||
|
"clear_requested": true,
|
||||||
|
"request_id": "5f0c1e...",
|
||||||
|
"cutoff": "2026-09-23T09:59:02.310000"
|
||||||
|
},
|
||||||
|
"message": "Clear of all errors requested; the display service applies it within about 5 seconds"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`cleared_count` is how many of the reported errors the clear hides. It is
|
||||||
|
`null` when that cannot be known before the display service applies it (an
|
||||||
|
age-based clear over more errors than the report lists). A request that
|
||||||
|
could not be written to the shared cache answers `500`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Health and Status
|
## Health and Status
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ class DisplayController:
|
|||||||
self.config_manager = config_manager # Keep for backward compatibility
|
self.config_manager = config_manager # Keep for backward compatibility
|
||||||
self.config = self.config_service.get_config()
|
self.config = self.config_service.get_config()
|
||||||
self.cache_manager = CacheManager()
|
self.cache_manager = CacheManager()
|
||||||
|
# The web interface's /api/v3/errors/* read what this publishes.
|
||||||
|
from src.error_aggregator import start_error_snapshot_publisher
|
||||||
|
start_error_snapshot_publisher(self.cache_manager)
|
||||||
logger.info("Config loaded in %.3f seconds (hot-reload: %s)", time.time() - start_time, enable_hot_reload)
|
logger.info("Config loaded in %.3f seconds (hot-reload: %s)", time.time() - start_time, enable_hot_reload)
|
||||||
|
|
||||||
# Validate startup configuration
|
# Validate startup configuration
|
||||||
|
|||||||
+469
-1
@@ -9,17 +9,21 @@ This is a local-only implementation with no external dependencies.
|
|||||||
Errors are stored in memory with optional JSON export.
|
Errors are stored in memory with optional JSON export.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import json
|
import json
|
||||||
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Any, Callable
|
from typing import Dict, List, Optional, Any, Callable, Tuple
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from src.exceptions import LEDMatrixError
|
from src.exceptions import LEDMatrixError
|
||||||
|
from src.redaction import redact_credentials
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -115,6 +119,10 @@ class ErrorAggregator:
|
|||||||
# Track session start for relative timing
|
# Track session start for relative timing
|
||||||
self._session_start = datetime.now()
|
self._session_start = datetime.now()
|
||||||
|
|
||||||
|
# Bumped on every change, so a publisher can tell "nothing new since
|
||||||
|
# the last snapshot" without comparing snapshots.
|
||||||
|
self._version = 0
|
||||||
|
|
||||||
def record_error(
|
def record_error(
|
||||||
self,
|
self,
|
||||||
error: Exception,
|
error: Exception,
|
||||||
@@ -161,6 +169,7 @@ class ErrorAggregator:
|
|||||||
self._error_counts[error_type] += 1
|
self._error_counts[error_type] += 1
|
||||||
if plugin_id:
|
if plugin_id:
|
||||||
self._plugin_error_counts[plugin_id][error_type] += 1
|
self._plugin_error_counts[plugin_id][error_type] += 1
|
||||||
|
self._version += 1
|
||||||
|
|
||||||
# Check for patterns
|
# Check for patterns
|
||||||
self._detect_pattern(record)
|
self._detect_pattern(record)
|
||||||
@@ -331,6 +340,77 @@ class ErrorAggregator:
|
|||||||
|
|
||||||
return cleared
|
return cleared
|
||||||
|
|
||||||
|
@property
|
||||||
|
def version(self) -> int:
|
||||||
|
"""Changes whenever the recorded errors do (see ErrorSnapshotPublisher)."""
|
||||||
|
return self._version
|
||||||
|
|
||||||
|
def clear_before(self, cutoff: datetime) -> int:
|
||||||
|
"""Forget every error recorded at or before ``cutoff``.
|
||||||
|
|
||||||
|
Unlike clear_old_records, this also resets what the summary reports:
|
||||||
|
the per-type and per-plugin counts are rebuilt from the records that
|
||||||
|
remain, and detected patterns that began before the cutoff are dropped
|
||||||
|
(one that is still happening is detected again on its next
|
||||||
|
occurrence). Errors recorded after the
|
||||||
|
cutoff are kept, so a clear that is applied a few seconds after it was
|
||||||
|
requested does not swallow what happened in between.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of records removed
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
kept = [r for r in self._records if r.timestamp > cutoff]
|
||||||
|
cleared = len(self._records) - len(kept)
|
||||||
|
self._records = kept
|
||||||
|
self._error_counts = defaultdict(int)
|
||||||
|
self._plugin_error_counts = defaultdict(lambda: defaultdict(int))
|
||||||
|
for r in kept:
|
||||||
|
self._error_counts[r.error_type] += 1
|
||||||
|
if r.plugin_id:
|
||||||
|
self._plugin_error_counts[r.plugin_id][r.error_type] += 1
|
||||||
|
# A pattern that began after the cutoff is made only of kept
|
||||||
|
# errors; any other would carry cleared ones in its count.
|
||||||
|
self._patterns = {k: p for k, p in self._patterns.items() if p.first_seen > cutoff}
|
||||||
|
self._version += 1
|
||||||
|
return cleared
|
||||||
|
|
||||||
|
def build_snapshot(self) -> Dict[str, Any]:
|
||||||
|
"""A bounded, JSON-safe copy of the summary for another process.
|
||||||
|
|
||||||
|
The shape of get_error_summary() plus ``generated_at`` and
|
||||||
|
``plugin_health`` (get_plugin_health() for every plugin with errors).
|
||||||
|
Messages, stack traces and context are clipped so that one plugin
|
||||||
|
raising a huge exception cannot make the snapshot large.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
summary = self.get_error_summary()
|
||||||
|
summary["generated_at"] = datetime.now().isoformat()
|
||||||
|
summary["recent_errors"] = [
|
||||||
|
_compact_record(r) for r in summary["recent_errors"][-_SNAPSHOT_RECENT_ERRORS:]
|
||||||
|
]
|
||||||
|
patterns = {}
|
||||||
|
for key, pattern in list(summary["active_patterns"].items())[:_SNAPSHOT_MAX_PATTERNS]:
|
||||||
|
pattern = dict(pattern)
|
||||||
|
pattern["affected_plugins"] = [
|
||||||
|
_clip(p, _SNAPSHOT_ID_CHARS) for p in pattern.get("affected_plugins", [])
|
||||||
|
][:_SNAPSHOT_MAX_AFFECTED_PLUGINS]
|
||||||
|
pattern["sample_messages"] = [
|
||||||
|
_redacted_clip(m, _SNAPSHOT_SAMPLE_CHARS) for m in pattern.get("sample_messages", [])
|
||||||
|
][:3]
|
||||||
|
patterns[key] = pattern
|
||||||
|
summary["active_patterns"] = patterns
|
||||||
|
health = {}
|
||||||
|
for plugin_id in summary["plugin_error_counts"]:
|
||||||
|
entry = self.get_plugin_health(plugin_id)
|
||||||
|
if entry["last_error"] is not None:
|
||||||
|
entry["last_error"] = _compact_record(entry["last_error"])
|
||||||
|
health[plugin_id] = entry
|
||||||
|
summary["plugin_health"] = health
|
||||||
|
# Round-trip so a context value the cache's encoder cannot handle is
|
||||||
|
# turned into a string here rather than failing the write.
|
||||||
|
return json.loads(json.dumps(summary, default=str))
|
||||||
|
|
||||||
def export_to_file(self, filepath: Path) -> None:
|
def export_to_file(self, filepath: Path) -> None:
|
||||||
"""
|
"""
|
||||||
Export error data to JSON file.
|
Export error data to JSON file.
|
||||||
@@ -419,3 +499,391 @@ def record_error(
|
|||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
operation=operation
|
operation=operation
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sharing the display service's errors with the web interface
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The two services are separate processes, so each has its own aggregator,
|
||||||
|
# and only the display service's ever records anything (plugin_executor runs
|
||||||
|
# the plugins there). The web interface therefore reads a snapshot the display
|
||||||
|
# service publishes to the shared cache directory -- the same channel, and the
|
||||||
|
# same file permissions, as display_current_state and plugin_metrics:*: files
|
||||||
|
# are 0660 and carry the cache directory's group, so root writes and the web
|
||||||
|
# user reads, and the other way round for the clear request.
|
||||||
|
#
|
||||||
|
# ERROR_SNAPSHOT_KEY written by the display service only
|
||||||
|
# ERROR_CLEAR_REQUEST_KEY written by the web interface only
|
||||||
|
#
|
||||||
|
# A clear is asynchronous: the web interface records a request, and the
|
||||||
|
# display service applies it (clear_before) on its next tick and republishes.
|
||||||
|
# Until it has, the web interface hides whatever the snapshot shows from
|
||||||
|
# before the cutoff, so a clear takes effect for readers immediately and a
|
||||||
|
# snapshot published just before the request cannot bring old errors back.
|
||||||
|
# The web interface never writes the snapshot itself: two writers would race,
|
||||||
|
# and a snapshot owned by the web user is one more file root's write has to
|
||||||
|
# replace.
|
||||||
|
|
||||||
|
ERROR_SNAPSHOT_KEY = "plugin_error_snapshot"
|
||||||
|
ERROR_CLEAR_REQUEST_KEY = "plugin_error_clear_request"
|
||||||
|
|
||||||
|
#: Shortest gap between two snapshot writes, in seconds. A plugin failing in
|
||||||
|
#: a tight loop changes the aggregator many times a second; the snapshot is
|
||||||
|
#: rewritten at most this often, and only when something changed.
|
||||||
|
SNAPSHOT_MIN_INTERVAL = 10.0
|
||||||
|
|
||||||
|
#: How often the display service checks for changes and clear requests. A
|
||||||
|
#: check is an in-memory comparison plus reading one small file.
|
||||||
|
SNAPSHOT_TICK_INTERVAL = 5.0
|
||||||
|
|
||||||
|
_SNAPSHOT_RECENT_ERRORS = 20
|
||||||
|
_SNAPSHOT_MAX_PATTERNS = 50
|
||||||
|
_SNAPSHOT_MAX_AFFECTED_PLUGINS = 50
|
||||||
|
_SNAPSHOT_MESSAGE_CHARS = 300
|
||||||
|
_SNAPSHOT_SAMPLE_CHARS = 200
|
||||||
|
_SNAPSHOT_TRACE_CHARS = 1200
|
||||||
|
_SNAPSHOT_ID_CHARS = 100
|
||||||
|
_SNAPSHOT_CONTEXT_KEYS = 20
|
||||||
|
_SNAPSHOT_CONTEXT_VALUE_CHARS = 200
|
||||||
|
|
||||||
|
#: Fields of get_error_summary(), which is what /errors/summary has always
|
||||||
|
#: returned. The snapshot's extra bookkeeping stays out of that response.
|
||||||
|
_SUMMARY_FIELDS = (
|
||||||
|
"session_start", "total_errors", "error_rate_per_hour",
|
||||||
|
"error_counts_by_type", "plugin_error_counts", "active_patterns",
|
||||||
|
"recent_errors",
|
||||||
|
)
|
||||||
|
|
||||||
|
_snapshot_logger = logging.getLogger(__name__ + ".snapshot")
|
||||||
|
|
||||||
|
|
||||||
|
def _clip(value: Any, limit: int) -> str:
|
||||||
|
text = value if isinstance(value, str) else str(value)
|
||||||
|
return text if len(text) <= limit else text[:limit - 3] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def _redacted_clip(value: Any, limit: int) -> str:
|
||||||
|
"""Redact, then clip. Clipping first could cut a ``token=`` marker off
|
||||||
|
while keeping the secret after it, and the web side's redaction would then
|
||||||
|
have nothing to match."""
|
||||||
|
return _clip(redact_credentials(value if isinstance(value, str) else str(value)), limit)
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_record(record: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""An ErrorRecord dict with every free-text field redacted and bounded."""
|
||||||
|
compact = dict(record)
|
||||||
|
compact["message"] = _redacted_clip(record.get("message") or "", _SNAPSHOT_MESSAGE_CHARS)
|
||||||
|
if record.get("plugin_id") is not None:
|
||||||
|
compact["plugin_id"] = _clip(record["plugin_id"], _SNAPSHOT_ID_CHARS)
|
||||||
|
trace = record.get("stack_trace")
|
||||||
|
if isinstance(trace, str):
|
||||||
|
trace = redact_credentials(trace)
|
||||||
|
if len(trace) > _SNAPSHOT_TRACE_CHARS:
|
||||||
|
# The end of a traceback is the part that says what went wrong.
|
||||||
|
trace = "..." + trace[-(_SNAPSHOT_TRACE_CHARS - 3):]
|
||||||
|
compact["stack_trace"] = trace
|
||||||
|
context = record.get("context")
|
||||||
|
if isinstance(context, dict):
|
||||||
|
compact["context"] = {
|
||||||
|
_clip(k, _SNAPSHOT_ID_CHARS): (
|
||||||
|
v if v is None or isinstance(v, (bool, int, float))
|
||||||
|
else _redacted_clip(v, _SNAPSHOT_CONTEXT_VALUE_CHARS)
|
||||||
|
)
|
||||||
|
for k, v in list(context.items())[:_SNAPSHOT_CONTEXT_KEYS]
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
compact["context"] = {}
|
||||||
|
return compact
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorSnapshotPublisher:
|
||||||
|
"""Publishes the display service's aggregator to the shared cache.
|
||||||
|
|
||||||
|
Runs in the display service only. tick() is the whole job; start() just
|
||||||
|
calls it from a daemon thread every SNAPSHOT_TICK_INTERVAL seconds, which
|
||||||
|
also means errors recorded while a write was being throttled still reach
|
||||||
|
the cache once the interval has passed, and a clear request is applied
|
||||||
|
even when no new error arrives to trigger a publish.
|
||||||
|
|
||||||
|
Nothing here raises: a failure to read or write the cache is logged at
|
||||||
|
debug and retried on a later tick.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cache_manager: Any, aggregator: Optional[ErrorAggregator] = None,
|
||||||
|
min_interval: float = SNAPSHOT_MIN_INTERVAL,
|
||||||
|
clock: Callable[[], float] = time.monotonic) -> None:
|
||||||
|
self.cache_manager = cache_manager
|
||||||
|
self.aggregator = aggregator or get_error_aggregator()
|
||||||
|
self.min_interval = min_interval
|
||||||
|
self._clock = clock
|
||||||
|
# None forces a first publish, which replaces a snapshot left behind
|
||||||
|
# by a previous run of the service with this run's (empty) one.
|
||||||
|
self._published_version: Optional[int] = None
|
||||||
|
self._last_attempt: Optional[float] = None
|
||||||
|
self._applied_clear_id: Optional[str] = None
|
||||||
|
self._tick_lock = threading.Lock()
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
def _apply_clear_request(self) -> bool:
|
||||||
|
"""Honour a clear request we have not applied yet. True if one was."""
|
||||||
|
request = self.cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||||
|
if not isinstance(request, dict):
|
||||||
|
return False
|
||||||
|
request_id = request.get("request_id")
|
||||||
|
if not isinstance(request_id, str) or not request_id or request_id == self._applied_clear_id:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
cutoff = float(request.get("cutoff"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
cutoff = float("nan")
|
||||||
|
if math.isfinite(cutoff):
|
||||||
|
cleared = self.aggregator.clear_before(datetime.fromtimestamp(cutoff))
|
||||||
|
_snapshot_logger.info("Cleared %d plugin error record(s) as requested (%s)",
|
||||||
|
cleared, request_id)
|
||||||
|
# A malformed request is acknowledged too, so it is not retried forever.
|
||||||
|
self._applied_clear_id = request_id
|
||||||
|
return True
|
||||||
|
|
||||||
|
def tick(self) -> bool:
|
||||||
|
"""Apply a pending clear and publish if due. True if a snapshot was written."""
|
||||||
|
with self._tick_lock:
|
||||||
|
try:
|
||||||
|
cleared = self._apply_clear_request()
|
||||||
|
version = self.aggregator.version
|
||||||
|
now = self._clock()
|
||||||
|
if not cleared:
|
||||||
|
if version == self._published_version:
|
||||||
|
return False
|
||||||
|
if (self._last_attempt is not None
|
||||||
|
and now - self._last_attempt < self.min_interval):
|
||||||
|
return False
|
||||||
|
# Stamp the attempt before writing: a cache that keeps failing
|
||||||
|
# is retried at the throttled rate, not on every tick.
|
||||||
|
self._last_attempt = now
|
||||||
|
snapshot = self.aggregator.build_snapshot()
|
||||||
|
snapshot["applied_clear_id"] = self._applied_clear_id
|
||||||
|
self.cache_manager.set(ERROR_SNAPSHOT_KEY, snapshot)
|
||||||
|
self._published_version = version
|
||||||
|
return True
|
||||||
|
except Exception as err: # never let reporting break the display
|
||||||
|
_snapshot_logger.debug("Could not publish the plugin error snapshot: %s",
|
||||||
|
err, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def start(self, interval: float = SNAPSHOT_TICK_INTERVAL) -> None:
|
||||||
|
"""Tick from a daemon thread until stop(). A no-op while running."""
|
||||||
|
if self._thread is not None and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
self.tick()
|
||||||
|
while not self._stop.wait(interval):
|
||||||
|
self.tick()
|
||||||
|
|
||||||
|
self._thread = threading.Thread(target=run, name="error-snapshot-publisher", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.join(timeout=2)
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
|
||||||
|
_snapshot_publisher: Optional[ErrorSnapshotPublisher] = None
|
||||||
|
_snapshot_publisher_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def start_error_snapshot_publisher(cache_manager: Any) -> Optional[ErrorSnapshotPublisher]:
|
||||||
|
"""Start publishing this process's errors for the web interface.
|
||||||
|
|
||||||
|
Call from the display service only: whichever process calls it becomes
|
||||||
|
the source of /api/v3/errors/*. Idempotent; never raises.
|
||||||
|
"""
|
||||||
|
global _snapshot_publisher
|
||||||
|
try:
|
||||||
|
with _snapshot_publisher_lock:
|
||||||
|
if _snapshot_publisher is None:
|
||||||
|
_snapshot_publisher = ErrorSnapshotPublisher(cache_manager)
|
||||||
|
else:
|
||||||
|
_snapshot_publisher.cache_manager = cache_manager
|
||||||
|
_snapshot_publisher.start()
|
||||||
|
return _snapshot_publisher
|
||||||
|
except Exception as err:
|
||||||
|
_snapshot_logger.warning("Plugin error reporting to the web interface is unavailable: %s", err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Reading side (web interface) -------------------------------------------
|
||||||
|
|
||||||
|
def read_error_report(cache_manager: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
|
"""The display service's latest snapshot and the latest clear request.
|
||||||
|
|
||||||
|
memory_ttl=0: both keys are written by the other process, so only the
|
||||||
|
file is current.
|
||||||
|
"""
|
||||||
|
snapshot = cache_manager.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||||
|
clear_request = cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||||
|
return (snapshot if isinstance(snapshot, dict) else None,
|
||||||
|
clear_request if isinstance(clear_request, dict) else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _epoch(iso: Any) -> Optional[float]:
|
||||||
|
"""Seconds since the epoch for an aggregator timestamp (local, naive)."""
|
||||||
|
if not isinstance(iso, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(iso).timestamp()
|
||||||
|
except (ValueError, OverflowError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_cutoff(snapshot: Optional[Dict[str, Any]],
|
||||||
|
clear_request: Optional[Dict[str, Any]]) -> Optional[float]:
|
||||||
|
"""The cutoff of a clear the snapshot has not applied yet, if any."""
|
||||||
|
if not clear_request:
|
||||||
|
return None
|
||||||
|
request_id = clear_request.get("request_id")
|
||||||
|
if not request_id:
|
||||||
|
return None
|
||||||
|
if snapshot is not None and snapshot.get("applied_clear_id") == request_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
cutoff = float(clear_request.get("cutoff"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return cutoff if math.isfinite(cutoff) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_after(item: Any, field_name: str, cutoff: float) -> bool:
|
||||||
|
when = _epoch(item.get(field_name)) if isinstance(item, dict) else None
|
||||||
|
return when is not None and when > cutoff
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_summary(snapshot: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"session_start": snapshot.get("session_start") if snapshot else None,
|
||||||
|
"total_errors": 0,
|
||||||
|
"error_rate_per_hour": 0.0,
|
||||||
|
"error_counts_by_type": {},
|
||||||
|
"plugin_error_counts": {},
|
||||||
|
"active_patterns": {},
|
||||||
|
"recent_errors": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def error_summary_from_report(snapshot: Optional[Dict[str, Any]],
|
||||||
|
clear_request: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""The /errors/summary payload: get_error_summary()'s shape plus
|
||||||
|
``generated_at``, ``snapshot_available`` and ``clear_pending``."""
|
||||||
|
cutoff = _pending_cutoff(snapshot, clear_request)
|
||||||
|
summary = _empty_summary(snapshot)
|
||||||
|
if snapshot is not None:
|
||||||
|
for name, default in summary.items():
|
||||||
|
value = snapshot.get(name)
|
||||||
|
if isinstance(value, type(default)) or (
|
||||||
|
isinstance(default, float) and isinstance(value, int)) or (
|
||||||
|
name == "session_start" and isinstance(value, str)):
|
||||||
|
summary[name] = value
|
||||||
|
if cutoff is not None:
|
||||||
|
recent = summary["recent_errors"]
|
||||||
|
newest = _epoch(recent[-1].get("timestamp")) if recent and isinstance(recent[-1], dict) else None
|
||||||
|
if newest is None or newest <= cutoff:
|
||||||
|
# Everything the display has reported predates the clear.
|
||||||
|
summary = _empty_summary(snapshot)
|
||||||
|
else:
|
||||||
|
# Only part of it does. The lists can be filtered exactly; the
|
||||||
|
# counts cannot, and stay as reported until the display
|
||||||
|
# applies the clear (clear_pending says so).
|
||||||
|
summary["recent_errors"] = [r for r in recent if _is_after(r, "timestamp", cutoff)]
|
||||||
|
summary["active_patterns"] = {
|
||||||
|
k: p for k, p in summary["active_patterns"].items()
|
||||||
|
if _is_after(p, "last_seen", cutoff)
|
||||||
|
}
|
||||||
|
summary["generated_at"] = snapshot.get("generated_at") if snapshot else None
|
||||||
|
summary["snapshot_available"] = snapshot is not None
|
||||||
|
summary["clear_pending"] = cutoff is not None
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_health_from_report(snapshot: Optional[Dict[str, Any]],
|
||||||
|
clear_request: Optional[Dict[str, Any]],
|
||||||
|
plugin_id: str) -> Dict[str, Any]:
|
||||||
|
"""The /errors/plugin/<id> payload: get_plugin_health()'s shape plus
|
||||||
|
``generated_at``, ``snapshot_available`` and ``clear_pending``."""
|
||||||
|
health: Dict[str, Any] = {
|
||||||
|
"plugin_id": plugin_id,
|
||||||
|
"status": "healthy",
|
||||||
|
"total_errors": 0,
|
||||||
|
"error_types": {},
|
||||||
|
"recent_error_count": 0,
|
||||||
|
"last_error": None,
|
||||||
|
}
|
||||||
|
cutoff = _pending_cutoff(snapshot, clear_request)
|
||||||
|
table = snapshot.get("plugin_health") if snapshot else None
|
||||||
|
entry = table.get(plugin_id) if isinstance(table, dict) else None
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
# last_error is the plugin's newest error: if even that predates a
|
||||||
|
# pending clear, so does everything else the display reported for it.
|
||||||
|
if cutoff is None or _is_after(entry.get("last_error"), "timestamp", cutoff):
|
||||||
|
for name in ("status", "total_errors", "error_types", "recent_error_count", "last_error"):
|
||||||
|
if name in entry:
|
||||||
|
health[name] = entry[name]
|
||||||
|
health["generated_at"] = snapshot.get("generated_at") if snapshot else None
|
||||||
|
health["snapshot_available"] = snapshot is not None
|
||||||
|
health["clear_pending"] = cutoff is not None
|
||||||
|
return health
|
||||||
|
|
||||||
|
|
||||||
|
def _count_cleared(summary: Dict[str, Any], cutoff: float) -> Optional[int]:
|
||||||
|
"""How many of the reported errors a clear at ``cutoff`` hides, if known.
|
||||||
|
|
||||||
|
Exact when every reported error predates the cutoff (always the case for
|
||||||
|
a clear of everything) or when the report lists every error; otherwise
|
||||||
|
only the display service knows, and None says so.
|
||||||
|
"""
|
||||||
|
total = summary["total_errors"]
|
||||||
|
times = [_epoch(r.get("timestamp")) if isinstance(r, dict) else None
|
||||||
|
for r in summary["recent_errors"]]
|
||||||
|
if total == 0 or (times and times[-1] is not None and times[-1] <= cutoff):
|
||||||
|
return total
|
||||||
|
if len(times) >= total and None not in times:
|
||||||
|
return sum(1 for t in times if t <= cutoff)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def request_error_clear(cache_manager: Any, cutoff: float) -> Dict[str, Any]:
|
||||||
|
"""Ask the display service to forget errors recorded at or before ``cutoff``.
|
||||||
|
|
||||||
|
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``
|
||||||
|
(see _count_cleared) and ``clear_requested``. Raises OSError when the
|
||||||
|
request did not reach the shared cache, since a cache without a usable
|
||||||
|
directory accepts set() and keeps nothing.
|
||||||
|
|
||||||
|
A request the display has not applied yet is only ever widened: a later,
|
||||||
|
narrower one ("older than 24 hours" after "everything") overwriting it
|
||||||
|
would otherwise bring back the errors the first one hid.
|
||||||
|
"""
|
||||||
|
snapshot, clear_request = read_error_report(cache_manager)
|
||||||
|
pending = _pending_cutoff(snapshot, clear_request)
|
||||||
|
if pending is not None:
|
||||||
|
cutoff = max(cutoff, pending)
|
||||||
|
before = error_summary_from_report(snapshot, clear_request)
|
||||||
|
request = {
|
||||||
|
"request_id": uuid.uuid4().hex,
|
||||||
|
"cutoff": cutoff,
|
||||||
|
"requested_at": time.time(),
|
||||||
|
}
|
||||||
|
cache_manager.set(ERROR_CLEAR_REQUEST_KEY, request)
|
||||||
|
stored = cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||||
|
if not isinstance(stored, dict) or stored.get("request_id") != request["request_id"]:
|
||||||
|
raise OSError("the clear request was not stored in the shared cache")
|
||||||
|
return {
|
||||||
|
"cleared_count": _count_cleared(before, cutoff),
|
||||||
|
"clear_requested": True,
|
||||||
|
"request_id": request["request_id"],
|
||||||
|
"cutoff": datetime.fromtimestamp(cutoff).isoformat(),
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Credential redaction for text that leaves the process that produced it.
|
||||||
|
|
||||||
|
Kept free of Flask so the display service can redact what it publishes (see
|
||||||
|
src/error_aggregator.py) as well as the web interface what it returns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Credentials that turn up inside exception text. A requests error quotes the
|
||||||
|
# URL it failed on, and plugins that authenticate by query string put their key
|
||||||
|
# there, so echoing an exception verbatim can hand out an API key. Redact the
|
||||||
|
# value, keep the parameter name -- knowing *which* credential was involved is
|
||||||
|
# part of the diagnosis.
|
||||||
|
_REDACT_CREDENTIAL = re.compile(
|
||||||
|
r'((?:api[_-]?key|access[_-]?token|auth|apikey|key|passwd|password|pwd|'
|
||||||
|
r'secret|sig|signature|token)["\']?\s*[=:]\s*["\']?)([^\s&"\'<>,}]+)',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# `Authorization: <scheme> <credential>`. The scheme name is kept because it
|
||||||
|
# says which kind of credential failed; the credential goes. Any scheme
|
||||||
|
# matches, not a fixed list: ApiKey, Negotiate, NTLM, AWS4-HMAC-SHA256 and
|
||||||
|
# whatever a plugin's API invents next are all credentials, and a list would
|
||||||
|
# silently leak the ones nobody thought of. Not covered by the generic pattern
|
||||||
|
# above, whose value part stops at whitespace and so would keep the credential
|
||||||
|
# once a space follows the scheme.
|
||||||
|
_REDACT_AUTH_HEADER = re.compile(
|
||||||
|
r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*'
|
||||||
|
r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept
|
||||||
|
r'([^\s,"\'<>}]+)', # the credential, redacted
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Credentials embedded in a URL: https://user:password@host. requests quotes
|
||||||
|
# the full URL in its exceptions, so this is a realistic leak. The username is
|
||||||
|
# kept -- it identifies which account failed without being the secret.
|
||||||
|
_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
|
||||||
|
re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def redact_credentials(text: str) -> str:
|
||||||
|
"""Replace credentials in ``text`` with ``<redacted>``; keep everything
|
||||||
|
else, including line breaks, so a stack trace stays readable."""
|
||||||
|
text = text or ''
|
||||||
|
# Order matters: the URL and header forms are more specific than the
|
||||||
|
# generic key=value pattern, which would otherwise chew the scheme.
|
||||||
|
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
|
||||||
|
text = _REDACT_AUTH_HEADER.sub(r'\1<redacted>', text)
|
||||||
|
return _REDACT_CREDENTIAL.sub(r'\1<redacted>', text)
|
||||||
@@ -4,7 +4,6 @@ Centralized error handling for web interface.
|
|||||||
Provides helpers for consistent error responses across API endpoints.
|
Provides helpers for consistent error responses across API endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
|
|
||||||
@@ -12,42 +11,12 @@ from src.web_interface.errors import (
|
|||||||
WebInterfaceError, ErrorCode, ErrorCategory
|
WebInterfaceError, ErrorCode, ErrorCategory
|
||||||
)
|
)
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
from src.redaction import redact_credentials
|
||||||
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Credentials that turn up inside exception text. A requests error quotes the
|
|
||||||
# URL it failed on, and plugins that authenticate by query string put their key
|
|
||||||
# there, so echoing an exception verbatim can hand out an API key. Redact the
|
|
||||||
# value, keep the parameter name -- knowing *which* credential was involved is
|
|
||||||
# part of the diagnosis.
|
|
||||||
_REDACT_CREDENTIAL = re.compile(
|
|
||||||
r'((?:api[_-]?key|access[_-]?token|auth|apikey|key|passwd|password|pwd|'
|
|
||||||
r'secret|sig|signature|token)["\']?\s*[=:]\s*["\']?)([^\s&"\'<>,}]+)',
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# `Authorization: <scheme> <credential>`. The scheme name is kept because it
|
|
||||||
# says which kind of credential failed; the credential goes. Any scheme
|
|
||||||
# matches, not a fixed list: ApiKey, Negotiate, NTLM, AWS4-HMAC-SHA256 and
|
|
||||||
# whatever a plugin's API invents next are all credentials, and a list would
|
|
||||||
# silently leak the ones nobody thought of. Not covered by the generic pattern
|
|
||||||
# above, whose value part stops at whitespace and so would keep the credential
|
|
||||||
# once a space follows the scheme.
|
|
||||||
_REDACT_AUTH_HEADER = re.compile(
|
|
||||||
r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*'
|
|
||||||
r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept
|
|
||||||
r'([^\s,"\'<>}]+)', # the credential, redacted
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Credentials embedded in a URL: https://user:password@host. requests quotes
|
|
||||||
# the full URL in its exceptions, so this is a realistic leak. The username is
|
|
||||||
# kept -- it identifies which account failed without being the secret.
|
|
||||||
_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
|
|
||||||
re.IGNORECASE)
|
|
||||||
|
|
||||||
# Long enough for an errno string with a path, short enough not to dump a
|
# Long enough for an errno string with a path, short enough not to dump a
|
||||||
# parser's worth of context into a JSON field.
|
# parser's worth of context into a JSON field.
|
||||||
_MAX_DETAIL_LENGTH = 400
|
_MAX_DETAIL_LENGTH = 400
|
||||||
@@ -95,12 +64,7 @@ def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
A single line, credentials replaced, length capped.
|
A single line, credentials replaced, length capped.
|
||||||
"""
|
"""
|
||||||
text = text or ''
|
text = redact_credentials(text)
|
||||||
# Order matters: the URL and header forms are more specific than the
|
|
||||||
# generic key=value pattern, which would otherwise chew the scheme.
|
|
||||||
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
|
|
||||||
text = _REDACT_AUTH_HEADER.sub(r'\1<redacted>', text)
|
|
||||||
text = _REDACT_CREDENTIAL.sub(r'\1<redacted>', text)
|
|
||||||
# Collapse newlines/tabs so the detail stays one line in a JSON field.
|
# Collapse newlines/tabs so the detail stays one line in a JSON field.
|
||||||
text = ' '.join(text.split())
|
text = ' '.join(text.split())
|
||||||
if len(text) > max_length:
|
if len(text) > max_length:
|
||||||
|
|||||||
@@ -0,0 +1,443 @@
|
|||||||
|
"""/api/v3/errors/* report the display service's errors, not the web's.
|
||||||
|
|
||||||
|
The error aggregator is a per-process singleton, and only the display service
|
||||||
|
runs plugins, so only its aggregator ever records anything. The routes used to
|
||||||
|
read the web process's own aggregator and so always answered "no errors".
|
||||||
|
|
||||||
|
Here the two services are two ErrorAggregator instances and two CacheManagers
|
||||||
|
over one temporary directory -- the same arrangement as the real services,
|
||||||
|
which share /var/cache/ledmatrix. The display side publishes through
|
||||||
|
ErrorSnapshotPublisher.tick(); the web side is the real blueprint.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.cache_manager import CacheManager # noqa: E402
|
||||||
|
from src import error_aggregator as errors # noqa: E402
|
||||||
|
from src.error_aggregator import ( # noqa: E402
|
||||||
|
ERROR_CLEAR_REQUEST_KEY, ERROR_SNAPSHOT_KEY, ErrorAggregator,
|
||||||
|
ErrorSnapshotPublisher,
|
||||||
|
)
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self):
|
||||||
|
self.now = 1000.0
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(aggregator, plugin_id="p1", message="boom", exc=ValueError, **kwargs):
|
||||||
|
try:
|
||||||
|
raise exc(message)
|
||||||
|
except Exception as e: # noqa: BLE001 - recording is the point
|
||||||
|
return aggregator.record_error(e, plugin_id=plugin_id, operation="update", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shared_cache(tmp_path, monkeypatch):
|
||||||
|
"""Two cache managers over one directory: the display's and the web's."""
|
||||||
|
monkeypatch.setattr(CacheManager, "_get_writable_cache_dir", lambda self: str(tmp_path))
|
||||||
|
display_cache, web_cache = CacheManager(), CacheManager()
|
||||||
|
yield display_cache, web_cache, tmp_path
|
||||||
|
display_cache.stop_cleanup_thread()
|
||||||
|
web_cache.stop_cleanup_thread()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def display(shared_cache):
|
||||||
|
display_cache, _, _ = shared_cache
|
||||||
|
aggregator = ErrorAggregator()
|
||||||
|
clock = FakeClock()
|
||||||
|
publisher = ErrorSnapshotPublisher(display_cache, aggregator=aggregator, clock=clock)
|
||||||
|
return aggregator, publisher, clock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def web(api_v3_module, api_v3_client, shared_cache): # noqa: F811
|
||||||
|
_, web_cache, _ = shared_cache
|
||||||
|
api_v3_module.api_v3.cache_manager = web_cache
|
||||||
|
return api_v3_client
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(client):
|
||||||
|
response = client.get("/api/v3/errors/summary")
|
||||||
|
assert response.status_code == 200, response.get_data(as_text=True)
|
||||||
|
return response.get_json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Display side -----------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPublishing:
|
||||||
|
def test_errors_reach_the_shared_cache(self, display, shared_cache):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_, web_cache, _ = shared_cache
|
||||||
|
_fail(aggregator)
|
||||||
|
assert publisher.tick() is True
|
||||||
|
snapshot = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||||
|
assert snapshot["total_errors"] == 1
|
||||||
|
assert snapshot["plugin_error_counts"] == {"p1": {"ValueError": 1}}
|
||||||
|
assert datetime.fromisoformat(snapshot["generated_at"])
|
||||||
|
|
||||||
|
def test_first_tick_publishes_even_with_no_errors(self, display, shared_cache):
|
||||||
|
# Replaces whatever a previous run of the display service left behind.
|
||||||
|
_, web_cache, _ = shared_cache
|
||||||
|
web_cache.set(ERROR_SNAPSHOT_KEY, {"total_errors": 99})
|
||||||
|
_, publisher, _ = display
|
||||||
|
assert publisher.tick() is True
|
||||||
|
assert web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)["total_errors"] == 0
|
||||||
|
|
||||||
|
def test_nothing_is_written_when_nothing_changed(self, display, shared_cache):
|
||||||
|
aggregator, publisher, clock = display
|
||||||
|
publisher.tick()
|
||||||
|
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
|
||||||
|
clock.now += 3600
|
||||||
|
assert publisher.tick() is False
|
||||||
|
publisher.cache_manager.set.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_tight_failure_loop_is_throttled(self, display):
|
||||||
|
aggregator, publisher, clock = display
|
||||||
|
_fail(aggregator)
|
||||||
|
assert publisher.tick() is True
|
||||||
|
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
|
||||||
|
for _ in range(50):
|
||||||
|
_fail(aggregator)
|
||||||
|
clock.now += 0.1
|
||||||
|
publisher.tick()
|
||||||
|
publisher.cache_manager.set.assert_not_called()
|
||||||
|
# Once the interval has passed, the backlog is published without a
|
||||||
|
# new error having to arrive.
|
||||||
|
clock.now += errors.SNAPSHOT_MIN_INTERVAL
|
||||||
|
assert publisher.tick() is True
|
||||||
|
assert publisher.cache_manager.set.call_count == 1
|
||||||
|
assert publisher.cache_manager.set.call_args[0][1]["total_errors"] == 51
|
||||||
|
|
||||||
|
def test_a_failing_cache_never_raises(self, display):
|
||||||
|
aggregator, publisher, clock = display
|
||||||
|
broken = MagicMock()
|
||||||
|
broken.get.side_effect = OSError("read-only file system")
|
||||||
|
broken.set.side_effect = OSError("read-only file system")
|
||||||
|
publisher.cache_manager = broken
|
||||||
|
_fail(aggregator)
|
||||||
|
assert publisher.tick() is False
|
||||||
|
broken.get.side_effect = None
|
||||||
|
broken.get.return_value = None
|
||||||
|
assert publisher.tick() is False # set still fails
|
||||||
|
# ...and retries at the throttled rate, not every tick.
|
||||||
|
calls = broken.set.call_count
|
||||||
|
clock.now += 1
|
||||||
|
publisher.tick()
|
||||||
|
assert broken.set.call_count == calls
|
||||||
|
|
||||||
|
def test_an_unserialisable_context_does_not_break_the_snapshot(self, display, shared_cache):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_fail(aggregator, context={"obj": object(), "n": 3})
|
||||||
|
assert publisher.tick() is True
|
||||||
|
_, web_cache, _ = shared_cache
|
||||||
|
snap = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||||
|
assert snap["recent_errors"][0]["context"]["n"] == 3
|
||||||
|
|
||||||
|
def test_snapshot_stays_small(self, display):
|
||||||
|
aggregator, _, _ = display
|
||||||
|
for i in range(300):
|
||||||
|
_fail(aggregator, plugin_id=f"plugin-{i % 5}", message="x" * 20000,
|
||||||
|
context={f"k{j}": "v" * 5000 for j in range(50)})
|
||||||
|
snapshot = aggregator.build_snapshot()
|
||||||
|
assert len(snapshot["recent_errors"]) == 20
|
||||||
|
assert all(len(r["message"]) <= 300 for r in snapshot["recent_errors"])
|
||||||
|
assert len(json.dumps(snapshot)) < 200_000
|
||||||
|
|
||||||
|
def test_start_publishes_from_a_background_thread(self, shared_cache):
|
||||||
|
display_cache, web_cache, _ = shared_cache
|
||||||
|
aggregator = ErrorAggregator()
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher = ErrorSnapshotPublisher(display_cache, aggregator=aggregator)
|
||||||
|
try:
|
||||||
|
publisher.start(interval=0.05)
|
||||||
|
deadline = datetime.now() + timedelta(seconds=5)
|
||||||
|
snapshot = None
|
||||||
|
while snapshot is None and datetime.now() < deadline:
|
||||||
|
snapshot = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||||
|
assert snapshot and snapshot["total_errors"] == 1
|
||||||
|
finally:
|
||||||
|
publisher.stop()
|
||||||
|
|
||||||
|
def test_start_helper_never_raises(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(errors, "_snapshot_publisher", None)
|
||||||
|
|
||||||
|
def explode(*a, **k):
|
||||||
|
raise RuntimeError("no threads for you")
|
||||||
|
|
||||||
|
monkeypatch.setattr(errors.ErrorSnapshotPublisher, "start", explode)
|
||||||
|
assert errors.start_error_snapshot_publisher(MagicMock()) is None
|
||||||
|
|
||||||
|
def test_display_controller_starts_the_publisher(self):
|
||||||
|
# The display service is the only process that runs plugins, so it is
|
||||||
|
# the one that must publish; the web process must not.
|
||||||
|
# Read, not imported: display_controller needs rgbmatrix.
|
||||||
|
root = Path(__file__).parent.parent
|
||||||
|
controller = (root / "src" / "display_controller.py").read_text(encoding="utf-8")
|
||||||
|
init = controller.split(" def __init__(self):", 1)[1].split("\n def ", 1)[0]
|
||||||
|
assert "start_error_snapshot_publisher(self.cache_manager)" in init
|
||||||
|
web_dir = root / "web_interface"
|
||||||
|
for source in web_dir.rglob("*.py"):
|
||||||
|
assert "start_error_snapshot_publisher" not in source.read_text(encoding="utf-8"), source
|
||||||
|
|
||||||
|
|
||||||
|
class TestClearBefore:
|
||||||
|
def test_keeps_later_errors_and_rebuilds_counts(self):
|
||||||
|
aggregator = ErrorAggregator(pattern_threshold=2)
|
||||||
|
for _ in range(3):
|
||||||
|
_fail(aggregator, plugin_id="old")
|
||||||
|
for record in aggregator._records:
|
||||||
|
record.timestamp -= timedelta(hours=2)
|
||||||
|
for pattern in aggregator._patterns.values():
|
||||||
|
pattern.first_seen -= timedelta(hours=2)
|
||||||
|
_fail(aggregator, plugin_id="new", exc=KeyError)
|
||||||
|
cleared = aggregator.clear_before(datetime.now() - timedelta(hours=1))
|
||||||
|
assert cleared == 3
|
||||||
|
summary = aggregator.get_error_summary()
|
||||||
|
assert summary["total_errors"] == 1
|
||||||
|
assert summary["error_counts_by_type"] == {"KeyError": 1}
|
||||||
|
assert summary["plugin_error_counts"] == {"new": {"KeyError": 1}}
|
||||||
|
assert summary["active_patterns"] == {}
|
||||||
|
|
||||||
|
def test_a_pattern_made_only_of_later_errors_survives(self):
|
||||||
|
# A clear applied a few seconds late must not drop a pattern that
|
||||||
|
# formed entirely after the cutoff.
|
||||||
|
aggregator = ErrorAggregator(pattern_threshold=2)
|
||||||
|
cutoff = datetime.now() - timedelta(seconds=1)
|
||||||
|
for _ in range(3):
|
||||||
|
_fail(aggregator)
|
||||||
|
aggregator.clear_before(cutoff)
|
||||||
|
assert list(aggregator.get_error_summary()["active_patterns"]) == ["ValueError"]
|
||||||
|
|
||||||
|
def test_changes_the_version(self):
|
||||||
|
aggregator = ErrorAggregator()
|
||||||
|
v = aggregator.version
|
||||||
|
aggregator.clear_before(datetime.now())
|
||||||
|
assert aggregator.version != v
|
||||||
|
|
||||||
|
|
||||||
|
# --- Web side ---------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRoutes:
|
||||||
|
def test_no_snapshot_yet(self, web):
|
||||||
|
data = _summary(web)
|
||||||
|
assert data["snapshot_available"] is False
|
||||||
|
assert data["generated_at"] is None
|
||||||
|
assert data["total_errors"] == 0
|
||||||
|
assert data["recent_errors"] == [] and data["active_patterns"] == {}
|
||||||
|
response = web.get("/api/v3/errors/summary")
|
||||||
|
assert "not reported" in response.get_json()["message"]
|
||||||
|
|
||||||
|
def test_summary_is_the_display_services(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
for _ in range(5):
|
||||||
|
_fail(aggregator, plugin_id="weather", message="HTTP 500")
|
||||||
|
publisher.tick()
|
||||||
|
data = _summary(web)
|
||||||
|
assert data["snapshot_available"] is True
|
||||||
|
assert data["clear_pending"] is False
|
||||||
|
assert data["total_errors"] == 5
|
||||||
|
assert data["plugin_error_counts"] == {"weather": {"ValueError": 5}}
|
||||||
|
pattern = data["active_patterns"]["ValueError"]
|
||||||
|
assert pattern["affected_plugins"] == ["weather"]
|
||||||
|
assert pattern["sample_messages"] == ["HTTP 500"]
|
||||||
|
# The documented shape, plus only the documented additions.
|
||||||
|
assert set(data) == set(ErrorAggregator().get_error_summary()) | {
|
||||||
|
"generated_at", "snapshot_available", "clear_pending"}
|
||||||
|
|
||||||
|
def test_web_process_aggregator_is_not_what_is_reported(self, web, display, monkeypatch):
|
||||||
|
# The original bug: the route read this process's own aggregator.
|
||||||
|
local = ErrorAggregator()
|
||||||
|
_fail(local, plugin_id="only-in-web")
|
||||||
|
monkeypatch.setattr(errors, "_error_aggregator", local)
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_fail(aggregator, plugin_id="from-display")
|
||||||
|
publisher.tick()
|
||||||
|
assert list(_summary(web)["plugin_error_counts"]) == ["from-display"]
|
||||||
|
|
||||||
|
def test_plugin_slice(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
for _ in range(6):
|
||||||
|
_fail(aggregator, plugin_id="weather")
|
||||||
|
_fail(aggregator, plugin_id="clock", exc=KeyError)
|
||||||
|
publisher.tick()
|
||||||
|
data = web.get("/api/v3/errors/plugin/weather").get_json()["data"]
|
||||||
|
assert data["plugin_id"] == "weather"
|
||||||
|
assert data["status"] == "unhealthy"
|
||||||
|
assert data["total_errors"] == 6
|
||||||
|
assert data["error_types"] == {"ValueError": 6}
|
||||||
|
assert data["last_error"]["plugin_id"] == "weather"
|
||||||
|
assert data["snapshot_available"] is True
|
||||||
|
healthy = web.get("/api/v3/errors/plugin/never-failed").get_json()["data"]
|
||||||
|
assert healthy["status"] == "healthy" and healthy["total_errors"] == 0
|
||||||
|
assert healthy["last_error"] is None
|
||||||
|
|
||||||
|
def test_credentials_in_exception_text_are_redacted(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
for _ in range(5):
|
||||||
|
_fail(aggregator, message="GET https://api.example.com/?api_key=SEKRIT123 failed")
|
||||||
|
publisher.tick()
|
||||||
|
body = web.get("/api/v3/errors/summary").get_data(as_text=True)
|
||||||
|
body += web.get("/api/v3/errors/plugin/p1").get_data(as_text=True)
|
||||||
|
assert "SEKRIT123" not in body
|
||||||
|
assert "api_key=<redacted>" in body
|
||||||
|
|
||||||
|
def test_snapshot_is_redacted_before_it_is_clipped(self, display):
|
||||||
|
"""The display redacts what it publishes, before clipping: keeping
|
||||||
|
only a traceback's tail could cut ``api_key=`` off and leave the key
|
||||||
|
itself, which the web side's redaction would then not recognise."""
|
||||||
|
aggregator, _, _ = display
|
||||||
|
record = _fail(aggregator, message="GET /?token=MSGSECRET failed")
|
||||||
|
tail = errors._SNAPSHOT_TRACE_CHARS - 3
|
||||||
|
filler = "x" * (tail - len("TRACESECRET "))
|
||||||
|
record.stack_trace = "requests failed: api_key=TRACESECRET " + filler
|
||||||
|
assert record.stack_trace[-tail:].startswith("TRACESECRET") # marker falls outside
|
||||||
|
record.context = {"url": "https://h/?password=CTXSECRET"}
|
||||||
|
for _ in range(5):
|
||||||
|
_fail(aggregator, message="GET /?token=SAMPLESECRET failed")
|
||||||
|
published = json.dumps(aggregator.build_snapshot())
|
||||||
|
for secret in ("MSGSECRET", "TRACESECRET", "CTXSECRET", "SAMPLESECRET"):
|
||||||
|
assert secret not in published, secret
|
||||||
|
|
||||||
|
|
||||||
|
class TestClear:
|
||||||
|
def test_clear_is_applied_by_the_display_and_republished(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_fail(aggregator)
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher.tick()
|
||||||
|
response = web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.get_json()["data"]
|
||||||
|
assert body["clear_requested"] is True
|
||||||
|
assert body["cleared_count"] == 2
|
||||||
|
# The display applies it on its next tick, throttle or not...
|
||||||
|
assert publisher.tick() is True
|
||||||
|
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||||
|
data = _summary(web)
|
||||||
|
assert data["total_errors"] == 0 and data["clear_pending"] is False
|
||||||
|
# ...and only once.
|
||||||
|
assert publisher.tick() is False
|
||||||
|
|
||||||
|
def test_summary_hides_cleared_errors_before_the_display_applies_it(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
for _ in range(5):
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher.tick()
|
||||||
|
web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
# No display tick yet.
|
||||||
|
data = _summary(web)
|
||||||
|
assert data["clear_pending"] is True
|
||||||
|
assert data["total_errors"] == 0
|
||||||
|
assert data["recent_errors"] == [] and data["active_patterns"] == {}
|
||||||
|
assert data["plugin_error_counts"] == {}
|
||||||
|
plugin = web.get("/api/v3/errors/plugin/p1").get_json()["data"]
|
||||||
|
assert plugin["status"] == "healthy" and plugin["total_errors"] == 0
|
||||||
|
|
||||||
|
def test_a_snapshot_written_just_before_the_clear_cannot_bring_errors_back(
|
||||||
|
self, web, display, shared_cache):
|
||||||
|
# The race: the display builds a snapshot, the user clicks Clear, and
|
||||||
|
# the display's write lands after the request.
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
display_cache, _, _ = shared_cache
|
||||||
|
for _ in range(3):
|
||||||
|
_fail(aggregator)
|
||||||
|
stale = aggregator.build_snapshot()
|
||||||
|
web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
stale["applied_clear_id"] = None
|
||||||
|
display_cache.set(ERROR_SNAPSHOT_KEY, stale)
|
||||||
|
assert _summary(web)["total_errors"] == 0
|
||||||
|
|
||||||
|
def test_errors_after_the_clear_are_kept(self, web, display):
|
||||||
|
aggregator, publisher, clock = display
|
||||||
|
_fail(aggregator, plugin_id="before")
|
||||||
|
publisher.tick()
|
||||||
|
web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
# An error lands after the request but before the display applies it.
|
||||||
|
for record in aggregator._records:
|
||||||
|
record.timestamp -= timedelta(seconds=5)
|
||||||
|
_fail(aggregator, plugin_id="after")
|
||||||
|
publisher.tick()
|
||||||
|
data = _summary(web)
|
||||||
|
assert data["plugin_error_counts"] == {"after": {"ValueError": 1}}
|
||||||
|
assert data["clear_pending"] is False
|
||||||
|
|
||||||
|
def test_age_based_clear(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_fail(aggregator, plugin_id="old")
|
||||||
|
_fail(aggregator, plugin_id="old")
|
||||||
|
for record in aggregator._records:
|
||||||
|
record.timestamp -= timedelta(hours=3)
|
||||||
|
_fail(aggregator, plugin_id="new")
|
||||||
|
publisher.tick()
|
||||||
|
body = web.post("/api/v3/errors/clear", json={"max_age_hours": 1}).get_json()["data"]
|
||||||
|
assert body["cleared_count"] == 2
|
||||||
|
pending = _summary(web)
|
||||||
|
assert pending["clear_pending"] is True
|
||||||
|
assert [r["plugin_id"] for r in pending["recent_errors"]] == ["new"]
|
||||||
|
publisher.tick()
|
||||||
|
data = _summary(web)
|
||||||
|
assert data["plugin_error_counts"] == {"new": {"ValueError": 1}}
|
||||||
|
assert data["total_errors"] == 1
|
||||||
|
|
||||||
|
def test_a_narrower_clear_does_not_undo_a_pending_wider_one(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
for _ in range(3):
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher.tick()
|
||||||
|
web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
web.post("/api/v3/errors/clear", json={"max_age_hours": 24})
|
||||||
|
assert _summary(web)["total_errors"] == 0
|
||||||
|
publisher.tick()
|
||||||
|
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||||
|
|
||||||
|
def test_default_body_still_means_older_than_24_hours(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher.tick()
|
||||||
|
response = web.post("/api/v3/errors/clear")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()["data"]["cleared_count"] == 0
|
||||||
|
publisher.tick()
|
||||||
|
assert _summary(web)["total_errors"] == 1
|
||||||
|
|
||||||
|
def test_validation_is_unchanged_but_all_skips_it(self, web):
|
||||||
|
assert web.post("/api/v3/errors/clear", json={"max_age_hours": 0}).status_code == 400
|
||||||
|
assert web.post("/api/v3/errors/clear", json={"max_age_hours": 9000}).status_code == 400
|
||||||
|
assert web.post("/api/v3/errors/clear",
|
||||||
|
json={"all": True, "max_age_hours": "junk"}).status_code == 200
|
||||||
|
|
||||||
|
def test_a_request_that_did_not_reach_the_cache_is_an_error(self, web, api_v3_module): # noqa: F811
|
||||||
|
cache = MagicMock()
|
||||||
|
cache.get.return_value = None
|
||||||
|
api_v3_module.api_v3.cache_manager = cache
|
||||||
|
response = web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert "clear request" in response.get_json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not hasattr(os, "fchmod") or os.name == "nt",
|
||||||
|
reason="POSIX file modes")
|
||||||
|
def test_both_files_are_group_readable(web, display, shared_cache):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_, _, directory = shared_cache
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher.tick()
|
||||||
|
web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
for key in (ERROR_SNAPSHOT_KEY, ERROR_CLEAR_REQUEST_KEY):
|
||||||
|
mode = stat.S_IMODE(os.stat(directory / f"{key}.json").st_mode)
|
||||||
|
assert mode == 0o660, (key, oct(mode))
|
||||||
@@ -10,10 +10,11 @@ from web_interface.blueprints.api_v3 import (
|
|||||||
_MQTT_BRIDGE_DIR, _SUDO, _coerce_mqtt_bridge_value,
|
_MQTT_BRIDGE_DIR, _SUDO, _coerce_mqtt_bridge_value,
|
||||||
_get_display_service_status, _mqtt_bridge_service_state,
|
_get_display_service_status, _mqtt_bridge_service_state,
|
||||||
_read_mqtt_bridge_config, api_v3, contextlib, describe_exception,
|
_read_mqtt_bridge_config, api_v3, contextlib, describe_exception,
|
||||||
error_response, get_error_aggregator, json, jsonify, logger, os, request,
|
error_response, json, jsonify, logger, os, redact_text, request,
|
||||||
subprocess, success_response, tempfile,
|
subprocess, success_response, tempfile,
|
||||||
)
|
)
|
||||||
from src.common.path_safety import safe_path_component
|
from src.common.path_safety import safe_path_component
|
||||||
|
from src import error_aggregator as _errors
|
||||||
import web_interface.blueprints.api_v3 as _pkg
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
# Read through the module rather than bound by value: tests patch these
|
# Read through the module rather than bound by value: tests patch these
|
||||||
# as module attributes, and a value binding would not see the patch.
|
# as module attributes, and a value binding would not see the patch.
|
||||||
@@ -281,17 +282,62 @@ def delete_cache_file():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error('Error in delete_cache_file', exc_info=True)
|
logger.error('Error in delete_cache_file', exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
def _errors_cache():
|
||||||
|
"""The shared cache the display service publishes its errors to."""
|
||||||
|
if not api_v3.cache_manager:
|
||||||
|
from src.cache_manager import CacheManager
|
||||||
|
api_v3.cache_manager = CacheManager()
|
||||||
|
return api_v3.cache_manager
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_error_text(text, keep_lines=False):
|
||||||
|
"""Credentials out of plugin exception text, which can quote a URL with
|
||||||
|
an API key in it. Stack traces keep their line breaks and indentation."""
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return text
|
||||||
|
if not keep_lines:
|
||||||
|
return redact_text(text, max_length=len(text) + 1)
|
||||||
|
return '\n'.join(
|
||||||
|
line[:len(line) - len(line.lstrip())] + redact_text(line, max_length=len(line) + 1)
|
||||||
|
for line in text.splitlines()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_error_record(record):
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
return record
|
||||||
|
record = dict(record)
|
||||||
|
record['message'] = _redact_error_text(record.get('message'))
|
||||||
|
record['stack_trace'] = _redact_error_text(record.get('stack_trace'), keep_lines=True)
|
||||||
|
if isinstance(record.get('context'), dict):
|
||||||
|
record['context'] = {k: _redact_error_text(v) for k, v in record['context'].items()}
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def _read_errors():
|
||||||
|
snapshot, clear_request = _errors.read_error_report(_errors_cache())
|
||||||
|
return snapshot, clear_request
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/errors/summary', methods=['GET'])
|
@api_v3.route('/errors/summary', methods=['GET'])
|
||||||
def get_error_summary():
|
def get_error_summary():
|
||||||
"""
|
"""
|
||||||
Get summary of all errors for monitoring and debugging.
|
Get summary of all errors for monitoring and debugging.
|
||||||
|
|
||||||
Returns error counts, detected patterns, and recent errors.
|
Returns error counts, detected patterns, and recent errors, as last
|
||||||
|
reported by the display service (which runs the plugins, so it is the
|
||||||
|
only process that records their errors). ``snapshot_available`` is false
|
||||||
|
until it has reported; ``generated_at`` says when it did.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
aggregator = get_error_aggregator()
|
summary = _errors.error_summary_from_report(*_read_errors())
|
||||||
summary = aggregator.get_error_summary()
|
summary['recent_errors'] = [_redact_error_record(r) for r in summary['recent_errors']]
|
||||||
return success_response(data=summary, message="Error summary retrieved")
|
for pattern in summary['active_patterns'].values():
|
||||||
|
if isinstance(pattern, dict) and isinstance(pattern.get('sample_messages'), list):
|
||||||
|
pattern['sample_messages'] = [_redact_error_text(m) for m in pattern['sample_messages']]
|
||||||
|
message = ("Error summary retrieved" if summary['snapshot_available']
|
||||||
|
else "The display service has not reported any errors yet")
|
||||||
|
return success_response(data=summary, message=message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting error summary: {e}", exc_info=True)
|
logger.error(f"Error getting error summary: {e}", exc_info=True)
|
||||||
return error_response(
|
return error_response(
|
||||||
@@ -307,11 +353,13 @@ def get_plugin_errors(plugin_id):
|
|||||||
Args:
|
Args:
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
|
|
||||||
Returns health status and error statistics for the plugin.
|
Returns health status and error statistics for the plugin, from the
|
||||||
|
display service's last report (see get_error_summary). A plugin with no
|
||||||
|
recorded errors is "healthy".
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
aggregator = get_error_aggregator()
|
health = _errors.plugin_health_from_report(*_read_errors(), plugin_id)
|
||||||
health = aggregator.get_plugin_health(plugin_id)
|
health['last_error'] = _redact_error_record(health['last_error'])
|
||||||
return success_response(data=health, message="Plugin health retrieved")
|
return success_response(data=health, message="Plugin health retrieved")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting plugin health for {plugin_id}: {e}", exc_info=True)
|
logger.error(f"Error getting plugin health for {plugin_id}: {e}", exc_info=True)
|
||||||
@@ -327,42 +375,61 @@ def clear_old_errors():
|
|||||||
|
|
||||||
Request body (optional):
|
Request body (optional):
|
||||||
max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year)
|
max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year)
|
||||||
|
all: true clears every error recorded so far (max_age_hours ignored)
|
||||||
|
|
||||||
|
The errors live in the display service, so this records a clear request
|
||||||
|
that it applies within a few seconds. Reads hide the cleared errors from
|
||||||
|
the moment the request is recorded.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
|
clear_all = _coerce_to_bool(data.get('all'))
|
||||||
raw_max_age = data.get('max_age_hours', 24)
|
raw_max_age = data.get('max_age_hours', 24)
|
||||||
|
|
||||||
# Validate and coerce max_age_hours
|
# Validate and coerce max_age_hours
|
||||||
|
max_age_hours = None
|
||||||
|
if not clear_all:
|
||||||
|
try:
|
||||||
|
max_age_hours = int(raw_max_age)
|
||||||
|
if max_age_hours < 1:
|
||||||
|
return error_response(
|
||||||
|
error_code=ErrorCode.INVALID_INPUT,
|
||||||
|
message="max_age_hours must be at least 1",
|
||||||
|
context={'provided_value': raw_max_age},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
if max_age_hours > 8760: # 1 year max
|
||||||
|
return error_response(
|
||||||
|
error_code=ErrorCode.INVALID_INPUT,
|
||||||
|
message="max_age_hours cannot exceed 8760 (1 year)",
|
||||||
|
context={'provided_value': raw_max_age},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError, OverflowError):
|
||||||
|
return error_response(
|
||||||
|
error_code=ErrorCode.INVALID_INPUT,
|
||||||
|
message="max_age_hours must be a valid integer",
|
||||||
|
context={'provided_value': str(raw_max_age)},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
now = _pkg.time.time()
|
||||||
|
cutoff = now if clear_all else now - max_age_hours * 3600
|
||||||
try:
|
try:
|
||||||
max_age_hours = int(raw_max_age)
|
result = _errors.request_error_clear(_errors_cache(), cutoff)
|
||||||
if max_age_hours < 1:
|
except OSError as e:
|
||||||
return error_response(
|
logger.error("Could not record an error clear request: %s", e)
|
||||||
error_code=ErrorCode.INVALID_INPUT,
|
|
||||||
message="max_age_hours must be at least 1",
|
|
||||||
context={'provided_value': raw_max_age},
|
|
||||||
status_code=400
|
|
||||||
)
|
|
||||||
if max_age_hours > 8760: # 1 year max
|
|
||||||
return error_response(
|
|
||||||
error_code=ErrorCode.INVALID_INPUT,
|
|
||||||
message="max_age_hours cannot exceed 8760 (1 year)",
|
|
||||||
context={'provided_value': raw_max_age},
|
|
||||||
status_code=400
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError, OverflowError):
|
|
||||||
return error_response(
|
return error_response(
|
||||||
error_code=ErrorCode.INVALID_INPUT,
|
error_code=ErrorCode.SYSTEM_ERROR,
|
||||||
message="max_age_hours must be a valid integer",
|
message="Could not record the clear request in the shared cache",
|
||||||
context={'provided_value': str(raw_max_age)},
|
status_code=500
|
||||||
status_code=400
|
|
||||||
)
|
)
|
||||||
|
|
||||||
aggregator = get_error_aggregator()
|
scope = "all errors" if clear_all else f"errors older than {max_age_hours} hours"
|
||||||
cleared_count = aggregator.clear_old_records(max_age_hours=max_age_hours)
|
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={'cleared_count': cleared_count},
|
data=result,
|
||||||
message=f"Cleared {cleared_count} error records older than {max_age_hours} hours"
|
message=(f"Clear of {scope} requested; the display service applies it "
|
||||||
|
f"within about {int(_errors.SNAPSHOT_TICK_INTERVAL)} seconds")
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error clearing old errors: {e}", exc_info=True)
|
logger.error(f"Error clearing old errors: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -104,6 +104,25 @@
|
|||||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
<span>Connected to log stream</span>
|
<span>Connected to log stream</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Plugin errors, as recorded by the display service -->
|
||||||
|
<div id="plugin-errors-panel" class="mt-6 border-t border-gray-200 pt-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-900">Plugin errors</h3>
|
||||||
|
<p id="plugin-errors-meta" class="text-xs text-gray-600">Recorded by the display service</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="plugin-errors-refresh-btn" type="button" class="btn bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm">
|
||||||
|
<i class="fas fa-sync-alt mr-1"></i>Refresh
|
||||||
|
</button>
|
||||||
|
<button id="plugin-errors-clear-btn" type="button" class="btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-sm">
|
||||||
|
<i class="fas fa-eraser mr-1"></i>Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="plugin-errors-body" class="text-sm text-gray-600" aria-live="polite">Loading...</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -218,10 +237,18 @@ window._filteredLogs = [];
|
|||||||
// Current-plugin poll and the realtime log stream run only while the Logs
|
// Current-plugin poll and the realtime log stream run only while the Logs
|
||||||
// tab is active and the page is visible (both restart with an immediate
|
// tab is active and the page is visible (both restart with an immediate
|
||||||
// refresh). Re-registering on partial reload replaces the old one.
|
// refresh). Re-registering on partial reload replaces the old one.
|
||||||
|
const pluginErrorsRefreshBtn = document.getElementById('plugin-errors-refresh-btn');
|
||||||
|
if (pluginErrorsRefreshBtn) pluginErrorsRefreshBtn.onclick = refreshPluginErrors;
|
||||||
|
const pluginErrorsClearBtn = document.getElementById('plugin-errors-clear-btn');
|
||||||
|
if (pluginErrorsClearBtn) pluginErrorsClearBtn.onclick = clearPluginErrors;
|
||||||
|
|
||||||
function startLogsActivity() {
|
function startLogsActivity() {
|
||||||
refreshCurrentPluginStatus();
|
refreshCurrentPluginStatus();
|
||||||
if (window._currentPluginPollTimer) clearInterval(window._currentPluginPollTimer);
|
if (window._currentPluginPollTimer) clearInterval(window._currentPluginPollTimer);
|
||||||
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
|
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
|
||||||
|
refreshPluginErrors();
|
||||||
|
if (window._pluginErrorsPollTimer) clearInterval(window._pluginErrorsPollTimer);
|
||||||
|
window._pluginErrorsPollTimer = setInterval(refreshPluginErrors, 15000);
|
||||||
if (window._isRealtime && !window._logsEventSource) setupRealtimeLogs();
|
if (window._isRealtime && !window._logsEventSource) setupRealtimeLogs();
|
||||||
}
|
}
|
||||||
function stopLogsActivity() {
|
function stopLogsActivity() {
|
||||||
@@ -229,6 +256,10 @@ window._filteredLogs = [];
|
|||||||
clearInterval(window._currentPluginPollTimer);
|
clearInterval(window._currentPluginPollTimer);
|
||||||
window._currentPluginPollTimer = null;
|
window._currentPluginPollTimer = null;
|
||||||
}
|
}
|
||||||
|
if (window._pluginErrorsPollTimer) {
|
||||||
|
clearInterval(window._pluginErrorsPollTimer);
|
||||||
|
window._pluginErrorsPollTimer = null;
|
||||||
|
}
|
||||||
stopRealtimeLogs();
|
stopRealtimeLogs();
|
||||||
}
|
}
|
||||||
if (window.LEDVisibility) {
|
if (window.LEDVisibility) {
|
||||||
@@ -787,6 +818,120 @@ function refreshCurrentPluginStatus() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plugin errors panel. The display service records them and publishes a
|
||||||
|
// snapshot every few seconds; /api/v3/errors/summary serves that snapshot.
|
||||||
|
// Plugin ids and messages come from plugins: everything goes through escapeHtml.
|
||||||
|
function formatErrorTime(iso) {
|
||||||
|
return iso ? String(iso).replace('T', ' ').slice(0, 19) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPluginErrors(summary) {
|
||||||
|
const body = document.getElementById('plugin-errors-body');
|
||||||
|
const meta = document.getElementById('plugin-errors-meta');
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
let text = 'Recorded by the display service';
|
||||||
|
if (summary.generated_at) text += ' · updated ' + formatErrorTime(summary.generated_at);
|
||||||
|
if (summary.clear_pending) text += ' · clearing…';
|
||||||
|
meta.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary.snapshot_available) {
|
||||||
|
body.innerHTML = '<p class="text-gray-600"><i class="fas fa-hourglass-half mr-1"></i>' +
|
||||||
|
'Display service hasn’t reported yet. Errors appear here once it is running.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const plugins = Object.entries(summary.plugin_error_counts || {}).map(([id, types]) => {
|
||||||
|
const total = Object.values(types || {}).reduce((sum, n) => sum + (Number(n) || 0), 0);
|
||||||
|
return { id, types: types || {}, total };
|
||||||
|
}).sort((a, b) => b.total - a.total);
|
||||||
|
const patterns = Object.values(summary.active_patterns || {});
|
||||||
|
|
||||||
|
if (!Number(summary.total_errors) && plugins.length === 0 && patterns.length === 0) {
|
||||||
|
body.innerHTML = '<p class="text-gray-600"><i class="fas fa-check-circle text-green-600 mr-1"></i>' +
|
||||||
|
'No plugin errors recorded</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
if (plugins.length) {
|
||||||
|
html += '<div class="overflow-x-auto"><table class="min-w-full text-sm">' +
|
||||||
|
'<thead><tr class="text-left text-xs uppercase text-gray-500 border-b border-gray-200">' +
|
||||||
|
'<th class="py-1 pr-4 font-medium">Plugin</th><th class="py-1 pr-4 font-medium">Errors</th>' +
|
||||||
|
'<th class="py-1 font-medium">Types</th></tr></thead><tbody>';
|
||||||
|
plugins.forEach(p => {
|
||||||
|
const types = Object.entries(p.types)
|
||||||
|
.map(([type, n]) => `${escapeHtml(type)} ×${escapeHtml(String(Number(n) || 0))}`)
|
||||||
|
.join(', ');
|
||||||
|
html += '<tr class="border-b border-gray-100">' +
|
||||||
|
`<td class="py-1 pr-4"><code class="bg-gray-100 px-1 rounded">${escapeHtml(p.id)}</code></td>` +
|
||||||
|
`<td class="py-1 pr-4 font-semibold text-gray-900">${escapeHtml(String(p.total))}</td>` +
|
||||||
|
`<td class="py-1 text-gray-700 break-words">${types}</td></tr>`;
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patterns.length) {
|
||||||
|
const badge = {
|
||||||
|
critical: 'bg-red-100 text-red-800',
|
||||||
|
error: 'bg-amber-100 text-amber-800',
|
||||||
|
warning: 'bg-yellow-100 text-yellow-800'
|
||||||
|
};
|
||||||
|
html += '<h4 class="mt-4 mb-2 text-sm font-semibold text-gray-900">Repeating errors</h4><ul class="list-none pl-0 space-y-2">';
|
||||||
|
patterns.forEach(p => {
|
||||||
|
const severity = String(p.severity || 'warning');
|
||||||
|
const affected = (p.affected_plugins || []).map(id => escapeHtml(id)).join(', ') || 'unknown';
|
||||||
|
const sample = (p.sample_messages || [])[0];
|
||||||
|
html += '<li class="border border-gray-200 rounded-lg px-3 py-2">' +
|
||||||
|
'<div class="flex flex-wrap items-center gap-2">' +
|
||||||
|
`<span class="px-2 py-0.5 rounded text-xs font-semibold ${badge[severity] || badge.warning}">${escapeHtml(severity)}</span>` +
|
||||||
|
`<code class="font-semibold text-gray-900">${escapeHtml(p.error_type || '')}</code>` +
|
||||||
|
`<span class="text-gray-700">×${escapeHtml(String(Number(p.count) || 0))}</span>` +
|
||||||
|
`<span class="text-xs text-gray-500 ml-auto">last seen ${escapeHtml(formatErrorTime(p.last_seen))}</span></div>` +
|
||||||
|
`<div class="mt-1 text-xs text-gray-600">Plugins: ${affected}</div>` +
|
||||||
|
(sample ? `<div class="mt-1 text-xs font-mono text-gray-700 break-words">${escapeHtml(sample)}</div>` : '') +
|
||||||
|
'</li>';
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
}
|
||||||
|
body.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPluginErrors() {
|
||||||
|
fetch('/api/v3/errors/summary')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status !== 'success' || !data.data) throw new Error(data.message || 'Request failed');
|
||||||
|
renderPluginErrors(data.data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
const body = document.getElementById('plugin-errors-body');
|
||||||
|
if (body) body.textContent = 'Could not load plugin errors.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPluginErrors() {
|
||||||
|
if (!confirm('Clear all recorded plugin errors?')) return;
|
||||||
|
fetch('/api/v3/errors/clear', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ all: true })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (typeof showNotification !== 'undefined') {
|
||||||
|
showNotification(data.status === 'success' ? 'Plugin errors cleared'
|
||||||
|
: (data.message || 'Could not clear plugin errors'),
|
||||||
|
data.status === 'success' ? 'success' : 'error');
|
||||||
|
}
|
||||||
|
refreshPluginErrors();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (typeof showNotification !== 'undefined') showNotification('Could not clear plugin errors', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup on page unload
|
// Cleanup on page unload
|
||||||
window.addEventListener('beforeunload', function() {
|
window.addEventListener('beforeunload', function() {
|
||||||
if (window._logsEventSource) {
|
if (window._logsEventSource) {
|
||||||
@@ -796,5 +941,9 @@ window.addEventListener('beforeunload', function() {
|
|||||||
clearInterval(window._currentPluginPollTimer);
|
clearInterval(window._currentPluginPollTimer);
|
||||||
window._currentPluginPollTimer = null;
|
window._currentPluginPollTimer = null;
|
||||||
}
|
}
|
||||||
|
if (window._pluginErrorsPollTimer) {
|
||||||
|
clearInterval(window._pluginErrorsPollTimer);
|
||||||
|
window._pluginErrorsPollTimer = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user