Path: blob/trunk/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx
2887 views
// Licensed to the Software Freedom Conservancy (SFC) under one1// or more contributor license agreements. See the NOTICE file2// distributed with this work for additional information3// regarding copyright ownership. The SFC licenses this file4// to you under the Apache License, Version 2.0 (the5// "License"); you may not use this file except in compliance6// with the License. You may obtain a copy of the License at7//8// http://www.apache.org/licenses/LICENSE-2.09//10// Unless required by applicable law or agreed to in writing,11// software distributed under the License is distributed on an12// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY13// KIND, either express or implied. See the License for the14// specific language governing permissions and limitations15// under the License.1617import React, { useState, useRef, useEffect } from 'react'18import Table from '@mui/material/Table'19import TableBody from '@mui/material/TableBody'20import TableCell from '@mui/material/TableCell'21import TableContainer from '@mui/material/TableContainer'22import TableHead from '@mui/material/TableHead'23import TablePagination from '@mui/material/TablePagination'24import TableRow from '@mui/material/TableRow'25import TableSortLabel from '@mui/material/TableSortLabel'26import Typography from '@mui/material/Typography'27import Paper from '@mui/material/Paper'28import FormControlLabel from '@mui/material/FormControlLabel'29import Switch from '@mui/material/Switch'30import {31Box,32Button,33Dialog,34DialogActions,35DialogContent,36DialogTitle,37IconButton38} from '@mui/material'39import { Info as InfoIcon } from '@mui/icons-material'40import {Videocam as VideocamIcon } from '@mui/icons-material'41import Slide from '@mui/material/Slide'42import { TransitionProps } from '@mui/material/transitions'43import browserVersion from '../../util/browser-version'44import EnhancedTableToolbar from '../EnhancedTableToolbar'45import prettyMilliseconds from 'pretty-ms'46import BrowserLogo from '../common/BrowserLogo'47import OsLogo from '../common/OsLogo'48import RunningSessionsSearchBar from './RunningSessionsSearchBar'49import { Size } from '../../models/size'50import LiveView from '../LiveView/LiveView'51import SessionData, { createSessionData } from '../../models/session-data'52import { useNavigate } from 'react-router-dom'53import ColumnSelector from './ColumnSelector'5455function descendingComparator<T> (a: T, b: T, orderBy: keyof T): number {56if (orderBy === 'sessionDurationMillis') {57return Number(b[orderBy]) - Number(a[orderBy])58}59if (b[orderBy] < a[orderBy]) {60return -161}62if (b[orderBy] > a[orderBy]) {63return 164}65return 066}6768type Order = 'asc' | 'desc'6970function getComparator<Key extends keyof any> (71order: Order,72orderBy: Key73): (a: { [key in Key]: number | string }, b: { [key in Key]: number | string }) => number {74return order === 'desc'75? (a, b) => descendingComparator(a, b, orderBy)76: (a, b) => -descendingComparator(a, b, orderBy)77}7879function stableSort<T> (array: T[], comparator: (a: T, b: T) => number): T[] {80const stabilizedThis = array.map((el, index) => [el, index] as [T, number])81stabilizedThis.sort((a, b) => {82const order = comparator(a[0], b[0])83if (order !== 0) {84return order85}86return a[1] - b[1]87})88return stabilizedThis.map((el) => el[0])89}9091interface HeadCell {92id: keyof SessionData93label: string94numeric: boolean95}9697const fixedHeadCells: HeadCell[] = [98{ id: 'id', numeric: false, label: 'Session' },99{ id: 'capabilities', numeric: false, label: 'Capabilities' },100{ id: 'startTime', numeric: false, label: 'Start time' },101{ id: 'sessionDurationMillis', numeric: true, label: 'Duration' },102{ id: 'nodeUri', numeric: false, label: 'Node URI' }103]104105interface EnhancedTableProps {106onRequestSort: (event: React.MouseEvent<unknown>,107property: keyof SessionData) => void108order: Order109orderBy: string110headCells: HeadCell[]111}112113function EnhancedTableHead (props: EnhancedTableProps): JSX.Element {114const { order, orderBy, onRequestSort, headCells } = props115const createSortHandler = (property: keyof SessionData) => (event: React.MouseEvent<unknown>) => {116onRequestSort(event, property)117}118119return (120<TableHead>121<TableRow>122{headCells.map((headCell) => (123<TableCell124key={headCell.id}125align='left'126padding='normal'127sortDirection={orderBy === headCell.id ? order : false}128>129<TableSortLabel130active={orderBy === headCell.id}131direction={orderBy === headCell.id ? order : 'asc'}132onClick={createSortHandler(headCell.id)}133>134<Box fontWeight='fontWeightBold' mr={1} display='inline'>135{headCell.label}136</Box>137{orderBy === headCell.id138? (139<Box140component='span'141sx={{142border: 0,143clip: 'rect(0 0 0 0)',144height: 1,145margin: -1,146overflow: 'hidden',147padding: 0,148position: 'absolute',149top: 20,150width: 1151}}152>153{order === 'desc'154? 'sorted descending'155: 'sorted ascending'}156</Box>157)158: null}159</TableSortLabel>160</TableCell>161))}162</TableRow>163</TableHead>164)165}166167const Transition = React.forwardRef(function Transition (168props: TransitionProps & { children: React.ReactElement },169ref: React.Ref<unknown>170) {171return <Slide direction='up' ref={ref} {...props} />172})173174function RunningSessions (props) {175const [rowOpen, setRowOpen] = useState('')176const [rowLiveViewOpen, setRowLiveViewOpen] = useState('')177const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)178const [sessionToDelete, setSessionToDelete] = useState('')179const [deleteLocation, setDeleteLocation] = useState('') // 'info' or 'liveview'180const [feedbackMessage, setFeedbackMessage] = useState('')181const [feedbackOpen, setFeedbackOpen] = useState(false)182const [feedbackSeverity, setFeedbackSeverity] = useState('success')183const [order, setOrder] = useState<Order>('asc')184const [orderBy, setOrderBy] = useState<keyof SessionData>('sessionDurationMillis')185const [selected, setSelected] = useState<string[]>([])186const [page, setPage] = useState(0)187const [dense, setDense] = useState(false)188const [rowsPerPage, setRowsPerPage] = useState(10)189const [searchFilter, setSearchFilter] = useState('')190const [searchBarHelpOpen, setSearchBarHelpOpen] = useState(false)191const [selectedColumns, setSelectedColumns] = useState<string[]>(() => {192try {193const savedColumns = localStorage.getItem('selenium-grid-selected-columns')194return savedColumns ? JSON.parse(savedColumns) : []195} catch (e) {196console.error('Error loading saved columns:', e)197return []198}199})200const [headCells, setHeadCells] = useState<HeadCell[]>(fixedHeadCells)201const liveViewRef = useRef(null)202const navigate = useNavigate()203204const handleDialogClose = () => {205if (liveViewRef.current) {206liveViewRef.current.disconnect()207}208navigate('/sessions')209}210211const handleRequestSort = (event: React.MouseEvent<unknown>,212property: keyof SessionData) => {213const isAsc = orderBy === property && order === 'asc'214setOrder(isAsc ? 'desc' : 'asc')215setOrderBy(property)216}217218const handleClick = (event: React.MouseEvent<unknown>, name: string) => {219const selectedIndex = selected.indexOf(name)220let newSelected: string[] = []221222if (selectedIndex === -1) {223newSelected = newSelected.concat(selected, name)224} else if (selectedIndex === 0) {225newSelected = newSelected.concat(selected.slice(1))226} else if (selectedIndex === selected.length - 1) {227newSelected = newSelected.concat(selected.slice(0, -1))228} else if (selectedIndex > 0) {229newSelected = newSelected.concat(230selected.slice(0, selectedIndex),231selected.slice(selectedIndex + 1)232)233}234setSelected(newSelected)235}236237const handleChangePage = (event: unknown, newPage: number) => {238setPage(newPage)239}240241const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {242setRowsPerPage(parseInt(event.target.value, 10))243setPage(0)244}245246const handleChangeDense = (event: React.ChangeEvent<HTMLInputElement>) => {247setDense(event.target.checked)248}249250const isSelected = (name: string): boolean => selected.includes(name)251252const handleDeleteConfirmation = (sessionId: string, location: string) => {253setSessionToDelete(sessionId)254setDeleteLocation(location)255setConfirmDeleteOpen(true)256}257258const handleDeleteSession = async () => {259try {260const session = sessions.find(s => s.id === sessionToDelete)261if (!session) {262setFeedbackMessage('Session not found')263setFeedbackSeverity('error')264setConfirmDeleteOpen(false)265setFeedbackOpen(true)266return267}268269let deleteUrl = ''270271const parsed = JSON.parse(session.capabilities)272let wsUrl = parsed['webSocketUrl'] ?? ''273if (wsUrl.length > 0) {274try {275const url = new URL(origin)276const sessionUrl = new URL(wsUrl)277url.pathname = sessionUrl.pathname.split('/se/')[0] // Remove /se/ and everything after278url.protocol = sessionUrl.protocol === 'wss:' ? 'https:' : 'http:'279deleteUrl = url.href280} catch (error) {281deleteUrl = ''282}283}284285if (!deleteUrl) {286const currentUrl = window.location.href287const baseUrl = currentUrl.split('/ui/')[0] // Remove /ui/ and everything after288deleteUrl = `${baseUrl}/session/${sessionToDelete}`289}290291const response = await fetch(deleteUrl, {292method: 'DELETE'293})294295if (response.ok) {296setFeedbackMessage('Session deleted successfully')297setFeedbackSeverity('success')298if (deleteLocation === 'liveview') {299handleDialogClose()300} else {301setRowOpen('')302}303} else {304setFeedbackMessage('Failed to delete session')305setFeedbackSeverity('error')306}307} catch (error) {308console.error('Error deleting session:', error)309setFeedbackMessage('Error deleting session')310setFeedbackSeverity('error')311}312313setConfirmDeleteOpen(false)314setFeedbackOpen(true)315setSessionToDelete('')316setDeleteLocation('')317}318319const handleCancelDelete = () => {320setConfirmDeleteOpen(false)321setSessionToDelete('')322setDeleteLocation('')323}324325const displaySessionInfo = (id: string): JSX.Element => {326const handleInfoIconClick = (): void => {327setRowOpen(id)328}329return (330<IconButton331sx={{ padding: '1px' }}332onClick={handleInfoIconClick}333size='large'334>335<InfoIcon />336</IconButton>337)338}339340const displayLiveView = (id: string): JSX.Element => {341const handleLiveViewIconClick = (): void => {342navigate(`/session/${id}`)343}344return (345<IconButton346sx={{ padding: '1px' }}347onClick={handleLiveViewIconClick}348size='large'349>350<VideocamIcon />351</IconButton>352)353}354355const { sessions, origin, sessionId } = props356357const getCapabilityValue = (capabilitiesStr: string, key: string): string => {358try {359const capabilities = JSON.parse(capabilitiesStr as string)360const value = capabilities[key]361362if (value === undefined || value === null) {363return ''364}365366if (typeof value === 'object') {367return JSON.stringify(value)368}369370return String(value)371} catch (e) {372return ''373}374}375376const hasDeleteSessionCapability = (capabilitiesStr: string): boolean => {377try {378const capabilities = JSON.parse(capabilitiesStr as string)379return capabilities['se:deleteSessionOnUi'] === true380} catch (e) {381return false382}383}384385const rows = sessions.map((session) => {386const sessionData = createSessionData(387session.id,388session.capabilities,389session.startTime,390session.uri,391session.nodeId,392session.nodeUri,393session.sessionDurationMillis,394session.slot,395origin396)397398selectedColumns.forEach(column => {399sessionData[column] = getCapabilityValue(session.capabilities, column)400})401402return sessionData403})404const emptyRows = rowsPerPage - Math.min(rowsPerPage, rows.length - page * rowsPerPage)405406useEffect(() => {407let s = sessionId || ''408409let session_ids = sessions.map((session) => session.id)410411if (!session_ids.includes(s)) {412setRowLiveViewOpen('')413navigate('/sessions')414} else {415setRowLiveViewOpen(s)416}417}, [sessionId, sessions])418419useEffect(() => {420const dynamicHeadCells = selectedColumns.map(column => ({421id: column,422numeric: false,423label: column424}))425426setHeadCells([...fixedHeadCells, ...dynamicHeadCells])427}, [selectedColumns])428429return (430<Box width='100%'>431{rows.length > 0 && (432<div>433<Paper sx={{ width: '100%', marginBottom: 2 }}>434<EnhancedTableToolbar title='Running'>435<Box display="flex" alignItems="center">436<ColumnSelector437sessions={sessions}438selectedColumns={selectedColumns}439onColumnSelectionChange={(columns) => {440setSelectedColumns(columns)441localStorage.setItem('selenium-grid-selected-columns', JSON.stringify(columns))442}}443/>444<RunningSessionsSearchBar445searchFilter={searchFilter}446handleSearch={setSearchFilter}447searchBarHelpOpen={searchBarHelpOpen}448setSearchBarHelpOpen={setSearchBarHelpOpen}449/>450</Box>451</EnhancedTableToolbar>452<TableContainer>453<Table454sx={{ minWidth: '750px' }}455aria-labelledby='tableTitle'456size={dense ? 'small' : 'medium'}457aria-label='enhanced table'458>459<EnhancedTableHead460order={order}461orderBy={orderBy}462onRequestSort={handleRequestSort}463headCells={headCells}464/>465<TableBody>466{stableSort(rows, getComparator(order, orderBy))467.filter((session) => {468if (searchFilter === '') {469// don't filter anything on empty search field470return true471}472473if (!searchFilter.includes('=')) {474// filter on the entire session if users don't use `=` symbol475return JSON.stringify(session)476.toLowerCase()477.includes(searchFilter.toLowerCase())478}479480const [filterField, filterItem] = searchFilter.split('=')481if (filterField.startsWith('capabilities,')) {482const capabilityID = filterField.split(',')[1]483return (JSON.parse(session.capabilities as string) as object)[capabilityID] === filterItem484}485return session[filterField] === filterItem486})487.slice(page * rowsPerPage,488page * rowsPerPage + rowsPerPage)489.map((row, index) => {490const isItemSelected = isSelected(row.id as string)491const labelId = `enhanced-table-checkbox-${index}`492return (493<TableRow494hover495onClick={(event) =>496handleClick(event, row.id as string)}497role='checkbox'498aria-checked={isItemSelected}499tabIndex={-1}500key={row.id}501selected={isItemSelected}502>503<TableCell504component='th'505id={labelId}506scope='row'507align='left'508>509{510(row.vnc as string).length > 0 &&511displayLiveView(row.id as string)512}513{row.name}514{515(row.vnc as string).length > 0 &&516<Dialog517onClose={() => navigate("/sessions")}518aria-labelledby='live-view-dialog'519open={rowLiveViewOpen === row.id}520fullWidth521maxWidth='xl'522fullScreen523TransitionComponent={Transition}524>525<DialogTitle id='live-view-dialog'>526<Typography527gutterBottom component='span'528sx={{ paddingX: '10px' }}529>530<Box531fontWeight='fontWeightBold'532mr={1}533display='inline'534>535Session536</Box>537{row.name}538</Typography>539<OsLogo540osName={row.platformName as string}541/>542<BrowserLogo543browserName={row.browserName as string}544/>545{browserVersion(546row.browserVersion as string)}547</DialogTitle>548<DialogContent549dividers550sx={{ height: '600px' }}551>552<LiveView553ref={liveViewRef}554url={row.vnc as string}555scaleViewport556onClose={() => navigate("/sessions")}557/>558</DialogContent>559<DialogActions>560<Button561onClick={handleDialogClose}562color='primary'563variant='contained'564>565Close566</Button>567</DialogActions>568</Dialog>569}570</TableCell>571<TableCell align='left'>572{displaySessionInfo(row.id as string)}573<OsLogo574osName={row.platformName as string}575size={Size.S}576/>577<BrowserLogo578browserName={row.browserName as string}579/>580{browserVersion(row.browserVersion as string)}581<Dialog582onClose={() => setRowOpen('')}583aria-labelledby='session-info-dialog'584open={rowOpen === row.id}585fullWidth586maxWidth='md'587>588<DialogTitle id='session-info-dialog'>589<Typography590gutterBottom component='span'591sx={{ paddingX: '10px' }}592>593<Box594fontWeight='fontWeightBold'595mr={1}596display='inline'597>598Session599</Box>600{row.name}601</Typography>602<OsLogo osName={row.platformName as string} />603<BrowserLogo604browserName={row.browserName as string}605/>606{browserVersion(row.browserVersion as string)}607</DialogTitle>608<DialogContent dividers>609<Typography gutterBottom>610Capabilities:611</Typography>612<Typography gutterBottom component='span'>613<pre>614{JSON.stringify(615JSON.parse(616row.capabilities as string) as object,617null, 2)}618</pre>619</Typography>620</DialogContent>621<DialogActions>622{hasDeleteSessionCapability(row.capabilities as string) && (623<Button624onClick={() => handleDeleteConfirmation(row.id as string, 'info')}625color='error'626variant='contained'627sx={{ marginRight: 1 }}628>629Delete630</Button>631)}632<Button633onClick={() => setRowOpen('')}634color='primary'635variant='contained'636>637Close638</Button>639</DialogActions>640</Dialog>641</TableCell>642<TableCell align='left'>643{row.startTime}644</TableCell>645<TableCell align='left'>646{prettyMilliseconds(647Number(row.sessionDurationMillis))}648</TableCell>649<TableCell align='left'>650{row.nodeUri}651</TableCell>652{/* Add dynamic columns */}653{selectedColumns.map(column => (654<TableCell key={column} align='left'>{row[column]}</TableCell>655))}656</TableRow>657)658})}659{emptyRows > 0 && (660<TableRow style={{ height: (dense ? 33 : 53) * emptyRows }}>661<TableCell colSpan={6} />662</TableRow>663)}664</TableBody>665</Table>666</TableContainer>667<TablePagination668rowsPerPageOptions={[5, 10, 15]}669component='div'670count={rows.length}671rowsPerPage={rowsPerPage}672page={page}673onPageChange={handleChangePage}674onRowsPerPageChange={handleChangeRowsPerPage}675/>676</Paper>677<FormControlLabel678control={<Switch679checked={dense}680onChange={handleChangeDense}681/>}682label='Dense padding'683/>684</div>685)}686{/* Confirmation Dialog */}687<Dialog688open={confirmDeleteOpen}689onClose={handleCancelDelete}690aria-labelledby='delete-confirmation-dialog'691>692<DialogTitle id='delete-confirmation-dialog'>693Confirm Session Deletion694</DialogTitle>695<DialogContent>696<Typography>697Are you sure you want to delete this session? This action cannot be undone.698</Typography>699</DialogContent>700<DialogActions>701<Button702onClick={handleCancelDelete}703color='primary'704variant='outlined'705>706Cancel707</Button>708<Button709onClick={handleDeleteSession}710color='error'711variant='contained'712autoFocus713>714Delete715</Button>716</DialogActions>717</Dialog>718719{/* Feedback Dialog */}720<Dialog721open={feedbackOpen}722onClose={() => setFeedbackOpen(false)}723aria-labelledby='feedback-dialog'724>725<DialogTitle id='feedback-dialog'>726{feedbackSeverity === 'success' ? 'Success' : 'Error'}727</DialogTitle>728<DialogContent>729<Typography color={feedbackSeverity === 'success' ? 'success.main' : 'error.main'}>730{feedbackMessage}731</Typography>732</DialogContent>733<DialogActions>734<Button735onClick={() => setFeedbackOpen(false)}736color='primary'737variant='contained'738>739OK740</Button>741</DialogActions>742</Dialog>743</Box>744)745}746747export default RunningSessions748749750