From 73ade1505226a2e4eb7e1f35ba61d94b63ced0ed Mon Sep 17 00:00:00 2001 From: tverma28 Date: Wed, 18 Feb 2026 02:50:10 -0800 Subject: [PATCH] Save and Load Sessions --- .../[session_id]/frontend-state/route.ts | 36 +++ frontend/app/api/sessions/route.ts | 23 ++ frontend/app/home/page.tsx | 67 +----- .../components/ui-header/settings-bar.tsx | 208 ++++++++++++++++- .../ui-react-flow/react-flow-view.tsx | 51 +++- .../components/ui-sessions/session-modal.tsx | 218 ++++++++++++++++++ frontend/context/GlobalContext.tsx | 17 +- frontend/lib/backend-proxy.ts | 69 ++++++ frontend/lib/frontend-state.ts | 22 ++ frontend/lib/session-api.ts | 87 +++++++ 10 files changed, 725 insertions(+), 73 deletions(-) create mode 100644 frontend/app/api/sessions/[session_id]/frontend-state/route.ts create mode 100644 frontend/app/api/sessions/route.ts create mode 100644 frontend/components/ui-sessions/session-modal.tsx create mode 100644 frontend/lib/backend-proxy.ts create mode 100644 frontend/lib/frontend-state.ts create mode 100644 frontend/lib/session-api.ts diff --git a/frontend/app/api/sessions/[session_id]/frontend-state/route.ts b/frontend/app/api/sessions/[session_id]/frontend-state/route.ts new file mode 100644 index 0000000..d394557 --- /dev/null +++ b/frontend/app/api/sessions/[session_id]/frontend-state/route.ts @@ -0,0 +1,36 @@ +import { forwardToBackend, passthroughJsonResponse } from '@/lib/backend-proxy'; + +export async function GET( + _req: Request, + context: { + params: { session_id: string } | Promise<{ session_id: string }>; + } +) { + const params = await Promise.resolve(context.params); + + const response = await forwardToBackend({ + method: 'GET', + path: `/api/sessions/${params.session_id}/frontend-state`, + }); + + return passthroughJsonResponse(response); +} + +export async function POST( + req: Request, + context: { + params: { session_id: string } | Promise<{ session_id: string }>; + } +) { + const params = await Promise.resolve(context.params); + const body = await req.text(); + + const response = await forwardToBackend({ + method: 'POST', + path: `/api/sessions/${params.session_id}/frontend-state`, + body, + }); + + return passthroughJsonResponse(response); +} + diff --git a/frontend/app/api/sessions/route.ts b/frontend/app/api/sessions/route.ts new file mode 100644 index 0000000..2c8d063 --- /dev/null +++ b/frontend/app/api/sessions/route.ts @@ -0,0 +1,23 @@ +import { forwardToBackend, passthroughJsonResponse } from '@/lib/backend-proxy'; + +export async function GET() { + const response = await forwardToBackend({ + method: 'GET', + path: '/api/sessions', + }); + + return passthroughJsonResponse(response); +} + +export async function POST(req: Request) { + const body = await req.text(); + + const response = await forwardToBackend({ + method: 'POST', + path: '/api/sessions', + body, + }); + + return passthroughJsonResponse(response); +} + diff --git a/frontend/app/home/page.tsx b/frontend/app/home/page.tsx index b1da36f..cadf0d9 100644 --- a/frontend/app/home/page.tsx +++ b/frontend/app/home/page.tsx @@ -3,68 +3,25 @@ import ReactFlowView from '@/components/ui-react-flow/react-flow-view'; import { GlobalProvider } from '@/context/GlobalContext'; import AppHeader from '@/components/ui-header/app-header'; import SettingsBar from '@/components/ui-header/settings-bar'; - -import { ErrorDialog, PermissionDialog } from '@/components/ui/custom-dialog'; -import { useState } from 'react'; +import { NotificationsProvider } from '@/components/notifications'; export default function Home() { - - // REMOVE LATER: consts for testing pop-up notifications - // const [showErrorDialog, setShowErrorDialog] = useState(false); - // const [showPermissionDialog, setShowPermissionDialog] = useState(false); - return ( -
- {/* Top section for header and settings bar */} -
- - -
- - {/* Bottom section for workspace and sidebar */} -
- + +
+ {/* Top section for header and settings bar */} +
+ + +
- {/* REMOVE LATER: Test pop-up buttons for notifications (positioned top-right corner) -
- - + {/* Bottom section for workspace and sidebar */} +
+
- */}
- - {/* REMOVE LATER: Test Dialogs for pop-up notifications - - - { - console.log('Permission granted'); - setShowPermissionDialog(false); - }} - /> - */} - -
+
); } diff --git a/frontend/components/ui-header/settings-bar.tsx b/frontend/components/ui-header/settings-bar.tsx index 5b642bc..5849912 100644 --- a/frontend/components/ui-header/settings-bar.tsx +++ b/frontend/components/ui-header/settings-bar.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'; import { useGlobalContext } from '@/context/GlobalContext'; import { Timer } from '@/components/ui/timer'; import { useEffect, useRef, useState } from 'react'; +import SessionModal from '@/components/ui-sessions/session-modal'; import { Dialog, DialogContent, @@ -15,15 +16,39 @@ import { DialogTrigger, DialogClose, } from '@/components/ui/dialog'; +import { useNotifications } from '@/components/notifications'; +import { + createSession, + getSessions, + loadFrontendState, + saveFrontendState, + SessionSummary, +} from '@/lib/session-api'; +import { + FrontendWorkspaceState, + isFrontendWorkspaceState, +} from '@/lib/frontend-state'; export default function SettingsBar() { - const { dataStreaming, setDataStreaming } = useGlobalContext(); + const { + dataStreaming, + setDataStreaming, + activeSessionId, + setActiveSessionId, + } = useGlobalContext(); + const notifications = useNotifications(); const [leftTimerSeconds, setLeftTimerSeconds] = useState(0); const intervalRef = useRef(null); const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); - // useEffect(() => { - // console.log('dataStreaming:', dataStreaming); - // }); + const [isSessionModalOpen, setIsSessionModalOpen] = useState(false); + const [sessionModalMode, setSessionModalMode] = useState<'save' | 'load'>( + 'save' + ); + const [sessions, setSessions] = useState([]); + const [isFetchingSessions, setIsFetchingSessions] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(false); + // Timer effect - starts/stops based on dataStreaming state useEffect(() => { if (dataStreaming) { @@ -54,7 +79,7 @@ export default function SettingsBar() { if (leftTimerSeconds >= 300 && dataStreaming) { setDataStreaming(false); } - }, [leftTimerSeconds, dataStreaming]); + }, [leftTimerSeconds, dataStreaming, setDataStreaming]); const handleStartStop = () => { @@ -76,6 +101,139 @@ export default function SettingsBar() { setIsResetDialogOpen(false); }; + const requestFrontendState = async (): Promise => + new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + reject(new Error('Timed out while collecting frontend state.')); + }, 4000); + + const responseListener = (event: Event) => { + window.clearTimeout(timeout); + const customEvent = event as CustomEvent; + if (!isFrontendWorkspaceState(customEvent.detail)) { + reject(new Error('Frontend state payload is invalid.')); + return; + } + resolve(customEvent.detail); + }; + + window.addEventListener('frontend-state-response', responseListener, { + once: true, + }); + window.dispatchEvent(new Event('request-frontend-state')); + }); + + const getErrorMessage = (error: unknown) => + error instanceof Error ? error.message : 'Unexpected error'; + + const handleSaveToExistingSession = async (sessionId: number) => { + const state = await requestFrontendState(); + await saveFrontendState(sessionId, state); + }; + + const handleSaveClick = async () => { + if (isSaving || isLoading || isFetchingSessions) { + return; + } + + if (activeSessionId !== null) { + setIsSaving(true); + try { + await handleSaveToExistingSession(activeSessionId); + notifications.success({ title: 'Session saved successfully' }); + } catch (error) { + notifications.error({ + title: 'Save failed', + description: getErrorMessage(error), + }); + } finally { + setIsSaving(false); + } + return; + } + + setIsFetchingSessions(true); + try { + const fetchedSessions = await getSessions(); + setSessions(fetchedSessions); + setSessionModalMode('save'); + setIsSessionModalOpen(true); + } catch (error) { + notifications.error({ + title: 'Save failed', + description: getErrorMessage(error), + }); + } finally { + setIsFetchingSessions(false); + } + }; + + const handleLoadClick = async () => { + if (isSaving || isLoading || isFetchingSessions) { + return; + } + + setIsFetchingSessions(true); + try { + const fetchedSessions = await getSessions(); + setSessions(fetchedSessions); + setSessionModalMode('load'); + setIsSessionModalOpen(true); + } catch (error) { + notifications.error({ + title: 'Load failed', + description: getErrorMessage(error), + }); + } finally { + setIsFetchingSessions(false); + } + }; + + const handleCreateAndSaveSession = async (sessionName: string) => { + setIsSaving(true); + try { + const state = await requestFrontendState(); + const createdSession = await createSession(sessionName); + await saveFrontendState(createdSession.id, state); + setActiveSessionId(createdSession.id); + setIsSessionModalOpen(false); + notifications.success({ title: 'Session saved successfully' }); + } catch (error) { + notifications.error({ + title: 'Save failed', + description: getErrorMessage(error), + }); + } finally { + setIsSaving(false); + } + }; + + const handleLoadSession = async (sessionId: number) => { + setIsLoading(true); + try { + const loadedPayload = await loadFrontendState(sessionId); + if (!isFrontendWorkspaceState(loadedPayload)) { + throw new Error('Loaded session payload has an invalid format.'); + } + + window.dispatchEvent( + new CustomEvent('restore-frontend-state', { + detail: loadedPayload, + }) + ); + setActiveSessionId(sessionId); + setIsSessionModalOpen(false); + notifications.success({ title: 'Session loaded successfully' }); + } catch (error) { + notifications.error({ + title: 'Load failed', + description: getErrorMessage(error), + }); + } finally { + setIsLoading(false); + } + }; + // Format seconds to MM:SS const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); @@ -85,14 +243,11 @@ export default function SettingsBar() { return (
- {/* System Control Panel, Filters, Settings */} + {/* System Control Panel, Settings */} System Control Panel - - Filters - Settings @@ -112,7 +267,7 @@ export default function SettingsBar() {
- {/* start/stop, load, save */} + {/* start/stop, reset, save, load */}
+ +
+ +
); } diff --git a/frontend/components/ui-react-flow/react-flow-view.tsx b/frontend/components/ui-react-flow/react-flow-view.tsx index 62ec984..b87415a 100644 --- a/frontend/components/ui-react-flow/react-flow-view.tsx +++ b/frontend/components/ui-react-flow/react-flow-view.tsx @@ -29,10 +29,13 @@ import MachineLearningNode from '@/components/nodes/machine-learning-node/machin import SignalGraphNode from '@/components/nodes/signal-graph-node/signal-graph-node'; import Sidebar from '@/components/ui-sidebar/sidebar'; +import { + FrontendWorkspaceState, + isFrontendWorkspaceState, +} from '@/lib/frontend-state'; import { useEffect, useState } from 'react'; import { X, Ellipsis, RotateCw, RotateCcw } from 'lucide-react'; -import { headers } from 'next/headers'; const nodeTypes = { 'source-node': SourceNode, @@ -64,6 +67,52 @@ const ReactFlowInterface = () => { return () => window.removeEventListener('pipeline-reset', listener); }, [setNodes, setEdges]); + useEffect(() => { + const exportListener = () => { + const state: FrontendWorkspaceState = { + nodes, + edges, + }; + + window.dispatchEvent( + new CustomEvent('frontend-state-response', { + detail: state, + }) + ); + }; + + window.addEventListener('request-frontend-state', exportListener); + return () => + window.removeEventListener('request-frontend-state', exportListener); + }, [nodes, edges]); + + useEffect(() => { + const importListener = (event: Event) => { + const customEvent = event as CustomEvent; + if (!isFrontendWorkspaceState(customEvent.detail)) { + return; + } + + const importedState = customEvent.detail; + setNodes(importedState.nodes); + setEdges(importedState.edges); + + // Keep generated IDs unique after loading nodes with node_{n} IDs. + const maxNodeIndex = importedState.nodes.reduce((max, node) => { + const match = /^node_(\d+)$/.exec(node.id); + if (!match) { + return max; + } + return Math.max(max, Number(match[1])); + }, -1); + id = Math.max(id, maxNodeIndex + 1); + }; + + window.addEventListener('restore-frontend-state', importListener); + return () => + window.removeEventListener('restore-frontend-state', importListener); + }, [setNodes, setEdges]); + // Helper to notify components that edges have changed const dispatchEdgesChanged = () => { try { diff --git a/frontend/components/ui-sessions/session-modal.tsx b/frontend/components/ui-sessions/session-modal.tsx new file mode 100644 index 0000000..cbda500 --- /dev/null +++ b/frontend/components/ui-sessions/session-modal.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { SessionSummary } from '@/lib/session-api'; + +type SessionModalMode = 'save' | 'load'; + +type SessionModalProps = { + open: boolean; + mode: SessionModalMode; + sessions: SessionSummary[]; + isSubmitting?: boolean; + onOpenChange: (open: boolean) => void; + onSave: (sessionName: string) => Promise | void; + onLoad: (sessionId: number) => Promise | void; +}; + +export default function SessionModal({ + open, + mode, + sessions, + isSubmitting = false, + onOpenChange, + onSave, + onLoad, +}: SessionModalProps) { + const [sessionName, setSessionName] = useState(''); + const [selectedSessionId, setSelectedSessionId] = useState( + null + ); + const [isSessionPickerOpen, setIsSessionPickerOpen] = useState(false); + const [validationError, setValidationError] = useState(null); + + useEffect(() => { + if (!open) { + setSessionName(''); + setSelectedSessionId(null); + setValidationError(null); + setIsSessionPickerOpen(false); + } + }, [open, mode]); + + const normalizedExistingNames = useMemo( + () => new Set(sessions.map((session) => session.name.trim().toLowerCase())), + [sessions] + ); + + const selectedSession = useMemo( + () => + selectedSessionId === null + ? null + : sessions.find((session) => session.id === selectedSessionId) || null, + [selectedSessionId, sessions] + ); + + const handleSave = async () => { + const trimmed = sessionName.trim(); + if (!trimmed) { + setValidationError('Session name is required.'); + return; + } + + if (normalizedExistingNames.has(trimmed.toLowerCase())) { + setValidationError('Session name must be unique.'); + return; + } + + setValidationError(null); + await onSave(trimmed); + }; + + const handleLoad = async () => { + if (selectedSessionId === null) { + setValidationError('Select a session to load.'); + return; + } + + setValidationError(null); + await onLoad(selectedSessionId); + }; + + return ( + + + + + {mode === 'save' ? 'Save Session' : 'Load Session'} + + + {mode === 'save' + ? 'Enter a unique name for this session.' + : 'Select an existing session by name or ID.'} + + + + {mode === 'save' ? ( +
+ setSessionName(event.target.value)} + placeholder="Session name" + className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" + disabled={isSubmitting} + /> + {validationError ? ( +

