Files
gridpilot.gg/apps/website/components/dev/DevToolbar.tsx
2026-01-03 02:42:47 +01:00

501 lines
18 KiB
TypeScript

'use client';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useNotifications } from '@/components/notifications/NotificationProvider';
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
import { Wrench, ChevronDown, ChevronUp, X, MessageSquare, Activity, LogIn, AlertTriangle } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { ApiConnectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
// Import our new components
import { Accordion } from './Accordion';
import { NotificationTypeSection } from './sections/NotificationTypeSection';
import { UrgencySection } from './sections/UrgencySection';
import { NotificationSendSection } from './sections/NotificationSendSection';
import { APIStatusSection } from './sections/APIStatusSection';
import { LoginSection } from './sections/LoginSection';
// Import types
import type { DemoNotificationType, DemoUrgency, LoginMode } from './types';
export default function DevToolbar() {
const router = useRouter();
const { addNotification } = useNotifications();
const [isExpanded, setIsExpanded] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [selectedType, setSelectedType] = useState<DemoNotificationType>('protest_filed');
const [selectedUrgency, setSelectedUrgency] = useState<DemoUrgency>('toast');
const [sending, setSending] = useState(false);
const [lastSent, setLastSent] = useState<string | null>(null);
const [loginMode, setLoginMode] = useState<LoginMode>('none');
const [loggingIn, setLoggingIn] = useState(false);
// API Status Monitoring State
const [apiStatus, setApiStatus] = useState(() => ApiConnectionMonitor.getInstance().getStatus());
const [apiHealth, setApiHealth] = useState(() => ApiConnectionMonitor.getInstance().getHealth());
const [circuitBreakers, setCircuitBreakers] = useState(() => CircuitBreakerRegistry.getInstance().getStatus());
const [checkingHealth, setCheckingHealth] = useState(false);
// Error Stats State
const [errorStats, setErrorStats] = useState({ total: 0, byType: {} as Record<string, number> });
// Accordion state - only one open at a time
const [openAccordion, setOpenAccordion] = useState<string | null>('notifications');
const currentDriverId = useEffectiveDriverId();
// Sync login mode with actual session state on mount
useEffect(() => {
if (typeof document !== 'undefined') {
// Check for actual session cookie first
const cookies = document.cookie.split(';');
const sessionCookie = cookies.find(c => c.trim().startsWith('gp_session='));
if (sessionCookie) {
// User has a session cookie, check if it's valid by calling the API
fetch('/api/auth/session', {
method: 'GET',
credentials: 'include'
})
.then(res => {
if (res.ok) {
return res.json();
}
throw new Error('No valid session');
})
.then(session => {
if (session && session.user) {
// Determine login mode based on user email patterns
const email = session.user.email?.toLowerCase() || '';
const displayName = session.user.displayName?.toLowerCase() || '';
const role = (session.user as any).role;
let mode: LoginMode = 'none';
// First check session.role if available
if (role) {
if (role === 'sponsor') mode = 'sponsor';
else if (role === 'league-owner') mode = 'league-owner';
else if (role === 'league-steward') mode = 'league-steward';
else if (role === 'league-admin') mode = 'league-admin';
else if (role === 'system-owner') mode = 'system-owner';
else if (role === 'super-admin') mode = 'super-admin';
else if (role === 'driver') mode = 'driver';
}
// Fallback to email patterns
else if (email.includes('sponsor') || displayName.includes('sponsor')) {
mode = 'sponsor';
} else if (email.includes('league-owner') || displayName.includes('owner')) {
mode = 'league-owner';
} else if (email.includes('league-steward') || displayName.includes('steward')) {
mode = 'league-steward';
} else if (email.includes('league-admin') || displayName.includes('admin')) {
mode = 'league-admin';
} else if (email.includes('system-owner') || displayName.includes('system owner')) {
mode = 'system-owner';
} else if (email.includes('super-admin') || displayName.includes('super admin')) {
mode = 'super-admin';
} else if (email.includes('driver') || displayName.includes('demo')) {
mode = 'driver';
}
setLoginMode(mode);
} else {
setLoginMode('none');
}
})
.catch(() => {
// Session invalid or expired
setLoginMode('none');
});
} else {
// No session cookie means not logged in
setLoginMode('none');
}
}
}, []);
// API Status Monitoring Effects
useEffect(() => {
const monitor = ApiConnectionMonitor.getInstance();
const registry = CircuitBreakerRegistry.getInstance();
const updateStatus = () => {
setApiStatus(monitor.getStatus());
setApiHealth(monitor.getHealth());
setCircuitBreakers(registry.getStatus());
};
// Initial update
updateStatus();
// Listen for status changes
monitor.on('connected', updateStatus);
monitor.on('disconnected', updateStatus);
monitor.on('degraded', updateStatus);
monitor.on('success', updateStatus);
monitor.on('failure', updateStatus);
// Poll for updates every 2 seconds
const interval = setInterval(updateStatus, 2000);
return () => {
monitor.off('connected', updateStatus);
monitor.off('disconnected', updateStatus);
monitor.off('degraded', updateStatus);
monitor.off('success', updateStatus);
monitor.off('failure', updateStatus);
clearInterval(interval);
};
}, []);
// Error Stats Effect
useEffect(() => {
const updateErrorStats = () => {
try {
const handler = getGlobalErrorHandler();
const stats = handler.getStats();
setErrorStats(stats);
} catch {
// Handler might not be initialized yet
setErrorStats({ total: 0, byType: {} });
}
};
// Initial update
updateErrorStats();
// Poll for updates every 3 seconds
const interval = setInterval(updateErrorStats, 3000);
return () => clearInterval(interval);
}, []);
// API Health Check Handler
const handleApiHealthCheck = async () => {
setCheckingHealth(true);
try {
const monitor = ApiConnectionMonitor.getInstance();
const result = await monitor.performHealthCheck();
addNotification({
type: result.healthy ? 'api_healthy' : 'api_unhealthy',
title: result.healthy ? 'API Health Check Passed' : 'API Health Check Failed',
message: result.healthy
? `API responded in ${result.responseTime}ms`
: `Health check failed: ${result.error}`,
variant: 'toast',
});
} catch (error) {
addNotification({
type: 'api_error',
title: 'Health Check Error',
message: 'Failed to perform health check',
variant: 'toast',
});
} finally {
setCheckingHealth(false);
}
};
// Reset API Stats
const handleResetApiStats = () => {
ApiConnectionMonitor.getInstance().reset();
CircuitBreakerRegistry.getInstance().resetAll();
addNotification({
type: 'api_reset',
title: 'API Stats Reset',
message: 'All API connection statistics have been reset',
variant: 'toast',
});
};
// Test API Error
const handleTestApiError = async () => {
try {
// This will intentionally fail to test error handling
const response = await fetch('http://localhost:3001/api/nonexistent', {
method: 'GET',
});
if (!response.ok) {
throw new Error(`Test error: ${response.status}`);
}
} catch (error) {
addNotification({
type: 'api_test_error',
title: 'Test Error Triggered',
message: 'This is a test API error to demonstrate error handling',
variant: 'toast',
});
}
};
const handleDemoLogin = async (role: LoginMode) => {
if (role === 'none') return;
setLoggingIn(true);
try {
// Use the demo login API
const response = await fetch('/api/auth/demo-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
});
if (!response.ok) {
throw new Error('Demo login failed');
}
setLoginMode(role);
// Navigate based on role
if (role === 'sponsor') {
window.location.href = '/sponsor/dashboard';
} else {
// For driver and league roles, go to dashboard
window.location.href = '/dashboard';
}
} catch (error) {
alert('Demo login failed. Please check the API server status.');
} finally {
setLoggingIn(false);
}
};
const handleLogout = async () => {
setLoggingIn(true);
try {
// Call logout API
await fetch('/api/auth/logout', { method: 'POST' });
setLoginMode('none');
// Refresh to update all components
window.location.href = '/';
} catch (error) {
alert('Logout failed. Please check the API server status.');
} finally {
setLoggingIn(false);
}
};
// Only show in development
if (process.env.NODE_ENV === 'production') {
return null;
}
const handleSendNotification = async () => {
setSending(true);
try {
const actionUrlByType: Record<DemoNotificationType, string> = {
protest_filed: '/races',
defense_requested: '/races',
vote_required: '/leagues',
race_performance_summary: '/races',
race_final_results: '/races',
};
const titleByType: Record<DemoNotificationType, string> = {
protest_filed: 'Protest Filed Against You',
defense_requested: 'Defense Requested',
vote_required: 'Vote Required',
race_performance_summary: 'Race Performance Summary',
race_final_results: 'Race Final Results',
};
const messageByType: Record<DemoNotificationType, string> = {
protest_filed: 'A protest has been filed against you. Please review the incident details.',
defense_requested: 'A steward requests your defense. Please respond within the deadline.',
vote_required: 'A protest vote is pending. Please review and vote.',
race_performance_summary: 'Your race is complete. View your provisional results.',
race_final_results: 'Stewarding is closed. Your final results are available.',
};
const notificationTypeByDemoType: Record<DemoNotificationType, string> = {
protest_filed: 'protest_filed',
defense_requested: 'protest_defense_requested',
vote_required: 'protest_vote_required',
race_performance_summary: 'race_performance_summary',
race_final_results: 'race_final_results',
};
const variant: NotificationVariant = selectedUrgency === 'modal' ? 'modal' : 'toast';
addNotification({
type: notificationTypeByDemoType[selectedType],
title: titleByType[selectedType],
message: messageByType[selectedType],
variant,
actionUrl: actionUrlByType[selectedType],
data: {
driverId: currentDriverId,
demo: true,
},
});
setLastSent(`${selectedType}-${selectedUrgency}`);
setTimeout(() => setLastSent(null), 3000);
} catch (error) {
// Silent failure for demo notifications
setSending(false);
}
};
if (isMinimized) {
return (
<button
onClick={() => setIsMinimized(false)}
className="fixed bottom-4 right-4 z-50 p-3 bg-iron-gray border border-charcoal-outline rounded-full shadow-lg hover:bg-charcoal-outline transition-colors"
title="Open Dev Toolbar"
>
<Wrench className="w-5 h-5 text-primary-blue" />
</button>
);
}
return (
<div className="fixed bottom-4 right-4 z-50 w-80 bg-deep-graphite border border-charcoal-outline rounded-xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-iron-gray/50 border-b border-charcoal-outline">
<div className="flex items-center gap-2">
<Wrench className="w-4 h-4 text-primary-blue" />
<span className="text-sm font-semibold text-white">Dev Toolbar</span>
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-primary-blue/20 text-primary-blue rounded">
DEMO
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1.5 hover:bg-charcoal-outline rounded transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronUp className="w-4 h-4 text-gray-400" />
)}
</button>
<button
onClick={() => setIsMinimized(true)}
className="p-1.5 hover:bg-charcoal-outline rounded transition-colors"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
</div>
{/* Content */}
{isExpanded && (
<div className="p-4 space-y-3">
{/* Notification Section - Accordion */}
<Accordion
title="Notifications"
icon={<MessageSquare className="w-4 h-4 text-gray-400" />}
isOpen={openAccordion === 'notifications'}
onToggle={() => setOpenAccordion(openAccordion === 'notifications' ? null : 'notifications')}
>
<div className="space-y-3">
<NotificationTypeSection
selectedType={selectedType}
onSelectType={setSelectedType}
/>
<UrgencySection
selectedUrgency={selectedUrgency}
onSelectUrgency={setSelectedUrgency}
/>
<NotificationSendSection
selectedType={selectedType}
selectedUrgency={selectedUrgency}
sending={sending}
lastSent={lastSent}
onSend={handleSendNotification}
/>
</div>
</Accordion>
{/* API Status Section - Accordion */}
<Accordion
title="API Status"
icon={<Activity className="w-4 h-4 text-gray-400" />}
isOpen={openAccordion === 'apiStatus'}
onToggle={() => setOpenAccordion(openAccordion === 'apiStatus' ? null : 'apiStatus')}
>
<APIStatusSection
apiStatus={apiStatus}
apiHealth={apiHealth}
circuitBreakers={circuitBreakers}
checkingHealth={checkingHealth}
onHealthCheck={handleApiHealthCheck}
onResetStats={handleResetApiStats}
onTestError={handleTestApiError}
/>
</Accordion>
{/* Login Section - Accordion */}
<Accordion
title="Demo Login"
icon={<LogIn className="w-4 h-4 text-gray-400" />}
isOpen={openAccordion === 'login'}
onToggle={() => setOpenAccordion(openAccordion === 'login' ? null : 'login')}
>
<LoginSection
loginMode={loginMode}
loggingIn={loggingIn}
onDemoLogin={handleDemoLogin}
onLogout={handleLogout}
/>
</Accordion>
{/* Error Stats Section - Accordion */}
<Accordion
title="Error Stats"
icon={<AlertTriangle className="w-4 h-4 text-gray-400" />}
isOpen={openAccordion === 'errors'}
onToggle={() => setOpenAccordion(openAccordion === 'errors' ? null : 'errors')}
>
<div className="space-y-2 text-xs">
<div className="flex justify-between items-center p-2 bg-iron-gray/30 rounded">
<span className="text-gray-400">Total Errors</span>
<span className="font-mono font-bold text-red-400">{errorStats.total}</span>
</div>
{Object.keys(errorStats.byType).length > 0 ? (
<div className="space-y-1">
{Object.entries(errorStats.byType).map(([type, count]) => (
<div key={type} className="flex justify-between items-center p-1.5 bg-deep-graphite rounded">
<span className="text-gray-300">{type}</span>
<span className="font-mono text-yellow-400">{count}</span>
</div>
))}
</div>
) : (
<div className="text-center text-gray-500 py-2">No errors yet</div>
)}
<button
onClick={() => {
const handler = getGlobalErrorHandler();
handler.clearHistory();
setErrorStats({ total: 0, byType: {} });
}}
className="w-full p-2 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded border border-charcoal-outline text-xs"
>
Clear Error History
</button>
</div>
</Accordion>
</div>
)}
{/* Collapsed state hint */}
{!isExpanded && (
<div className="px-4 py-2 text-xs text-gray-500">
Click to expand dev tools
</div>
)}
</div>
);
}