feat: Implement CSV File Browser component, add API endpoints for CSV file management, and update system state for dataset activation
This commit is contained in:
parent
4481eb33a7
commit
60db337284
|
@ -7561,8 +7561,49 @@
|
|||
"activated_datasets": 2,
|
||||
"total_datasets": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T20:06:52.191312",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T20:06:52.271192",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: DAR",
|
||||
"details": {
|
||||
"dataset_id": "DAR",
|
||||
"variables_count": 2,
|
||||
"streaming_count": 2,
|
||||
"prefix": "gateway_phoenix"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T20:06:52.281191",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: Fast",
|
||||
"details": {
|
||||
"dataset_id": "Fast",
|
||||
"variables_count": 2,
|
||||
"streaming_count": 1,
|
||||
"prefix": "fast"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T20:06:52.289232",
|
||||
"level": "info",
|
||||
"event_type": "csv_recording_started",
|
||||
"message": "CSV recording started: 2 datasets activated",
|
||||
"details": {
|
||||
"activated_datasets": 2,
|
||||
"total_datasets": 3
|
||||
}
|
||||
}
|
||||
],
|
||||
"last_updated": "2025-08-15T19:53:52.599789",
|
||||
"total_entries": 616
|
||||
"last_updated": "2025-08-15T20:06:52.289232",
|
||||
"total_entries": 620
|
||||
}
|
|
@ -0,0 +1,737 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Input,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Flex,
|
||||
Spacer,
|
||||
Badge,
|
||||
Spinner,
|
||||
useToast,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
Checkbox,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Collapse,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react'
|
||||
import {
|
||||
FaPlay,
|
||||
FaFileExcel,
|
||||
FaFolder,
|
||||
FaFile,
|
||||
FaSearch,
|
||||
FaFilter,
|
||||
FaCalendar,
|
||||
FaDatabase,
|
||||
FaSync,
|
||||
FaCog
|
||||
} from 'react-icons/fa'
|
||||
import * as api from '../services/api'
|
||||
|
||||
// Filter functions
|
||||
const filterFiles = (files, query, selectedDatasets, selectedDates) => {
|
||||
if (!files) return []
|
||||
|
||||
return files.filter(file => {
|
||||
// Text search in filename
|
||||
const matchesQuery = !query ||
|
||||
file.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
file.dataset.toLowerCase().includes(query.toLowerCase())
|
||||
|
||||
// Dataset filter
|
||||
const matchesDataset = selectedDatasets.length === 0 ||
|
||||
selectedDatasets.includes(file.dataset)
|
||||
|
||||
// Date filter
|
||||
const matchesDate = selectedDates.length === 0 ||
|
||||
selectedDates.includes(file.date)
|
||||
|
||||
return matchesQuery && matchesDataset && matchesDate
|
||||
})
|
||||
}
|
||||
|
||||
// Rebuild tree structure from filtered files
|
||||
const rebuildTreeFromFilteredFiles = (filteredFiles) => {
|
||||
if (!filteredFiles || filteredFiles.length === 0) return []
|
||||
|
||||
const treeMap = new Map()
|
||||
|
||||
filteredFiles.forEach(file => {
|
||||
const dateKey = file.date
|
||||
const datasetKey = `${file.date}_${file.dataset}`
|
||||
|
||||
// Create date node if it doesn't exist
|
||||
if (!treeMap.has(dateKey)) {
|
||||
treeMap.set(dateKey, {
|
||||
id: `date_${dateKey}`,
|
||||
name: `📅 ${dateKey}`,
|
||||
value: dateKey,
|
||||
type: "date",
|
||||
children: new Map()
|
||||
})
|
||||
}
|
||||
|
||||
const dateNode = treeMap.get(dateKey)
|
||||
|
||||
// Create dataset node if it doesn't exist under this date
|
||||
if (!dateNode.children.has(datasetKey)) {
|
||||
dateNode.children.set(datasetKey, {
|
||||
id: `dataset_${dateKey}_${file.dataset}`,
|
||||
name: `📊 ${file.dataset}`,
|
||||
value: file.dataset,
|
||||
type: "dataset",
|
||||
children: []
|
||||
})
|
||||
}
|
||||
|
||||
const datasetNode = dateNode.children.get(datasetKey)
|
||||
datasetNode.children.push({
|
||||
id: `file_${file.date}_${file.value}`,
|
||||
name: `📊 ${file.value}`,
|
||||
value: file.value,
|
||||
type: "file",
|
||||
path: file.path,
|
||||
date: file.date,
|
||||
dataset: file.dataset,
|
||||
size: file.size,
|
||||
size_human: file.size_human,
|
||||
modified: file.modified,
|
||||
modified_human: file.modified_human,
|
||||
columns: file.columns,
|
||||
column_count: file.column_count,
|
||||
row_count: file.row_count,
|
||||
preview: file.preview
|
||||
})
|
||||
})
|
||||
|
||||
// Convert Maps to Arrays and sort
|
||||
const tree = Array.from(treeMap.values()).map(dateNode => ({
|
||||
...dateNode,
|
||||
children: Array.from(dateNode.children.values()).sort((a, b) => a.value.localeCompare(b.value))
|
||||
})).sort((a, b) => b.value.localeCompare(a.value)) // Sort dates in descending order
|
||||
|
||||
return tree
|
||||
}
|
||||
|
||||
// PlotJuggler Configuration Modal
|
||||
function PlotJugglerConfigModal({ isOpen, onClose, onSave, currentPath }) {
|
||||
const [path, setPath] = useState(currentPath || '')
|
||||
const [testing, setTesting] = useState(false)
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
setPath(currentPath || '')
|
||||
}, [currentPath])
|
||||
|
||||
const testPath = async () => {
|
||||
if (!path) {
|
||||
toast({
|
||||
title: '❌ No path provided',
|
||||
description: 'Please enter a path to test',
|
||||
status: 'error',
|
||||
duration: 2000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setTesting(true)
|
||||
try {
|
||||
// Simple test by trying to set the path
|
||||
await api.setPlotJugglerPath(path)
|
||||
toast({
|
||||
title: '✅ Path is valid',
|
||||
description: 'PlotJuggler executable found',
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Invalid path',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(path)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>🔧 Configure PlotJuggler Path</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>PlotJuggler Executable Path</FormLabel>
|
||||
<Input
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="C:\Program Files\PlotJuggler\plotjuggler.exe"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
<Text fontSize="sm">
|
||||
The system will automatically search common locations for PlotJuggler.
|
||||
You only need to set this if PlotJuggler is installed in a custom location.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Common locations:
|
||||
<br />• C:\Program Files\PlotJuggler\plotjuggler.exe
|
||||
<br />• C:\Program Files (x86)\PlotJuggler\plotjuggler.exe
|
||||
</Text>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={testPath}
|
||||
isLoading={testing}
|
||||
loadingText="Testing..."
|
||||
>
|
||||
🧪 Test Path
|
||||
</Button>
|
||||
<Button colorScheme="blue" onClick={handleSave}>
|
||||
💾 Save
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// File Tree Component (Accordion-based since TreeView might not be available)
|
||||
function FileTree({ tree, selectedFiles, onFileToggle, expandedItems, onToggleExpand }) {
|
||||
const cardBg = useColorModeValue('white', 'gray.700')
|
||||
|
||||
if (!tree || tree.length === 0) {
|
||||
return (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="gray.500">No CSV files found</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
allowMultiple
|
||||
index={expandedItems}
|
||||
onChange={onToggleExpand}
|
||||
>
|
||||
{tree.map((dateNode, dateIndex) => (
|
||||
<AccordionItem key={dateNode.id} border="1px solid" borderColor="gray.200" mb={2}>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
<HStack>
|
||||
<FaCalendar />
|
||||
<Text fontWeight="bold">{dateNode.name}</Text>
|
||||
<Badge colorScheme="blue">
|
||||
{dateNode.children?.reduce((sum, dataset) => sum + (dataset.children?.length || 0), 0)} files
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>
|
||||
<Accordion allowMultiple>
|
||||
{dateNode.children?.map((datasetNode, datasetIndex) => (
|
||||
<AccordionItem key={datasetNode.id}>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
<HStack>
|
||||
<FaDatabase />
|
||||
<Text>{datasetNode.name}</Text>
|
||||
<Badge colorScheme="green">
|
||||
{datasetNode.children?.length || 0} files
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{datasetNode.children?.map((fileNode) => (
|
||||
<Card key={fileNode.id} size="sm" bg={cardBg}>
|
||||
<CardBody py={2}>
|
||||
<HStack spacing={3}>
|
||||
<Checkbox
|
||||
isChecked={selectedFiles.has(fileNode.path)}
|
||||
onChange={() => onFileToggle(fileNode)}
|
||||
/>
|
||||
<FaFile />
|
||||
<VStack spacing={1} align="start" flex={1}>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{fileNode.value}
|
||||
</Text>
|
||||
<HStack spacing={2}>
|
||||
<Badge size="sm" colorScheme="gray">
|
||||
{fileNode.size_human}
|
||||
</Badge>
|
||||
<Badge size="sm" colorScheme="blue">
|
||||
{fileNode.row_count} rows
|
||||
</Badge>
|
||||
<Badge size="sm" colorScheme="green">
|
||||
{fileNode.column_count} cols
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
📊 {fileNode.preview}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack spacing={1}>
|
||||
<Tooltip label="Open in PlotJuggler">
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon={<FaPlay />}
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
onClick={() => onFileToggle(fileNode, 'plotjuggler')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Open in Excel">
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon={<FaFileExcel />}
|
||||
colorScheme="green"
|
||||
variant="outline"
|
||||
onClick={() => onFileToggle(fileNode, 'excel')}
|
||||
/>
|
||||
</Tooltip>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
// Main CSV File Browser Component
|
||||
export default function CsvFileBrowser() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [files, setFiles] = useState([])
|
||||
const [originalTree, setOriginalTree] = useState([])
|
||||
const [filteredTree, setFilteredTree] = useState([])
|
||||
const [filteredFiles, setFilteredFiles] = useState([])
|
||||
const [selectedFiles, setSelectedFiles] = useState(new Set())
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedDatasets, setSelectedDatasets] = useState([])
|
||||
const [selectedDates, setSelectedDates] = useState([])
|
||||
const [expandedItems, setExpandedItems] = useState([])
|
||||
const [plotjugglerPath, setPlotjugglerPath] = useState(null)
|
||||
|
||||
const toast = useToast()
|
||||
const { isOpen: isConfigOpen, onOpen: onConfigOpen, onClose: onConfigClose } = useDisclosure()
|
||||
|
||||
// Load CSV files
|
||||
const loadFiles = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await api.getCsvFiles()
|
||||
setFiles(response.files || [])
|
||||
setOriginalTree(response.tree || [])
|
||||
setFilteredTree(response.tree || [])
|
||||
|
||||
toast({
|
||||
title: '✅ Files loaded',
|
||||
description: `Found ${response.total_files || 0} CSV files`,
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to load files',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load PlotJuggler path
|
||||
const loadPlotJugglerPath = async () => {
|
||||
try {
|
||||
const response = await api.getPlotJugglerPath()
|
||||
setPlotjugglerPath(response.path)
|
||||
} catch (error) {
|
||||
console.warn('PlotJuggler path not configured')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle file selection
|
||||
const handleFileToggle = async (fileNode, action = 'select') => {
|
||||
if (action === 'plotjuggler') {
|
||||
// Open single file in PlotJuggler
|
||||
try {
|
||||
await api.launchPlotJuggler([fileNode.path])
|
||||
toast({
|
||||
title: '🚀 PlotJuggler launched',
|
||||
description: `Opened ${fileNode.value}`,
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to launch PlotJuggler',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} else if (action === 'excel') {
|
||||
// Open in Excel
|
||||
try {
|
||||
await api.openCsvInExcel(fileNode.path)
|
||||
toast({
|
||||
title: '📊 File opened',
|
||||
description: `Opened ${fileNode.value} in Excel`,
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to open file',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Toggle selection
|
||||
const newSelected = new Set(selectedFiles)
|
||||
if (newSelected.has(fileNode.path)) {
|
||||
newSelected.delete(fileNode.path)
|
||||
} else {
|
||||
newSelected.add(fileNode.path)
|
||||
}
|
||||
setSelectedFiles(newSelected)
|
||||
}
|
||||
}
|
||||
|
||||
// Launch PlotJuggler with selected files
|
||||
const launchSelectedFiles = async () => {
|
||||
if (selectedFiles.size === 0) {
|
||||
toast({
|
||||
title: '⚠️ No files selected',
|
||||
description: 'Please select files to open in PlotJuggler',
|
||||
status: 'warning',
|
||||
duration: 2000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const filePaths = Array.from(selectedFiles)
|
||||
await api.launchPlotJuggler(filePaths)
|
||||
toast({
|
||||
title: '🚀 PlotJuggler launched',
|
||||
description: `Opened ${filePaths.length} file(s)`,
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to launch PlotJuggler',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Save PlotJuggler path
|
||||
const savePlotJugglerPath = async (path) => {
|
||||
try {
|
||||
await api.setPlotJugglerPath(path)
|
||||
setPlotjugglerPath(path)
|
||||
toast({
|
||||
title: '✅ PlotJuggler path saved',
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to save path',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get unique datasets and dates for filtering
|
||||
const availableDatasets = [...new Set(files.map(f => f.dataset))].sort()
|
||||
const availableDates = [...new Set(files.map(f => f.date))].sort().reverse()
|
||||
|
||||
// Filter files and rebuild tree based on search and filters
|
||||
useEffect(() => {
|
||||
const filtered = filterFiles(files, searchQuery, selectedDatasets, selectedDates)
|
||||
setFilteredFiles(filtered)
|
||||
|
||||
// Rebuild tree with filtered files
|
||||
const newFilteredTree = rebuildTreeFromFilteredFiles(filtered)
|
||||
setFilteredTree(newFilteredTree)
|
||||
}, [files, searchQuery, selectedDatasets, selectedDates])
|
||||
|
||||
// Load data on mount
|
||||
useEffect(() => {
|
||||
loadFiles()
|
||||
loadPlotJugglerPath()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Flex align="center" justify="center" py={8}>
|
||||
<Spinner size="xl" mr={4} />
|
||||
<Text fontSize="lg">Loading CSV files...</Text>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* Header */}
|
||||
<Flex align="center">
|
||||
<Heading size="lg">📁 CSV File Browser</Heading>
|
||||
<Spacer />
|
||||
<HStack spacing={2}>
|
||||
<Tooltip label="Configure PlotJuggler">
|
||||
<IconButton
|
||||
icon={<FaCog />}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onConfigOpen}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
leftIcon={<FaSync />}
|
||||
onClick={loadFiles}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* PlotJuggler Status */}
|
||||
{plotjugglerPath && (
|
||||
<Alert status="success">
|
||||
<AlertIcon />
|
||||
<Text fontSize="sm">
|
||||
PlotJuggler found: <code>{plotjugglerPath}</code>
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="md">🔍 Search & Filter</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* Search */}
|
||||
<HStack>
|
||||
<FaSearch />
|
||||
<Input
|
||||
placeholder="Search files by name or dataset..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* Filters */}
|
||||
<HStack spacing={4} align="start">
|
||||
<Box flex={1}>
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>📊 Datasets</Text>
|
||||
<Wrap>
|
||||
{availableDatasets.map(dataset => (
|
||||
<WrapItem key={dataset}>
|
||||
<Tag
|
||||
size="sm"
|
||||
variant={selectedDatasets.includes(dataset) ? 'solid' : 'outline'}
|
||||
colorScheme="blue"
|
||||
cursor="pointer"
|
||||
onClick={() => {
|
||||
if (selectedDatasets.includes(dataset)) {
|
||||
setSelectedDatasets(selectedDatasets.filter(d => d !== dataset))
|
||||
} else {
|
||||
setSelectedDatasets([...selectedDatasets, dataset])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TagLabel>{dataset}</TagLabel>
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
</Box>
|
||||
|
||||
<Box flex={1}>
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>📅 Dates</Text>
|
||||
<Wrap>
|
||||
{availableDates.map(date => (
|
||||
<WrapItem key={date}>
|
||||
<Tag
|
||||
size="sm"
|
||||
variant={selectedDates.includes(date) ? 'solid' : 'outline'}
|
||||
colorScheme="green"
|
||||
cursor="pointer"
|
||||
onClick={() => {
|
||||
if (selectedDates.includes(date)) {
|
||||
setSelectedDates(selectedDates.filter(d => d !== date))
|
||||
} else {
|
||||
setSelectedDates([...selectedDates, date])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TagLabel>{date}</TagLabel>
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
</Box>
|
||||
</HStack>
|
||||
|
||||
{/* Clear filters */}
|
||||
{(searchQuery || selectedDatasets.length > 0 || selectedDates.length > 0) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setSelectedDatasets([])
|
||||
setSelectedDates([])
|
||||
}}
|
||||
>
|
||||
🗑️ Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Selected Files Actions */}
|
||||
{selectedFiles.size > 0 && (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<HStack spacing={4}>
|
||||
<Text fontWeight="bold">
|
||||
{selectedFiles.size} file(s) selected
|
||||
</Text>
|
||||
<Button
|
||||
leftIcon={<FaPlay />}
|
||||
colorScheme="blue"
|
||||
onClick={launchSelectedFiles}
|
||||
>
|
||||
Open in PlotJuggler
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedFiles(new Set())}
|
||||
>
|
||||
Clear Selection
|
||||
</Button>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* File Tree */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Flex align="center">
|
||||
<Heading size="md">📂 File Tree</Heading>
|
||||
<Spacer />
|
||||
<Badge colorScheme="blue">
|
||||
{filteredFiles.length} of {files.length} files
|
||||
</Badge>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<FileTree
|
||||
tree={filteredTree}
|
||||
selectedFiles={selectedFiles}
|
||||
onFileToggle={handleFileToggle}
|
||||
expandedItems={expandedItems}
|
||||
onToggleExpand={setExpandedItems}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* PlotJuggler Config Modal */}
|
||||
<PlotJugglerConfigModal
|
||||
isOpen={isConfigOpen}
|
||||
onClose={onConfigClose}
|
||||
onSave={savePlotJugglerPath}
|
||||
currentPath={plotjugglerPath}
|
||||
/>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -51,6 +51,7 @@ import validator from '@rjsf/validator-ajv8'
|
|||
import PlotManager from '../components/PlotManagerSimple'
|
||||
import allWidgets from '../components/widgets/AllWidgets'
|
||||
import LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTemplate'
|
||||
import CsvFileBrowser from '../components/CsvFileBrowser'
|
||||
import { VariableProvider, useVariableContext } from '../contexts/VariableContext'
|
||||
import * as api from '../services/api'
|
||||
import { useCoordinatedPolling } from '../hooks/useCoordinatedConnection'
|
||||
|
@ -1137,7 +1138,8 @@ function DashboardContent() {
|
|||
<Tab>🔧 Configuration</Tab>
|
||||
<Tab>📊 Datasets</Tab>
|
||||
<Tab>📈 Plotting</Tab>
|
||||
<Tab>📋 Events</Tab>
|
||||
<Tab><EFBFBD> CSV Files</Tab>
|
||||
<Tab><EFBFBD>📋 Events</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
|
@ -1163,6 +1165,11 @@ function DashboardContent() {
|
|||
<PlotManager />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel p={0} pt={4}>
|
||||
{/* CSV File Browser Section */}
|
||||
<CsvFileBrowser />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel p={0} pt={4}>
|
||||
{/* Events Section */}
|
||||
<EventsDisplay
|
||||
|
|
|
@ -227,4 +227,53 @@ export async function controlPlotSession(sessionId, action) {
|
|||
return await controlPlot(sessionId, action)
|
||||
}
|
||||
|
||||
// CSV File Browser API
|
||||
export async function getCsvFiles() {
|
||||
const res = await fetch(`${BASE_URL}/api/csv/files`, { headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// PlotJuggler integration
|
||||
export async function launchPlotJuggler(filePaths) {
|
||||
const res = await fetch(`${BASE_URL}/api/plotjuggler/launch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ files: filePaths })
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function getPlotJugglerPath() {
|
||||
const res = await fetch(`${BASE_URL}/api/plotjuggler/path`, { headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function setPlotJugglerPath(path) {
|
||||
const res = await fetch(`${BASE_URL}/api/plotjuggler/path`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path })
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Open CSV in Excel
|
||||
export async function openCsvInExcel(filePath) {
|
||||
const res = await fetch(`${BASE_URL}/api/csv/open-excel`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ file_path: filePath })
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
|
||||
|
|
308
main.py
308
main.py
|
@ -2822,6 +2822,314 @@ def process_symbol_variables():
|
|||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
# CSV File Browser Endpoints
|
||||
@app.route("/api/csv/files", methods=["GET"])
|
||||
def get_csv_files():
|
||||
"""Get structured list of CSV files organized by dataset, date, and hour"""
|
||||
try:
|
||||
records_dir = resource_path("records")
|
||||
if not os.path.exists(records_dir):
|
||||
return jsonify({"files": [], "tree": []})
|
||||
|
||||
file_tree = []
|
||||
files_flat = []
|
||||
|
||||
# Scan records directory
|
||||
for date_dir in os.listdir(records_dir):
|
||||
date_path = os.path.join(records_dir, date_dir)
|
||||
if not os.path.isdir(date_path):
|
||||
continue
|
||||
|
||||
date_node = {
|
||||
"id": f"date_{date_dir}",
|
||||
"name": f"📅 {date_dir}",
|
||||
"value": date_dir,
|
||||
"type": "date",
|
||||
"children": [],
|
||||
}
|
||||
|
||||
# Group files by dataset
|
||||
datasets = {}
|
||||
for filename in os.listdir(date_path):
|
||||
if not filename.endswith(".csv"):
|
||||
continue
|
||||
|
||||
file_path = os.path.join(date_path, filename)
|
||||
|
||||
# Extract dataset name (everything before the first underscore + number)
|
||||
dataset_name = filename.split("_")[0]
|
||||
if len(filename.split("_")) > 1:
|
||||
# If there's a second part, it might be part of dataset name
|
||||
parts = filename.split("_")
|
||||
if not parts[-1].replace(".csv", "").isdigit():
|
||||
dataset_name = "_".join(parts[:-1])
|
||||
|
||||
# Get file info
|
||||
try:
|
||||
stat = os.stat(file_path)
|
||||
file_size = stat.st_size
|
||||
file_mtime = datetime.fromtimestamp(stat.st_mtime)
|
||||
|
||||
# Try to get CSV info (first 3 columns and row count)
|
||||
csv_info = get_csv_file_info(file_path)
|
||||
|
||||
file_info = {
|
||||
"id": f"file_{date_dir}_{filename}",
|
||||
"name": f"📊 {filename}",
|
||||
"value": filename,
|
||||
"type": "file",
|
||||
"path": file_path,
|
||||
"date": date_dir,
|
||||
"dataset": dataset_name,
|
||||
"size": file_size,
|
||||
"size_human": format_file_size(file_size),
|
||||
"modified": file_mtime.isoformat(),
|
||||
"modified_human": file_mtime.strftime("%H:%M:%S"),
|
||||
**csv_info,
|
||||
}
|
||||
|
||||
files_flat.append(file_info)
|
||||
|
||||
if dataset_name not in datasets:
|
||||
datasets[dataset_name] = {
|
||||
"id": f"dataset_{date_dir}_{dataset_name}",
|
||||
"name": f"📊 {dataset_name}",
|
||||
"value": dataset_name,
|
||||
"type": "dataset",
|
||||
"children": [],
|
||||
}
|
||||
|
||||
datasets[dataset_name]["children"].append(file_info)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing file {filename}: {e}")
|
||||
continue
|
||||
|
||||
# Add datasets to date node
|
||||
date_node["children"] = list(datasets.values())
|
||||
if date_node["children"]: # Only add dates that have files
|
||||
file_tree.append(date_node)
|
||||
|
||||
# Sort by date (newest first)
|
||||
file_tree.sort(key=lambda x: x["value"], reverse=True)
|
||||
|
||||
return jsonify(
|
||||
{"files": files_flat, "tree": file_tree, "total_files": len(files_flat)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e), "files": [], "tree": []}), 500
|
||||
|
||||
|
||||
def get_csv_file_info(file_path):
|
||||
"""Get CSV file information: first 3 columns and row count"""
|
||||
try:
|
||||
import csv
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
header = next(reader, [])
|
||||
|
||||
# Count rows (approximately, for performance)
|
||||
row_count = sum(1 for _ in reader)
|
||||
|
||||
# Get first 3 column names
|
||||
first_columns = header[:3] if header else []
|
||||
|
||||
return {
|
||||
"columns": first_columns,
|
||||
"column_count": len(header),
|
||||
"row_count": row_count,
|
||||
"preview": ", ".join(first_columns[:3]),
|
||||
}
|
||||
except Exception:
|
||||
return {"columns": [], "column_count": 0, "row_count": 0, "preview": "Unknown"}
|
||||
|
||||
|
||||
def format_file_size(size_bytes):
|
||||
"""Format file size in human readable format"""
|
||||
if size_bytes == 0:
|
||||
return "0 B"
|
||||
size_names = ["B", "KB", "MB", "GB"]
|
||||
import math
|
||||
|
||||
i = int(math.floor(math.log(size_bytes, 1024)))
|
||||
p = math.pow(1024, i)
|
||||
s = round(size_bytes / p, 2)
|
||||
return f"{s} {size_names[i]}"
|
||||
|
||||
|
||||
@app.route("/api/plotjuggler/launch", methods=["POST"])
|
||||
def launch_plotjuggler():
|
||||
"""Launch PlotJuggler with selected CSV files"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
file_paths = data.get("files", [])
|
||||
|
||||
if not file_paths:
|
||||
return jsonify({"error": "No files provided"}), 400
|
||||
|
||||
# Get PlotJuggler path from system state
|
||||
plotjuggler_path = get_plotjuggler_path()
|
||||
if not plotjuggler_path:
|
||||
return jsonify({"error": "PlotJuggler not found"}), 404
|
||||
|
||||
# Launch PlotJuggler with files
|
||||
import subprocess
|
||||
|
||||
if len(file_paths) == 1:
|
||||
# Single file
|
||||
cmd = [plotjuggler_path, "--nosplash", "--datafile", file_paths[0]]
|
||||
else:
|
||||
# Multiple files - launch separate instances
|
||||
commands = []
|
||||
for file_path in file_paths:
|
||||
commands.append(
|
||||
[plotjuggler_path, "--nosplash", "--datafile", file_path]
|
||||
)
|
||||
|
||||
# Execute all commands
|
||||
for cmd in commands:
|
||||
subprocess.Popen(cmd, shell=True)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Launched {len(file_paths)} PlotJuggler instances",
|
||||
"commands": [" ".join(cmd) for cmd in commands],
|
||||
}
|
||||
)
|
||||
|
||||
# Execute single command
|
||||
subprocess.Popen(cmd, shell=True)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": "PlotJuggler launched successfully",
|
||||
"command": " ".join(cmd),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/plotjuggler/path", methods=["GET"])
|
||||
def get_plotjuggler_path_endpoint():
|
||||
"""Get PlotJuggler executable path"""
|
||||
try:
|
||||
path = get_plotjuggler_path()
|
||||
return jsonify({"path": path, "found": path is not None})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/plotjuggler/path", methods=["POST"])
|
||||
def set_plotjuggler_path():
|
||||
"""Set PlotJuggler executable path"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
path = data.get("path", "")
|
||||
|
||||
if not path or not os.path.exists(path):
|
||||
return jsonify({"error": "Invalid path provided"}), 400
|
||||
|
||||
# Save to system state
|
||||
save_plotjuggler_path(path)
|
||||
|
||||
return jsonify(
|
||||
{"success": True, "message": "PlotJuggler path saved", "path": path}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
def get_plotjuggler_path():
|
||||
"""Get PlotJuggler executable path, search if not found"""
|
||||
try:
|
||||
# Load from system state
|
||||
state_file = resource_path("system_state.json")
|
||||
if os.path.exists(state_file):
|
||||
with open(state_file, "r") as f:
|
||||
state = json.load(f)
|
||||
saved_path = state.get("plotjuggler_path")
|
||||
if saved_path and os.path.exists(saved_path):
|
||||
return saved_path
|
||||
|
||||
# Search for PlotJuggler
|
||||
search_paths = [
|
||||
r"C:\Program Files\PlotJuggler\plotjuggler.exe",
|
||||
r"C:\Program Files (x86)\PlotJuggler\plotjuggler.exe",
|
||||
r"C:\PlotJuggler\plotjuggler.exe",
|
||||
]
|
||||
|
||||
for path in search_paths:
|
||||
if os.path.exists(path):
|
||||
save_plotjuggler_path(path)
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting PlotJuggler path: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def save_plotjuggler_path(path):
|
||||
"""Save PlotJuggler path to system state"""
|
||||
try:
|
||||
state_file = resource_path("system_state.json")
|
||||
state = {}
|
||||
|
||||
if os.path.exists(state_file):
|
||||
with open(state_file, "r") as f:
|
||||
state = json.load(f)
|
||||
|
||||
state["plotjuggler_path"] = path
|
||||
state["last_update"] = datetime.now().isoformat()
|
||||
|
||||
with open(state_file, "w") as f:
|
||||
json.dump(state, f, indent=4)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving PlotJuggler path: {e}")
|
||||
|
||||
|
||||
@app.route("/api/csv/open-excel", methods=["POST"])
|
||||
def open_csv_in_excel():
|
||||
"""Open CSV file in Excel (or default spreadsheet app)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
file_path = data.get("file_path", "")
|
||||
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
import subprocess
|
||||
import platform
|
||||
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
# Use os.startfile on Windows
|
||||
os.startfile(file_path)
|
||||
elif system == "Darwin": # macOS
|
||||
subprocess.Popen(["open", file_path])
|
||||
else: # Linux
|
||||
subprocess.Popen(["xdg-open", file_path])
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Opened {os.path.basename(file_path)} in default application",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
# Initialize streamer instance
|
||||
|
|
|
@ -3,11 +3,12 @@
|
|||
"should_connect": true,
|
||||
"should_stream": false,
|
||||
"active_datasets": [
|
||||
"DAR",
|
||||
"Fast",
|
||||
"Test",
|
||||
"Fast"
|
||||
"DAR"
|
||||
]
|
||||
},
|
||||
"auto_recovery_enabled": true,
|
||||
"last_update": "2025-08-15T19:53:52.606916"
|
||||
"last_update": "2025-08-15T20:15:35.459362",
|
||||
"plotjuggler_path": "C:\\Program Files\\PlotJuggler\\plotjuggler.exe"
|
||||
}
|
Loading…
Reference in New Issue