Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
seleniumhq
GitHub Repository: seleniumhq/selenium
Path: blob/trunk/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx
2887 views
1
// Licensed to the Software Freedom Conservancy (SFC) under one
2
// or more contributor license agreements. See the NOTICE file
3
// distributed with this work for additional information
4
// regarding copyright ownership. The SFC licenses this file
5
// to you under the Apache License, Version 2.0 (the
6
// "License"); you may not use this file except in compliance
7
// with the License. You may obtain a copy of the License at
8
//
9
// http://www.apache.org/licenses/LICENSE-2.0
10
//
11
// Unless required by applicable law or agreed to in writing,
12
// software distributed under the License is distributed on an
13
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
// KIND, either express or implied. See the License for the
15
// specific language governing permissions and limitations
16
// under the License.
17
18
import React, { useState, useRef, useEffect } from 'react'
19
import Table from '@mui/material/Table'
20
import TableBody from '@mui/material/TableBody'
21
import TableCell from '@mui/material/TableCell'
22
import TableContainer from '@mui/material/TableContainer'
23
import TableHead from '@mui/material/TableHead'
24
import TablePagination from '@mui/material/TablePagination'
25
import TableRow from '@mui/material/TableRow'
26
import TableSortLabel from '@mui/material/TableSortLabel'
27
import Typography from '@mui/material/Typography'
28
import Paper from '@mui/material/Paper'
29
import FormControlLabel from '@mui/material/FormControlLabel'
30
import Switch from '@mui/material/Switch'
31
import {
32
Box,
33
Button,
34
Dialog,
35
DialogActions,
36
DialogContent,
37
DialogTitle,
38
IconButton
39
} from '@mui/material'
40
import { Info as InfoIcon } from '@mui/icons-material'
41
import {Videocam as VideocamIcon } from '@mui/icons-material'
42
import Slide from '@mui/material/Slide'
43
import { TransitionProps } from '@mui/material/transitions'
44
import browserVersion from '../../util/browser-version'
45
import EnhancedTableToolbar from '../EnhancedTableToolbar'
46
import prettyMilliseconds from 'pretty-ms'
47
import BrowserLogo from '../common/BrowserLogo'
48
import OsLogo from '../common/OsLogo'
49
import RunningSessionsSearchBar from './RunningSessionsSearchBar'
50
import { Size } from '../../models/size'
51
import LiveView from '../LiveView/LiveView'
52
import SessionData, { createSessionData } from '../../models/session-data'
53
import { useNavigate } from 'react-router-dom'
54
import ColumnSelector from './ColumnSelector'
55
56
function descendingComparator<T> (a: T, b: T, orderBy: keyof T): number {
57
if (orderBy === 'sessionDurationMillis') {
58
return Number(b[orderBy]) - Number(a[orderBy])
59
}
60
if (b[orderBy] < a[orderBy]) {
61
return -1
62
}
63
if (b[orderBy] > a[orderBy]) {
64
return 1
65
}
66
return 0
67
}
68
69
type Order = 'asc' | 'desc'
70
71
function getComparator<Key extends keyof any> (
72
order: Order,
73
orderBy: Key
74
): (a: { [key in Key]: number | string }, b: { [key in Key]: number | string }) => number {
75
return order === 'desc'
76
? (a, b) => descendingComparator(a, b, orderBy)
77
: (a, b) => -descendingComparator(a, b, orderBy)
78
}
79
80
function stableSort<T> (array: T[], comparator: (a: T, b: T) => number): T[] {
81
const stabilizedThis = array.map((el, index) => [el, index] as [T, number])
82
stabilizedThis.sort((a, b) => {
83
const order = comparator(a[0], b[0])
84
if (order !== 0) {
85
return order
86
}
87
return a[1] - b[1]
88
})
89
return stabilizedThis.map((el) => el[0])
90
}
91
92
interface HeadCell {
93
id: keyof SessionData
94
label: string
95
numeric: boolean
96
}
97
98
const fixedHeadCells: HeadCell[] = [
99
{ id: 'id', numeric: false, label: 'Session' },
100
{ id: 'capabilities', numeric: false, label: 'Capabilities' },
101
{ id: 'startTime', numeric: false, label: 'Start time' },
102
{ id: 'sessionDurationMillis', numeric: true, label: 'Duration' },
103
{ id: 'nodeUri', numeric: false, label: 'Node URI' }
104
]
105
106
interface EnhancedTableProps {
107
onRequestSort: (event: React.MouseEvent<unknown>,
108
property: keyof SessionData) => void
109
order: Order
110
orderBy: string
111
headCells: HeadCell[]
112
}
113
114
function EnhancedTableHead (props: EnhancedTableProps): JSX.Element {
115
const { order, orderBy, onRequestSort, headCells } = props
116
const createSortHandler = (property: keyof SessionData) => (event: React.MouseEvent<unknown>) => {
117
onRequestSort(event, property)
118
}
119
120
return (
121
<TableHead>
122
<TableRow>
123
{headCells.map((headCell) => (
124
<TableCell
125
key={headCell.id}
126
align='left'
127
padding='normal'
128
sortDirection={orderBy === headCell.id ? order : false}
129
>
130
<TableSortLabel
131
active={orderBy === headCell.id}
132
direction={orderBy === headCell.id ? order : 'asc'}
133
onClick={createSortHandler(headCell.id)}
134
>
135
<Box fontWeight='fontWeightBold' mr={1} display='inline'>
136
{headCell.label}
137
</Box>
138
{orderBy === headCell.id
139
? (
140
<Box
141
component='span'
142
sx={{
143
border: 0,
144
clip: 'rect(0 0 0 0)',
145
height: 1,
146
margin: -1,
147
overflow: 'hidden',
148
padding: 0,
149
position: 'absolute',
150
top: 20,
151
width: 1
152
}}
153
>
154
{order === 'desc'
155
? 'sorted descending'
156
: 'sorted ascending'}
157
</Box>
158
)
159
: null}
160
</TableSortLabel>
161
</TableCell>
162
))}
163
</TableRow>
164
</TableHead>
165
)
166
}
167
168
const Transition = React.forwardRef(function Transition (
169
props: TransitionProps & { children: React.ReactElement },
170
ref: React.Ref<unknown>
171
) {
172
return <Slide direction='up' ref={ref} {...props} />
173
})
174
175
function RunningSessions (props) {
176
const [rowOpen, setRowOpen] = useState('')
177
const [rowLiveViewOpen, setRowLiveViewOpen] = useState('')
178
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
179
const [sessionToDelete, setSessionToDelete] = useState('')
180
const [deleteLocation, setDeleteLocation] = useState('') // 'info' or 'liveview'
181
const [feedbackMessage, setFeedbackMessage] = useState('')
182
const [feedbackOpen, setFeedbackOpen] = useState(false)
183
const [feedbackSeverity, setFeedbackSeverity] = useState('success')
184
const [order, setOrder] = useState<Order>('asc')
185
const [orderBy, setOrderBy] = useState<keyof SessionData>('sessionDurationMillis')
186
const [selected, setSelected] = useState<string[]>([])
187
const [page, setPage] = useState(0)
188
const [dense, setDense] = useState(false)
189
const [rowsPerPage, setRowsPerPage] = useState(10)
190
const [searchFilter, setSearchFilter] = useState('')
191
const [searchBarHelpOpen, setSearchBarHelpOpen] = useState(false)
192
const [selectedColumns, setSelectedColumns] = useState<string[]>(() => {
193
try {
194
const savedColumns = localStorage.getItem('selenium-grid-selected-columns')
195
return savedColumns ? JSON.parse(savedColumns) : []
196
} catch (e) {
197
console.error('Error loading saved columns:', e)
198
return []
199
}
200
})
201
const [headCells, setHeadCells] = useState<HeadCell[]>(fixedHeadCells)
202
const liveViewRef = useRef(null)
203
const navigate = useNavigate()
204
205
const handleDialogClose = () => {
206
if (liveViewRef.current) {
207
liveViewRef.current.disconnect()
208
}
209
navigate('/sessions')
210
}
211
212
const handleRequestSort = (event: React.MouseEvent<unknown>,
213
property: keyof SessionData) => {
214
const isAsc = orderBy === property && order === 'asc'
215
setOrder(isAsc ? 'desc' : 'asc')
216
setOrderBy(property)
217
}
218
219
const handleClick = (event: React.MouseEvent<unknown>, name: string) => {
220
const selectedIndex = selected.indexOf(name)
221
let newSelected: string[] = []
222
223
if (selectedIndex === -1) {
224
newSelected = newSelected.concat(selected, name)
225
} else if (selectedIndex === 0) {
226
newSelected = newSelected.concat(selected.slice(1))
227
} else if (selectedIndex === selected.length - 1) {
228
newSelected = newSelected.concat(selected.slice(0, -1))
229
} else if (selectedIndex > 0) {
230
newSelected = newSelected.concat(
231
selected.slice(0, selectedIndex),
232
selected.slice(selectedIndex + 1)
233
)
234
}
235
setSelected(newSelected)
236
}
237
238
const handleChangePage = (event: unknown, newPage: number) => {
239
setPage(newPage)
240
}
241
242
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
243
setRowsPerPage(parseInt(event.target.value, 10))
244
setPage(0)
245
}
246
247
const handleChangeDense = (event: React.ChangeEvent<HTMLInputElement>) => {
248
setDense(event.target.checked)
249
}
250
251
const isSelected = (name: string): boolean => selected.includes(name)
252
253
const handleDeleteConfirmation = (sessionId: string, location: string) => {
254
setSessionToDelete(sessionId)
255
setDeleteLocation(location)
256
setConfirmDeleteOpen(true)
257
}
258
259
const handleDeleteSession = async () => {
260
try {
261
const session = sessions.find(s => s.id === sessionToDelete)
262
if (!session) {
263
setFeedbackMessage('Session not found')
264
setFeedbackSeverity('error')
265
setConfirmDeleteOpen(false)
266
setFeedbackOpen(true)
267
return
268
}
269
270
let deleteUrl = ''
271
272
const parsed = JSON.parse(session.capabilities)
273
let wsUrl = parsed['webSocketUrl'] ?? ''
274
if (wsUrl.length > 0) {
275
try {
276
const url = new URL(origin)
277
const sessionUrl = new URL(wsUrl)
278
url.pathname = sessionUrl.pathname.split('/se/')[0] // Remove /se/ and everything after
279
url.protocol = sessionUrl.protocol === 'wss:' ? 'https:' : 'http:'
280
deleteUrl = url.href
281
} catch (error) {
282
deleteUrl = ''
283
}
284
}
285
286
if (!deleteUrl) {
287
const currentUrl = window.location.href
288
const baseUrl = currentUrl.split('/ui/')[0] // Remove /ui/ and everything after
289
deleteUrl = `${baseUrl}/session/${sessionToDelete}`
290
}
291
292
const response = await fetch(deleteUrl, {
293
method: 'DELETE'
294
})
295
296
if (response.ok) {
297
setFeedbackMessage('Session deleted successfully')
298
setFeedbackSeverity('success')
299
if (deleteLocation === 'liveview') {
300
handleDialogClose()
301
} else {
302
setRowOpen('')
303
}
304
} else {
305
setFeedbackMessage('Failed to delete session')
306
setFeedbackSeverity('error')
307
}
308
} catch (error) {
309
console.error('Error deleting session:', error)
310
setFeedbackMessage('Error deleting session')
311
setFeedbackSeverity('error')
312
}
313
314
setConfirmDeleteOpen(false)
315
setFeedbackOpen(true)
316
setSessionToDelete('')
317
setDeleteLocation('')
318
}
319
320
const handleCancelDelete = () => {
321
setConfirmDeleteOpen(false)
322
setSessionToDelete('')
323
setDeleteLocation('')
324
}
325
326
const displaySessionInfo = (id: string): JSX.Element => {
327
const handleInfoIconClick = (): void => {
328
setRowOpen(id)
329
}
330
return (
331
<IconButton
332
sx={{ padding: '1px' }}
333
onClick={handleInfoIconClick}
334
size='large'
335
>
336
<InfoIcon />
337
</IconButton>
338
)
339
}
340
341
const displayLiveView = (id: string): JSX.Element => {
342
const handleLiveViewIconClick = (): void => {
343
navigate(`/session/${id}`)
344
}
345
return (
346
<IconButton
347
sx={{ padding: '1px' }}
348
onClick={handleLiveViewIconClick}
349
size='large'
350
>
351
<VideocamIcon />
352
</IconButton>
353
)
354
}
355
356
const { sessions, origin, sessionId } = props
357
358
const getCapabilityValue = (capabilitiesStr: string, key: string): string => {
359
try {
360
const capabilities = JSON.parse(capabilitiesStr as string)
361
const value = capabilities[key]
362
363
if (value === undefined || value === null) {
364
return ''
365
}
366
367
if (typeof value === 'object') {
368
return JSON.stringify(value)
369
}
370
371
return String(value)
372
} catch (e) {
373
return ''
374
}
375
}
376
377
const hasDeleteSessionCapability = (capabilitiesStr: string): boolean => {
378
try {
379
const capabilities = JSON.parse(capabilitiesStr as string)
380
return capabilities['se:deleteSessionOnUi'] === true
381
} catch (e) {
382
return false
383
}
384
}
385
386
const rows = sessions.map((session) => {
387
const sessionData = createSessionData(
388
session.id,
389
session.capabilities,
390
session.startTime,
391
session.uri,
392
session.nodeId,
393
session.nodeUri,
394
session.sessionDurationMillis,
395
session.slot,
396
origin
397
)
398
399
selectedColumns.forEach(column => {
400
sessionData[column] = getCapabilityValue(session.capabilities, column)
401
})
402
403
return sessionData
404
})
405
const emptyRows = rowsPerPage - Math.min(rowsPerPage, rows.length - page * rowsPerPage)
406
407
useEffect(() => {
408
let s = sessionId || ''
409
410
let session_ids = sessions.map((session) => session.id)
411
412
if (!session_ids.includes(s)) {
413
setRowLiveViewOpen('')
414
navigate('/sessions')
415
} else {
416
setRowLiveViewOpen(s)
417
}
418
}, [sessionId, sessions])
419
420
useEffect(() => {
421
const dynamicHeadCells = selectedColumns.map(column => ({
422
id: column,
423
numeric: false,
424
label: column
425
}))
426
427
setHeadCells([...fixedHeadCells, ...dynamicHeadCells])
428
}, [selectedColumns])
429
430
return (
431
<Box width='100%'>
432
{rows.length > 0 && (
433
<div>
434
<Paper sx={{ width: '100%', marginBottom: 2 }}>
435
<EnhancedTableToolbar title='Running'>
436
<Box display="flex" alignItems="center">
437
<ColumnSelector
438
sessions={sessions}
439
selectedColumns={selectedColumns}
440
onColumnSelectionChange={(columns) => {
441
setSelectedColumns(columns)
442
localStorage.setItem('selenium-grid-selected-columns', JSON.stringify(columns))
443
}}
444
/>
445
<RunningSessionsSearchBar
446
searchFilter={searchFilter}
447
handleSearch={setSearchFilter}
448
searchBarHelpOpen={searchBarHelpOpen}
449
setSearchBarHelpOpen={setSearchBarHelpOpen}
450
/>
451
</Box>
452
</EnhancedTableToolbar>
453
<TableContainer>
454
<Table
455
sx={{ minWidth: '750px' }}
456
aria-labelledby='tableTitle'
457
size={dense ? 'small' : 'medium'}
458
aria-label='enhanced table'
459
>
460
<EnhancedTableHead
461
order={order}
462
orderBy={orderBy}
463
onRequestSort={handleRequestSort}
464
headCells={headCells}
465
/>
466
<TableBody>
467
{stableSort(rows, getComparator(order, orderBy))
468
.filter((session) => {
469
if (searchFilter === '') {
470
// don't filter anything on empty search field
471
return true
472
}
473
474
if (!searchFilter.includes('=')) {
475
// filter on the entire session if users don't use `=` symbol
476
return JSON.stringify(session)
477
.toLowerCase()
478
.includes(searchFilter.toLowerCase())
479
}
480
481
const [filterField, filterItem] = searchFilter.split('=')
482
if (filterField.startsWith('capabilities,')) {
483
const capabilityID = filterField.split(',')[1]
484
return (JSON.parse(session.capabilities as string) as object)[capabilityID] === filterItem
485
}
486
return session[filterField] === filterItem
487
})
488
.slice(page * rowsPerPage,
489
page * rowsPerPage + rowsPerPage)
490
.map((row, index) => {
491
const isItemSelected = isSelected(row.id as string)
492
const labelId = `enhanced-table-checkbox-${index}`
493
return (
494
<TableRow
495
hover
496
onClick={(event) =>
497
handleClick(event, row.id as string)}
498
role='checkbox'
499
aria-checked={isItemSelected}
500
tabIndex={-1}
501
key={row.id}
502
selected={isItemSelected}
503
>
504
<TableCell
505
component='th'
506
id={labelId}
507
scope='row'
508
align='left'
509
>
510
{
511
(row.vnc as string).length > 0 &&
512
displayLiveView(row.id as string)
513
}
514
{row.name}
515
{
516
(row.vnc as string).length > 0 &&
517
<Dialog
518
onClose={() => navigate("/sessions")}
519
aria-labelledby='live-view-dialog'
520
open={rowLiveViewOpen === row.id}
521
fullWidth
522
maxWidth='xl'
523
fullScreen
524
TransitionComponent={Transition}
525
>
526
<DialogTitle id='live-view-dialog'>
527
<Typography
528
gutterBottom component='span'
529
sx={{ paddingX: '10px' }}
530
>
531
<Box
532
fontWeight='fontWeightBold'
533
mr={1}
534
display='inline'
535
>
536
Session
537
</Box>
538
{row.name}
539
</Typography>
540
<OsLogo
541
osName={row.platformName as string}
542
/>
543
<BrowserLogo
544
browserName={row.browserName as string}
545
/>
546
{browserVersion(
547
row.browserVersion as string)}
548
</DialogTitle>
549
<DialogContent
550
dividers
551
sx={{ height: '600px' }}
552
>
553
<LiveView
554
ref={liveViewRef}
555
url={row.vnc as string}
556
scaleViewport
557
onClose={() => navigate("/sessions")}
558
/>
559
</DialogContent>
560
<DialogActions>
561
<Button
562
onClick={handleDialogClose}
563
color='primary'
564
variant='contained'
565
>
566
Close
567
</Button>
568
</DialogActions>
569
</Dialog>
570
}
571
</TableCell>
572
<TableCell align='left'>
573
{displaySessionInfo(row.id as string)}
574
<OsLogo
575
osName={row.platformName as string}
576
size={Size.S}
577
/>
578
<BrowserLogo
579
browserName={row.browserName as string}
580
/>
581
{browserVersion(row.browserVersion as string)}
582
<Dialog
583
onClose={() => setRowOpen('')}
584
aria-labelledby='session-info-dialog'
585
open={rowOpen === row.id}
586
fullWidth
587
maxWidth='md'
588
>
589
<DialogTitle id='session-info-dialog'>
590
<Typography
591
gutterBottom component='span'
592
sx={{ paddingX: '10px' }}
593
>
594
<Box
595
fontWeight='fontWeightBold'
596
mr={1}
597
display='inline'
598
>
599
Session
600
</Box>
601
{row.name}
602
</Typography>
603
<OsLogo osName={row.platformName as string} />
604
<BrowserLogo
605
browserName={row.browserName as string}
606
/>
607
{browserVersion(row.browserVersion as string)}
608
</DialogTitle>
609
<DialogContent dividers>
610
<Typography gutterBottom>
611
Capabilities:
612
</Typography>
613
<Typography gutterBottom component='span'>
614
<pre>
615
{JSON.stringify(
616
JSON.parse(
617
row.capabilities as string) as object,
618
null, 2)}
619
</pre>
620
</Typography>
621
</DialogContent>
622
<DialogActions>
623
{hasDeleteSessionCapability(row.capabilities as string) && (
624
<Button
625
onClick={() => handleDeleteConfirmation(row.id as string, 'info')}
626
color='error'
627
variant='contained'
628
sx={{ marginRight: 1 }}
629
>
630
Delete
631
</Button>
632
)}
633
<Button
634
onClick={() => setRowOpen('')}
635
color='primary'
636
variant='contained'
637
>
638
Close
639
</Button>
640
</DialogActions>
641
</Dialog>
642
</TableCell>
643
<TableCell align='left'>
644
{row.startTime}
645
</TableCell>
646
<TableCell align='left'>
647
{prettyMilliseconds(
648
Number(row.sessionDurationMillis))}
649
</TableCell>
650
<TableCell align='left'>
651
{row.nodeUri}
652
</TableCell>
653
{/* Add dynamic columns */}
654
{selectedColumns.map(column => (
655
<TableCell key={column} align='left'>{row[column]}</TableCell>
656
))}
657
</TableRow>
658
)
659
})}
660
{emptyRows > 0 && (
661
<TableRow style={{ height: (dense ? 33 : 53) * emptyRows }}>
662
<TableCell colSpan={6} />
663
</TableRow>
664
)}
665
</TableBody>
666
</Table>
667
</TableContainer>
668
<TablePagination
669
rowsPerPageOptions={[5, 10, 15]}
670
component='div'
671
count={rows.length}
672
rowsPerPage={rowsPerPage}
673
page={page}
674
onPageChange={handleChangePage}
675
onRowsPerPageChange={handleChangeRowsPerPage}
676
/>
677
</Paper>
678
<FormControlLabel
679
control={<Switch
680
checked={dense}
681
onChange={handleChangeDense}
682
/>}
683
label='Dense padding'
684
/>
685
</div>
686
)}
687
{/* Confirmation Dialog */}
688
<Dialog
689
open={confirmDeleteOpen}
690
onClose={handleCancelDelete}
691
aria-labelledby='delete-confirmation-dialog'
692
>
693
<DialogTitle id='delete-confirmation-dialog'>
694
Confirm Session Deletion
695
</DialogTitle>
696
<DialogContent>
697
<Typography>
698
Are you sure you want to delete this session? This action cannot be undone.
699
</Typography>
700
</DialogContent>
701
<DialogActions>
702
<Button
703
onClick={handleCancelDelete}
704
color='primary'
705
variant='outlined'
706
>
707
Cancel
708
</Button>
709
<Button
710
onClick={handleDeleteSession}
711
color='error'
712
variant='contained'
713
autoFocus
714
>
715
Delete
716
</Button>
717
</DialogActions>
718
</Dialog>
719
720
{/* Feedback Dialog */}
721
<Dialog
722
open={feedbackOpen}
723
onClose={() => setFeedbackOpen(false)}
724
aria-labelledby='feedback-dialog'
725
>
726
<DialogTitle id='feedback-dialog'>
727
{feedbackSeverity === 'success' ? 'Success' : 'Error'}
728
</DialogTitle>
729
<DialogContent>
730
<Typography color={feedbackSeverity === 'success' ? 'success.main' : 'error.main'}>
731
{feedbackMessage}
732
</Typography>
733
</DialogContent>
734
<DialogActions>
735
<Button
736
onClick={() => setFeedbackOpen(false)}
737
color='primary'
738
variant='contained'
739
>
740
OK
741
</Button>
742
</DialogActions>
743
</Dialog>
744
</Box>
745
)
746
}
747
748
export default RunningSessions
749
750