Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/src/content/docs/user-guide/jobs-baselines-incidents.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ and execution-tuning changes do not reset the baseline. A run that waits for a
free scan slot uses the job's settings when it starts; if the job is paused or
archived while a scheduled run waits, that run is skipped.

**Pause schedule** on the job page stops a job's scheduled runs without
editing the job, and **Resume schedule** starts them again; **Scan now** keeps
working while a job is paused. Pausing and resuming are unavailable while the
job's scan is running.

Archiving stops a job while keeping its results and incidents available. An
administrator can permanently delete an archived job by typing its exact name;
this also removes that job's scan results, incidents, saved scan progress, and
Expand Down
18 changes: 18 additions & 0 deletions src/main.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,24 @@ describe('application shell', () => {
act(() => stream.emit('scan-interrupted', 'job-9'))
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['activity-events'] })
expect(invalidate).not.toHaveBeenCalledWith()
for (const [type, key] of [
['job.paused', ['job', 'job-9']],
['job.resumed', ['jobs']],
['job-silent', ['activity-events']],
['baseline-approved', ['job-baseline-overview', 'job-9']],
['baseline-reset', ['baseline-hosts', 'job-9']],
['baseline-complete', ['job', 'job-9']],
['baseline-updated', ['job-pending-changes', 'job-9']],
['baseline-stalled', ['scan-cycle', 'job-9']],
['scan.cancellation_requested', ['active-scans']],
['scan.cycle_discarded', ['scan-cycle', 'job-9']],
['scanner-profile.changed', ['scanner-profiles']],
] as const) {
invalidate.mockClear()
act(() => stream.emit(type, 'job-9'))
expect(invalidate, type).toHaveBeenCalledWith({ queryKey: key })
expect(invalidate, type).not.toHaveBeenCalledWith()
}
act(() => stream.emit('scan.skipped', 'job-9', 'paused'))
expect(skipped).toHaveBeenCalledWith(expect.objectContaining({ detail: { job_id: 'job-9', reason: 'paused' } }))
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['active-scans'] })
Expand Down
35 changes: 35 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,45 @@ export function Shell({ displayName, role, permissions, onLogout, unit }: { disp
case 'job.archived':
case 'job.restored':
case 'job.deleted':
case 'job.paused':
case 'job.resumed':
void client.invalidateQueries({ queryKey: ['jobs'] })
void client.invalidateQueries({ queryKey: ['activity-events'] })
if (event.job_id) void client.invalidateQueries({ queryKey: ['job', event.job_id] })
break
case 'job-silent':
void client.invalidateQueries({ queryKey: ['jobs'] })
void client.invalidateQueries({ queryKey: ['activity-events'] })
break
case 'baseline-approved':
case 'baseline-reset':
case 'baseline-complete':
case 'baseline-updated':
case 'baseline-stalled':
void client.invalidateQueries({ queryKey: ['jobs'] })
void client.invalidateQueries({ queryKey: ['activity-events'] })
void client.invalidateQueries({ queryKey: ['incidents'] })
if (event.job_id) {
void client.invalidateQueries({ queryKey: ['job', event.job_id] })
void client.invalidateQueries({ queryKey: ['job-baseline-overview', event.job_id] })
void client.invalidateQueries({ queryKey: ['baseline-hosts', event.job_id] })
void client.invalidateQueries({ queryKey: ['job-pending-changes', event.job_id] })
void client.invalidateQueries({ queryKey: ['scan-cycle', event.job_id] })
}
break
case 'scan.cancellation_requested':
void client.invalidateQueries({ queryKey: ['active-scans'] })
break
case 'scan.cycle_discarded':
void client.invalidateQueries({ queryKey: ['jobs'] })
if (event.job_id) {
void client.invalidateQueries({ queryKey: ['job', event.job_id] })
void client.invalidateQueries({ queryKey: ['scan-cycle', event.job_id] })
}
break
case 'scanner-profile.changed':
void client.invalidateQueries({ queryKey: ['scanner-profiles'] })
break
case 'notification.changed':
void client.invalidateQueries({ queryKey: ['notifications'] })
void client.invalidateQueries({ queryKey: ['admin-status'] })
Expand Down
39 changes: 37 additions & 2 deletions src/pages/JobDetail.actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Route, Routes } from 'react-router-dom'
import { APIError, activeScans, approveBaseline, archiveJob, cancelScan, deleteJob, discardScanCycle, getJob, getSession, jobBaseline, jobScans, latestSuccessfulScan, resetBaseline, restoreJob, runJob, scanCycle, scanDetail, scanHosts, scanResults } from '../api'
import { APIError, activeScans, approveBaseline, archiveJob, cancelScan, deleteJob, discardScanCycle, getJob, getSession, jobBaseline, jobScans, latestSuccessfulScan, pauseJob, resetBaseline, restoreJob, resumeJob, runJob, scanCycle, scanDetail, scanHosts, scanResults } from '../api'
import { renderWithProviders, defaultUnitScope } from '../test/test-utils'
import { JobDetail } from './JobDetail'

vi.mock('../api', async () => {
const actual = await vi.importActual<typeof import('../api')>('../api')
return { ...actual, activeScans: vi.fn(), approveBaseline: vi.fn(), archiveJob: vi.fn(), cancelScan: vi.fn(), deleteJob: vi.fn(), getJob: vi.fn(), getSession: vi.fn(), jobBaseline: vi.fn(), jobScans: vi.fn(), latestSuccessfulScan: vi.fn(), resetBaseline: vi.fn(), restoreJob: vi.fn(), runJob: vi.fn(), scanCycle: vi.fn(), discardScanCycle: vi.fn(), scanDetail: vi.fn(), scanHosts: vi.fn(), scanResults: vi.fn() }
return { ...actual, activeScans: vi.fn(), approveBaseline: vi.fn(), archiveJob: vi.fn(), cancelScan: vi.fn(), deleteJob: vi.fn(), getJob: vi.fn(), getSession: vi.fn(), jobBaseline: vi.fn(), jobScans: vi.fn(), latestSuccessfulScan: vi.fn(), pauseJob: vi.fn(), resetBaseline: vi.fn(), restoreJob: vi.fn(), resumeJob: vi.fn(), runJob: vi.fn(), scanCycle: vi.fn(), discardScanCycle: vi.fn(), scanDetail: vi.fn(), scanHosts: vi.fn(), scanResults: vi.fn() }
})

const job = {
Expand Down Expand Up @@ -45,6 +45,8 @@ describe('job detail actions', () => {
vi.mocked(resetBaseline).mockResolvedValue(undefined)
vi.mocked(approveBaseline).mockResolvedValue(undefined)
vi.mocked(archiveJob).mockResolvedValue(undefined)
vi.mocked(pauseJob).mockResolvedValue(undefined)
vi.mocked(resumeJob).mockResolvedValue(undefined)
vi.mocked(restoreJob).mockResolvedValue(undefined)
vi.mocked(deleteJob).mockResolvedValue(undefined)
})
Expand Down Expand Up @@ -195,6 +197,39 @@ describe('job detail actions', () => {
await waitFor(() => expect(screen.getByRole('button', { name: 'Scan now' })).toBeEnabled())
})

it('pauses and resumes the schedule from the job page with the loaded revision', async () => {
vi.mocked(pauseJob).mockImplementation(async () => {
vi.mocked(getJob).mockResolvedValue({ ...job, enabled: false, revision: 8 } as never)
})
renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Pause schedule' }))
await waitFor(() => expect(pauseJob).toHaveBeenCalledWith('job-1', 7))

fireEvent.click(await screen.findByRole('button', { name: 'Resume schedule' }))
await waitFor(() => expect(resumeJob).toHaveBeenCalledWith('job-1', 8))
})

it('lets an operator pause a job and explains a refusal while a scan runs', async () => {
vi.mocked(getSession).mockResolvedValue(operator)
vi.mocked(pauseJob).mockRejectedValueOnce(new APIError('pause or resume is unavailable while a scan is running; wait for it to finish and try again', 'job_active', undefined, 409))
renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Pause schedule' }))
expect(await screen.findByRole('alert')).toHaveTextContent('unavailable while a scan is running')
})

it('disables the schedule control while a scan runs and hides it for archived jobs', async () => {
vi.mocked(activeScans).mockResolvedValue({ scans: [activeScan] } as never)
const view = renderPage()
await waitFor(() => expect(screen.getByRole('button', { name: 'Pause schedule' })).toBeDisabled())
view.unmount()

vi.mocked(activeScans).mockResolvedValue({ scans: [] })
vi.mocked(getJob).mockResolvedValue({ ...job, archived: true, enabled: false } as never)
renderPage()
await screen.findByRole('button', { name: 'Restore' })
expect(screen.queryByRole('button', { name: /schedule/ })).not.toBeInTheDocument()
})

it('shows the active phase and progress and allows cancellation from the job page', async () => {
vi.mocked(activeScans).mockResolvedValueOnce({ scans: [] }).mockResolvedValue({ scans: [activeScan] } as never)
vi.mocked(cancelScan).mockImplementation(async (id) => {
Expand Down
22 changes: 22 additions & 0 deletions src/pages/JobDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import {
Archive,
CalendarClock,
CheckCircle2,
Clock3,
Edit3,
Pause,
Play,
RotateCcw,
ShieldAlert,
Expand All @@ -24,7 +26,9 @@ import {
jobScans,
latestSuccessfulScan,
jobBaseline,
pauseJob,
resetBaseline,
resumeJob,
restoreJob,
runJob,
scanCycle,
Expand Down Expand Up @@ -382,6 +386,19 @@ export function JobDetail() {
setActionBusy('')
}
}
async function setSchedule(enabled: boolean) {
setActionError('')
setActionBusy(enabled ? 'resume' : 'pause')
try {
await (enabled ? resumeJob : pauseJob)(id, value.revision)
await client.invalidateQueries({ queryKey: ['job', id] })
await client.invalidateQueries({ queryKey: ['jobs'] })
} catch (err) {
reportLifecycleError(err, enabled ? 'Could not resume this job.' : 'Could not pause this job.')
} finally {
setActionBusy('')
}
}
async function restore() {
setActionError('')
setActionBusy('restore')
Expand Down Expand Up @@ -516,6 +533,11 @@ export function JobDetail() {
<button className="button secondary" onClick={() => navigate(`/jobs/${id}/edit`)} disabled={!!actionBusy}>
<Edit3 size={16} /> Edit
</button>
{!value.archived && <button className="button secondary" onClick={() => void setSchedule(!value.enabled)} disabled={!!actionBusy || !!activeJobScan} title={activeJobScan ? 'Available when the running scan finishes' : undefined}>
{value.enabled
? <><Pause size={16} /> {actionBusy === 'pause' ? 'Pausing…' : 'Pause schedule'}</>
: <><CalendarClock size={16} /> {actionBusy === 'resume' ? 'Resuming…' : 'Resume schedule'}</>}
</button>}
{value.archived ? <><button className="button secondary" onClick={restore} disabled={!!actionBusy}>{actionBusy === 'restore' ? 'Restoring…' : 'Restore'}</button>{canDelete && <button className="button danger" onClick={() => { setActionError(''); setDialog('delete') }} disabled={!!actionBusy}>{actionBusy === 'delete' ? 'Deleting…' : 'Delete permanently'}</button>}</> : <button className="icon-button danger" aria-label="Archive job" onClick={() => { setActionError(''); setDialog('archive') }} disabled={!!actionBusy}><Archive size={17} /></button>}
</div>}
</div>
Expand Down
Loading