diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz new file mode 100644 index 000000000..aa806fef6 Binary files /dev/null and b/.yarn/install-state.gz differ diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 000000000..3186f3f07 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/package.json b/package.json index 2b8bb78c1..f89226367 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "electron-window-state": "5.0.3", "fs-extra": "11.1.0", "lodash": "4.17.21", + "react-json-view": "^1.21.3", "semver": "7.7.2", "shell-env": "3.0.1", "unzipper": "0.12.3", diff --git a/src/__mocks__/fileService.js b/src/__mocks__/fileService.js new file mode 100644 index 000000000..ef553bb11 --- /dev/null +++ b/src/__mocks__/fileService.js @@ -0,0 +1,9 @@ +// __mocks__/fileService.js + +module.exports = { + fileService: { + existsSync: jest.fn(() => false), + readFileSync: jest.fn(() => '{}'), + copyFileSync: jest.fn(), + }, +}; \ No newline at end of file diff --git a/src/components/common/NetworkMonitoringButton.spec.tsx b/src/components/common/NetworkMonitoringButton.spec.tsx new file mode 100644 index 000000000..c4bc77199 --- /dev/null +++ b/src/components/common/NetworkMonitoringButton.spec.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Status } from 'shared/types'; +import { getNetwork, renderWithProviders } from 'utils/tests'; +import NetworkMonitoringButton from './NetworkMonitoringButton'; +import { fireEvent } from '@testing-library/react'; +import { initChartFromNetwork } from 'utils/chart'; + +describe('NetworkMonitoringButton', () => { + let unmount: () => void; + + const renderComponent = () => { + const network = getNetwork(1, 'test network', Status.Started); + const initialState = { + network: { + networks: [network], + }, + designer: { + activeId: 1, + allCharts: { + 1: initChartFromNetwork(network), + }, + }, + modals: { + networkMonitoring: { visible: false }, + }, + }; + const cmp = ; + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + return result; + }; + + afterEach(() => unmount()); + + it('should render the button', () => { + const { getByRole } = renderComponent(); + const btn = getByRole('monitor-network'); + expect(btn).toBeInTheDocument(); + }); + + it('should open the modal on click', () => { + const { getByRole, store } = renderComponent(); + const btn = getByRole('monitor-network'); + fireEvent.click(btn); + expect(store.getState().modals.networkMonitoring.visible).toBe(true); + }); +}); diff --git a/src/components/common/NetworkMonitoringButton.tsx b/src/components/common/NetworkMonitoringButton.tsx new file mode 100644 index 000000000..05afb48c9 --- /dev/null +++ b/src/components/common/NetworkMonitoringButton.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { EyeOutlined } from '@ant-design/icons'; +import styled from '@emotion/styled'; +import { Button, Tooltip } from 'antd'; +import { usePrefixedTranslation } from 'hooks'; +import { useStoreActions } from 'store'; + +const Styled = { + Button: styled(Button)` + margin-left: 8px; + `, +}; + +interface Props { + networkId: number; +} + +const NetworkMonitoringButton: React.FC = ({ networkId }) => { + const { l } = usePrefixedTranslation('cmps.network.NetworkActions'); + const { showNetworkMonitoring } = useStoreActions(s => s.modals); + + const showModal = async () => { + await showNetworkMonitoring({ networkId }); + }; + + return ( + + + + + + ); +}; + +export default NetworkMonitoringButton; diff --git a/src/components/common/NetworkMonitoringModal.spec.tsx b/src/components/common/NetworkMonitoringModal.spec.tsx new file mode 100644 index 000000000..8d0af6205 --- /dev/null +++ b/src/components/common/NetworkMonitoringModal.spec.tsx @@ -0,0 +1,718 @@ +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders, getNetwork } from 'utils/tests'; +import NetworkMonitoringModal from './NetworkMonitoringModal'; +import { initChartFromNetwork } from 'utils/chart'; + +// Example packet data (replace with actual structure from output.json if needed) +const examplePacketData = [ + { + _source: { + layers: { + frame: { + 'frame.time': '2025-06-26 12:00:00', + 'frame.protocols': 'tcp', + 'frame.len': 60, + }, + ip: { 'ip.src': '10.0.0.1', 'ip.dst': '10.0.0.2' }, + }, + }, + }, +]; + +describe('NetworkMonitoringModal (clean scenarios)', () => { + let unmount: () => void; + + afterEach(() => unmount && unmount()); + + it('shows "no data available" when there is no data and no filter', async () => { + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + console.log(examplePacketData); + expect( + result.getByText('No packet data available. Start monitoring to capture packets.'), + ).toBeInTheDocument(); + }); + + it('shows summary when there is data and no filter', async () => { + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + expect(result.getByText('Source IP:')).toBeInTheDocument(); + }); + + it('shows "no packets match" when there is data but filter excludes all', async () => { + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ; + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + expect( + result.getByText('No packets match the current filter criteria.'), + ).toBeInTheDocument(); + }); + + it('shows summary when there is data and filter matches', async () => { + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + expect(result.getByText('UTC Time:')).toBeInTheDocument(); + }); + + it('shows loading state when loading', async () => { + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + expect(result.getByText('Loading captured packet data...')).toBeInTheDocument(); + }); + + it('renders config mode and allows switching back', async () => { + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + fireEvent.click(result.getByText('Configuration')); + expect(result.getByText('Back to Monitoring')).toBeInTheDocument(); + fireEvent.click(result.getByText('Back to Monitoring')); + expect(result.getByText('Configuration')).toBeInTheDocument(); + }); + + it('handles start/stop monitoring button', async () => { + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + global.fetch = jest.fn().mockResolvedValue({ ok: true, type: 'basic' }); + fireEvent.click(result.getByText('Start Monitoring')); + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + // Simulate rerender with running true + // ...existing code for rerender if needed... + }); + + it('shows null if network is missing', async () => { + const initialState = { + network: { networks: [] }, + designer: { activeId: 999, allCharts: {} }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ; + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + expect(result.container.firstChild).toBeNull(); + }); + + it('shows error alert when config save fails with 422', async () => { + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + fireEvent.click(result.getByText('Configuration')); + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 422, + json: async () => ({ error: 'tshark: some error' }), + }); + fireEvent.click(result.getByText('Save Configuration')); + await waitFor(() => + expect( + result.getByText('Configuration saved but filtering failed:'), + ).toBeInTheDocument(), + ); + }); + + it('shows alert when store monitoring is clicked with no PCAP file', async () => { + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + window.alert = jest.fn(); + fireEvent.click(result.getByText('Store Monitoring PCAP')); + expect(window.alert).toHaveBeenCalled(); + }); + + it('handles clear config after save', async () => { + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + fireEvent.click(result.getByText('Configuration')); + global.fetch = jest + .fn() + .mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); + fireEvent.click(result.getByText('Save Configuration')); + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }); + fireEvent.click(result.getByText('Clear Configuration')); + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + }); +}); + +// Additional tests to improve coverage +describe('NetworkMonitoringModal (additional coverage)', () => { + let unmount: () => void; + let originalRequire: any; + + beforeEach(() => { + // Store the original require function if it exists + originalRequire = (window as any).require; + }); + + afterEach(() => { + unmount && unmount(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + + // Restore original require or delete it + if (originalRequire) { + (window as any).require = originalRequire; + } else { + delete (window as any).require; + } + }); + + it('handles clear config error gracefully', async () => { + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + + fireEvent.click(result.getByText('Configuration')); + + // First save a configuration to make Clear Configuration button appear + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }); + fireEvent.click(result.getByText('Save Configuration')); + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + + // Now mock fetch to fail for clear config + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + fireEvent.click(result.getByText('Clear Configuration')); + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + + // Config should still be cleared even if fetch fails + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to clean configuration:', + 'Internal Server Error', + ); + + consoleSpy.mockRestore(); + }); + + it('handles clear config network error gracefully', async () => { + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + + fireEvent.click(result.getByText('Configuration')); + + // First save a configuration to make Clear Configuration button appear + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 }); + fireEvent.click(result.getByText('Save Configuration')); + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + + // Now mock fetch to throw an error for clear config + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + fireEvent.click(result.getByText('Clear Configuration')); + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Error during configuration clean:', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it('handles config field changes for all supported fields', async () => { + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + + fireEvent.click(result.getByText('Configuration')); + + // Test various config field changes + const ipInput = result.getByPlaceholderText('Enter IP Address'); + fireEvent.change(ipInput, { target: { value: '10.0.0.1' } }); + + const sourceIpInput = result.getByPlaceholderText('Enter Source IP'); + fireEvent.change(sourceIpInput, { target: { value: '10.0.0.2' } }); + + const destinationIpInput = result.getByPlaceholderText('Enter Destination IP'); + fireEvent.change(destinationIpInput, { target: { value: '10.0.0.3' } }); + + // Test protocol selection (should show port fields when TCP/UDP selected) + const protocolSelect = + result.container.querySelector('[data-testid="select-protocol"]') || + result.container.querySelector('.ant-select-selector'); + if (protocolSelect) { + fireEvent.mouseDown(protocolSelect); + await waitFor(() => { + const tcpOption = result.getByText('TCP'); + fireEvent.click(tcpOption); + }); + } + + await waitFor(() => { + expect(result.getByPlaceholderText('Enter Port')).toBeInTheDocument(); + }); + }); + + it('shows more fields when protocol is selected', async () => { + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + + fireEvent.click(result.getByText('Configuration')); + + // Count visible form fields initially (non-hidden ones) + const initialVisibleFields = result.container.querySelectorAll( + '.ant-form-item:not(.ant-form-item-hidden)', + ); + const initialCount = initialVisibleFields.length; + + // Select TCP protocol + const protocolSelect = result.container.querySelector('.ant-select-selector'); + if (protocolSelect) { + fireEvent.mouseDown(protocolSelect); + await waitFor(() => { + const tcpOption = result.getByText('tcp'); + fireEvent.click(tcpOption); + }); + + // After selecting TCP, there should be more visible fields + await waitFor(() => { + const newVisibleFields = result.container.querySelectorAll( + '.ant-form-item:not(.ant-form-item-hidden)', + ); + expect(newVisibleFields.length).toBeGreaterThan(initialCount); + }); + } + }); + + it('handles pagination controls', async () => { + // Create more data to test pagination + const morePacketData = Array.from({ length: 150 }, (_, i) => ({ + _source: { + layers: { + frame: { + 'frame.time': `2025-06-26 12:${String(i).padStart(2, '0')}:00`, + 'frame.protocols': 'tcp', + 'frame.len': 60 + i, + }, + ip: { + 'ip.src': `10.0.0.${(i % 254) + 1}`, + 'ip.dst': `10.0.1.${(i % 254) + 1}`, + }, + }, + }, + })); + + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + + // Should show total packet count + expect(result.getByText('Total packets: 150')).toBeInTheDocument(); + + // Test page size selector - need to find the correct one among multiple selectors + const pageSizeSelectors = result.container.querySelectorAll('.ant-select-selector'); + const pageSizeSelect = Array.from(pageSizeSelectors).find(selector => + selector + .closest('.ant-select') + ?.previousElementSibling?.textContent?.includes('Page Size'), + ); + + if (pageSizeSelect) { + fireEvent.mouseDown(pageSizeSelect); + await waitFor(() => { + const option50 = result.getByText('50'); + fireEvent.click(option50); + }); + } + + // Test pagination navigation + const paginationButtons = result.container.querySelectorAll('.ant-pagination-item'); + if (paginationButtons.length > 0) { + fireEvent.click(paginationButtons[1]); // Click page 2 + } + }); + + it('shows fallback alert when no electron dialog available', async () => { + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + + // Mock file system without electron + const mockFs = { + existsSync: jest.fn().mockReturnValue(false), // No PCAP file exists + }; + + const mockRequire = jest.fn((module: string) => { + if (module === 'fs') return mockFs; + return {}; + }); + + // Ensure no electron environment + if ((window as any).require) { + delete (window as any).require; + } + Object.defineProperty(window, 'require', { + value: mockRequire, + configurable: true, + writable: true, + }); + + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + + window.alert = jest.fn(); + fireEvent.click(result.getByText('Store Monitoring PCAP')); + + expect(window.alert).toHaveBeenCalledWith( + 'No PCAP file found. There is no filtered.pcap or merged.pcap to store.', + ); + }); + + it('handles packet expansion toggle', async () => { + // Mock file system + const mockFs = { + existsSync: jest.fn().mockReturnValue(false), + }; + + const mockRequire = jest.fn((module: string) => { + if (module === 'fs') return mockFs; + return {}; + }); + + if ((window as any).require) { + delete (window as any).require; + } + Object.defineProperty(window, 'require', { + value: mockRequire, + configurable: true, + writable: true, + }); + + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + + // Look for expand/collapse functionality in packet display + const expandableElements = result.container.querySelectorAll( + 'div[style*="cursor: pointer"]', + ); + + if (expandableElements.length > 0) { + fireEvent.click(expandableElements[0]); + + // Check if the element contains expanded indicator + expect(expandableElements[0].textContent).toContain('▼'); + } + }); + + it('handles save configuration with different error status codes', async () => { + // Mock file system + const mockFs = { + existsSync: jest.fn().mockReturnValue(false), + }; + + const mockRequire = jest.fn((module: string) => { + if (module === 'fs') return mockFs; + return {}; + }); + + if ((window as any).require) { + delete (window as any).require; + } + Object.defineProperty(window, 'require', { + value: mockRequire, + configurable: true, + writable: true, + }); + + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + + fireEvent.click(result.getByText('Configuration')); + + // Test 500 error + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => ({ error: 'Internal server error' }), + }); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + fireEvent.click(result.getByText('Save Configuration')); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to save configuration:', + 'Internal Server Error', + ); + }); + + consoleSpy.mockRestore(); + }); + + it('handles network errors during save configuration', async () => { + // Mock file system + const mockFs = { + existsSync: jest.fn().mockReturnValue(false), + }; + + const mockRequire = jest.fn((module: string) => { + if (module === 'fs') return mockFs; + return {}; + }); + + if ((window as any).require) { + delete (window as any).require; + } + Object.defineProperty(window, 'require', { + value: mockRequire, + configurable: true, + writable: true, + }); + + const network = getNetwork(1, 'test network'); + const initialState = { + network: { networks: [network] }, + designer: { + activeId: network.id, + allCharts: { [network.id]: initChartFromNetwork(network) }, + }, + modals: { networkMonitoring: { visible: true } }, + }; + const cmp = ( + + ); + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + + fireEvent.click(result.getByText('Configuration')); + + // Mock fetch to throw network error + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + fireEvent.click(result.getByText('Save Configuration')); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Error during configuration save:', + expect.any(Error), + ); + }); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/components/common/NetworkMonitoringModal.tsx b/src/components/common/NetworkMonitoringModal.tsx new file mode 100644 index 000000000..df4da8927 --- /dev/null +++ b/src/components/common/NetworkMonitoringModal.tsx @@ -0,0 +1,793 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { Modal, Typography, Button, Input, Form, Select, Alert, Pagination } from 'antd'; +import ReactJson from 'react-json-view'; +import { useStoreActions, useStoreState } from 'store'; +import { networksPath } from 'utils/config'; +import usePrefixedTranslation from 'hooks/usePrefixedTranslation'; +import { fileService } from 'utils/fileService'; + +// Remove getElectronDeps and all direct window.require usage + +const { Text, Title } = Typography; + +interface NetworkMonitoringModalProps { + networkId: number; + testLoading?: boolean; + testJsonData?: any; + testIsRunning?: boolean; // <-- add this for testing +} + +// Helper to get the correct JSON file path +const getJsonFilePath = (networkId: number, configExists: boolean) => { + const base = `${networksPath}/${networkId}/volumes/shared_data`; + return configExists ? `${base}/filtered.json` : `${base}/output.json`; +}; + +// Helper to get the correct PCAP file path and name +const getPcapFileInfo = (networkId: number, configExists: boolean) => { + const base = `${networksPath}/${networkId}/volumes/shared_data`; + if (configExists) { + const pcapPath = `${base}/filtered.pcap`; + if (fileService.existsSync(pcapPath)) + return { path: pcapPath, name: 'filtered.pcap' }; + } + const pcapPath = `${base}/merged.pcap`; + if (fileService.existsSync(pcapPath)) return { path: pcapPath, name: 'merged.pcap' }; + return null; +}; + +const fetchJsonData = async ( + setJsonData: React.Dispatch>, + setLoading: React.Dispatch>, + networkId: number, + configExists: boolean, + isMountedRef?: React.MutableRefObject, +) => { + setLoading(true); + const jsonFilePath = getJsonFilePath(networkId, configExists); + if (fileService.existsSync(jsonFilePath)) { + const fileContent = fileService.readFileSync(jsonFilePath, 'utf-8'); + let parsed; + try { + parsed = JSON.parse(fileContent); + } catch { + parsed = null; + } + if (!isMountedRef || isMountedRef.current) { + setJsonData(parsed); + setLoading(false); + } + } else { + if (!isMountedRef || isMountedRef.current) { + setJsonData(null); + setLoading(false); + } + } +}; + +const configFilePath = (networkId: number) => { + return `${networksPath}/${networkId}/volumes/shared_data/config.json`; +}; + +const checkConfigExists = (networkId: number) => { + return fileService.existsSync(configFilePath(networkId)); +}; + +const loadConfigFromFile = (networkId: number) => { + try { + const filePath = configFilePath(networkId); + if (fileService.existsSync(filePath)) { + const fileContent = fileService.readFileSync(filePath, 'utf-8'); + return JSON.parse(fileContent); + } + } catch (err) { + console.error('Failed to load config.json:', err); + } + return null; +}; + +const packetTypeOptions = ['tcp', 'udp', 'icmp', 'arp']; // Predefined packet types + +const renderJsonData = ( + data: any, + expandedState: boolean[], + toggleExpanded: (index: number) => void, + l: (key: string) => string, +) => { + if (!Array.isArray(data)) + return {l('monitoringNoDataAvailable')}; + + return ( +
+ {data.map((item: any, index: number) => { + const layers = item?._source?.layers || {}; + const summary = { + utcTime: layers?.frame?.['frame.time'] || 'N/A', + srcIp: layers?.ip?.['ip.src'] || 'N/A', + dstIp: layers?.ip?.['ip.dst'] || 'N/A', + messageType: layers?.frame?.['frame.protocols']?.split(':').pop() || 'N/A', + packetSize: layers?.frame?.['frame.len'] || 'N/A', // Add packet size + }; + + return ( +
+
toggleExpanded(index)} + > + {expandedState[index] ? '\u25bc' : '\u25b6'}{' '} + {l('monitoringSummaryUtcTime')}{' '} + + {summary.utcTime} + + , {l('monitoringSummarySrcIp')}{' '} + + {summary.srcIp} + + , {l('monitoringSummaryDstIp')}{' '} + + {summary.dstIp} + + ,{' '} + + {l('monitoringSummaryMessageType')} + {' '} + + {summary.messageType} + + ,{' '} + {l('monitoringSummaryPacketSize')}{' '} + + {summary.packetSize} + +
+ {expandedState[index] && ( +
+ +
+ )} +
+ ); + })} +
+ ); +}; + +const extractTsharkError = (msg: string): string => { + // Find the part after "tshark:" + const tsharkIdx = msg.indexOf('tshark:'); + if (tsharkIdx === -1) return msg; + let error = msg.slice(tsharkIdx + 7).trim(); + // Remove anything after a line that starts with "(" (the filter expression) + const filterIdx = error.search(/\(.*\)\s*\^/); + if (filterIdx !== -1) { + error = error.slice(0, filterIdx).trim(); + } + // Remove trailing lines after a newline if present + const newlineIdx = error.indexOf('\n'); + if (newlineIdx !== -1) { + error = error.slice(0, newlineIdx).trim(); + } + return error; +}; + +// Helper function to check monitoring status from backend +const checkMonitoringStatus = async (networkId: number): Promise => { + try { + const port = `39${networkId.toString().padStart(3, '0')}`; + const response = await fetch(`http://localhost:${port}/status`, { method: 'GET' }); + if (response.ok) { + const data = await response.json(); + return data.isMonitoringActive || false; + } + } catch (error) { + console.error('Error checking monitoring status:', error); + } + return false; +}; + +const NetworkMonitoringModal: React.FC = ({ + networkId, + testLoading, + testJsonData, + testIsRunning, +}) => { + const { l } = usePrefixedTranslation('cmps.common.NetworkMonitoringModal'); + const { visible } = useStoreState(s => s.modals.networkMonitoring); + const { hideNetworkMonitoring } = useStoreActions(s => s.modals); + const network = useStoreState(s => { + try { + return s.network.networkById?.(networkId); + } catch { + return undefined; + } + }); + + const [jsonData, setJsonData] = useState(null); + const [loading, setLoading] = useState(false); + const [isRunning, setIsRunning] = useState(false); // Track start/stop state + const [isConfigMode, setIsConfigMode] = useState(false); // Track if in configuration mode + const [config, setConfig] = useState({ + ip: '', + port: '', + protocol: '', // use protocol only + sourceIp: '', + destinationIp: '', + sourcePort: '', + destinationPort: '', + packetSizeMin: '', + packetSizeMax: '', + timeRange: '', + tcpFlags: '', + payloadContent: '', + macAddress: '', + }); // Store configuration fields + const [expandedState, setExpandedState] = useState([]); + const [configExists, setConfigExists] = useState(false); + const [isBusy, setIsBusy] = useState(false); // Unified busy state + const [configErrorMsg, setConfigErrorMsg] = useState(null); + + // Track which PCAP file is being used + const [pcapFileName, setPcapFileName] = useState(null); + + // Ref to track the previous value of isConfigMode + const prevConfigMode = useRef(false); + + // Ref to track if component is mounted + const isMountedRef = useRef(true); + const networkStatus = network?.status; + + useEffect(() => { + if (!network && visible) { + hideNetworkMonitoring(); + } + }, [network, visible, hideNetworkMonitoring]); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + // Always check config and load files together when modal is opened + useEffect(() => { + if (visible) { + const configNowExists = checkConfigExists(networkId); + setConfigExists(configNowExists); + fetchJsonData(setJsonData, setLoading, networkId, configNowExists, isMountedRef); + const pcapInfo = getPcapFileInfo(networkId, configNowExists); + setPcapFileName(pcapInfo ? pcapInfo.name : null); + // Check the current monitoring status from backend + checkMonitoringStatus(networkId).then(actualStatus => { + setIsRunning(actualStatus); + }); + } + // Optionally, clear data when modal closes + if (!visible) { + setJsonData(null); + setExpandedState([]); + } + }, [visible, networkId]); + + useEffect(() => { + if (Array.isArray(jsonData)) { + setExpandedState(new Array(jsonData.length).fill(false)); + setPage(1); // Reset to first page when data changes + } + }, [jsonData]); + + useEffect(() => { + // Only load config from file when entering config mode + if (isConfigMode && !prevConfigMode.current) { + if (configExists) { + const loaded = loadConfigFromFile(networkId); + if (loaded) setConfig({ ...config, ...loaded }); + } else { + setConfig({ + ip: '', + port: '', + protocol: '', + sourceIp: '', + destinationIp: '', + sourcePort: '', + destinationPort: '', + packetSizeMin: '', + packetSizeMax: '', + timeRange: '', + tcpFlags: '', + payloadContent: '', + macAddress: '', + }); + } + } + prevConfigMode.current = isConfigMode; + }, [isConfigMode, configExists, networkId]); + + // Sync isRunning with network status: if network is stopped, monitoring must be stopped + useEffect(() => { + if (networkStatus === 3 /* Status.Stopped */ && isRunning) { + setIsRunning(false); + } + }, [networkStatus, isRunning]); + + const handleStartStop = async () => { + setIsBusy(true); + const port = `39${networkId.toString().padStart(3, '0')}`; // Dynamically calculate port + const url = isRunning + ? `http://localhost:${port}/stop` // Stop command + : `http://localhost:${port}/start`; // Start command + + try { + const response = await fetch(url, { method: 'GET', mode: 'no-cors' }); // Set mode to 'no-cors' + if (response.ok || response.type === 'opaque') { + console.log('State toggled successfully'); + if (isRunning) { + // Stop mode: Reload JSON data + await fetchJsonData(setJsonData, setLoading, networkId, configExists); + } else { + // Start mode: Clear JSON data + setJsonData(null); + } + setIsRunning(!isRunning); // Toggle state on success + } else { + console.error('Failed to toggle state:', response.statusText); + } + } catch (error) { + console.error('Error during request:', error); + } finally { + setIsBusy(false); + } + }; + + const handleSaveConfig = async () => { + setIsBusy(true); + setConfigErrorMsg(null); + const port = `39${networkId.toString().padStart(3, '0')}`; + const url = `http://localhost:${port}/config`; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + + if (response.status === 422) { + const data = await response.json(); + let errorMsg = data?.error || 'Configuration saved but filtering failed'; + errorMsg = extractTsharkError(errorMsg); + setConfigErrorMsg(errorMsg); + setConfigExists(true); + fetchJsonData(setJsonData, setLoading, networkId, false, isMountedRef); + } else if (response.ok) { + setConfigErrorMsg(null); + setConfigExists(true); + fetchJsonData(setJsonData, setLoading, networkId, true, isMountedRef); + } else { + console.error('Failed to save configuration:', response.statusText); + } + } catch (error) { + console.error('Error during configuration save:', error); + } finally { + setIsBusy(false); + } + }; + + const handleClearConfig = async () => { + setIsBusy(true); + const port = `39${networkId.toString().padStart(3, '0')}`; + const url = `http://localhost:${port}/cleanConf`; + try { + const response = await fetch(url, { method: 'GET' }); + if (response.ok) { + console.log('Configuration and filtered files cleaned.'); + setConfigExists(false); + // Reload JSON data after config is cleared + fetchJsonData(setJsonData, setLoading, networkId, false, isMountedRef); + } else { + console.error('Failed to clean configuration:', response.statusText); + } + } catch (error) { + console.error('Error during configuration clean:', error); + } finally { + // Always clear the form fields, regardless of fetch result + setConfig({ + ip: '', + port: '', + protocol: '', + sourceIp: '', + destinationIp: '', + sourcePort: '', + destinationPort: '', + packetSizeMin: '', + packetSizeMax: '', + timeRange: '', + tcpFlags: '', + payloadContent: '', + macAddress: '', + }); + setIsBusy(false); + } + }; + + const handleConfigChange = (field: string, value: string) => { + setConfig(prev => ({ ...prev, [field]: value })); + }; + + // Helper: get selected types as lowercase array from protocol + const selectedTypes = config.protocol + ? config.protocol.split(',').map(t => t.trim().toLowerCase()) + : []; + + const isTcpSelected = selectedTypes.includes('tcp'); + const isUdpSelected = selectedTypes.includes('udp'); + const isPortRelevant = isTcpSelected || isUdpSelected; + + const toggleExpanded = (index: number) => { + setExpandedState(prevState => { + const newState = [...prevState]; + newState[index] = !prevState[index]; + return newState; + }); + }; + + // Store Monitoring handler + const handleStoreMonitoring = async () => { + // Dialog abstraction for Electron, fallback to alert in browser/tests + const dialog = + typeof window !== 'undefined' && window.require + ? window.require('electron').remote + ? window.require('electron').remote.dialog + : window.require('electron').dialog + : null; + + const pcapInfo = getPcapFileInfo(networkId, configExists); + if (!pcapInfo) { + if (dialog) { + dialog.showErrorBox( + 'No PCAP file found', + 'There is no filtered.pcap or merged.pcap to store.', + ); + } else { + alert('No PCAP file found. There is no filtered.pcap or merged.pcap to store.'); + } + return; + } + if (dialog) { + const result = await dialog.showSaveDialog({ + title: 'Save Monitoring PCAP', + defaultPath: pcapInfo.name, + filters: [{ name: 'PCAP Files', extensions: ['pcap'] }], + }); + if (!result.canceled && result.filePath) { + try { + fileService.copyFileSync(pcapInfo.path, result.filePath); + dialog.showMessageBox({ + message: 'PCAP file saved successfully.', + buttons: ['OK'], + }); + } catch (err) { + dialog.showErrorBox('Error', 'Failed to save the PCAP file.'); + } + } + } else { + // Fallback: just alert in browser/tests + alert('PCAP file save dialog is not available in this environment.'); + } + }; + + // Pagination state + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(100); + + // Pagination logic for JSON data + const paginatedJsonData = Array.isArray(jsonData) + ? jsonData.slice((page - 1) * pageSize, page * pageSize) + : jsonData; + + // Use test overrides if provided + const effectiveLoading = typeof testLoading === 'boolean' ? testLoading : loading; + const effectiveJsonData = typeof testJsonData !== 'undefined' ? testJsonData : jsonData; + const effectiveIsRunning = + typeof testIsRunning === 'boolean' ? testIsRunning : isRunning; + const paginatedEffectiveJsonData = Array.isArray(effectiveJsonData) + ? effectiveJsonData.slice((page - 1) * pageSize, page * pageSize) + : paginatedJsonData; + + // Place the guard here, after all hooks + if (!network) { + return null; + } + + return ( + hideNetworkMonitoring()} + footer={null} + destroyOnClose + width={'80%'} + bodyStyle={{ + maxHeight: '80vh', + overflowY: 'auto', + }} + > + {isConfigMode ? l('configTitle') : l('monitoringTitle')} + + {isConfigMode + ? l('configDescription') + : pcapFileName + ? l('monitoringDescription', { pcapFileName }) + : l('monitoringDefaultDescription')} + +
+ {!isConfigMode && ( + <> + + + + )} + + {isConfigMode && ( + <> + + {configExists && ( + + )} + + )} +
+
+ {isConfigMode ? ( + <> + {configErrorMsg && ( + + )} +
+ + handleConfigChange('ip', e.target.value)} + placeholder={l('configFieldsIpPlaceholder')} + /> + + + + handleConfigChange('sourceIp', e.target.value)} + placeholder={l('configFieldsSourceIpPlaceholder')} + /> + + + handleConfigChange('destinationIp', e.target.value)} + placeholder={l('configFieldsDestinationIpPlaceholder')} + /> + + + + + handleConfigChange('packetSizeMin', e.target.value)} + placeholder={l('configFieldsPacketSizeMinPlaceholder')} + /> + + + handleConfigChange('packetSizeMax', e.target.value)} + placeholder={l('configFieldsPacketSizeMaxPlaceholder')} + /> + + + handleConfigChange('timeRange', e.target.value)} + placeholder={l('configFieldsTimeRangePlaceholder')} + /> + + + + + handleConfigChange('macAddress', e.target.value)} + placeholder={l('configFieldsMacAddressPlaceholder')} + /> + +
+ + ) : effectiveLoading ? ( + {l('monitoringLoading')} + ) : Array.isArray(effectiveJsonData) ? ( + effectiveJsonData.length === 0 ? ( + + ) : ( + <> +
+ {l('monitoringPageSize')}: +