diff --git a/app/core/error_codes.py b/app/core/error_codes.py
new file mode 100644
index 0000000..cc06162
--- /dev/null
+++ b/app/core/error_codes.py
@@ -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)
diff --git a/app/core/plugin_error_handler.py b/app/core/plugin_error_handler.py
new file mode 100644
index 0000000..45907a5
--- /dev/null
+++ b/app/core/plugin_error_handler.py
@@ -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
diff --git a/app/main.py b/app/main.py
index 6711037..90e7b38 100644
--- a/app/main.py
+++ b/app/main.py
@@ -19,8 +19,10 @@ logger = logging.getLogger(__name__)
from app.config import get_settings
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.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.plugins.registry import get_registry
from app.routes import (
@@ -325,6 +327,14 @@ def create_app() -> FastAPI:
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(metrics.router)
app.include_router(auth.router)
@@ -380,6 +390,7 @@ def create_app() -> FastAPI:
"app.plugins.builtins.mcp_server",
"app.plugins.builtins.system_notif",
"app.plugins.builtins.unified_search",
+ "app.plugins.builtins.forgejo_error_reporter",
]
for mod_name in plugin_modules:
try:
@@ -392,6 +403,10 @@ def create_app() -> FastAPI:
try:
router_module = importlib.import_module(route_def.module)
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)
except Exception as exc:
logger.error(f"Failed to register route {route_def.module}.{route_def.router_attr}: {exc}")
diff --git a/app/plugins/builtins/forgejo_error_reporter/__init__.py b/app/plugins/builtins/forgejo_error_reporter/__init__.py
new file mode 100644
index 0000000..87195d2
--- /dev/null
+++ b/app/plugins/builtins/forgejo_error_reporter/__init__.py
@@ -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"]
diff --git a/app/plugins/builtins/forgejo_error_reporter/migrations/0001_initial.sql b/app/plugins/builtins/forgejo_error_reporter/migrations/0001_initial.sql
new file mode 100644
index 0000000..649bd6a
--- /dev/null
+++ b/app/plugins/builtins/forgejo_error_reporter/migrations/0001_initial.sql
@@ -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);
diff --git a/app/plugins/builtins/forgejo_error_reporter/models.py b/app/plugins/builtins/forgejo_error_reporter/models.py
new file mode 100644
index 0000000..5396760
--- /dev/null
+++ b/app/plugins/builtins/forgejo_error_reporter/models.py
@@ -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")
diff --git a/app/plugins/builtins/forgejo_error_reporter/plugin.py b/app/plugins/builtins/forgejo_error_reporter/plugin.py
new file mode 100644
index 0000000..c7ff415
--- /dev/null
+++ b/app/plugins/builtins/forgejo_error_reporter/plugin.py
@@ -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")
diff --git a/app/plugins/builtins/forgejo_error_reporter/routes.py b/app/plugins/builtins/forgejo_error_reporter/routes.py
new file mode 100644
index 0000000..af985a8
--- /dev/null
+++ b/app/plugins/builtins/forgejo_error_reporter/routes.py
@@ -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",
+ }
diff --git a/app/plugins/builtins/forgejo_error_reporter/schemas.py b/app/plugins/builtins/forgejo_error_reporter/schemas.py
new file mode 100644
index 0000000..3fdf56c
--- /dev/null
+++ b/app/plugins/builtins/forgejo_error_reporter/schemas.py
@@ -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")
diff --git a/app/plugins/builtins/forgejo_error_reporter/service.py b/app/plugins/builtins/forgejo_error_reporter/service.py
new file mode 100644
index 0000000..602d7a1
--- /dev/null
+++ b/app/plugins/builtins/forgejo_error_reporter/service.py
@@ -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
diff --git a/app/routes/errors.py b/app/routes/errors.py
index 6012bfd..b04c793 100644
--- a/app/routes/errors.py
+++ b/app/routes/errors.py
@@ -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)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index aeac8a5..ae867d4 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -5,27 +5,62 @@ import { setUnauthorizedHandler } from '@/api/client';
import { useAuthStore } from '@/store/authStore';
import { useThemeStore } from '@/store/themeStore';
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
+import { useToast } from '@/components/ui/Toast';
+import { useOnlineStatus } from '@/hooks/useOnlineStatus';
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: 1,
- staleTime: 5 * 60 * 1000,
- refetchOnWindowFocus: false,
+function QueryClientWrapper({ children }: { children: React.ReactNode }) {
+ const toast = useToast();
+
+ const [queryClient] = React.useState(() => new QueryClient({
+ defaultOptions: {
+ 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 (
+