blob: 52c7df7d60e28348dc230401d29353b63382964a [file] [log] [blame]
// 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 React, { useState, useRef, useEffect } from 'react'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TablePagination from '@mui/material/TablePagination'
import TableRow from '@mui/material/TableRow'
import TableSortLabel from '@mui/material/TableSortLabel'
import Typography from '@mui/material/Typography'
import Paper from '@mui/material/Paper'
import FormControlLabel from '@mui/material/FormControlLabel'
import Switch from '@mui/material/Switch'
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton
} from '@mui/material'
import { Info as InfoIcon } from '@mui/icons-material'
import {Videocam as VideocamIcon } from '@mui/icons-material'
import Slide from '@mui/material/Slide'
import { TransitionProps } from '@mui/material/transitions'
import browserVersion from '../../util/browser-version'
import EnhancedTableToolbar from '../EnhancedTableToolbar'
import prettyMilliseconds from 'pretty-ms'
import BrowserLogo from '../common/BrowserLogo'
import OsLogo from '../common/OsLogo'
import RunningSessionsSearchBar from './RunningSessionsSearchBar'
import { Size } from '../../models/size'
import LiveView from '../LiveView/LiveView'
import SessionData, { createSessionData } from '../../models/session-data'
import { useNavigate } from 'react-router-dom'
import ColumnSelector from './ColumnSelector'
function descendingComparator<T> (a: T, b: T, orderBy: keyof T): number {
if (orderBy === 'sessionDurationMillis') {
return Number(b[orderBy]) - Number(a[orderBy])
}
if (b[orderBy] < a[orderBy]) {
return -1
}
if (b[orderBy] > a[orderBy]) {
return 1
}
return 0
}
type Order = 'asc' | 'desc'
function getComparator<Key extends keyof any> (
order: Order,
orderBy: Key
): (a: { [key in Key]: number | string }, b: { [key in Key]: number | string }) => number {
return order === 'desc'
? (a, b) => descendingComparator(a, b, orderBy)
: (a, b) => -descendingComparator(a, b, orderBy)
}
function stableSort<T> (array: T[], comparator: (a: T, b: T) => number): T[] {
const stabilizedThis = array.map((el, index) => [el, index] as [T, number])
stabilizedThis.sort((a, b) => {
const order = comparator(a[0], b[0])
if (order !== 0) {
return order
}
return a[1] - b[1]
})
return stabilizedThis.map((el) => el[0])
}
interface HeadCell {
id: keyof SessionData
label: string
numeric: boolean
}
const fixedHeadCells: HeadCell[] = [
{ id: 'id', numeric: false, label: 'Session' },
{ id: 'capabilities', numeric: false, label: 'Capabilities' },
{ id: 'startTime', numeric: false, label: 'Start time' },
{ id: 'sessionDurationMillis', numeric: true, label: 'Duration' },
{ id: 'nodeUri', numeric: false, label: 'Node URI' }
]
interface EnhancedTableProps {
onRequestSort: (event: React.MouseEvent<unknown>,
property: keyof SessionData) => void
order: Order
orderBy: string
headCells: HeadCell[]
}
function EnhancedTableHead (props: EnhancedTableProps): JSX.Element {
const { order, orderBy, onRequestSort, headCells } = props
const createSortHandler = (property: keyof SessionData) => (event: React.MouseEvent<unknown>) => {
onRequestSort(event, property)
}
return (
<TableHead>
<TableRow>
{headCells.map((headCell) => (
<TableCell
key={headCell.id}
align='left'
padding='normal'
sortDirection={orderBy === headCell.id ? order : false}
>
<TableSortLabel
active={orderBy === headCell.id}
direction={orderBy === headCell.id ? order : 'asc'}
onClick={createSortHandler(headCell.id)}
>
<Box fontWeight='fontWeightBold' mr={1} display='inline'>
{headCell.label}
</Box>
{orderBy === headCell.id
? (
<Box
component='span'
sx={{
border: 0,
clip: 'rect(0 0 0 0)',
height: 1,
margin: -1,
overflow: 'hidden',
padding: 0,
position: 'absolute',
top: 20,
width: 1
}}
>
{order === 'desc'
? 'sorted descending'
: 'sorted ascending'}
</Box>
)
: null}
</TableSortLabel>
</TableCell>
))}
</TableRow>
</TableHead>
)
}
const Transition = React.forwardRef(function Transition (
props: TransitionProps & { children: React.ReactElement },
ref: React.Ref<unknown>
) {
return <Slide direction='up' ref={ref} {...props} />
})
function RunningSessions (props) {
const [rowOpen, setRowOpen] = useState('')
const [rowLiveViewOpen, setRowLiveViewOpen] = useState('')
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
const [sessionToDelete, setSessionToDelete] = useState('')
const [deleteLocation, setDeleteLocation] = useState('') // 'info' or 'liveview'
const [feedbackMessage, setFeedbackMessage] = useState('')
const [feedbackOpen, setFeedbackOpen] = useState(false)
const [feedbackSeverity, setFeedbackSeverity] = useState('success')
const [order, setOrder] = useState<Order>('asc')
const [orderBy, setOrderBy] = useState<keyof SessionData>('sessionDurationMillis')
const [selected, setSelected] = useState<string[]>([])
const [page, setPage] = useState(0)
const [dense, setDense] = useState(false)
const [rowsPerPage, setRowsPerPage] = useState(10)
const [searchFilter, setSearchFilter] = useState('')
const [searchBarHelpOpen, setSearchBarHelpOpen] = useState(false)
const [selectedColumns, setSelectedColumns] = useState<string[]>(() => {
try {
const savedColumns = localStorage.getItem('selenium-grid-selected-columns')
return savedColumns ? JSON.parse(savedColumns) : []
} catch (e) {
console.error('Error loading saved columns:', e)
return []
}
})
const [headCells, setHeadCells] = useState<HeadCell[]>(fixedHeadCells)
const liveViewRef = useRef(null)
const navigate = useNavigate()
const handleDialogClose = () => {
if (liveViewRef.current) {
liveViewRef.current.disconnect()
}
navigate('/sessions')
}
const handleRequestSort = (event: React.MouseEvent<unknown>,
property: keyof SessionData) => {
const isAsc = orderBy === property && order === 'asc'
setOrder(isAsc ? 'desc' : 'asc')
setOrderBy(property)
}
const handleClick = (event: React.MouseEvent<unknown>, name: string) => {
const selectedIndex = selected.indexOf(name)
let newSelected: string[] = []
if (selectedIndex === -1) {
newSelected = newSelected.concat(selected, name)
} else if (selectedIndex === 0) {
newSelected = newSelected.concat(selected.slice(1))
} else if (selectedIndex === selected.length - 1) {
newSelected = newSelected.concat(selected.slice(0, -1))
} else if (selectedIndex > 0) {
newSelected = newSelected.concat(
selected.slice(0, selectedIndex),
selected.slice(selectedIndex + 1)
)
}
setSelected(newSelected)
}
const handleChangePage = (event: unknown, newPage: number) => {
setPage(newPage)
}
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10))
setPage(0)
}
const handleChangeDense = (event: React.ChangeEvent<HTMLInputElement>) => {
setDense(event.target.checked)
}
const isSelected = (name: string): boolean => selected.includes(name)
const handleDeleteConfirmation = (sessionId: string, location: string) => {
setSessionToDelete(sessionId)
setDeleteLocation(location)
setConfirmDeleteOpen(true)
}
const handleDeleteSession = async () => {
try {
const session = sessions.find(s => s.id === sessionToDelete)
if (!session) {
setFeedbackMessage('Session not found')
setFeedbackSeverity('error')
setConfirmDeleteOpen(false)
setFeedbackOpen(true)
return
}
let deleteUrl = ''
const parsed = JSON.parse(session.capabilities)
let wsUrl = parsed['webSocketUrl'] ?? ''
if (wsUrl.length > 0) {
try {
const url = new URL(origin)
const sessionUrl = new URL(wsUrl)
url.pathname = sessionUrl.pathname.split('/se/')[0] // Remove /se/ and everything after
url.protocol = sessionUrl.protocol === 'wss:' ? 'https:' : 'http:'
deleteUrl = url.href
} catch (error) {
deleteUrl = ''
}
}
if (!deleteUrl) {
const currentUrl = window.location.href
const baseUrl = currentUrl.split('/ui/')[0] // Remove /ui/ and everything after
deleteUrl = `${baseUrl}/session/${sessionToDelete}`
}
const response = await fetch(deleteUrl, {
method: 'DELETE'
})
if (response.ok) {
setFeedbackMessage('Session deleted successfully')
setFeedbackSeverity('success')
if (deleteLocation === 'liveview') {
handleDialogClose()
} else {
setRowOpen('')
}
} else {
setFeedbackMessage('Failed to delete session')
setFeedbackSeverity('error')
}
} catch (error) {
console.error('Error deleting session:', error)
setFeedbackMessage('Error deleting session')
setFeedbackSeverity('error')
}
setConfirmDeleteOpen(false)
setFeedbackOpen(true)
setSessionToDelete('')
setDeleteLocation('')
}
const handleCancelDelete = () => {
setConfirmDeleteOpen(false)
setSessionToDelete('')
setDeleteLocation('')
}
const displaySessionInfo = (id: string): JSX.Element => {
const handleInfoIconClick = (): void => {
setRowOpen(id)
}
return (
<IconButton
sx={{ padding: '1px' }}
onClick={handleInfoIconClick}
size='large'
>
<InfoIcon />
</IconButton>
)
}
const displayLiveView = (id: string): JSX.Element => {
const handleLiveViewIconClick = (): void => {
navigate(`/session/${id}`)
}
return (
<IconButton
sx={{ padding: '1px' }}
onClick={handleLiveViewIconClick}
size='large'
>
<VideocamIcon />
</IconButton>
)
}
const { sessions, origin, sessionId } = props
const getCapabilityValue = (capabilitiesStr: string, key: string): string => {
try {
const capabilities = JSON.parse(capabilitiesStr as string)
const value = capabilities[key]
if (value === undefined || value === null) {
return ''
}
if (typeof value === 'object') {
return JSON.stringify(value)
}
return String(value)
} catch (e) {
return ''
}
}
const hasDeleteSessionCapability = (capabilitiesStr: string): boolean => {
try {
const capabilities = JSON.parse(capabilitiesStr as string)
return capabilities['se:deleteSessionOnUi'] === true
} catch (e) {
return false
}
}
const rows = sessions.map((session) => {
const sessionData = createSessionData(
session.id,
session.capabilities,
session.startTime,
session.uri,
session.nodeId,
session.nodeUri,
session.sessionDurationMillis,
session.slot,
origin
)
selectedColumns.forEach(column => {
sessionData[column] = getCapabilityValue(session.capabilities, column)
})
return sessionData
})
const emptyRows = rowsPerPage - Math.min(rowsPerPage, rows.length - page * rowsPerPage)
useEffect(() => {
let s = sessionId || ''
let session_ids = sessions.map((session) => session.id)
if (!session_ids.includes(s)) {
setRowLiveViewOpen('')
navigate('/sessions')
} else {
setRowLiveViewOpen(s)
}
}, [sessionId, sessions])
useEffect(() => {
const dynamicHeadCells = selectedColumns.map(column => ({
id: column,
numeric: false,
label: column
}))
setHeadCells([...fixedHeadCells, ...dynamicHeadCells])
}, [selectedColumns])
return (
<Box width='100%'>
{rows.length > 0 && (
<div>
<Paper sx={{ width: '100%', marginBottom: 2 }}>
<EnhancedTableToolbar title='Running'>
<Box display="flex" alignItems="center">
<ColumnSelector
sessions={sessions}
selectedColumns={selectedColumns}
onColumnSelectionChange={(columns) => {
setSelectedColumns(columns)
localStorage.setItem('selenium-grid-selected-columns', JSON.stringify(columns))
}}
/>
<RunningSessionsSearchBar
searchFilter={searchFilter}
handleSearch={setSearchFilter}
searchBarHelpOpen={searchBarHelpOpen}
setSearchBarHelpOpen={setSearchBarHelpOpen}
/>
</Box>
</EnhancedTableToolbar>
<TableContainer>
<Table
sx={{ minWidth: '750px' }}
aria-labelledby='tableTitle'
size={dense ? 'small' : 'medium'}
aria-label='enhanced table'
>
<EnhancedTableHead
order={order}
orderBy={orderBy}
onRequestSort={handleRequestSort}
headCells={headCells}
/>
<TableBody>
{stableSort(rows, getComparator(order, orderBy))
.filter((session) => {
if (searchFilter === '') {
// don't filter anything on empty search field
return true
}
if (!searchFilter.includes('=')) {
// filter on the entire session if users don't use `=` symbol
return JSON.stringify(session)
.toLowerCase()
.includes(searchFilter.toLowerCase())
}
const [filterField, filterItem] = searchFilter.split('=')
if (filterField.startsWith('capabilities,')) {
const capabilityID = filterField.split(',')[1]
return (JSON.parse(session.capabilities as string) as object)[capabilityID] === filterItem
}
return session[filterField] === filterItem
})
.slice(page * rowsPerPage,
page * rowsPerPage + rowsPerPage)
.map((row, index) => {
const isItemSelected = isSelected(row.id as string)
const labelId = `enhanced-table-checkbox-${index}`
return (
<TableRow
hover
onClick={(event) =>
handleClick(event, row.id as string)}
role='checkbox'
aria-checked={isItemSelected}
tabIndex={-1}
key={row.id}
selected={isItemSelected}
>
<TableCell
component='th'
id={labelId}
scope='row'
align='left'
>
{
(row.vnc as string).length > 0 &&
displayLiveView(row.id as string)
}
{row.name}
{
(row.vnc as string).length > 0 &&
<Dialog
onClose={() => navigate("/sessions")}
aria-labelledby='live-view-dialog'
open={rowLiveViewOpen === row.id}
fullWidth
maxWidth='xl'
fullScreen
TransitionComponent={Transition}
>
<DialogTitle id='live-view-dialog'>
<Typography
gutterBottom component='span'
sx={{ paddingX: '10px' }}
>
<Box
fontWeight='fontWeightBold'
mr={1}
display='inline'
>
Session
</Box>
{row.name}
</Typography>
<OsLogo
osName={row.platformName as string}
/>
<BrowserLogo
browserName={row.browserName as string}
/>
{browserVersion(
row.browserVersion as string)}
</DialogTitle>
<DialogContent
dividers
sx={{ height: '600px', bgcolor: 'background.default', p: 0 }}
>
<LiveView
ref={liveViewRef}
url={row.vnc as string}
scaleViewport
onClose={() => navigate("/sessions")}
/>
</DialogContent>
<DialogActions>
<Button
onClick={handleDialogClose}
color='primary'
variant='contained'
>
Close
</Button>
</DialogActions>
</Dialog>
}
</TableCell>
<TableCell align='left'>
{displaySessionInfo(row.id as string)}
<OsLogo
osName={row.platformName as string}
size={Size.S}
/>
<BrowserLogo
browserName={row.browserName as string}
/>
{browserVersion(row.browserVersion as string)}
<Dialog
onClose={() => setRowOpen('')}
aria-labelledby='session-info-dialog'
open={rowOpen === row.id}
fullWidth
maxWidth='md'
>
<DialogTitle id='session-info-dialog'>
<Typography
gutterBottom component='span'
sx={{ paddingX: '10px' }}
>
<Box
fontWeight='fontWeightBold'
mr={1}
display='inline'
>
Session
</Box>
{row.name}
</Typography>
<OsLogo osName={row.platformName as string} />
<BrowserLogo
browserName={row.browserName as string}
/>
{browserVersion(row.browserVersion as string)}
</DialogTitle>
<DialogContent dividers>
<Typography gutterBottom>
Capabilities:
</Typography>
<Typography gutterBottom component='span'>
<pre>
{JSON.stringify(
JSON.parse(
row.capabilities as string) as object,
null, 2)}
</pre>
</Typography>
</DialogContent>
<DialogActions>
{hasDeleteSessionCapability(row.capabilities as string) && (
<Button
onClick={() => handleDeleteConfirmation(row.id as string, 'info')}
color='error'
variant='contained'
sx={{ marginRight: 1 }}
>
Delete
</Button>
)}
<Button
onClick={() => setRowOpen('')}
color='primary'
variant='contained'
>
Close
</Button>
</DialogActions>
</Dialog>
</TableCell>
<TableCell align='left'>
{row.startTime}
</TableCell>
<TableCell align='left'>
{prettyMilliseconds(
Number(row.sessionDurationMillis))}
</TableCell>
<TableCell align='left'>
{row.nodeUri}
</TableCell>
{/* Add dynamic columns */}
{selectedColumns.map(column => (
<TableCell key={column} align='left'>{row[column]}</TableCell>
))}
</TableRow>
)
})}
{emptyRows > 0 && (
<TableRow style={{ height: (dense ? 33 : 53) * emptyRows }}>
<TableCell colSpan={6} />
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[5, 10, 15]}
component='div'
count={rows.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</Paper>
<FormControlLabel
control={<Switch
checked={dense}
onChange={handleChangeDense}
/>}
label='Dense padding'
/>
</div>
)}
{/* Confirmation Dialog */}
<Dialog
open={confirmDeleteOpen}
onClose={handleCancelDelete}
aria-labelledby='delete-confirmation-dialog'
>
<DialogTitle id='delete-confirmation-dialog'>
Confirm Session Deletion
</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete this session? This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button
onClick={handleCancelDelete}
color='primary'
variant='outlined'
>
Cancel
</Button>
<Button
onClick={handleDeleteSession}
color='error'
variant='contained'
autoFocus
>
Delete
</Button>
</DialogActions>
</Dialog>
{/* Feedback Dialog */}
<Dialog
open={feedbackOpen}
onClose={() => setFeedbackOpen(false)}
aria-labelledby='feedback-dialog'
>
<DialogTitle id='feedback-dialog'>
{feedbackSeverity === 'success' ? 'Success' : 'Error'}
</DialogTitle>
<DialogContent>
<Typography color={feedbackSeverity === 'success' ? 'success.main' : 'error.main'}>
{feedbackMessage}
</Typography>
</DialogContent>
<DialogActions>
<Button
onClick={() => setFeedbackOpen(false)}
color='primary'
variant='contained'
>
OK
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
export default RunningSessions