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 (
+
+ );
+}
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 (
+
+ );
+}
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);
+ }
+}