Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions e2e/responsive-issue-regressions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
1 change: 1 addition & 0 deletions scripts/coverage-gates.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions src/main.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Jobs />)
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(<Jobs />)
Expand Down Expand Up @@ -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(<Incidents />)
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])
Expand Down
46 changes: 42 additions & 4 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 <section className="page"><div className="page-heading"><div><p className="eyebrow">Configuration</p><h1>Jobs</h1><p className="muted">Each job owns its targets, protocols, schedule, and baseline.</p></div>{canWrite && <button className="button primary" onClick={() => navigate('/jobs/new')}>+ New job</button>}</div>{jobs.isLoading ? <Loading /> : jobs.error ? <ErrorNotice message="Could not load jobs." onRetry={() => jobs.refetch()} /> : <div className="job-grid">{jobs.data?.jobs.map(job => { const baseline = baselinePresentation(job.baseline); return <Link className={job.archived ? 'job-card archived' : 'job-card'} to={`/jobs/${job.id}`} key={job.id}><div className="job-card-top"><span className={job.enabled && !job.archived ? 'pill green' : 'pill gray'}>{job.archived ? 'Archived' : job.enabled ? 'Scheduled' : 'Paused'}</span><span className="revision">r{job.revision}</span></div><h3>{job.job.name}</h3><p className="muted">{job.job.targets.length} target{job.job.targets.length === 1 ? '' : 's'} · {protocolSummary(job)}</p><div className="job-card-bottom"><span className={`baseline${baseline.status === 'complete' ? ' complete' : baseline.status === 'stalled' ? ' stalled' : ''}`}>{baseline.marker} {baseline.status === 'complete' ? `Baseline ${baseline.label.toLowerCase()}` : baseline.status === 'stalled' ? 'Baseline stalled' : `Collecting ${job.baseline.samples ?? 0}/${job.job.baseline_samples}`}</span><span>{job.job.schedule}</span></div></Link> })}{!jobs.data?.jobs.length && canWrite && <Empty title="No jobs yet" body="Create your first TCP or UDP monitoring job." action={<button className="button primary" onClick={() => navigate('/jobs/new')}>Create a job</button>} />}{!jobs.data?.jobs.length && !canWrite && <Empty title="No jobs configured" body="An operator can create a monitoring job for this EdgeWatch instance." />}</div>}</section>
return (
<section className="page">
<div className="page-heading">
<div>
<p className="eyebrow">Configuration</p>
<h1>Jobs</h1>
<p className="muted">Each job owns its targets, protocols, schedule, and baseline.</p>
</div>
{canWrite && <button className="button primary" onClick={() => navigate('/jobs/new')}>+ New job</button>}
</div>
{jobs.isLoading ? <Loading /> : jobs.error ? <ErrorNotice message="Could not load jobs." onRetry={() => jobs.refetch()} /> : (
<div className="job-grid">
{jobs.data?.jobs.map(job => {
const baseline = baselinePresentation(job.baseline)
const state = jobStatePresentation(job.archived, job.enabled)
return (
<Link className={job.archived ? 'job-card archived' : 'job-card'} to={`/jobs/${job.id}`} key={job.id}>
<div className="job-card-top">
<span className={`pill ${state.tone}`}>{state.label}</span>
<span className="revision">r{job.revision}</span>
</div>
<h3>{job.job.name}</h3>
<p className="muted">{job.job.targets.length} target{job.job.targets.length === 1 ? '' : 's'} · {protocolSummary(job)}</p>
<div className="job-card-bottom">
<span className={`baseline${baseline.status === 'complete' ? ' complete' : baseline.status === 'stalled' ? ' stalled' : ''}`}>
{baseline.marker} {baseline.status === 'complete' ? `Baseline ${baseline.label.toLowerCase()}` : baseline.status === 'stalled' ? 'Baseline stalled' : `Collecting ${job.baseline.samples ?? 0}/${job.job.baseline_samples}`}
</span>
<span>{job.job.schedule}</span>
</div>
</Link>
)
})}
{!jobs.data?.jobs.length && canWrite && <Empty title="No jobs yet" body="Create your first TCP or UDP monitoring job." action={<button className="button primary" onClick={() => navigate('/jobs/new')}>Create a job</button>} />}
{!jobs.data?.jobs.length && !canWrite && <Empty title="No jobs configured" body="An operator can create a monitoring job for this EdgeWatch instance." />}
</div>
)}
</section>
)
}

function protocolSummary(job: { job: { tcp?: { ports: string }; udp?: { ports: string } } }) {
Expand Down Expand Up @@ -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 <tr><td><strong>{row.job}</strong></td><td>{row.incident.change.target}</td><td><strong>{formatIncidentChange(row.incident.change)}</strong><br /><span className="muted">{changeValues(row.incident.change)}</span></td><td><span className={`pill ${row.incident.change.severity === 'critical' ? 'red' : 'amber'}`}>{row.incident.change.severity}</span></td><td>{formatDateTime(row.incident.last_seen_at)}</td><td><IncidentActions row={row} busy={busy} acceptID={acceptID} suppressID={suppressID} onAction={onAction} /></td></tr>
return <tr><td><strong>{row.job}</strong></td><td>{row.incident.change.target}</td><td><strong>{formatIncidentChange(row.incident.change)}</strong><br /><span className="muted">{changeValues(row.incident.change)}</span></td><td><span className={`pill ${row.incident.change.severity === 'critical' ? 'red' : 'amber'}`}>{severityLabel(row.incident.change.severity)}</span></td><td>{formatDateTime(row.incident.last_seen_at)}</td><td><IncidentActions row={row} busy={busy} acceptID={acceptID} suppressID={suppressID} onAction={onAction} /></td></tr>
}

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 <article className="incident-card" aria-label={`Incident for ${row.job}`}><div className="incident-card-heading"><strong>{row.job}</strong><span className={`pill ${row.incident.change.severity === 'critical' ? 'red' : 'amber'}`}>{row.incident.change.severity}</span></div><dl className="incident-facts"><div><dt>Target</dt><dd>{row.incident.change.target}</dd></div><div><dt>Change</dt><dd><strong>{formatIncidentChange(row.incident.change)}</strong><br /><span className="muted">{changeValues(row.incident.change)}</span></dd></div><div><dt>Last seen</dt><dd>{formatDateTime(row.incident.last_seen_at)}</dd></div></dl><IncidentActions row={row} busy={busy} acceptID={acceptID} suppressID={suppressID} onAction={onAction} /></article>
return <article className="incident-card" aria-label={`Incident for ${row.job}`}><div className="incident-card-heading"><strong>{row.job}</strong><span className={`pill ${row.incident.change.severity === 'critical' ? 'red' : 'amber'}`}>{severityLabel(row.incident.change.severity)}</span></div><dl className="incident-facts"><div><dt>Target</dt><dd>{row.incident.change.target}</dd></div><div><dt>Change</dt><dd><strong>{formatIncidentChange(row.incident.change)}</strong><br /><span className="muted">{changeValues(row.incident.change)}</span></dd></div><div><dt>Last seen</dt><dd>{formatDateTime(row.incident.last_seen_at)}</dd></div></dl><IncidentActions row={row} busy={busy} acceptID={acceptID} suppressID={suppressID} onAction={onAction} /></article>
}

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']) {
Expand Down
24 changes: 24 additions & 0 deletions src/pages/Dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading
Loading