From 11f37bf609386ac5a3213e3ddd2389eb807be2e2 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Tue, 16 Sep 2025 19:05:36 +0530 Subject: [PATCH 1/8] feat: implement light mode and theme toggle functionality - Added light mode CSS variables to globals.css for improved UI flexibility. - Introduced ThemeProvider context to manage theme state across the application. - Integrated ThemeToggle component for users to switch between light and dark modes. - Updated layout and components to support theme changes, ensuring consistent styling. - Enhanced GraphTemplateBuilder and GraphVisualization components to reflect theme changes. These changes enhance user experience by providing a customizable interface that adapts to user preferences. auth: @nk-ag --- .../src/app/api/manual-retry-state/route.ts | 49 ++ dashboard/src/app/globals.css | 63 +- .../app/graph/[namespace]/[runId]/page.tsx | 133 ++++ dashboard/src/app/layout.tsx | 5 +- dashboard/src/app/page.tsx | 4 +- .../src/components/GraphTemplateBuilder.tsx | 16 +- .../src/components/GraphTemplateDetail.tsx | 625 ++++++++++++++++++ .../components/GraphTemplateDetailModal.tsx | 623 +---------------- .../src/components/GraphVisualization.tsx | 348 ++++------ dashboard/src/components/NodeDetailsModal.tsx | 387 +++++++++++ dashboard/src/components/RunsTable.tsx | 34 +- dashboard/src/components/ThemeToggle.tsx | 31 + dashboard/src/components/ui/button.tsx | 4 +- dashboard/src/components/ui/tabs.tsx | 4 +- dashboard/src/contexts/ThemeContext.tsx | 71 ++ dashboard/src/services/clientApi.ts | 15 + dashboard/src/types/state-manager.ts | 6 + .../app/controller/get_graph_structure.py | 7 +- .../pending_test_get_graph_structure.py | 4 +- .../controller/test_get_graph_structure.py | 4 +- 20 files changed, 1545 insertions(+), 888 deletions(-) create mode 100644 dashboard/src/app/api/manual-retry-state/route.ts create mode 100644 dashboard/src/app/graph/[namespace]/[runId]/page.tsx create mode 100644 dashboard/src/components/GraphTemplateDetail.tsx create mode 100644 dashboard/src/components/NodeDetailsModal.tsx create mode 100644 dashboard/src/components/ThemeToggle.tsx create mode 100644 dashboard/src/contexts/ThemeContext.tsx diff --git a/dashboard/src/app/api/manual-retry-state/route.ts b/dashboard/src/app/api/manual-retry-state/route.ts new file mode 100644 index 00000000..7ab60d67 --- /dev/null +++ b/dashboard/src/app/api/manual-retry-state/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE_URL = process.env.EXOSPHERE_STATE_MANAGER_URI || 'http://localhost:8000'; +const API_KEY = process.env.EXOSPHERE_API_KEY; + +export async function POST(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const namespace = searchParams.get('namespace'); + const stateId = searchParams.get('stateId'); + + if (!namespace || !stateId) { + return NextResponse.json({ error: 'Namespace and stateId are required' }, { status: 400 }); + } + + if (!API_KEY) { + return NextResponse.json({ error: 'API key not configured' }, { status: 500 }); + } + + const body = await request.json(); + + if (!body.fanout_id) { + return NextResponse.json({ error: 'fanout_id is required in request body' }, { status: 400 }); + } + + const response = await fetch(`${API_BASE_URL}/v0/namespace/${namespace}/state/${stateId}/manual-retry`, { + method: 'POST', + headers: { + 'X-API-Key': API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`State manager API error: ${response.status} ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error('Error retrying state:', error); + return NextResponse.json( + { error: 'Failed to retry state' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/dashboard/src/app/globals.css b/dashboard/src/app/globals.css index 353c9d91..5ab90515 100644 --- a/dashboard/src/app/globals.css +++ b/dashboard/src/app/globals.css @@ -38,8 +38,8 @@ --input: #1a2a5a; /* Navy input background */ --ring: #87ceeb; /* Sky blue focus ring */ --chart-1: #87ceeb; /* Sky blue */ - --chart-2: #4ade80; /* Green accent */ - --chart-3: #fbbf24; /* Yellow accent */ + --chart-2: #66d1b5; /* Green accent */ + --chart-3: #ffed9e; /* Yellow accent */ --chart-4: #ff6b8a; /* Pink accent */ --chart-5: #a78bfa; /* Purple accent */ --sidebar: #0a1a4a; @@ -52,6 +52,44 @@ --sidebar-ring: #87ceeb; } +/* Light Mode Variables */ +.light { + --background: #ffffff; /* White background */ + --foreground: #031035; /* Dark navy text */ + --card: #f2f7fb; /* Light card background */ + --card-foreground: #031035; + --popover: #ffffff; + --popover-foreground: #031035; + --primary: #031035; /* Dark navy primary (keeping dark blue accent) */ + --primary-foreground: #ffffff; + --secondary: #f1f5f9; /* Light gray secondary */ + --secondary-foreground: #031035; + --muted: #f1f5f9; /* Light muted background */ + --muted-foreground: #64748b; /* Medium gray text */ + --accent: #031035; /* Keep dark blue accent */ + --accent-light: #0a1a4a; /* Keep dark blue accent */ + --accent-lighter: #1a2a5a; /* Keep dark blue accent */ + --accent-lightest: #2a3a6a; /* Keep dark blue accent */ + --accent-foreground: #ffffff; + --destructive: #dc2626; /* Red for errors in light mode */ + --border: #e2e8f0; /* Light border */ + --input: #ffffff; /* White input background */ + --ring: #87ceeb; /* Keep sky blue focus ring */ + --chart-1: #87ceeb; /* Sky blue */ + --chart-2: #4ade80; /* Green accent */ + --chart-3: #cca301; /* Yellow accent */ + --chart-4: #ff6b8a; /* Pink accent */ + --chart-5: #a78bfa; /* Purple accent */ + --sidebar: #f8fafc; + --sidebar-foreground: #031035; + --sidebar-primary: #031035; /* Dark navy for sidebar primary in light mode */ + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #f1f5f9; + --sidebar-accent-foreground: #031035; + --sidebar-border: #e2e8f0; + --sidebar-ring: #87ceeb; +} + /* Custom Scrollbar Styling */ ::-webkit-scrollbar { width: 8px; @@ -113,7 +151,18 @@ } } - +/* Light mode react-flow nodes */ +.light .react-flow__node{ + background-color: var(--card); + color: var(--card-foreground); + border: 1px solid var(--border); + &:hover{ + background-color: var(--muted); + } + &:active{ + background-color: var(--muted); + } +} @theme inline { --color-background: var(--background); @@ -169,9 +218,13 @@ @apply bg-background text-foreground font-sans; } - /* Custom select dropdown styling for better dark theme support */ + /* Custom select dropdown styling for better theme support */ select { - color-scheme: dark; + color-scheme: light dark; + } + + .light select { + color-scheme: light; } select option { diff --git a/dashboard/src/app/graph/[namespace]/[runId]/page.tsx b/dashboard/src/app/graph/[namespace]/[runId]/page.tsx new file mode 100644 index 00000000..48722305 --- /dev/null +++ b/dashboard/src/app/graph/[namespace]/[runId]/page.tsx @@ -0,0 +1,133 @@ +'use client'; + +import React, { useState, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { GraphVisualization } from '@/components/GraphVisualization'; +import { GraphTemplateDetail } from '@/components/GraphTemplateDetail'; +import { ThemeToggle } from '@/components/ThemeToggle'; +import { Button } from '@/components/ui/button'; +import { ArrowLeft } from 'lucide-react'; +import { clientApiService } from '@/services/clientApi'; +import { UpsertGraphTemplateResponse } from '@/types/state-manager'; + +export default function GraphPage() { + const router = useRouter(); + const params = useParams(); + + const namespace = params?.namespace as string; + const runId = params?.runId as string; + + // Graph template state + const [graphTemplate, setGraphTemplate] = useState(null); + const [isLoadingTemplate, setIsLoadingTemplate] = useState(false); + const [templateError, setTemplateError] = useState(null); + + const handleBack = () => { + // Go back to the previous page or close the tab if opened from external link + if (window.history.length > 1) { + router.back(); + } else { + window.close(); + } + }; + + const handleOpenGraphTemplate = useCallback(async (graphName: string) => { + if (!graphName || !namespace) return; + + try { + setIsLoadingTemplate(true); + setTemplateError(null); + const template = await clientApiService.getGraphTemplate(namespace, graphName); + // Add name and namespace to the template + template.name = graphName; + template.namespace = namespace; + setGraphTemplate(template); + } catch (err) { + setTemplateError(err instanceof Error ? err.message : 'Failed to load graph template'); + } finally { + setIsLoadingTemplate(false); + } + }, [namespace]); + + const handleCloseGraphTemplate = () => { + setGraphTemplate(null); + setTemplateError(null); + }; + + if (!namespace || !runId) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+

+ Graph Visualization +

+

+ Namespace: {namespace} | Run: {runId} +

+
+
+ +
+
+
+ + {/* Main Content */} +
+ +
+ + {/* Graph Template Detail Modal - Inline at bottom */} +
+ {templateError && ( +
+

{templateError}

+
+ )} + + {isLoadingTemplate && ( +
+
+
+

Loading graph template...

+
+
+ )} + + +
+
+ ); +} \ No newline at end of file diff --git a/dashboard/src/app/layout.tsx b/dashboard/src/app/layout.tsx index 76934dfe..f790cec4 100644 --- a/dashboard/src/app/layout.tsx +++ b/dashboard/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { ThemeProvider } from "@/contexts/ThemeContext"; import "./globals.css"; const geistSans = Geist({ @@ -27,7 +28,9 @@ export default function RootLayout({ - {children} + + {children} + ); diff --git a/dashboard/src/app/page.tsx b/dashboard/src/app/page.tsx index 879b2982..d03e99d6 100644 --- a/dashboard/src/app/page.tsx +++ b/dashboard/src/app/page.tsx @@ -6,7 +6,8 @@ import { GraphTemplateBuilder } from '@/components/GraphTemplateBuilder'; import { NamespaceOverview } from '@/components/NamespaceOverview'; import { RunsTable } from '@/components/RunsTable'; import { NodeDetailModal } from '@/components/NodeDetailModal'; -import { GraphTemplateDetailModal } from '@/components/GraphTemplateDetailModal'; +import { GraphTemplateDetailModal} from '@/components/GraphTemplateDetailModal'; +import { ThemeToggle } from '@/components/ThemeToggle'; import { clientApiService } from '@/services/clientApi'; import { NodeRegistration, @@ -168,6 +169,7 @@ export default function Dashboard() { )} + diff --git a/dashboard/src/components/GraphTemplateBuilder.tsx b/dashboard/src/components/GraphTemplateBuilder.tsx index 83ab2472..d663e71e 100644 --- a/dashboard/src/components/GraphTemplateBuilder.tsx +++ b/dashboard/src/components/GraphTemplateBuilder.tsx @@ -79,11 +79,11 @@ export const GraphTemplateBuilder: React.FC = ({ return (
-

Graph Template Builder

+

Graph Template Builder

{!readOnly && ( @@ -93,11 +93,11 @@ export const GraphTemplateBuilder: React.FC = ({ {/* Nodes Section */}
-

Workflow Nodes

+

Workflow Nodes

{!readOnly && ( -
- - - {/* Content */} - - - - Overview - Visualization - Nodes - Retry Policy - Store Config - - - -
- - - - - Template Information - - - -
- -
{graphTemplate.name}
-
-
- -
{graphTemplate.namespace}
-
-
- -
- {new Date(graphTemplate.created_at).toLocaleString()} -
-
-
-
- - - - - - Statistics - - - -
- Total Nodes - {graphTemplate.nodes?.length || 0} -
-
- Secrets - - {graphTemplate.secrets ? Object.keys(graphTemplate.secrets).length : 0} - -
-
- Status - - {graphTemplate.validation_status} - -
-
-
-
- - {graphTemplate.validation_errors && ( - - - Validation Errors - - -
- {graphTemplate.validation_errors} -
-
-
- )} -
- - - - - - - {graphTemplate.nodes && graphTemplate.nodes.length > 0 ? ( -
- {graphTemplate.nodes.map((node, index) => ( - - ))} -
- ) : ( - - - -

No Nodes

-

This graph template doesn't have any nodes configured.

-
-
- )} -
- - - - - - - - -
-
- +
+ +
); }; diff --git a/dashboard/src/components/GraphVisualization.tsx b/dashboard/src/components/GraphVisualization.tsx index 56f115f6..7536bfcc 100644 --- a/dashboard/src/components/GraphVisualization.tsx +++ b/dashboard/src/components/GraphVisualization.tsx @@ -34,10 +34,12 @@ import { import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { NodeDetailsModal } from '@/components/NodeDetailsModal'; interface GraphVisualizationProps { namespace: string; runId: string; + onGraphTemplateRequest?: (graphName: string) => void; } // Custom Node Component @@ -108,7 +110,7 @@ const CustomNode: React.FC<{
{data.label}
-
{data.identifier}
+
{data.identifier}
); }; @@ -119,7 +121,8 @@ const nodeTypes: NodeTypes = { export const GraphVisualization: React.FC = ({ namespace, - runId + runId, + onGraphTemplateRequest }) => { const [graphData, setGraphData] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -136,12 +139,17 @@ export const GraphVisualization: React.FC = ({ try { const data = await clientApiService.getGraphStructure(namespace, runId); setGraphData(data); + + // Request graph template details if callback is provided and graph name exists + if (onGraphTemplateRequest && data.graph_name) { + onGraphTemplateRequest(data.graph_name); + } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load graph structure'); } finally { setIsLoading(false); } - }, [namespace, runId]); + }, [namespace, runId, onGraphTemplateRequest]); const loadNodeDetails = useCallback(async (nodeId: string, graphName: string) => { setIsLoadingNodeDetails(true); @@ -378,21 +386,115 @@ export const GraphVisualization: React.FC = ({ {/* Execution Summary */} - - + +
Execution Summary
-
- {Object.entries(graphData.execution_summary).map(([status, count]) => ( -
-
{count}
-
{status.toLowerCase()}
-
- ))} +
+ {Object.entries(graphData.execution_summary).map(([status, count]) => { + const getStatusConfig = (status: string) => { + switch (status.toLowerCase()) { + case 'success': + return { + bg: 'bg-secondary', + border: 'border-border', + text: 'text-chart-2', // Green from chart-2 + icon: , + count: 'text-chart-2' + }; + case 'created': + return { + bg: 'bg-secondary', + border: 'border-border', + text: 'text-chart-1', // Sky blue from chart-1 + icon: , + count: 'text-chart-1' + }; + case 'queued': + return { + bg: 'bg-secondary', + border: 'border-border', + text: 'text-chart-3', // Yellow from chart-3 + icon: , + count: 'text-chart-3' + }; + case 'executed': + return { + bg: 'bg-secondary', + border: 'border-border', + text: 'text-chart-5', // Purple from chart-5 + icon: , + count: 'text-chart-5' + }; + case 'errored': + return { + bg: 'bg-secondary', + border: 'border-border', + text: 'text-destructive', // Red from destructive + icon: , + count: 'text-destructive' + }; + case 'next_created_error': + return { + bg: 'bg-secondary', + border: 'border-border', + text: 'text-chart-4', // Pink from chart-4 + icon: , + count: 'text-chart-4' + }; + case 'pruned': + return { + bg: 'bg-secondary', + border: 'border-border', + text: 'text-muted-foreground', + icon: , + count: 'text-muted-foreground' + }; + case 'retry_created': + return { + bg: 'bg-secondary', + border: 'border-border', + text: 'text-primary', // Sky blue from primary + icon: , + count: 'text-primary' + }; + default: + return { + bg: 'bg-muted', + border: 'border-border', + text: 'text-muted-foreground', + icon: , + count: 'text-foreground' + }; + } + }; + + const config = getStatusConfig(status); + const displayName = status.toLowerCase().replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + + return ( +
+
+
+ {config.icon} +
+
+ {count} +
+
+ {displayName} +
+
+
+ ); + })}
@@ -435,215 +537,19 @@ export const GraphVisualization: React.FC = ({ {/* Node Details Modal */} - {selectedNode && ( -
- - -
- Node Details - -
-
- - - {/* Loading State */} - {isLoadingNodeDetails && ( -
- - Loading node details... -
- )} - - {/* Error State */} - {nodeDetailsError && ( -
- -
-

{nodeDetailsError}

-
-
- )} - - {/* Node Header - always show basic info */} -
- {(() => { - const status = selectedNodeDetails?.status || selectedNode.status; - switch (status) { - case 'CREATED': - return ; - case 'QUEUED': - return ; - case 'EXECUTED': - case 'SUCCESS': - return ; - case 'ERRORED': - case 'TIMEDOUT': - case 'CANCELLED': - return ; - default: - return ; - } - })()} -
-

{selectedNodeDetails?.node_name || selectedNode.node_name}

-

{selectedNodeDetails?.identifier || selectedNode.identifier}

-
- { - const status = selectedNodeDetails?.status || selectedNode.status; - switch (status) { - case 'EXECUTED': - case 'SUCCESS': - return 'success' as const; - case 'ERRORED': - case 'TIMEDOUT': - case 'CANCELLED': - return 'destructive' as const; - case 'QUEUED': - return 'secondary' as const; - default: - return 'default' as const; - } - })()}> - {selectedNodeDetails?.status || selectedNode.status} - -
- - {/* Only show detailed sections if not loading and no error */} - {!isLoadingNodeDetails && !nodeDetailsError && ( - <> -
-
-
Node Information
-
-
- ID: - {selectedNodeDetails?.id || selectedNode.id} -
-
- Name: - {selectedNodeDetails?.node_name || selectedNode.node_name} -
-
- Identifier: - {selectedNodeDetails?.identifier || selectedNode.identifier} -
- {selectedNodeDetails?.graph_name && ( -
- Graph: - {selectedNodeDetails.graph_name} -
- )} - {selectedNodeDetails?.run_id && ( -
- Run ID: - {selectedNodeDetails.run_id} -
- )} -
-
-
-
Status & Timestamps
-
-
- Current Status: - {selectedNodeDetails?.status || selectedNode.status} -
- {selectedNodeDetails?.created_at && ( -
- Created: - {new Date(selectedNodeDetails.created_at).toLocaleString()} -
- )} - {selectedNodeDetails?.updated_at && ( -
- Updated: - {new Date(selectedNodeDetails.updated_at).toLocaleString()} -
- )} -
-
-
- - {/* Error Section */} - {(selectedNodeDetails?.error || selectedNode.error) && ( -
-
Error
-
- {selectedNodeDetails?.error || selectedNode.error} -
-
- )} - - {/* Parent Nodes Section */} - {selectedNodeDetails?.parents && Object.keys(selectedNodeDetails.parents).length > 0 && ( -
-
Parent Nodes
-
-
- {Object.entries(selectedNodeDetails.parents).map(([identifier, parentId]) => ( -
- {identifier}: - {parentId} -
- ))} -
-
-
- )} - - {/* Inputs Section */} -
-
Inputs
-
- {(() => { - const inputs = selectedNodeDetails?.inputs || selectedNode.inputs || {}; - return Object.keys(inputs).length > 0 ? ( -
-                            {JSON.stringify(inputs, null, 2)}
-                          
- ) : ( -

No inputs

- ); - })()} -
-
- - {/* Outputs Section */} -
-
Outputs
-
- {(() => { - const outputs = selectedNodeDetails?.outputs || selectedNode.outputs || {}; - return Object.keys(outputs).length > 0 ? ( -
-                            {JSON.stringify(outputs, null, 2)}
-                          
- ) : ( -

No outputs

- ); - })()} -
-
- - )} - -
- Node ID: {selectedNodeDetails?.id || selectedNode.id} -
-
-
-
- )} + { + setSelectedNode(null); + setSelectedNodeDetails(null); + setNodeDetailsError(null); + }} + onRefreshGraph={loadGraphStructure} + />
); }; diff --git a/dashboard/src/components/NodeDetailsModal.tsx b/dashboard/src/components/NodeDetailsModal.tsx new file mode 100644 index 00000000..68b25e4c --- /dev/null +++ b/dashboard/src/components/NodeDetailsModal.tsx @@ -0,0 +1,387 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { + AlertCircle, + Clock, + CheckCircle, + XCircle, + Loader2, + RefreshCw +} from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { GraphNode as GraphNodeType, NodeRunDetailsResponse } from '@/types/state-manager'; +import { clientApiService } from '@/services/clientApi'; + +interface NodeDetailsModalProps { + selectedNode: GraphNodeType | null; + selectedNodeDetails: NodeRunDetailsResponse | null; + isLoadingNodeDetails: boolean; + nodeDetailsError: string | null; + namespace: string; + onClose: () => void; + onRefreshGraph: () => void; +} + +export const NodeDetailsModal: React.FC = ({ + selectedNode, + selectedNodeDetails, + isLoadingNodeDetails, + nodeDetailsError, + namespace, + onClose, + onRefreshGraph +}) => { + const [retryState, setRetryState] = useState<'idle' | 'confirm' | 'loading' | 'success' | 'error'>('idle'); + const [retryError, setRetryError] = useState(null); + const [countdown, setCountdown] = useState(null); + + // Reset retry state when modal closes or node changes + useEffect(() => { + setRetryState('idle'); + setRetryError(null); + setCountdown(null); + }, [selectedNode]); + + // Countdown timer for reverting from confirm state + useEffect(() => { + let timer: NodeJS.Timeout; + if (retryState === 'confirm' && countdown !== null && countdown > 0) { + timer = setTimeout(() => { + setCountdown(countdown - 1); + }, 1000); + } else if (retryState === 'confirm' && countdown === 0) { + setRetryState('idle'); + setCountdown(null); + } + return () => clearTimeout(timer); + }, [retryState, countdown]); + + const handleRetryClick = async () => { + if (!selectedNode) return; + + if (retryState === 'idle') { + // First click - show confirmation + setRetryState('confirm'); + setCountdown(10); + } else if (retryState === 'confirm') { + // Second click - execute retry + setRetryState('loading'); + setRetryError(null); + setCountdown(null); + + try { + // Generate UUID for fanout_id + const fanoutId = crypto.randomUUID ? crypto.randomUUID() : + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + + await clientApiService.manualRetryState(namespace, selectedNode.id, fanoutId); + + setRetryState('success'); + + // Refresh the graph visualization to show updated state + onRefreshGraph(); + + // Reset to idle after 3 seconds + setTimeout(() => { + setRetryState('idle'); + }, 3000); + } catch (error) { + setRetryError(error instanceof Error ? error.message : 'Failed to retry state'); + setRetryState('error'); + + // Reset to idle after 5 seconds + setTimeout(() => { + setRetryState('idle'); + setRetryError(null); + }, 5000); + } + } + }; + + const getRetryButtonContent = () => { + switch (retryState) { + case 'confirm': + return ( + <> + + Confirm Retry? ({countdown}s) + + ); + case 'loading': + return ( + <> + + Retrying... + + ); + case 'success': + return ( + <> + + Retry Initiated + + ); + case 'error': + return ( + <> + + Retry Failed + + ); + default: + return ( + <> + + Retry + + ); + } + }; + + const getRetryButtonVariant = () => { + switch (retryState) { + case 'confirm': + return 'pending' as const; + case 'success': + return 'default' as const; + case 'error': + return 'destructive' as const; + default: + return 'outline' as const; + } + }; + + if (!selectedNode) return null; + + return ( +
+ + +
+ Node Details + +
+
+ + + {/* Loading State */} + {isLoadingNodeDetails && ( +
+ + Loading node details... +
+ )} + + {/* Error State */} + {nodeDetailsError && ( +
+ +
+

{nodeDetailsError}

+
+
+ )} + + {/* Node Header - always show basic info */} +
+ {(() => { + const status = selectedNodeDetails?.status || selectedNode.status; + switch (status) { + case 'CREATED': + return ; + case 'QUEUED': + return ; + case 'EXECUTED': + case 'SUCCESS': + return ; + case 'ERRORED': + case 'TIMEDOUT': + case 'CANCELLED': + return ; + default: + return ; + } + })()} +
+

{selectedNodeDetails?.node_name || selectedNode.node_name}

+

{selectedNodeDetails?.identifier || selectedNode.identifier}

+
+ { + const status = selectedNodeDetails?.status || selectedNode.status; + switch (status) { + case 'EXECUTED': + case 'SUCCESS': + return 'success' as const; + case 'ERRORED': + case 'TIMEDOUT': + case 'CANCELLED': + return 'destructive' as const; + case 'QUEUED': + return 'secondary' as const; + default: + return 'default' as const; + } + })()}> + {selectedNodeDetails?.status || selectedNode.status} + +
+ + {/* Retry Button Section */} +
+
+
Node Actions
+

Retry this node's execution

+ {retryError && ( +

{retryError}

+ )} +
+ +
+ + {/* Only show detailed sections if not loading and no error */} + {!isLoadingNodeDetails && !nodeDetailsError && ( + <> +
+
+
Node Information
+
+
+ ID: + {selectedNodeDetails?.id || selectedNode.id} +
+
+ Name: + {selectedNodeDetails?.node_name || selectedNode.node_name} +
+
+ Identifier: + {selectedNodeDetails?.identifier || selectedNode.identifier} +
+ {selectedNodeDetails?.graph_name && ( +
+ Graph: + {selectedNodeDetails.graph_name} +
+ )} + {selectedNodeDetails?.run_id && ( +
+ Run ID: + {selectedNodeDetails.run_id} +
+ )} +
+
+
+
Status & Timestamps
+
+
+ Current Status: + {selectedNodeDetails?.status || selectedNode.status} +
+ {selectedNodeDetails?.created_at && ( +
+ Created: + {new Date(selectedNodeDetails.created_at).toLocaleString()} +
+ )} + {selectedNodeDetails?.updated_at && ( +
+ Updated: + {new Date(selectedNodeDetails.updated_at).toLocaleString()} +
+ )} +
+
+
+ + {/* Error Section */} + {(selectedNodeDetails?.error || selectedNode.error) && ( +
+
Error
+
+ {selectedNodeDetails?.error || selectedNode.error} +
+
+ )} + + {/* Parent Nodes Section */} + {selectedNodeDetails?.parents && Object.keys(selectedNodeDetails.parents).length > 0 && ( +
+
Parent Nodes
+
+
+ {Object.entries(selectedNodeDetails.parents).map(([identifier, parentId]) => ( +
+ {identifier}: + {parentId} +
+ ))} +
+
+
+ )} + + {/* Inputs Section */} +
+
Inputs
+
+ {(() => { + const inputs = selectedNodeDetails?.inputs || selectedNode.inputs || {}; + return Object.keys(inputs).length > 0 ? ( +
+                        {JSON.stringify(inputs, null, 2)}
+                      
+ ) : ( +

No inputs

+ ); + })()} +
+
+ + {/* Outputs Section */} +
+
Outputs
+
+ {(() => { + const outputs = selectedNodeDetails?.outputs || selectedNode.outputs || {}; + return Object.keys(outputs).length > 0 ? ( +
+                        {JSON.stringify(outputs, null, 2)}
+                      
+ ) : ( +

No outputs

+ ); + })()} +
+
+ + )} + +
+ Node ID: {selectedNodeDetails?.id || selectedNode.id} +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/dashboard/src/components/RunsTable.tsx b/dashboard/src/components/RunsTable.tsx index ad490211..cf0e1a98 100644 --- a/dashboard/src/components/RunsTable.tsx +++ b/dashboard/src/components/RunsTable.tsx @@ -3,7 +3,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import { clientApiService } from '@/services/clientApi'; import { RunsResponse, RunListItem, RunStatusEnum } from '@/types/state-manager'; -import { GraphVisualization } from './GraphVisualization'; import { ChevronLeft, ChevronRight, @@ -48,8 +47,6 @@ export const RunsTable: React.FC = ({ const [pageSize, setPageSize] = useState(20); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [selectedRunId, setSelectedRunId] = useState(null); - const [showGraph, setShowGraph] = useState(false); const [refreshInterval, setRefreshInterval] = useState(0); const loadRuns = useCallback(async (page: number, size: number) => { @@ -98,20 +95,17 @@ export const RunsTable: React.FC = ({ const handlePageChange = (newPage: number) => { setCurrentPage(newPage); - setSelectedRunId(null); - setShowGraph(false); }; const handlePageSizeChange = (newSize: number) => { setPageSize(newSize); setCurrentPage(1); - setSelectedRunId(null); - setShowGraph(false); }; const handleRowClick = (runId: string) => { - setSelectedRunId(runId); - setShowGraph(true); + // Open graph visualization in a new tab + const url = `/graph/${namespace}/${runId}`; + window.open(url, '_blank'); }; const getStatusIcon = (status: RunStatusEnum) => { @@ -221,28 +215,6 @@ export const RunsTable: React.FC = ({ - {/* Graph Visualization */} - {showGraph && selectedRunId && ( - - - Graph Visualization for Run: {selectedRunId} - - - - - - - )} - {/* Runs Table */} diff --git a/dashboard/src/components/ThemeToggle.tsx b/dashboard/src/components/ThemeToggle.tsx new file mode 100644 index 00000000..d7da8c90 --- /dev/null +++ b/dashboard/src/components/ThemeToggle.tsx @@ -0,0 +1,31 @@ +'use client'; + +import React from 'react'; +import { Sun, Moon } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useTheme } from '@/contexts/ThemeContext'; + +export const ThemeToggle: React.FC = () => { + const { theme, toggleTheme } = useTheme(); + + return ( + + ); +}; \ No newline at end of file diff --git a/dashboard/src/components/ui/button.tsx b/dashboard/src/components/ui/button.tsx index a2df8dce..64afec7a 100644 --- a/dashboard/src/components/ui/button.tsx +++ b/dashboard/src/components/ui/button.tsx @@ -13,8 +13,10 @@ const buttonVariants = cva( "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + pending: + "bg-accent-lightest text-white shadow-xs hover:bg-accent-lightest/80 focus-visible:ring-accent-lightest/20 dark:focus-visible:ring-accent-lightest/40 dark:bg-accent-lightest/60", outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + "border bg-background shadow-xs hover:bg-accent-light hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: diff --git a/dashboard/src/components/ui/tabs.tsx b/dashboard/src/components/ui/tabs.tsx index 497ba5ea..0492b847 100644 --- a/dashboard/src/components/ui/tabs.tsx +++ b/dashboard/src/components/ui/tabs.tsx @@ -26,7 +26,7 @@ function TabsList({ void; + setTheme: (theme: Theme) => void; +} + +const ThemeContext = createContext(undefined); + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; + +interface ThemeProviderProps { + children: React.ReactNode; +} + +export const ThemeProvider: React.FC = ({ children }) => { + const [theme, setThemeState] = useState('dark'); + + useEffect(() => { + // Check for saved theme preference or default to dark + const savedTheme = localStorage.getItem('theme') as Theme; + if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) { + setThemeState(savedTheme); + } else { + // Check system preference + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + setThemeState(prefersDark ? 'dark' : 'light'); + } + }, []); + + useEffect(() => { + // Apply theme to document + const root = document.documentElement; + root.classList.remove('light', 'dark'); + root.classList.add(theme); + + // Save theme preference + localStorage.setItem('theme', theme); + }, [theme]); + + const toggleTheme = () => { + setThemeState(prev => prev === 'light' ? 'dark' : 'light'); + }; + + const setTheme = (newTheme: Theme) => { + setThemeState(newTheme); + }; + + const value: ThemeContextType = { + theme, + toggleTheme, + setTheme, + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/dashboard/src/services/clientApi.ts b/dashboard/src/services/clientApi.ts index 9647f009..3674ea58 100644 --- a/dashboard/src/services/clientApi.ts +++ b/dashboard/src/services/clientApi.ts @@ -30,6 +30,21 @@ export class ClientApiService { return response.json(); } + // Manual Retry State + async manualRetryState(namespace: string, stateId: string, fanoutId: string) { + const response = await fetch(`/api/manual-retry-state?namespace=${encodeURIComponent(namespace)}&stateId=${encodeURIComponent(stateId)}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fanout_id: fanoutId }), + }); + if (!response.ok) { + throw new Error(`Failed to retry state: ${response.statusText}`); + } + return response.json(); + } + // Namespace Overview async getNamespaceOverview(namespace: string) { const response = await fetch(`/api/namespace-overview?namespace=${encodeURIComponent(namespace)}`); diff --git a/dashboard/src/types/state-manager.ts b/dashboard/src/types/state-manager.ts index e1b9a466..7ae03134 100644 --- a/dashboard/src/types/state-manager.ts +++ b/dashboard/src/types/state-manager.ts @@ -245,3 +245,9 @@ export interface RunsResponse { size: number; runs: RunListItem[]; } + +// Manual Retry Types +export interface ManualRetryResponseModel { + id: string; + status: StateStatus; +} diff --git a/state-manager/app/controller/get_graph_structure.py b/state-manager/app/controller/get_graph_structure.py index e9504c4b..7a22113b 100644 --- a/state-manager/app/controller/get_graph_structure.py +++ b/state-manager/app/controller/get_graph_structure.py @@ -5,6 +5,7 @@ from ..models.db.state import State from ..models.graph_structure_models import GraphStructureResponse, GraphNode, GraphEdge +from ..models.state_status_enum import StateStatusEnum from ..singletons.logs_manager import LogsManager @@ -96,11 +97,11 @@ async def get_graph_structure(namespace: str, run_id: str, request_id: str) -> G edges.append(edge) edge_id_counter += 1 - # Build execution summary - execution_summary: Dict[str, int] = {} + # Build execution summary - initialize all possible states with zero counts + execution_summary: Dict[str, int] = {status.value: 0 for status in StateStatusEnum} for state in states: status = state.status.value - execution_summary[status] = execution_summary.get(status, 0) + 1 + execution_summary[status] += 1 logger.info(f"Built graph structure with {len(nodes)} nodes and {len(edges)} edges for run ID: {run_id}", x_exosphere_request_id=request_id) diff --git a/state-manager/tests/unit/controller/pending_test_get_graph_structure.py b/state-manager/tests/unit/controller/pending_test_get_graph_structure.py index 92a5c362..55d2ee54 100644 --- a/state-manager/tests/unit/controller/pending_test_get_graph_structure.py +++ b/state-manager/tests/unit/controller/pending_test_get_graph_structure.py @@ -138,7 +138,9 @@ async def test_get_graph_structure_no_states(): assert result.edge_count == 0 assert len(result.nodes) == 0 assert len(result.edges) == 0 - assert result.execution_summary == {} + # All states should be initialized to 0 + expected_summary = {status.value: 0 for status in StateStatusEnum} + assert result.execution_summary == expected_summary assert result.root_states == [] diff --git a/state-manager/tests/unit/controller/test_get_graph_structure.py b/state-manager/tests/unit/controller/test_get_graph_structure.py index c82071f6..db89c2ca 100644 --- a/state-manager/tests/unit/controller/test_get_graph_structure.py +++ b/state-manager/tests/unit/controller/test_get_graph_structure.py @@ -100,7 +100,9 @@ async def test_get_graph_structure_no_states(self): assert len(result.nodes) == 0 assert len(result.edges) == 0 assert len(result.root_states) == 0 - assert result.execution_summary == {} + # All states should be initialized to 0 + expected_summary = {status.value: 0 for status in StateStatusEnum} + assert result.execution_summary == expected_summary @pytest.mark.asyncio async def test_get_graph_structure_with_errors(self): From b771071025ad086fa368cfeb5f4668bec4ced129 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Tue, 16 Sep 2025 19:15:44 +0530 Subject: [PATCH 2/8] fix: update execution_summary initialization in get_graph_structure.py - Modified the initialization of execution_summary to use a dictionary comprehension that sets the status values from StateStatusEnum to 0. This change ensures that the execution summary accurately reflects the initial state of all statuses. This update improves the clarity and correctness of the graph structure's execution summary. --- state-manager/app/controller/get_graph_structure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/state-manager/app/controller/get_graph_structure.py b/state-manager/app/controller/get_graph_structure.py index 7a22113b..140bc420 100644 --- a/state-manager/app/controller/get_graph_structure.py +++ b/state-manager/app/controller/get_graph_structure.py @@ -41,7 +41,7 @@ async def get_graph_structure(namespace: str, run_id: str, request_id: str) -> G edges=[], node_count=0, edge_count=0, - execution_summary={} + execution_summary={status.value: 0 for status in StateStatusEnum} ) # Get graph name from first state (all states in a run should have same graph name) From 74cd2d45bfa5fbbe09774c90c106fe3a99459438 Mon Sep 17 00:00:00 2001 From: Nikita Agarwal Date: Tue, 16 Sep 2025 19:16:50 +0530 Subject: [PATCH 3/8] Update dashboard/src/components/GraphTemplateDetail.tsx Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- dashboard/src/components/GraphTemplateDetail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/components/GraphTemplateDetail.tsx b/dashboard/src/components/GraphTemplateDetail.tsx index 94cbab2b..9d3013c8 100644 --- a/dashboard/src/components/GraphTemplateDetail.tsx +++ b/dashboard/src/components/GraphTemplateDetail.tsx @@ -48,7 +48,7 @@ const CustomNode: React.FC<{ data: NodeTemplate & { index: number } }> = ({ data type="target" position={Position.Left} id="target" - style={{ background: 'hsl(var(--primary))', width: '12px', height: '12px' }} + style={{ background: 'var(--primary)', width: '12px', height: '12px' }} /> {/* Source Handle (Right side) - only show if node has next_nodes */} From fa425e4cb970f42f6f6a376aac9b445423a506e8 Mon Sep 17 00:00:00 2001 From: Nikita Agarwal Date: Tue, 16 Sep 2025 19:19:15 +0530 Subject: [PATCH 4/8] Update dashboard/src/components/GraphTemplateDetail.tsx Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- dashboard/src/components/GraphTemplateDetail.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dashboard/src/components/GraphTemplateDetail.tsx b/dashboard/src/components/GraphTemplateDetail.tsx index 9d3013c8..1cb9356a 100644 --- a/dashboard/src/components/GraphTemplateDetail.tsx +++ b/dashboard/src/components/GraphTemplateDetail.tsx @@ -212,7 +212,7 @@ const GraphVisualizer: React.FC<{ nodes: NodeTemplate[] }> = ({ nodes }) => { type: 'default', animated: false, style: { - stroke: '#87ceeb', + stroke: 'var(--chart-1)', strokeWidth: 2, strokeDasharray: 'none', }, @@ -220,7 +220,7 @@ const GraphVisualizer: React.FC<{ nodes: NodeTemplate[] }> = ({ nodes }) => { type: MarkerType.ArrowClosed, width: 10, height: 10, - color: '#87ceeb', + color: 'var(--chart-1)', }, }); } else { From 71df4d32a7d910fb2c5391bd7a9d5bd285d6f8f9 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Tue, 16 Sep 2025 19:23:25 +0530 Subject: [PATCH 5/8] feat: enhance theme management with automatic theme detection and toggle functionality - Added a script in the layout to automatically apply the user's preferred theme from localStorage or system settings. - Refactored ThemeToggle component to use setTheme for toggling between light and dark modes. - Updated ThemeContext to initialize theme based on the applied class from the document, ensuring consistent theme application. - Prevented rendering of children in ThemeProvider until the theme is mounted to avoid hydration issues. These changes improve user experience by providing a seamless theme management system that adapts to user preferences. --- dashboard/src/app/layout.tsx | 22 +++++++++++++++++ dashboard/src/components/ThemeToggle.tsx | 6 ++++- dashboard/src/contexts/ThemeContext.tsx | 30 +++++++++++------------- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/dashboard/src/app/layout.tsx b/dashboard/src/app/layout.tsx index f790cec4..bbdfa6b8 100644 --- a/dashboard/src/app/layout.tsx +++ b/dashboard/src/app/layout.tsx @@ -25,6 +25,28 @@ export default function RootLayout({ }>) { return ( + +