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 && (
+
+ )}
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 ? (
+ } onClick={handleStopCollection}>
+ {t('logs.stop_and_export')}
+
+ ) : (
+ }
+ onClick={handleStartCollection}
+ isDisabled={connectedCount === 0}
+ >
+ {t('logs.start_collection')}
+
+ )}
+
+
+
+ {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,
+ },
],
},
{