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 && (
+
+ )}
+ >
)
}
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()
+ })
+})