{validationError}

+ ) : null} +
+ ) : ( +
+ + + + + + + + + No sessions found. + + {sessions.map((session) => ( + { + setSelectedSessionId(session.id); + setIsSessionPickerOpen(false); + }} + > + + {session.name} + + ID {session.id} + + + ))} + + + + + + {validationError ? ( +

{validationError}

+ ) : null} +
+ )} + + + + + +
+
+ ); +} + diff --git a/frontend/context/GlobalContext.tsx b/frontend/context/GlobalContext.tsx index 31af352..c22185c 100644 --- a/frontend/context/GlobalContext.tsx +++ b/frontend/context/GlobalContext.tsx @@ -3,25 +3,30 @@ import React, { useContext, useState, ReactNode, - useEffect, } from 'react'; type GlobalContextType = { dataStreaming: boolean; setDataStreaming: React.Dispatch>; + activeSessionId: number | null; + setActiveSessionId: React.Dispatch>; }; const GlobalContext = createContext(undefined); export const GlobalProvider = ({ children }: { children: ReactNode }) => { const [dataStreaming, setDataStreaming] = useState(false); - - useEffect(() => { - console.log('Data streaming:', dataStreaming); - }, [dataStreaming]); + const [activeSessionId, setActiveSessionId] = useState(null); return ( - + {children} ); diff --git a/frontend/lib/backend-proxy.ts b/frontend/lib/backend-proxy.ts new file mode 100644 index 0000000..96fe0da --- /dev/null +++ b/frontend/lib/backend-proxy.ts @@ -0,0 +1,69 @@ +const DEFAULT_API_BASES = [ + process.env.SESSION_API_BASE_URL, + process.env.API_BASE_URL, + process.env.VITE_API_URL, + 'http://api-server:9000', + 'http://127.0.0.1:9000', + 'http://localhost:9000', +].filter((v): v is string => Boolean(v)); + +type ForwardOptions = { + path: string; + method: 'GET' | 'POST'; + body?: string; +}; + +export async function forwardToBackend( + options: ForwardOptions +): Promise { + const headers: Record = {}; + if (options.method === 'POST') { + headers['Content-Type'] = 'application/json'; + } + + let lastError: unknown = null; + + for (const baseUrl of DEFAULT_API_BASES) { + const url = `${baseUrl.replace(/\/$/, '')}${options.path}`; + try { + return await fetch(url, { + method: options.method, + headers, + body: options.body, + cache: 'no-store', + }); + } catch (error) { + lastError = error; + } + } + + const fallbackMessage = + lastError instanceof Error ? lastError.message : 'Unknown error'; + + return new Response( + JSON.stringify({ + message: `Could not reach API backend: ${fallbackMessage}`, + }), + { + status: 503, + headers: { + 'Content-Type': 'application/json', + }, + } + ); +} + +export async function passthroughJsonResponse( + backendResponse: Response +): Promise { + const text = await backendResponse.text(); + return new Response(text, { + status: backendResponse.status, + headers: { + 'Content-Type': + backendResponse.headers.get('Content-Type') || + 'application/json', + }, + }); +} + diff --git a/frontend/lib/frontend-state.ts b/frontend/lib/frontend-state.ts new file mode 100644 index 0000000..bb8faa7 --- /dev/null +++ b/frontend/lib/frontend-state.ts @@ -0,0 +1,22 @@ +import { Edge, Node } from '@xyflow/react'; + +export type FrontendWorkspaceState = { + nodes: Node[]; + edges: Edge[]; +}; + +export function isFrontendWorkspaceState( + value: unknown +): value is FrontendWorkspaceState { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as { + nodes?: unknown; + edges?: unknown; + }; + + return Array.isArray(candidate.nodes) && Array.isArray(candidate.edges); +} + diff --git a/frontend/lib/session-api.ts b/frontend/lib/session-api.ts new file mode 100644 index 0000000..1c5a7e1 --- /dev/null +++ b/frontend/lib/session-api.ts @@ -0,0 +1,87 @@ +import { FrontendWorkspaceState } from '@/lib/frontend-state'; + +export type SessionSummary = { + id: number; + name: string; +}; + +async function parseErrorMessage(response: Response): Promise { + const fallback = `Request failed (${response.status})`; + + try { + const text = await response.text(); + if (!text) { + return fallback; + } + + try { + const parsed = JSON.parse(text) as unknown; + if (typeof parsed === 'string') { + return parsed; + } + if ( + parsed && + typeof parsed === 'object' && + 'message' in parsed && + typeof (parsed as { message?: unknown }).message === 'string' + ) { + return (parsed as { message: string }).message; + } + } catch (_) { + return text; + } + + return text; + } catch (_) { + return fallback; + } +} + +async function parseJsonResponse(response: Response): Promise { + if (!response.ok) { + throw new Error(await parseErrorMessage(response)); + } + return (await response.json()) as T; +} + +export async function getSessions(): Promise { + const response = await fetch('/api/sessions', { method: 'GET' }); + return parseJsonResponse(response); +} + +export async function createSession(name: string): Promise { + const response = await fetch('/api/sessions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + // Backend currently expects a JSON string body. + body: JSON.stringify(name), + }); + return parseJsonResponse(response); +} + +export async function saveFrontendState( + sessionId: number, + state: FrontendWorkspaceState +): Promise { + const response = await fetch(`/api/sessions/${sessionId}/frontend-state`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(state), + }); + + if (!response.ok) { + throw new Error(await parseErrorMessage(response)); + } +} + +export async function loadFrontendState(sessionId: number): Promise { + const response = await fetch(`/api/sessions/${sessionId}/frontend-state`, { + method: 'GET', + }); + return parseJsonResponse(response); +} +