diff --git a/docs/src/content/docs/user-guide/jobs-baselines-incidents.md b/docs/src/content/docs/user-guide/jobs-baselines-incidents.md index 1c18ecc..dbc3fc1 100644 --- a/docs/src/content/docs/user-guide/jobs-baselines-incidents.md +++ b/docs/src/content/docs/user-guide/jobs-baselines-incidents.md @@ -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 diff --git a/src/main.test.tsx b/src/main.test.tsx index 5d05c7f..b3013f9 100644 --- a/src/main.test.tsx +++ b/src/main.test.tsx @@ -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'] }) diff --git a/src/main.tsx b/src/main.tsx index 8d54a1a..42d2809 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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'] }) diff --git a/src/pages/JobDetail.actions.test.tsx b/src/pages/JobDetail.actions.test.tsx index c46f813..65df3a4 100644 --- a/src/pages/JobDetail.actions.test.tsx +++ b/src/pages/JobDetail.actions.test.tsx @@ -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('../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 = { @@ -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) }) @@ -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) => { diff --git a/src/pages/JobDetail.tsx b/src/pages/JobDetail.tsx index b08536a..fc32026 100644 --- a/src/pages/JobDetail.tsx +++ b/src/pages/JobDetail.tsx @@ -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, @@ -24,7 +26,9 @@ import { jobScans, latestSuccessfulScan, jobBaseline, + pauseJob, resetBaseline, + resumeJob, restoreJob, runJob, scanCycle, @@ -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') @@ -516,6 +533,11 @@ export function JobDetail() { + {!value.archived && } {value.archived ? <>{canDelete && } : } }