617 lines
26 KiB
TypeScript
617 lines
26 KiB
TypeScript
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||
import {
|
||
Box,
|
||
Flex,
|
||
VStack,
|
||
HStack,
|
||
Text,
|
||
IconButton,
|
||
Button,
|
||
Input,
|
||
useDisclosure,
|
||
Drawer,
|
||
DrawerOverlay,
|
||
DrawerContent,
|
||
DrawerHeader,
|
||
DrawerBody,
|
||
DrawerCloseButton,
|
||
Divider,
|
||
Tag,
|
||
Code,
|
||
Badge,
|
||
Modal,
|
||
ModalOverlay,
|
||
ModalContent,
|
||
ModalHeader,
|
||
ModalBody,
|
||
ModalCloseButton,
|
||
Tabs,
|
||
TabList,
|
||
TabPanels,
|
||
Tab,
|
||
TabPanel,
|
||
Tooltip,
|
||
Collapse,
|
||
useToast,
|
||
} from '@chakra-ui/react';
|
||
import { LayoutDashboard, Wallet, Settings, Terminal as TerminalIcon, ChevronRight, Eye, Search, BarChart3, Bot, ChevronDown, ChevronUp } from 'lucide-react';
|
||
import { MetricHeader } from './components/MetricHeader';
|
||
import { AgentGraph } from './components/AgentGraph';
|
||
import { PortfolioViewer } from './components/PortfolioViewer';
|
||
import { useAgentStream, AgentEvent } from './hooks/useAgentStream';
|
||
import axios from 'axios';
|
||
|
||
const API_BASE = 'http://127.0.0.1:8088/api';
|
||
|
||
// ─── Run type definitions with required parameters ────────────────────
|
||
type RunType = 'scan' | 'pipeline' | 'portfolio' | 'auto';
|
||
|
||
interface RunParams {
|
||
date: string;
|
||
ticker: string;
|
||
portfolio_id: string;
|
||
}
|
||
|
||
const RUN_TYPE_LABELS: Record<RunType, string> = {
|
||
scan: 'Scan',
|
||
pipeline: 'Pipeline',
|
||
portfolio: 'Portfolio',
|
||
auto: 'Auto',
|
||
};
|
||
|
||
/** Which params each run type needs. */
|
||
const REQUIRED_PARAMS: Record<RunType, (keyof RunParams)[]> = {
|
||
scan: ['date'],
|
||
pipeline: ['ticker', 'date'],
|
||
portfolio: ['date', 'portfolio_id'],
|
||
auto: ['date', 'ticker'],
|
||
};
|
||
|
||
/** Return the colour token for a given event type. */
|
||
const eventColor = (type: AgentEvent['type']): string => {
|
||
switch (type) {
|
||
case 'tool': return 'purple.400';
|
||
case 'tool_result': return 'purple.300';
|
||
case 'result': return 'green.400';
|
||
case 'log': return 'yellow.300';
|
||
default: return 'cyan.400';
|
||
}
|
||
};
|
||
|
||
/** Return a short label badge for the event type. */
|
||
const eventLabel = (type: AgentEvent['type']): string => {
|
||
switch (type) {
|
||
case 'thought': return '💭';
|
||
case 'tool': return '🔧';
|
||
case 'tool_result': return '✅🔧';
|
||
case 'result': return '✅';
|
||
case 'log': return 'ℹ️';
|
||
default: return '●';
|
||
}
|
||
};
|
||
|
||
/** Short summary for terminal — no inline prompts, just agent + type. */
|
||
const eventSummary = (evt: AgentEvent): string => {
|
||
switch (evt.type) {
|
||
case 'thought': return `Thinking… (${evt.metrics?.model || 'LLM'})`;
|
||
case 'tool': return evt.message.startsWith('✓') ? 'Tool result received' : `Tool call: ${evt.message.replace(/^▶ Tool: /, '').split(' | ')[0]}`;
|
||
case 'tool_result': return `Tool done: ${evt.message.replace(/^✓ Tool result: /, '').split(' | ')[0]}`;
|
||
case 'result': return 'Completed';
|
||
case 'log': return evt.message;
|
||
default: return evt.type;
|
||
}
|
||
};
|
||
|
||
// ─── Full Event Detail Modal ─────────────────────────────────────────
|
||
const EventDetailModal: React.FC<{ event: AgentEvent | null; isOpen: boolean; onClose: () => void }> = ({ event, isOpen, onClose }) => {
|
||
if (!event) return null;
|
||
|
||
return (
|
||
<Modal isOpen={isOpen} onClose={onClose} size="4xl" scrollBehavior="inside">
|
||
<ModalOverlay backdropFilter="blur(6px)" />
|
||
<ModalContent bg="slate.900" color="white" maxH="85vh" border="1px solid" borderColor="whiteAlpha.200">
|
||
<ModalCloseButton />
|
||
<ModalHeader borderBottomWidth="1px" borderColor="whiteAlpha.100">
|
||
<HStack>
|
||
<Badge colorScheme={event.type === 'result' ? 'green' : event.type === 'tool' || event.type === 'tool_result' ? 'purple' : 'cyan'} fontSize="sm">
|
||
{event.type.toUpperCase()}
|
||
</Badge>
|
||
<Badge variant="outline" fontSize="sm">{event.agent}</Badge>
|
||
<Text fontSize="sm" color="whiteAlpha.400" fontWeight="normal">{event.timestamp}</Text>
|
||
</HStack>
|
||
</ModalHeader>
|
||
<ModalBody py={4}>
|
||
<Tabs variant="soft-rounded" colorScheme="cyan" size="sm">
|
||
<TabList mb={4}>
|
||
{event.prompt && <Tab>Prompt / Request</Tab>}
|
||
{(event.response || (event.type === 'result' && event.message)) && <Tab>Response</Tab>}
|
||
<Tab>Summary</Tab>
|
||
{event.metrics && <Tab>Metrics</Tab>}
|
||
</TabList>
|
||
<TabPanels>
|
||
{event.prompt && (
|
||
<TabPanel p={0}>
|
||
<Box bg="blackAlpha.500" p={4} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100" maxH="60vh" overflowY="auto">
|
||
<Text fontSize="xs" fontFamily="mono" whiteSpace="pre-wrap" wordBreak="break-word" color="whiteAlpha.900">
|
||
{event.prompt}
|
||
</Text>
|
||
</Box>
|
||
</TabPanel>
|
||
)}
|
||
{(event.response || (event.type === 'result' && event.message)) && (
|
||
<TabPanel p={0}>
|
||
<Box bg="blackAlpha.500" p={4} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100" maxH="60vh" overflowY="auto">
|
||
<Text fontSize="xs" fontFamily="mono" whiteSpace="pre-wrap" wordBreak="break-word" color="whiteAlpha.900">
|
||
{event.response || event.message}
|
||
</Text>
|
||
</Box>
|
||
</TabPanel>
|
||
)}
|
||
<TabPanel p={0}>
|
||
<Box bg="blackAlpha.500" p={4} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100">
|
||
<Text fontSize="sm" whiteSpace="pre-wrap" wordBreak="break-word" color="whiteAlpha.900">
|
||
{event.message}
|
||
</Text>
|
||
</Box>
|
||
</TabPanel>
|
||
{event.metrics && (
|
||
<TabPanel p={0}>
|
||
<VStack align="stretch" spacing={3}>
|
||
{event.metrics.model && event.metrics.model !== 'unknown' && (
|
||
<HStack><Text fontSize="sm" color="whiteAlpha.600" minW="80px">Model:</Text><Code colorScheme="blue" fontSize="sm">{event.metrics.model}</Code></HStack>
|
||
)}
|
||
{event.metrics.tokens_in != null && event.metrics.tokens_in > 0 && (
|
||
<HStack><Text fontSize="sm" color="whiteAlpha.600" minW="80px">Tokens In:</Text><Code>{event.metrics.tokens_in}</Code></HStack>
|
||
)}
|
||
{event.metrics.tokens_out != null && event.metrics.tokens_out > 0 && (
|
||
<HStack><Text fontSize="sm" color="whiteAlpha.600" minW="80px">Tokens Out:</Text><Code>{event.metrics.tokens_out}</Code></HStack>
|
||
)}
|
||
{event.metrics.latency_ms != null && event.metrics.latency_ms > 0 && (
|
||
<HStack><Text fontSize="sm" color="whiteAlpha.600" minW="80px">Latency:</Text><Code>{event.metrics.latency_ms}ms</Code></HStack>
|
||
)}
|
||
{event.node_id && (
|
||
<HStack><Text fontSize="sm" color="whiteAlpha.600" minW="80px">Node ID:</Text><Code fontSize="xs">{event.node_id}</Code></HStack>
|
||
)}
|
||
</VStack>
|
||
</TabPanel>
|
||
)}
|
||
</TabPanels>
|
||
</Tabs>
|
||
</ModalBody>
|
||
</ModalContent>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
// ─── Detail card for a single event in the drawer ─────────────────────
|
||
const EventDetail: React.FC<{ event: AgentEvent; onOpenModal?: (evt: AgentEvent) => void }> = ({ event, onOpenModal }) => (
|
||
<VStack align="stretch" spacing={4}>
|
||
<HStack>
|
||
<Badge colorScheme={event.type === 'result' ? 'green' : event.type === 'tool' || event.type === 'tool_result' ? 'purple' : 'cyan'}>{event.type.toUpperCase()}</Badge>
|
||
<Badge variant="outline">{event.agent}</Badge>
|
||
<Text fontSize="xs" color="whiteAlpha.400">{event.timestamp}</Text>
|
||
{onOpenModal && (
|
||
<Button size="xs" variant="ghost" colorScheme="cyan" ml="auto" onClick={() => onOpenModal(event)}>
|
||
Full Detail →
|
||
</Button>
|
||
)}
|
||
</HStack>
|
||
|
||
{event.metrics?.model && event.metrics.model !== 'unknown' && (
|
||
<Box>
|
||
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" mb={1}>Model</Text>
|
||
<Code colorScheme="blue" fontSize="sm">{event.metrics.model}</Code>
|
||
</Box>
|
||
)}
|
||
|
||
{event.metrics && (event.metrics.tokens_in != null || event.metrics.latency_ms != null) && (
|
||
<Box>
|
||
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" mb={1}>Metrics</Text>
|
||
<HStack spacing={4} fontSize="sm">
|
||
{event.metrics.tokens_in != null && event.metrics.tokens_in > 0 && (
|
||
<Text>Tokens: <Code>{event.metrics.tokens_in}</Code> in / <Code>{event.metrics.tokens_out}</Code> out</Text>
|
||
)}
|
||
{event.metrics.latency_ms != null && event.metrics.latency_ms > 0 && (
|
||
<Text>Latency: <Code>{event.metrics.latency_ms}ms</Code></Text>
|
||
)}
|
||
</HStack>
|
||
</Box>
|
||
)}
|
||
|
||
{/* Show prompt if available */}
|
||
{event.prompt && (
|
||
<Box>
|
||
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" mb={1}>Request / Prompt</Text>
|
||
<Box bg="blackAlpha.500" p={3} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100" maxH="200px" overflowY="auto">
|
||
<Text fontSize="xs" fontFamily="mono" whiteSpace="pre-wrap" wordBreak="break-word" color="whiteAlpha.900">
|
||
{event.prompt.length > 1000 ? event.prompt.substring(0, 1000) + '…' : event.prompt}
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
{/* Show response if available (result events) */}
|
||
{event.response && (
|
||
<Box>
|
||
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" mb={1}>Response</Text>
|
||
<Box bg="blackAlpha.500" p={3} borderRadius="md" border="1px solid" borderColor="green.900" maxH="200px" overflowY="auto">
|
||
<Text fontSize="xs" fontFamily="mono" whiteSpace="pre-wrap" wordBreak="break-word" color="whiteAlpha.900">
|
||
{event.response.length > 1000 ? event.response.substring(0, 1000) + '…' : event.response}
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
{/* Fallback: show message if no prompt/response */}
|
||
{!event.prompt && !event.response && (
|
||
<Box>
|
||
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" mb={1}>Message</Text>
|
||
<Box bg="blackAlpha.500" p={3} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100" maxH="300px" overflowY="auto">
|
||
<Text fontSize="xs" fontFamily="mono" whiteSpace="pre-wrap" wordBreak="break-word" color="whiteAlpha.900">
|
||
{event.message}
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
{event.node_id && (
|
||
<Box>
|
||
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" mb={1}>Node ID</Text>
|
||
<Code fontSize="xs">{event.node_id}</Code>
|
||
</Box>
|
||
)}
|
||
</VStack>
|
||
);
|
||
|
||
// ─── Detail drawer showing all events for a given graph node ──────────
|
||
const NodeEventsDetail: React.FC<{ nodeId: string; events: AgentEvent[]; onOpenModal: (evt: AgentEvent) => void }> = ({ nodeId, events, onOpenModal }) => {
|
||
const nodeEvents = useMemo(
|
||
() => events.filter((e) => e.node_id === nodeId),
|
||
[events, nodeId],
|
||
);
|
||
|
||
if (nodeEvents.length === 0) {
|
||
return <Text color="whiteAlpha.500" fontSize="sm">No events recorded for this node yet.</Text>;
|
||
}
|
||
|
||
return (
|
||
<VStack align="stretch" spacing={4}>
|
||
{nodeEvents.map((evt) => (
|
||
<Box key={evt.id} bg="whiteAlpha.50" p={3} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100">
|
||
<EventDetail event={evt} onOpenModal={onOpenModal} />
|
||
</Box>
|
||
))}
|
||
</VStack>
|
||
);
|
||
};
|
||
|
||
// ─── Sidebar page type ────────────────────────────────────────────────
|
||
type Page = 'dashboard' | 'portfolio';
|
||
|
||
export const Dashboard: React.FC = () => {
|
||
const [activePage, setActivePage] = useState<Page>('dashboard');
|
||
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
||
const [activeRunType, setActiveRunType] = useState<RunType | null>(null);
|
||
const [isTriggering, setIsTriggering] = useState(false);
|
||
const { events, status, clearEvents } = useAgentStream(activeRunId);
|
||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||
const toast = useToast();
|
||
|
||
// Event detail modal state
|
||
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
|
||
const [modalEvent, setModalEvent] = useState<AgentEvent | null>(null);
|
||
|
||
// What's shown in the drawer: either a single event or all events for a node
|
||
const [drawerMode, setDrawerMode] = useState<'event' | 'node'>('event');
|
||
const [selectedEvent, setSelectedEvent] = useState<AgentEvent | null>(null);
|
||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||
|
||
// Parameter inputs
|
||
const [showParams, setShowParams] = useState(false);
|
||
const [params, setParams] = useState<RunParams>({
|
||
date: new Date().toISOString().split('T')[0],
|
||
ticker: 'AAPL',
|
||
portfolio_id: 'main_portfolio',
|
||
});
|
||
|
||
// Auto-scroll the terminal to the bottom as new events arrive
|
||
const terminalEndRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
terminalEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
}, [events.length]);
|
||
|
||
// Clear activeRunType when run completes
|
||
useEffect(() => {
|
||
if (status === 'completed' || status === 'error') {
|
||
setActiveRunType(null);
|
||
}
|
||
}, [status]);
|
||
|
||
const isRunning = isTriggering || status === 'streaming' || status === 'connecting';
|
||
|
||
const startRun = async (type: RunType) => {
|
||
if (isRunning) return;
|
||
|
||
// Validate required params
|
||
const required = REQUIRED_PARAMS[type];
|
||
const missing = required.filter((k) => !params[k]?.trim());
|
||
if (missing.length > 0) {
|
||
toast({
|
||
title: `Missing required fields for ${RUN_TYPE_LABELS[type]}`,
|
||
description: `Please fill in: ${missing.join(', ')}`,
|
||
status: 'warning',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
position: 'top',
|
||
});
|
||
setShowParams(true);
|
||
return;
|
||
}
|
||
|
||
setIsTriggering(true);
|
||
setActiveRunType(type);
|
||
try {
|
||
clearEvents();
|
||
const res = await axios.post(`${API_BASE}/run/${type}`, {
|
||
portfolio_id: params.portfolio_id,
|
||
date: params.date,
|
||
ticker: params.ticker,
|
||
});
|
||
setActiveRunId(res.data.run_id);
|
||
} catch (err) {
|
||
console.error("Failed to start run:", err);
|
||
setActiveRunType(null);
|
||
} finally {
|
||
setIsTriggering(false);
|
||
}
|
||
};
|
||
|
||
/** Open the full-screen event detail modal */
|
||
const openModal = useCallback((evt: AgentEvent) => {
|
||
setModalEvent(evt);
|
||
onModalOpen();
|
||
}, [onModalOpen]);
|
||
|
||
/** Open the drawer for a single event (terminal click). */
|
||
const openEventDetail = useCallback((evt: AgentEvent) => {
|
||
setDrawerMode('event');
|
||
setSelectedEvent(evt);
|
||
setSelectedNodeId(null);
|
||
onOpen();
|
||
}, [onOpen]);
|
||
|
||
/** Open the drawer showing all events for a graph node (node click). */
|
||
const openNodeDetail = useCallback((nodeId: string) => {
|
||
setDrawerMode('node');
|
||
setSelectedNodeId(nodeId);
|
||
setSelectedEvent(null);
|
||
onOpen();
|
||
}, [onOpen]);
|
||
|
||
// Derive a readable drawer title
|
||
const drawerTitle = drawerMode === 'event'
|
||
? `Event: ${selectedEvent?.agent ?? ''} — ${selectedEvent?.type ?? ''}`
|
||
: `Node: ${selectedNodeId ?? ''}`;
|
||
|
||
return (
|
||
<Flex h="100vh" bg="slate.950" color="white" overflow="hidden">
|
||
{/* Sidebar */}
|
||
<VStack w="64px" bg="slate.900" borderRight="1px solid" borderColor="whiteAlpha.100" py={4} spacing={6}>
|
||
<Box mb={4}><Text fontWeight="black" color="cyan.400" fontSize="xl">A</Text></Box>
|
||
<Tooltip label="Dashboard" placement="right">
|
||
<IconButton
|
||
aria-label="Dashboard"
|
||
icon={<LayoutDashboard size={20} />}
|
||
variant="ghost"
|
||
color={activePage === 'dashboard' ? 'cyan.400' : 'whiteAlpha.600'}
|
||
bg={activePage === 'dashboard' ? 'whiteAlpha.100' : 'transparent'}
|
||
_hover={{ bg: "whiteAlpha.100" }}
|
||
onClick={() => setActivePage('dashboard')}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip label="Portfolio" placement="right">
|
||
<IconButton
|
||
aria-label="Portfolio"
|
||
icon={<Wallet size={20} />}
|
||
variant="ghost"
|
||
color={activePage === 'portfolio' ? 'cyan.400' : 'whiteAlpha.600'}
|
||
bg={activePage === 'portfolio' ? 'whiteAlpha.100' : 'transparent'}
|
||
_hover={{ bg: "whiteAlpha.100" }}
|
||
onClick={() => setActivePage('portfolio')}
|
||
/>
|
||
</Tooltip>
|
||
<IconButton aria-label="Settings" icon={<Settings size={20} />} variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} />
|
||
</VStack>
|
||
|
||
{/* ─── Portfolio Page ────────────────────────────────────────── */}
|
||
{activePage === 'portfolio' && (
|
||
<Box flex="1">
|
||
<PortfolioViewer defaultPortfolioId={params.portfolio_id} />
|
||
</Box>
|
||
)}
|
||
|
||
{/* ─── Dashboard Page ────────────────────────────────────────── */}
|
||
{activePage === 'dashboard' && (
|
||
<Flex flex="1" direction="column">
|
||
{/* Top Metric Header */}
|
||
<MetricHeader portfolioId={params.portfolio_id} />
|
||
|
||
{/* Dashboard Body */}
|
||
<Flex flex="1" overflow="hidden">
|
||
{/* Left Side: Graph Area */}
|
||
<Box flex="1" position="relative" borderRight="1px solid" borderColor="whiteAlpha.100">
|
||
<AgentGraph events={events} onNodeClick={openNodeDetail} />
|
||
|
||
{/* Floating Control Panel */}
|
||
<VStack position="absolute" top={4} left={4} spacing={2} align="stretch">
|
||
{/* Run buttons row */}
|
||
<HStack bg="blackAlpha.800" p={2} borderRadius="lg" backdropFilter="blur(10px)" border="1px solid" borderColor="whiteAlpha.200" spacing={2}>
|
||
{(['scan', 'pipeline', 'portfolio', 'auto'] as RunType[]).map((type) => {
|
||
const isThisRunning = isRunning && activeRunType === type;
|
||
const isOtherRunning = isRunning && activeRunType !== type;
|
||
const icons: Record<RunType, React.ReactElement> = {
|
||
scan: <Search size={14} />,
|
||
pipeline: <BarChart3 size={14} />,
|
||
portfolio: <Wallet size={14} />,
|
||
auto: <Bot size={14} />,
|
||
};
|
||
const colors: Record<RunType, string> = {
|
||
scan: 'cyan',
|
||
pipeline: 'blue',
|
||
portfolio: 'purple',
|
||
auto: 'green',
|
||
};
|
||
return (
|
||
<Button
|
||
key={type}
|
||
size="sm"
|
||
leftIcon={icons[type]}
|
||
colorScheme={colors[type]}
|
||
variant="solid"
|
||
onClick={() => startRun(type)}
|
||
isLoading={isThisRunning}
|
||
loadingText="Running…"
|
||
isDisabled={isOtherRunning}
|
||
>
|
||
{RUN_TYPE_LABELS[type]}
|
||
</Button>
|
||
);
|
||
})}
|
||
<Divider orientation="vertical" h="20px" />
|
||
<Tag size="sm" colorScheme={status === 'streaming' ? 'green' : status === 'completed' ? 'blue' : status === 'error' ? 'red' : 'gray'}>
|
||
{status.toUpperCase()}
|
||
</Tag>
|
||
<IconButton
|
||
aria-label="Toggle params"
|
||
icon={showParams ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||
size="xs"
|
||
variant="ghost"
|
||
color="whiteAlpha.600"
|
||
onClick={() => setShowParams(!showParams)}
|
||
/>
|
||
</HStack>
|
||
|
||
{/* Collapsible parameter inputs */}
|
||
<Collapse in={showParams} animateOpacity>
|
||
<Box bg="blackAlpha.800" p={3} borderRadius="lg" backdropFilter="blur(10px)" border="1px solid" borderColor="whiteAlpha.200">
|
||
<VStack spacing={2} align="stretch">
|
||
<HStack>
|
||
<Text fontSize="xs" color="whiteAlpha.600" minW="70px">Date:</Text>
|
||
<Input
|
||
size="xs"
|
||
type="date"
|
||
bg="whiteAlpha.100"
|
||
borderColor="whiteAlpha.200"
|
||
value={params.date}
|
||
onChange={(e) => setParams((p) => ({ ...p, date: e.target.value }))}
|
||
/>
|
||
</HStack>
|
||
<HStack>
|
||
<Text fontSize="xs" color="whiteAlpha.600" minW="70px">Ticker:</Text>
|
||
<Input
|
||
size="xs"
|
||
placeholder="AAPL"
|
||
bg="whiteAlpha.100"
|
||
borderColor="whiteAlpha.200"
|
||
value={params.ticker}
|
||
onChange={(e) => setParams((p) => ({ ...p, ticker: e.target.value.toUpperCase() }))}
|
||
/>
|
||
</HStack>
|
||
<HStack>
|
||
<Text fontSize="xs" color="whiteAlpha.600" minW="70px">Portfolio:</Text>
|
||
<Input
|
||
size="xs"
|
||
placeholder="main_portfolio"
|
||
bg="whiteAlpha.100"
|
||
borderColor="whiteAlpha.200"
|
||
value={params.portfolio_id}
|
||
onChange={(e) => setParams((p) => ({ ...p, portfolio_id: e.target.value }))}
|
||
/>
|
||
</HStack>
|
||
<Text fontSize="2xs" color="whiteAlpha.400">
|
||
Required: Scan → date · Pipeline → ticker, date · Portfolio → date, portfolio · Auto → date, ticker
|
||
</Text>
|
||
</VStack>
|
||
</Box>
|
||
</Collapse>
|
||
</VStack>
|
||
</Box>
|
||
|
||
{/* Right Side: Live Terminal */}
|
||
<VStack w="400px" bg="blackAlpha.400" align="stretch" spacing={0}>
|
||
<Flex p={3} bg="whiteAlpha.50" align="center" gap={2} borderBottom="1px solid" borderColor="whiteAlpha.100">
|
||
<TerminalIcon size={16} color="#4fd1c5" />
|
||
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider">Live Terminal</Text>
|
||
<Text fontSize="2xs" color="whiteAlpha.400" ml="auto">{events.length} events</Text>
|
||
</Flex>
|
||
|
||
<Box flex="1" overflowY="auto" p={4} sx={{
|
||
'&::-webkit-scrollbar': { width: '4px' },
|
||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||
'&::-webkit-scrollbar-thumb': { background: 'whiteAlpha.300' }
|
||
}}>
|
||
{events.map((evt) => (
|
||
<Box
|
||
key={evt.id}
|
||
mb={2}
|
||
fontSize="xs"
|
||
fontFamily="mono"
|
||
px={2}
|
||
py={1}
|
||
borderRadius="md"
|
||
cursor="pointer"
|
||
_hover={{ bg: 'whiteAlpha.100' }}
|
||
onClick={() => openEventDetail(evt)}
|
||
transition="background 0.15s"
|
||
>
|
||
<Flex gap={2} align="center">
|
||
<Text color="whiteAlpha.400" minW="52px" flexShrink={0}>[{evt.timestamp}]</Text>
|
||
<Text flexShrink={0}>{eventLabel(evt.type)}</Text>
|
||
<Text color={eventColor(evt.type)} fontWeight="bold" flexShrink={0}>
|
||
{evt.agent}
|
||
</Text>
|
||
<ChevronRight size={10} style={{ flexShrink: 0, opacity: 0.4 }} />
|
||
<Text color="whiteAlpha.700" isTruncated>{eventSummary(evt)}</Text>
|
||
<Eye size={12} style={{ flexShrink: 0, opacity: 0.3, marginLeft: 'auto' }} />
|
||
</Flex>
|
||
</Box>
|
||
))}
|
||
{events.length === 0 && (
|
||
<Flex h="100%" align="center" justify="center" direction="column" gap={4} opacity={0.3}>
|
||
<TerminalIcon size={48} />
|
||
<Text fontSize="sm">Awaiting agent activation...</Text>
|
||
</Flex>
|
||
)}
|
||
<div ref={terminalEndRef} />
|
||
</Box>
|
||
</VStack>
|
||
</Flex>
|
||
</Flex>
|
||
)}
|
||
|
||
{/* Unified Inspector Drawer (single event or all node events) */}
|
||
<Drawer isOpen={isOpen} placement="right" onClose={onClose} size="md">
|
||
<DrawerOverlay backdropFilter="blur(4px)" />
|
||
<DrawerContent bg="slate.900" color="white" borderLeft="1px solid" borderColor="whiteAlpha.200">
|
||
<DrawerCloseButton />
|
||
<DrawerHeader borderBottomWidth="1px" borderColor="whiteAlpha.100">
|
||
{drawerTitle}
|
||
</DrawerHeader>
|
||
<DrawerBody py={4}>
|
||
{drawerMode === 'event' && selectedEvent && (
|
||
<EventDetail event={selectedEvent} onOpenModal={openModal} />
|
||
)}
|
||
{drawerMode === 'node' && selectedNodeId && (
|
||
<NodeEventsDetail nodeId={selectedNodeId} events={events} onOpenModal={openModal} />
|
||
)}
|
||
</DrawerBody>
|
||
</DrawerContent>
|
||
</Drawer>
|
||
|
||
{/* Full event detail modal */}
|
||
<EventDetailModal event={modalEvent} isOpen={isModalOpen} onClose={onModalClose} />
|
||
</Flex>
|
||
);
|
||
};
|