diff --git a/javascript/grid-ui/src/components/Node/Node.tsx b/javascript/grid-ui/src/components/Node/Node.tsx index 79b2ced05f2a9..294474e4faa23 100644 --- a/javascript/grid-ui/src/components/Node/Node.tsx +++ b/javascript/grid-ui/src/components/Node/Node.tsx @@ -15,15 +15,92 @@ // specific language governing permissions and limitations // under the License. -import { Box, Card, CardContent, Grid, Typography } from '@mui/material' -import React from 'react' +import { Box, Card, CardContent, Dialog, DialogActions, DialogContent, DialogTitle, Grid, IconButton, Typography, Button, keyframes, styled } from '@mui/material' +import React, { useState, useRef } from 'react' +import { Videocam as VideocamIcon } from '@mui/icons-material' import NodeDetailsDialog from './NodeDetailsDialog' import NodeLoad from './NodeLoad' import Stereotypes from './Stereotypes' import OsLogo from '../common/OsLogo' +import LiveView from '../LiveView/LiveView' + +const pulse = keyframes` + 0% { + box-shadow: 0 0 0 0 rgba(25, 118, 210, 0.7); + transform: scale(1); + } + 50% { + box-shadow: 0 0 0 5px rgba(25, 118, 210, 0); + transform: scale(1.05); + } + 100% { + box-shadow: 0 0 0 0 rgba(25, 118, 210, 0); + transform: scale(1); + } +` + +const LiveIconButton = styled(IconButton)(({ theme }) => ({ + marginLeft: theme.spacing(1), + position: 'relative', + '&::after': { + content: '""', + position: 'absolute', + width: '100%', + height: '100%', + borderRadius: '50%', + animation: `${pulse} 2s infinite`, + zIndex: 0 + } +})) + +function getVncUrl(session, origin) { + try { + const parsed = JSON.parse(session.capabilities) + let vnc = parsed['se:vnc'] ?? '' + if (vnc.length > 0) { + try { + const url = new URL(origin) + const vncUrl = new URL(vnc) + url.pathname = vncUrl.pathname + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + return url.href + } catch (error) { + console.log(error) + return '' + } + } + return '' + } catch (e) { + return '' + } +} function Node (props) { - const { node } = props + const { node, sessions = [], origin } = props + const [liveViewSessionId, setLiveViewSessionId] = useState('') + const liveViewRef = useRef<{ disconnect: () => void }>(null) + + const vncSession = sessions.find(session => { + try { + const capabilities = JSON.parse(session.capabilities) + return capabilities['se:vnc'] !== undefined && capabilities['se:vnc'] !== '' + } catch (e) { + return false + } + }) + + const handleLiveViewIconClick = () => { + if (vncSession) { + setLiveViewSessionId(vncSession.id) + } + } + + const handleDialogClose = () => { + if (liveViewRef.current) { + liveViewRef.current.disconnect() + } + setLiveViewSessionId('') + } const getCardStyle = (status: string) => ({ height: '100%', flexGrow: 1, @@ -32,6 +109,7 @@ function Node (props) { }) return ( + <> + + {vncSession && ( + + + + )} + @@ -70,6 +155,39 @@ function Node (props) { + {vncSession && liveViewSessionId && ( + + + + + Node Session Live View + + {node.uri} + + + + + + + + + + )} + ) } diff --git a/javascript/grid-ui/src/screens/Overview/Overview.tsx b/javascript/grid-ui/src/screens/Overview/Overview.tsx index 0ccfdae7940ea..bdd62600206e3 100644 --- a/javascript/grid-ui/src/screens/Overview/Overview.tsx +++ b/javascript/grid-ui/src/screens/Overview/Overview.tsx @@ -41,12 +41,18 @@ import browserVersion from '../../util/browser-version' import Capabilities from '../../models/capabilities' import { GridConfig } from '../../config' import { NODES_QUERY } from '../../graphql/nodes' +import { GRID_SESSIONS_QUERY } from '../../graphql/sessions' function Overview (): JSX.Element { const { loading, error, data } = useQuery(NODES_QUERY, { pollInterval: GridConfig.status.xhrPollingIntervalMillis, fetchPolicy: 'network-only' }) + + const { data: sessionsData } = useQuery(GRID_SESSIONS_QUERY, { + pollInterval: GridConfig.status.xhrPollingIntervalMillis, + fetchPolicy: 'network-only' + }) function compareSlotStereotypes(a: NodeInfo, b: NodeInfo, attribute: string): number { const joinA = a.slotStereotypes.length === 1 @@ -217,7 +223,13 @@ function Overview (): JSX.Element { flexDirection: 'column' }} > - + session.nodeId === node.id + ) || []} + origin={window.location.origin} + /> ) diff --git a/javascript/grid-ui/src/tests/components/Node.test.tsx b/javascript/grid-ui/src/tests/components/Node.test.tsx index 7f6f3fd5096d8..a114626f3692a 100644 --- a/javascript/grid-ui/src/tests/components/Node.test.tsx +++ b/javascript/grid-ui/src/tests/components/Node.test.tsx @@ -20,8 +20,21 @@ import Node from '../../components/Node/Node' import NodeInfo from '../../models/node-info' import OsInfo from '../../models/os-info' import StereotypeInfo from '../../models/stereotype-info' -import { render, screen } from '@testing-library/react' +import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import '@testing-library/jest-dom' + +jest.mock('../../components/LiveView/LiveView', () => { + return { + __esModule: true, + default: React.forwardRef((props: { url: string, scaleViewport?: boolean, onClose?: () => void }, ref) => { + React.useImperativeHandle(ref, () => ({ + disconnect: jest.fn() + })) + return
LiveView Mock
+ }) + } +}) const osInfo: OsInfo = { name: 'Mac OS X', @@ -49,24 +62,135 @@ const node: NodeInfo = { slotStereotypes: [slotStereotype] } -it('renders basic node information', () => { - render() - expect(screen.getByText(node.uri)).toBeInTheDocument() - expect( - screen.getByText(`Sessions: ${node.sessionCount}`)).toBeInTheDocument() - expect(screen.getByText( - `Max. Concurrency: ${node.maxSession}`)).toBeInTheDocument() -}) +const sessionWithVnc = { + id: 'session-with-vnc', + capabilities: JSON.stringify({ + 'browserName': 'chrome', + 'browserVersion': '88.0', + 'se:vnc': 'ws://192.168.1.7:5900/websockify' + }), + nodeId: node.id +} + +const sessionWithoutVnc = { + id: 'session-without-vnc', + capabilities: JSON.stringify({ + 'browserName': 'chrome', + 'browserVersion': '88.0' + }), + nodeId: node.id +} + +describe('Node component', () => { + it('renders basic node information', () => { + render() + expect(screen.getByText(node.uri)).toBeInTheDocument() + expect( + screen.getByText(`Sessions: ${node.sessionCount}`)).toBeInTheDocument() + expect(screen.getByText( + `Max. Concurrency: ${node.maxSession}`)).toBeInTheDocument() + }) + + it('renders detailed node information', async () => { + render() + const user = userEvent.setup() + await user.click(screen.getByRole('button')) + expect(screen.getByText(`Node Id: ${node.id}`)).toBeInTheDocument() + expect( + screen.getByText(`Total slots: ${node.slotCount}`)).toBeInTheDocument() + expect(screen.getByText(`OS Arch: ${node.osInfo.arch}`)).toBeInTheDocument() + expect(screen.getByText(`OS Name: ${node.osInfo.name}`)).toBeInTheDocument() + expect( + screen.getByText(`OS Version: ${node.osInfo.version}`)).toBeInTheDocument() + }) + + it('does not render live view icon when no VNC session is available', () => { + render() + expect(screen.queryByTestId('VideocamIcon')).not.toBeInTheDocument() + }) + + it('renders live view icon when VNC session is available', () => { + render() + expect(screen.getByTestId('VideocamIcon')).toBeInTheDocument() + }) + + it('opens live view dialog when camera icon is clicked', async () => { + render() + + const user = userEvent.setup() + await user.click(screen.getByTestId('VideocamIcon')) + + expect(screen.getByText('Node Session Live View')).toBeInTheDocument() + const dialogTitle = screen.getByText('Node Session Live View') + const dialog = dialogTitle.closest('.MuiDialog-root') + expect(dialog).not.toBeNull() + if (dialog) { + expect(within(dialog as HTMLElement).getAllByText(node.uri).length).toBeGreaterThan(0) + } + expect(screen.getByTestId('mock-live-view')).toBeInTheDocument() + }) + + it('closes live view dialog when close button is clicked', async () => { + render() + + const user = userEvent.setup() + await user.click(screen.getByTestId('VideocamIcon')) + + expect(screen.getByText('Node Session Live View')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /close/i })) + + expect(screen.queryByText('Node Session Live View')).not.toBeInTheDocument() + }) + + it('correctly transforms VNC URL for WebSocket connection', async () => { + const origin = 'https://grid.example.com' + render() + + const user = userEvent.setup() + await user.click(screen.getByTestId('VideocamIcon')) + + const liveView = screen.getByTestId('mock-live-view') + const url = liveView.getAttribute('data-url') + + expect(url).toContain('wss:') + expect(url).toContain('grid.example.com') + expect(url).toContain('/websockify') + }) + + it('handles HTTP to WS protocol conversion correctly', async () => { + const httpOrigin = 'http://grid.example.com' + render() + + const user = userEvent.setup() + await user.click(screen.getByTestId('VideocamIcon')) + + const liveView = screen.getByTestId('mock-live-view') + const url = liveView.getAttribute('data-url') + + expect(url).toContain('ws:') + expect(url).not.toContain('wss:') + }) -it('renders detailed node information', async () => { - render() - const user = userEvent.setup() - await user.click(screen.getByRole('button')) - expect(screen.getByText(`Node Id: ${node.id}`)).toBeInTheDocument() - expect( - screen.getByText(`Total slots: ${node.slotCount}`)).toBeInTheDocument() - expect(screen.getByText(`OS Arch: ${node.osInfo.arch}`)).toBeInTheDocument() - expect(screen.getByText(`OS Name: ${node.osInfo.name}`)).toBeInTheDocument() - expect( - screen.getByText(`OS Version: ${node.osInfo.version}`)).toBeInTheDocument() + it('handles invalid VNC URLs gracefully', async () => { + const invalidVncSession = { + id: 'session-invalid-vnc', + capabilities: JSON.stringify({ + 'browserName': 'chrome', + 'browserVersion': '88.0', + 'se:vnc': 'invalid-url' + }), + nodeId: node.id + } + + render() + + const user = userEvent.setup() + await user.click(screen.getByTestId('VideocamIcon')) + + const liveView = screen.getByTestId('mock-live-view') + const url = liveView.getAttribute('data-url') + + expect(url).toBe('') + }) }) diff --git a/javascript/grid-ui/src/tests/components/Overview.test.tsx b/javascript/grid-ui/src/tests/components/Overview.test.tsx new file mode 100644 index 0000000000000..393e06e81692c --- /dev/null +++ b/javascript/grid-ui/src/tests/components/Overview.test.tsx @@ -0,0 +1,335 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import * as React from 'react' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import '@testing-library/jest-dom' +import { MockedProvider } from '@apollo/client/testing' +import Overview from '../../screens/Overview/Overview' +import { NODES_QUERY } from '../../graphql/nodes' +import { GRID_SESSIONS_QUERY } from '../../graphql/sessions' + +jest.mock('../../components/LiveView/LiveView', () => { + return { + __esModule: true, + default: React.forwardRef((props: { url: string, scaleViewport?: boolean, onClose?: () => void }, ref) => { + React.useImperativeHandle(ref, () => ({ + disconnect: jest.fn() + })) + return
LiveView Mock
+ }) + } +}) + +const mockNodesData = { + nodesInfo: { + nodes: [ + { + id: 'node1', + uri: 'http://192.168.1.10:4444', + status: 'UP', + maxSession: 5, + slotCount: 5, + version: '4.0.0', + osInfo: { + name: 'Linux', + version: '5.4.0', + arch: 'x86_64' + }, + sessionCount: 1, + stereotypes: JSON.stringify([ + { + stereotype: { + browserName: 'chrome', + browserVersion: '88.0', + platformName: 'linux' + }, + slots: 5 + } + ]) + }, + { + id: 'node2', + uri: 'http://192.168.1.11:4444', + status: 'UP', + maxSession: 5, + slotCount: 5, + version: '4.0.0', + osInfo: { + name: 'Windows', + version: '10', + arch: 'x86_64' + }, + sessionCount: 2, + stereotypes: JSON.stringify([ + { + stereotype: { + browserName: 'firefox', + browserVersion: '78.0', + platformName: 'windows' + }, + slots: 5 + } + ]) + } + ] + } +} + +const mockSessionsData = { + sessionsInfo: { + sessions: [ + { + id: 'session1', + nodeId: 'node1', + capabilities: JSON.stringify({ + browserName: 'chrome', + browserVersion: '88.0', + platformName: 'linux', + 'se:vnc': 'ws://192.168.1.10:5900/websockify' + }) + }, + { + id: 'session2', + nodeId: 'node2', + capabilities: JSON.stringify({ + browserName: 'firefox', + browserVersion: '78.0', + platformName: 'windows' + }) + } + ] + } +} + +const mocks = [ + { + request: { + query: NODES_QUERY + }, + result: { + data: mockNodesData + } + }, + { + request: { + query: GRID_SESSIONS_QUERY + }, + result: { + data: mockSessionsData + } + } +] + +describe('Overview component', () => { + beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { + origin: 'http://localhost:4444' + }, + writable: true + }) + }) + + it('renders loading state initially', () => { + render( + + + + ) + + expect(screen.getByRole('progressbar')).toBeInTheDocument() + }) + + it('renders nodes when data is loaded', async () => { + render( + + + + ) + + await screen.findByText('http://192.168.1.10:4444') + + expect(screen.getByText('http://192.168.1.10:4444')).toBeInTheDocument() + expect(screen.getByText('http://192.168.1.11:4444')).toBeInTheDocument() + }) + + it('renders sort controls', async () => { + render( + + + + ) + + await screen.findByText('http://192.168.1.10:4444') + + expect(screen.getAllByText('Sort By').length).toBeGreaterThan(0) + expect(screen.getByText('Descending')).toBeInTheDocument() + }) + + it('changes sort option when selected', async () => { + render( + + + + ) + + await screen.findByText('http://192.168.1.10:4444') + + const user = userEvent.setup() + const selectElement = screen.getByRole('combobox') + await user.click(selectElement) + + await user.click(screen.getByText('Browser Name')) + + expect(selectElement).toHaveTextContent('Browser Name') + }) + + it('toggles sort order when descending checkbox is clicked', async () => { + render( + + + + ) + + await screen.findByText('http://192.168.1.10:4444') + + const user = userEvent.setup() + const descendingLabel = screen.getByText('Descending') + const checkbox = descendingLabel.closest('label')?.querySelector('input[type="checkbox"]') + + expect(checkbox).not.toBeNull() + if (checkbox) { + await user.click(checkbox) + expect(checkbox).toBeChecked() + } + }) + + it('renders live view icon for node with VNC session', async () => { + render( + + + + ) + + await screen.findByText('http://192.168.1.10:4444') + + expect(screen.getByTestId('VideocamIcon')).toBeInTheDocument() + }) + + it('does not render live view icon for node without VNC session', async () => { + render( + + + + ) + + await screen.findByText('http://192.168.1.11:4444') + + const videocamIcons = screen.getAllByTestId('VideocamIcon') + + expect(videocamIcons.length).toBe(1) + + const node2Element = screen.getByText('http://192.168.1.11:4444') + const node2Card = node2Element.closest('.MuiCard-root') + + if (node2Card) { + expect(within(node2Card as HTMLElement).queryByTestId('VideocamIcon')).not.toBeInTheDocument() + } + }) + + it('opens live view dialog when camera icon is clicked', async () => { + render( + + + + ) + + await screen.findByText('http://192.168.1.10:4444') + + const user = userEvent.setup() + await user.click(screen.getByTestId('VideocamIcon')) + + expect(screen.getByText('Node Session Live View')).toBeInTheDocument() + expect(screen.getByTestId('mock-live-view')).toBeInTheDocument() + }) + + it('closes live view dialog when close button is clicked', async () => { + render( + + + + ) + + await screen.findByText('http://192.168.1.10:4444') + + const user = userEvent.setup() + await user.click(screen.getByTestId('VideocamIcon')) + + expect(screen.getByText('Node Session Live View')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /close/i })) + + expect(screen.queryByText('Node Session Live View')).not.toBeInTheDocument() + }) + + it('handles error state', async () => { + const errorMocks = [ + { + request: { + query: NODES_QUERY + }, + error: new Error('Network error') + } + ] + + render( + + + + ) + + await new Promise(resolve => setTimeout(resolve, 0)) + + const errorElement = screen.getByRole('heading', { level: 3 }) + expect(errorElement).toBeInTheDocument() + }) + + it('handles empty nodes state', async () => { + const emptyMocks = [ + { + request: { + query: NODES_QUERY + }, + result: { + data: { nodesInfo: { nodes: [] } } + } + } + ] + + render( + + + + ) + + await screen.findByText('The Grid has no registered Nodes yet.') + + expect(screen.getByText('The Grid has no registered Nodes yet.')).toBeInTheDocument() + }) +})