diff --git a/src/__tests__/services/connectedClientUtils.test.ts b/src/__tests__/services/connectedClientUtils.test.ts new file mode 100644 index 00000000..3aa78a51 --- /dev/null +++ b/src/__tests__/services/connectedClientUtils.test.ts @@ -0,0 +1,140 @@ +import { groupConnections } from '@app/utils/connectedClientUtils'; + +describe('groupConnections', () => { + it('groups connections based on specific properties', () => { + const connections: ConnectedClients[] = [ + { + id: 70, + 'server-node-name': 'infinispan-4-e2e', + name: 'flower', + created: '2023-05-18T14:54:37.882566188Z', + principal: 'admin', + 'local-address': '/127.0.0.1:11222', + 'remote-address': '/127.0.0.1:58230', + 'protocol-version': 'RESP3', + 'client-library': 'test', + 'client-version': null, + 'ssl-application-protocol': 'http/1.1', + 'ssl-cipher-suite': 'TLS_AES_256_GCM_SHA384', + 'ssl-protocol': 'TLSv1.3' + }, + { + id: 71, + 'server-node-name': 'infinispan-4-e2e', + name: 'car', + created: '2023-07-05T06:50:45.960705319Z', + principal: 'admin', + 'local-address': '/127.0.0.1:11222', + 'remote-address': '/127.0.0.1:58230', + 'protocol-version': 'RESP3', + 'client-library': 'test', + 'client-version': null, + 'ssl-application-protocol': 'http/1.1', + 'ssl-cipher-suite': 'TLS_AES_256_GCM_SHA384', + 'ssl-protocol': 'TLSv1.3' + }, + { + id: 72, + 'server-node-name': 'infinispan-4-e2e', + name: 'school', + created: '2023-07-05T10:00:54.472232620Z', + principal: 'observer', + 'local-address': '/127.0.0.1:11222', + 'remote-address': '/127.0.0.1:58230', + 'protocol-version': 'RESP3', + 'client-library': 'test', + 'client-version': null, + 'ssl-application-protocol': 'http/1.1', + 'ssl-cipher-suite': 'TLS_AES_256_GCM_SHA384', + 'ssl-protocol': 'TLSv1.3' + }, + { + id: 73, + 'server-node-name': 'infinispan-4-e2e', + name: 'office', + created: '2023-07-05T10:00:53.824309416Z', + principal: 'admin', + 'local-address': '/127.0.0.1:11222', + 'remote-address': '/127.0.0.1:58231', + 'protocol-version': 'RESP3', + 'client-library': 'test', + 'client-version': null, + 'ssl-application-protocol': 'http/1.1', + 'ssl-cipher-suite': 'TLS_AES_256_GCM_SHA384', + 'ssl-protocol': 'TLSv1.3' + }, + { + id: 74, + 'server-node-name': 'infinispan-4-e2e', + name: 'bike', + created: '2023-07-05T10:00:53.824533716Z', + principal: 'observer', + 'local-address': '/127.0.0.1:11222', + 'remote-address': '/127.0.0.1:58230', + 'protocol-version': 'RESP3', + 'client-library': 'test', + 'client-version': null, + 'ssl-application-protocol': 'http/1.1', + 'ssl-cipher-suite': 'TLS_AES_256_GCM_SHA384', + 'ssl-protocol': 'TLSv1.3' + } + ]; + + // Expected output + const expectedGroupedConnections: ConnectedClients[] = [ + { + id: 70, + 'server-node-name': 'infinispan-4-e2e', + name: 'flower', + created: '2023-05-18T14:54:37.882566188Z', + principal: 'admin', + 'local-address': '/127.0.0.1:11222', + 'remote-address': '/127.0.0.1:58230', + 'protocol-version': 'RESP3', + 'client-library': 'test', + 'client-version': null, + 'ssl-application-protocol': 'http/1.1', + 'ssl-cipher-suite': 'TLS_AES_256_GCM_SHA384', + 'ssl-protocol': 'TLSv1.3', + count: 2 + }, + { + id: 72, + 'server-node-name': 'infinispan-4-e2e', + name: 'school', + created: '2023-07-05T10:00:54.472232620Z', + principal: 'observer', + 'local-address': '/127.0.0.1:11222', + 'remote-address': '/127.0.0.1:58230', + 'protocol-version': 'RESP3', + 'client-library': 'test', + 'client-version': null, + 'ssl-application-protocol': 'http/1.1', + 'ssl-cipher-suite': 'TLS_AES_256_GCM_SHA384', + 'ssl-protocol': 'TLSv1.3', + count: 2 + }, + { + id: 73, + 'server-node-name': 'infinispan-4-e2e', + name: 'office', + created: '2023-07-05T10:00:53.824309416Z', + principal: 'admin', + 'local-address': '/127.0.0.1:11222', + 'remote-address': '/127.0.0.1:58231', + 'protocol-version': 'RESP3', + 'client-library': 'test', + 'client-version': null, + 'ssl-application-protocol': 'http/1.1', + 'ssl-cipher-suite': 'TLS_AES_256_GCM_SHA384', + 'ssl-protocol': 'TLSv1.3', + count: 1 + } + ]; + + const result = groupConnections(connections); + + // Assert the result + expect(result).toEqual(expectedGroupedConnections); + }); +}); diff --git a/src/__tests__/services/formatAge.test.ts b/src/__tests__/services/formatAge.test.ts new file mode 100644 index 00000000..9059cd5a --- /dev/null +++ b/src/__tests__/services/formatAge.test.ts @@ -0,0 +1,44 @@ +import { formatAge } from '@app/utils/formatAge'; + +describe('formatAge', () => { + test('should return the formatted age in hours, minutes, and seconds', () => { + const currentTimestamp = new Date().getTime(); + + // Mock the current date to be 5 hours, 30 minutes, and 45 seconds ahead of the input date + const inputTimestamp = currentTimestamp - (5 * 60 * 60 * 1000 + 30 * 60 * 1000 + 45 * 1000); + const expectedOutput = '5 hr 30 min 45 s'; + + expect(formatAge(new Date(inputTimestamp).toISOString())).toEqual(expectedOutput); + }); + + test('should return only the formatted age in minutes and seconds', () => { + const currentTimestamp = new Date().getTime(); + + // Mock the current date to be 3 minutes and 20 seconds ahead of the input date + const inputTimestamp = currentTimestamp - (3 * 60 * 1000 + 20 * 1000); + const expectedOutput = '3 min 20 s'; + + expect(formatAge(new Date(inputTimestamp).toISOString())).toEqual(expectedOutput); + }); + + test('should return only the formatted age in seconds', () => { + const currentTimestamp = new Date().getTime(); + + // Mock the current date to be 15 seconds ahead of the input date + const inputTimestamp = currentTimestamp - 15 * 1000; + const expectedOutput = '15 s'; + + expect(formatAge(new Date(inputTimestamp).toISOString())).toEqual(expectedOutput); + }); + + test('should return an empty string for future dates', () => { + const currentTimestamp = new Date().getTime(); + + // Mock the input date to be 10 minutes ahead of the current date + const inputTimestamp = currentTimestamp + 10 * 60 * 1000; + console.log('inputTimestamp', inputTimestamp); + const expectedOutput = ''; + + expect(formatAge(new Date(inputTimestamp).toISOString())).toEqual(expectedOutput); + }); +}); diff --git a/src/app/ConnectedClients/ConnectedClients.tsx b/src/app/ConnectedClients/ConnectedClients.tsx new file mode 100644 index 00000000..39779e81 --- /dev/null +++ b/src/app/ConnectedClients/ConnectedClients.tsx @@ -0,0 +1,363 @@ +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { + Bullseye, + Card, + CardBody, + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateVariant, + Icon, + SearchInput, + PageSection, + PageSectionVariants, + Pagination, + Popover, + Spinner, + Label, + Text, + TextContent, + TextVariants, + Title, + Toolbar, + ToolbarFilter, + ToolbarContent, + ToolbarItem, + ToolbarItemVariant, + ToolbarGroup +} from '@patternfly/react-core'; +import { CubesIcon, SearchIcon, InfoCircleIcon } from '@patternfly/react-icons'; +import { TableComposable, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; +import { TableErrorState } from '@app/Common/TableErrorState'; +import { useTranslation } from 'react-i18next'; +import { useFetchConnectedClients } from '@app/services/serverHook'; +import { formatAge } from '@app/utils/formatAge'; +import { onSearch } from '@app/utils/searchFilter'; + +const ConnectedClients: React.FunctionComponent = () => { + const { t } = useTranslation(); + const brandname = t('brandname.brandname'); + const { connectedClients, error, loading } = useFetchConnectedClients(); + const [filteredConnections, setFilteredConnections] = useState([]); + const [rows, setRows] = useState([]); + const [connectionPagination, setConnectionPagination] = useState({ + page: 1, + perPage: 10 + }); + const [searchValue, setSearchValue] = useState(''); + + useEffect(() => { + if (connectedClients) { + setFilteredConnections(connectedClients); + } + }, [connectedClients, loading, error]); + + useEffect(() => { + if (filteredConnections) { + const initSlice = (connectionPagination.page - 1) * connectionPagination.perPage; + const updateRows = filteredConnections.slice(initSlice, initSlice + connectionPagination.perPage); + updateRows.length > 0 ? setRows(updateRows) : setRows([]); + } + }, [filteredConnections, connectionPagination]); + + useEffect(() => { + setFilteredConnections(connectedClients.filter((client) => onSearch(searchValue, client['server-node-name']))); + }, [searchValue]); + + const columnNames = { + nodeName: t('connected-clients.node-name'), + principal: t('connected-clients.principal'), + clientLibrary: t('connected-clients.client-library'), + clientAddress: t('connected-clients.client-address'), + clientVersion: t('connected-clients.client-version'), + moreInfo: t('connected-clients.more-info') + }; + + const onSetPage = (_event, pageNumber) => { + setConnectionPagination({ + ...connectionPagination, + page: pageNumber + }); + }; + + const onPerPageSelect = (_event, perPage) => { + setConnectionPagination({ + page: 1, + perPage: perPage + }); + }; + + const emptyPage = ( + + + + {t('connected-clients.no-connections')} + + {t('connected-clients.no-connections-body')} + + ); + + const buildSearch = ( + + + setSearchValue(value)} + onClear={() => setSearchValue('')} + /> + + + ); + + const pagination = ( + + ); + + const displayPrincipal = (row) => { + if (row.principal == null) { + return ( + + {t('connected-clients.null')} + + ); + } + return row.principal; + }; + + const displayClientAddress = (row) => { + if (row['remote-address'] == null) { + return ( + + {t('connected-clients.null')} + + ); + } + + return ; + }; + + const displayClientVersion = (row) => { + if (row['client-version'] == null) { + return ( + + {t('connected-clients.null')} + + ); + } + + return ( + + ); + }; + + const displayClientLibrary = (row) => { + if (row['client-library'] === null) { + return ( + + {t('connected-clients.null')} + + ); + } + + return row['client-library']; + }; + + const displayMoreInfo = (row: ConnectedClients) => { + const description = ( + + + {t('connected-clients.id')} + {row.id} + + + {t('connected-clients.created')} + {formatAge(row.created)} + + + {t('connected-clients.ssl-application-protocol')} + + {row['ssl-application-protocol'] ? ( + row['ssl-application-protocol'] + ) : ( + + {t('connected-clients.null')} + + )} + + + + {t('connected-clients.ssl-cipher-suite')} + + {row['ssl-cipher-suite'] ? ( + row['ssl-cipher-suite'] + ) : ( + + {t('connected-clients.null')} + + )} + + + + {t('connected-clients.ssl-protocol')} + + {row['ssl-protocol'] ? ( + row['ssl-protocol'] + ) : ( + + {t('connected-clients.null')} + + )} + + + + {t('connected-clients.local-address')} + + + + + + ); + + return ( + + + + ); + }; + + const buildConnectedClients = () => { + if (loading && !error) { + return ( + + + + + + ); + } + + if (error) { + return ( + + + + + + ); + } + + return ( + + {connectedClients.length === 0 ? ( + emptyPage + ) : ( + + + + + {buildSearch} + {pagination} + + + + + + + {columnNames.nodeName} + + + {columnNames.principal} + + {columnNames.clientLibrary} + + {columnNames.clientAddress} + + + {columnNames.clientVersion} + + + + + + {filteredConnections.length == 0 ? ( + + + + + + + {t('connected-clients.no-filtered-connections')} + + {t('connected-clients.no-filtered-connections-body')} + + + + + ) : ( + rows.map((row) => { + return ( + + {row['server-node-name']} + {displayPrincipal(row)} + {displayClientLibrary(row)} + {displayClientAddress(row)} + {displayClientVersion(row)} + {displayMoreInfo(row)} + + ); + }) + )} + + + + {pagination} + + + + )} + + ); + }; + + return ( + + + + {t('connected-clients.title')} + {t('connected-clients.connected-clients-description')} + + + {buildConnectedClients()} + + ); +}; + +export { ConnectedClients }; diff --git a/src/app/assets/languages/en.json b/src/app/assets/languages/en.json index abb6cb43..633245f6 100644 --- a/src/app/assets/languages/en.json +++ b/src/app/assets/languages/en.json @@ -742,5 +742,26 @@ "empty-cluster": "This cluster is empty", "no-cluster-title": "No cluster members", "no-cluster-body": "Add nodes to create a cluster." + }, + "connected-clients": { + "title": "Connected clients", + "connected-clients-description": "Connected clients description", + "no-connections": "No connected clients", + "no-connections-body": "No connected clients body", + "no-filtered-connections": "No filtered connected clients", + "no-filtered-connections-body": "No filtered connected clients body", + "node-name": "Server node", + "principal": "Principal", + "client-library": "Client library", + "client-address": "Client address", + "client-version": "Client version", + "id": "id", + "created": "Created", + "ssl-application-protocol": "ssl-application-protocol", + "ssl-cipher-suite": "ssl-cipher-suite", + "ssl-protocol": "ssl-protocol", + "local-address": "Local address", + "null": "N/A", + "more-info": "More info" } } diff --git a/src/app/routes.tsx b/src/app/routes.tsx index b6748b31..0a006bd4 100644 --- a/src/app/routes.tsx +++ b/src/app/routes.tsx @@ -14,6 +14,7 @@ import { useDocumentTitle } from '@app/utils/useDocumentTitle'; import { IndexManagement } from '@app/IndexManagement/IndexManagement'; import { XSiteCache } from '@app/XSite/XSiteCache'; import { DetailCachePage } from '@app/Caches/DetailCachePage'; +import { ConnectedClients } from './ConnectedClients/ConnectedClients'; let routeFocusTimer: number; @@ -145,6 +146,14 @@ const routes: IAppRoute[] = [ path: '/cache/:cacheName', title: 'Cache', menu: false + }, + { + component: ConnectedClients, + exact: true, + label: 'Connected Clients', + path: '/connected-clients', + title: 'Connected Clients', + menu: true } ]; diff --git a/src/app/services/serverHook.ts b/src/app/services/serverHook.ts index 35ffcf44..2a98555a 100644 --- a/src/app/services/serverHook.ts +++ b/src/app/services/serverHook.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { ConsoleServices } from '@services/ConsoleServices'; +import { groupConnections } from '@app/utils/connectedClientUtils'; export function useFetchVersion() { const [version, setVersion] = useState(''); @@ -28,3 +29,27 @@ export function useFetchVersion() { error }; } + +export function useFetchConnectedClients() { + const [connectedClients, setConnectedClients] = useState([]); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (loading) { + ConsoleServices.server() + .getConnectedClients() + .then((either) => { + if (either.isRight()) { + // Grouping similar connections + setConnectedClients(groupConnections(either.value)); + } else { + setError(either.value.message); + } + }) + .finally(() => setLoading(false)); + } + }, [loading]); + + return { connectedClients, error, loading }; +} diff --git a/src/app/utils/connectedClientUtils.ts b/src/app/utils/connectedClientUtils.ts new file mode 100644 index 00000000..3fe2a8d0 --- /dev/null +++ b/src/app/utils/connectedClientUtils.ts @@ -0,0 +1,32 @@ +/** + * Utility function to group connections + * having same "principal", "server-node-name", remote-address and 'client-version' + * @param connections + */ + +export function groupConnections(connections: ConnectedClients[]): ConnectedClients[] { + const groupedArray: ConnectedClients[] = []; + + // Loop through each connection + for (const connection of connections) { + // Find an existing group that matches the connection's criteria + const foundGroup = groupedArray.find( + (group) => + group['server-node-name'] === connection['server-node-name'] && + group.principal === connection.principal && + group['remote-address'] === connection['remote-address'] && + group['client-version'] === connection['client-version'] + ); + + // If a matching group is found, increment the count + if (foundGroup) { + foundGroup.count = (foundGroup.count || 0) + 1; + } else { + // If no matching group is found, create a new connection with count = 1 + const newConnection = { ...connection, count: 1 }; + groupedArray.push(newConnection); + } + } + + return groupedArray; +} diff --git a/src/app/utils/formatAge.ts b/src/app/utils/formatAge.ts new file mode 100644 index 00000000..2b29e725 --- /dev/null +++ b/src/app/utils/formatAge.ts @@ -0,0 +1,36 @@ +/** + * Formats a given date string into a friendly age format. + * The friendly age format includes hours, minutes, and seconds. + * @param dateString The date string to format. + * @returns The formatted age string. + */ +export function formatAge(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + + if (date > now) { + return ''; // Return an empty string for future dates + } + + const diff = Math.abs(now.getTime() - date.getTime()); + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff / (1000 * 60)) % 60); + const seconds = Math.floor((diff / 1000) % 60); + + let formattedAge = ''; + + if (hours > 0) { + formattedAge += hours + ' hr'; + } + + if (minutes > 0) { + formattedAge += ' ' + minutes + ' min'; + } + + if (seconds > 0) { + formattedAge += ' ' + seconds + ' s'; + } + + return formattedAge.trim(); +} diff --git a/src/services/serverService.ts b/src/services/serverService.ts index 79454bc5..8452c611 100644 --- a/src/services/serverService.ts +++ b/src/services/serverService.ts @@ -48,4 +48,11 @@ export class ServerService { }); }); } + + /** + * Get connected clients + */ + public async getConnectedClients(): Promise> { + return this.utils.get(this.endpoint + '/connections', (data) => data); + } } diff --git a/src/types/InfinispanTypes.ts b/src/types/InfinispanTypes.ts index 21670a53..bf483664 100644 --- a/src/types/InfinispanTypes.ts +++ b/src/types/InfinispanTypes.ts @@ -454,3 +454,20 @@ interface ClusterDistribution { memory_available: number; memory_used: number; } + +interface ConnectedClients { + id: number; + 'server-node-name': string; + name?: string; + created: string; + principal: string; + 'local-address': string; + 'remote-address': string; + 'protocol-version': string; + 'client-library': string; + 'client-version'?: string | null; + 'ssl-application-protocol'?: string; + 'ssl-cipher-suite'?: string; + 'ssl-protocol'?: string; + count?: number; +}