983 lines
62 KiB
TypeScript
983 lines
62 KiB
TypeScript
import React from 'react';
|
|
import { ConfigTab } from './ConfigTab';
|
|
import { ReconciliationAuditPanel } from './ReconciliationAuditPanel';
|
|
import {
|
|
ShieldCheck, Activity, Cpu, Hexagon,
|
|
Settings2, Bug,
|
|
Wifi, WifiOff,
|
|
TrendingUp, Clock, Crosshair, Zap,
|
|
Target, ShieldAlert, BrainCircuit,
|
|
ChevronRight, Pause, Play, AlertTriangle,
|
|
Database, RefreshCcw, Heart, Info, XCircle
|
|
} from 'lucide-react';
|
|
import type { BotState } from '../hooks/useWebSocket';
|
|
import type { Socket } from 'socket.io-client';
|
|
import { useAuth } from '../components/AuthContext';
|
|
import { fetchDynamicConfigItems, upsertDynamicConfigItems } from '../lib/dynamicConfigApi';
|
|
import { getPlatformAccessToken } from '../lib/authSession';
|
|
import { createRequestId } from '../../../shared/request-id.js';
|
|
import { tradingRuntime } from '../lib/runtime';
|
|
|
|
interface AdminTabProps {
|
|
botState: BotState;
|
|
socket: Socket | null;
|
|
}
|
|
|
|
const ruleDescriptions: { [key: string]: { desc: string; category: string; icon: typeof Hexagon } } = {
|
|
'TrendBiasRule': { desc: 'Analyzes 4H and 1H trends to determine the primary market bias using EMA crossovers.', category: 'Trend', icon: TrendingUp },
|
|
'SessionRule': { desc: 'Restricts trading to major market sessions — London and New York overlap.', category: 'Filter', icon: Clock },
|
|
'ZoneRule': { desc: 'Identifies key Support & Resistance zones for high-precision entries.', category: 'Entry', icon: Crosshair },
|
|
'MomentumRule': { desc: 'Analyzes RSI divergence and EMA crossovers for short-term momentum.', category: 'Momentum', icon: Zap },
|
|
'EntryTriggerRule': { desc: 'Final confirmation logic for executing market orders based on pattern detection.', category: 'Entry', icon: Target },
|
|
'RiskManagementRule': { desc: 'Calculates dynamic stop-loss, take-profit targets, and position sizing via ATR.', category: 'Risk', icon: ShieldAlert },
|
|
'AIAnalysisRule': { desc: 'Leverages LLM models (Perplexity / OpenAI) for real-time sentiment validation.', category: 'AI', icon: BrainCircuit }
|
|
};
|
|
|
|
const categoryColors: { [key: string]: string } = {
|
|
'Trend': 'var(--bl-info-strong)',
|
|
'Filter': 'var(--bl-emphasis)',
|
|
'Entry': 'var(--bl-warning)',
|
|
'Momentum': 'var(--bl-attention)',
|
|
'Risk': 'var(--bl-danger)',
|
|
'AI': 'var(--bl-success)',
|
|
};
|
|
|
|
export const AdminTab = ({ botState, socket }: AdminTabProps) => {
|
|
const { profile } = useAuth();
|
|
const [subTab, setSubTab] = React.useState<'rules' | 'config' | 'debug' | 'health' | 'reconciliation'>('rules');
|
|
const [debugLogs, setDebugLogs] = React.useState<any[]>([]);
|
|
const logEndRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
React.useEffect(() => {
|
|
if (!socket) return;
|
|
const handler = (log: any) => {
|
|
setDebugLogs(prev => [...prev, log].slice(-100));
|
|
};
|
|
socket.on('debug_log', handler);
|
|
return () => { socket.off('debug_log', handler); };
|
|
}, [socket]);
|
|
|
|
React.useEffect(() => {
|
|
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [debugLogs]);
|
|
|
|
const rules = botState.settings.enabledRules || [];
|
|
|
|
const [botConfig, setBotConfig] = React.useState<Record<string, any> | null>(null);
|
|
const [isControlLoading, setIsControlLoading] = React.useState(false);
|
|
const [controlError, setControlError] = React.useState<string | null>(null);
|
|
|
|
// DB Sync Controls
|
|
const [dbSyncEnabled, setDbSyncEnabled] = React.useState<boolean>(true);
|
|
const [dbSyncInterval, setDbSyncInterval] = React.useState<number>(300000);
|
|
const [isDbSyncLoading, setIsDbSyncLoading] = React.useState(false);
|
|
|
|
const observabilityHealth = (botState.health || {}) as any;
|
|
const tradingControl = botState.health?.tradingControl;
|
|
const isPaused = tradingControl?.mode === 'PAUSED';
|
|
const operationalEvents = (botState.operationalEvents ?? []).filter((event): event is NonNullable<typeof event> => Boolean(event));
|
|
|
|
const formatDuration = (ms?: number) => {
|
|
if (!ms || !Number.isFinite(ms)) return 'Idle';
|
|
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
|
|
return `${Math.max(ms, 0).toFixed(0)}ms`;
|
|
};
|
|
|
|
const resolveLoopStatus = (
|
|
lastRun: number | undefined,
|
|
isHealthy: boolean,
|
|
intervalMs: number
|
|
): 'Idle' | 'Active' | 'Degraded' | 'Failing' => {
|
|
if (!lastRun) return 'Idle';
|
|
const safeIntervalMs = Math.max(1, Number(intervalMs) || 60000);
|
|
const staleThresholdMs = Math.max(safeIntervalMs * 2, 120000);
|
|
const isStale = (Date.now() - lastRun) > staleThresholdMs;
|
|
if (!isHealthy) {
|
|
return isStale ? 'Degraded' : 'Failing';
|
|
}
|
|
return isStale ? 'Degraded' : 'Active';
|
|
};
|
|
|
|
const formatTimeAgo = (lastRun?: number) => {
|
|
if (!lastRun) return 'Waiting for samples...';
|
|
const seconds = Math.floor((Date.now() - lastRun) / 1000);
|
|
if (seconds <= 0) return 'Just now';
|
|
if (seconds < 60) return `${seconds}s ago`;
|
|
return `${Math.floor(seconds / 60)}m ${seconds % 60}s ago`;
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
if (status === 'Failing') return 'bg-red-500';
|
|
if (status === 'Idle') return 'bg-zinc-600';
|
|
if (status === 'Degraded') return 'bg-orange-500 animate-pulse';
|
|
return 'bg-emerald-500 animate-pulse';
|
|
};
|
|
|
|
const getStatusTextColor = (status: string) => {
|
|
if (status === 'Failing') return 'text-red-400';
|
|
if (status === 'Idle') return 'text-zinc-500';
|
|
if (status === 'Degraded') return 'text-orange-400';
|
|
return 'text-white';
|
|
};
|
|
|
|
const tradingLoopStatus = resolveLoopStatus(
|
|
observabilityHealth.tradingLoopLastRun,
|
|
Boolean(observabilityHealth.tradingLoopHealthy),
|
|
Number(botConfig?.POLLING_INTERVAL || 30000)
|
|
);
|
|
const monitorLoopStatus = resolveLoopStatus(
|
|
observabilityHealth.monitorLoopLastRun,
|
|
Boolean(observabilityHealth.monitorLoopHealthy),
|
|
Number(botConfig?.MONITOR_INTERVAL_MS || 60000)
|
|
);
|
|
const reconciliationLoopStatus = resolveLoopStatus(
|
|
observabilityHealth.reconciliationLoopLastRun,
|
|
Boolean(observabilityHealth.reconciliationLoopHealthy),
|
|
Number(botConfig?.MONITOR_INTERVAL_MS || 60000)
|
|
);
|
|
const reconciliationMismatchCount = Number(observabilityHealth.reconciliationMismatchCount || 0);
|
|
const reconciliationNoGoTrades = Number(observabilityHealth.reconciliationNoGoTrades || 0);
|
|
const reconciliationIntegrityWatchdogTriggered = Boolean(observabilityHealth.reconciliationIntegrityWatchdogTriggered);
|
|
const hasReconciliationBacklog = reconciliationMismatchCount > 0 || reconciliationNoGoTrades > 0 || reconciliationIntegrityWatchdogTriggered;
|
|
const systemCritical = tradingLoopStatus === 'Failing' || monitorLoopStatus === 'Failing';
|
|
const systemDegraded = !systemCritical && (
|
|
tradingLoopStatus === 'Degraded'
|
|
|| monitorLoopStatus === 'Degraded'
|
|
|| reconciliationLoopStatus === 'Degraded'
|
|
|| hasReconciliationBacklog
|
|
);
|
|
const systemBadgeLabel = systemCritical ? 'Critical' : systemDegraded ? 'Degraded' : 'Healthy';
|
|
const systemBadgeDotClass = systemCritical ? 'bg-red-500' : systemDegraded ? 'bg-orange-500' : 'bg-[var(--bl-success)]';
|
|
const systemBadgeTextClass = systemCritical ? 'text-red-400' : systemDegraded ? 'text-orange-400' : 'text-[var(--bl-success)]';
|
|
const adminPanelClass = 'bg-[var(--bl-surface-strong)] border border-[var(--bl-border-subtle)]';
|
|
const adminPanelOverlayClass = 'bg-[var(--bl-surface-overlay)] border border-[var(--bl-border-subtle)]';
|
|
const adminPanelHoverClass = 'bg-[var(--bl-surface-strong)] border border-[var(--bl-border-subtle)] hover:border-[var(--bl-border-soft)]';
|
|
const adminNavigationClass = 'bg-[var(--bl-surface-overlay)] border border-[var(--bl-border-subtle)]';
|
|
const adminActiveAccentClass = 'bg-[var(--bl-success)]/10 border border-[var(--bl-success)]/15';
|
|
|
|
const handlePauseTrading = async () => {
|
|
setIsControlLoading(true);
|
|
setControlError(null);
|
|
try {
|
|
const apiUrl = tradingRuntime.tradingApiUrl;
|
|
const accessToken = await getPlatformAccessToken();
|
|
const res = await fetch(`${apiUrl}/internal/trading/pause`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
'x-request-id': createRequestId('web-admin')
|
|
},
|
|
body: JSON.stringify({ reason: 'Admin pause from dashboard' })
|
|
});
|
|
if (!res.ok) {
|
|
const errorData = await res.json().catch(() => ({ error: 'Unknown error' }));
|
|
throw new Error(errorData.error || `HTTP ${res.status}`);
|
|
}
|
|
} catch (err: any) {
|
|
setControlError(err.message || 'Failed to pause trading');
|
|
} finally {
|
|
setIsControlLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleResumeTrading = async () => {
|
|
setIsControlLoading(true);
|
|
setControlError(null);
|
|
try {
|
|
const apiUrl = tradingRuntime.tradingApiUrl;
|
|
const accessToken = await getPlatformAccessToken();
|
|
const res = await fetch(`${apiUrl}/internal/trading/resume`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
'x-request-id': createRequestId('web-admin')
|
|
},
|
|
body: JSON.stringify({ reason: 'Admin resume from dashboard' })
|
|
});
|
|
if (!res.ok) {
|
|
const errorData = await res.json().catch(() => ({ error: 'Unknown error' }));
|
|
throw new Error(errorData.error || `HTTP ${res.status}`);
|
|
}
|
|
} catch (err: any) {
|
|
setControlError(err.message || 'Failed to resume trading');
|
|
} finally {
|
|
setIsControlLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleClearEvents = async () => {
|
|
setIsControlLoading(true);
|
|
setControlError(null);
|
|
try {
|
|
const apiUrl = tradingRuntime.tradingApiUrl;
|
|
const accessToken = await getPlatformAccessToken();
|
|
const res = await fetch(`${apiUrl}/api/events`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
'x-request-id': createRequestId('web-admin')
|
|
}
|
|
});
|
|
if (!res.ok) {
|
|
const errorData = await res.json().catch(() => ({ error: 'Unknown error' }));
|
|
throw new Error(errorData.error || `HTTP ${res.status}`);
|
|
}
|
|
} catch (err: any) {
|
|
setControlError(err.message || 'Failed to clear events');
|
|
} finally {
|
|
setIsControlLoading(false);
|
|
}
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
if (profile?.role !== 'admin') return;
|
|
|
|
const fetchConfig = async () => {
|
|
try {
|
|
const apiUrl = tradingRuntime.tradingApiUrl;
|
|
const accessToken = await getPlatformAccessToken().catch(() => null);
|
|
if (!accessToken) return;
|
|
const res = await fetch(`${apiUrl}/api/config`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'x-request-id': createRequestId('web-admin')
|
|
}
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setBotConfig(data);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to fetch bot config:", err);
|
|
}
|
|
};
|
|
|
|
const fetchDbSyncSettings = async () => {
|
|
try {
|
|
const data = await fetchDynamicConfigItems();
|
|
if (data) {
|
|
data.forEach((item: { key: string; value: string; }) => {
|
|
if (item.key === 'ENABLE_DB_SNAPSHOTS') {
|
|
setDbSyncEnabled(item.value === 'true');
|
|
} else if (item.key === 'DB_SNAPSHOT_INTERVAL_MS') {
|
|
setDbSyncInterval(parseInt(item.value) || 300000);
|
|
}
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to fetch DB sync settings:", err);
|
|
}
|
|
};
|
|
|
|
fetchConfig();
|
|
fetchDbSyncSettings();
|
|
}, [profile?.role]);
|
|
|
|
const handleUpdateDbSync = async () => {
|
|
setIsDbSyncLoading(true);
|
|
try {
|
|
const updates = [
|
|
{ key: 'ENABLE_DB_SNAPSHOTS', value: String(dbSyncEnabled), description: 'Enable/Disable bot state snapshots to database' },
|
|
{ key: 'DB_SNAPSHOT_INTERVAL_MS', value: String(dbSyncInterval), description: 'Minimum interval between database snapshots in ms' }
|
|
];
|
|
|
|
await upsertDynamicConfigItems(updates);
|
|
} catch (err: any) {
|
|
setControlError(`DB Sync Update Failed: ${err.message}`);
|
|
} finally {
|
|
setIsDbSyncLoading(false);
|
|
}
|
|
};
|
|
|
|
const subTabs = [
|
|
{ id: 'rules' as const, label: 'Strategy Pipeline', icon: Hexagon },
|
|
{ id: 'config' as const, label: 'Global Config', icon: Settings2 },
|
|
{ id: 'health' as const, label: 'System Health', icon: Heart },
|
|
{ id: 'reconciliation' as const, label: 'Recon Audit', icon: ShieldAlert },
|
|
{ id: 'debug' as const, label: 'Debug', icon: Bug },
|
|
];
|
|
|
|
const renderSubContent = () => {
|
|
switch (subTab) {
|
|
case 'config':
|
|
return <ConfigTab />;
|
|
case 'health':
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Health Summary Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{/* Trading Engine Card */}
|
|
<div className={`${adminPanelClass} rounded-2xl p-5`}>
|
|
<p className="text-[9px] text-zinc-500 uppercase tracking-widest mb-1">Trading Engine</p>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-2 h-2 rounded-full ${getStatusColor(tradingLoopStatus)}`} />
|
|
<span className={`text-sm font-bold ${getStatusTextColor(tradingLoopStatus)}`}>
|
|
{tradingLoopStatus}
|
|
</span>
|
|
</div>
|
|
<p className="text-[10px] text-zinc-600 mt-2 flex items-center justify-between">
|
|
<span>{formatTimeAgo(observabilityHealth.tradingLoopLastRun)}</span>
|
|
{tradingLoopStatus === 'Degraded' && (
|
|
<span className="text-[9px] text-orange-500/80 font-bold uppercase tracking-tighter">Stale</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Position Monitor Card */}
|
|
<div className={`${adminPanelClass} rounded-2xl p-5`}>
|
|
<p className="text-[9px] text-zinc-500 uppercase tracking-widest mb-1">Position Monitor</p>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-2 h-2 rounded-full ${getStatusColor(monitorLoopStatus)}`} />
|
|
<span className={`text-sm font-bold ${getStatusTextColor(monitorLoopStatus)}`}>
|
|
{monitorLoopStatus}
|
|
</span>
|
|
</div>
|
|
<p className="text-[10px] text-zinc-600 mt-2 flex items-center justify-between">
|
|
<span>{formatTimeAgo(observabilityHealth.monitorLoopLastRun)}</span>
|
|
{monitorLoopStatus === 'Degraded' && (
|
|
<span className="text-[9px] text-orange-500/80 font-bold uppercase tracking-tighter">Stale</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Reconciliation Card */}
|
|
<div className={`${adminPanelClass} rounded-2xl p-5`}>
|
|
<p className="text-[9px] text-zinc-500 uppercase tracking-widest mb-1">Reconciliation</p>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-2 h-2 rounded-full ${getStatusColor(reconciliationLoopStatus)}`} />
|
|
<span className={`text-sm font-bold ${getStatusTextColor(reconciliationLoopStatus)}`}>
|
|
{reconciliationLoopStatus}
|
|
</span>
|
|
</div>
|
|
<div className="text-[10px] text-zinc-600 mt-2 flex items-center justify-between">
|
|
<span>{formatTimeAgo(observabilityHealth.reconciliationLoopLastRun)}</span>
|
|
{reconciliationLoopStatus === 'Degraded' && (
|
|
<span className="text-[9px] text-orange-500/80 font-bold uppercase tracking-tighter">Stale</span>
|
|
)}
|
|
</div>
|
|
<p className="text-[10px] text-zinc-600 mt-1">
|
|
Mismatches: {reconciliationMismatchCount} | NO_GO: {reconciliationNoGoTrades}
|
|
</p>
|
|
{reconciliationIntegrityWatchdogTriggered && (
|
|
<p className="text-[10px] text-red-400 mt-1 uppercase tracking-wide">Integrity watchdog active</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Operational Events (Admin Error Panel) */}
|
|
<section className={`${adminPanelClass} rounded-2xl overflow-hidden`}>
|
|
<div className="px-5 py-4 border-b border-white/[0.04] flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<ShieldAlert size={14} className="text-orange-400" />
|
|
<h3 className="text-xs font-bold text-zinc-300 uppercase tracking-wider">Operational Events</h3>
|
|
<button
|
|
onClick={handleClearEvents}
|
|
disabled={isControlLoading}
|
|
className="ml-4 px-2 py-0.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-white rounded text-[10px] transition-colors disabled:opacity-50"
|
|
>
|
|
Clear History
|
|
</button>
|
|
</div>
|
|
<span className="text-[10px] text-zinc-500 font-mono">
|
|
Buffer: {operationalEvents.length || 0} events
|
|
</span>
|
|
</div>
|
|
|
|
{/* 24h Severity Summary Bar */}
|
|
{operationalEvents.length > 0 && (
|
|
<div className="px-5 py-3 bg-white/[0.01] border-b border-white/[0.04] flex items-center gap-6">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-red-500" />
|
|
<span className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">
|
|
Errors: <span className="text-red-400 text-xs ml-1">
|
|
{operationalEvents.filter(e => e.severity === 'ERROR' && (Date.now() - e.timestamp < 86400000)).length}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-orange-500" />
|
|
<span className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">
|
|
Warnings: <span className="text-orange-400 text-xs ml-1">
|
|
{operationalEvents.filter(e => e.severity === 'WARN' && (Date.now() - e.timestamp < 86400000)).length}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-blue-500" />
|
|
<span className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">
|
|
Info: <span className="text-blue-400 text-xs ml-1">
|
|
{operationalEvents.filter(e => e.severity === 'INFO' && (Date.now() - e.timestamp < 86400000)).length}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<div className="ml-auto text-[9px] text-zinc-600 font-bold uppercase tracking-widest">
|
|
Buffer Distribution
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="max-h-[400px] overflow-y-auto custom-scrollbar">
|
|
{(operationalEvents.length === 0) ? (
|
|
<div className="py-12 flex flex-col items-center justify-center text-zinc-600">
|
|
<ShieldCheck size={24} className="opacity-20 mb-2" />
|
|
<p className="text-xs">No actionable issues detected</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-white/[0.02]">
|
|
{operationalEvents.map((event) => (
|
|
<div key={event.id} className="px-5 py-3 hover:bg-white/[0.01] transition-colors">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="mt-0.5">
|
|
{event.severity === 'ERROR' ? (
|
|
<XCircle size={14} className="text-red-500" />
|
|
) : event.severity === 'WARN' ? (
|
|
<AlertTriangle size={14} className="text-orange-500" />
|
|
) : (
|
|
<Info size={14} className="text-blue-500" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className={`text-[10px] font-black tracking-widest ${event.severity === 'ERROR' ? 'text-red-500' :
|
|
event.severity === 'WARN' ? 'text-orange-500' : 'text-blue-500'
|
|
}`}>
|
|
{event.type}
|
|
</span>
|
|
{event.symbol && (
|
|
<span className="text-[10px] font-mono text-zinc-400 bg-zinc-800/50 px-1.5 py-0.5 rounded">
|
|
{event.symbol}
|
|
</span>
|
|
)}
|
|
{event.profileId && (
|
|
<span className="text-[10px] text-zinc-500">
|
|
profile: {event.profileId.slice(-8)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-zinc-300 mt-1 leading-relaxed">{event.message}</p>
|
|
</div>
|
|
</div>
|
|
<span className="text-[10px] font-mono text-zinc-600 whitespace-nowrap">
|
|
{new Date(event.timestamp).toLocaleTimeString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Detailed Metrics */}
|
|
<section className={`${adminPanelClass} rounded-2xl p-5`}>
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<Cpu size={14} className="text-blue-400" />
|
|
<h3 className="text-xs font-bold text-zinc-300 uppercase tracking-wider">Performance Telemetry</h3>
|
|
</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<p className="text-[9px] text-zinc-500 uppercase tracking-wider">Avg Execution Loop</p>
|
|
<span className={`text-[8px] px-1 rounded ${tradingLoopStatus === 'Active' ? 'bg-emerald-500/10 text-emerald-500' :
|
|
tradingLoopStatus === 'Degraded' ? 'bg-orange-500/10 text-orange-500' :
|
|
tradingLoopStatus === 'Failing' ? 'bg-red-500/10 text-red-500' : 'bg-zinc-500/10 text-zinc-500'
|
|
}`}>
|
|
{tradingLoopStatus}
|
|
</span>
|
|
</div>
|
|
<p className="text-lg font-mono font-bold text-white">{formatDuration((observabilityHealth as any)?.tradingLoopDurationMs)}</p>
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<p className="text-[9px] text-zinc-500 uppercase tracking-wider">Reconciliation Loop</p>
|
|
<span className={`text-[8px] px-1 rounded ${reconciliationLoopStatus === 'Active' ? 'bg-emerald-500/10 text-emerald-500' :
|
|
reconciliationLoopStatus === 'Degraded' ? 'bg-orange-500/10 text-orange-500' :
|
|
reconciliationLoopStatus === 'Failing' ? 'bg-red-500/10 text-red-500' : 'bg-zinc-500/10 text-zinc-500'
|
|
}`}>
|
|
{reconciliationLoopStatus}
|
|
</span>
|
|
</div>
|
|
<p className="text-lg font-mono font-bold text-white">{formatDuration((observabilityHealth as any)?.reconciliationLoopDurationMs)}</p>
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<p className="text-[9px] text-zinc-500 uppercase tracking-wider">Exchange Latency (p95)</p>
|
|
<span className={`text-[8px] px-1 rounded ${(observabilityHealth as any)?.exchangeLatencyP95 ? 'bg-emerald-500/10 text-emerald-500' : 'bg-zinc-500/10 text-zinc-500'
|
|
}`}>
|
|
{(observabilityHealth as any)?.exchangeLatencyP95 ? 'Active' : 'Idle'}
|
|
</span>
|
|
</div>
|
|
<p className="text-lg font-mono font-bold text-white">
|
|
{(observabilityHealth as any)?.exchangeLatencyP95 ? `${((observabilityHealth as any).exchangeLatencyP95).toFixed(1)}ms` : 'Idle'}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<p className="text-[9px] text-zinc-500 uppercase tracking-wider">Uptime</p>
|
|
<span className="text-[8px] px-1 rounded bg-emerald-500/10 text-emerald-500">Active</span>
|
|
</div>
|
|
<p className="text-lg font-mono font-bold text-white">
|
|
{formatDuration(botState.uptime)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-6 mt-8 pt-8 border-t border-white/[0.02]">
|
|
<div>
|
|
<p className="text-[9px] text-zinc-500 uppercase tracking-wider mb-1">Entry Lock Contention</p>
|
|
<p className="text-sm font-mono text-white">{(observabilityHealth as any)?.entryLockContentionCount ?? 0} hits</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[9px] text-zinc-500 uppercase tracking-wider mb-1">Recon Lock Contention</p>
|
|
<p className="text-sm font-mono text-white">{(observabilityHealth as any)?.reconciliationLockContentionCount ?? 0} hits</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[9px] text-zinc-500 uppercase tracking-wider mb-1">Order Failures (24h)</p>
|
|
<p className="text-sm font-mono text-white">{botState.orderFailures?.length ?? 0} events</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
case 'reconciliation':
|
|
return <ReconciliationAuditPanel />;
|
|
case 'debug':
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Dashboard Environment */}
|
|
<section>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<Activity size={14} className="text-blue-400" />
|
|
<h3 className="text-xs font-bold text-zinc-300 uppercase tracking-wider">Dashboard Environment</h3>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
{Object.entries(import.meta.env)
|
|
.filter(([key]) => key.startsWith('VITE_') || key === 'MODE')
|
|
.map(([key, value]) => (
|
|
<div key={key} className={`flex items-center justify-between gap-4 px-4 py-3 ${adminPanelClass} rounded-xl`}>
|
|
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider truncate">{key}</span>
|
|
<span className="text-[10px] font-mono text-blue-400/80 bg-blue-500/[0.06] px-2.5 py-1 rounded-lg truncate max-w-[260px]">
|
|
{String(value)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Bot Config */}
|
|
<section>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<Cpu size={14} className="text-orange-400" />
|
|
<h3 className="text-xs font-bold text-zinc-300 uppercase tracking-wider">Bot Configuration</h3>
|
|
</div>
|
|
{botConfig ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
{Object.entries(botConfig).map(([key, value]) => (
|
|
<div key={key} className={`flex items-center justify-between gap-4 px-4 py-3 ${adminPanelClass} rounded-xl`}>
|
|
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider truncate">{key}</span>
|
|
<span className="text-[10px] font-mono text-orange-400/80 bg-orange-500/[0.06] px-2.5 py-1 rounded-lg truncate max-w-[260px]">
|
|
{Array.isArray(value) ? value.join(', ') : String(value)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className={`flex items-center justify-center gap-3 py-12 ${adminPanelClass} border-dashed border-red-500/10 rounded-xl`}>
|
|
<WifiOff size={18} className="text-red-500/40" />
|
|
<p className="text-xs text-red-500/60 font-medium">Bot offline — unable to fetch config</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Live Debug Logs */}
|
|
<section>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<Bug size={14} className="text-emerald-400" />
|
|
<h3 className="text-xs font-bold text-zinc-300 uppercase tracking-wider">Live Debug Stream</h3>
|
|
</div>
|
|
<span className="text-[9px] text-zinc-600 font-mono uppercase tracking-widest">Buffer: 100 entries</span>
|
|
</div>
|
|
<div className={`${adminPanelOverlayClass} rounded-xl overflow-hidden shadow-2xl`}>
|
|
<div className="flex items-center gap-2 px-4 py-2 bg-white/[0.02] border-b border-white/[0.04]">
|
|
<div className="flex gap-1.5">
|
|
<div className="w-2.5 h-2.5 rounded-full bg-red-500/20" />
|
|
<div className="w-2.5 h-2.5 rounded-full bg-orange-500/20" />
|
|
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500/20" />
|
|
</div>
|
|
<span className="text-[9px] text-zinc-600 font-mono ml-2">bytelyst-stdout</span>
|
|
</div>
|
|
<div className="p-4 h-[400px] overflow-y-auto font-mono text-[11px] custom-scrollbar selection:bg-emerald-500/30">
|
|
{debugLogs.length === 0 ? (
|
|
<div className="h-full flex flex-col items-center justify-center text-zinc-700 animate-pulse">
|
|
<Bug size={32} className="opacity-20 mb-4" />
|
|
<p>Awaiting debug events...</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{debugLogs.map((log, idx) => (
|
|
<div key={idx} className="flex gap-3 py-0.5 group">
|
|
<span className="text-zinc-700 shrink-0 select-none">[{new Date().toLocaleTimeString([], { hour12: false })}]</span>
|
|
<span className="text-emerald-400/90 break-all">{typeof log === 'string' ? log : JSON.stringify(log)}</span>
|
|
</div>
|
|
))}
|
|
<div ref={logEndRef} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
default: // rules
|
|
return (
|
|
<div className="space-y-4">
|
|
{rules.length === 0 ? (
|
|
<div className={`flex flex-col items-center justify-center py-20 ${adminPanelClass} border-dashed border-white/[0.06] rounded-xl`}>
|
|
<Hexagon size={28} className="text-zinc-700 mb-4" />
|
|
<p className="text-sm font-semibold text-zinc-500">No Active Rules</p>
|
|
<p className="text-[11px] text-zinc-600 mt-1 max-w-xs text-center">Connect to the bot service to load the pipeline.</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Pipeline header */}
|
|
<div className="flex items-center justify-between px-1 pb-2">
|
|
<p className="text-[10px] text-zinc-500 font-medium">
|
|
{rules.length} rules execute sequentially per trading cycle
|
|
</p>
|
|
<span className="flex items-center gap-1.5 text-[10px] text-zinc-500">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-[var(--bl-success)] animate-pulse" /> All active
|
|
</span>
|
|
</div>
|
|
|
|
{/* Rule cards */}
|
|
<div className="space-y-2">
|
|
{rules.map((rule, idx) => {
|
|
const info = ruleDescriptions[rule] || { desc: 'Technical strategy rule.', category: 'System', icon: Hexagon };
|
|
const color = categoryColors[info.category] || 'var(--bl-text-quiet)';
|
|
const RuleIcon = info.icon;
|
|
const isLast = idx === rules.length - 1;
|
|
|
|
return (
|
|
<React.Fragment key={rule}>
|
|
<div className={`group relative ${adminPanelHoverClass} rounded-xl transition-all duration-200`}>
|
|
{/* Left accent */}
|
|
<div
|
|
className="absolute left-0 top-3 bottom-3 w-[3px] rounded-full opacity-50 group-hover:opacity-100 transition-opacity"
|
|
style={{ background: color }}
|
|
/>
|
|
|
|
<div className="flex items-center gap-4 pl-5 pr-5 py-4">
|
|
{/* Step number */}
|
|
<span className="text-[10px] font-bold text-zinc-600 font-mono w-5 text-center shrink-0">
|
|
{idx + 1}
|
|
</span>
|
|
|
|
{/* Icon */}
|
|
<div
|
|
className="shrink-0 w-9 h-9 rounded-lg flex items-center justify-center border group-hover:scale-105 transition-transform"
|
|
style={{
|
|
background: `${color}10`,
|
|
borderColor: `${color}20`,
|
|
color: color
|
|
}}
|
|
>
|
|
<RuleIcon size={16} />
|
|
</div>
|
|
|
|
{/* Text */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-0.5">
|
|
<h4 className="text-[13px] font-bold text-white tracking-tight">
|
|
{rule.replace('Rule', '')}
|
|
</h4>
|
|
<span
|
|
className="text-[8px] font-bold uppercase tracking-widest px-1.5 py-0.5 rounded border"
|
|
style={{
|
|
background: `${color}08`,
|
|
borderColor: `${color}18`,
|
|
color: color
|
|
}}
|
|
>
|
|
{info.category}
|
|
</span>
|
|
</div>
|
|
<p className="text-[11px] text-zinc-500 group-hover:text-zinc-400 transition-colors leading-relaxed">
|
|
{info.desc}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Status */}
|
|
<div className="shrink-0">
|
|
<span className="flex items-center gap-1.5 px-2.5 py-1 bg-[var(--bl-success)]/10 border border-[var(--bl-success)]/10 rounded-full">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-[var(--bl-success)]" />
|
|
<span className="text-[9px] font-bold text-[var(--bl-success)]/80 uppercase tracking-wider">Active</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Connector arrow between rules */}
|
|
{!isLast && (
|
|
<div className="flex justify-center py-0.5">
|
|
<ChevronRight size={12} className="text-zinc-700 rotate-90" />
|
|
</div>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Pipeline footer note */}
|
|
<p className="text-[10px] text-zinc-600 text-center pt-3">
|
|
Rules execute in order — each must pass before the next is evaluated
|
|
</p>
|
|
|
|
{/* Trading Control Panel */}
|
|
<section className="mt-6 space-y-4">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<ShieldCheck size={14} className={isPaused ? "text-orange-400" : "text-emerald-400"} />
|
|
<h3 className="text-xs font-bold text-zinc-300 uppercase tracking-wider">Trading Control</h3>
|
|
</div>
|
|
|
|
{/* Status Banner */}
|
|
<div className={`flex items-center justify-between px-4 py-3 rounded-xl border ${isPaused
|
|
? 'bg-orange-500/[0.06] border-orange-500/20'
|
|
: 'bg-emerald-500/[0.06] border-emerald-500/20'
|
|
}`}>
|
|
<div className="flex items-center gap-3">
|
|
{isPaused ? (
|
|
<Pause size={16} className="text-orange-400" />
|
|
) : (
|
|
<Play size={16} className="text-emerald-400" />
|
|
)}
|
|
<div>
|
|
<p className={`text-sm font-bold ${isPaused ? 'text-orange-300' : 'text-emerald-300'}`}>
|
|
AUTO-TRADING: {isPaused ? 'PAUSED' : 'RUNNING'}
|
|
</p>
|
|
<p className="text-[10px] text-zinc-500 mt-0.5">
|
|
{isPaused
|
|
? 'No new positions will be opened. Existing positions are still managed.'
|
|
: 'Bot is actively monitoring and executing trades based on strategy rules.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{tradingControl && (
|
|
<div className="text-right">
|
|
<p className="text-[9px] text-zinc-500 uppercase tracking-wider">Last Changed</p>
|
|
<p className="text-[10px] text-zinc-400 font-mono">
|
|
{new Date(tradingControl.lastChangedAt).toLocaleString()}
|
|
</p>
|
|
<p className="text-[9px] text-zinc-600">by {tradingControl.lastChangedBy}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Control Buttons */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<button
|
|
onClick={handlePauseTrading}
|
|
disabled={isPaused || isControlLoading}
|
|
className={`flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-bold text-sm transition-all ${isPaused || isControlLoading
|
|
? 'bg-zinc-800/50 border border-zinc-700/30 text-zinc-600 cursor-not-allowed'
|
|
: 'bg-orange-500/10 border border-orange-500/20 text-orange-400 hover:bg-orange-500/20 hover:border-orange-500/30'
|
|
}`}
|
|
title={isPaused ? "Trading is already paused" : "Pause all new trade entries"}
|
|
>
|
|
<Pause size={16} />
|
|
{isControlLoading && !isPaused ? 'Pausing...' : 'Pause Auto Trading'}
|
|
</button>
|
|
<button
|
|
onClick={handleResumeTrading}
|
|
disabled={!isPaused || isControlLoading}
|
|
className={`flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-bold text-sm transition-all ${!isPaused || isControlLoading
|
|
? 'bg-zinc-800/50 border border-zinc-700/30 text-zinc-600 cursor-not-allowed'
|
|
: 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/20 hover:border-emerald-500/30'
|
|
}`}
|
|
title={!isPaused ? "Trading is already running" : "Resume automated trade execution"}
|
|
>
|
|
<Play size={16} />
|
|
{isControlLoading && isPaused ? 'Resuming...' : 'Resume Auto Trading'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error Display */}
|
|
{controlError && (
|
|
<div className="flex items-center gap-2 px-4 py-3 bg-red-500/[0.06] border border-red-500/20 rounded-xl">
|
|
<AlertTriangle size={14} className="text-red-400 shrink-0" />
|
|
<p className="text-xs text-red-400">{controlError}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Safety Notice */}
|
|
<div className="flex items-start gap-2 px-4 py-3 bg-blue-500/[0.04] border border-blue-500/10 rounded-xl">
|
|
<AlertTriangle size={12} className="text-blue-400 shrink-0 mt-0.5" />
|
|
<p className="text-[10px] text-blue-400/80 leading-relaxed">
|
|
<strong>Safety Note:</strong> Pausing trading blocks new entries only.
|
|
Existing positions will continue to be monitored for exits, stop-losses, and take-profits.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
|
|
{/* Database Synchronization Control */}
|
|
<section className="mt-6 space-y-4 pt-6 border-t border-white/[0.04]">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<Database size={14} className="text-cyan-400" />
|
|
<h3 className="text-xs font-bold text-zinc-300 uppercase tracking-wider">Database Synchronization</h3>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-cyan-500/10 border border-cyan-500/20 rounded-full">
|
|
<span className="w-1 h-1 rounded-full bg-cyan-400" />
|
|
<span className="text-[8px] font-bold text-cyan-400 uppercase tracking-widest">Neural Persistence</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`${adminPanelClass} rounded-2xl overflow-hidden p-5 space-y-5`}>
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-bold text-white">State Snapshots</p>
|
|
<p className="text-[10px] text-zinc-500">Persistent sync of bot state to Supabase</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setDbSyncEnabled(!dbSyncEnabled)}
|
|
className={`relative inline-flex h-5 w-10 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${dbSyncEnabled ? 'bg-cyan-500' : 'bg-zinc-700'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${dbSyncEnabled ? 'translate-x-5' : 'translate-x-0'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-bold text-white">Sync Interval</p>
|
|
<p className="text-[10px] text-zinc-500">Minimum time between DB writes ({Math.round(dbSyncInterval / 60000)}m)</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="range"
|
|
min="60000"
|
|
max="3600000"
|
|
step="60000"
|
|
value={dbSyncInterval}
|
|
onChange={(e) => setDbSyncInterval(parseInt(e.target.value))}
|
|
className="w-32 accent-cyan-500"
|
|
/>
|
|
<span className="text-[10px] font-mono text-cyan-400 w-12 text-right">
|
|
{(dbSyncInterval / 60000).toFixed(0)}m
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 p-3 bg-cyan-500/[0.03] border border-cyan-500/10 rounded-xl">
|
|
<RefreshCcw size={12} className="text-cyan-400/60" />
|
|
<p className="text-[9px] text-zinc-500 leading-relaxed italic">
|
|
Increasing the interval reduces database IOPS and prevents service throttling under high volatility.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleUpdateDbSync}
|
|
disabled={isDbSyncLoading}
|
|
className={`w-full py-2.5 rounded-xl font-bold text-[11px] uppercase tracking-widest transition-all ${isDbSyncLoading
|
|
? 'bg-zinc-800 text-zinc-600 cursor-not-allowed'
|
|
: 'bg-cyan-500/10 border border-cyan-500/20 text-cyan-400 hover:bg-cyan-500/20 shadow-lg shadow-cyan-500/5'
|
|
}`}
|
|
>
|
|
{isDbSyncLoading ? 'Synchronizing...' : 'Apply DB Sync Parameters'}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
if (profile?.role !== 'admin') {
|
|
return (
|
|
<div className={`p-8 text-center ${adminPanelClass} border-red-500/20 rounded-2xl`}>
|
|
<XCircle className="mx-auto text-red-500 mb-4" size={48} />
|
|
<h2 className="text-xl font-bold text-white mb-2">Access Denied</h2>
|
|
<p className="text-zinc-500">You do not have administrative privileges to access this area.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 max-w-5xl mx-auto">
|
|
{/* Header */}
|
|
<header className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-10 h-10 rounded-xl ${adminActiveAccentClass} flex items-center justify-center`}>
|
|
<ShieldCheck size={18} className="text-[var(--bl-success)]" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-bold text-white tracking-tight">Admin Panel</h2>
|
|
<p className="text-[10px] text-zinc-500">System configuration & rule pipeline</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className={`flex items-center gap-1.5 px-3 py-1.5 ${adminPanelClass} rounded-lg`}>
|
|
{botConfig ? (
|
|
<>
|
|
<Wifi size={11} className="text-emerald-500" />
|
|
<span className="text-[10px] text-emerald-400 font-medium">Bot Connected</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<WifiOff size={11} className="text-zinc-600" />
|
|
<span className="text-[10px] text-zinc-500 font-medium">Bot Offline</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* System Status Badge */}
|
|
<div className={`flex items-center gap-1.5 px-3 py-1.5 ${adminPanelClass} rounded-lg`}>
|
|
<div className={`w-1.5 h-1.5 rounded-full ${systemBadgeDotClass}`} />
|
|
<span className={`text-[10px] font-bold uppercase tracking-widest ${systemBadgeTextClass}`}>
|
|
System: {systemBadgeLabel}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Sub Navigation */}
|
|
<nav className={`flex gap-1 ${adminNavigationClass} rounded-xl p-1`}>
|
|
{subTabs.map((tab) => {
|
|
const Icon = tab.icon;
|
|
const isActive = subTab === tab.id;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setSubTab(tab.id)}
|
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-[11px] font-bold transition-all duration-200 ${isActive
|
|
? 'bg-white/[0.07] text-white'
|
|
: 'text-zinc-500 hover:text-zinc-300 hover:bg-white/[0.02]'
|
|
}`}
|
|
>
|
|
<Icon size={13} className={isActive ? 'text-[var(--bl-success)]' : ''} />
|
|
<span>{tab.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
{/* Content */}
|
|
<main className="min-h-[300px]">
|
|
{renderSubContent()}
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|