Skip to content

[grid] UI Overview is able to see live preview per Node #15777

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 121 additions & 3 deletions javascript/grid-ui/src/components/Node/Node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,6 +109,7 @@ function Node (props) {
})

return (
<>
<Card sx={getCardStyle(node.status)}>
<CardContent sx={{ pl: 2, pr: 1 }}>
<Grid
Expand Down Expand Up @@ -62,14 +140,54 @@ function Node (props) {
</Typography>
</Grid>
<Grid item xs={12}>
<Box display="flex" alignItems="center">
<Stereotypes stereotypes={node.slotStereotypes}/>
{vncSession && (
<LiveIconButton onClick={handleLiveViewIconClick} size='medium' color="primary">
<VideocamIcon data-testid="VideocamIcon" />
</LiveIconButton>
)}
</Box>
</Grid>
<Grid item xs={12}>
<NodeLoad node={node}/>
</Grid>
</Grid>
</CardContent>
</Card>
{vncSession && liveViewSessionId && (
<Dialog
onClose={handleDialogClose}
aria-labelledby='live-view-dialog'
open={liveViewSessionId === vncSession.id}
fullWidth
maxWidth='xl'
fullScreen
>
<DialogTitle id='live-view-dialog'>
<Typography gutterBottom component='span' sx={{ paddingX: '10px' }}>
<Box fontWeight='fontWeightBold' mr={1} display='inline'>
Node Session Live View
</Box>
{node.uri}
</Typography>
</DialogTitle>
<DialogContent dividers sx={{ height: '600px' }}>
<LiveView
ref={liveViewRef as any}
url={getVncUrl(vncSession, origin)}
scaleViewport
onClose={handleDialogClose}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleDialogClose} color='primary' variant='contained'>
Close
</Button>
</DialogActions>
</Dialog>
)}
</>
)
}

Expand Down
14 changes: 13 additions & 1 deletion javascript/grid-ui/src/screens/Overview/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -217,7 +223,13 @@ function Overview (): JSX.Element {
flexDirection: 'column'
}}
>
<Node node={node}/>
<Node
node={node}
sessions={sessionsData?.sessionsInfo?.sessions?.filter(
session => session.nodeId === node.id
) || []}
origin={window.location.origin}
/>
</Paper>
</Grid>
)
Expand Down
164 changes: 144 additions & 20 deletions javascript/grid-ui/src/tests/components/Node.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div data-testid="mock-live-view" data-url={props.url}>LiveView Mock</div>
})
}
})

const osInfo: OsInfo = {
name: 'Mac OS X',
Expand Down Expand Up @@ -49,24 +62,135 @@ const node: NodeInfo = {
slotStereotypes: [slotStereotype]
}

it('renders basic node information', () => {
render(<Node node={node} />)
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(<Node node={node} />)
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(<Node node={node}/>)
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(<Node node={node} sessions={[sessionWithoutVnc]} origin="http://localhost:4444" />)
expect(screen.queryByTestId('VideocamIcon')).not.toBeInTheDocument()
})

it('renders live view icon when VNC session is available', () => {
render(<Node node={node} sessions={[sessionWithVnc]} origin="http://localhost:4444" />)
expect(screen.getByTestId('VideocamIcon')).toBeInTheDocument()
})

it('opens live view dialog when camera icon is clicked', async () => {
render(<Node node={node} sessions={[sessionWithVnc]} origin="http://localhost:4444" />)

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(<Node node={node} sessions={[sessionWithVnc]} origin="http://localhost: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('correctly transforms VNC URL for WebSocket connection', async () => {
const origin = 'https://grid.example.com'
render(<Node node={node} sessions={[sessionWithVnc]} origin={origin} />)

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(<Node node={node} sessions={[sessionWithVnc]} origin={httpOrigin} />)

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(<Node node={node}/>)
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(<Node node={node} sessions={[invalidVncSession]} origin="http://localhost:4444" />)

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('')
})
})
Loading