2026-07-23 19:01:18 +02:00
|
|
|
|
import React, { Suspense, lazy, Component, ReactNode } from 'react';
|
|
|
|
|
|
import { Loader2 } from 'lucide-react';
|
2026-08-13 21:57:59 +02:00
|
|
|
|
import { logError } from '@/utils/errorLogger';
|
2026-07-23 19:01:18 +02:00
|
|
|
|
|
|
|
|
|
|
// ── Error Boundary ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
interface PluginErrorBoundaryProps {
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
pluginName: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface PluginErrorBoundaryState {
|
|
|
|
|
|
hasError: boolean;
|
2026-08-13 21:57:59 +02:00
|
|
|
|
error: Error | null;
|
|
|
|
|
|
traceId: string | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function generateTraceId(): string {
|
|
|
|
|
|
return 'plugin-err-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
2026-07-23 19:01:18 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class PluginErrorBoundary extends Component<PluginErrorBoundaryProps, PluginErrorBoundaryState> {
|
2026-08-13 21:57:59 +02:00
|
|
|
|
state: PluginErrorBoundaryState = { hasError: false, error: null, traceId: null };
|
2026-07-23 19:01:18 +02:00
|
|
|
|
|
2026-08-13 21:57:59 +02:00
|
|
|
|
static getDerivedStateFromError(error: Error): PluginErrorBoundaryState {
|
|
|
|
|
|
return { hasError: true, error, traceId: generateTraceId() };
|
2026-07-23 19:01:18 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 21:57:59 +02:00
|
|
|
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
|
|
|
|
|
logError(error, {
|
|
|
|
|
|
componentStack: errorInfo.componentStack,
|
|
|
|
|
|
pluginName: this.props.pluginName,
|
|
|
|
|
|
trace_id: this.state.traceId,
|
|
|
|
|
|
source: 'PluginErrorBoundary',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
handleRetry = (): void => {
|
|
|
|
|
|
this.setState({ hasError: false, error: null, traceId: null });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-23 19:01:18 +02:00
|
|
|
|
render() {
|
|
|
|
|
|
if (this.state.hasError) {
|
|
|
|
|
|
return (
|
2026-08-13 21:57:59 +02:00
|
|
|
|
<div
|
|
|
|
|
|
className="flex flex-col items-center justify-center min-h-[300px] p-8 text-center"
|
|
|
|
|
|
role="alert"
|
|
|
|
|
|
aria-live="assertive"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="text-danger-500 text-4xl mb-3" aria-hidden="true">⚠️</div>
|
|
|
|
|
|
<h2 className="text-lg font-bold text-secondary-900 mb-2">
|
|
|
|
|
|
Plugin konnte nicht geladen werden
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<p className="text-sm text-secondary-600 mb-3">
|
|
|
|
|
|
Das Plugin „{this.props.pluginName}" hat einen Fehler verursacht.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{this.state.traceId && (
|
|
|
|
|
|
<p className="text-xs text-secondary-400 mb-3 font-mono" data-testid="plugin-error-trace-id">
|
|
|
|
|
|
Trace-ID: {this.state.traceId}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{this.state.error && (
|
|
|
|
|
|
<details className="mb-4 max-w-lg text-left text-secondary-500">
|
|
|
|
|
|
<summary className="cursor-pointer text-sm hover:text-secondary-700">
|
|
|
|
|
|
Fehlerdetails
|
|
|
|
|
|
</summary>
|
|
|
|
|
|
<pre className="mt-2 p-3 bg-secondary-50 rounded text-xs overflow-auto max-h-32">
|
|
|
|
|
|
{this.state.error.message}
|
|
|
|
|
|
{this.state.error.stack ? `\n\n${this.state.error.stack}` : ''}
|
|
|
|
|
|
</pre>
|
|
|
|
|
|
</details>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="flex gap-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={this.handleRetry}
|
|
|
|
|
|
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-md hover:bg-primary-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 transition-colors min-h-touch"
|
|
|
|
|
|
>
|
|
|
|
|
|
Erneut versuchen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => window.location.reload()}
|
|
|
|
|
|
className="inline-flex items-center px-4 py-2 bg-secondary-100 text-secondary-700 text-sm font-medium rounded-md hover:bg-secondary-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400 transition-colors min-h-touch"
|
|
|
|
|
|
>
|
|
|
|
|
|
Neu laden
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-07-23 19:01:18 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return this.props.children;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 22:14:45 +02:00
|
|
|
|
// ── Static Chunk Map (ARCH-019) ─────────────────────────────────────────
|
|
|
|
|
|
//
|
|
|
|
|
|
// Vite cannot statically analyze dynamic import paths built at runtime
|
|
|
|
|
|
// (`import(/* @vite-ignore */ path)`), so production builds would fail to
|
|
|
|
|
|
// load plugin pages. All plugin-contributed component paths are known at
|
|
|
|
|
|
// build time — they are declared in the backend manifests — so we register
|
|
|
|
|
|
// them here as explicit lazy imports. Vite chunks each one correctly.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Unknown paths (e.g. third-party plugins added later) fall back to the
|
|
|
|
|
|
// runtime dynamic import, which works in dev mode.
|
|
|
|
|
|
|
|
|
|
|
|
type ComponentModule = Record<string, unknown> & { default?: React.ComponentType<any> };
|
|
|
|
|
|
type LazyComponentFactory = () => Promise<{ default: React.ComponentType<any> }>;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Normalize a module namespace to the { default } shape React.lazy expects:
|
|
|
|
|
|
* uses the default export when present, otherwise the first named export
|
|
|
|
|
|
* (same fallback as the runtime dynamic-import path below).
|
|
|
|
|
|
*/
|
|
|
|
|
|
function normalizeModule(m: ComponentModule): { default: React.ComponentType<any> } {
|
|
|
|
|
|
const Comp = (m.default ?? m[Object.keys(m)[0]]) as React.ComponentType<any>;
|
|
|
|
|
|
return { default: Comp };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const STATIC_COMPONENT_MAP: Record<string, LazyComponentFactory> = {
|
|
|
|
|
|
// Pages
|
|
|
|
|
|
'@/pages/AgentDashboard': () => import('@/pages/AgentDashboard').then(normalizeModule),
|
2026-08-24 21:50:21 +02:00
|
|
|
|
// BUG (ghost page) fixed in Block I-D: page now exists.
|
|
|
|
|
|
'@/pages/AIAssistant': () => import('@/pages/AIAssistant').then(normalizeModule),
|
2026-08-23 22:14:45 +02:00
|
|
|
|
'@/pages/AISettings': () => import('@/pages/AISettings').then(normalizeModule),
|
|
|
|
|
|
'@/pages/AutomationDashboard': () => import('@/pages/AutomationDashboard').then(normalizeModule),
|
|
|
|
|
|
'@/pages/AutomationSettings': () => import('@/pages/AutomationSettings').then(normalizeModule),
|
|
|
|
|
|
'@/pages/Calendar': () => import('@/pages/Calendar').then(normalizeModule),
|
|
|
|
|
|
'@/pages/Communication': () => import('@/pages/Communication').then(normalizeModule),
|
|
|
|
|
|
'@/pages/Dms': () => import('@/pages/Dms').then(normalizeModule),
|
|
|
|
|
|
'@/pages/DmsTrash': () => import('@/pages/DmsTrash').then(normalizeModule),
|
|
|
|
|
|
'@/pages/GlobalSearchResults': () => import('@/pages/GlobalSearchResults').then(normalizeModule),
|
|
|
|
|
|
'@/pages/ImportExport': () => import('@/pages/ImportExport').then(normalizeModule),
|
|
|
|
|
|
'@/pages/Mail': () => import('@/pages/Mail').then(normalizeModule),
|
|
|
|
|
|
'@/pages/MailSettings': () => import('@/pages/MailSettings').then(normalizeModule),
|
|
|
|
|
|
'@/pages/ProactiveAISettings': () => import('@/pages/ProactiveAISettings').then(normalizeModule),
|
|
|
|
|
|
'@/pages/Reports': () => import('@/pages/Reports').then(normalizeModule),
|
|
|
|
|
|
'@/pages/SettingsGroups': () => import('@/pages/SettingsGroups').then(normalizeModule),
|
|
|
|
|
|
'@/pages/SettingsNotifications': () => import('@/pages/SettingsNotifications').then(normalizeModule),
|
|
|
|
|
|
'@/pages/SettingsRoles': () => import('@/pages/SettingsRoles').then(normalizeModule),
|
|
|
|
|
|
'@/pages/SettingsUsers': () => import('@/pages/SettingsUsers').then(normalizeModule),
|
|
|
|
|
|
'@/pages/Tasks': () => import('@/pages/Tasks').then(normalizeModule),
|
|
|
|
|
|
'@/pages/Wiki': () => import('@/pages/Wiki').then(normalizeModule),
|
|
|
|
|
|
'@/pages/Workflows': () => import('@/pages/Workflows').then(normalizeModule),
|
|
|
|
|
|
// NOTE: The backend manifests also declare contact detail tabs
|
|
|
|
|
|
// (@/components/contact/Contact*Tab) whose components do not exist in the
|
|
|
|
|
|
// frontend yet — they are intentionally NOT registered here; they fall
|
|
|
|
|
|
// through to the runtime fallback and surface via the error boundary.
|
|
|
|
|
|
// See PROGRESS.md "Ghost components" finding.
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-23 19:01:18 +02:00
|
|
|
|
// ── Lazy Component Cache ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const componentCache = new Map<string, React.LazyExoticComponent<React.ComponentType<any>>>();
|
|
|
|
|
|
|
|
|
|
|
|
function getLazyComponent(componentPath: string): React.LazyExoticComponent<React.ComponentType<any>> {
|
|
|
|
|
|
if (componentCache.has(componentPath)) {
|
|
|
|
|
|
return componentCache.get(componentPath)!;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 22:14:45 +02:00
|
|
|
|
// Known plugin components: statically chunked at build time (ARCH-019)
|
|
|
|
|
|
const factory = STATIC_COMPONENT_MAP[componentPath];
|
|
|
|
|
|
if (factory) {
|
|
|
|
|
|
const LazyComp = lazy(factory);
|
|
|
|
|
|
componentCache.set(componentPath, LazyComp);
|
|
|
|
|
|
return LazyComp;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fallback for unknown paths (third-party plugins): runtime dynamic
|
|
|
|
|
|
// import. Works in dev; production needs the path registered above.
|
2026-07-24 19:29:48 +02:00
|
|
|
|
const importPath = componentPath.replace(/^@\//, '../../');
|
2026-07-23 19:01:18 +02:00
|
|
|
|
|
|
|
|
|
|
const LazyComp = lazy(() =>
|
|
|
|
|
|
import(/* @vite-ignore */ importPath).then((m) => ({
|
|
|
|
|
|
default: m.default || m[Object.keys(m)[0]],
|
|
|
|
|
|
}))
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
componentCache.set(componentPath, LazyComp);
|
|
|
|
|
|
return LazyComp;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── PluginPage Component ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
interface PluginPageProps {
|
|
|
|
|
|
component: string;
|
|
|
|
|
|
pluginName: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* PluginPage — dynamically loads a plugin page component using React.lazy().
|
|
|
|
|
|
* Wraps the lazy component in Suspense with a centered spinner fallback
|
|
|
|
|
|
* and an ErrorBoundary that shows a fallback message on failure.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function PluginPage({ component, pluginName }: PluginPageProps) {
|
|
|
|
|
|
const LazyComp = getLazyComponent(component);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<PluginErrorBoundary pluginName={pluginName}>
|
|
|
|
|
|
<Suspense
|
|
|
|
|
|
fallback={
|
|
|
|
|
|
<div className="flex items-center justify-center min-h-[50vh]">
|
|
|
|
|
|
<Loader2 className="animate-spin h-8 w-8 text-primary-500" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<LazyComp />
|
|
|
|
|
|
</Suspense>
|
|
|
|
|
|
</PluginErrorBoundary>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|