diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index ded60c82..01d8cb9a 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -269,6 +269,8 @@ "map": "Map", "max": "Max", "min": "Min", + "minute": "minute", + "minutes": "minutes", "miscellaneous": "Miscellaneous", "mode": "Mode", "model": "Model", @@ -303,7 +305,8 @@ "save": "Save", "search": "Search", "seconds": "Seconds", - "select_all": "Show All", + "select_all": "Select All", + "select_none": "Select None", "select_value": "Select Value", "sending": "Sending", "sent_code": "Sent Code!", @@ -708,6 +711,23 @@ "update_success": "Entity updated!", "venues_under_root": "Venues cannot be created directly under the root entity" }, + "export": { + "title": "Export Device Data", + "select_data": "Select the data you want to include in the export:", + "select_at_least_one": "Please select at least one option to export", + "success": "Data exported successfully!", + "error": "Error exporting data", + "device_info_desc": "Serial number, MAC, manufacturer, firmware, etc.", + "configuration_desc": "Current device configuration", + "status_desc": "Connection status, IP address, associations", + "statistics_desc": "Interface metrics, memory usage, VLAN data", + "health_checks_desc": "Device health check history", + "provisioning_desc": "Entity, venue, and subscriber information", + "commands_desc": "Command history (configure, reboot, upgrade, etc.)", + "logs_desc": "Device log entries", + "crashes_desc": "Crash log records", + "reboots_desc": "Reboot log records" + }, "firmware": { "confirm_default_data": "Please confirm the information below and click 'Confirm' once you are ready to start the process", "create_success": "Created new default firmware settings!", @@ -882,6 +902,10 @@ "device_firmware_upgrade": "Firmware Upgrade", "device_statistics": "Device Statistics", "export": "Export", + "export_all": "Export All", + "export_all_title": "Export All Logs", + "export_format": "Export Format", + "export_success": "Successfully exported {{count}} log entries", "filter": "Filter", "firmware": "Firmware", "global_connections": "Global Connections", @@ -894,7 +918,21 @@ "thread": "Thread", "venue_config": "Configuration", "venue_reboot": "Reboot", - "venue_upgrade": "Upgrade" + "venue_upgrade": "Upgrade", + "websocket_status": "WebSocket Status", + "collection_duration": "Collection Duration", + "collection_started": "Collection Started", + "collection_started_desc": "Collecting logs for {{minutes}} minute(s)", + "collecting_logs": "Collecting logs...", + "logs_collected": "Logs collected", + "logs_will_export": "Logs will be exported as {{format}}", + "start_collection": "Start Collection", + "stay_on_page_info": "Please stay on this page during log collection. Navigating away or refreshing will cancel the collection.", + "stay_on_page_warning": "Do not leave this page or refresh. Collection is in progress.", + "stop_and_export": "Stop & Export", + "no_logs_collected": "No logs were collected during this period", + "no_websockets_connected": "No WebSocket connections available", + "connected_count": "{{count}} of 3 WebSockets connected" }, "map": { "auto_align": "Auto Align", diff --git a/src/components/Buttons/DeviceActionDropdown/index.tsx b/src/components/Buttons/DeviceActionDropdown/index.tsx index 9e1a5799..b493d5a8 100644 --- a/src/components/Buttons/DeviceActionDropdown/index.tsx +++ b/src/components/Buttons/DeviceActionDropdown/index.tsx @@ -33,6 +33,7 @@ interface Props { onOpenScriptModal: (device: GatewayDevice) => void; onOpenRebootModal: (serialNumber: string) => void; onOpenReEnrollModal?: (serialNumber: string) => void; + onOpenExportModal?: (serialNumber: string) => void; size?: 'sm' | 'md' | 'lg'; isCompact?: boolean; } @@ -51,6 +52,7 @@ const DeviceActionDropdown = ({ onOpenScriptModal, onOpenRebootModal, onOpenReEnrollModal, + onOpenExportModal, size, isCompact, }: Props) => { @@ -250,6 +252,11 @@ const DeviceActionDropdown = ({ + {onOpenExportModal && ( + onOpenExportModal(device.serialNumber)}> + {t('export.title')} + + )} diff --git a/src/components/Modals/ExportStatsModal/index.tsx b/src/components/Modals/ExportStatsModal/index.tsx new file mode 100644 index 00000000..680a38f2 --- /dev/null +++ b/src/components/Modals/ExportStatsModal/index.tsx @@ -0,0 +1,381 @@ +import * as React from 'react'; +import { + Box, + Button, + Center, + Checkbox, + FormLabel, + Spinner, + Stack, + Text, + useToast, +} from '@chakra-ui/react'; +import { DownloadSimple } from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from 'components/Modals/Modal'; +import { useGetDevice, useGetDeviceHealthChecks, useGetDeviceStatus } from 'hooks/Network/Devices'; +import { useGetDeviceLastStats, useGetDeviceNewestStats } from 'hooks/Network/Statistics'; +import { useGetTag } from 'hooks/Network/Inventory'; +import { useGetCommandHistory } from 'hooks/Network/Commands'; +import { useGetDeviceLogs } from 'hooks/Network/DeviceLogs'; + +type Props = { + serialNumber: string; + modalProps: { + isOpen: boolean; + onOpen: () => void; + onClose: () => void; + }; +}; + +const ExportStatsModal = ({ serialNumber, modalProps }: Props) => { + const { t } = useTranslation(); + const toast = useToast(); + const [selectedOptions, setSelectedOptions] = React.useState([ + 'deviceInfo', + 'status', + 'statistics', + ]); + const [isExporting, setIsExporting] = React.useState(false); + + const getDevice = useGetDevice({ serialNumber }); + const getStatus = useGetDeviceStatus({ serialNumber }); + const getStats = useGetDeviceLastStats({ serialNumber }); + const getNewestStats = useGetDeviceNewestStats({ serialNumber, limit: 30 }); + const getHealth = useGetDeviceHealthChecks({ serialNumber, limit: 50 }); + const getTag = useGetTag({ serialNumber }); + const getCommands = useGetCommandHistory({ serialNumber, limit: 100 }); + const getLogs = useGetDeviceLogs({ serialNumber, limit: 100, logType: 0 }); + const getCrashes = useGetDeviceLogs({ serialNumber, limit: 100, logType: 1 }); + const getReboots = useGetDeviceLogs({ serialNumber, limit: 100, logType: 2 }); + + const onToggle = (value: string) => (e: { target: { checked: boolean } }) => { + if (e.target.checked) { + setSelectedOptions([...selectedOptions, value]); + } else { + setSelectedOptions(selectedOptions.filter((opt) => opt !== value)); + } + }; + + const buildExportData = React.useCallback(() => { + const exportData: Record = { + exportedAt: new Date().toISOString(), + serialNumber, + }; + + if (selectedOptions.includes('deviceInfo') && getDevice.data) { + exportData.deviceInfo = { + serialNumber: getDevice.data.serialNumber, + macAddress: getDevice.data.macAddress, + manufacturer: getDevice.data.manufacturer, + deviceType: getDevice.data.deviceType, + compatible: getDevice.data.compatible, + firmware: getDevice.data.firmware, + locale: getDevice.data.locale, + createdTimestamp: getDevice.data.createdTimestamp, + modified: getDevice.data.modified, + lastConfigurationChange: getDevice.data.lastConfigurationChange, + lastConfigurationDownload: getDevice.data.lastConfigurationDownload, + lastFWUpdate: getDevice.data.lastFWUpdate, + lastRecordedContact: getDevice.data.lastRecordedContact, + certificateExpiryDate: getDevice.data.certificateExpiryDate, + fwUpdatePolicy: getDevice.data.fwUpdatePolicy, + restrictedDevice: getDevice.data.restrictedDevice, + restrictionDetails: getDevice.data.restrictionDetails, + }; + } + + if (selectedOptions.includes('configuration') && getDevice.data) { + exportData.configuration = getDevice.data.configuration; + } + + if (selectedOptions.includes('status') && getStatus.data) { + exportData.status = { + connected: getStatus.data.connected, + connectReason: getStatus.data.connectReason, + ipAddress: getStatus.data.ipAddress, + firmware: getStatus.data.firmware, + lastContact: getStatus.data.lastContact, + certificateExpiryDate: getStatus.data.certificateExpiryDate, + certificateIssuerName: getStatus.data.certificateIssuerName, + started: getStatus.data.started, + sessionId: getStatus.data.sessionId, + totalConnectionTime: getStatus.data.totalConnectionTime, + associations_2G: getStatus.data.associations_2G, + associations_5G: getStatus.data.associations_5G, + rxBytes: getStatus.data.rxBytes, + txBytes: getStatus.data.txBytes, + messageCount: getStatus.data.messageCount, + }; + } + + if (selectedOptions.includes('statistics')) { + if (getStats.data) { + exportData.lastStatistics = getStats.data; + } + if (getNewestStats.data?.data) { + exportData.statisticsHistory = getNewestStats.data.data.map((stat) => ({ + recorded: stat.recorded, + UUID: stat.UUID, + data: stat.data, + })); + } + } + + if (selectedOptions.includes('healthChecks') && getHealth.data?.values) { + exportData.healthChecks = getHealth.data.values.map((check) => ({ + recorded: check.recorded, + sanity: check.sanity, + UUID: check.UUID, + values: check.values, + })); + } + + if (selectedOptions.includes('provisioning') && getTag.data) { + exportData.provisioning = { + entity: getTag.data.entity, + venue: getTag.data.venue, + subscriber: getTag.data.subscriber, + extendedInfo: getTag.data.extendedInfo, + }; + } + + if (selectedOptions.includes('commands') && getCommands.data?.commands) { + exportData.commands = getCommands.data.commands; + } + + if (selectedOptions.includes('logs') && getLogs.data?.values) { + exportData.logs = getLogs.data.values; + } + + if (selectedOptions.includes('crashes') && getCrashes.data?.values) { + exportData.crashes = getCrashes.data.values; + } + + if (selectedOptions.includes('reboots') && getReboots.data?.values) { + exportData.reboots = getReboots.data.values; + } + + return exportData; + }, [selectedOptions, getDevice.data, getStatus.data, getStats.data, getNewestStats.data, getHealth.data, getTag.data, getCommands.data, getLogs.data, getCrashes.data, getReboots.data, serialNumber]); + + const handleExport = () => { + if (selectedOptions.length === 0) { + toast({ + id: 'export-no-selection', + title: t('common.error'), + description: t('export.select_at_least_one'), + status: 'warning', + duration: 3000, + isClosable: true, + position: 'top-right', + }); + return; + } + + setIsExporting(true); + + try { + const exportData = buildExportData(); + const jsonString = JSON.stringify(exportData, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${serialNumber}-export-${new Date().toISOString().replace(/[:.]/g, '-')}.json`; + link.click(); + URL.revokeObjectURL(url); + + toast({ + id: 'export-success', + title: t('common.success'), + description: t('export.success'), + status: 'success', + duration: 3000, + isClosable: true, + position: 'top-right', + }); + + modalProps.onClose(); + } catch (e) { + toast({ + id: 'export-error', + title: t('common.error'), + description: t('export.error'), + status: 'error', + duration: 5000, + isClosable: true, + position: 'top-right', + }); + } finally { + setIsExporting(false); + } + }; + + const isLoading = + getDevice.isFetching || + getStatus.isFetching || + getStats.isFetching || + getNewestStats.isFetching || + getHealth.isFetching || + getTag.isFetching || + getCommands.isFetching || + getLogs.isFetching || + getCrashes.isFetching || + getReboots.isFetching; + + return ( + } + onClick={handleExport} + isLoading={isExporting} + isDisabled={selectedOptions.length === 0 || isLoading} + > + {t('common.download')} + + } + > + + {isLoading ? ( +
+ +
+ ) : ( + + {t('export.select_data')} + + + + {t('controller.devices.commands')} + + + {t('export.commands_desc')} + + + + + {t('configurations.one')} + + + {t('export.configuration_desc')} + + + + + {t('devices.crash_logs')} + + + {t('export.crashes_desc')} + + + + + {t('common.details')} + + + {t('export.device_info_desc')} + + + + + {t('controller.devices.healthchecks')} + + + {t('export.health_checks_desc')} + + + + + {t('controller.devices.logs')} + + + {t('export.logs_desc')} + + + + + {t('controller.provisioning.title')} + + + {t('export.provisioning_desc')} + + + + + {t('devices.reboot_logs')} + + + {t('export.reboots_desc')} + + + + + {t('configurations.statistics')} + + + {t('export.statistics_desc')} + + + + + {t('common.status')} + + + {t('export.status_desc')} + + + + + )} +
+
+ ); +}; + +export default React.memo(ExportStatsModal); diff --git a/src/pages/Device/ViewConfigurationModal.tsx b/src/pages/Device/ViewConfigurationModal.tsx index 05371b3f..39a2cef4 100644 --- a/src/pages/Device/ViewConfigurationModal.tsx +++ b/src/pages/Device/ViewConfigurationModal.tsx @@ -16,7 +16,7 @@ import { useDisclosure, } from '@chakra-ui/react'; import { JsonViewer } from '@textea/json-viewer'; -import { Barcode } from '@phosphor-icons/react'; +import { Barcode, DownloadSimple } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; import { Modal } from 'components/Modals/Modal'; import { useGetDevice } from 'hooks/Network/Devices'; @@ -35,6 +35,17 @@ const ViewConfigurationModal = ({ serialNumber }: { serialNumber: string }) => { } }, [getDevice.data?.configuration]); + const handleDownload = () => { + const jsonString = JSON.stringify(getDevice.data?.configuration ?? {}, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${serialNumber}-configuration.json`; + link.click(); + URL.revokeObjectURL(url); + }; + const handleOpenClick = () => { getDevice.refetch(); onOpen(); @@ -58,6 +69,14 @@ const ViewConfigurationModal = ({ serialNumber }: { serialNumber: string }) => { + + } + onClick={handleDownload} + colorScheme="blue" + /> + } diff --git a/src/pages/Device/Wrapper.tsx b/src/pages/Device/Wrapper.tsx index 29decd8e..43512ecf 100644 --- a/src/pages/Device/Wrapper.tsx +++ b/src/pages/Device/Wrapper.tsx @@ -42,6 +42,7 @@ import FactoryResetModal from 'components/Modals/FactoryResetModal'; import { FirmwareUpgradeModal } from 'components/Modals/FirmwareUpgradeModal'; import { RebootModal } from 'components/Modals/RebootModal'; import ReEnrollModal from 'components/Modals/ReEnrollModal'; +import ExportStatsModal from 'components/Modals/ExportStatsModal'; import { useScriptModal } from 'components/Modals/ScriptModal/useScriptModal'; import ethernetConnected from './ethernetIconConnected.svg?react'; import ethernetDisconnected from './ethernetIconDisconnected.svg?react'; @@ -78,6 +79,7 @@ const DevicePageWrapper = ({ serialNumber }: Props) => { const traceModalProps = useDisclosure(); const rebootModalProps = useDisclosure(); const reEnrollModalProps = useDisclosure(); + const exportModalProps = useDisclosure(); const scriptModal = useScriptModal(); // Sticky-top styles const isCompact = breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md'; @@ -219,6 +221,7 @@ const DevicePageWrapper = ({ serialNumber }: Props) => { onOpenScriptModal={scriptModal.openModal} onOpenRebootModal={rebootModalProps.onOpen} onOpenReEnrollModal={reEnrollModalProps.onOpen} + onOpenExportModal={exportModalProps.onOpen} size="md" isCompact /> @@ -272,6 +275,7 @@ const DevicePageWrapper = ({ serialNumber }: Props) => { onOpenRebootModal={rebootModalProps.onOpen} onOpenScriptModal={scriptModal.openModal} onOpenReEnrollModal={reEnrollModalProps.onOpen} + onOpenExportModal={exportModalProps.onOpen} size="md" /> )} @@ -316,6 +320,7 @@ const DevicePageWrapper = ({ serialNumber }: Props) => { + {scriptModal.modal} { + const { t } = useTranslation(); + const toast = useToast(); + + const [duration, setDuration] = React.useState(1); + const [format, setFormat] = React.useState('json'); + const [isCollecting, setIsCollecting] = React.useState(false); + const [timeRemaining, setTimeRemaining] = React.useState(0); + const [startTime, setStartTime] = React.useState(null); + + const controllerLogs = useControllerStore((state) => state.allMessages); + const securityLogs = useSecurityStore((state) => state.allMessages); + const firmwareLogs = useFirmwareStore((state) => state.allMessages); + + const controllerConnected = useControllerStore((state) => state.isWebSocketOpen); + const securityConnected = useSecurityStore((state) => state.isWebSocketOpen); + const firmwareConnected = useFirmwareStore((state) => state.isWebSocketOpen); + + const timerRef = React.useRef(null); + const collectedLogsRef = React.useRef<{ + devices: typeof controllerLogs; + controller: typeof controllerLogs; + security: typeof securityLogs; + firmware: typeof firmwareLogs; + startTime: Date | null; + }>({ + devices: [], + controller: [], + security: [], + firmware: [], + startTime: null, + }); + + const formatLogEntry = ( + source: string, + msg: (typeof controllerLogs)[0] | (typeof securityLogs)[0] | (typeof firmwareLogs)[0], + ): LogEntry | null => { + if (msg.type !== 'NOTIFICATION') return null; + + const data = msg.data; + if (data.type === 'LOG' && data.log) { + return { + source, + timestamp: msg.timestamp.toISOString(), + type: 'LOG', + level: data.log.level, + thread: `${data.log.thread_id}-${data.log.thread_name}`, + message: typeof data.log.msg === 'string' ? data.log.msg : JSON.stringify(data.log.msg), + }; + } + if (data.type === 'DEVICE_CONNECTION' || data.type === 'DEVICE_DISCONNECTION') { + return { + source: 'Devices', + timestamp: msg.timestamp.toISOString(), + type: data.type, + serialNumber: data.serialNumber, + message: data.type === 'DEVICE_CONNECTION' ? 'Device connected' : 'Device disconnected', + }; + } + if (data.type === 'DEVICE_STATISTICS') { + return { + source: 'Devices', + timestamp: msg.timestamp.toISOString(), + type: 'DEVICE_STATISTICS', + serialNumber: data.serialNumber, + message: 'New statistics received', + }; + } + if (data.type === 'DEVICE_CONNECTIONS_STATISTICS') { + return { + source: 'Devices', + timestamp: msg.timestamp.toISOString(), + type: 'DEVICE_CONNECTIONS_STATISTICS', + message: 'Global connection statistics update', + rawData: JSON.stringify(data.statistics), + }; + } + + return null; + }; + + const buildExportData = React.useCallback(() => { + const logs = collectedLogsRef.current; + const allLogs: LogEntry[] = []; + + // Filter logs that arrived after startTime + const filterByTime = (arr: T[], start: Date | null): T[] => { + if (!start) return arr; + return arr.filter((item) => item.timestamp >= start); + }; + + // Process Device logs (connections, disconnections, statistics) + filterByTime(logs.devices, logs.startTime).forEach((msg) => { + const entry = formatLogEntry('Devices', msg); + if (entry) allLogs.push(entry); + }); + + // Process Controller logs + filterByTime(logs.controller, logs.startTime).forEach((msg) => { + if (msg.type === 'NOTIFICATION' && msg.data.type === 'LOG') { + const entry = formatLogEntry('Controller', msg); + if (entry) allLogs.push(entry); + } + }); + + // Process Security logs + filterByTime(logs.security, logs.startTime).forEach((msg) => { + const entry = formatLogEntry('Security', msg); + if (entry) allLogs.push(entry); + }); + + // Process Firmware logs + filterByTime(logs.firmware, logs.startTime).forEach((msg) => { + const entry = formatLogEntry('Firmware', msg); + if (entry) allLogs.push(entry); + }); + + // Sort by timestamp + allLogs.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + return allLogs; + }, []); + + const exportToJson = (data: LogEntry[]) => { + const exportObj = { + exportedAt: new Date().toISOString(), + collectionDuration: `${duration} minute(s)`, + totalLogs: data.length, + logs: data, + }; + const jsonString = JSON.stringify(exportObj, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `all-logs-export-${new Date().toISOString().replace(/[:.]/g, '-')}.json`; + link.click(); + URL.revokeObjectURL(url); + }; + + const exportToCsv = (data: LogEntry[]) => { + const headers = ['Source', 'Timestamp', 'Type', 'Level', 'Thread', 'Serial Number', 'Message', 'Raw Data']; + const rows = data.map((log) => [ + log.source, + log.timestamp, + log.type, + log.level || '', + log.thread || '', + log.serialNumber || '', + `"${(log.message || '').replace(/"/g, '""')}"`, + `"${(log.rawData || '').replace(/"/g, '""')}"`, + ]); + + const csvContent = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n'); + 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 = `all-logs-export-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`; + link.click(); + URL.revokeObjectURL(url); + }; + + const handleStartCollection = () => { + const now = new Date(); + setStartTime(now); + setIsCollecting(true); + setTimeRemaining(duration * 60); + + collectedLogsRef.current = { + devices: [], + controller: [], + security: [], + firmware: [], + startTime: now, + }; + + toast({ + id: 'collection-started', + title: t('logs.collection_started'), + description: t('logs.collection_started_desc', { minutes: duration }), + status: 'info', + duration: 3000, + isClosable: true, + position: 'top-right', + }); + }; + + const handleStopCollection = React.useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + + // Capture final state of logs + collectedLogsRef.current = { + devices: [...controllerLogs], + controller: [...controllerLogs], + security: [...securityLogs], + firmware: [...firmwareLogs], + startTime: collectedLogsRef.current.startTime, + }; + + const data = buildExportData(); + + if (data.length === 0) { + toast({ + id: 'no-logs', + title: t('common.warning'), + description: t('logs.no_logs_collected'), + status: 'warning', + duration: 5000, + isClosable: true, + position: 'top-right', + }); + } else { + if (format === 'json') { + exportToJson(data); + } else { + exportToCsv(data); + } + + toast({ + id: 'export-success', + title: t('common.success'), + description: t('logs.export_success', { count: data.length }), + status: 'success', + duration: 5000, + isClosable: true, + position: 'top-right', + }); + } + + setIsCollecting(false); + setTimeRemaining(0); + setStartTime(null); + }, [controllerLogs, securityLogs, firmwareLogs, format, duration, buildExportData, t, toast]); + + // Timer countdown + React.useEffect(() => { + if (isCollecting && timeRemaining > 0) { + timerRef.current = setInterval(() => { + setTimeRemaining((prev) => { + if (prev <= 1) { + handleStopCollection(); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + } + }; + } + return undefined; + }, [isCollecting, handleStopCollection]); + + // Cleanup on unmount + React.useEffect( + () => () => { + if (timerRef.current) { + clearInterval(timerRef.current); + } + }, + [], + ); + + // Warn user before leaving page or refreshing during collection + React.useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (isCollecting) { + e.preventDefault(); + e.returnValue = ''; + return ''; + } + return undefined; + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [isCollecting]); + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + const progress = duration * 60 > 0 ? ((duration * 60 - timeRemaining) / (duration * 60)) * 100 : 0; + + const connectedCount = [controllerConnected, securityConnected, firmwareConnected].filter(Boolean).length; + + return ( + + + + {t('logs.export_all_title')} + + + {isCollecting ? ( + + ) : ( + + )} + + + + {isCollecting ? ( +
+ + + + {formatTime(timeRemaining)} + + + + + {t('logs.collecting_logs')} + + + {t('logs.logs_will_export', { format: format.toUpperCase() })} + + + {t('logs.logs_collected')}: + + + {t('devices.title')}: {startTime ? controllerLogs.filter((m) => m.timestamp >= startTime).length : 0} + + + {t('simulation.controller')}: {startTime ? controllerLogs.filter((m) => m.timestamp >= startTime && m.data?.type === 'LOG').length : 0} + + + {t('logs.security')}: {startTime ? securityLogs.filter((m) => m.timestamp >= startTime).length : 0} + + + {t('logs.firmware')}: {startTime ? firmwareLogs.filter((m) => m.timestamp >= startTime).length : 0} + + + + + + {t('logs.stay_on_page_warning')} + +
+ ) : ( + + + {t('logs.websocket_status')} + + + + {t('simulation.controller')} + + ({t('devices.title')}, {t('simulation.controller')}) + + + + + {t('logs.security')} + + ({t('logs.security')}) + + + + + {t('logs.firmware')} + + ({t('logs.firmware')}) + + + + + {t('logs.connected_count', { count: connectedCount })} + + + + + {t('logs.collection_duration')} + + + + + {t('logs.export_format')} + setFormat(val as ExportFormat)}> + + JSON + CSV + + + + + + + {t('logs.stay_on_page_info')} + + + {connectedCount === 0 && ( + + {t('logs.no_websockets_connected')} + + )} + + )} +
+
+
+ ); +}; + +export default ExportAllLogsPage; diff --git a/src/router/routes.tsx b/src/router/routes.tsx index 3defd696..2d598d49 100644 --- a/src/router/routes.tsx +++ b/src/router/routes.tsx @@ -11,6 +11,7 @@ const AllDevicesPage = React.lazy(() => import('pages/Devices/ListCard')); const BlacklistPage = React.lazy(() => import('pages/Devices/Blacklist')); const ControllerLogsPage = React.lazy(() => import('pages/Notifications/GeneralLogs')); const DeviceLogsPage = React.lazy(() => import('pages/Notifications/DeviceLogs')); +const ExportAllLogsPage = React.lazy(() => import('pages/Notifications/ExportAll')); const FmsLogsPage = React.lazy(() => import('pages/Notifications/FmsLogs')); const SecLogsPage = React.lazy(() => import('pages/Notifications/SecLogs')); const FirmwarePage = React.lazy(() => import('pages/Firmware/List')); @@ -144,6 +145,13 @@ const routes: Route[] = [ navName: (t) => `${t('logs.firmware')} ${t('controller.devices.logs')}`, component: FmsLogsPage, }, + { + id: 'logs-export', + authorized: ['root', 'partner', 'admin', 'csr', 'system'], + path: '/logs/export', + name: 'logs.export_all', + component: ExportAllLogsPage, + }, ], }, {