From 61dfa3b25cc886da63d6f6821cf11dc3837025ab Mon Sep 17 00:00:00 2001 From: crypt0rr <57799908+crypt0rr@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:07:33 +0200 Subject: [PATCH] fix(ui): normalize state labels and tones --- e2e/responsive-issue-regressions.spec.ts | 22 +++++++++ scripts/coverage-gates.test.mjs | 1 + src/main.test.tsx | 10 ++++ src/main.tsx | 46 ++++++++++++++++-- src/pages/Dashboard.test.tsx | 24 +++++++++ src/pages/Dashboard.tsx | 6 ++- src/pages/HostDetail.tsx | 3 +- src/pages/JobDetail.test.tsx | 12 +++++ src/pages/JobDetail.tsx | 10 ++-- src/pages/platform/PlatformAdmins.tsx | 4 +- src/pages/platform/PlatformPages.test.tsx | 5 ++ src/pages/platform/UnitAccounts.tsx | 2 +- src/pages/platform/UnitDetail.test.tsx | 1 + src/status.test.ts | 34 +++++++++++++ src/status.ts | 59 +++++++++++++++++++++++ src/styles.css | 8 +++ 16 files changed, 232 insertions(+), 15 deletions(-) create mode 100644 src/status.test.ts create mode 100644 src/status.ts diff --git a/e2e/responsive-issue-regressions.spec.ts b/e2e/responsive-issue-regressions.spec.ts index e00b3fbb..b67f9248 100644 --- a/e2e/responsive-issue-regressions.spec.ts +++ b/e2e/responsive-issue-regressions.spec.ts @@ -193,6 +193,28 @@ test.describe('responsive issue regressions', () => { await expectNoHorizontalScroll(page) }) + test('status labels stay readable and archived host badges stay compact (#994)', async ({ page }) => { + await mockConsole(page, 'administrator') + await page.route('**/api/v1/hosts**', async route => { + if (route.request().method() !== 'GET') return route.fallback() + await route.fulfill({ json: { hosts: [{ + address: '198.51.100.20', job_id: 'job-archived', job: 'retired job', scan_id: 'scan-archived', + scanned_at: timestamp, data_quality: 'detailed', open_ports: 0, open_filtered_ports: 0, + has_open_ports: false, archived: true, + }], pagination: { limit: 100, offset: 0, total: 1, has_more: false, next_offset: null } } }) + }) + + await page.goto('/scanner-profiles') + const builtIn = page.getByText('Built-in', { exact: true }) + await expect(builtIn).toBeVisible() + expect(await builtIn.evaluate(element => getComputedStyle(element).textTransform)).toBe('none') + + await page.goto('/public-dashboard') + const archived = page.locator('.public-picker-archived') + await expect(archived).toHaveText('Archived') + expect(await archived.evaluate(element => getComputedStyle(element).justifySelf)).toBe('start') + }) + test('long dashboard values stay in shrinkable cards (#971)', async ({ page }) => { await mockConsole(page, 'operator') await page.route('**/api/v1/jobs**', async route => { diff --git a/scripts/coverage-gates.test.mjs b/scripts/coverage-gates.test.mjs index 8d006708..253beead 100644 --- a/scripts/coverage-gates.test.mjs +++ b/scripts/coverage-gates.test.mjs @@ -59,6 +59,7 @@ const frontendReport = (overrides = {}) => { 'src/pages/Security.tsx', 'src/pages/TotpEnrollment.tsx', 'src/pages/Users.tsx', + 'src/status.ts', 'src/target.ts', 'src/types.ts', 'src/useDebouncedValue.ts', diff --git a/src/main.test.tsx b/src/main.test.tsx index a3426f8e..45a30ed4 100644 --- a/src/main.test.tsx +++ b/src/main.test.tsx @@ -238,6 +238,14 @@ describe('application shell', () => { await waitFor(() => expect(screen.getByText('No jobs configured')).toBeInTheDocument()) }) + it('shows paused jobs in amber on the jobs list', async () => { + vi.mocked(listJobs).mockResolvedValue({ jobs: [{ id: 'job-paused', revision: 1, enabled: false, archived: false, job: { name: 'Paused monitor', targets: [], schedule: '0 * * * *' }, baseline: { status: 'complete', samples: 1, host_count: 0 } }] } as never) + renderWithProviders() + const card = await screen.findByRole('link', { name: /Paused monitor/ }) + expect(card.querySelector('.pill')).toHaveTextContent('Paused') + expect(card.querySelector('.pill')).toHaveClass('amber') + }) + it('lists a baseline whose stored scope is being updated as ready', async () => { vi.mocked(listJobs).mockResolvedValue({ jobs: [{ id: 'job-4', revision: 3, enabled: true, archived: false, job: { name: 'Legacy ports', targets: ['198.51.100.13'], tcp: { ports: '2, 1' }, schedule: '0 * * * *', baseline_samples: 2 }, baseline: { status: 'updating', scan_id: 'scan-4', host_count: 1, samples: 0 } }] } as never) renderWithProviders() @@ -279,6 +287,8 @@ describe('application shell', () => { vi.mocked(listIncidents).mockResolvedValue({ incidents: [incident, legacy], pagination: { limit: 50, offset: 0, total: 2, has_more: false, next_offset: null } } as never) renderWithProviders() await waitFor(() => expect(screen.getAllByRole('button', { name: 'Accept change' })).toHaveLength(4)) + expect(screen.getAllByText('Port opened / tcp:443').length).toBeGreaterThan(0) + expect(screen.getAllByText('Critical').length).toBeGreaterThan(0) expect(screen.getAllByText('No before/after value recorded')).toHaveLength(2) expect(screen.getAllByRole('button', { name: 'Accept change' })[1]).toBeDisabled() fireEvent.click(screen.getAllByRole('button', { name: 'Accept change' })[0]) diff --git a/src/main.tsx b/src/main.tsx index 7b3c9c73..472a003d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -30,6 +30,7 @@ import { compactPortExpression } from './components/PortScopeDetails' import type { Incident } from './types' import { baselinePresentation } from './baseline' import { formatDateTime } from './format' +import { changeKindLabel, jobStatePresentation, severityLabel } from './status' import './tailwind.css' import './styles.css' @@ -214,7 +215,44 @@ export function Jobs() { const jobs = useQuery({ queryKey: ['jobs', true], queryFn: () => listJobs(true) }) const session = useQuery({ queryKey: ['session'], queryFn: getSession }) const canWrite = session.data?.permissions.includes('jobs.write') ?? false - return

Configuration

Jobs

Each job owns its targets, protocols, schedule, and baseline.

{canWrite && }
{jobs.isLoading ? : jobs.error ? jobs.refetch()} /> :
{jobs.data?.jobs.map(job => { const baseline = baselinePresentation(job.baseline); return
{job.archived ? 'Archived' : job.enabled ? 'Scheduled' : 'Paused'}r{job.revision}

{job.job.name}

{job.job.targets.length} target{job.job.targets.length === 1 ? '' : 's'} · {protocolSummary(job)}

{baseline.marker} {baseline.status === 'complete' ? `Baseline ${baseline.label.toLowerCase()}` : baseline.status === 'stalled' ? 'Baseline stalled' : `Collecting ${job.baseline.samples ?? 0}/${job.job.baseline_samples}`}{job.job.schedule}
})}{!jobs.data?.jobs.length && canWrite && navigate('/jobs/new')}>Create a job} />}{!jobs.data?.jobs.length && !canWrite && }
}
+ return ( +
+
+
+

Configuration

+

Jobs

+

Each job owns its targets, protocols, schedule, and baseline.

+
+ {canWrite && } +
+ {jobs.isLoading ? : jobs.error ? jobs.refetch()} /> : ( +
+ {jobs.data?.jobs.map(job => { + const baseline = baselinePresentation(job.baseline) + const state = jobStatePresentation(job.archived, job.enabled) + return ( + +
+ {state.label} + r{job.revision} +
+

{job.job.name}

+

{job.job.targets.length} target{job.job.targets.length === 1 ? '' : 's'} · {protocolSummary(job)}

+
+ + {baseline.marker} {baseline.status === 'complete' ? `Baseline ${baseline.label.toLowerCase()}` : baseline.status === 'stalled' ? 'Baseline stalled' : `Collecting ${job.baseline.samples ?? 0}/${job.job.baseline_samples}`} + + {job.job.schedule} +
+ + ) + })} + {!jobs.data?.jobs.length && canWrite && navigate('/jobs/new')}>Create a job} />} + {!jobs.data?.jobs.length && !canWrite && } +
+ )} +
+ ) } function protocolSummary(job: { job: { tcp?: { ports: string }; udp?: { ports: string } } }) { @@ -286,18 +324,18 @@ function IncidentTableRow({ row, busy, onAction }: { row: Incident; busy: string const key = row.incident.change.key const acceptID = `accept:${row.job_id}:${key ?? ''}` const suppressID = `suppress:${row.job_id}:${key ?? ''}` - return {row.job}{row.incident.change.target}{formatIncidentChange(row.incident.change)}
{changeValues(row.incident.change)}{row.incident.change.severity}{formatDateTime(row.incident.last_seen_at)} + return {row.job}{row.incident.change.target}{formatIncidentChange(row.incident.change)}
{changeValues(row.incident.change)}{severityLabel(row.incident.change.severity)}{formatDateTime(row.incident.last_seen_at)} } function IncidentCard({ row, busy, onAction }: { row: Incident; busy: string; onAction: (row: Incident, action: 'accept' | 'suppress') => void }) { const key = row.incident.change.key const acceptID = `accept:${row.job_id}:${key ?? ''}` const suppressID = `suppress:${row.job_id}:${key ?? ''}` - return
{row.job}{row.incident.change.severity}
Target
{row.incident.change.target}
Change
{formatIncidentChange(row.incident.change)}
{changeValues(row.incident.change)}
Last seen
{formatDateTime(row.incident.last_seen_at)}
+ return
{row.job}{severityLabel(row.incident.change.severity)}
Target
{row.incident.change.target}
Change
{formatIncidentChange(row.incident.change)}
{changeValues(row.incident.change)}
Last seen
{formatDateTime(row.incident.last_seen_at)}
} function formatIncidentChange(change: Incident['incident']['change']) { - return `${change.kind}${change.port ? ` / ${change.protocol}:${change.port}` : ''}` + return `${changeKindLabel(change.kind, change.old, change.new)}${change.port ? ` / ${change.protocol}:${change.port}` : ''}` } function changeValues(change: Incident['incident']['change']) { diff --git a/src/pages/Dashboard.test.tsx b/src/pages/Dashboard.test.tsx index 27b3e1f2..04bfc5df 100644 --- a/src/pages/Dashboard.test.tsx +++ b/src/pages/Dashboard.test.tsx @@ -185,6 +185,30 @@ describe('dashboard', () => { expect(container.textContent).toContain('1 destination tested') }) + it('labels the legacy-job action as a status check and refreshes the status', async () => { + vi.mocked(adminStatus).mockResolvedValue({ ...status, legacy_yaml_jobs: ['office'] }) + await renderDashboard() + const checkAgain = Array.from(container.querySelectorAll('button')).find(button => button.textContent === 'Check again') as HTMLButtonElement + expect(checkAgain).toBeTruthy() + expect(container.querySelector('[aria-label="Refresh status"]')).toBeNull() + await act(async () => { + checkAgain.click() + await Promise.resolve() + await Promise.resolve() + }) + expect(adminStatus).toHaveBeenCalledTimes(2) + }) + + it('uses neutral activity markers for incomplete scans and red only for failures', async () => { + vi.mocked(listScans).mockResolvedValue({ scans: [{ ...scan, id: 'incomplete', status: 'incomplete' }, { ...scan, id: 'failed', status: 'failed' }], pagination: { ...pagination, total: 2 } }) + await renderDashboard() + const dots = Array.from(container.querySelectorAll('.activity-list .activity-dot')) + expect(dots).toHaveLength(2) + expect(dots[0]).not.toHaveClass('fail') + expect(dots[0]).not.toHaveClass('success') + expect(dots[1]).toHaveClass('fail') + }) + it('warns about a proxy that web.trusted_proxies does not list, only when the status reports one', async () => { await renderDashboard() expect(container.textContent).not.toContain('web.trusted_proxies') diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 2fb09dfc..7d379210 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -6,6 +6,7 @@ import { Link, useNavigate } from 'react-router-dom' import { formatRetention, formatTime } from '../format' import { baselinePresentation, type BaselineStatusInfo } from '../baseline' import { UntrustedProxyBanner } from '../components/UntrustedProxyBanner' +import { scanOutcomeTone } from '../status' export function Dashboard() { const navigate = useNavigate() @@ -72,7 +73,7 @@ export function Dashboard() { } return

Monitoring console

Good afternoon, {displayName}

A calm view of your network’s expected surface. {canOperate && notificationCount ? `${notificationCount} notification destination${notificationCount === 1 ? '' : 's'} configured.` : canOperate ? 'Notifications are configured by an administrator.' : ''} {policy}

{isAdmin && }{canOperate && }
- {notifyState &&
{notifyState.error ? : }{notifyState.text}
}{runError &&
{runError}
}{setup.error && setup.refetch()} />}{canOperate && active.error && active.refetch()} />}{canOperate && incidents.error && incidents.refetch()} />}{canOperate && setup.data?.legacy_yaml_jobs?.length ?
Legacy YAML jobs are inactive. Recreate {setup.data.legacy_yaml_jobs.join(', ')} in the console to resume scheduling.
: null} + {notifyState &&
{notifyState.error ? : }{notifyState.text}
}{runError &&
{runError}
}{setup.error && setup.refetch()} />}{canOperate && active.error && active.refetch()} />}{canOperate && incidents.error && incidents.refetch()} />}{canOperate && setup.data?.legacy_yaml_jobs?.length ?
Legacy YAML jobs are inactive. Recreate {setup.data.legacy_yaml_jobs.join(', ')} in the console to resume scheduling.
: null}
} label="Active jobs" value={activeJobs} detail={`${ready} baselines ready`} tone="blue" status={jobsMetricState} onRetry={() => jobs.refetch()} />} label="Healthy baselines" value={ready} detail="Stable monitoring scopes" tone="green" status={jobsMetricState} onRetry={() => jobs.refetch()} />{canOperate && } label="Open incidents" value={incidentTotal} detail="Confirmed changes" tone="amber" status={incidentsMetricState} onRetry={() => incidents.refetch()} />}} label="Recent scans" value={scanTotal} detail={runningDetail} tone="purple" status={scansMetricState} onRetry={() => scans.refetch()} />
{isAdmin && telemetry &&

Deployment footprint

Cached storage and operational scale indicators.

{telemetry.database_bytes !== undefined && }
Collected {formatTime(telemetry.collected_at, { hour: '2-digit', minute: '2-digit' })}
} {canOperate && active.data?.scans.length ?

Scans in progress

Broad scans can take time; progress follows Nmap task updates when available and reports process liveness between them.

{active.data.scans.map(scan => )}
: null} @@ -99,7 +100,8 @@ function MetricRetry({ onRetry }: { onRetry: () => Promise }) { } function LatestActivityRow({ scan }: { scan: Awaited>['scans'][number] }) { - const content = <>
{scan.job}{scan.status === 'success' ? 'Completed successfully · Open scan details' : scan.error ?? scan.status}
+ const outcome = scanOutcomeTone(scan.status) + const content = <>
{scan.job}{scan.status === 'success' ? 'Completed successfully · Open scan details' : scan.error ?? scan.status}
if (scan.job_id) return {content} return {content} } diff --git a/src/pages/HostDetail.tsx b/src/pages/HostDetail.tsx index 26145954..150f7bf8 100644 --- a/src/pages/HostDetail.tsx +++ b/src/pages/HostDetail.tsx @@ -7,13 +7,14 @@ import { ErrorNotice } from '../components/ErrorNotice' import { PortScopeDetails } from '../components/PortScopeDetails' import type { HostObservation, PortObservation, ProtocolObservation, RdapResult } from '../types' import { formatDate, formatDateTime } from '../format' +import { hostStatusLabel } from '../status' function displayStatusReason(reason?: string) { return reason === 'scan-complete' ? 'full-range scan complete' : reason } function HostIdentity({ host }: { host: HostObservation }) { - return

{host.address}

{host.address_family ?? (host.address.includes(':') ? 'IPv6' : 'IPv4')}{host.status ?? 'up'}{host.status_reason && {displayStatusReason(host.status_reason)}{host.reason_ttl ? ` · TTL ${host.reason_ttl}` : ''}}
+ return

{host.address}

{host.address_family ?? (host.address.includes(':') ? 'IPv6' : 'IPv4')}{hostStatusLabel(host.status ?? 'up')}{host.status_reason && {displayStatusReason(host.status_reason)}{host.reason_ttl ? ` · TTL ${host.reason_ttl}` : ''}}
} function ServiceText({ port }: { port: PortObservation }) { diff --git a/src/pages/JobDetail.test.tsx b/src/pages/JobDetail.test.tsx index 5c655383..2ee6f4d0 100644 --- a/src/pages/JobDetail.test.tsx +++ b/src/pages/JobDetail.test.tsx @@ -174,6 +174,18 @@ describe('job surface overview', () => { await vi.waitFor(() => expect(container.textContent).toContain('View host details'), { timeout: 1000 }) }) + it('uses a consistent paused tone and keeps incomplete scan activity neutral', async () => { + vi.mocked(getJob).mockResolvedValue({ ...job, enabled: false }) + vi.mocked(jobScans).mockResolvedValue({ scans: [{ ...summary, status: 'incomplete' }, { ...summary, id: 'scan-2', status: 'failed' }], pagination: { ...pagination, total: 2 } }) + await renderPage() + await vi.waitFor(() => expect(container.querySelectorAll('.scan-row')).toHaveLength(2), { timeout: 1000 }) + expect(container.querySelector('.title-row .pill')).toHaveClass('amber') + const dots = Array.from(container.querySelectorAll('.scan-row .activity-dot')) + expect(dots[0]).not.toHaveClass('fail') + expect(dots[0]).not.toHaveClass('success') + expect(dots[1]).toHaveClass('fail') + }) + it('orders expected baseline, latest successful scan, and history vertically', async () => { await renderPage() await vi.waitFor(() => expect(container.textContent).toContain('Latest successful scan'), { timeout: 1000 }) diff --git a/src/pages/JobDetail.tsx b/src/pages/JobDetail.tsx index 86b126ae..6ded3afe 100644 --- a/src/pages/JobDetail.tsx +++ b/src/pages/JobDetail.tsx @@ -39,6 +39,7 @@ import { SurfaceUnitList } from '../components/SurfaceUnitList' import type { WorkEstimate } from '../types' import { baselinePresentation } from '../baseline' import { formatDateTime } from '../format' +import { changeKindLabel, jobStatePresentation, scanOutcomeTone } from '../status' type JobDialog = 'reset' | 'approve' | 'archive' | 'delete' | 'discard-cycle' @@ -321,7 +322,7 @@ export function JobDetail() {
{detail.data.changes.map((change, index) => (
- {change.kind} + {changeKindLabel(change.kind, change.old, change.new)} {change.target}{change.port ? ` · ${change.protocol}:${change.port}` : ''} {change.old ?? '—'} → {change.new ?? '—'}
@@ -340,6 +341,7 @@ export function JobDetail() { ) : null const selectedScanIsVisible = Boolean(selectedScan && scans.data?.scans.some((scan) => scan.id === selectedScan)) const baselineStatus = baselinePresentation(value.baseline) + const jobStatus = jobStatePresentation(value.archived, value.enabled) return (
@@ -348,9 +350,7 @@ export function JobDetail() { ← Jobs

{value.job.name}

- - {value.archived ? 'Archived' : value.enabled ? 'Scheduled' : 'Paused'} - + {jobStatus.label}

Revision {value.revision} · Updated {formatDateTime(value.updated_at)}

@@ -506,7 +506,7 @@ export function JobDetail() { aria-expanded={selectedScan === scan.id} aria-controls={selectedScan === scan.id ? selectedScanDetailID : undefined} > - +
{formatDateTime(scan.finished_at)} diff --git a/src/pages/platform/PlatformAdmins.tsx b/src/pages/platform/PlatformAdmins.tsx index ffeddee9..b52d16f4 100644 --- a/src/pages/platform/PlatformAdmins.tsx +++ b/src/pages/platform/PlatformAdmins.tsx @@ -151,8 +151,8 @@ export function PlatformAdmins() {
{admin.display_name}{self ? ' (you)' : ''}{admin.username}{lastSignIn(admin.last_login_at)}
{admin.pending ? 'Pending activation' : admin.enabled ? 'Enabled' : 'Disabled'}{admin.totp_enabled ? 'TOTP on' : 'No TOTP'} {!self &&
{admin.pending - ? (Object.keys(pendingActions) as PendingAction[]).map(action => ) - : }
} + ? (Object.keys(pendingActions) as PendingAction[]).map(action => ) + : }
} })}} diff --git a/src/pages/platform/PlatformPages.test.tsx b/src/pages/platform/PlatformPages.test.tsx index e735dd4c..14a5b2d7 100644 --- a/src/pages/platform/PlatformPages.test.tsx +++ b/src/pages/platform/PlatformPages.test.tsx @@ -65,9 +65,14 @@ describe('platform administrators', () => { // A pending administrator has not redeemed its invitation, which is // renewed or revoked, or the account removed, instead of disabled. expect(within(screen.getByTestId('admin-lee')).getAllByRole('button').map(button => button.textContent)).toEqual(['Renew invitation', 'Revoke invitation', 'Remove']) + const pendingActions = within(screen.getByTestId('admin-lee')).getAllByRole('button') + expect(pendingActions[0]).not.toHaveClass('danger-text') + expect(pendingActions[1]).toHaveClass('danger-text') + expect(pendingActions[2]).toHaveClass('danger-text') expect(within(screen.getByTestId('admin-lee')).getByText('Pending activation')).toBeInTheDocument() expect(within(screen.getByTestId('admin-sam')).getByText('No TOTP')).toBeInTheDocument() expect(within(screen.getByTestId('admin-kim')).getByRole('button', { name: 'Enable' })).toBeInTheDocument() + expect(within(screen.getByTestId('admin-sam')).getByRole('button', { name: 'Disable' })).toHaveClass('danger-text') vi.mocked(setPlatformAdminEnabled).mockRejectedValueOnce(new APIError('the platform keeps at least one enabled platform administrator', 'last_platform_admin')) fireEvent.click(within(screen.getByTestId('admin-sam')).getByRole('button', { name: 'Disable' })) diff --git a/src/pages/platform/UnitAccounts.tsx b/src/pages/platform/UnitAccounts.tsx index 73895d0d..f1b9da86 100644 --- a/src/pages/platform/UnitAccounts.tsx +++ b/src/pages/platform/UnitAccounts.tsx @@ -154,7 +154,7 @@ function AccountRow({ account, unitActive, onAction }: { account: UnitAccount; u
{account.display_name}{account.username} · {unitRoleLabels[account.role] ?? account.role}{lastSignIn(account.last_login_at)}
{status[0]}{account.totp_enabled ? 'TOTP on' : 'No TOTP'} {(offered.sessions || offered.reset) &&
- {offered.sessions && } + {offered.sessions && } {offered.reset && }
} diff --git a/src/pages/platform/UnitDetail.test.tsx b/src/pages/platform/UnitDetail.test.tsx index e0512aed..b520c839 100644 --- a/src/pages/platform/UnitDetail.test.tsx +++ b/src/pages/platform/UnitDetail.test.tsx @@ -108,6 +108,7 @@ describe('business unit detail', () => { expect(within(row(username)).queryByRole('button', { name: /Reset password|New activation link/ })).not.toBeInTheDocument() } for (const username of ['riley', 'jordan', 'dana', 'casey', 'taylor']) expect(within(row(username)).getByRole('button', { name: /Revoke sessions/ })).toBeInTheDocument() + expect(within(row('casey')).getByRole('button', { name: /Revoke sessions/ })).toHaveClass('danger-text') expect(within(row('sam')).queryByRole('button', { name: /Revoke sessions/ })).not.toBeInTheDocument() expect(within(row('casey')).getByText('casey · Operator')).toBeInTheDocument() expect(within(row('sam')).getByText('Pending activation')).toBeInTheDocument() diff --git a/src/status.test.ts b/src/status.test.ts new file mode 100644 index 00000000..f881e29c --- /dev/null +++ b/src/status.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { changeKindLabel, hostStatusLabel, jobStatePresentation, scanOutcomeTone, severityLabel } from './status' + +describe('shared status presentation', () => { + it('uses consistent job labels and tones', () => { + expect(jobStatePresentation(false, true)).toEqual({ label: 'Scheduled', tone: 'green' }) + expect(jobStatePresentation(false, false)).toEqual({ label: 'Paused', tone: 'amber' }) + expect(jobStatePresentation(true, false)).toEqual({ label: 'Archived', tone: 'gray' }) + }) + + it('emphasizes failed scans while keeping canceled and incomplete scans neutral', () => { + expect(scanOutcomeTone('success')).toBe('success') + expect(scanOutcomeTone('failed')).toBe('fail') + expect(scanOutcomeTone('canceled')).toBe('neutral') + expect(scanOutcomeTone('cancelled')).toBe('neutral') + expect(scanOutcomeTone('incomplete')).toBe('neutral') + }) + + it('turns change identifiers into readable labels', () => { + expect(changeKindLabel('Port_closed')).toBe('Port closed') + expect(changeKindLabel('dns')).toBe('DNS') + expect(changeKindLabel('service')).toBe('Service') + expect(changeKindLabel('port', 'closed', 'open')).toBe('Port opened') + expect(changeKindLabel('port', 'open', 'not-open')).toBe('Port closed') + expect(changeKindLabel('other_change')).toBe('Other change') + }) + + it('uses explicit readable labels for lower-case severity and host states', () => { + expect(severityLabel('critical')).toBe('Critical') + expect(severityLabel('warning')).toBe('Warning') + expect(hostStatusLabel('no-response')).toBe('No response') + expect(hostStatusLabel('up')).toBe('Up') + }) +}) diff --git a/src/status.ts b/src/status.ts new file mode 100644 index 00000000..d7d758e3 --- /dev/null +++ b/src/status.ts @@ -0,0 +1,59 @@ +/** User-facing labels and tones for recurring job, scan, and change states. */ + +export type StateTone = 'green' | 'amber' | 'gray' | 'red' + +export function jobStatePresentation(archived: boolean, enabled: boolean): { label: string; tone: StateTone } { + if (archived) return { label: 'Archived', tone: 'gray' } + if (enabled) return { label: 'Scheduled', tone: 'green' } + return { label: 'Paused', tone: 'amber' } +} + +/** Success and failure are emphasized; canceled and incomplete scans stay neutral. */ +export function scanOutcomeTone(status: string): 'success' | 'fail' | 'neutral' { + switch (status.trim().toLowerCase()) { + case 'success': return 'success' + case 'failed': + case 'error': return 'fail' + default: return 'neutral' + } +} + +export function changeKindLabel(kind: string, oldValue?: string, newValue?: string): string { + const normalized = kind.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') + if (['port_open', 'port_opened'].includes(normalized)) return 'Port opened' + if (['port_closed', 'port_removed'].includes(normalized)) return 'Port closed' + if (['port', 'port_state', 'port_changed'].includes(normalized)) { + const oldPositive = isPositivePortState(oldValue) + const newPositive = isPositivePortState(newValue) + if (newPositive && !oldPositive) return 'Port opened' + if (oldPositive && !newPositive) return 'Port closed' + return 'Port state' + } + if (normalized === 'dns' || normalized.startsWith('dns_')) return 'DNS' + if (normalized === 'service' || normalized.startsWith('service_')) return 'Service' + return humanize(normalized || kind) +} + +export function severityLabel(severity: string): string { + const normalized = severity.trim().toLowerCase() + if (normalized === 'critical') return 'Critical' + if (normalized === 'warning') return 'Warning' + if (normalized === 'info') return 'Info' + return humanize(severity) +} + +export function hostStatusLabel(status: string): string { + const normalized = status.trim().toLowerCase().replace(/[_-]+/g, ' ') + if (normalized === 'no response') return 'No response' + return humanize(normalized) +} + +function isPositivePortState(state?: string) { + const normalized = state?.trim().toLowerCase() + return normalized === 'open' || normalized === 'open|filtered' +} + +function humanize(value: string) { + const words = value.trim().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ') + return words ? words[0].toUpperCase() + words.slice(1) : value +} diff --git a/src/styles.css b/src/styles.css index e2203dc3..9b6e83eb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2007,3 +2007,11 @@ label small, flex-direction: column; } } + +/* Keep pill copy intentional; identifiers are formatted by the UI. */ +.pill { text-transform: none; } + +/* Archived is a compact marker, not a full-width grid row. */ +.public-picker-archived { justify-self: start; margin-left: 0; } + +.legacy-banner-recheck { flex: 0 0 auto; white-space: nowrap; }