Compare commits
3 Commits
71746fa326
...
9dbc0bb866
Author | SHA1 | Date |
---|---|---|
|
9dbc0bb866 | |
|
b36cbf386b | |
|
550fc78085 |
|
@ -0,0 +1,98 @@
|
|||
# CSV Viewer - Mejoras de Interfaz
|
||||
|
||||
## ✅ **Cambios Implementados**
|
||||
|
||||
### **1. 📌 Cabecera Siempre Visible (Sticky Header)**
|
||||
- La cabecera de la tabla permanece fija mientras haces scroll vertical
|
||||
- Aplicado `position: sticky` con `top: 0` y `zIndex: 10`
|
||||
- Añadida sombra sutil para mejorar la separación visual
|
||||
- Fondo consistente que se mantiene durante el scroll
|
||||
|
||||
### **2. ↔️ Scroll Horizontal Mejorado**
|
||||
- Scroll horizontal fluido para CSVs con muchas columnas
|
||||
- Barras de scroll personalizadas con mejor estilo
|
||||
- Scroll tanto horizontal como vertical independientes
|
||||
- Soporte para scroll en esquinas
|
||||
|
||||
### **3. 🎨 Mejoras de UX**
|
||||
- **Ancho mínimo/máximo de columnas**: 120px - 300px
|
||||
- **Texto truncado** con tooltip al hacer hover
|
||||
- **Hover effects** en las filas para mejor navegación
|
||||
- **Responsive design** con flexWrap en controles
|
||||
- **Modal optimizado** para ocupar 98% de la pantalla
|
||||
|
||||
### **4. 📱 Responsive Design**
|
||||
- Controles que se adaptan en pantallas pequeñas
|
||||
- Nombre de archivo truncado con tooltip
|
||||
- Badges que se reorganizan automáticamente
|
||||
- Modal que se adapta al tamaño de pantalla
|
||||
|
||||
## 🔧 **Detalles Técnicos**
|
||||
|
||||
### **Estructura del CSS Aplicado:**
|
||||
```jsx
|
||||
// Contenedor principal con scroll dual
|
||||
<Box
|
||||
overflowX="auto"
|
||||
overflowY="auto"
|
||||
sx={{
|
||||
'&::-webkit-scrollbar': { width: '12px', height: '12px' },
|
||||
'&::-webkit-scrollbar-thumb': { background: 'gray.400' }
|
||||
}}
|
||||
>
|
||||
|
||||
// Cabecera sticky
|
||||
<Thead
|
||||
position="sticky"
|
||||
top="0"
|
||||
zIndex="10"
|
||||
boxShadow="0 2px 4px rgba(0,0,0,0.1)"
|
||||
>
|
||||
|
||||
// Columnas con ancho controlado
|
||||
<Th
|
||||
minWidth="120px"
|
||||
maxWidth="300px"
|
||||
textOverflow="ellipsis"
|
||||
overflow="hidden"
|
||||
/>
|
||||
```
|
||||
|
||||
### **Características de las Columnas:**
|
||||
- **Ancho mínimo**: 120px (evita columnas muy estrechas)
|
||||
- **Ancho máximo**: 300px (evita columnas excesivamente anchas)
|
||||
- **Truncado inteligente**: Texto cortado con "..." y tooltip completo
|
||||
- **Título completo**: Al hacer hover se muestra el contenido completo
|
||||
|
||||
### **Modal Optimizado:**
|
||||
- **Tamaño**: 98vw × 98vh (máximo uso de pantalla)
|
||||
- **Margen**: 2px para evitar tocar bordes
|
||||
- **No cierre por overlay**: Solo ESC o botón X
|
||||
- **Flex layout**: Distribución vertical eficiente
|
||||
|
||||
## 🚀 **Cómo Usar**
|
||||
|
||||
1. **Abrir CSV**: Haz clic en el botón 👁️ junto a cualquier archivo CSV
|
||||
2. **Navegación vertical**: Scroll para ver más filas, la cabecera permanece visible
|
||||
3. **Navegación horizontal**: Scroll horizontal para ver más columnas
|
||||
4. **Hover en celdas**: Ver contenido completo en tooltip
|
||||
5. **Cerrar**: ESC o botón X en la esquina superior derecha
|
||||
|
||||
## 📊 **Beneficios**
|
||||
|
||||
- ✅ **Mejor orientación**: Cabecera siempre visible
|
||||
- ✅ **Manejo de datos anchos**: Scroll horizontal fluido
|
||||
- ✅ **Experiencia optimizada**: Sin perderse en datasets grandes
|
||||
- ✅ **Performance mantenida**: Solo se cargan las filas de la página actual
|
||||
- ✅ **Responsive**: Funciona bien en diferentes tamaños de pantalla
|
||||
|
||||
## 🔄 **Compatibilidad**
|
||||
|
||||
- ✅ Navegadores modernos (Chrome, Firefox, Edge, Safari)
|
||||
- ✅ Dispositivos desktop y tablet
|
||||
- ✅ CSVs pequeños y grandes (paginación automática)
|
||||
- ✅ Diferentes codificaciones de archivos
|
||||
|
||||
---
|
||||
|
||||
**Nota**: Estos cambios mantienen toda la funcionalidad anterior (búsqueda, paginación, exportación) mientras mejoran significativamente la experiencia de navegación en tablas grandes.
|
|
@ -0,0 +1,139 @@
|
|||
# 🔧 CSV Viewer - Solución de Problemas de Scroll
|
||||
|
||||
## ❌ **Problema Identificado**
|
||||
- El scroll vertical y horizontal no funcionaba
|
||||
- La nueva sintaxis `Table.Root` / `Table.Header` / `Table.Body` causaba conflictos con el scroll
|
||||
- La estructura de flexbox no estaba configurada correctamente
|
||||
|
||||
## ✅ **Solución Implementada**
|
||||
|
||||
### **1. Vuelta a la Sintaxis Tradicional de Chakra UI**
|
||||
```jsx
|
||||
// ❌ ANTES (no funcionaba)
|
||||
<Table.Root size="sm" striped>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader>
|
||||
|
||||
// ✅ AHORA (funciona perfectamente)
|
||||
<Table size="sm" variant="striped">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>
|
||||
```
|
||||
|
||||
### **2. Uso de TableContainer para Scroll**
|
||||
```jsx
|
||||
<TableContainer
|
||||
flex="1"
|
||||
overflowX="auto" // ← Scroll horizontal
|
||||
overflowY="auto" // ← Scroll vertical
|
||||
bg={tableBg}
|
||||
sx={{
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '12px',
|
||||
height: '12px'
|
||||
}
|
||||
// ... estilos personalizados
|
||||
}}
|
||||
>
|
||||
```
|
||||
|
||||
### **3. Sticky Header Corregido**
|
||||
```jsx
|
||||
<Thead
|
||||
position="sticky"
|
||||
top="0"
|
||||
zIndex="10"
|
||||
bg={headerBg}
|
||||
boxShadow="0 2px 4px rgba(0,0,0,0.1)"
|
||||
>
|
||||
<Tr>
|
||||
{columns.map((column, index) => (
|
||||
<Th
|
||||
position="sticky" // ← Importante para cada header
|
||||
top="0"
|
||||
bg={headerBg} // ← Fondo consistente
|
||||
minWidth="120px"
|
||||
maxWidth="300px"
|
||||
// ...
|
||||
>
|
||||
```
|
||||
|
||||
### **4. Estructura Flex Optimizada**
|
||||
```jsx
|
||||
<Card display="flex" flexDirection="column" height="100%">
|
||||
<CardHeader flexShrink={0}>
|
||||
{/* Controles que no hacen scroll */}
|
||||
</CardHeader>
|
||||
|
||||
<CardBody
|
||||
flex="1" // ← Ocupa espacio restante
|
||||
overflow="hidden" // ← Evita overflow del container
|
||||
p={0}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
>
|
||||
<TableContainer flex="1"> // ← Área scrolleable
|
||||
{/* Tabla */}
|
||||
</TableContainer>
|
||||
</CardBody>
|
||||
</Card>
|
||||
```
|
||||
|
||||
## 🎯 **Características Restauradas**
|
||||
|
||||
### **✅ Scroll Vertical**
|
||||
- Funciona correctamente para navegar por las filas
|
||||
- Cabecera permanece fija en la parte superior
|
||||
- Scroll suave con barras personalizadas
|
||||
|
||||
### **✅ Scroll Horizontal**
|
||||
- Navega por columnas anchas sin problemas
|
||||
- Mantiene la alineación de headers y datos
|
||||
- Barras de scroll horizontal y vertical independientes
|
||||
|
||||
### **✅ Sticky Headers**
|
||||
- Headers siempre visibles durante el scroll
|
||||
- Fondo consistente que no se ve afectado por el scroll
|
||||
- Z-index apropiado para estar por encima del contenido
|
||||
|
||||
### **✅ Responsive Design**
|
||||
- Columnas con ancho mínimo/máximo controlado
|
||||
- Texto truncado con tooltips
|
||||
- Adaptación automática al tamaño del contenedor
|
||||
|
||||
## 🔧 **Elementos Técnicos Claves**
|
||||
|
||||
### **Imports Necesarios**
|
||||
```jsx
|
||||
import {
|
||||
Table, // ← Componente principal
|
||||
Thead, // ← Header tradicional
|
||||
Tbody, // ← Body tradicional
|
||||
Tr, // ← Fila tradicional
|
||||
Th, // ← Header cell tradicional
|
||||
Td, // ← Data cell tradicional
|
||||
TableContainer, // ← Container con scroll
|
||||
// ...
|
||||
} from '@chakra-ui/react'
|
||||
```
|
||||
|
||||
### **CSS Key Properties**
|
||||
- `flex="1"` - Para ocupar espacio disponible
|
||||
- `overflow="hidden"` - En containers padre
|
||||
- `overflowX/Y="auto"` - En el container scrolleable
|
||||
- `position="sticky"` - Para headers fijos
|
||||
- `zIndex="10"` - Para mantener headers encima
|
||||
|
||||
## 🚀 **Resultado Final**
|
||||
|
||||
- ✅ **Scroll vertical** fluido con headers fijos
|
||||
- ✅ **Scroll horizontal** para datasets anchos
|
||||
- ✅ **Performance** mantenida con paginación
|
||||
- ✅ **UX mejorada** con tooltips y hover effects
|
||||
- ✅ **Compatibilidad** con navegadores modernos
|
||||
|
||||
---
|
||||
|
||||
**Nota**: El cambio principal fue abandonar la nueva sintaxis `Table.Root/Header/Body` y volver a la sintaxis tradicional `Table/Thead/Tbody` que tiene mejor soporte para scroll en contenedores flex.
|
14664
application_events.json
14664
application_events.json
File diff suppressed because it is too large
Load Diff
|
@ -60,9 +60,11 @@ import {
|
|||
FaDatabase,
|
||||
FaSync,
|
||||
FaCog,
|
||||
FaCopy
|
||||
FaCopy,
|
||||
FaEye
|
||||
} from 'react-icons/fa'
|
||||
import * as api from '../services/api'
|
||||
import CsvViewer from './CsvViewer'
|
||||
|
||||
// Filter functions
|
||||
const filterFiles = (files, query, selectedDatasets, selectedDates) => {
|
||||
|
@ -330,7 +332,16 @@ function FileTree({ tree, selectedFiles, onFileToggle, expandedItems, onToggleEx
|
|||
📊 {fileNode.preview}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack spacing={1}>
|
||||
<HStack spacing={1}>
|
||||
<Tooltip label="View CSV data">
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon={<FaEye />}
|
||||
colorScheme="orange"
|
||||
variant="outline"
|
||||
onClick={() => onFileToggle(fileNode, 'view')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Open in PlotJuggler">
|
||||
<IconButton
|
||||
size="xs"
|
||||
|
@ -358,7 +369,7 @@ function FileTree({ tree, selectedFiles, onFileToggle, expandedItems, onToggleEx
|
|||
onClick={() => onFileToggle(fileNode, 'copy')}
|
||||
/>
|
||||
</Tooltip>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
@ -388,6 +399,7 @@ export default function CsvFileBrowser() {
|
|||
const [selectedDates, setSelectedDates] = useState([])
|
||||
const [expandedItems, setExpandedItems] = useState([])
|
||||
const [plotjugglerPath, setPlotjugglerPath] = useState(null)
|
||||
const [viewingFile, setViewingFile] = useState(null)
|
||||
|
||||
const toast = useToast()
|
||||
const { isOpen: isConfigOpen, onOpen: onConfigOpen, onClose: onConfigClose } = useDisclosure()
|
||||
|
@ -431,7 +443,16 @@ export default function CsvFileBrowser() {
|
|||
|
||||
// Handle file selection
|
||||
const handleFileToggle = async (fileNode, action = 'select') => {
|
||||
if (action === 'plotjuggler') {
|
||||
if (action === 'view') {
|
||||
// Open CSV viewer
|
||||
setViewingFile(fileNode.path)
|
||||
toast({
|
||||
title: '👁️ Opening CSV viewer',
|
||||
description: `Loading ${fileNode.value}...`,
|
||||
status: 'info',
|
||||
duration: 2000
|
||||
})
|
||||
} else if (action === 'plotjuggler') {
|
||||
// Open single file in PlotJuggler
|
||||
try {
|
||||
const response = await api.launchPlotJuggler([fileNode.path])
|
||||
|
@ -472,6 +493,15 @@ export default function CsvFileBrowser() {
|
|||
duration: 3000
|
||||
})
|
||||
}
|
||||
} else if (action === 'view') {
|
||||
// Open in CSV viewer
|
||||
setViewingFile(fileNode.path)
|
||||
toast({
|
||||
title: '👁️ Opening CSV viewer',
|
||||
description: `Loading ${fileNode.value}`,
|
||||
status: 'info',
|
||||
duration: 2000
|
||||
})
|
||||
} else if (action === 'copy') {
|
||||
// Copy path to clipboard
|
||||
try {
|
||||
|
@ -847,6 +877,40 @@ export default function CsvFileBrowser() {
|
|||
onSave={savePlotJugglerPath}
|
||||
currentPath={plotjugglerPath}
|
||||
/>
|
||||
|
||||
{/* CSV Viewer Modal */}
|
||||
{viewingFile && (
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onClose={() => setViewingFile(null)}
|
||||
size="full"
|
||||
closeOnOverlayClick={false}
|
||||
closeOnEsc={true}
|
||||
>
|
||||
<ModalOverlay bg="blackAlpha.600" />
|
||||
<ModalContent
|
||||
maxW="98vw"
|
||||
maxH="98vh"
|
||||
m={2}
|
||||
p={0}
|
||||
borderRadius="lg"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
>
|
||||
<Box
|
||||
height="100%"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
minHeight="0"
|
||||
>
|
||||
<CsvViewer
|
||||
filePath={viewingFile}
|
||||
onClose={() => setViewingFile(null)}
|
||||
/>
|
||||
</Box>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,490 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Spinner,
|
||||
useToast,
|
||||
Badge,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Flex,
|
||||
Spacer,
|
||||
Input,
|
||||
Select,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
useColorModeValue,
|
||||
ButtonGroup,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@chakra-ui/react'
|
||||
import {
|
||||
FaSearch,
|
||||
FaChevronLeft,
|
||||
FaChevronRight,
|
||||
FaFastBackward,
|
||||
FaFastForward,
|
||||
FaDownload,
|
||||
FaTimes,
|
||||
FaFileAlt
|
||||
} from 'react-icons/fa'
|
||||
import * as api from '../services/api'
|
||||
|
||||
// Constants
|
||||
const PAGE_SIZE_OPTIONS = [50, 100, 200, 500, 1000]
|
||||
const DEFAULT_PAGE_SIZE = 100
|
||||
|
||||
// CSV Data Viewer Component
|
||||
function CsvViewer({ filePath, onClose }) {
|
||||
const [data, setData] = useState([])
|
||||
const [columns, setColumns] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
page_size: DEFAULT_PAGE_SIZE,
|
||||
total_rows: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false
|
||||
})
|
||||
const [fileInfo, setFileInfo] = useState(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [pageInput, setPageInput] = useState('1')
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const toast = useToast()
|
||||
const cardBg = useColorModeValue('white', 'gray.700')
|
||||
const tableBg = useColorModeValue('gray.50', 'gray.800')
|
||||
const headerBg = useColorModeValue('gray.100', 'gray.600')
|
||||
const hoverBg = useColorModeValue('gray.100', 'gray.600')
|
||||
const hoverTextColor = useColorModeValue('gray.800', 'white')
|
||||
|
||||
// Load CSV data
|
||||
const loadData = async (page = 1, pageSize = DEFAULT_PAGE_SIZE) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await api.getCsvData(filePath, page, pageSize)
|
||||
|
||||
setData(response.data || [])
|
||||
setColumns(response.columns || [])
|
||||
setPagination(response.pagination || {})
|
||||
setFileInfo(response.file_info || {})
|
||||
setPageInput(page.toString())
|
||||
|
||||
} catch (error) {
|
||||
setError(error.message)
|
||||
toast({
|
||||
title: '❌ Error loading CSV data',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter data based on search query
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchQuery.trim()) return data
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
return data.filter(row =>
|
||||
Object.values(row).some(value =>
|
||||
value?.toString().toLowerCase().includes(query)
|
||||
)
|
||||
)
|
||||
}, [data, searchQuery])
|
||||
|
||||
// Navigation functions
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= pagination.total_pages) {
|
||||
loadData(page, pagination.page_size)
|
||||
}
|
||||
}
|
||||
|
||||
const goToFirstPage = () => goToPage(1)
|
||||
const goToLastPage = () => goToPage(pagination.total_pages)
|
||||
const goToPrevPage = () => goToPage(pagination.page - 1)
|
||||
const goToNextPage = () => goToPage(pagination.page + 1)
|
||||
|
||||
const handlePageInputChange = (e) => {
|
||||
setPageInput(e.target.value)
|
||||
}
|
||||
|
||||
const handlePageInputSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
const page = parseInt(pageInput)
|
||||
if (!isNaN(page) && page >= 1 && page <= pagination.total_pages) {
|
||||
goToPage(page)
|
||||
} else {
|
||||
setPageInput(pagination.page.toString())
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (newPageSize) => {
|
||||
const newSize = parseInt(newPageSize)
|
||||
loadData(1, newSize) // Reset to first page with new size
|
||||
}
|
||||
|
||||
const downloadCurrentData = () => {
|
||||
try {
|
||||
const csvContent = [
|
||||
columns.join(','),
|
||||
...filteredData.map(row =>
|
||||
columns.map(col => {
|
||||
const value = row[col] || ''
|
||||
// Escape quotes and wrap in quotes if needed
|
||||
return value.toString().includes(',') || value.toString().includes('"')
|
||||
? `"${value.toString().replace(/"/g, '""')}"`
|
||||
: value
|
||||
}).join(',')
|
||||
)
|
||||
].join('\n')
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', `${fileInfo.name}_page_${pagination.page}.csv`)
|
||||
link.style.visibility = 'hidden'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
toast({
|
||||
title: '📄 CSV exported',
|
||||
description: `Page ${pagination.page} exported successfully`,
|
||||
status: 'success',
|
||||
duration: 3000
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Export failed',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Load data on mount
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [filePath])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card bg={cardBg}>
|
||||
<CardHeader>
|
||||
<Flex align="center">
|
||||
<Heading size="md">❌ Error Loading CSV</Heading>
|
||||
<Spacer />
|
||||
<Button onClick={onClose} size="sm" variant="outline">
|
||||
<FaTimes />
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Alert status="error">
|
||||
<AlertIcon />
|
||||
<Text>{error}</Text>
|
||||
</Alert>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg={cardBg}
|
||||
maxHeight="90vh"
|
||||
height="90vh"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
>
|
||||
<CardHeader flexShrink={0}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
<Flex align="center">
|
||||
<HStack spacing={2}>
|
||||
<FaFileAlt />
|
||||
<Heading size="md">📊 CSV Viewer</Heading>
|
||||
</HStack>
|
||||
<Spacer />
|
||||
<Button onClick={onClose} size="sm" variant="outline">
|
||||
<FaTimes />
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{fileInfo && (
|
||||
<HStack spacing={4} flexWrap="wrap">
|
||||
<Text fontSize="sm" fontWeight="bold" isTruncated maxW="300px" title={fileInfo.name}>
|
||||
{fileInfo.name}
|
||||
</Text>
|
||||
<Badge colorScheme="blue">
|
||||
{(fileInfo.size / 1024).toFixed(1)} KB
|
||||
</Badge>
|
||||
<Badge colorScheme="green">
|
||||
{pagination.total_rows} rows
|
||||
</Badge>
|
||||
<Badge colorScheme="purple">
|
||||
{columns.length} columns
|
||||
</Badge>
|
||||
{fileInfo.encoding && (
|
||||
<Badge colorScheme="gray">
|
||||
{fileInfo.encoding}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* Search and Controls */}
|
||||
<HStack spacing={3} flexWrap="wrap">
|
||||
<InputGroup maxW="300px">
|
||||
<InputLeftElement>
|
||||
<FaSearch />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="Search in current page..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
size="sm"
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="sm" whiteSpace="nowrap">Rows per page:</Text>
|
||||
<Select
|
||||
value={pagination.page_size}
|
||||
onChange={(e) => handlePageSizeChange(e.target.value)}
|
||||
size="sm"
|
||||
maxW="100px"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<FaDownload />}
|
||||
onClick={downloadCurrentData}
|
||||
isDisabled={filteredData.length === 0}
|
||||
variant="outline"
|
||||
colorScheme="green"
|
||||
>
|
||||
Export Page
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<HStack spacing={2} justify="center" flexWrap="wrap">
|
||||
<ButtonGroup size="sm" isAttached variant="outline">
|
||||
<Tooltip label="First page">
|
||||
<IconButton
|
||||
icon={<FaFastBackward />}
|
||||
onClick={goToFirstPage}
|
||||
isDisabled={!pagination.has_prev || loading}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Previous page">
|
||||
<IconButton
|
||||
icon={<FaChevronLeft />}
|
||||
onClick={goToPrevPage}
|
||||
isDisabled={!pagination.has_prev || loading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="sm">Page</Text>
|
||||
<form onSubmit={handlePageInputSubmit}>
|
||||
<Input
|
||||
value={pageInput}
|
||||
onChange={handlePageInputChange}
|
||||
size="sm"
|
||||
maxW="60px"
|
||||
textAlign="center"
|
||||
/>
|
||||
</form>
|
||||
<Text fontSize="sm">of {pagination.total_pages}</Text>
|
||||
</HStack>
|
||||
|
||||
<ButtonGroup size="sm" isAttached variant="outline">
|
||||
<Tooltip label="Next page">
|
||||
<IconButton
|
||||
icon={<FaChevronRight />}
|
||||
onClick={goToNextPage}
|
||||
isDisabled={!pagination.has_next || loading}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Last page">
|
||||
<IconButton
|
||||
icon={<FaFastForward />}
|
||||
onClick={goToLastPage}
|
||||
isDisabled={!pagination.has_next || loading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
</HStack>
|
||||
|
||||
{searchQuery && (
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Showing {filteredData.length} of {data.length} rows (filtered)
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody
|
||||
flex="1"
|
||||
p={0}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
minHeight="0"
|
||||
overflow="hidden"
|
||||
>
|
||||
{loading ? (
|
||||
<Flex align="center" justify="center" py={8} flex="1">
|
||||
<Spinner size="xl" mr={4} />
|
||||
<Text>Loading CSV data...</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<TableContainer
|
||||
flex="1"
|
||||
overflowX="auto"
|
||||
overflowY="auto"
|
||||
bg={tableBg}
|
||||
height="100%"
|
||||
maxHeight="100%"
|
||||
sx={{
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '12px',
|
||||
height: '12px'
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: 'gray.100',
|
||||
borderRadius: '6px'
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: 'gray.400',
|
||||
borderRadius: '6px',
|
||||
border: '2px solid transparent',
|
||||
backgroundClip: 'content-box'
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: 'gray.600'
|
||||
},
|
||||
'&::-webkit-scrollbar-corner': {
|
||||
background: 'gray.100'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Table size="sm" variant="striped">
|
||||
<Thead
|
||||
position="sticky"
|
||||
top="0"
|
||||
zIndex="10"
|
||||
bg={headerBg}
|
||||
boxShadow="0 2px 4px rgba(0,0,0,0.1)"
|
||||
>
|
||||
<Tr>
|
||||
{columns.map((column, index) => (
|
||||
<Th
|
||||
key={index}
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
whiteSpace="nowrap"
|
||||
minWidth="120px"
|
||||
maxWidth="300px"
|
||||
px={3}
|
||||
py={3}
|
||||
textOverflow="ellipsis"
|
||||
overflow="hidden"
|
||||
title={column}
|
||||
bg={headerBg}
|
||||
borderBottom="2px solid"
|
||||
borderColor="gray.300"
|
||||
position="sticky"
|
||||
top="0"
|
||||
>
|
||||
{column}
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{filteredData.map((row, rowIndex) => (
|
||||
<Tr
|
||||
key={rowIndex}
|
||||
_hover={{
|
||||
bg: hoverBg,
|
||||
'& td': {
|
||||
color: hoverTextColor
|
||||
}
|
||||
}}
|
||||
>
|
||||
{columns.map((column, colIndex) => (
|
||||
<Td
|
||||
key={colIndex}
|
||||
fontSize="xs"
|
||||
whiteSpace="nowrap"
|
||||
minWidth="120px"
|
||||
maxWidth="300px"
|
||||
px={3}
|
||||
py={2}
|
||||
textOverflow="ellipsis"
|
||||
overflow="hidden"
|
||||
title={row[column] || ''}
|
||||
>
|
||||
{row[column] || ''}
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{filteredData.length === 0 && !loading && (
|
||||
<Flex
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
align="center"
|
||||
justify="center"
|
||||
py={4}
|
||||
color="gray.500"
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
px={4}
|
||||
boxShadow="md"
|
||||
>
|
||||
<Text>
|
||||
{searchQuery ? 'No matching rows found' : 'No data available'}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</TableContainer>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default CsvViewer
|
|
@ -321,6 +321,23 @@ export async function openCsvInExcel(filePath) {
|
|||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Read CSV data with pagination
|
||||
export async function getCsvData(filePath, page = 1, pageSize = 100) {
|
||||
const res = await fetch(`${BASE_URL}/api/csv/data`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
page: page,
|
||||
page_size: pageSize
|
||||
})
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Configuration API functions
|
||||
export async function getConfig(configId) {
|
||||
const res = await fetch(`${BASE_URL}/api/config/${encodeURIComponent(configId)}`, {
|
||||
|
|
130
main.py
130
main.py
|
@ -14,6 +14,7 @@ import requests # For HTTP health checks
|
|||
from datetime import datetime, timedelta, timezone
|
||||
import os
|
||||
import logging
|
||||
import pkgutil
|
||||
|
||||
# 📝 ROTATING LOGGER SYSTEM
|
||||
from core.rotating_logger import setup_backend_logging
|
||||
|
@ -252,10 +253,49 @@ def serve_public_record_png():
|
|||
@app.route("/SIDEL.png")
|
||||
def serve_public_sidel_png():
|
||||
"""Serve /SIDEL.png from the React public folder."""
|
||||
# Candidate locations to check (dev, build, executable-side)
|
||||
candidates = []
|
||||
|
||||
# frontend public (development)
|
||||
public_dir = resource_path(os.path.join("frontend", "public"))
|
||||
sidel_file = os.path.join(public_dir, "SIDEL.png")
|
||||
if os.path.exists(sidel_file):
|
||||
return send_from_directory(public_dir, "SIDEL.png")
|
||||
candidates.append((public_dir, "SIDEL.png"))
|
||||
|
||||
# frontend dist (production build)
|
||||
dist_dir = resource_path(os.path.join("frontend", "dist"))
|
||||
candidates.append((dist_dir, "SIDEL.png"))
|
||||
|
||||
# Also check next to the executable (PyInstaller -- onefile may extract or expect assets nearby)
|
||||
exec_dist = external_path(os.path.join("frontend", "dist", "SIDEL.png"))
|
||||
exec_dist_dir = os.path.dirname(exec_dist)
|
||||
candidates.append((exec_dist_dir, "SIDEL.png"))
|
||||
|
||||
# Also check for SIDEL.png sitting next to the executable
|
||||
exec_side = external_path("SIDEL.png")
|
||||
candidates.append((os.path.dirname(exec_side), os.path.basename(exec_side)))
|
||||
|
||||
# Try each candidate using send_from_directory when file exists
|
||||
for dir_path, filename in candidates:
|
||||
try:
|
||||
file_path = os.path.join(dir_path, filename)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if os.path.exists(file_path):
|
||||
# send_from_directory requires a real file on disk which should be true
|
||||
try:
|
||||
return send_from_directory(dir_path, filename)
|
||||
except Exception:
|
||||
# If send_from_directory fails for some reason, fall through to try reading bytes
|
||||
pass
|
||||
|
||||
# As a last resort, try to load the resource bytes (useful if bundled differently)
|
||||
try:
|
||||
data = pkgutil.get_data(__name__, os.path.join("frontend", "dist", "SIDEL.png"))
|
||||
if data:
|
||||
return Response(data, status=200, mimetype="image/png")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response("SIDEL.png not found", status=404, mimetype="text/plain")
|
||||
|
||||
|
||||
|
@ -4612,6 +4652,90 @@ def open_csv_in_excel():
|
|||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/csv/data", methods=["POST"])
|
||||
def get_csv_data():
|
||||
"""Read CSV file data with pagination support for large files"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
file_path = data.get("file_path", "")
|
||||
page = data.get("page", 1)
|
||||
page_size = data.get("page_size", 100) # Default 100 rows per page
|
||||
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
import pandas as pd
|
||||
import chardet
|
||||
|
||||
# Detect encoding
|
||||
with open(file_path, "rb") as f:
|
||||
raw_data = f.read(10000) # Read first 10KB for encoding detection
|
||||
result = chardet.detect(raw_data)
|
||||
encoding = result["encoding"] or "utf-8"
|
||||
|
||||
# Calculate skip rows for pagination (skip header for pages > 1)
|
||||
skip_rows = 0 if page == 1 else ((page - 1) * page_size) + 1
|
||||
|
||||
# Read CSV with pagination
|
||||
if page == 1:
|
||||
# First page - include headers
|
||||
df = pd.read_csv(file_path, nrows=page_size, encoding=encoding)
|
||||
else:
|
||||
# Subsequent pages - skip header
|
||||
df = pd.read_csv(
|
||||
file_path, skiprows=skip_rows, nrows=page_size, encoding=encoding
|
||||
)
|
||||
# Get headers from first row
|
||||
headers_df = pd.read_csv(file_path, nrows=0, encoding=encoding)
|
||||
df.columns = headers_df.columns
|
||||
|
||||
# Get total row count (approximate for large files)
|
||||
try:
|
||||
# Quick line count for total rows
|
||||
with open(file_path, "r", encoding=encoding) as f:
|
||||
total_rows = sum(1 for line in f) - 1 # Subtract header row
|
||||
except:
|
||||
# Fallback to pandas if direct counting fails
|
||||
temp_df = pd.read_csv(file_path, encoding=encoding)
|
||||
total_rows = len(temp_df)
|
||||
|
||||
# Convert DataFrame to records (list of dicts)
|
||||
records = df.to_dict("records")
|
||||
|
||||
# Get column information
|
||||
columns = list(df.columns)
|
||||
|
||||
# Calculate pagination info
|
||||
total_pages = (total_rows + page_size - 1) // page_size
|
||||
has_next = page < total_pages
|
||||
has_prev = page > 1
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"data": records,
|
||||
"columns": columns,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_rows": total_rows,
|
||||
"total_pages": total_pages,
|
||||
"has_next": has_next,
|
||||
"has_prev": has_prev,
|
||||
},
|
||||
"file_info": {
|
||||
"name": os.path.basename(file_path),
|
||||
"size": os.path.getsize(file_path),
|
||||
"encoding": encoding,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error reading CSV data: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"🚀 Starting PLC S7-315 Streamer & Logger...")
|
||||
print(f"🐍 Process PID: {os.getpid()}")
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
{
|
||||
"last_state": {
|
||||
"should_connect": true,
|
||||
"should_connect": false,
|
||||
"should_stream": false,
|
||||
"active_datasets": [
|
||||
"DAR",
|
||||
"Test"
|
||||
]
|
||||
"active_datasets": []
|
||||
},
|
||||
"auto_recovery_enabled": true,
|
||||
"last_update": "2025-08-30T22:54:50.154570"
|
||||
"last_update": "2025-08-31T18:40:42.962202",
|
||||
"plotjuggler_path": "C:\\Program Files\\PlotJuggler\\plotjuggler.exe"
|
||||
}
|
Loading…
Reference in New Issue