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 ( + + {children} + + ); +} + +function OfflineBanner() { + const isOnline = useOnlineStatus(); + + if (isOnline) return null; + + return ( +
+ Sie sind offline. Änderungen werden gespeichert wenn die Verbindung wiederhergestellt ist. +
+ ); +} 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 ( - + + - + ); } diff --git a/frontend/src/hooks/useAIUIControl.ts b/frontend/src/hooks/useAIUIControl.ts index 07dd513..0e5664e 100644 --- a/frontend/src/hooks/useAIUIControl.ts +++ b/frontend/src/hooks/useAIUIControl.ts @@ -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); diff --git a/frontend/src/hooks/useCommWebSocket.ts b/frontend/src/hooks/useCommWebSocket.ts index b376dbc..9cc4652 100644 --- a/frontend/src/hooks/useCommWebSocket.ts +++ b/frontend/src/hooks/useCommWebSocket.ts @@ -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); diff --git a/frontend/src/hooks/useOnlineStatus.ts b/frontend/src/hooks/useOnlineStatus.ts new file mode 100644 index 0000000..6d97a19 --- /dev/null +++ b/frontend/src/hooks/useOnlineStatus.ts @@ -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; +} diff --git a/frontend/src/utils/errorTypes.ts b/frontend/src/utils/errorTypes.ts new file mode 100644 index 0000000..3a54f68 --- /dev/null +++ b/frontend/src/utils/errorTypes.ts @@ -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 }; +}