mirror of
https://github.com/Telecominfraproject/wlan-cloud-ucentralgw-ui.git
synced 2025-12-27 15:54:58 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ee5537381 | ||
|
|
06b9bc227d | ||
|
|
624b97d685 | ||
|
|
d07e83d000 | ||
|
|
0a671bbdab | ||
|
|
9ec7a25766 | ||
|
|
757404b682 |
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
381
src/components/Modals/ExportStatsModal/index.tsx
Normal file
381
src/components/Modals/ExportStatsModal/index.tsx
Normal 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);
|
||||
@@ -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} />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
469
src/pages/Notifications/ExportAll/index.tsx
Normal file
469
src/pages/Notifications/ExportAll/index.tsx
Normal 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;
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user