From 5cf7713df6e36fc1af35c40e53833950dc7bcdf4 Mon Sep 17 00:00:00 2001 From: tverma28 Date: Sun, 22 Mar 2026 00:32:53 -0700 Subject: [PATCH 1/2] Export & Import task --- .../[session_id]/eeg_data/export/route.ts | 25 +++ .../[session_id]/eeg_data/import/route.ts | 55 ++++++ .../signal-graph-node/signal-graph-node.tsx | 31 +++- frontend/components/ui-header/app-header.tsx | 49 ++++- .../components/ui-header/settings-bar.tsx | 11 +- frontend/components/ui/export-dialog.tsx | 152 ++++++++++++++++ frontend/components/ui/import-dialog.tsx | 168 ++++++++++++++++++ frontend/lib/backend-proxy.ts | 5 +- frontend/lib/eeg-api.ts | 97 ++++++++++ 9 files changed, 575 insertions(+), 18 deletions(-) create mode 100644 frontend/app/api/sessions/[session_id]/eeg_data/export/route.ts create mode 100644 frontend/app/api/sessions/[session_id]/eeg_data/import/route.ts create mode 100644 frontend/components/ui/export-dialog.tsx create mode 100644 frontend/components/ui/import-dialog.tsx create mode 100644 frontend/lib/eeg-api.ts diff --git a/frontend/app/api/sessions/[session_id]/eeg_data/export/route.ts b/frontend/app/api/sessions/[session_id]/eeg_data/export/route.ts new file mode 100644 index 0000000..cadb10e --- /dev/null +++ b/frontend/app/api/sessions/[session_id]/eeg_data/export/route.ts @@ -0,0 +1,25 @@ +import { forwardToBackend } from '@/lib/backend-proxy'; +import { NextRequest } from 'next/server'; + +export async function POST( + req: NextRequest, + { params }: { params: { session_id: string } } +) { + const body = await req.text(); + + const response = await forwardToBackend({ + method: 'POST', + path: `/api/sessions/${params.session_id}/eeg_data/export`, + body, + contentType: 'application/json', + }); + + // Return the CSV as plain text + const text = await response.text(); + return new Response(text, { + status: response.status, + headers: { + 'Content-Type': response.headers.get('Content-Type') ?? 'text/csv', + }, + }); +} diff --git a/frontend/app/api/sessions/[session_id]/eeg_data/import/route.ts b/frontend/app/api/sessions/[session_id]/eeg_data/import/route.ts new file mode 100644 index 0000000..b499e94 --- /dev/null +++ b/frontend/app/api/sessions/[session_id]/eeg_data/import/route.ts @@ -0,0 +1,55 @@ +import { NextRequest } from 'next/server'; + +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)); + +export async function POST( + req: NextRequest, + { params }: { params: { session_id: string } } +) { + const csvBody = await req.text(); + const path = `/api/sessions/${params.session_id}/eeg_data/import`; + + let lastError: unknown = null; + for (const baseUrl of DEFAULT_API_BASES) { + const url = `${baseUrl.replace(/\/$/, '')}${path}`; + try { + const backendResp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'text/csv' }, + body: csvBody, + cache: 'no-store', + }); + const text = await backendResp.text(); + return new Response(text, { + status: backendResp.status, + headers: { + 'Content-Type': + backendResp.headers.get('Content-Type') ?? + 'application/json', + }, + }); + } 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' }, + } + ); +} diff --git a/frontend/components/nodes/signal-graph-node/signal-graph-node.tsx b/frontend/components/nodes/signal-graph-node/signal-graph-node.tsx index aa768b1..a7a7c2d 100644 --- a/frontend/components/nodes/signal-graph-node/signal-graph-node.tsx +++ b/frontend/components/nodes/signal-graph-node/signal-graph-node.tsx @@ -2,8 +2,8 @@ import { Card } from '@/components/ui/card'; import { Handle, Position, useReactFlow } from '@xyflow/react'; import { useGlobalContext } from '@/context/GlobalContext'; import useNodeData from '@/hooks/useNodeData'; -import { ArrowUpRight } from 'lucide-react'; -import React from 'react'; +import { ArrowUpRight, Download } from 'lucide-react'; +import React, { useState } from 'react'; import { Dialog, @@ -14,6 +14,7 @@ import { DialogTrigger, } from '@/components/ui/dialog'; import SignalGraphView from './signal-graph-full'; +import ExportDialog from '@/components/ui/export-dialog'; export default function SignalGraphNode({ id }: { id?: string }) { const { dataStreaming } = useGlobalContext(); @@ -22,6 +23,8 @@ export default function SignalGraphNode({ id }: { id?: string }) { const processedData = renderData; const reactFlowInstance = useReactFlow(); const [isConnected, setIsConnected] = React.useState(false); + const { activeSessionId } = useGlobalContext(); + const [isExportOpen, setIsExportOpen] = useState(false); // Determine if this Chart View node has an upstream path from a Source const checkConnectionStatus = React.useCallback(() => { @@ -98,7 +101,7 @@ export default function SignalGraphNode({ id }: { id?: string }) { Chart View {isConnected && ( -
+
+
)}
@@ -122,9 +134,9 @@ export default function SignalGraphNode({ id }: { id?: string }) { - @@ -135,6 +147,13 @@ export default function SignalGraphNode({ id }: { id?: string }) { + + {/* Export dialog — outside the ReactFlow Dialog to avoid nesting */} + ); } \ No newline at end of file diff --git a/frontend/components/ui-header/app-header.tsx b/frontend/components/ui-header/app-header.tsx index 70e64e7..5f14ac1 100644 --- a/frontend/components/ui-header/app-header.tsx +++ b/frontend/components/ui-header/app-header.tsx @@ -11,9 +11,15 @@ import { } from '@/components/ui/dropdown-menu'; import { useState } from 'react'; import Image from 'next/image'; +import { useGlobalContext } from '@/context/GlobalContext'; +import ExportDialog from '@/components/ui/export-dialog'; +import ImportDialog from '@/components/ui/import-dialog'; export default function AppHeader() { const [isOpen, setIsOpen] = useState(false); + const [isExportOpen, setIsExportOpen] = useState(false); + const [isImportOpen, setIsImportOpen] = useState(false); + const { activeSessionId } = useGlobalContext(); return (
@@ -28,21 +34,41 @@ export default function AppHeader() { /> - {/* update, issues */} + {/* update, issues, import, export, help */}
+ + {/* Export Data */} + + + {/* Import Data */} + {/* help */} @@ -53,9 +79,8 @@ export default function AppHeader() { Help
@@ -69,6 +94,18 @@ export default function AppHeader() { + + {/* Dialogs */} + +
); } diff --git a/frontend/components/ui-header/settings-bar.tsx b/frontend/components/ui-header/settings-bar.tsx index 0d79282..51cfefc 100644 --- a/frontend/components/ui-header/settings-bar.tsx +++ b/frontend/components/ui-header/settings-bar.tsx @@ -320,7 +320,7 @@ export default function SettingsBar() { - {/* start/stop, reset, save, load */} + {/* start/stop, reset, import, export, save, load */}
diff --git a/frontend/components/ui/export-dialog.tsx b/frontend/components/ui/export-dialog.tsx new file mode 100644 index 0000000..ede5335 --- /dev/null +++ b/frontend/components/ui/export-dialog.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { useNotifications } from '@/components/notifications'; +import { exportEEGData, downloadCSV } from '@/lib/eeg-api'; + +type ExportDialogProps = { + open: boolean; + sessionId: number | null; + onOpenChange: (open: boolean) => void; +}; + +const ExportIcon = () => ( + + + + +); + +export default function ExportDialog({ + open, + sessionId, + onOpenChange, +}: ExportDialogProps) { + const notifications = useNotifications(); + const [durationValue, setDurationValue] = useState('30'); + const [durationUnit, setDurationUnit] = useState('Minutes'); + const [isExporting, setIsExporting] = useState(false); + + const handleClose = () => { + if (!isExporting) { + onOpenChange(false); + } + }; + + const handleExport = async () => { + if (sessionId === null) { + notifications.error({ + title: 'No active session', + description: 'Please start or load a session before exporting.', + }); + return; + } + + const value = parseFloat(durationValue); + if (isNaN(value) || value <= 0) { + notifications.error({ + title: 'Invalid duration', + description: 'Please enter a valid number greater than 0.', + }); + return; + } + + setIsExporting(true); + try { + const options: Record = {}; + + let multiplier = 1000; // default to seconds + if (durationUnit === 'Minutes') multiplier = 60 * 1000; + if (durationUnit === 'Hours') multiplier = 60 * 60 * 1000; + if (durationUnit === 'Days') multiplier = 24 * 60 * 60 * 1000; + + const durationMs = value * multiplier; + const now = new Date(); + + options.start_time = new Date(now.getTime() - durationMs).toISOString(); + options.end_time = now.toISOString(); + + const csvContent = await exportEEGData(sessionId, options); + downloadCSV(csvContent, sessionId); + notifications.success({ title: 'EEG data exported successfully' }); + onOpenChange(false); + } catch (error) { + notifications.error({ + title: 'Export failed', + description: + error instanceof Error ? error.message : 'Unexpected error', + }); + } finally { + setIsExporting(false); + } + }; + + return ( + + + + + + Export Data + + + +
+

+ Export data from the last: +

+ +
+ setDurationValue(e.target.value)} + disabled={isExporting} + className="flex h-10 w-24 rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ +
+ + + +
+
+
+ +
+ +

+ Data will be exported as CSV format. +

+ + +
+
+
+ ); +} diff --git a/frontend/components/ui/import-dialog.tsx b/frontend/components/ui/import-dialog.tsx new file mode 100644 index 0000000..224a082 --- /dev/null +++ b/frontend/components/ui/import-dialog.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useRef, useState, DragEvent } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useNotifications } from '@/components/notifications'; +import { importEEGData } from '@/lib/eeg-api'; +import { Folder } from 'lucide-react'; + +type ImportDialogProps = { + open: boolean; + sessionId: number | null; + onOpenChange: (open: boolean) => void; + /** Called on successful import so downstream components can react */ + onImportSuccess?: () => void; +}; + +const ImportIcon = () => ( + + + + +); + +export default function ImportDialog({ + open, + sessionId, + onOpenChange, + onImportSuccess, +}: ImportDialogProps) { + const notifications = useNotifications(); + const fileInputRef = useRef(null); + const [isImporting, setIsImporting] = useState(false); + const [isDragging, setIsDragging] = useState(false); + + const handleClose = () => { + if (!isImporting) { + if (fileInputRef.current) fileInputRef.current.value = ''; + onOpenChange(false); + } + }; + + const processFile = async (file: File) => { + if (sessionId === null) { + notifications.error({ + title: 'No active session', + description: 'Please start or load a session before importing.', + }); + return; + } + + if (file.type !== 'text/csv' && !file.name.endsWith('.csv')) { + notifications.error({ + title: 'Invalid file type', + description: 'Please select a CSV file.', + }); + return; + } + + setIsImporting(true); + try { + const csvText = await file.text(); + await importEEGData(sessionId, csvText); + notifications.success({ + title: 'EEG data imported successfully', + description: `${file.name} has been loaded into session ${sessionId}.`, + }); + onImportSuccess?.(); + onOpenChange(false); + } catch (error) { + notifications.error({ + title: 'Import failed', + description: + error instanceof Error ? error.message : 'Unexpected error', + }); + } finally { + setIsImporting(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] ?? null; + if (file) { + processFile(file); + } + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const file = e.dataTransfer.files?.[0]; + if (file) { + processFile(file); + } + }; + + return ( + + + + + + Import Data + + + Only CSV files are accepted. + + + +
+ {/* Drag and drop zone */} +
+ +

+ {isImporting ? 'Importing...' : 'Drag CSV file here'} +

+ {!isImporting && ( +

+ Or{' '} + +

+ )} + +
+
+
+
+ ); +} diff --git a/frontend/lib/backend-proxy.ts b/frontend/lib/backend-proxy.ts index 96fe0da..a61e35a 100644 --- a/frontend/lib/backend-proxy.ts +++ b/frontend/lib/backend-proxy.ts @@ -11,13 +11,16 @@ type ForwardOptions = { path: string; method: 'GET' | 'POST'; body?: string; + contentType?: string; }; export async function forwardToBackend( options: ForwardOptions ): Promise { const headers: Record = {}; - if (options.method === 'POST') { + if (options.contentType) { + headers['Content-Type'] = options.contentType; + } else if (options.method === 'POST') { headers['Content-Type'] = 'application/json'; } diff --git a/frontend/lib/eeg-api.ts b/frontend/lib/eeg-api.ts new file mode 100644 index 0000000..cdd844c --- /dev/null +++ b/frontend/lib/eeg-api.ts @@ -0,0 +1,97 @@ +export type ExportOptions = { + format?: 'csv'; + includeHeader?: boolean; + start_time?: string; // RFC3339 + end_time?: string; // RFC3339 +}; + +export type ExportRequest = { + filename: string; + options: ExportOptions; +}; + +/** + * Request an EEG CSV export from the backend for the given session. + * Returns the raw CSV string. + */ +export async function exportEEGData( + sessionId: number, + options: ExportOptions = {} +): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `session_${sessionId}_${timestamp}.csv`; + + const body: ExportRequest = { + filename, + options: { + format: 'csv', + includeHeader: true, + ...options, + }, + }; + + const response = await fetch( + `/api/sessions/${sessionId}/eeg_data/export`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ); + + if (!response.ok) { + let message = `Export failed (${response.status})`; + try { + const text = await response.text(); + if (text) message = text; + } catch (_) { } + throw new Error(message); + } + + return response.text(); +} + +/** + * Download a CSV string as a file in the browser. + */ +export function downloadCSV(csvContent: string, sessionId: number): void { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `session_${sessionId}_${timestamp}.csv`; + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +/** + * Import EEG data from a raw CSV string into the given session. + */ +export async function importEEGData( + sessionId: number, + csvText: string +): Promise { + const response = await fetch( + `/api/sessions/${sessionId}/eeg_data/import`, + { + method: 'POST', + headers: { 'Content-Type': 'text/csv' }, + body: csvText, + } + ); + + if (!response.ok) { + let message = `Import failed (${response.status})`; + try { + const text = await response.text(); + if (text) message = text; + } catch (_) { } + throw new Error(message); + } +} From 96ba13360bcb22b7831d49c9899ba0b845fc71b0 Mon Sep 17 00:00:00 2001 From: Maddie Date: Mon, 23 Mar 2026 21:37:10 -0700 Subject: [PATCH 2/2] ui changes, add popover for import assumptions, add export all option --- frontend/components/ui-header/app-header.tsx | 28 +--- frontend/components/ui/export-dialog.tsx | 143 ++++++++++++------- frontend/components/ui/import-dialog.tsx | 28 ++-- 3 files changed, 114 insertions(+), 85 deletions(-) diff --git a/frontend/components/ui-header/app-header.tsx b/frontend/components/ui-header/app-header.tsx index 5f14ac1..9b31ead 100644 --- a/frontend/components/ui-header/app-header.tsx +++ b/frontend/components/ui-header/app-header.tsx @@ -1,7 +1,7 @@ 'use client'; import { Button } from '@/components/ui/button'; import { ChevronUpIcon } from '@radix-ui/react-icons'; -import { MoveUpRight } from 'lucide-react'; +import { ArrowUpRight } from 'lucide-react'; import { DropdownMenu, @@ -22,7 +22,7 @@ export default function AppHeader() { const { activeSessionId } = useGlobalContext(); return ( -
+
{/* logo */}
- {/* update, issues, import, export, help */} + {/* import, export, help */}
- - {/* Export Data */} {/* Import Data */} {/* help */} diff --git a/frontend/components/ui/export-dialog.tsx b/frontend/components/ui/export-dialog.tsx index ede5335..2213b8d 100644 --- a/frontend/components/ui/export-dialog.tsx +++ b/frontend/components/ui/export-dialog.tsx @@ -10,6 +10,7 @@ import { import { Button } from '@/components/ui/button'; import { useNotifications } from '@/components/notifications'; import { exportEEGData, downloadCSV } from '@/lib/eeg-api'; +import { ExitIcon } from '@radix-ui/react-icons'; type ExportDialogProps = { open: boolean; @@ -17,19 +18,13 @@ type ExportDialogProps = { onOpenChange: (open: boolean) => void; }; -const ExportIcon = () => ( - - - - -); - export default function ExportDialog({ open, sessionId, onOpenChange, }: ExportDialogProps) { const notifications = useNotifications(); + const [exportMode, setExportMode] = useState<'range' | 'all'>('range'); const [durationValue, setDurationValue] = useState('30'); const [durationUnit, setDurationUnit] = useState('Minutes'); const [isExporting, setIsExporting] = useState(false); @@ -49,29 +44,30 @@ export default function ExportDialog({ return; } - const value = parseFloat(durationValue); - if (isNaN(value) || value <= 0) { - notifications.error({ - title: 'Invalid duration', - description: 'Please enter a valid number greater than 0.', - }); - return; - } + const options: Record = {}; - setIsExporting(true); - try { - const options: Record = {}; + if (exportMode === 'range') { + const value = parseFloat(durationValue); + if (isNaN(value) || value <= 0) { + notifications.error({ + title: 'Invalid duration', + description: 'Please enter a valid number greater than 0.', + }); + return; + } - let multiplier = 1000; // default to seconds + let multiplier = 1000; if (durationUnit === 'Minutes') multiplier = 60 * 1000; if (durationUnit === 'Hours') multiplier = 60 * 60 * 1000; if (durationUnit === 'Days') multiplier = 24 * 60 * 60 * 1000; - const durationMs = value * multiplier; const now = new Date(); - - options.start_time = new Date(now.getTime() - durationMs).toISOString(); + options.start_time = new Date(now.getTime() - value * multiplier).toISOString(); options.end_time = now.toISOString(); + } + + setIsExporting(true); + try { const csvContent = await exportEEGData(sessionId, options); downloadCSV(csvContent, sessionId); @@ -92,47 +88,84 @@ export default function ExportDialog({ - - + + Export Data
-

- Export data from the last: -

- -
- setDurationValue(e.target.value)} + {/* Mode toggle */} +
+ + +
+ +
+ {exportMode === 'range' && ( + <> +

+ Export data from the last: +

+
+ setDurationValue(e.target.value)} + disabled={isExporting} + className="flex h-10 w-24 rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ +
+ + + +
+
+
+ + )} + + {exportMode === 'all' && ( +

+ Exports all recorded data for this session, from the earliest timestamp to now. +

+ )}
-
+
+ + {sessionId === null && ( +

+ No active session - please start or load a session before exporting. +

+ )}

Data will be exported as CSV format. diff --git a/frontend/components/ui/import-dialog.tsx b/frontend/components/ui/import-dialog.tsx index 224a082..3a24bbb 100644 --- a/frontend/components/ui/import-dialog.tsx +++ b/frontend/components/ui/import-dialog.tsx @@ -11,6 +11,8 @@ import { import { useNotifications } from '@/components/notifications'; import { importEEGData } from '@/lib/eeg-api'; import { Folder } from 'lucide-react'; +import { EnterIcon, InfoCircledIcon } from '@radix-ui/react-icons'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; type ImportDialogProps = { open: boolean; @@ -20,13 +22,6 @@ type ImportDialogProps = { onImportSuccess?: () => void; }; -const ImportIcon = () => ( - - - - -); - export default function ImportDialog({ open, sessionId, @@ -119,11 +114,26 @@ export default function ImportDialog({ - + Import Data - + Only CSV files are accepted. + + + + + +

Expected CSV format

+

The file must have a header row followed by data rows in this shape:

+ + Time,Channel1,Channel2,Channel3,Channel4 + +

The Time column must be in RFC 3339 format (e.g. 2024-01-15T13:45:00Z).

+ +