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.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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
+42
-6
@@ -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({
|
||||
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 (
|
||||
<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() {
|
||||
const { logout } = useAuthStore();
|
||||
const loadThemeFromStorage = useThemeStore((s) => s.loadFromStorage);
|
||||
const toast = useToast();
|
||||
|
||||
React.useEffect(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
toast.warning('Ihre Sitzung ist abgelaufen. Sie werden zur Anmeldung weitergeleitet.');
|
||||
logout();
|
||||
window.location.href = '/login';
|
||||
setTimeout(() => { window.location.href = '/login'; }, 1500);
|
||||
});
|
||||
}, [logout]);
|
||||
}, [logout, toast]);
|
||||
|
||||
// Load theme from localStorage on app start
|
||||
React.useEffect(() => {
|
||||
@@ -33,10 +68,11 @@ export default function App() {
|
||||
}, [loadThemeFromStorage]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientWrapper>
|
||||
<OfflineBanner />
|
||||
<ErrorBoundary>
|
||||
<AppRouter />
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
</QueryClientWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -364,9 +364,14 @@ export function useAIUIControl() {
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
ws.onclose = (event) => {
|
||||
setConnected(false);
|
||||
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);
|
||||
reconnectAttempts++;
|
||||
reconnectTimeout.current = window.setTimeout(connect, delay);
|
||||
|
||||
@@ -98,8 +98,13 @@ export function useCommWebSocket() {
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
ws.onclose = (event) => {
|
||||
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);
|
||||
reconnectAttempts++;
|
||||
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