= {
'scan-canceled': 'Scan canceled',
'scan-interrupted': 'Scan interrupted',
'scan-anomaly': 'Scan anomaly',
+ 'scan-budget-exceeded': 'Scheduled scan skipped',
'application-update-available': 'Update available',
'application-updated': 'Application updated',
'job-silent': 'Job notification warning',
@@ -31,7 +32,7 @@ function eventTone(type: string) {
if (type === 'changes-recovered' || type === 'incident-accepted') return 'recovered'
if (type === 'changes-detected' || type === 'changes-reminder') return 'incident'
if (type === 'scan-failure') return 'failure'
- if (type === 'scan-anomaly') return 'warning'
+ if (type === 'scan-anomaly' || type === 'scan-budget-exceeded') return 'warning'
return 'neutral'
}
diff --git a/src/pages/JobDetail.actions.test.tsx b/src/pages/JobDetail.actions.test.tsx
index fb74c92..3e5ac64 100644
--- a/src/pages/JobDetail.actions.test.tsx
+++ b/src/pages/JobDetail.actions.test.tsx
@@ -136,6 +136,26 @@ describe('job detail actions', () => {
expect(screen.queryByRole('button', { name: 'Cancel queued scan' })).not.toBeInTheDocument()
})
+ it('explains why scheduled scans of an over-budget job are skipped', async () => {
+ vi.mocked(getJob).mockResolvedValue({ ...job, scan_budget: { exceeded: true, estimated_probes: 999, limit: 100, approval_would_fit: true } } as never)
+ const view = renderPage()
+ expect(await screen.findByText(/About 999 probes exceed this unit's probe budget of 100\. Scheduled scans are skipped until an administrator approves high-cost scans for this job\./)).toBeInTheDocument()
+ view.unmount()
+
+ vi.mocked(getJob).mockResolvedValue({ ...job, job: { ...job.job, allow_high_cost: true }, scan_budget: { exceeded: true, estimated_probes: 2000000000, limit: 1000000000, approval_would_fit: false } } as never)
+ renderPage()
+ expect(await screen.findByText(/Scheduled scans are skipped until its scope is reduced\./)).toBeInTheDocument()
+ expect(screen.getByText(/High-cost scans approved/)).toBeInTheDocument()
+ })
+
+ it('shows no budget warning for a job that fits its budget', async () => {
+ vi.mocked(getJob).mockResolvedValue({ ...job, scan_budget: { exceeded: false } } as never)
+ renderPage()
+ await screen.findByRole('button', { name: 'Scan now' })
+ expect(document.querySelector('.scan-budget-warning')).toBeNull()
+ expect(screen.queryByText(/High-cost scans approved/)).not.toBeInTheDocument()
+ })
+
it('clears a locally queued request when the scan appears in history', async () => {
const { client } = renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Scan now' }))
diff --git a/src/pages/JobDetail.tsx b/src/pages/JobDetail.tsx
index dcef694..dd625f7 100644
--- a/src/pages/JobDetail.tsx
+++ b/src/pages/JobDetail.tsx
@@ -44,7 +44,7 @@ import { ActionDialog } from '../components/ActionDialog'
import { ErrorNotice } from '../components/ErrorNotice'
import { PortScopeDetails } from '../components/PortScopeDetails'
import { SurfaceUnitList } from '../components/SurfaceUnitList'
-import type { ActiveScan, QueuedRun, WorkEstimate } from '../types'
+import type { ActiveScan, QueuedRun, ScanBudget, WorkEstimate } from '../types'
import { baselinePresentation } from '../baseline'
import { formatDateTime } from '../format'
import { changeKindLabel, jobStatePresentation, scanOutcomeTone, severityTone } from '../status'
@@ -541,7 +541,7 @@ export function JobDetail() {
{value.job.name}
{jobStatus.label}
- Revision {value.revision} · Updated {formatDateTime(value.updated_at)}
+ Revision {value.revision} · Updated {formatDateTime(value.updated_at)}{value.job.allow_high_cost ? ' · High-cost scans approved' : ''}
{canOperate &&
@@ -559,6 +559,7 @@ export function JobDetail() {
}
{actionError && {actionError}
}
+ {value.scan_budget?.exceeded && {scanBudgetMessage(value.scan_budget)}
}
{canOperate && canReadScans && active.error && active.refetch()} />}
{canOperate && canReadScans && (activeJobScan || pendingScanRequest || activeJobQueuedRun) &&
}
+function scanBudgetMessage(budget: ScanBudget) {
+ const estimate = budget.estimated_probes?.toLocaleString() ?? 'The estimated'
+ const limit = budget.limit?.toLocaleString() ?? 'its limit'
+ return budget.approval_would_fit
+ ? `About ${estimate} probes exceed this unit's probe budget of ${limit}. Scheduled scans are skipped until an administrator approves high-cost scans for this job.`
+ : `About ${estimate} probes exceed the most this job may send (${limit}). Scheduled scans are skipped until its scope is reduced.`
+}
+
function scanSkippedMessage(reason?: string) {
switch (reason) {
case 'busy': return 'The scan could not start because another scan already owns this job.'
diff --git a/src/pages/JobEditor.behavior.test.tsx b/src/pages/JobEditor.behavior.test.tsx
index 762e263..090207a 100644
--- a/src/pages/JobEditor.behavior.test.tsx
+++ b/src/pages/JobEditor.behavior.test.tsx
@@ -502,6 +502,7 @@ describe('job editor workflow coverage', () => {
renderWithProviders( } /> , { route: ['/jobs/job-1/edit'] })
await waitFor(() => expect(screen.getByDisplayValue('Broad edge')).toBeInTheDocument())
expect(await screen.findByText(/Only an administrator can approve high-cost scans\./)).toBeInTheDocument()
+ expect(screen.getByText(/Changing the targets, ports or scanner clears this approval, and an administrator must approve the new scope again\./)).toBeInTheDocument()
const highCost = screen.getByRole('checkbox', { name: /Allow high-cost scans/ })
expect(highCost).toBeChecked()
expect(highCost).toBeEnabled()
diff --git a/src/pages/JobEditor.tsx b/src/pages/JobEditor.tsx
index f6aa513..f7b1b92 100644
--- a/src/pages/JobEditor.tsx
+++ b/src/pages/JobEditor.tsx
@@ -385,7 +385,7 @@ export function JobEditor() {
: 'Compare DNS answer membership, individual host reachability, and ports/services. Choose aggregate mode only when DNS answers rotate routinely; it will not alert on address membership or per-backend reachability. Changing this requires confirming a new baseline.'}
Maximum expanded hosts hosts {(formErrors.max_expanded_hosts?.message || fieldErrors.max_expanded_hosts) && {formErrors.max_expanded_hosts?.message || fieldErrors.max_expanded_hosts} }
- Allow high-cost scans Override the deployment probe budget for deliberately broad scopes. The estimated cost is shown after saving.{session.data && !canApproveHighCost ? ' Only an administrator can approve high-cost scans.' : ''}
+ Allow high-cost scans Override the deployment probe budget for deliberately broad scopes. The estimated cost is shown after saving.{session.data && !canApproveHighCost ? ' Only an administrator can approve high-cost scans.' : ''}{session.data && !canApproveHighCost && existing.data?.job.allow_high_cost ? ' Changing the targets, ports or scanner clears this approval, and an administrator must approve the new scope again.' : ''}
Large CIDRs can take a long time to scan. The expansion limit protects the host from accidental wide scopes.
diff --git a/src/types.ts b/src/types.ts
index 9ffd3e3..11f8323 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -9,7 +9,10 @@ export type JobForm = {
resume_window?: string; notification_destinations?: string[]
}
export type WorkEstimate = { hosts: number; tcp_ports: number; udp_ports: number; probes: number; naabu_probes?: number; nmap_probes?: number; naabu_invocations?: number; nmap_invocations: number; estimated_seconds?: number; unknown_dns: number }
-export type Job = { id: string; revision: number; enabled: boolean; archived: boolean; security_hash: string; created_at: string; updated_at: string; job: JobForm; baseline: { status: string; samples?: number; attempts?: number; incomplete_attempts?: number; scan_id?: string; modified?: boolean; incidents?: number; pending?: number; host_count?: number }; scan_estimate?: WorkEstimate; scan_cycle?: ScanCycle | null; scan_cycle_error?: 'cycle_status_unavailable'; missing_notification_destinations?: string[] }
+export type Job = { id: string; revision: number; enabled: boolean; archived: boolean; security_hash: string; created_at: string; updated_at: string; job: JobForm; baseline: { status: string; samples?: number; attempts?: number; incomplete_attempts?: number; scan_id?: string; modified?: boolean; incidents?: number; pending?: number; host_count?: number }; scan_estimate?: WorkEstimate; scan_budget?: ScanBudget; high_cost_approval_cleared?: boolean; scan_cycle?: ScanCycle | null; scan_cycle_error?: 'cycle_status_unavailable'; missing_notification_destinations?: string[] }
+// scan_budget says whether the job's estimated work fits its unit's probe
+// budget; a scheduled run that does not fit is skipped before it starts.
+export type ScanBudget = { exceeded: boolean; estimated_probes?: number; limit?: number; approval_would_fit?: boolean }
export type Scan = { id: string; job_id?: string; job: string; job_revision?: number; started_at: string; finished_at: string; status: string; error?: string; nmap_version?: string; scanner_engine?: string; scanner_profile_id?: string; scanner_profile_revision?: number; naabu_version?: string; discovery_ports?: number; confirmed_ports?: number; discovery_duration_ms?: number; enrichment_duration_ms?: number; config_hash: string; cycle_id?: string; cycle_attempt?: number; cycle_status?: string; resumable?: boolean; completed_probes?: number; total_probes?: number; completed_units?: number; total_units?: number; no_progress_attempts?: number; baseline_scan_id?: string; baseline_config_hash?: string; snapshot?: { units: Unit[]; scopes: Scope[]; dns?: Record; hosts?: HostObservation[] } }
export type ScanSummary = { id: string; job_id?: string; job: string; job_revision?: number; started_at: string; finished_at: string; status: string; error?: string; nmap_version?: string; scanner_engine?: string; scanner_profile_id?: string; scanner_profile_revision?: number; naabu_version?: string; discovery_ports?: number; confirmed_ports?: number; discovery_duration_ms?: number; enrichment_duration_ms?: number; config_hash: string; cycle_id?: string; cycle_attempt?: number; cycle_status?: string; resumable?: boolean; completed_probes?: number; total_probes?: number; completed_units?: number; total_units?: number; no_progress_attempts?: number; baseline_scan_id?: string; baseline_config_hash?: string }
export type ActiveScan = {