Compare commits

...

7 Commits

Author SHA1 Message Date
TIP Automation User
6ee5537381 Chg: update image tag in helm values to v4.2.0 2025-12-25 06:34:14 +00:00
TIP Automation User
06b9bc227d Chg: update image tag in helm values to v4.2.0-RC1 2025-12-12 23:56:50 +00:00
Sebastian Rubina
624b97d685 Merge pull request #234 from Telecominfraproject/OLS_SDK_4.2_011-stats-exports-in-json
Add Download Button
2025-12-09 17:51:18 -05:00
SebastianRubina
d07e83d000 Export all page.
Add export-all page to capture and export device, controller, security
and firmware logs.

Signed-off-by: SebastianRubina <sebastian.rubina@icloud.com>
2025-12-09 17:09:42 -05:00
SebastianRubina
0a671bbdab Add more export data to the new Export Modal
Signed-off-by: SebastianRubina <sebastian.rubina@icloud.com>
2025-12-09 17:08:39 -05:00
SebastianRubina
9ec7a25766 Add export stats button
- Add export stats action, modal, and functionality

Signed-off-by Sebastian Rubina <sebastian.rubina@icloud.com>

Signed-off-by: SebastianRubina <sebastian.rubina@icloud.com>
2025-12-08 11:19:22 -05:00
SebastianRubina
757404b682 Add Download Button
- Add download button on ViewConfigurationModal.tsx
- Add download logic on ViewConfigurationModal.tsx

Signed-off-by: Sebastian Rubina <sebastian.rubina@icloud.com>
Signed-off-by: SebastianRubina <sebastian.rubina@icloud.com>
2025-12-08 11:19:22 -05:00
8 changed files with 931 additions and 4 deletions

View File

@@ -8,7 +8,7 @@ fullnameOverride: ""
images:
owgwui:
repository: tip-tip-wlan-cloud-ucentral.jfrog.io/owgw-ui
tag: main
tag: v4.2.0
pullPolicy: Always
services:

View File

@@ -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",

View File

