feat: add forgejo_error_reporter plugin for automatic error reporting to Forgejo issues
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
"""Standardized error codes for consistent frontend handling."""
|
||||||
|
|
||||||
|
ERROR_CODES = {
|
||||||
|
'not_found': {'status': 404, 'message': 'Resource not found'},
|
||||||
|
'permission_denied': {'status': 403, 'message': 'Permission denied'},
|
||||||
|
'validation_error': {'status': 422, 'message': 'Validation failed'},
|
||||||
|
'rate_limited': {'status': 429, 'message': 'Too many requests'},
|
||||||
|
'internal_error': {'status': 500, 'message': 'Internal server error'},
|
||||||
|
'service_unavailable': {'status': 503, 'message': 'Service temporarily unavailable'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ApiError(Exception):
|
||||||
|
def __init__(self, code: str, detail: str = None, field: str = None, status: int = None):
|
||||||
|
self.code = code
|
||||||
|
self.detail = detail or ERROR_CODES.get(code, {}).get('message', 'Unknown error')
|
||||||
|
self.field = field
|
||||||
|
self.status = status or ERROR_CODES.get(code, {}).get('status', 500)
|
||||||
|
super().__init__(self.detail)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Plugin error isolation wrapper."""
|
||||||
|
import logging
|
||||||
|
import functools
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_plugin_route(handler):
|
||||||
|
"""Decorator that isolates plugin route errors and returns structured JSON."""
|
||||||
|
@functools.wraps(handler)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return await handler(*args, **kwargs)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f'Plugin route error: {exc}', exc_info=True)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={'detail': f'Plugin error: {exc}', 'code': 'plugin_error'}
|
||||||
|
)
|
||||||
|
return wrapper
|
||||||
+15
@@ -19,8 +19,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.core.db import close_engine, get_engine
|
from app.core.db import close_engine, get_engine
|
||||||
|
from app.core.error_codes import ApiError
|
||||||
from app.core.middleware import CSRFMiddleware
|
from app.core.middleware import CSRFMiddleware
|
||||||
from app.core.monitoring import record_error, record_request
|
from app.core.monitoring import record_error, record_request
|
||||||
|
from app.core.plugin_error_handler import wrap_plugin_route
|
||||||
from app.core.service_container import get_container
|
from app.core.service_container import get_container
|
||||||
from app.plugins.registry import get_registry
|
from app.plugins.registry import get_registry
|
||||||
from app.routes import (
|
from app.routes import (
|
||||||
@@ -325,6 +327,14 @@ def create_app() -> FastAPI:
|
|||||||
content={"detail": "Internal server error", "code": "internal_error"},
|
content={"detail": "Internal server error", "code": "internal_error"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── ApiError handler — structured error responses ──
|
||||||
|
@app.exception_handler(ApiError)
|
||||||
|
async def api_error_handler(request: Request, exc: ApiError):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status,
|
||||||
|
content={'code': exc.code, 'detail': exc.detail, 'field': exc.field}
|
||||||
|
)
|
||||||
|
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(metrics.router)
|
app.include_router(metrics.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
@@ -380,6 +390,7 @@ def create_app() -> FastAPI:
|
|||||||
"app.plugins.builtins.mcp_server",
|
"app.plugins.builtins.mcp_server",
|
||||||
"app.plugins.builtins.system_notif",
|
"app.plugins.builtins.system_notif",
|
||||||
"app.plugins.builtins.unified_search",
|
"app.plugins.builtins.unified_search",
|
||||||
|
"app.plugins.builtins.forgejo_error_reporter",
|
||||||
]
|
]
|
||||||
for mod_name in plugin_modules:
|
for mod_name in plugin_modules:
|
||||||
try:
|
try:
|
||||||
@@ -392,6 +403,10 @@ def create_app() -> FastAPI:
|
|||||||
try:
|
try:
|
||||||
router_module = importlib.import_module(route_def.module)
|
router_module = importlib.import_module(route_def.module)
|
||||||
router = getattr(router_module, route_def.router_attr)
|
router = getattr(router_module, route_def.router_attr)
|
||||||
|
# Wrap each route handler with plugin error isolation
|
||||||
|
for route in router.routes:
|
||||||
|
if hasattr(route, 'endpoint'):
|
||||||
|
route.endpoint = wrap_plugin_route(route.endpoint)
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"Failed to register route {route_def.module}.{route_def.router_attr}: {exc}")
|
logger.error(f"Failed to register route {route_def.module}.{route_def.router_attr}: {exc}")
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Forgejo Error Reporter plugin — automatically reports errors as Forgejo issues."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.plugins.builtins.forgejo_error_reporter.plugin import ForgejoErrorReporterPlugin
|
||||||
|
|
||||||
|
__all__ = ["ForgejoErrorReporterPlugin"]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Migration 0001: Create forgejo_reported_errors table
|
||||||
|
-- Tracks which errors have been reported to Forgejo for audit purposes.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS forgejo_reported_errors (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
dedup_key VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
stack TEXT,
|
||||||
|
forgejo_issue_number INTEGER,
|
||||||
|
reported_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'reported'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_forgejo_reported_errors_dedup_key ON forgejo_reported_errors(dedup_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_forgejo_reported_errors_reported_at ON forgejo_reported_errors(reported_at);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Database models for the Forgejo Error Reporter plugin.
|
||||||
|
|
||||||
|
Tracks which errors have been reported to Forgejo for audit purposes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class ReportedError(Base):
|
||||||
|
"""Tracks errors that have been reported to Forgejo."""
|
||||||
|
|
||||||
|
__tablename__ = "forgejo_reported_errors"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
dedup_key = Column(String(64), nullable=False, unique=True, index=True, comment="SHA256 hash of message+stack for deduplication")
|
||||||
|
message = Column(Text, nullable=False, comment="Error message")
|
||||||
|
stack = Column(Text, nullable=True, comment="Stack trace")
|
||||||
|
forgejo_issue_number = Column(Integer, nullable=True, comment="Forgejo issue number")
|
||||||
|
reported_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, comment="When the error was reported")
|
||||||
|
status = Column(String(20), nullable=False, default="reported", comment="reported/failed")
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Forgejo Error Reporter plugin — automatically reports errors as Forgejo issues.
|
||||||
|
|
||||||
|
Only active in test/staging environments. Disabled in production.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.plugins.base import BasePlugin
|
||||||
|
from app.plugins.manifest import PluginManifest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ForgejoErrorReporterPlugin(BasePlugin):
|
||||||
|
"""Plugin that forwards errors to Forgejo as issues."""
|
||||||
|
|
||||||
|
manifest = PluginManifest(
|
||||||
|
name="forgejo_error_reporter",
|
||||||
|
version="1.0.0",
|
||||||
|
display_name="Forgejo Error Reporter",
|
||||||
|
description="Automatically reports errors to Forgejo as issues. Test environment only.",
|
||||||
|
is_core=False,
|
||||||
|
dependencies=[],
|
||||||
|
events=[],
|
||||||
|
migrations=[],
|
||||||
|
permissions=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._enabled: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
"""Whether the plugin is enabled based on environment and settings."""
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
@enabled.setter
|
||||||
|
def enabled(self, value: bool) -> None:
|
||||||
|
self._enabled = value
|
||||||
|
|
||||||
|
async def on_activate(self, db: Any, service_container: Any, event_bus: Any) -> None:
|
||||||
|
"""Activate the plugin — check environment and enable if not production."""
|
||||||
|
await super().on_activate(db, service_container, event_bus)
|
||||||
|
|
||||||
|
environment = os.environ.get("FORGEJO_ERROR_REPORTER_ENV", "test").lower()
|
||||||
|
enabled_flag = os.environ.get("FORGEJO_ERROR_REPORTER_ENABLED", "false").lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
if environment == "production":
|
||||||
|
logger.info(
|
||||||
|
"Forgejo Error Reporter is disabled in production environment. "
|
||||||
|
"Set FORGEJO_ERROR_REPORTER_ENV to 'test' or 'staging' to enable."
|
||||||
|
)
|
||||||
|
self._enabled = False
|
||||||
|
return
|
||||||
|
|
||||||
|
if not enabled_flag:
|
||||||
|
logger.info(
|
||||||
|
"Forgejo Error Reporter is disabled via FORGEJO_ERROR_REPORTER_ENABLED=false. "
|
||||||
|
"Set it to 'true' to enable."
|
||||||
|
)
|
||||||
|
self._enabled = False
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check required settings
|
||||||
|
token = os.environ.get("FORGEJO_ERROR_REPORTER_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
logger.warning(
|
||||||
|
"Forgejo Error Reporter: FORGEJO_ERROR_REPORTER_TOKEN is not set. "
|
||||||
|
"Plugin will be inactive until token is configured."
|
||||||
|
)
|
||||||
|
self._enabled = False
|
||||||
|
return
|
||||||
|
|
||||||
|
self._enabled = True
|
||||||
|
logger.info(
|
||||||
|
"Forgejo Error Reporter enabled (env=%s, url=%s, owner=%s, repo=%s)",
|
||||||
|
environment,
|
||||||
|
os.environ.get("FORGEJO_ERROR_REPORTER_URL", "https://forgejo.media-on.de"),
|
||||||
|
os.environ.get("FORGEJO_ERROR_REPORTER_OWNER", "Leopoldadmin"),
|
||||||
|
os.environ.get("FORGEJO_ERROR_REPORTER_REPO", "leocrm"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_deactivate(self, db: Any, service_container: Any, event_bus: Any) -> None:
|
||||||
|
"""Deactivate the plugin."""
|
||||||
|
self._enabled = False
|
||||||
|
await super().on_deactivate(db, service_container, event_bus)
|
||||||
|
logger.info("Forgejo Error Reporter deactivated")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""API routes for the Forgejo Error Reporter plugin."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/forgejo-error-reporter", tags=["forgejo_error_reporter"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def get_status(request: Request) -> dict:
|
||||||
|
"""Get the current status of the Forgejo Error Reporter plugin."""
|
||||||
|
from app.plugins.registry import get_registry
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
plugin = registry.get_plugin("forgejo_error_reporter")
|
||||||
|
|
||||||
|
if plugin is None:
|
||||||
|
return {"enabled": False, "reason": "Plugin not found"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": plugin.enabled,
|
||||||
|
"environment": plugin.enabled and "non-production" or "disabled",
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Pydantic schemas for the Forgejo Error Reporter plugin."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ForgejoErrorReport(BaseModel):
|
||||||
|
"""Schema for an error report that will be forwarded to Forgejo."""
|
||||||
|
|
||||||
|
message: str = Field(..., max_length=2000, description="Error message")
|
||||||
|
stack: str | None = Field(None, max_length=10000, description="Stack trace")
|
||||||
|
url: str | None = Field(None, max_length=500, description="URL where error occurred")
|
||||||
|
user_agent: str | None = Field(None, max_length=500, description="User agent string")
|
||||||
|
timestamp: str | None = Field(None, description="Error timestamp")
|
||||||
|
context: dict[str, Any] | None = Field(None, description="Additional context")
|
||||||
|
|
||||||
|
|
||||||
|
class ForgejoIssueResponse(BaseModel):
|
||||||
|
"""Response from creating a Forgejo issue."""
|
||||||
|
|
||||||
|
success: bool = Field(..., description="Whether the issue was created successfully")
|
||||||
|
issue_number: int | None = Field(None, description="Forgejo issue number if created")
|
||||||
|
issue_url: str | None = Field(None, description="URL to the created issue")
|
||||||
|
error: str | None = Field(None, description="Error message if creation failed")
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""Forgejo API client for error reporting with deduplication and rate limiting."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from collections import OrderedDict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Deduplication cache ──────────────────────────────────────────────────────
|
||||||
|
# In-memory cache: key = hash(message + stack), value = timestamp
|
||||||
|
# Max 100 entries, TTL 1 hour
|
||||||
|
_DEDUP_CACHE: OrderedDict[str, float] = OrderedDict()
|
||||||
|
_DEDUP_MAX_SIZE = 100
|
||||||
|
_DEDUP_TTL_SECONDS = 3600 # 1 hour
|
||||||
|
|
||||||
|
# ── Rate limiting ────────────────────────────────────────────────────────────
|
||||||
|
# Max 10 issues per hour
|
||||||
|
_RATE_LIMIT_MAX = 10
|
||||||
|
_RATE_LIMIT_WINDOW = 3600 # 1 hour in seconds
|
||||||
|
_rate_limit_timestamps: list[float] = []
|
||||||
|
|
||||||
|
# ── Lock for thread safety ──────────────────────────────────────────────────
|
||||||
|
_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Settings helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_settings() -> dict[str, str]:
|
||||||
|
"""Get Forgejo settings from environment variables."""
|
||||||
|
return {
|
||||||
|
"url": os.environ.get("FORGEJO_ERROR_REPORTER_URL", "https://forgejo.media-on.de").rstrip("/"),
|
||||||
|
"token": os.environ.get("FORGEJO_ERROR_REPORTER_TOKEN", ""),
|
||||||
|
"owner": os.environ.get("FORGEJO_ERROR_REPORTER_OWNER", "Leopoldadmin"),
|
||||||
|
"repo": os.environ.get("FORGEJO_ERROR_REPORTER_REPO", "leocrm"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_dedup_key(message: str, stack: str | None) -> str:
|
||||||
|
"""Create a deduplication key from error message and stack trace."""
|
||||||
|
import hashlib
|
||||||
|
raw = f"{message}|||{stack or ''}"
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def _is_duplicate(dedup_key: str) -> bool:
|
||||||
|
"""Check if an error with this key was already reported recently.
|
||||||
|
|
||||||
|
Cleans expired entries and enforces max cache size.
|
||||||
|
"""
|
||||||
|
async with _lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
# Clean expired entries
|
||||||
|
expired_keys = [
|
||||||
|
k for k, ts in _DEDUP_CACHE.items()
|
||||||
|
if now - ts > _DEDUP_TTL_SECONDS
|
||||||
|
]
|
||||||
|
for k in expired_keys:
|
||||||
|
del _DEDUP_CACHE[k]
|
||||||
|
|
||||||
|
# Check if key exists and is still valid
|
||||||
|
if dedup_key in _DEDUP_CACHE:
|
||||||
|
logger.debug("Duplicate error detected, skipping Forgejo issue creation")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Enforce max cache size
|
||||||
|
while len(_DEDUP_CACHE) >= _DEDUP_MAX_SIZE:
|
||||||
|
_DEDUP_CACHE.popitem(last=False)
|
||||||
|
|
||||||
|
# Add new entry
|
||||||
|
_DEDUP_CACHE[dedup_key] = now
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_rate_limit() -> bool:
|
||||||
|
"""Check if we're within the rate limit.
|
||||||
|
|
||||||
|
Returns True if request is allowed, False if rate limited.
|
||||||
|
"""
|
||||||
|
async with _lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
window_start = now - _RATE_LIMIT_WINDOW
|
||||||
|
|
||||||
|
# Remove timestamps outside the window
|
||||||
|
while _rate_limit_timestamps and _rate_limit_timestamps[0] < window_start:
|
||||||
|
_rate_limit_timestamps.pop(0)
|
||||||
|
|
||||||
|
if len(_rate_limit_timestamps) >= _RATE_LIMIT_MAX:
|
||||||
|
logger.warning(
|
||||||
|
"Forgejo Error Reporter rate limited: %d issues in the last hour",
|
||||||
|
len(_rate_limit_timestamps),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
_rate_limit_timestamps.append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_labels_exist(client: httpx.AsyncClient, settings: dict[str, str]) -> None:
|
||||||
|
"""Ensure required labels exist in the Forgejo repository.
|
||||||
|
|
||||||
|
Creates 'auto-reported' and 'bug' labels if they don't exist.
|
||||||
|
"""
|
||||||
|
url = f"{settings['url']}/api/v1/repos/{settings['owner']}/{settings['repo']}/labels"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"token {settings['token']}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.get(url, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
existing_labels = response.json()
|
||||||
|
existing_names = {label.get("name", "") for label in existing_labels}
|
||||||
|
|
||||||
|
required_labels = [
|
||||||
|
{"name": "auto-reported", "color": "0366d6", "description": "Automatically reported by error reporter"},
|
||||||
|
{"name": "bug", "color": "d73a4a", "description": "Bug report"},
|
||||||
|
]
|
||||||
|
|
||||||
|
for label_data in required_labels:
|
||||||
|
if label_data["name"] not in existing_names:
|
||||||
|
create_response = await client.post(url, headers=headers, json=label_data)
|
||||||
|
if create_response.status_code in (201, 200):
|
||||||
|
logger.info("Created label '%s' in Forgejo repo", label_data["name"])
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to create label '%s': %s",
|
||||||
|
label_data["name"],
|
||||||
|
create_response.text,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
logger.warning("Failed to fetch/create labels: %s", exc)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
logger.warning("Network error while managing labels: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def report_error_to_forgejo(entry: dict[str, Any]) -> bool:
|
||||||
|
"""Report an error to Forgejo as a new issue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entry: Error entry dict with keys:
|
||||||
|
- message: Error message (required)
|
||||||
|
- stack: Stack trace (optional)
|
||||||
|
- url: URL where error occurred (optional)
|
||||||
|
- userAgent: User agent string (optional)
|
||||||
|
- timestamp: Error timestamp (optional)
|
||||||
|
- context: Additional context dict (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the issue was created successfully, False otherwise.
|
||||||
|
"""
|
||||||
|
settings = _get_settings()
|
||||||
|
|
||||||
|
# Validate settings
|
||||||
|
if not settings["token"]:
|
||||||
|
logger.debug("Forgejo token not configured, skipping error report")
|
||||||
|
return False
|
||||||
|
|
||||||
|
message = entry.get("message", "Unknown error")
|
||||||
|
stack = entry.get("stack")
|
||||||
|
|
||||||
|
# Deduplication check
|
||||||
|
dedup_key = _make_dedup_key(message, stack)
|
||||||
|
if await _is_duplicate(dedup_key):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Rate limit check
|
||||||
|
if not await _check_rate_limit():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Build issue body
|
||||||
|
body_parts = ["## Error Report\n"]
|
||||||
|
body_parts.append(f"**Message:** {message}")
|
||||||
|
if stack:
|
||||||
|
body_parts.append(f"\n**Stack:**\n```\n{stack}\n```")
|
||||||
|
if entry.get("url"):
|
||||||
|
body_parts.append(f"\n**URL:** {entry['url']}")
|
||||||
|
if entry.get("userAgent"):
|
||||||
|
body_parts.append(f"\n**User Agent:** {entry['userAgent']}")
|
||||||
|
if entry.get("timestamp"):
|
||||||
|
body_parts.append(f"\n**Timestamp:** {entry['timestamp']}")
|
||||||
|
if entry.get("context"):
|
||||||
|
import json
|
||||||
|
context_str = json.dumps(entry["context"], indent=2, default=str)
|
||||||
|
body_parts.append(f"\n**Context:**\n```json\n{context_str}\n```")
|
||||||
|
|
||||||
|
body = "\n".join(body_parts)
|
||||||
|
|
||||||
|
# Truncate title if too long (Forgejo/Gitea has limits)
|
||||||
|
title = f"[Auto] {message}"
|
||||||
|
if len(title) > 255:
|
||||||
|
title = title[:252] + "..."
|
||||||
|
|
||||||
|
issue_data = {
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"labels": ["auto-reported", "bug"],
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"{settings['url']}/api/v1/repos/{settings['owner']}/{settings['repo']}/issues"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"token {settings['token']}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
# Ensure labels exist first
|
||||||
|
await _ensure_labels_exist(client, settings)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.post(url, headers=headers, json=issue_data)
|
||||||
|
if response.status_code in (201, 200):
|
||||||
|
issue = response.json()
|
||||||
|
issue_number = issue.get("number", "unknown")
|
||||||
|
logger.info(
|
||||||
|
"Created Forgejo issue #%s for error: %s",
|
||||||
|
issue_number,
|
||||||
|
message[:100],
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Failed to create Forgejo issue (status=%d): %s",
|
||||||
|
response.status_code,
|
||||||
|
response.text[:500],
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
logger.error("HTTP error creating Forgejo issue: %s", exc)
|
||||||
|
return False
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
logger.error("Network error creating Forgejo issue: %s", exc)
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Unexpected error creating Forgejo issue: %s", exc)
|
||||||
|
return False
|
||||||
@@ -74,4 +74,19 @@ async def report_error(error: ErrorReport, request: Request) -> Response:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# If forgejo_error_reporter plugin is active, forward error
|
||||||
|
try:
|
||||||
|
from app.plugins.builtins.forgejo_error_reporter.service import report_error_to_forgejo
|
||||||
|
entry = {
|
||||||
|
"message": error.message,
|
||||||
|
"stack": error.stack,
|
||||||
|
"url": error.url,
|
||||||
|
"userAgent": error.userAgent,
|
||||||
|
"timestamp": error.timestamp,
|
||||||
|
"context": error.context,
|
||||||
|
}
|
||||||
|
await report_error_to_forgejo(entry)
|
||||||
|
except Exception:
|
||||||
|
pass # Plugin not active or error in reporting
|
||||||
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
+48
-12
@@ -5,27 +5,62 @@ import { setUnauthorizedHandler } from '@/api/client';
|
|||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { useThemeStore } from '@/store/themeStore';
|
import { useThemeStore } from '@/store/themeStore';
|
||||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
function QueryClientWrapper({ children }: { children: React.ReactNode }) {
|
||||||
defaultOptions: {
|
const toast = useToast();
|
||||||
queries: {
|
|
||||||
retry: 1,
|
const [queryClient] = React.useState(() => new QueryClient({
|
||||||
staleTime: 5 * 60 * 1000,
|
defaultOptions: {
|
||||||
refetchOnWindowFocus: false,
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: 0,
|
||||||
|
onError: (error: any) => {
|
||||||
|
// Don't show toast for 401 (handled by auth)
|
||||||
|
if (error?.status === 401) return;
|
||||||
|
const msg = error?.detail || error?.message || 'Ein Fehler ist aufgetreten';
|
||||||
|
toast.error(msg);
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}));
|
||||||
});
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OfflineBanner() {
|
||||||
|
const isOnline = useOnlineStatus();
|
||||||
|
|
||||||
|
if (isOnline) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-0 left-0 right-0 z-[200] bg-warning-500 text-white text-center py-2 px-4 text-sm font-medium shadow-md">
|
||||||
|
Sie sind offline. Änderungen werden gespeichert wenn die Verbindung wiederhergestellt ist.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { logout } = useAuthStore();
|
const { logout } = useAuthStore();
|
||||||
const loadThemeFromStorage = useThemeStore((s) => s.loadFromStorage);
|
const loadThemeFromStorage = useThemeStore((s) => s.loadFromStorage);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setUnauthorizedHandler(() => {
|
setUnauthorizedHandler(() => {
|
||||||
|
toast.warning('Ihre Sitzung ist abgelaufen. Sie werden zur Anmeldung weitergeleitet.');
|
||||||
logout();
|
logout();
|
||||||
window.location.href = '/login';
|
setTimeout(() => { window.location.href = '/login'; }, 1500);
|
||||||
});
|
});
|
||||||
}, [logout]);
|
}, [logout, toast]);
|
||||||
|
|
||||||
// Load theme from localStorage on app start
|
// Load theme from localStorage on app start
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -33,10 +68,11 @@ export default function App() {
|
|||||||
}, [loadThemeFromStorage]);
|
}, [loadThemeFromStorage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientWrapper>
|
||||||
|
<OfflineBanner />
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</QueryClientProvider>
|
</QueryClientWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -364,9 +364,14 @@ export function useAIUIControl() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = (event) => {
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
console.log('AI UI Control WebSocket disconnected');
|
console.log('AI UI Control WebSocket disconnected');
|
||||||
|
// Don't reconnect on 403 (forbidden) — user lacks permission
|
||||||
|
if (event.code === 3003 || event.code === 1008) {
|
||||||
|
console.warn('AI UI Control WebSocket closed with forbidden code — not reconnecting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
|
||||||
reconnectAttempts++;
|
reconnectAttempts++;
|
||||||
reconnectTimeout.current = window.setTimeout(connect, delay);
|
reconnectTimeout.current = window.setTimeout(connect, delay);
|
||||||
|
|||||||
@@ -98,8 +98,13 @@ export function useCommWebSocket() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = (event) => {
|
||||||
console.log('Comm WebSocket disconnected');
|
console.log('Comm WebSocket disconnected');
|
||||||
|
// Don't reconnect on 403 (forbidden) — user lacks permission
|
||||||
|
if (event.code === 3003 || event.code === 1008) {
|
||||||
|
console.warn('Comm WebSocket closed with forbidden code — not reconnecting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
|
||||||
reconnectAttempts++;
|
reconnectAttempts++;
|
||||||
reconnectTimeout.current = window.setTimeout(connect, delay);
|
reconnectTimeout.current = window.setTimeout(connect, delay);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useOnlineStatus() {
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const goOnline = () => setIsOnline(true);
|
||||||
|
const goOffline = () => setIsOnline(false);
|
||||||
|
window.addEventListener('online', goOnline);
|
||||||
|
window.addEventListener('offline', goOffline);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', goOnline);
|
||||||
|
window.removeEventListener('offline', goOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isOnline;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export type ErrorCategory = 'network' | 'permission' | 'validation' | 'server' | 'auth' | 'unknown';
|
||||||
|
|
||||||
|
export interface CategorizedError {
|
||||||
|
category: ErrorCategory;
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
detail?: string;
|
||||||
|
code?: string;
|
||||||
|
field?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categorizeError(error: any): CategorizedError {
|
||||||
|
const status = error?.status || 0;
|
||||||
|
if (status === 0) return { category: 'network', status: 0, message: 'Netzwerkfehler', detail: error?.message };
|
||||||
|
if (status === 401) return { category: 'auth', status, message: 'Nicht authentifiziert', detail: error?.detail };
|
||||||
|
if (status === 403) return { category: 'permission', status, message: 'Keine Berechtigung', detail: error?.detail };
|
||||||
|
if (status === 422) return { category: 'validation', status, message: 'Validierungsfehler', detail: error?.detail, field: error?.field };
|
||||||
|
if (status >= 500) return { category: 'server', status, message: 'Serverfehler', detail: error?.detail };
|
||||||
|
return { category: 'unknown', status, message: error?.message || 'Unbekannter Fehler', detail: error?.detail };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user