OurBigBook logoOurBigBook Docs OurBigBook logoOurBigBook.comSite Source code
web/front/BuildJobs.tsx
import { useRouter } from 'next/router'
import React from 'react'

import CustomLink from 'front/CustomLink'
import Pagination from 'front/Pagination'
import { CancelIcon, ListIcon, OkIcon } from 'front'
import { webApi } from 'front/api'
import routes from 'front/routes'
import { formatNumberApprox } from 'ourbigbook'

type BulkJob = { username?: string; id: number; batchIndex?: number | null; batchCount?: number | null; phase: string; status: string; completed: number | null; total: number | null; error: string | null; createdAt: string; runtimeMs: number | null }
type BulkStatus = { jobs: BulkJob[]; jobsCount: number; todoCount: number; doneCount: number; ongoing?: boolean }

const BuildJobTable = ({ jobs, loading, error, done, global }: { jobs: BulkJob[]; loading: boolean; error: string; done: boolean; global: boolean }) => {
  return <table className="list" aria-label="Build jobs">
    <thead><tr>{global && <th>Username</th>}<th>Job</th><th>Job ID</th><th>Phase</th><th>Status</th><th>Progress</th><th>Created (UTC)</th>{done && <th>Runtime</th>}<th>Error</th></tr></thead>
    <tbody>
      {error && <tr><td colSpan={(done ? 8 : 7) + (global ? 1 : 0)} role="alert">{error}</td></tr>}
      {!jobs.length && !error && <tr><td colSpan={(done ? 8 : 7) + (global ? 1 : 0)}>{loading ? 'Loading jobs…' : 'No jobs yet.'}</td></tr>}
      {jobs.map(job => <tr key={`${job.phase}-${job.id}`}>
      {global && <td><CustomLink href={routes.user(job.username)}>{job.username}</CustomLink></td>}
      <td>{job.batchIndex == null || job.batchCount == null ? '—' : `${job.batchIndex + 1}/${job.batchCount}`}</td>
      <td>{job.id}</td>
      <td>{{ extract: 'ID extraction', check: 'Database check', render: 'Rendering', tree: 'Tree rebuild' }[job.phase] || job.phase}</td>
      <td>{job.status}</td>
      <td className="right">{job.total === null ? '—' : `${job.completed} / ${job.total}`}</td>
      <td><time dateTime={job.createdAt}>{job.createdAt ? (global ? job.createdAt : new Date(job.createdAt).toISOString().slice(0, 19).replace('T', ' ')) : '—'}</time></td>
      {done && <td className="right">{job.runtimeMs == null ? '—' : `${(job.runtimeMs / 1000).toFixed(3)} s`}</td>}
      <td>{job.error || ''}</td>
    </tr>)}</tbody>
  </table>
}

// Fetch the tab count once on Settings; poll only while the jobs table is open.
// The outer tab and the table share the same request and counts.
export function useBuildJobs(username?: string) {
  const router = useRouter()
  const buildsTab = router.query.tab === 'builds'
  const jobsView = buildsTab && router.query.jobs === 'done' ? 'done' : 'todo'
  const requestedPage = buildsTab ? Number(router.query.page || 1) : 1
  const jobsPage = Number.isSafeInteger(requestedPage) && requestedPage > 0 ? requestedPage - 1 : 0
  const jobsPerPage = 20
  const [bulkStatus, setBulkStatus] = React.useState<BulkStatus | null>(null)
  const [bulkError, setBulkError] = React.useState('')
  React.useEffect(() => {
    let active = true
    let timer: ReturnType<typeof setTimeout>
    setBulkStatus(null)
    const poll = async () => {
      try {
        const options = { view: jobsView, limit: buildsTab ? jobsPerPage : 0, offset: buildsTab ? jobsPage * jobsPerPage : 0 }
        const requestOptions = { timeout: 15000 }
        const { data, status } = await (username
          ? webApi.articlesBulkStatus(username, requestOptions, options)
          : webApi.siteJobs(requestOptions, options))
        if (status !== 200) throw new Error('Could not load background upload status')
        if (active) {
          setBulkStatus(data)
          setBulkError('')
        }
      } catch {
        if (active) setBulkError(`Could not load background upload status.${buildsTab ? ' Retrying…' : ''}`)
      } finally {
        if (active && buildsTab) timer = setTimeout(poll, 3000)
      }
    }
    poll()
    return () => { active = false; clearTimeout(timer) }
  }, [username, buildsTab, jobsView, jobsPage])
  return { bulkStatus, bulkError, jobsView, jobsPage, jobsPerPage }
}

export default function BuildJobs({ username, baseUrl, canCancel=false, buildJobs }: { username?: string; baseUrl: string; canCancel?: boolean; buildJobs: ReturnType<typeof useBuildJobs> }) {
  const { bulkStatus, bulkError, jobsView, jobsPage, jobsPerPage } = buildJobs
  const [cancelling, setCancelling] = React.useState(false)
  const [cancelMessage, setCancelMessage] = React.useState('')
  const cancelBuild = async () => {
    setCancelling(true)
    setCancelMessage('')
    try {
      const current = await webApi.articlesCurrentBuild({ timeout: 15000 }, username)
      if (current.status !== 200) throw new Error('Could not load the current build.')
      if (!window.confirm('Cancel this build? Completed articles will be kept.')) return
      const result = await webApi.articlesCancelBuild(current.data.build?.token || null, username, { timeout: 15000 })
      if (result.status !== 202) throw new Error('Could not cancel the build. It may have changed; refresh and try again.')
      setCancelMessage(result.data.message)
    } catch (error) {
      setCancelMessage(error.message)
    } finally {
      setCancelling(false)
    }
  }
  return <div id="background-uploads" className="list-container">
    <div className="tab-list" role="navigation" aria-label="Build job status">
      <CustomLink href={`${baseUrl}?tab=builds`} className={`tab-item${jobsView === 'todo' ? ' active' : ''}`}><ListIcon /> TODO{bulkStatus && <span className="mobile-hide"> ({formatNumberApprox(bulkStatus.todoCount)})</span>}</CustomLink>
      {' '}
      <CustomLink href={`${baseUrl}?tab=builds&jobs=done`} className={`tab-item${jobsView === 'done' ? ' active' : ''}`}><OkIcon /> Done{bulkStatus && <span className="mobile-hide"> ({formatNumberApprox(bulkStatus.doneCount)})</span>}</CustomLink>
    </div>
    <BuildJobTable jobs={bulkStatus?.jobs || []} loading={!bulkStatus} error={bulkError} done={jobsView === 'done'} global={!username} />
    {bulkStatus && <Pagination currentPage={jobsPage} itemsCount={bulkStatus.jobsCount} itemsPerPage={jobsPerPage} what="jobs" wrap={false} />}
    {canCancel && username && bulkStatus?.ongoing && <div className="build-actions">
      <button type="button" className="btn cancel-build" onClick={cancelBuild} disabled={cancelling}><CancelIcon title={null} /> {cancelling ? 'Cancelling…' : 'Cancel build'}</button>
    </div>}
    {cancelMessage && <div role="status">{cancelMessage}</div>}
  </div>
}