github.com/filecoin-project/bacalhau@v0.3.23-0.20230228154132-45c989550ace/dashboard/frontend/src/pages/Jobs.tsx (about) 1 import React, { FC, useState, useEffect, useMemo, useCallback } from 'react' 2 import bluebird from 'bluebird' 3 import { A, navigate, useQueryParams } from 'hookrouter' 4 import Grid from '@mui/material/Grid' 5 import Container from '@mui/material/Container' 6 import TextField from '@mui/material/TextField' 7 import Button from '@mui/material/Button' 8 import Box from '@mui/material/Box' 9 import IconButton from '@mui/material/IconButton' 10 import Tooltip from '@mui/material/Tooltip' 11 import FormControl from '@mui/material/FormControl' 12 import FormLabel from '@mui/material/FormLabel' 13 import FormGroup from '@mui/material/FormGroup' 14 import FormControlLabel from '@mui/material/FormControlLabel' 15 import Checkbox from '@mui/material/Checkbox' 16 import { 17 DataGrid, 18 GridColDef, 19 GridSortModel, 20 GridSortDirection, 21 } from '@mui/x-data-grid' 22 23 import { 24 getShortId, 25 getJobStateTitle, 26 } from '../utils/job' 27 import { 28 Job, 29 AnnotationSummary, 30 } from '../types' 31 32 import RefreshIcon from '@mui/icons-material/Refresh' 33 import InfoIcon from '@mui/icons-material/InfoOutlined'; 34 import InputVolumes from '../components/job/InputVolumes' 35 import OutputVolumes from '../components/job/OutputVolumes' 36 import JobState from '../components/job/JobState' 37 import JobProgram from '../components/job/JobProgram' 38 import useLoadingErrorHandler from '../hooks/useLoadingErrorHandler' 39 import useApi from '../hooks/useApi' 40 41 const DEFAULT_PAGE_SIZE = 25 42 const PAGE_SIZES = [10, 25, 50, 100] 43 44 const columns: GridColDef[] = [ 45 { 46 field: 'actions', 47 headerName: 'Actions', 48 width: 50, 49 sortable: false, 50 filterable: false, 51 renderCell: (params: any) => { 52 return ( 53 <Box 54 sx={{ 55 display: 'flex', 56 justifyContent: 'flex-start', 57 alignItems: 'center', 58 width: '100%', 59 }} 60 component="div" 61 > 62 <IconButton 63 component="label" 64 onClick={ () => navigate(`/jobs/${params.row.job.Metadata.ID}`) } 65 > 66 <InfoIcon color="primary" /> 67 </IconButton> 68 </Box> 69 ) 70 }, 71 }, 72 { 73 field: 'id', 74 headerName: 'ID', 75 width: 100, 76 sortable: false, 77 filterable: false, 78 renderCell: (params: any) => { 79 return ( 80 <span style={{ 81 fontSize: '0.8em' 82 }}> 83 <A href={`/jobs/${params.row.job.Metadata.ID}`}>{ getShortId(params.row.job.Metadata.ID) }</A> 84 </span> 85 ) 86 }, 87 }, 88 { 89 field: 'date', 90 headerName: 'Date', 91 width: 120, 92 sortable: true, 93 filterable: false, 94 renderCell: (params: any) => { 95 return ( 96 <span style={{ 97 fontSize: '0.8em' 98 }}>{ params.row.date }</span> 99 ) 100 }, 101 }, 102 { 103 field: 'inputs', 104 headerName: 'Inputs', 105 width: 260, 106 sortable: false, 107 filterable: false, 108 renderCell: (params: any) => { 109 return ( 110 <InputVolumes 111 storageSpecs={ params.row.inputs } 112 includeDatacap={ false } 113 /> 114 ) 115 }, 116 }, 117 { 118 field: 'program', 119 headerName: 'Program', 120 flex: 1, 121 minWidth: 200, 122 sortable: false, 123 filterable: false, 124 renderCell: (params: any) => { 125 return ( 126 <JobProgram 127 job={ params.row.job } 128 /> 129 ) 130 }, 131 }, 132 { 133 field: 'outputs', 134 headerName: 'Outputs', 135 width: 200, 136 sortable: false, 137 filterable: false, 138 renderCell: (params: any) => { 139 return ( 140 <A href={`/jobs/${params.row.job.ID}`} style={{color: '#333'}}> 141 <OutputVolumes 142 outputVolumes={ params.row.outputs } 143 includeDatacap={ false } 144 /> 145 </A> 146 ) 147 }, 148 }, 149 { 150 field: 'state', 151 headerName: 'State', 152 width: 140, 153 sortable: false, 154 filterable: false, 155 renderCell: (params: any) => { 156 return ( 157 <JobState 158 job={ params.row.job } 159 /> 160 ) 161 }, 162 }, 163 ] 164 165 const Jobs: FC = () => { 166 const [ findJobID, setFindJobID ] = useState('') 167 const [ jobs, setJobs ] = useState<Job[]>([]) 168 const [ jobsCount, setJobsCount ] = useState(0) 169 const [ annotations, setAnnotations ] = useState<AnnotationSummary[]>([]) 170 const [ queryParams, setQueryParams ] = useQueryParams() 171 const api = useApi() 172 const loadingErrorHandler = useLoadingErrorHandler() 173 174 // annoyingly hookrouter queryParams mutates it's object 175 // so we need this to know if the query params have changed 176 const qs = JSON.stringify(queryParams) 177 178 const rows = useMemo(() => { 179 return jobs.map(job => { 180 const { 181 inputs = [], 182 outputs = [], 183 } = job.Spec 184 return { 185 job, 186 id: getShortId(job.Metadata.ID), 187 date: new Date(job.Metadata.CreatedAt).toLocaleDateString() + ' ' + new Date(job.Metadata.CreatedAt).toLocaleTimeString(), 188 inputs, 189 outputs, 190 shardState: getJobStateTitle(job), 191 } 192 }) 193 }, [ 194 jobs, 195 ]) 196 197 const sortModel = useMemo<GridSortModel | undefined>(() => { 198 if(!queryParams.sort_field || !queryParams.sort_order) return [{ 199 field: 'date', 200 sort: 'desc' as GridSortDirection, 201 }] 202 return [{ 203 field: queryParams.sort_field, 204 sort: queryParams.sort_order as GridSortDirection, 205 }] 206 }, [ 207 qs, 208 ]) 209 210 const page_size = useMemo(() => { 211 if(queryParams.page_size) { 212 let t = parseInt(queryParams.page_size) 213 return isNaN(t) ? DEFAULT_PAGE_SIZE : t 214 } else { 215 return DEFAULT_PAGE_SIZE 216 } 217 }, [ 218 qs, 219 ]) 220 221 const activeAnnotations = useMemo<string[]>(() => { 222 return queryParams.annotations ? queryParams.annotations.split(',') : [] 223 }, [ 224 qs, 225 annotations, 226 ]) 227 228 const updateAnnotation = useCallback((annotation: string, active: boolean) => { 229 let newAnnotations = activeAnnotations.filter(a => a != annotation) 230 if(active) { 231 newAnnotations = [...newAnnotations, annotation] 232 } 233 setQueryParams({ 234 annotations: newAnnotations.join(','), 235 }) 236 }, [ 237 activeAnnotations, 238 ]) 239 240 const loadAnnotations = useCallback(async () => { 241 const data = await api.get('/api/v1/summary/annotations') 242 setAnnotations(data) 243 }, []) 244 245 const loadJobs = useCallback(async (params: Record<string, string>) => { 246 const handler = loadingErrorHandler(async () => { 247 let page = parseInt(params.page) 248 let page_size = parseInt(params.page_size) 249 if (isNaN(page)) page = 0 250 if (isNaN(page_size)) page_size = DEFAULT_PAGE_SIZE 251 const activeAnnotations = queryParams.annotations ? queryParams.annotations.split(',') : [] 252 const query = { 253 return_all: true, 254 // we only really support sorting by date 255 sort_by: 'created_at', 256 sort_reverse: params.sort_order == 'asc' ? false : true, 257 limit: page_size, 258 offset: page * page_size, 259 include_tags: activeAnnotations, 260 } 261 const { 262 jobs, 263 count, 264 } = await bluebird.props({ 265 jobs: await api.post('/api/v1/jobs', query), 266 count: await api.post('/api/v1/jobs/count', query), 267 }) 268 setJobs(jobs) 269 setJobsCount(count.count) 270 }) 271 await handler() 272 }, []) 273 274 const reloadJobs = useCallback(async () => { 275 loadJobs(queryParams) 276 }, [ 277 queryParams, 278 ]) 279 280 // the grid does this annoying 281 const handleSortModelChange = useCallback(() => { 282 setQueryParams({ 283 sort_field: 'date', 284 sort_order: queryParams.sort_order == 'asc' ? 'desc' : 'asc', 285 }) 286 }, [ 287 setQueryParams, 288 qs, 289 ]) 290 291 const handlePageChange = useCallback((page: number) => { 292 setQueryParams({ 293 page, 294 }) 295 }, [ 296 setQueryParams, 297 qs, 298 ]) 299 300 const handlePageSizeChange = useCallback((page_size: number) => { 301 setQueryParams({ 302 page: 0, 303 page_size, 304 }) 305 }, [ 306 setQueryParams, 307 qs, 308 ]) 309 310 const findJob = useCallback(async () => { 311 const handler = loadingErrorHandler(async () => { 312 if(!findJobID) throw new Error(`please enter a job id`) 313 try { 314 const job = await api.get(`/api/v1/job/${findJobID}`) as Job 315 navigate(`/jobs/${job.Metadata.ID}`) 316 } catch(err: any) { 317 throw new Error(`could not load job with id ${findJobID}: ${err.toString()}`) 318 } 319 }) 320 await handler() 321 }, [ 322 findJobID, 323 ]) 324 325 useEffect(() => { 326 loadJobs(queryParams) 327 loadAnnotations() 328 }, [ 329 qs, 330 ]) 331 332 return ( 333 <Container 334 maxWidth={ 'xl' } 335 sx={{ 336 mt: 4, 337 mb: 4, 338 height: '100%', 339 }} 340 > 341 <Box 342 component="div" 343 sx={{ 344 display: 'flex', 345 flexDirection: 'column', 346 height: '100%', 347 }} 348 > 349 <Box 350 component="div" 351 sx={{ 352 display: 'flex', 353 flexDirection: 'row', 354 alignItems: 'center', 355 justifyContent: 'space-between', 356 }} 357 > 358 <Box 359 component="div" 360 sx={{ 361 flexGrow: 1, 362 }} 363 > 364 <TextField 365 fullWidth 366 size="small" 367 label="Find Job by ID" 368 value={ findJobID } 369 sx={{ 370 backgroundColor: 'white', 371 }} 372 onChange={ (e) => setFindJobID(e.target.value) } 373 /> 374 </Box> 375 <Box 376 component="div" 377 sx={{ 378 flexGrow: 0, 379 whiteSpace: 'nowrap' 380 }} 381 > 382 <Button 383 size="small" 384 variant="outlined" 385 sx={{ 386 height: '35px', 387 ml: 2, 388 }} 389 onClick={ findJob } 390 > 391 Find Job 392 </Button> 393 394 <Tooltip title="Refresh"> 395 <IconButton aria-label="delete" color="primary" onClick={ reloadJobs }> 396 <RefreshIcon /> 397 </IconButton> 398 </Tooltip> 399 400 </Box> 401 </Box> 402 <Box 403 component="div" 404 sx={{ 405 flexGrow: 1, 406 mt: 2, 407 }} 408 > 409 <div style={{ height: '100%', width: '100%' }}> 410 <DataGrid 411 rows={rows} 412 rowCount={jobsCount} 413 columns={columns} 414 pageSize={page_size} 415 rowsPerPageOptions={PAGE_SIZES} 416 paginationMode="server" 417 sortingMode="server" 418 sortModel={sortModel} 419 onSortModelChange={handleSortModelChange} 420 onPageChange={handlePageChange} 421 onPageSizeChange={handlePageSizeChange} 422 /> 423 </div> 424 </Box> 425 </Box> 426 </Container> 427 ) 428 } 429 430 export default Jobs