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
40 changes: 40 additions & 0 deletions e2e/responsive-issue-regressions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,46 @@ test.describe('responsive issue regressions', () => {
await expectNoHorizontalScroll(page)
})

test('platform unit tabs, capacity ceiling and unit list remain usable on phones (#992)', async ({ page }) => {
await mockConsole(page, 'platform_admin')
await page.setViewportSize({ width: 320, height: 760 })
await page.goto('/platform/units/unit-retail/overview')

const tabs = page.getByRole('navigation', { name: 'Retail sections' }).getByRole('link')
await expect(tabs).toHaveCount(4)
for (const tab of await tabs.all()) {
await expect(tab).toBeVisible()
const box = (await tab.boundingBox())!
expect(box.x).toBeGreaterThanOrEqual(0)
expect(box.x + box.width).toBeLessThanOrEqual(320)
}
await expectNoHorizontalScroll(page)

await page.goto('/platform/units/unit-retail/capacity')
const grant = page.getByRole('radio', { name: 'Grant a ceiling' })
await grant.check()
const grantOption = page.locator('.ceiling-option').filter({ has: grant })
const ceiling = grantOption.getByLabel('High-cost ceiling')
await expect(ceiling).toBeVisible()
await ceiling.fill('5000000')
await expect(grantOption).toContainText('Granted ceiling: 5,000,000 probes per run. Maximum 100,000,000.')
await expectNoHorizontalScroll(page)

await page.setViewportSize({ width: 1280, height: 900 })
await page.goto('/platform/units')
const accountLabels = page.locator('.unit-row .unit-facts > div:first-child dt')
await expect(accountLabels).toHaveCount(2)
const labelPositions = await accountLabels.evaluateAll(elements => elements.map(element => element.getBoundingClientRect().x))
expect(Math.abs(labelPositions[0] - labelPositions[1])).toBeLessThanOrEqual(1)

await page.setViewportSize({ width: 320, height: 760 })
const search = page.getByRole('searchbox', { name: 'Search by name or public slug' })
await search.fill('retail')
await expect(page.getByRole('link', { name: 'Open Retail' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Open Default' })).toHaveCount(0)
await expectNoHorizontalScroll(page)
})

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
8 changes: 7 additions & 1 deletion src/pages/platform/UnitDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -442,9 +442,13 @@ describe('business unit detail', () => {
// A grant starts empty, so it is always a number the administrator typed.
const ceiling = screen.getByLabelText('High-cost ceiling', { selector: 'input[type="number"]' })
expect(ceiling).toHaveValue(null)
const grantOption = screen.getByLabelText('Grant a ceiling').closest<HTMLElement>('.ceiling-option')!
expect(within(grantOption).getByLabelText('High-cost ceiling', { selector: 'input[type="number"]' })).toBeInTheDocument()
expect(within(grantOption).queryByText('Use the deployment’s setting')).not.toBeInTheDocument()
expect(screen.getByText('Use a whole number from 1 to 100,000,000.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save capacity' })).toBeDisabled()
fireEvent.change(ceiling, { target: { value: '7000000' } })
expect(screen.getByText('Granted ceiling: 7,000,000 probes per run. Maximum 100,000,000.')).toBeInTheDocument()
fireEvent.submit(slots.closest('form')!)
await waitFor(() => expect(updateUnitCapacity).toHaveBeenLastCalledWith('unit-retail', 3, { high_cost_ceiling: 7_000_000 }))
expect(await screen.findByText('Capacity saved. It applies to the next scan this unit queues.')).toBeInTheDocument()
Expand Down Expand Up @@ -796,7 +800,9 @@ describe('business unit detail', () => {
vi.mocked(getUnit).mockResolvedValue(businessUnit({ status: 'deleting', jobs: 0, stored_scans: 40, purge: { phase: 'scan_hosts', rows: 1500 } }))
const view = renderUnit('danger')
expect(await screen.findByRole('heading', { name: 'Deleting Retail…' })).toBeInTheDocument()
expect(screen.getByText('Erasing scan hosts · 1,500 rows erased so far.')).toBeInTheDocument()
const progressLine = screen.getByText('Erasing scan hosts · 1,500 rows erased so far.').closest('p')!
expect(progressLine).toHaveClass('delete-progress-line')
expect(progressLine.querySelector('.spinner')).toBeInTheDocument()
// The heading counts the scans that the purge has yet to erase.
expect(screen.getByText(/ · 0 jobs · 40 stored scans · created /)).toBeInTheDocument()
expect(screen.queryByRole('navigation', { name: 'Retail sections' })).not.toBeInTheDocument()
Expand Down
20 changes: 14 additions & 6 deletions src/pages/platform/UnitDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,14 +267,22 @@ function UnitCapacityForm({ unit, capacity }: { unit: BusinessUnit; capacity: Un
// proposes no number of its own, so a grant is always one that a
// platform administrator typed or saved.
const ceiling = field.key === 'high_cost_ceiling'
const help = (ceiling && ceilingModes.find(option => option.mode === entry.mode)?.help) || field.help
return <fieldset className="capacity-field" key={field.key}>
<legend>{field.label}</legend>
{ceiling
? ceilingModes.map(option => <label className="checkbox-label" key={option.mode} htmlFor={`${inputID}-${option.mode}`}><input id={`${inputID}-${option.mode}`} type="radio" name={`${inputID}-mode`} checked={entry.mode === option.mode} onChange={() => change(field.key, { mode: option.mode })} /><span>{option.label}</span></label>)
: <label className="checkbox-label" htmlFor={`${inputID}-inherit`}><input id={`${inputID}-inherit`} type="checkbox" checked={entry.mode === 'inherit'} onChange={event => change(field.key, { mode: event.target.checked ? 'inherit' : 'set', value: entry.value || String(field.maximum(limits)) })} /><span>Use the deployment’s setting</span></label>}
{entry.mode === 'set' && <label htmlFor={inputID}><span className="sr-only">{field.label}</span><input id={inputID} type="number" inputMode="numeric" min={1} max={field.maximum(limits)} step={1} value={entry.value} onChange={event => change(field.key, { value: event.target.value })} aria-invalid={!!problem} aria-describedby={`${inputID}-help`} /></label>}
<small id={`${inputID}-help`} className={problem ? 'field-error' : undefined}>{problem ?? help(limits)}</small>
? <div className="ceiling-options">{ceilingModes.map(option => <div className="ceiling-option" key={option.mode}>
<label className="checkbox-label" htmlFor={`${inputID}-${option.mode}`}><input id={`${inputID}-${option.mode}`} type="radio" name={`${inputID}-mode`} checked={entry.mode === option.mode} onChange={() => change(field.key, { mode: option.mode })} /><span>{option.label}</span></label>
{entry.mode === option.mode && option.help && <small>{option.help(limits)}</small>}
{option.mode === 'set' && entry.mode === 'set' && <div className="ceiling-input-group">
<label htmlFor={inputID}><span className="sr-only">{field.label}</span><input id={inputID} type="number" inputMode="numeric" min={1} max={field.maximum(limits)} step={1} value={entry.value} onChange={event => change(field.key, { value: event.target.value })} aria-invalid={!!problem} aria-describedby={`${inputID}-help`} /></label>
<small id={`${inputID}-help`} className={problem ? 'field-error' : undefined}>{problem ?? (entry.value.trim() ? `Granted ceiling: ${formatCount(Number(entry.value))} probes per run. Maximum ${formatCount(field.maximum(limits))}.` : field.help(limits))}</small>
</div>}
</div>)}</div>
: <>
<label className="checkbox-label" htmlFor={`${inputID}-inherit`}><input id={`${inputID}-inherit`} type="checkbox" checked={entry.mode === 'inherit'} onChange={event => change(field.key, { mode: event.target.checked ? 'inherit' : 'set', value: entry.value || String(field.maximum(limits)) })} /><span>Use the deployment’s setting</span></label>
{entry.mode === 'set' && <label htmlFor={inputID}><span className="sr-only">{field.label}</span><input id={inputID} type="number" inputMode="numeric" min={1} max={field.maximum(limits)} step={1} value={entry.value} onChange={event => change(field.key, { value: event.target.value })} aria-invalid={!!problem} aria-describedby={`${inputID}-help`} /></label>}
<small id={`${inputID}-help`} className={problem ? 'field-error' : undefined}>{problem ?? field.help(limits)}</small>
</>}
</fieldset>
})}
<p className="notice">A cap is a limit, not a reservation. When units wait for slots, free slots go round-robin to the waiting units, up to each unit’s cap.</p>
Expand Down Expand Up @@ -409,7 +417,7 @@ function purgeActivity(phase = '') {

function DeleteProgress({ unit }: { unit: BusinessUnit }) {
const purge = unit.purge
return <div className="panel delete-progress" role="status" aria-live="polite"><h2>Deleting {unit.name}…</h2><p>{purgeActivity(purge?.phase)} · {plural(purge?.rows ?? 0, 'row')} erased so far.</p><p className="muted">EdgeWatch erases the unit in small batches, then compacts the search indexes and truncates the database log, which a running backup can delay. You can leave this page: the deletion continues in the background and resumes after a restart.</p></div>
return <div className="panel delete-progress" role="status" aria-live="polite"><h2>Deleting {unit.name}…</h2><p className="delete-progress-line"><span className="spinner" aria-hidden="true" />{purgeActivity(purge?.phase)} · {plural(purge?.rows ?? 0, 'row')} erased so far.</p><p className="muted">EdgeWatch erases the unit in small batches, then compacts the search indexes and truncates the database log, which a running backup can delay. You can leave this page: the deletion continues in the background and resumes after a restart.</p></div>
}

function DeletedNotice({ unit }: { unit: BusinessUnit }) {
Expand Down
28 changes: 28 additions & 0 deletions src/pages/platform/Units.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ describe('business unit list', () => {
await waitFor(() => expect(within(defaultUnit).getByText('0 in use · 0 queued · cap unavailable')).toBeInTheDocument())
const deleting = screen.getByRole('link', { name: 'Open Old' })
expect(within(deleting).getByText('Deleting · 1,200 rows erased')).toBeInTheDocument()
expect(within(deleting).getByText('Deletion progress', { selector: 'dt' })).toBeInTheDocument()
expect(within(deleting).queryByText('Scan slots', { selector: 'dt' })).not.toBeInTheDocument()
expect(getUnitCapacity).not.toHaveBeenCalledWith('unit-old')
expect(screen.queryByRole('link', { name: 'Open Gone' })).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: '3 units' })).toBeInTheDocument()
Expand All @@ -91,6 +93,32 @@ describe('business unit list', () => {
}
})

it('filters units by name or slug and by lifecycle state', async () => {
vi.mocked(listUnits).mockResolvedValue({ limits, units: [
businessUnit({ id: 'unit-default', name: 'Default', slug: 'default', is_default: true }),
businessUnit(),
businessUnit({ id: 'unit-warehouse', name: 'North Warehouse', slug: 'north-warehouse', status: 'disabled' }),
businessUnit({ id: 'unit-old', name: 'Old Warehouse', slug: 'old-warehouse', status: 'deleting', purge: { phase: 'scan_hosts', rows: 1200 } }),
] })
renderUnits()

const search = await screen.findByRole('searchbox', { name: 'Search by name or public slug' })
fireEvent.change(search, { target: { value: 'NORTH-WAREHOUSE' } })
expect(screen.getByRole('link', { name: 'Open North Warehouse' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Open Retail' })).not.toBeInTheDocument()

fireEvent.change(search, { target: { value: '' } })
fireEvent.change(screen.getByLabelText('State'), { target: { value: 'disabled' } })
expect(screen.getByRole('link', { name: 'Open North Warehouse' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Open Old Warehouse' })).not.toBeInTheDocument()

fireEvent.change(screen.getByLabelText('State'), { target: { value: 'deleting' } })
expect(screen.getByRole('link', { name: 'Open Old Warehouse' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Open North Warehouse' })).not.toBeInTheDocument()
fireEvent.change(search, { target: { value: 'does-not-exist' } })
expect(screen.getByText('No units match these filters.')).toBeInTheDocument()
})

it('creates a unit with a derived or a chosen slug and opens its accounts', async () => {
renderUnits()
await screen.findByRole('link', { name: 'Open Retail' })
Expand Down
23 changes: 19 additions & 4 deletions src/pages/platform/Units.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate } from 'react-router-dom'
import { Building2, Plus } from 'lucide-react'
import { createUnit, getUnitCapacity, listUnits } from '../../api'
import type { BusinessUnit, SessionUser } from '../../api'
import type { BusinessUnit, BusinessUnitStatus, SessionUser } from '../../api'
import { ActionDialog } from '../../components/ActionDialog'
import { ErrorNotice } from '../../components/ErrorNotice'
import { errorMessage, formatCount, Loading, plural, slugProblem, UnitStatusPill } from './common'
Expand All @@ -20,6 +20,8 @@ export function Units() {
const units = useQuery({ queryKey: ['platform-units'], queryFn: listUnits, refetchInterval: 15_000 })
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | BusinessUnitStatus>('all')
async function create(name: string, requestedSlug = '') {
setError('')
const slug = requestedSlug.trim()
Expand All @@ -44,12 +46,25 @@ export function Units() {
}
}
const visible = units.data?.units.filter(unit => unit.status !== 'deleted') ?? []
const searchTerm = search.trim().toLocaleLowerCase()
const filtered = visible.filter(unit => {
const matchesSearch = !searchTerm || unit.name.toLocaleLowerCase().includes(searchTerm) || unit.slug.toLocaleLowerCase().includes(searchTerm)
return matchesSearch && (statusFilter === 'all' || unit.status === statusFilter)
})
const limits = units.data?.limits
return <section className="page">
<div className="page-heading"><div><p className="eyebrow">Platform</p><h1>Business units</h1><p className="muted">Each unit owns its jobs, results, notification destinations, and accounts. You manage the units and their administrators; you never see their scan data.</p></div><div className="heading-actions"><button type="button" className="button primary" onClick={() => { setError(''); setCreating(true) }}><Plus size={16} /> New unit</button></div></div>
{units.isLoading ? <Loading label="Loading business units…" /> : units.error || !units.data ? <ErrorNotice message="Could not load business units." onRetry={() => units.refetch()} /> : <div className="panel">
<div className="panel-heading"><div><h2>{plural(visible.length, 'unit')}</h2>{limits && <p className="muted">Deployment limits: {plural(limits.max_concurrent_scans, 'scan slot')} · {formatCount(limits.max_probe_count)} Nmap and {formatCount(limits.max_naabu_probe_count)} Naabu probes per run.</p>}</div><Building2 className="muted-icon" size={20} /></div>
{visible.length ? <div className="unit-list">{visible.map(unit => <UnitRow key={unit.id} unit={unit} />)}</div> : <div className="inline-empty">No business units yet.</div>}
<div className="panel-heading"><div><h2>{plural(filtered.length, 'unit')}</h2>{limits && <p className="muted">Deployment limits: {plural(limits.max_concurrent_scans, 'scan slot')} · {formatCount(limits.max_probe_count)} Nmap and {formatCount(limits.max_naabu_probe_count)} Naabu probes per run.</p>}</div><Building2 className="muted-icon" size={20} /></div>
{visible.length > 0 && <div className="unit-filters" role="search" aria-label="Filter business units">
<label>Search by name or public slug<input type="search" value={search} onChange={event => setSearch(event.target.value)} placeholder="For example, Retail or retail" /></label>
<label>State<select value={statusFilter} onChange={event => setStatusFilter(event.target.value as 'all' | BusinessUnitStatus)}>
<option value="all">All states</option><option value="active">Active</option><option value="disabled">Disabled</option><option value="deleting">Deleting</option>
</select></label>
</div>}
{visible.length === 0 ? <div className="inline-empty">No business units yet.</div>
: filtered.length > 0 ? <div className="unit-list">{filtered.map(unit => <UnitRow key={unit.id} unit={unit} />)}</div>
: <div className="inline-empty">No units match these filters.</div>}
</div>}
{creating && <ActionDialog title="New business unit" description="Create an empty unit, then invite its first administrator from the unit’s Accounts tab. That administrator sets up the unit’s jobs, destinations, and accounts." confirmLabel="Create unit" valueLabel="Unit name" valueRequired placeholder="For example, Logistics" autoComplete="off" secondaryValueLabel="Slug for the public link (optional)" secondaryPlaceholder="Derived from the name" secondaryAutoComplete="off" onConfirm={create} onCancel={() => { setCreating(false); setError('') }} error={error} />}
</section>
Expand All @@ -67,7 +82,7 @@ function UnitRow({ unit }: { unit: BusinessUnit }) {
<div><dt>Accounts</dt><dd>{plural(unit.accounts, 'account')} · {plural(unit.administrators, 'admin')}</dd></div>
<div><dt>Jobs</dt><dd>{plural(unit.jobs, 'job')}</dd></div>
<div><dt>Stored scans</dt><dd>{plural(unit.stored_scans, 'scan')}</dd></div>
<div><dt>Scan slots</dt><dd>{unit.purge ? `Deleting · ${formatCount(unit.purge.rows)} rows erased` : [`${unit.slots.in_use} in use`, `${unit.slots.queued} queued`, cap].filter(Boolean).join(' · ')}</dd></div>
<div><dt>{unit.purge ? 'Deletion progress' : 'Scan slots'}</dt><dd>{unit.purge ? `Deleting · ${formatCount(unit.purge.rows)} rows erased` : [`${unit.slots.in_use} in use`, `${unit.slots.queued} queued`, cap].filter(Boolean).join(' · ')}</dd></div>
</dl>
</Link>
}
Loading
Loading