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 && }{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 && }{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; }