diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a058a82..9115f5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,5 +95,5 @@ jobs: run: | if [ -f packages/ui/package.json ]; then cd packages/ui - npm run build || true + npm run build fi diff --git a/packages/ui/app/globals.css b/packages/ui/app/globals.css new file mode 100644 index 0000000..6152f1a --- /dev/null +++ b/packages/ui/app/globals.css @@ -0,0 +1,72 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 240 10% 3.9%; + --foreground: 240 5% 96%; + + --card: 240 10% 6%; + --card-foreground: 240 5% 96%; + + --popover: 240 10% 3.9%; + --popover-foreground: 240 5% 96%; + + --primary: 263 70% 50%; + --primary-foreground: 210 20% 98%; + + --secondary: 240 4% 16%; + --secondary-foreground: 240 5% 96%; + + --muted: 240 4% 16%; + --muted-foreground: 240 5% 65%; + + --accent: 263 60% 40%; + --accent-foreground: 240 5% 96%; + + --destructive: 0 72% 51%; + --destructive-foreground: 210 20% 98%; + + --border: 240 4% 16%; + --input: 240 4% 16%; + --ring: 263 70% 50%; + --radius: 0.75rem; + } +} + +body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + font-family: 'Inter', system-ui, -apple-system, sans-serif; + overflow-x: hidden; +} + +/* Custom scrollbars for dashboard views */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: hsl(var(--border)); + border-radius: 9999px; +} +::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground)); +} + +/* Premium Card Glass Effects */ +.glass-panel { + background: rgba(15, 15, 23, 0.65); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.05); +} +.glass-panel-hover:hover { + border-color: rgba(147, 51, 234, 0.2); + box-shadow: 0 0 15px rgba(147, 51, 234, 0.05); + transition: all 0.2s ease-in-out; +} diff --git a/packages/ui/app/layout.tsx b/packages/ui/app/layout.tsx new file mode 100644 index 0000000..e13ea09 --- /dev/null +++ b/packages/ui/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "AgentScope Observability Dashboard", + description: "Real-time multi-agent telemetry and monitoring dashboard", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/packages/ui/app/page.tsx b/packages/ui/app/page.tsx new file mode 100644 index 0000000..b553a12 --- /dev/null +++ b/packages/ui/app/page.tsx @@ -0,0 +1,557 @@ +"use client"; + +import React, { useEffect, useState, useRef } from "react"; +import { useSessionStore } from "../store/sessionStore"; +import { Session, AgentEvent, ReactFlowNode, ReactFlowEdge } from "../types"; +import { + Activity, + Layers, + Cpu, + Wrench, + Database, + ChevronDown, + ChevronUp, + DollarSign, + Clock, + AlertTriangle, + Play, + CheckCircle, + XCircle, + HelpCircle, + RefreshCw, + Search, +} from "lucide-react"; + +export default function Dashboard() { + const { + sessions, + activeSession, + events, + setSessions, + setActiveSession, + setEvents, + addEvent, + updateSessionMeta, + } = useSessionStore(); + + const [graphData, setGraphData] = useState<{ + nodes: ReactFlowNode[]; + edges: ReactFlowEdge[]; + } | null>(null); + const [statsData, setStatsData] = useState(null); + const [expandedEvents, setExpandedEvents] = useState>( + {} + ); + const [searchQuery, setSearchQuery] = useState(""); + + const eventFeedEndRef = useRef(null); + + // Auto-scroll event feed to bottom + useEffect(() => { + eventFeedEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [events]); + + // Fetch Session list + const fetchSessionsList = async () => { + try { + const res = await fetch("http://127.0.0.1:8765/api/sessions"); + if (res.ok) { + const data = await res.json(); + setSessions(data); + + // Auto-select first session if none selected + if (data.length > 0 && !activeSession) { + handleSelectSession(data[0]); + } + } + } catch (e) { + console.warn("Failed to fetch sessions list", e); + } + }; + + useEffect(() => { + fetchSessionsList(); + const interval = setInterval(fetchSessionsList, 4000); + return () => clearInterval(interval); + }, [activeSession]); + + // Handle active session selection + const handleSelectSession = async (session: Session) => { + setActiveSession(session); + setEvents([]); + setGraphData(null); + setStatsData(null); + + // Fetch existing events + try { + const resEvents = await fetch( + `http://127.0.0.1:8765/api/sessions/${session.session_id}/events` + ); + if (resEvents.ok) { + const eventsData = await resEvents.json(); + setEvents(eventsData); + } + } catch (e) { + console.warn("Failed to fetch session events", e); + } + + // Fetch graph + try { + const resGraph = await fetch( + `http://127.0.0.1:8765/api/sessions/${session.session_id}/graph` + ); + if (resGraph.ok) { + const graph = await resGraph.json(); + setGraphData(graph); + } + } catch (e) { + console.warn("Failed to fetch session graph", e); + } + + // Fetch stats + try { + const resStats = await fetch( + `http://127.0.0.1:8765/api/sessions/${session.session_id}/stats` + ); + if (resStats.ok) { + const stats = await resStats.json(); + setStatsData(stats); + } + } catch (e) { + console.warn("Failed to fetch session stats", e); + } + }; + + // WebSocket event stream + useEffect(() => { + if (!activeSession) return; + + let ws: WebSocket; + const connectWS = () => { + const host = window.location.hostname || "127.0.0.1"; + ws = new WebSocket(`ws://${host}:8765/ws?client_type=ui`); + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + if (message.type === "event" && message.session_id === activeSession.session_id) { + addEvent(message.event); + // Refresh graph & stats when new terminal event lands + if (message.event.event_type.endsWith("_end") || message.event.event_type.endsWith("_error")) { + fetchGraphAndStats(activeSession.session_id); + } + } else if (message.type === "session_update" && message.session_id === activeSession.session_id) { + updateSessionMeta(message.session_id, message.meta); + } + } catch (err) { + console.error("Error processing websocket payload:", err); + } + }; + + ws.onclose = () => { + setTimeout(connectWS, 2000); + }; + }; + + connectWS(); + return () => ws?.close(); + }, [activeSession]); + + const fetchGraphAndStats = async (sessionId: string) => { + try { + const resGraph = await fetch(`http://127.0.0.1:8765/api/sessions/${sessionId}/graph`); + if (resGraph.ok) setGraphData(await resGraph.json()); + + const resStats = await fetch(`http://127.0.0.1:8765/api/sessions/${sessionId}/stats`); + if (resStats.ok) setStatsData(await resStats.json()); + } catch (e) { + console.warn("Refresh stats failed", e); + } + }; + + const toggleExpandEvent = (eventId: string) => { + setExpandedEvents((prev) => ({ + ...prev, + [eventId]: !prev[eventId], + })); + }; + + const getEventIcon = (type: string) => { + switch (type) { + case "chain_start": + case "chain_end": + case "chain_error": + return ; + case "llm_start": + case "llm_end": + case "llm_token": + case "llm_error": + return ; + case "tool_start": + case "tool_end": + case "tool_error": + return ; + case "retriever_start": + case "retriever_end": + return ; + default: + return ; + } + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case "completed": + return ( + + Completed + + ); + case "failed": + case "error": + return ( + + Failed + + ); + case "running": + return ( + + Running + + ); + default: + return ( + + Unknown + + ); + } + }; + + const formatTimestamp = (iso: string) => { + if (!iso) return ""; + const d = new Date(iso); + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + }; + + // Filter sessions + const filteredSessions = sessions.filter((s) => + s.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( +
+ {/* Sidebar Session List */} + + + {/* Main Panel */} +
+ {activeSession ? ( + <> + {/* Top Stats Banner */} +
+
+

{activeSession.name}

+

+ Session ID: {activeSession.session_id} +

+
+
+ {getStatusBadge(activeSession.status)} +
+
+ + {/* Metrics cards */} +
+
+
+ +
+
+
+ Total Tokens +
+
+ {statsData?.total_tokens ?? activeSession.total_tokens} +
+
+
+ +
+
+ +
+
+
+ Total Cost (USD) +
+
+ ${(statsData?.total_cost_usd ?? activeSession.total_cost_usd).toFixed(5)} +
+
+
+ +
+
+ +
+
+
+ Duration (s) +
+
+ {statsData?.total_duration_ms + ? (statsData.total_duration_ms / 1000).toFixed(2) + : "0.00"} +
+
+
+ +
+
+ +
+
+
+ Errors +
+
+ {statsData?.error_count ?? activeSession.error_count} +
+
+
+ +
+
+ +
+
+
+ Active Agents +
+
+ {statsData?.agents?.length ?? activeSession.agent_count} +
+
+
+
+ + {/* Split Screen Workspace */} +
+ {/* Chronological Event Feed */} +
+
+

+ Live Execution Feed +

+ + {events.length} Events Received + +
+ +
+ {events.map((event) => { + const isExpanded = !!expandedEvents[event.event_id]; + return ( +
+
toggleExpandEvent(event.event_id)} + className="p-3 flex items-center justify-between cursor-pointer hover:bg-secondary/20 transition-colors" + > +
+
+ {getEventIcon(event.event_type)} +
+
+
+ + {event.agent_name || "System"} + + + {event.event_type} + +
+ + {formatTimestamp(event.timestamp)} + +
+
+
+ {getStatusBadge(event.status)} + {isExpanded ? ( + + ) : ( + + )} +
+
+ + {/* Expandable Details Drawer */} + {isExpanded && ( +
+ {event.payload && ( +
+ {Object.entries(event.payload).map(([k, v]) => { + if (v === null || v === undefined) return null; + return ( +
+ {k}: + + {typeof v === "object" ? JSON.stringify(v, null, 2) : String(v)} + +
+ ); + })} +
+ )} +
+ )} +
+ ); + })} +
+
+
+ + {/* Agent Call Graph Visualization */} +
+
+

+ Agent Call Hierarchy +

+
+ +
+ {graphData && graphData.nodes?.length > 0 ? ( + graphData.nodes.map((node) => { + return ( +
+
+
+
+
{node.data.agentName}
+
+ Type: {node.data.eventType} +
+
+
+
+
+ {node.data.durationMs ? `${(node.data.durationMs / 1000).toFixed(2)}s` : "0.00s"} +
+
+ {node.data.tokenCount ? `${node.data.tokenCount} tokens` : ""} +
+
+
+ ); + }) + ) : ( +
+ +

No graph nodes generated for this session yet.

+
+ )} +
+
+
+ + ) : ( +
+ +

No Active Session Selected

+

+ Select an execution session from the left sidebar list or launch an agent pipeline to start streaming telemetry. +

+
+ )} +
+
+ ); +} diff --git a/packages/ui/next-env.d.ts b/packages/ui/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/packages/ui/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/packages/ui/next.config.js b/packages/ui/next.config.js index e2f0e6c..a843cbe 100644 --- a/packages/ui/next.config.js +++ b/packages/ui/next.config.js @@ -4,4 +4,3 @@ const nextConfig = { } module.exports = nextConfig -``` diff --git a/packages/ui/store/sessionStore.ts b/packages/ui/store/sessionStore.ts index a4146e1..5a016ff 100644 --- a/packages/ui/store/sessionStore.ts +++ b/packages/ui/store/sessionStore.ts @@ -40,8 +40,8 @@ export const useSessionStore = create((set) => ({ const existing = state.events[existingIndex]; // Merge payloads avoiding empty values - const mergedPayload = { ...existing.payload }; - const incomingPayload = event.payload || {}; + const mergedPayload = { ...existing.payload } as Record; + const incomingPayload = (event.payload || {}) as Record; for (const key in incomingPayload) { if (Object.prototype.hasOwnProperty.call(incomingPayload, key)) { const val = incomingPayload[key];