@@ -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 = ({
<MenuItem onClick={handleOpenScan} hidden={!isCompact || deviceType !== 'ap'}>
{t('commands.wifiscan')}
</MenuItem>
{onOpenExportModal && (
<MenuItem onClick={() => onOpenExportModal(device.serialNumber)}>
{t('export.title')}
</MenuItem>
)}
</MenuList>
</Portal>
</Menu>

View File

@@ -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<string[]>([
'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<string, unknown> = {
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 (
<Modal
isOpen={modalProps.isOpen}
onClose={modalProps.onClose}
title={t('export.title')}
topRightButtons={
<Button
colorScheme="blue"
leftIcon={<DownloadSimple size={20} />}
onClick={handleExport}
isLoading={isExporting}
isDisabled={selectedOptions.length === 0 || isLoading}
>
{t('common.download')}
</Button>
}
>
<Box>
{isLoading ? (
<Center my={8}>
<Spinner size="xl" />
</Center>
) : (
<Box>
<FormLabel>{t('export.select_data')}</FormLabel>
<Stack spacing={3} mt={2}>
<Box>
<Checkbox
colorScheme="pink"
isChecked={selectedOptions.includes('commands')}
onChange={onToggle('commands')}
>
{t('controller.devices.commands')}
</Checkbox>
<Text fontSize="xs" color="gray.500" ml={6}>
{t('export.commands_desc')}
</Text>
</Box>
<Box>
<Checkbox
colorScheme="purple"
isChecked={selectedOptions.includes('configuration')}
onChange={onToggle('configuration')}
>
{t('configurations.one')}
</Checkbox>
<Text fontSize="xs" color="gray.500" ml={6}>
{t('export.configuration_desc')}
</Text>
</Box>
<Box>
<Checkbox
colorScheme="red"
isChecked={selectedOptions.includes('crashes')}
onChange={onToggle('crashes')}
>
{t('devices.crash_logs')}
</Checkbox>
<Text fontSize="xs" color="gray.500" ml={6}>
{t('export.crashes_desc')}
</Text>
</Box>
<Box>
<Checkbox
colorScheme="blue"
isChecked={selectedOptions.includes('deviceInfo')}
onChange={onToggle('deviceInfo')}
>
{t('common.details')}
</Checkbox>
<Text fontSize="xs" color="gray.500" ml={6}>
{t('export.device_info_desc')}
</Text>
</Box>
<Box>
<Checkbox
colorScheme="orange"
isChecked={selectedOptions.includes('healthChecks')}
onChange={onToggle('healthChecks')}
>
{t('controller.devices.healthchecks')}
</Checkbox>
<Text fontSize="xs" color="gray.500" ml={6}>
{t('export.health_checks_desc')}
</Text>
</Box>
<Box>
<Checkbox
colorScheme="yellow"
isChecked={selectedOptions.includes('logs')}
onChange={onToggle('logs')}
>
{t('controller.devices.logs')}
</Checkbox>
<Text fontSize="xs" color="gray.500" ml={6}>
{t('export.logs_desc')}
</Text>
</Box>
<Box>
<Checkbox
colorScheme="cyan"
isChecked={selectedOptions.includes('provisioning')}
onChange={onToggle('provisioning')}
>
{t('controller.provisioning.title')}
</Checkbox>
<Text fontSize="xs" color="gray.500" ml={6}>
{t('export.provisioning_desc')}
</Text>
</Box>
<Box>
<Checkbox
colorScheme="gray"
isChecked={selectedOptions.includes('reboots')}
onChange={onToggle('reboots')}
>
{t('devices.reboot_logs')}
</Checkbox>
<Text fontSize="xs" color="gray.500" ml={6}>
{t('export.reboots_desc')}
</Text>
</Box>
<Box>
<Checkbox
colorScheme="teal"
isChecked={selectedOptions.includes('statistics')}
onChange={onToggle('statistics')}
>
{t('configurations.statistics')}
</Checkbox>
<Text fontSize="xs" color="gray.500" ml={6}>
{t('export.statistics_desc')}
</Text>
</Box>
<Box>
<Checkbox
colorScheme="green"
isChecked={selectedOptions.includes('status')}
onChange={onToggle('status')}
>
{t('common.status')}
</Checkbox>
<Text fontSize="xs" color="gray.500" ml={6}>
{t('export.status_desc')}
</Text>
</Box>
</Stack>
</Box>
)}
</Box>
</Modal>
);
};
export default React.memo(ExportStatsModal);

View File

@@ -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 }) => {
<Button onClick={onCopy} size="md" colorScheme="teal">
{hasCopied ? `${t('common.copied')}!` : t('common.copy')}
</Button>
<Tooltip label={t('common.download')} hasArrow>
<IconButton
aria-label={t('common.download')}
icon={<DownloadSimple size={20} />}
onClick={handleDownload}
colorScheme="blue"
/>
</Tooltip>
<RefreshButton onClick={getDevice.refetch} isFetching={getDevice.isFetching} />
</>
}

View File

@@ -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) => {
<TelemetryModal serialNumber={serialNumber} modalProps={telemetryModalProps} />
<RebootModal serialNumber={serialNumber} modalProps={rebootModalProps} />
<ReEnrollModal serialNumber={serialNumber} modalProps={reEnrollModalProps} />
<ExportStatsModal serialNumber={serialNumber} modalProps={exportModalProps} />
{scriptModal.modal}
<Box mt={isCompact ? '0px' : '68px'}>
<Masonry

View File

@@ -0,0 +1,469 @@
import * as React from 'react';
import {
Alert,
AlertIcon,
Box,
Button,
Center,
CircularProgress,
CircularProgressLabel,
FormLabel,
HStack,
Radio,
RadioGroup,
Select,
Stack,
Text,
useToast,
} from '@chakra-ui/react';
import { Play, Stop, Warning } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import { Card } from 'components/Containers/Card';
import { CardBody } from 'components/Containers/Card/CardBody';
import { CardHeader } from 'components/Containers/Card/CardHeader';
import { useControllerStore } from 'contexts/ControllerSocketProvider/useStore';
import { useSecurityStore } from 'contexts/SecuritySocketProvider/useStore';
import { useFirmwareStore } from 'contexts/FirmwareSocketProvider/useStore';
type ExportFormat = 'json' | 'csv';
type LogEntry = {
source: string;
timestamp: string;
type: string;
level?: string;
thread?: string;
message: string;
serialNumber?: string;
rawData?: string;
};
const ExportAllLogsPage = () => {
const { t } = useTranslation();
const toast = useToast();
const [duration, setDuration] = React.useState<number>(1);
const [format, setFormat] = React.useState<ExportFormat>('json');
const [isCollecting, setIsCollecting] = React.useState(false);
const [timeRemaining, setTimeRemaining] = React.useState<number>(0);
const [startTime, setStartTime] = React.useState<Date | null>(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<NodeJS.Timeout | null>(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 = <T extends { timestamp: Date }>(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 (
<Card>
<CardHeader>
<Text fontSize="xl" fontWeight="bold">
{t('logs.export_all_title')}
</Text>
<Box flex={1} />
{isCollecting ? (
<Button colorScheme="red" leftIcon={<Stop size={20} />} onClick={handleStopCollection}>
{t('logs.stop_and_export')}
</Button>
) : (
<Button
colorScheme="blue"
leftIcon={<Play size={20} />}
onClick={handleStartCollection}
isDisabled={connectedCount === 0}
>
{t('logs.start_collection')}
</Button>
)}
</CardHeader>
<CardBody>
<Box>
{isCollecting ? (
<Center flexDirection="column" py={8}>
<CircularProgress value={progress} size="150px" thickness="8px" color="blue.400">
<CircularProgressLabel>
<Text fontSize="2xl" fontWeight="bold">
{formatTime(timeRemaining)}
</Text>
</CircularProgressLabel>
</CircularProgress>
<Text mt={4} fontSize="lg">
{t('logs.collecting_logs')}
</Text>
<Text mt={2} color="gray.500">
{t('logs.logs_will_export', { format: format.toUpperCase() })}
</Text>
<Box mt={4}>
<Text fontSize="sm" color="gray.500" mb={2}>{t('logs.logs_collected')}:</Text>
<HStack spacing={4}>
<Text fontSize="sm">
{t('devices.title')}: {startTime ? controllerLogs.filter((m) => m.timestamp >= startTime).length : 0}
</Text>
<Text fontSize="sm">
{t('simulation.controller')}: {startTime ? controllerLogs.filter((m) => m.timestamp >= startTime && m.data?.type === 'LOG').length : 0}
</Text>
<Text fontSize="sm">
{t('logs.security')}: {startTime ? securityLogs.filter((m) => m.timestamp >= startTime).length : 0}
</Text>
<Text fontSize="sm">
{t('logs.firmware')}: {startTime ? firmwareLogs.filter((m) => m.timestamp >= startTime).length : 0}
</Text>
</HStack>
</Box>
<Alert status="warning" mt={6} maxW="400px" borderRadius="md">
<AlertIcon />
<Text fontSize="sm">{t('logs.stay_on_page_warning')}</Text>
</Alert>
</Center>
) : (
<Stack spacing={6} maxW="400px">
<Box>
<FormLabel>{t('logs.websocket_status')}</FormLabel>
<Stack spacing={2}>
<HStack>
<Box w={3} h={3} borderRadius="full" bg={controllerConnected ? 'green.500' : 'red.500'} />
<Text fontSize="sm" fontWeight="medium">{t('simulation.controller')}</Text>
<Text fontSize="xs" color="gray.500">
({t('devices.title')}, {t('simulation.controller')})
</Text>
</HStack>
<HStack>
<Box w={3} h={3} borderRadius="full" bg={securityConnected ? 'green.500' : 'red.500'} />
<Text fontSize="sm" fontWeight="medium">{t('logs.security')}</Text>
<Text fontSize="xs" color="gray.500">
({t('logs.security')})
</Text>
</HStack>
<HStack>
<Box w={3} h={3} borderRadius="full" bg={firmwareConnected ? 'green.500' : 'red.500'} />
<Text fontSize="sm" fontWeight="medium">{t('logs.firmware')}</Text>
<Text fontSize="xs" color="gray.500">
({t('logs.firmware')})
</Text>
</HStack>
</Stack>
<Text fontSize="xs" color="gray.500" mt={2}>
{t('logs.connected_count', { count: connectedCount })}
</Text>
</Box>
<Box>
<FormLabel>{t('logs.collection_duration')}</FormLabel>
<Select value={duration} onChange={(e) => setDuration(Number(e.target.value))} w="200px">
<option value={1}>1 {t('common.minute')}</option>
<option value={2}>2 {t('common.minutes')}</option>
<option value={5}>5 {t('common.minutes')}</option>
<option value={10}>10 {t('common.minutes')}</option>
<option value={15}>15 {t('common.minutes')}</option>
<option value={30}>30 {t('common.minutes')}</option>
</Select>
</Box>
<Box>
<FormLabel>{t('logs.export_format')}</FormLabel>
<RadioGroup value={format} onChange={(val) => setFormat(val as ExportFormat)}>
<Stack direction="row" spacing={4}>
<Radio value="json">JSON</Radio>
<Radio value="csv">CSV</Radio>
</Stack>
</RadioGroup>
</Box>
<Alert status="info" borderRadius="md">
<AlertIcon />
<Text fontSize="sm">{t('logs.stay_on_page_info')}</Text>
</Alert>
{connectedCount === 0 && (
<Text color="red.500" fontSize="sm">
{t('logs.no_websockets_connected')}
</Text>
)}
</Stack>
)}
</Box>
</CardBody>
</Card>
);
};
export default ExportAllLogsPage;

View File

@@ -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,
},
],
},
{