Skip to content

Commit 9f3c923

Browse files
VietND96diemol
andauthored
[grid] UI Overview is able to see live preview per Node (#15777)
* [grid] UI Overview is able to see live preview per Node Signed-off-by: Viet Nguyen Duc <nguyenducviet4496@gmail.com> * Add component test Signed-off-by: Viet Nguyen Duc <nguyenducviet4496@gmail.com> --------- Signed-off-by: Viet Nguyen Duc <nguyenducviet4496@gmail.com> Co-authored-by: Diego Molina <diemol@users.noreply.github.com>
1 parent 7401a3d commit 9f3c923

File tree

4 files changed

+613
-24
lines changed

4 files changed

+613
-24
lines changed

javascript/grid-ui/src/components/Node/Node.tsx

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,92 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18-
import { Box, Card, CardContent, Grid, Typography } from '@mui/material'
19-
import React from 'react'
18+
import { Box, Card, CardContent, Dialog, DialogActions, DialogContent, DialogTitle, Grid, IconButton, Typography, Button, keyframes, styled } from '@mui/material'
19+
import React, { useState, useRef } from 'react'
20+
import { Videocam as VideocamIcon } from '@mui/icons-material'
2021
import NodeDetailsDialog from './NodeDetailsDialog'
2122
import NodeLoad from './NodeLoad'
2223
import Stereotypes from './Stereotypes'
2324
import OsLogo from '../common/OsLogo'
25+
import LiveView from '../LiveView/LiveView'
26+
27+
const pulse = keyframes`
28+
0% {
29+
box-shadow: 0 0 0 0 rgba(25, 118, 210, 0.7);
30+
transform: scale(1);
31+
}
32+
50% {
33+
box-shadow: 0 0 0 5px rgba(25, 118, 210, 0);
34+
transform: scale(1.05);
35+
}
36+
100% {
37+
box-shadow: 0 0 0 0 rgba(25, 118, 210, 0);
38+
transform: scale(1);
39+
}
40+
`
41+
42+
const LiveIconButton = styled(IconButton)(({ theme }) => ({
43+
marginLeft: theme.spacing(1),
44+
position: 'relative',
45+
'&::after': {
46+
content: '""',
47+
position: 'absolute',
48+
width: '100%',
49+
height: '100%',
50+
borderRadius: '50%',
51+
animation: `${pulse} 2s infinite`,
52+
zIndex: 0
53+
}
54+
}))
55+
56+
function getVncUrl(session, origin) {
57+
try {
58+
const parsed = JSON.parse(session.capabilities)
59+
let vnc = parsed['se:vnc'] ?? ''
60+
if (vnc.length > 0) {
61+
try {
62+
const url = new URL(origin)
63+
const vncUrl = new URL(vnc)
64+
url.pathname = vncUrl.pathname
65+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
66+
return url.href
67+
} catch (error) {
68+
console.log(error)
69+
return ''
70+
}
71+
}
72+
return ''
73+
} catch (e) {
74+
return ''
75+
}
76+
}
2477

2578
function Node (props) {
26-
const { node } = props
79+
const { node, sessions = [], origin } = props
80+
const [liveViewSessionId, setLiveViewSessionId] = useState('')
81+
const liveViewRef = useRef<{ disconnect: () => void }>(null)
82+
83+
const vncSession = sessions.find(session => {
84+
try {
85+
const capabilities = JSON.parse(session.capabilities)
86+
return capabilities['se:vnc'] !== undefined && capabilities['se:vnc'] !== ''
87+
} catch (e) {
88+
return false
89+
}
90+
})
91+
92+
const handleLiveViewIconClick = () => {
93+
if (vncSession) {
94+
setLiveViewSessionId(vncSession.id)
95+
}
96+
}
97+
98+
const handleDialogClose = () => {
99+
if (liveViewRef.current) {
100+
liveViewRef.current.disconnect()
101+
}
102+
setLiveViewSessionId('')
103+
}
27104
const getCardStyle = (status: string) => ({
28105
height: '100%',
29106
flexGrow: 1,
@@ -32,6 +109,7 @@ function Node (props) {
32109
})
33110

34111
return (
112+
<>
35113
<Card sx={getCardStyle(node.status)}>
36114
<CardContent sx={{ pl: 2, pr: 1 }}>
37115
<Grid
@@ -62,14 +140,54 @@ function Node (props) {
62140
</Typography>
63141
</Grid>
64142
<Grid item xs={12}>
143+
<Box display="flex" alignItems="center">
65144
<Stereotypes stereotypes={node.slotStereotypes}/>
145+
{vncSession && (
146+
<LiveIconButton onClick={handleLiveViewIconClick} size='medium' color="primary">
147+
<VideocamIcon data-testid="VideocamIcon" />
148+
</LiveIconButton>
149+
)}
150+
</Box>
66151
</Grid>
67152
<Grid item xs={12}>
68153
<NodeLoad node={node}/>
69154
</Grid>
70155
</Grid>
71156
</CardContent>
72157
</Card>
158+
{vncSession && liveViewSessionId && (
159+
<Dialog
160+
onClose={handleDialogClose}
161+
aria-labelledby='live-view-dialog'
162+
open={liveViewSessionId === vncSession.id}
163+
fullWidth
164+
maxWidth='xl'
165+
fullScreen
166+
>
167+
<DialogTitle id='live-view-dialog'>
168+
<Typography gutterBottom component='span' sx={{ paddingX: '10px' }}>
169+
<Box fontWeight='fontWeightBold' mr={1} display='inline'>
170+
Node Session Live View
171+
</Box>
172+
{node.uri}
173+
</Typography>
174+
</DialogTitle>
175+
<DialogContent dividers sx={{ height: '600px' }}>
176+
<LiveView
177+
ref={liveViewRef as any}
178+
url={getVncUrl(vncSession, origin)}
179+
scaleViewport
180+
onClose={handleDialogClose}
181+
/>
182+
</DialogContent>
183+
<DialogActions>
184+
<Button onClick={handleDialogClose} color='primary' variant='contained'>
185+
Close
186+
</Button>
187+
</DialogActions>
188+
</Dialog>
189+
)}
190+
</>
73191
)
74192
}
75193

javascript/grid-ui/src/screens/Overview/Overview.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,18 @@ import browserVersion from '../../util/browser-version'
4141
import Capabilities from '../../models/capabilities'
4242
import { GridConfig } from '../../config'
4343
import { NODES_QUERY } from '../../graphql/nodes'
44+
import { GRID_SESSIONS_QUERY } from '../../graphql/sessions'
4445

4546
function Overview (): JSX.Element {
4647
const { loading, error, data } = useQuery(NODES_QUERY, {
4748
pollInterval: GridConfig.status.xhrPollingIntervalMillis,
4849
fetchPolicy: 'network-only'
4950
})
51+
52+
const { data: sessionsData } = useQuery(GRID_SESSIONS_QUERY, {
53+
pollInterval: GridConfig.status.xhrPollingIntervalMillis,
54+
fetchPolicy: 'network-only'
55+
})
5056

5157
function compareSlotStereotypes(a: NodeInfo, b: NodeInfo, attribute: string): number {
5258
const joinA = a.slotStereotypes.length === 1
@@ -217,7 +223,13 @@ function Overview (): JSX.Element {
217223
flexDirection: 'column'
218224
}}
219225
>
220-
<Node node={node}/>
226+
<Node
227+
node={node}
228+
sessions={sessionsData?.sessionsInfo?.sessions?.filter(
229+
session => session.nodeId === node.id
230+
) || []}
231+
origin={window.location.origin}
232+
/>
221233
</Paper>
222234
</Grid>
223235
)

javascript/grid-ui/src/tests/components/Node.test.tsx

Lines changed: 144 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,21 @@ import Node from '../../components/Node/Node'
2020
import NodeInfo from '../../models/node-info'
2121
import OsInfo from '../../models/os-info'
2222
import StereotypeInfo from '../../models/stereotype-info'
23-
import { render, screen } from '@testing-library/react'
23+
import { render, screen, within } from '@testing-library/react'
2424
import userEvent from '@testing-library/user-event'
25+
import '@testing-library/jest-dom'
26+
27+
jest.mock('../../components/LiveView/LiveView', () => {
28+
return {
29+
__esModule: true,
30+
default: React.forwardRef((props: { url: string, scaleViewport?: boolean, onClose?: () => void }, ref) => {
31+
React.useImperativeHandle(ref, () => ({
32+
disconnect: jest.fn()
33+
}))
34+
return <div data-testid="mock-live-view" data-url={props.url}>LiveView Mock</div>
35+
})
36+
}
37+
})
2538

2639
const osInfo: OsInfo = {
2740
name: 'Mac OS X',
@@ -49,24 +62,135 @@ const node: NodeInfo = {
4962
slotStereotypes: [slotStereotype]
5063
}
5164

52-
it('renders basic node information', () => {
53-
render(<Node node={node} />)
54-
expect(screen.getByText(node.uri)).toBeInTheDocument()
55-
expect(
56-
screen.getByText(`Sessions: ${node.sessionCount}`)).toBeInTheDocument()
57-
expect(screen.getByText(
58-
`Max. Concurrency: ${node.maxSession}`)).toBeInTheDocument()
59-
})
65+
const sessionWithVnc = {
66+
id: 'session-with-vnc',
67+
capabilities: JSON.stringify({
68+
'browserName': 'chrome',
69+
'browserVersion': '88.0',
70+
'se:vnc': 'ws://192.168.1.7:5900/websockify'
71+
}),
72+
nodeId: node.id
73+
}
74+
75+
const sessionWithoutVnc = {
76+
id: 'session-without-vnc',
77+
capabilities: JSON.stringify({
78+
'browserName': 'chrome',
79+
'browserVersion': '88.0'
80+
}),
81+
nodeId: node.id
82+
}
83+
84+
describe('Node component', () => {
85+
it('renders basic node information', () => {
86+
render(<Node node={node} />)
87+
expect(screen.getByText(node.uri)).toBeInTheDocument()
88+
expect(
89+
screen.getByText(`Sessions: ${node.sessionCount}`)).toBeInTheDocument()
90+
expect(screen.getByText(
91+
`Max. Concurrency: ${node.maxSession}`)).toBeInTheDocument()
92+
})
93+
94+
it('renders detailed node information', async () => {
95+
render(<Node node={node}/>)
96+
const user = userEvent.setup()
97+
await user.click(screen.getByRole('button'))
98+
expect(screen.getByText(`Node Id: ${node.id}`)).toBeInTheDocument()
99+
expect(
100+
screen.getByText(`Total slots: ${node.slotCount}`)).toBeInTheDocument()
101+
expect(screen.getByText(`OS Arch: ${node.osInfo.arch}`)).toBeInTheDocument()
102+
expect(screen.getByText(`OS Name: ${node.osInfo.name}`)).toBeInTheDocument()
103+
expect(
104+
screen.getByText(`OS Version: ${node.osInfo.version}`)).toBeInTheDocument()
105+
})
106+
107+
it('does not render live view icon when no VNC session is available', () => {
108+
render(<Node node={node} sessions={[sessionWithoutVnc]} origin="http://localhost:4444" />)
109+
expect(screen.queryByTestId('VideocamIcon')).not.toBeInTheDocument()
110+
})
111+
112+
it('renders live view icon when VNC session is available', () => {
113+
render(<Node node={node} sessions={[sessionWithVnc]} origin="http://localhost:4444" />)
114+
expect(screen.getByTestId('VideocamIcon')).toBeInTheDocument()
115+
})
116+
117+
it('opens live view dialog when camera icon is clicked', async () => {
118+
render(<Node node={node} sessions={[sessionWithVnc]} origin="http://localhost:4444" />)
119+
120+
const user = userEvent.setup()
121+
await user.click(screen.getByTestId('VideocamIcon'))
122+
123+
expect(screen.getByText('Node Session Live View')).toBeInTheDocument()
124+
const dialogTitle = screen.getByText('Node Session Live View')
125+
const dialog = dialogTitle.closest('.MuiDialog-root')
126+
expect(dialog).not.toBeNull()
127+
if (dialog) {
128+
expect(within(dialog as HTMLElement).getAllByText(node.uri).length).toBeGreaterThan(0)
129+
}
130+
expect(screen.getByTestId('mock-live-view')).toBeInTheDocument()
131+
})
132+
133+
it('closes live view dialog when close button is clicked', async () => {
134+
render(<Node node={node} sessions={[sessionWithVnc]} origin="http://localhost:4444" />)
135+
136+
const user = userEvent.setup()
137+
await user.click(screen.getByTestId('VideocamIcon'))
138+
139+
expect(screen.getByText('Node Session Live View')).toBeInTheDocument()
140+
141+
await user.click(screen.getByRole('button', { name: /close/i }))
142+
143+
expect(screen.queryByText('Node Session Live View')).not.toBeInTheDocument()
144+
})
145+
146+
it('correctly transforms VNC URL for WebSocket connection', async () => {
147+
const origin = 'https://grid.example.com'
148+
render(<Node node={node} sessions={[sessionWithVnc]} origin={origin} />)
149+
150+
const user = userEvent.setup()
151+
await user.click(screen.getByTestId('VideocamIcon'))
152+
153+
const liveView = screen.getByTestId('mock-live-view')
154+
const url = liveView.getAttribute('data-url')
155+
156+
expect(url).toContain('wss:')
157+
expect(url).toContain('grid.example.com')
158+
expect(url).toContain('/websockify')
159+
})
160+
161+
it('handles HTTP to WS protocol conversion correctly', async () => {
162+
const httpOrigin = 'http://grid.example.com'
163+
render(<Node node={node} sessions={[sessionWithVnc]} origin={httpOrigin} />)
164+
165+
const user = userEvent.setup()
166+
await user.click(screen.getByTestId('VideocamIcon'))
167+
168+
const liveView = screen.getByTestId('mock-live-view')
169+
const url = liveView.getAttribute('data-url')
170+
171+
expect(url).toContain('ws:')
172+
expect(url).not.toContain('wss:')
173+
})
60174

61-
it('renders detailed node information', async () => {
62-
render(<Node node={node}/>)
63-
const user = userEvent.setup()
64-
await user.click(screen.getByRole('button'))
65-
expect(screen.getByText(`Node Id: ${node.id}`)).toBeInTheDocument()
66-
expect(
67-
screen.getByText(`Total slots: ${node.slotCount}`)).toBeInTheDocument()
68-
expect(screen.getByText(`OS Arch: ${node.osInfo.arch}`)).toBeInTheDocument()
69-
expect(screen.getByText(`OS Name: ${node.osInfo.name}`)).toBeInTheDocument()
70-
expect(
71-
screen.getByText(`OS Version: ${node.osInfo.version}`)).toBeInTheDocument()
175+
it('handles invalid VNC URLs gracefully', async () => {
176+
const invalidVncSession = {
177+
id: 'session-invalid-vnc',
178+
capabilities: JSON.stringify({
179+
'browserName': 'chrome',
180+
'browserVersion': '88.0',
181+
'se:vnc': 'invalid-url'
182+
}),
183+
nodeId: node.id
184+
}
185+
186+
render(<Node node={node} sessions={[invalidVncSession]} origin="http://localhost:4444" />)
187+
188+
const user = userEvent.setup()
189+
await user.click(screen.getByTestId('VideocamIcon'))
190+
191+
const liveView = screen.getByTestId('mock-live-view')
192+
const url = liveView.getAttribute('data-url')
193+
194+
expect(url).toBe('')
195+
})
72196
})

0 commit comments

Comments
 (0)