From 4534ff73c6394aa61e0e664f0059b46868940d3a Mon Sep 17 00:00:00 2001 From: crypt0rr <57799908+crypt0rr@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:40:47 +0200 Subject: [PATCH] fix(ui): improve platform unit management layout (#992) --- e2e/responsive-issue-regressions.spec.ts | 40 ++++++++++++++++++++++++ src/pages/platform/UnitDetail.test.tsx | 8 ++++- src/pages/platform/UnitDetail.tsx | 20 ++++++++---- src/pages/platform/Units.test.tsx | 28 +++++++++++++++++ src/pages/platform/Units.tsx | 23 +++++++++++--- src/styles.css | 23 ++++++++++++-- 6 files changed, 128 insertions(+), 14 deletions(-) diff --git a/e2e/responsive-issue-regressions.spec.ts b/e2e/responsive-issue-regressions.spec.ts index 0870199f..e00b3fbb 100644 --- a/e2e/responsive-issue-regressions.spec.ts +++ b/e2e/responsive-issue-regressions.spec.ts @@ -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 => { diff --git a/src/pages/platform/UnitDetail.test.tsx b/src/pages/platform/UnitDetail.test.tsx index 97ff820f..e0512aed 100644 --- a/src/pages/platform/UnitDetail.test.tsx +++ b/src/pages/platform/UnitDetail.test.tsx @@ -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('.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() @@ -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() diff --git a/src/pages/platform/UnitDetail.tsx b/src/pages/platform/UnitDetail.tsx index 4483accf..549b9294 100644 --- a/src/pages/platform/UnitDetail.tsx +++ b/src/pages/platform/UnitDetail.tsx @@ -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
{field.label} {ceiling - ? ceilingModes.map(option => ) - : } - {entry.mode === 'set' && } - {problem ?? help(limits)} + ?
{ceilingModes.map(option =>
+ + {entry.mode === option.mode && option.help && {option.help(limits)}} + {option.mode === 'set' && entry.mode === 'set' &&
+ + {problem ?? (entry.value.trim() ? `Granted ceiling: ${formatCount(Number(entry.value))} probes per run. Maximum ${formatCount(field.maximum(limits))}.` : field.help(limits))} +
} +
)}
+ : <> + + {entry.mode === 'set' && } + {problem ?? field.help(limits)} + }
})}

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.

