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&nbsp;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