mirror of
https://github.com/Telecominfraproject/wlan-cloud-ucentralgw-ui.git
synced 2025-12-25 23:07:35 +00:00
Compare commits
21 Commits
version_up
...
v4.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ee5537381 | ||
|
|
06b9bc227d | ||
|
|
624b97d685 | ||
|
|
d07e83d000 | ||
|
|
0a671bbdab | ||
|
|
9ec7a25766 | ||
|
|
757404b682 | ||
|
|
c4aff418ed | ||
|
|
dd5c894b03 | ||
|
|
c3256b93c7 | ||
|
|
932f1f4a12 | ||
|
|
db3cbb0b35 | ||
|
|
c895274ebf | ||
|
|
a3647bca08 | ||
|
|
5fbf421d77 | ||
|
|
e09b3ee5f4 | ||
|
|
855960559d | ||
|
|
4cecfc6fc4 | ||
|
|
e62d1e4a98 | ||
|
|
6dddba0848 | ||
|
|
30fffdfe52 |
@@ -8,7 +8,7 @@ fullnameOverride: ""
|
||||
images:
|
||||
owgwui:
|
||||
repository: tip-tip-wlan-cloud-ucentral.jfrog.io/owgw-ui
|
||||
tag: v3.2.0
|
||||
tag: v4.2.0
|
||||
pullPolicy: Always
|
||||
|
||||
services:
|
||||
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ucentral-client",
|
||||
"version": "3.2.0",
|
||||
"version": "4.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ucentral-client",
|
||||
"version": "3.2.0",
|
||||
"version": "4.1.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@chakra-ui/anatomy": "^2.1.1",
|
||||
@@ -3540,7 +3540,7 @@
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.0",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -3548,7 +3548,7 @@
|
||||
},
|
||||
"node_modules/@jridgewell/set-array": {
|
||||
"version": "1.1.2",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -3556,7 +3556,7 @@
|
||||
},
|
||||
"node_modules/@jridgewell/source-map": {
|
||||
"version": "0.3.2",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.0",
|
||||
@@ -3565,7 +3565,7 @@
|
||||
},
|
||||
"node_modules/@jridgewell/source-map/node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.2",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/set-array": "^1.0.1",
|
||||
@@ -3578,12 +3578,12 @@
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.4.14",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.17",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "3.1.0",
|
||||
@@ -4374,7 +4374,7 @@
|
||||
"version": "18.15.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz",
|
||||
"integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@types/parse-json": {
|
||||
"version": "4.0.0",
|
||||
@@ -4419,7 +4419,7 @@
|
||||
"version": "18.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.11.tgz",
|
||||
"integrity": "sha512-O38bPbI2CWtgw/OoQoY+BRelw7uysmXbWvw3nLWO21H1HSh+GOlqPuXshJfjmpNlKiiSDG9cc1JZAaMmVdcTlw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
@@ -4753,7 +4753,7 @@
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.8.0",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -5174,7 +5174,7 @@
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/builtin-modules": {
|
||||
@@ -9781,7 +9781,7 @@
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.21",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
@@ -9790,7 +9790,7 @@
|
||||
},
|
||||
"node_modules/source-map-support/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -10080,7 +10080,7 @@
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.15.1",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.2",
|
||||
@@ -10097,7 +10097,7 @@
|
||||
},
|
||||
"node_modules/terser/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/text-table": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ucentral-client",
|
||||
"version": "4.0.0",
|
||||
"version": "4.1.0",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"main": "index.tsx",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,8 @@ interface Props {
|
||||
onOpenTelemetryModal: (serialNumber: string) => void;
|
||||
onOpenScriptModal: (device: GatewayDevice) => void;
|
||||
onOpenRebootModal: (serialNumber: string) => void;
|
||||
onOpenReEnrollModal?: (serialNumber: string) => void;
|
||||
onOpenExportModal?: (serialNumber: string) => void;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
isCompact?: boolean;
|
||||
}
|
||||
@@ -49,6 +51,8 @@ const DeviceActionDropdown = ({
|
||||
onOpenConfigureModal,
|
||||
onOpenScriptModal,
|
||||
onOpenRebootModal,
|
||||
onOpenReEnrollModal,
|
||||
onOpenExportModal,
|
||||
size,
|
||||
isCompact,
|
||||
}: Props) => {
|
||||
@@ -234,6 +238,11 @@ const DeviceActionDropdown = ({
|
||||
<MenuItem onClick={handleRebootClick} hidden={!isCompact}>
|
||||
{t('commands.reboot')}
|
||||
</MenuItem>
|
||||
{onOpenReEnrollModal && (
|
||||
<MenuItem onClick={() => onOpenReEnrollModal(device.serialNumber)}>
|
||||
{t('controller.devices.re_enroll')}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={handleOpenTelemetry}>{t('controller.telemetry.title')}</MenuItem>
|
||||
<MenuItem onClick={handleOpenScript}>{t('script.one')}</MenuItem>
|
||||
<MenuItem onClick={handleOpenTrace}>{t('controller.devices.trace')}</MenuItem>
|
||||
@@ -243,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);
|
||||
50
src/components/Modals/ReEnrollModal/index.tsx
Normal file
50
src/components/Modals/ReEnrollModal/index.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Center, Spinner, Alert, Button } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '../Modal';
|
||||
import { useReEnroll } from 'hooks/Network/ReEnroll';
|
||||
import { ModalProps } from 'models/Modal';
|
||||
|
||||
interface Props {
|
||||
modalProps: ModalProps;
|
||||
serialNumber: string;
|
||||
}
|
||||
|
||||
const ReEnrollModal = ({ modalProps: { isOpen, onClose }, serialNumber }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { mutate: reEnroll, isLoading } = useReEnroll({ serialNumber });
|
||||
|
||||
const submit = () => {
|
||||
reEnroll(
|
||||
{ serialNumber, when: 0 },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('controller.devices.re_enroll')}>
|
||||
{isLoading ? (
|
||||
<Center>
|
||||
<Spinner size="lg" />
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
<Alert colorScheme="blue" mb={6}>
|
||||
{t('controller.devices.re_enroll_warning', { serialNumber })}
|
||||
</Alert>
|
||||
<Center mb={6}>
|
||||
<Button size="lg" colorScheme="blue" onClick={submit} fontWeight="bold">
|
||||
{t('controller.devices.confirm_re_enroll', { serialNumber })}
|
||||
</Button>
|
||||
</Center>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReEnrollModal;
|
||||
@@ -166,6 +166,7 @@ export type DeviceStatus = {
|
||||
connected: boolean;
|
||||
connectReason?: string;
|
||||
certificateExpiryDate: number;
|
||||
certificateIssuerName?: string;
|
||||
connectionCompletionTime: number;
|
||||
firmware: string;
|
||||
ipAddress: string;
|
||||
|
||||
78
src/hooks/Network/ReEnroll.ts
Normal file
78
src/hooks/Network/ReEnroll.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { axiosGw } from 'constants/axiosInstances';
|
||||
|
||||
export type ReEnrollRequest = {
|
||||
serialNumber: string;
|
||||
when?: number;
|
||||
};
|
||||
|
||||
export type ReEnrollResponse = {
|
||||
UUID: string;
|
||||
command: 're-enroll' | 'reenroll';
|
||||
completed: number;
|
||||
custom: number;
|
||||
details: {
|
||||
serial: string;
|
||||
when: number;
|
||||
};
|
||||
errorCode: number;
|
||||
errorText: string;
|
||||
executed: number;
|
||||
executionTime: number;
|
||||
results: {
|
||||
serial: string;
|
||||
status: {
|
||||
error: number;
|
||||
resultCode: number;
|
||||
resultText: string;
|
||||
text: string;
|
||||
};
|
||||
};
|
||||
serialNumber: string;
|
||||
status: string;
|
||||
submitted: number;
|
||||
submittedBy: string;
|
||||
when: number;
|
||||
};
|
||||
|
||||
const reEnrollDevice = async ({ serialNumber, when = 0 }: ReEnrollRequest) =>
|
||||
axiosGw.post<ReEnrollResponse>(`device/${serialNumber}/reenroll`, {
|
||||
serial: serialNumber,
|
||||
when,
|
||||
});
|
||||
|
||||
export const useReEnroll = ({ serialNumber }: { serialNumber: string }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
|
||||
return useMutation(reEnrollDevice, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['commands', serialNumber]);
|
||||
queryClient.invalidateQueries(['device', serialNumber]);
|
||||
queryClient.invalidateQueries(['device-status', serialNumber]);
|
||||
toast({
|
||||
id: `re-enroll-success-${serialNumber}`,
|
||||
title: t('common.success'),
|
||||
description: t('controller.devices.re_enroll_initiated', { serialNumber }),
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
id: `re-enroll-error-${serialNumber}`,
|
||||
title: t('common.error'),
|
||||
description: error?.response?.data?.ErrorDescription || t('common.error'),
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -171,6 +171,12 @@ const DeviceSummary = ({ serialNumber }: Props) => {
|
||||
'-'
|
||||
)}
|
||||
</GridItem>
|
||||
<GridItem colSpan={1} alignContent="center" alignItems="center">
|
||||
<Heading size="sm">{t('devices.certificate_issuer')}:</Heading>
|
||||
</GridItem>
|
||||
<GridItem colSpan={1}>
|
||||
{getStatus.data?.certificateIssuerName ? getStatus.data.certificateIssuerName.split('CN=')[1] : '-'}
|
||||
</GridItem>
|
||||
<GridItem colSpan={1} alignContent="center" alignItems="center">
|
||||
<Heading size="sm">Connect Reason:</Heading>
|
||||
</GridItem>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ import { EventQueueModal } from 'components/Modals/EventQueueModal';
|
||||
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';
|
||||
@@ -76,6 +78,8 @@ const DevicePageWrapper = ({ serialNumber }: Props) => {
|
||||
const telemetryModalProps = useDisclosure();
|
||||
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';
|
||||
@@ -216,6 +220,8 @@ const DevicePageWrapper = ({ serialNumber }: Props) => {
|
||||
onOpenTelemetryModal={telemetryModalProps.onOpen}
|
||||
onOpenScriptModal={scriptModal.openModal}
|
||||
onOpenRebootModal={rebootModalProps.onOpen}
|
||||
onOpenReEnrollModal={reEnrollModalProps.onOpen}
|
||||
onOpenExportModal={exportModalProps.onOpen}
|
||||
size="md"
|
||||
isCompact
|
||||
/>
|
||||
@@ -268,6 +274,8 @@ const DevicePageWrapper = ({ serialNumber }: Props) => {
|
||||
onOpenTelemetryModal={telemetryModalProps.onOpen}
|
||||
onOpenRebootModal={rebootModalProps.onOpen}
|
||||
onOpenScriptModal={scriptModal.openModal}
|
||||
onOpenReEnrollModal={reEnrollModalProps.onOpen}
|
||||
onOpenExportModal={exportModalProps.onOpen}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
@@ -311,6 +319,8 @@ const DevicePageWrapper = ({ serialNumber }: Props) => {
|
||||
<ConfigureModal serialNumber={serialNumber} modalProps={configureModalProps} />
|
||||
<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