@@ -409,7 +417,7 @@ function purgeActivity(phase = '') { function DeleteProgress({ unit }: { unit: BusinessUnit }) { const purge = unit.purge - return

Deleting {unit.name}…

{purgeActivity(purge?.phase)} · {plural(purge?.rows ?? 0, 'row')} erased so far.

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.

+ return

Deleting {unit.name}…

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.

} function DeletedNotice({ unit }: { unit: BusinessUnit }) { diff --git a/src/pages/platform/Units.test.tsx b/src/pages/platform/Units.test.tsx index 5de93e51..89df03c8 100644 --- a/src/pages/platform/Units.test.tsx +++ b/src/pages/platform/Units.test.tsx @@ -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() @@ -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' }) diff --git a/src/pages/platform/Units.tsx b/src/pages/platform/Units.tsx index 419104d7..d3160563 100644 --- a/src/pages/platform/Units.tsx +++ b/src/pages/platform/Units.tsx @@ -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' @@ -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() @@ -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

Platform

Business units

Each unit owns its jobs, results, notification destinations, and accounts. You manage the units and their administrators; you never see their scan data.

{units.isLoading ? : units.error || !units.data ? units.refetch()} /> :
-

{plural(visible.length, 'unit')}

{limits &&

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.

}
- {visible.length ?
{visible.map(unit => )}
:
No business units yet.
} +

{plural(filtered.length, 'unit')}

{limits &&

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.

}
+ {visible.length > 0 &&
+ + +
} + {visible.length === 0 ?
No business units yet.
+ : filtered.length > 0 ?
{filtered.map(unit => )}
+ :
No units match these filters.
}
} {creating && { setCreating(false); setError('') }} error={error} />}
@@ -67,7 +82,7 @@ function UnitRow({ unit }: { unit: BusinessUnit }) {
Accounts
{plural(unit.accounts, 'account')} · {plural(unit.administrators, 'admin')}
Jobs
{plural(unit.jobs, 'job')}
Stored scans
{plural(unit.stored_scans, 'scan')}
-
Scan slots
{unit.purge ? `Deleting · ${formatCount(unit.purge.rows)} rows erased` : [`${unit.slots.in_use} in use`, `${unit.slots.queued} queued`, cap].filter(Boolean).join(' · ')}
+
{unit.purge ? 'Deletion progress' : 'Scan slots'}
{unit.purge ? `Deleting · ${formatCount(unit.purge.rows)} rows erased` : [`${unit.slots.in_use} in use`, `${unit.slots.queued} queued`, cap].filter(Boolean).join(' · ')}
} diff --git a/src/styles.css b/src/styles.css index d9721f3e..e2203dc3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1109,7 +1109,9 @@ .tab:hover { color: #eaf4ff; } .tab.active { color: #fff; border-bottom-color: var(--blue); } .unit-list { display: grid; } -.unit-row { display: grid; grid-template-columns: minmax(150px, 1fr) auto minmax(0, 2.6fr); align-items: center; gap: 16px; padding: 15px 8px; border-top: 1px solid #1c3047; border-radius: 6px; } +.unit-filters { display: grid; grid-template-columns: minmax(0, 1fr) minmax(160px, 220px); gap: 12px; margin: -2px 0 12px; } +.unit-filters label { min-width: 0; } +.unit-row { display: grid; grid-template-columns: minmax(150px, 1fr) 150px minmax(0, 2.6fr); align-items: center; gap: 16px; padding: 15px 8px; border-top: 1px solid #1c3047; border-radius: 6px; } .unit-row:first-child { border-top: 0; } .unit-row:hover, .unit-row:focus-visible { background: #102d4b; outline: none; } .unit-row-name { min-width: 0; } @@ -1118,7 +1120,7 @@ .unit-row-badges, .account-badges { display: flex; flex-wrap: wrap; gap: 6px; } .unit-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(120px, 100%), 1fr)); gap: 12px; margin: 0; } .unit-facts dt { color: #7895b2; font-size: 9px; letter-spacing: .08em; text-transform: uppercase; } -.unit-facts dd { margin: 4px 0 0; color: #d4e5f6; font-size: 12px; } +.unit-facts dd { margin: 4px 0 0; color: #d4e5f6; font-size: 12px; overflow-wrap: anywhere; } .unit-overview-facts dd { overflow-wrap: anywhere; } .account-row { grid-template-columns: minmax(0, 1fr) auto; } .account-row .user-row-actions { grid-column: 1 / -1; flex-wrap: wrap; align-items: center; } @@ -1128,6 +1130,12 @@ .one-time-warning { margin-top: 10px; font-size: 12px; } .action-dialog-details { margin: 0 0 16px; font-size: 12px; } .action-dialog-details .notice { font-size: 12px; } +.ceiling-options { display: grid; gap: 12px; } +.ceiling-option { display: grid; gap: 7px; min-width: 0; } +.ceiling-option > small, +.ceiling-input-group { margin-left: 26px; } +.ceiling-input-group { display: grid; gap: 6px; } +.ceiling-input-group > label { max-width: 480px; } .erase-list { display: grid; gap: 8px; margin: 0; padding: 12px 14px 12px 28px; border: 1px solid #71353f; border-radius: 8px; background: #2a151b; color: #f3c3c6; } .erase-list strong, .erase-list span { display: block; } .erase-list span { color: #d59ca1; font-size: 11px; margin-top: 2px; } @@ -1327,9 +1335,15 @@ body:has(.connection-notice) .auth-side { padding-bottom: 110px; } .user-row-actions { grid-column: 1; } } +@media (max-width: 420px) { + .tab-bar { flex-wrap: wrap; overflow-x: visible; } + .tab { flex: 1 1 calc(50% - 4px); text-align: center; padding-inline: 8px; white-space: normal; } +} + @media (max-width: 760px) { .unit-row { grid-template-columns: minmax(0, 1fr) auto; } - .unit-facts { grid-column: 1 / -1; grid-template-columns: 1fr; gap: 8px; } + .unit-facts { grid-column: 1 / -1; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; min-width: 0; } + .unit-filters { grid-template-columns: minmax(0, 1fr); } .audit-row { grid-template-columns: 1fr; gap: 6px; } .account-row .user-row-actions .button { flex: 1 1 calc(50% - 8px); min-height: 44px; } .tab { min-height: 44px; } @@ -1900,6 +1914,9 @@ label small, font-size: 13px; } +.delete-progress-line { display: flex; align-items: center; gap: 8px; line-height: 1.5; } +.delete-progress-line .spinner { flex: 0 0 auto; } + @media (max-width: 760px) { .eyebrow, .pill,