Compare commits

...

21 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
Sebastian Rubina
c4aff418ed Merge pull request #233 from Telecominfraproject/WIFI-14521-set-correct-tag-for-main
Set correct tag for helm version
2025-08-05 13:06:11 -04:00
Carsten Schafer
dd5c894b03 Set correct tag for helm version
Signed-off-by: Carsten Schafer <Carsten.Schafer@kinarasystems.com>
2025-08-05 11:51:43 -04:00
Sebastian Rubina
c3256b93c7 Merge pull request #232 from Telecominfraproject/re-enroll-modal
Add device re-enrollment with confirmation modal
2025-07-14 15:50:59 -04:00
Sebastian Rubina
932f1f4a12 Change wording of translation.json.
Signed-off-by: Sebastian Rubina <sebastian.rubina@icloud.com>
2025-07-14 15:48:41 -04:00
Sebastian Rubina
db3cbb0b35 Add device re-enrollment with confirmation modal
- Add ReEnrollModal component for user confirmation before re-enrollment
  - Update DeviceActionDropdown to open modal instead of direct action
  - Add modal state management in Device Wrapper component
  - Add translation keys for re-enrollment UI with certificate renewal
  messaging
  - Remove direct useReEnroll hook usage in favor of modal pattern

Signed-off-by: Sebastian Rubina <sebastian.rubina@icloud.com>
2025-07-14 15:38:30 -04:00
Sebastian Rubina
c895274ebf Merge pull request #231 from Telecominfraproject/re-enroll-devices
Add device re-enrollment functionality
2025-07-14 13:31:58 -04:00
Sebastian Rubina
a3647bca08 Add device re-enrollment functionality
- Add re-enrollment API hook with mutation handling
  - Add re-enroll option to device action dropdown menu
  - Add translation keys for re-enrollment UI messages

Signed-off-by: Sebastian Rubina <sebastian.rubina@icloud.com>
2025-07-14 13:16:26 -04:00
Carsten Schafer
5fbf421d77 Merge pull request #230 from Telecominfraproject/display-certificate-issuer
Display certificate issuer
2025-07-02 13:49:24 -04:00
Carsten Schafer
e09b3ee5f4 Merge branch 'main' into display-certificate-issuer 2025-07-02 11:45:47 -04:00
Sebastian Rubina
855960559d Update package.json version 2025-07-02 11:33:03 -04:00
Sebastian Rubina
4cecfc6fc4 Display Certificate Issuer
- Add label on translation.json
- Add new key on DeviceStatus
- Add column label and data on Summary.txt

Signed-off-by: Sebastian Rubina <sebastian.rubina@icloud.com>
2025-07-02 09:49:49 -04:00
Sebastian Rubina
e62d1e4a98 Display Certificate Issuer
- Add label on translation.json
- Add new key on DeviceStatus
- Add column label and data on Summary.txt

Signed-off-by: Sebastian Rubina <sebastian.rubina@icloud.com>
2025-07-02 09:49:49 -04:00
Ivan Chvets
6dddba0848 fix: Version update - release 4.0.0
Signed-off-by: Ivan Chvets <ivan.chvets@kinarasystems.com>
Signed-off-by: Sebastian Rubina <sebastian.rubina@icloud.com>
2025-07-02 09:49:49 -04:00
i-chvets
30fffdfe52 Merge pull request #228 from Telecominfraproject/version_update
WIFI-14521: fix: Version update - release 4.0.0
2025-04-24 16:39:17 -04:00
14 changed files with 2328 additions and 1249 deletions

View File

@@ -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
View File

@@ -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": {

View File

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

View File

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

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

@@ -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;

View File

@@ -166,6 +166,7 @@ export type DeviceStatus = {
connected: boolean;
connectReason?: string;
certificateExpiryDate: number;
certificateIssuerName?: string;
connectionCompletionTime: number;
firmware: string;
ipAddress: string;

View 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',
});
},
});
};

View File

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

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

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

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