From 4194543026c3336755fb64a38bfb7e4bdfba5737 Mon Sep 17 00:00:00 2001 From: crypt0rr <57799908+crypt0rr@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:51:21 +0200 Subject: [PATCH] feat: pause and resume job schedules from the job page The job page has a Pause schedule / Resume schedule button that uses the existing pause and resume endpoints with the loaded revision, instead of requiring an edit and save of the whole job. It is disabled while the job's scan runs and hidden for archived jobs. The console now handles job.paused, job.resumed, the baseline events, job-silent, scan.cancellation_requested, scan.cycle_discarded and scanner-profile.changed with targeted query invalidation instead of refreshing every cached query in every open tab. Fixes #1232 --- .../user-guide/jobs-baselines-incidents.md | 5 +++ src/main.test.tsx | 18 +++++++++ src/main.tsx | 35 +++++++++++++++++ src/pages/JobDetail.actions.test.tsx | 39 ++++++++++++++++++- src/pages/JobDetail.tsx | 22 +++++++++++ 5 files changed, 117 insertions(+), 2 deletions(-) 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 1c18ecc3..dbc3fc16 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 5d05c7f6..b3013f90 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 8d54a1a4..42d28098 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 c46f8130..65df3a4b 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 b08536a4..fc320266 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 && } : } }