From f16cdd3d51fd4f9d1fe344aceffe552e30a77199 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 22:25:09 +0000 Subject: [PATCH] M24: collection workspace (3-region info-dense layout) New /collections/:cid/workspace route. Loosely modeled after the upstream stig-manager Manage Collection view: Manage panel on the left (name + description + Grants/Users/Settings/Metadata/Labels tab strip), Assets metrics table top right, STIGs metrics table bottom right. Both tables drive off the M10 /metrics/summary/{asset,stig} endpoints, so the same Assessed/Submitted/Accepted/Rejected percentages surface here that the Metrics tab uses. Percentage formulas mirror upstream's collectionManager.js (assessed/ assessments * 100 etc). Tables include bulk-select checkboxes, sortable columns, name filter, and a toolbar. Wired actions: Create asset (opens NewAssetDialog), bulk Delete assets (Manage role required), and Refresh. Toolbar buttons for Import/Transfer/Modify/Assign STIG/ Unassign STIG are surfaced as disabled placeholders pointing at their current home (Reviews tab / Asset detail page). Collections list now links to the workspace by default; the collection id column still links to the legacy tabbed detail page. No API changes. Co-Authored-By: Bryce Anglin --- e2e/tests/web.spec.ts | 66 ++ web/src/app/pages/collections/list.tsx | 11 +- web/src/app/pages/collections/workspace.tsx | 1084 +++++++++++++++++++ web/src/app/router.tsx | 9 + web/src/lib/api/hooks.ts | 2 + 5 files changed, 1170 insertions(+), 2 deletions(-) create mode 100644 web/src/app/pages/collections/workspace.tsx diff --git a/e2e/tests/web.spec.ts b/e2e/tests/web.spec.ts index 587533a..1cc5a42 100644 --- a/e2e/tests/web.spec.ts +++ b/e2e/tests/web.spec.ts @@ -432,6 +432,72 @@ test.describe('Web SPA', () => { await expect(page.getByTestId('workspace-search')).toBeFocused() }) + test('collection workspace renders 3-region layout with assets + STIGs tables (M24)', async ({ + page, + }) => { + // Create a fresh collection through the dialog. This lands on the + // legacy detail page, which is fine — we navigate to the workspace + // explicitly below. + await page.goto(`${urls.web}/collections`) + const collectionName = `e2e-cw-coll-${Date.now()}` + await page.getByTestId('new-collection-button').click() + const cdialog = page.getByTestId('new-collection-dialog') + await cdialog.getByTestId('new-collection-name-input').fill(collectionName) + await cdialog.getByTestId('new-collection-submit').click() + await expect(page).toHaveURL(/\/collections\/\d+$/, { timeout: 10_000 }) + const collectionId = page.url().match(/\/collections\/(\d+)/)?.[1] + expect(collectionId).toBeTruthy() + + // Hop to the workspace. + await page.goto(`${urls.web}/collections/${collectionId}/workspace`) + await expect(page.getByTestId('collection-workspace')).toBeVisible() + await expect(page.getByTestId('workspace-collection-name')).toHaveText( + collectionName, + ) + // The owner role badge surfaces because the creating user is the + // collection owner. + await expect(page.getByTestId('workspace-role-badge')).toContainText( + /owner/i, + ) + + // Manage panel + tabs render with Grants selected by default. + await expect(page.getByTestId('workspace-manage-panel')).toBeVisible() + await expect(page.getByTestId('manage-tab-grants')).toBeVisible() + await expect(page.getByTestId('manage-tab-labels')).toBeVisible() + + // Both metrics tables are rendered. + const assetsTable = page.getByTestId('workspace-assets-table') + const stigsTable = page.getByTestId('workspace-stigs-table') + await expect(assetsTable).toBeVisible() + await expect(stigsTable).toBeVisible() + // Empty state copy on both tables (fresh collection has neither + // assets nor STIGs yet). + await expect( + assetsTable.getByText(/no assets yet/i), + ).toBeVisible({ timeout: 10_000 }) + await expect( + stigsTable.getByText(/no stigs assigned yet/i), + ).toBeVisible({ timeout: 10_000 }) + + // Bulk-delete on the Assets toolbar is disabled while nothing is + // selected; the Create button is enabled for the owner. + await expect(page.getByTestId('assets-toolbar-create')).toBeEnabled() + await expect(page.getByTestId('assets-toolbar-delete')).toBeDisabled() + + // The Collections list now defaults its row link to the workspace + // route. + await page.goto(`${urls.web}/collections`) + const row = page.getByTestId(`collection-link-${collectionId}`) + await expect(row).toHaveAttribute( + 'href', + `/collections/${collectionId}/workspace`, + ) + // The legacy detail link remains reachable from the list. + await expect( + page.getByTestId(`collection-detail-link-${collectionId}`), + ).toHaveAttribute('href', `/collections/${collectionId}`) + }) + test('admin layout renders with the four tabs (M18f)', async ({ page }) => { await page.goto(`${urls.web}/admin/users`) await expect(page.getByTestId('admin-layout')).toBeVisible() diff --git a/web/src/app/pages/collections/list.tsx b/web/src/app/pages/collections/list.tsx index af2411a..2cfb14e 100644 --- a/web/src/app/pages/collections/list.tsx +++ b/web/src/app/pages/collections/list.tsx @@ -147,7 +147,7 @@ export function CollectionsListPage() { > @@ -158,7 +158,14 @@ export function CollectionsListPage() { {c.description || '—'} - {c.collectionId} + + {c.collectionId} + ))} diff --git a/web/src/app/pages/collections/workspace.tsx b/web/src/app/pages/collections/workspace.tsx new file mode 100644 index 0000000..d24c843 --- /dev/null +++ b/web/src/app/pages/collections/workspace.tsx @@ -0,0 +1,1084 @@ +// /collections/:collectionId/workspace +// +// Collection management workspace. Loosely modeled after the upstream +// stig-manager "Manage Collection" view: a single info-dense page with +// three regions — Manage panel on the left (name + description + +// Grants/Users/Settings/Metadata/Labels tab strip), Assets table top +// right, STIGs table bottom right. +// +// Both right-side tables drive off /collections/{cid}/metrics/summary/* +// so the same Assessed / Submitted / Accepted / Rejected percentages +// surface here that the Metrics tab uses, but in a one-screen layout +// designed to replace the per-tab click-through. +// +// Routing decision: this is a *new* route alongside the existing +// /collections/:cid tabbed detail. The Collections list points here by +// default; direct links to /collections/:cid keep working. + +import { + ArrowLeft, + ArrowUpDown, + Loader2, + Plus, + RefreshCw, + Search, + Settings, + Tag, + Trash2, + Upload, + UserPlus, + Users, +} from 'lucide-react' +import * as React from 'react' +import { Link, useParams } from 'react-router-dom' + +import { GrantsTab } from './grants-tab' +import { NewAssetDialog } from '../assets/new-asset-dialog' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + useCollection, + useCurrentUser, + useDeleteAsset, + useMetricsByAsset, + useMetricsByStig, + type MetricsSummary, + type MetricsSummaryAggAsset, + type MetricsSummaryAggStig, +} from '@/lib/api/hooks' +import { + atLeast, + roleForCollection, + ROLE_LABELS, + type CollectionRoleId, +} from '@/lib/auth/roles' +import { cn } from '@/lib/utils' + +export function CollectionWorkspacePage() { + const { collectionId } = useParams<{ collectionId: string }>() + const collection = useCollection(collectionId) + const me = useCurrentUser() + const role = roleForCollection(me.data, collectionId ?? '') + + if (collection.isLoading) { + return ( +
+ Loading collection… +
+ ) + } + + if (collection.isError || !collection.data || !collectionId) { + return ( +
+ + Back to Collections + +

+ Failed to load collection {collectionId}:{' '} + {(collection.error as Error | null)?.message ?? 'Not found.'} +

+
+ ) + } + + const c = collection.data + return ( +
+
+
+ + Collections + + / +

+ {c.name} +

+ +
+
+ Need the legacy tabbed view?{' '} + + open detail + +
+
+ + {/* Body: 1 column on mobile, 2 columns (manage | tables) on lg+. */} +
+ +
+ + +
+
+
+ ) +} + +// ---- Manage panel (left) ------------------------------------------------ + +interface ManagePanelProps { + collectionId: string + collection: { name: string; description?: string | null; collectionId: string } + role: CollectionRoleId | null +} + +function ManagePanel({ collectionId, collection, role }: ManagePanelProps) { + // The five Manage tabs match the upstream layout. Grants is the only + // tab with a full implementation today (re-uses the existing + // GrantsTab component); the others are intentional placeholders + // pointing to where their content will land. + return ( + + + Manage Collection + + Properties, grants, and labels for this Collection. + + + +
+ + +
+ + + + + Grants + + + Users + + + Settings + + + Metadata + + + Labels + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ) +} + +function Field({ + label, + value, + multiline = false, +}: { + label: string + value: string + multiline?: boolean +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} + +function Placeholder({ + title, + blurb, + link, + linkLabel, +}: { + title: string + blurb: string + link?: string + linkLabel?: string +}) { + return ( + + + {title} + + +

{blurb}

+ {link && linkLabel && ( + + {linkLabel} + + )} +
+
+ ) +} + +// ---- Assets metrics table (top right) ----------------------------------- + +interface TableProps { + collectionId: string + role: CollectionRoleId | null +} + +type AssetSortKey = + | 'name' + | 'stigs' + | 'assessments' + | 'assessedPct' + | 'submittedPct' + | 'acceptedPct' + | 'rejectedPct' + +function AssetsMetricsTable({ collectionId, role }: TableProps) { + const metrics = useMetricsByAsset(collectionId) + const del = useDeleteAsset() + const canCreate = atLeast(role, 2) + const canDelete = atLeast(role, 3) + const [selected, setSelected] = React.useState>(new Set()) + const [query, setQuery] = React.useState('') + const [sortKey, setSortKey] = React.useState('name') + const [sortDir, setSortDir] = React.useState<'asc' | 'desc'>('asc') + const [dialogOpen, setDialogOpen] = React.useState(false) + const [deleteError, setDeleteError] = React.useState(null) + + const rows = React.useMemo(() => { + const list = metrics.data ?? [] + const filtered = query + ? list.filter((r) => + r.name.toLowerCase().includes(query.trim().toLowerCase()), + ) + : list + const cmp = (a: MetricsSummaryAggAsset, b: MetricsSummaryAggAsset) => { + const av = sortValueAsset(a, sortKey) + const bv = sortValueAsset(b, sortKey) + if (typeof av === 'number' && typeof bv === 'number') { + return sortDir === 'asc' ? av - bv : bv - av + } + return sortDir === 'asc' + ? String(av).localeCompare(String(bv)) + : String(bv).localeCompare(String(av)) + } + return [...filtered].sort(cmp) + }, [metrics.data, query, sortKey, sortDir]) + + function toggleAll() { + if (selected.size === rows.length && rows.length > 0) { + setSelected(new Set()) + } else { + setSelected(new Set(rows.map((r) => r.assetId))) + } + } + function toggleOne(id: string) { + setSelected((s) => { + const next = new Set(s) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + function flipSort(key: AssetSortKey) { + if (key === sortKey) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')) + } else { + setSortKey(key) + setSortDir(key === 'name' ? 'asc' : 'desc') + } + } + + async function bulkDelete() { + setDeleteError(null) + const ids = [...selected] + if (!ids.length) return + if ( + !window.confirm( + `Delete ${ids.length} asset${ids.length === 1 ? '' : 's'}? This cannot be undone.`, + ) + ) { + return + } + try { + // Serial deletes — bulk endpoint isn't in the API surface. + for (const id of ids) { + await del.mutateAsync({ assetId: id }) + } + setSelected(new Set()) + void metrics.refetch() + } catch (e) { + setDeleteError((e as Error).message) + } + } + + return ( + + +
+ Assets + + {metrics.data + ? `${metrics.data.length} asset${metrics.data.length === 1 ? '' : 's'}` + : '—'} + +
+
+ } + label="Create" + disabled={!canCreate} + onClick={() => setDialogOpen(true)} + testid="assets-toolbar-create" + /> + } + label="Import" + disabled + title="File-based imports live on the Reviews tab (M22)." + testid="assets-toolbar-import" + /> + } + label={`Delete${selected.size ? ` (${selected.size})` : ''}`} + disabled={!canDelete || selected.size === 0 || del.isPending} + onClick={bulkDelete} + testid="assets-toolbar-delete" + destructive + /> + } + label="Refresh" + onClick={() => metrics.refetch()} + disabled={metrics.isFetching} + testid="assets-toolbar-refresh" + /> +
+
+ + +
+
+ + setQuery(e.target.value)} + data-testid="assets-filter" + /> +
+ {metrics.isFetching && !metrics.isLoading && ( + + )} + {deleteError && ( + + {deleteError} + + )} +
+ +
+ + + + + flipSort('name')} + /> + + flipSort('stigs')} + className="text-right" + /> + flipSort('assessments')} + className="text-right" + /> + flipSort('assessedPct')} + /> + flipSort('submittedPct')} + /> + flipSort('acceptedPct')} + /> + flipSort('rejectedPct')} + /> + + + + {metrics.isLoading && ( + + + + )} + {metrics.isError && ( + + + + )} + {metrics.data && rows.length === 0 && ( + + + + )} + {rows.map((r) => ( + + + + + + + + + + + + ))} + +
+ 0 && selected.size === rows.length + } + onChange={toggleAll} + aria-label="Select all assets" + data-testid="assets-select-all" + /> + Labels
+ Loading assets… +
+ Failed to load:{' '} + {(metrics.error as Error | null)?.message ?? 'Unknown error.'} +
+ {query + ? 'No assets match that filter.' + : 'No assets yet. Create one to get started.'} +
+ toggleOne(r.assetId)} + aria-label={`Select asset ${r.name}`} + data-testid={`asset-row-checkbox-${r.assetId}`} + /> + + + {r.name} + + + + + {r.benchmarkIds?.length ?? 0} + + {r.metrics.assessments} +
+
+
+ + {canCreate && ( + + )} +
+ ) +} + +function sortValueAsset( + r: MetricsSummaryAggAsset, + key: AssetSortKey, +): string | number { + switch (key) { + case 'name': + return r.name + case 'stigs': + return r.benchmarkIds?.length ?? 0 + case 'assessments': + return r.metrics.assessments + case 'assessedPct': + return percent(r.metrics, 'assessed') + case 'submittedPct': + return percent(r.metrics, 'submitted') + case 'acceptedPct': + return percent(r.metrics, 'accepted') + case 'rejectedPct': + return percent(r.metrics, 'rejected') + } +} + +// ---- STIGs metrics table (bottom right) --------------------------------- + +type StigSortKey = + | 'benchmarkId' + | 'revisionStr' + | 'assessments' + | 'assets' + | 'assessedPct' + | 'submittedPct' + | 'acceptedPct' + | 'rejectedPct' + +function StigsMetricsTable({ collectionId }: TableProps) { + const metrics = useMetricsByStig(collectionId) + const [query, setQuery] = React.useState('') + const [sortKey, setSortKey] = React.useState('benchmarkId') + const [sortDir, setSortDir] = React.useState<'asc' | 'desc'>('asc') + const [selected, setSelected] = React.useState>(new Set()) + + const rows = React.useMemo(() => { + const list = metrics.data ?? [] + const filtered = query + ? list.filter((r) => + r.benchmarkId.toLowerCase().includes(query.trim().toLowerCase()), + ) + : list + const cmp = (a: MetricsSummaryAggStig, b: MetricsSummaryAggStig) => { + const av = sortValueStig(a, sortKey) + const bv = sortValueStig(b, sortKey) + if (typeof av === 'number' && typeof bv === 'number') { + return sortDir === 'asc' ? av - bv : bv - av + } + return sortDir === 'asc' + ? String(av).localeCompare(String(bv)) + : String(bv).localeCompare(String(av)) + } + return [...filtered].sort(cmp) + }, [metrics.data, query, sortKey, sortDir]) + + function toggleAll() { + if (selected.size === rows.length && rows.length > 0) { + setSelected(new Set()) + } else { + setSelected(new Set(rows.map((r) => r.benchmarkId))) + } + } + function toggleOne(id: string) { + setSelected((s) => { + const next = new Set(s) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + function flipSort(key: StigSortKey) { + if (key === sortKey) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')) + } else { + setSortKey(key) + setSortDir(key === 'benchmarkId' ? 'asc' : 'desc') + } + } + + return ( + + +
+ STIGs + + {metrics.data + ? `${metrics.data.length} STIG${metrics.data.length === 1 ? '' : 's'}` + : '—'} + +
+
+ } + label="Assign STIG" + disabled + title="Attach STIGs from the Asset detail page (M21b)." + testid="stigs-toolbar-assign" + /> + } + label={`Unassign${selected.size ? ` (${selected.size})` : ''}`} + disabled + title="Unassign STIGs from Assets one at a time on the Asset detail page." + testid="stigs-toolbar-unassign" + /> + } + label="Refresh" + onClick={() => metrics.refetch()} + disabled={metrics.isFetching} + testid="stigs-toolbar-refresh" + /> +
+
+ + +
+
+ + setQuery(e.target.value)} + data-testid="stigs-filter" + /> +
+ {metrics.isFetching && !metrics.isLoading && ( + + )} +
+ +
+ + + + + flipSort('benchmarkId')} + /> + flipSort('revisionStr')} + /> + flipSort('assessments')} + className="text-right" + /> + flipSort('assets')} + className="text-right" + /> + flipSort('assessedPct')} + /> + flipSort('submittedPct')} + /> + flipSort('acceptedPct')} + /> + flipSort('rejectedPct')} + /> + + + + {metrics.isLoading && ( + + + + )} + {metrics.isError && ( + + + + )} + {metrics.data && rows.length === 0 && ( + + + + )} + {rows.map((r) => ( + + + + + + + + + + + + ))} + +
+ 0 && selected.size === rows.length + } + onChange={toggleAll} + aria-label="Select all STIGs" + data-testid="stigs-select-all" + /> +
+ Loading STIGs… +
+ Failed to load:{' '} + {(metrics.error as Error | null)?.message ?? 'Unknown error.'} +
+ {query + ? 'No STIGs match that filter.' + : 'No STIGs assigned yet. Attach one to an Asset to start.'} +
+ toggleOne(r.benchmarkId)} + aria-label={`Select STIG ${r.benchmarkId}`} + data-testid={`stig-row-checkbox-${r.benchmarkId}`} + /> + + + {r.benchmarkId} + + + {r.revisionStr ?? '—'} + + {r.metrics.assessments} + + {r.assets} +
+
+
+
+ ) +} + +function sortValueStig( + r: MetricsSummaryAggStig, + key: StigSortKey, +): string | number { + switch (key) { + case 'benchmarkId': + return r.benchmarkId + case 'revisionStr': + return r.revisionStr ?? '' + case 'assessments': + return r.metrics.assessments + case 'assets': + return r.assets + case 'assessedPct': + return percent(r.metrics, 'assessed') + case 'submittedPct': + return percent(r.metrics, 'submitted') + case 'acceptedPct': + return percent(r.metrics, 'accepted') + case 'rejectedPct': + return percent(r.metrics, 'rejected') + } +} + +// ---- Shared bits -------------------------------------------------------- + +type PctKind = 'assessed' | 'submitted' | 'accepted' | 'rejected' + +// Matches upstream's percentage formulas in collectionManager.js +// (assessed / assessments * 100; submitted = (submitted+accepted+rejected)/assessments * 100; +// accepted = accepted/assessments * 100; rejected = rejected/assessments * 100). +function percent(m: MetricsSummary['metrics'], kind: PctKind): number { + if (!m.assessments) return 0 + const s = m.statuses + switch (kind) { + case 'assessed': + return (m.assessed / m.assessments) * 100 + case 'submitted': + return ((s.submitted + s.accepted + s.rejected) / m.assessments) * 100 + case 'accepted': + return (s.accepted / m.assessments) * 100 + case 'rejected': + return (s.rejected / m.assessments) * 100 + } +} + +function ProgressTd({ + metrics, + kind, +}: { + metrics: MetricsSummary['metrics'] + kind: PctKind +}) { + const pct = percent(metrics, kind) + return ( + + + + ) +} + +function ProgressBar({ pct, kind }: { pct: number; kind: PctKind }) { + const display = pct === 0 ? '0%' : pct >= 99.95 ? '100%' : `${pct.toFixed(0)}%` + const colorClass = + kind === 'rejected' + ? 'bg-red-500/60' + : kind === 'accepted' + ? 'bg-emerald-500/60' + : kind === 'submitted' + ? 'bg-sky-500/60' + : 'bg-emerald-500/40' + return ( +
+
+ + {display} + +
+ ) +} + +function ToolbarButton({ + icon, + label, + onClick, + disabled, + destructive, + title, + testid, +}: { + icon: React.ReactNode + label: string + onClick?: () => void + disabled?: boolean + destructive?: boolean + title?: string + testid?: string +}) { + return ( + + ) +} + +function SortableTh({ + label, + active, + dir, + onClick, + className, +}: { + label: string + active: boolean + dir: 'asc' | 'desc' + onClick: () => void + className?: string +}) { + return ( + + + {label} + {active ? ( + {dir === 'asc' ? '▲' : '▼'} + ) : ( + + )} + + + ) +} + +function LabelChips({ + labels, +}: { + labels: MetricsSummaryAggAsset['labels'] +}) { + if (!labels || labels.length === 0) { + return — + } + return ( +
+ {labels.map((l, i) => ( + + {l.name ?? l.labelId ?? '—'} + + ))} +
+ ) +} + +function RoleBadge({ role }: { role: CollectionRoleId | null }) { + if (!role) { + return ( + + No grant + + ) + } + return ( + + {ROLE_LABELS[role]} + + ) +} diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index a65b21d..d0df020 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -14,6 +14,7 @@ import { ReviewEditorPage } from './pages/assets/review-editor' import { AssetReviewWorkspacePage } from './pages/assets/workspace' import { CollectionDetailPage } from './pages/collections/detail' import { CollectionsListPage } from './pages/collections/list' +import { CollectionWorkspacePage } from './pages/collections/workspace' import { DashboardPage } from './pages/dashboard' import { LibraryDetailPage } from './pages/library/detail' import { LibraryListPage } from './pages/library/list' @@ -50,6 +51,14 @@ export const router = createBrowserRouter([ ), }, + { + path: 'collections/:collectionId/workspace', + element: ( + + + + ), + }, { path: 'collections/:collectionId/assets/:assetId', element: ( diff --git a/web/src/lib/api/hooks.ts b/web/src/lib/api/hooks.ts index 72a0730..cbe9864 100644 --- a/web/src/lib/api/hooks.ts +++ b/web/src/lib/api/hooks.ts @@ -91,6 +91,7 @@ import { type JobRun, type JobRunOutput, type JobTask, + type MetricsSummary, type MetricsSummaryAggAsset, type MetricsSummaryAggCollection, type MetricsSummaryAggStig, @@ -141,6 +142,7 @@ export type { JobRun, JobRunOutput, JobTask, + MetricsSummary, MetricsSummaryAggAsset, MetricsSummaryAggCollection, MetricsSummaryAggStig,