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
77 changes: 77 additions & 0 deletions e2e/tests/web.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,83 @@ test.describe('Web SPA', () => {
).toBeVisible({ timeout: 10_000 })
})

test('metrics tab shows KPI grid and empty state tables (M18e)', async ({
page,
}) => {
await page.goto(`${urls.web}/collections`)
const collectionName = `e2e-metrics-${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 })

await page.getByTestId('collection-tab-metrics').click()
await expect(page.getByTestId('collection-metrics-tab')).toBeVisible()

// KPI grid is rendered (even though counts are zero for a fresh collection).
await expect(page.getByTestId('metrics-kpi-grid')).toBeVisible({
timeout: 10_000,
})
await expect(page.getByTestId('metrics-kpi-assets')).toContainText('0')
// Per-asset and per-stig tables show empty state.
await expect(page.getByTestId('metrics-by-asset-empty')).toBeVisible()
await expect(page.getByTestId('metrics-by-stig-empty')).toBeVisible()
})

test('history tab shows stats and empty entries state (M18e)', async ({
page,
}) => {
await page.goto(`${urls.web}/collections`)
const collectionName = `e2e-history-${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 })

await page.getByTestId('collection-tab-history').click()
await expect(page.getByTestId('collection-history-tab')).toBeVisible()

// Stats panel is visible.
await expect(page.getByTestId('history-stats')).toBeVisible({
timeout: 10_000,
})
await expect(page.getByTestId('history-total-entries')).toContainText('0')
// No history entries yet → empty state.
await expect(page.getByTestId('history-empty')).toBeVisible()
})

test('exports tab renders the download form (M18e)', async ({ page }) => {
await page.goto(`${urls.web}/collections`)
const collectionName = `e2e-exports-${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 })

await page.getByTestId('collection-tab-exports').click()
await expect(page.getByTestId('collection-exports-tab')).toBeVisible()
await expect(page.getByTestId('export-format-toggle')).toBeVisible()
await expect(page.getByTestId('export-download-button')).toBeVisible()
})

test('POAM tab renders the download form (M18e)', async ({ page }) => {
await page.goto(`${urls.web}/collections`)
const collectionName = `e2e-poam-${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 })

await page.getByTestId('collection-tab-poam').click()
await expect(page.getByTestId('collection-poam-tab')).toBeVisible()
await expect(page.getByTestId('poam-aggregator')).toBeVisible()
await expect(page.getByTestId('poam-download-button')).toBeVisible()
})

test('dry-run batch review surfaces will-insert counts (M18d)', async ({
page,
}) => {
Expand Down
34 changes: 33 additions & 1 deletion web/src/app/pages/collections/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { ArrowLeft, Loader2 } from 'lucide-react'
import { Link, useParams } from 'react-router-dom'

import { AssetsTab } from '../assets/assets-tab'
import { ExportsTab } from './exports-tab'
import { HistoryTab } from './history-tab'
import { MetricsTab } from './metrics-tab'
import { PoamTab } from './poam-tab'
import { ReviewsTab } from './reviews-tab'
import {
Card,
Expand Down Expand Up @@ -187,12 +191,40 @@ export function CollectionDetailPage() {
</TabsContent>
)}

{visibleTabs.some((t) => t.value === 'metrics') && (
<TabsContent value="metrics">
<MetricsTab collectionId={c.collectionId} />
</TabsContent>
)}

{visibleTabs.some((t) => t.value === 'history') && (
<TabsContent value="history">
<HistoryTab collectionId={c.collectionId} role={role} />
</TabsContent>
)}

{visibleTabs.some((t) => t.value === 'exports') && (
<TabsContent value="exports">
<ExportsTab collectionId={c.collectionId} />
</TabsContent>
)}

{visibleTabs.some((t) => t.value === 'poam') && (
<TabsContent value="poam">
<PoamTab collectionId={c.collectionId} />
</TabsContent>
)}

{visibleTabs
.filter(
(t) =>
t.value !== 'overview' &&
t.value !== 'assets' &&
t.value !== 'reviews',
t.value !== 'reviews' &&
t.value !== 'metrics' &&
t.value !== 'history' &&
t.value !== 'exports' &&
t.value !== 'poam',
)
.map((tab) => (
<TabsContent key={tab.value} value={tab.value}>
Expand Down
255 changes: 255 additions & 0 deletions web/src/app/pages/collections/exports-tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
// Exports tab inside the Collection detail page. Wraps the three
// `/collections/{cid}/archive/{ckl|cklb|xccdf}` endpoints in one
// form. The user picks an asset subset and a format; the API streams
// a ZIP that we trigger as a browser download. The default selection
// is "all assets in the Collection"; omitting per-asset `stigs`
// requests the default revisions of every benchmark mapped to the
// asset (and visible to the caller).

import { Download, Loader2 } from 'lucide-react'
import * as React from 'react'

import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { useAssets } from '@/lib/api/hooks'
import {
downloadCklArchive,
downloadCklbArchive,
downloadXccdfArchive,
type AssetStigSelection,
type CklMode,
} from '@/lib/api'

interface ExportsTabProps {
collectionId: string
}

type Format = 'ckl' | 'cklb' | 'xccdf'

export function ExportsTab({ collectionId }: ExportsTabProps) {
const assets = useAssets({ collectionId })

const [selected, setSelected] = React.useState<Set<string>>(new Set())
const [format, setFormat] = React.useState<Format>('ckl')
const [mode, setMode] = React.useState<CklMode>('mono')
const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [lastDownload, setLastDownload] = React.useState<string | null>(null)

function toggleAll(checked: boolean) {
if (!checked) {
setSelected(new Set())
return
}
setSelected(new Set((assets.data ?? []).map((a) => a.assetId)))
}

function toggleOne(assetId: string, checked: boolean) {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(assetId)
else next.delete(assetId)
return next
})
}

async function onDownload() {
setError(null)
setLastDownload(null)
const list = assets.data ?? []
const effective = selected.size === 0 ? list.map((a) => a.assetId) : Array.from(selected)
if (effective.length === 0) {
setError('No assets in this Collection to export.')
return
}
const selections: AssetStigSelection[] = effective.map((assetId) => ({
assetId,
}))
setPending(true)
try {
if (format === 'ckl') {
await downloadCklArchive(collectionId, selections, mode)
} else if (format === 'cklb') {
await downloadCklbArchive(collectionId, selections, mode)
} else {
await downloadXccdfArchive(collectionId, selections)
}
setLastDownload(
`Downloaded ${format.toUpperCase()} archive for ${effective.length} asset${effective.length === 1 ? '' : 's'}.`,
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Download failed.')
} finally {
setPending(false)
}
}

const allChecked =
selected.size > 0 && selected.size === (assets.data ?? []).length

return (
<div className="space-y-4" data-testid="collection-exports-tab">
<Card>
<CardHeader>
<CardTitle>Checklist exports</CardTitle>
<p className="text-sm text-[var(--color-muted-foreground)]">
Generates a ZIP containing one file per (asset, STIG) by default.
If no assets are selected, every asset in the Collection is
included. Switch to <span className="font-medium">mono</span>{' '}
mode for a single combined file per asset (CKL / CKLB only).
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label>Format</Label>
<div
className="mt-1 inline-flex rounded-md border border-[var(--color-border)] p-0.5 text-xs"
role="radiogroup"
data-testid="export-format-toggle"
>
{(['ckl', 'cklb', 'xccdf'] as Format[]).map((f) => (
<button
key={f}
type="button"
role="radio"
aria-checked={format === f}
onClick={() => setFormat(f)}
className={`rounded px-3 py-1 uppercase tracking-wider ${
format === f
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
: 'text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)]'
}`}
data-testid={`export-format-${f}`}
>
{f}
</button>
))}
</div>
</div>

{format !== 'xccdf' && (
<div>
<Label>Mode</Label>
<div
className="mt-1 inline-flex rounded-md border border-[var(--color-border)] p-0.5 text-xs"
role="radiogroup"
data-testid="export-mode-toggle"
>
{(['mono', 'multi'] as CklMode[]).map((m) => (
<button
key={m}
type="button"
role="radio"
aria-checked={mode === m}
onClick={() => setMode(m)}
className={`rounded px-3 py-1 uppercase tracking-wider ${
mode === m
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
: 'text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)]'
}`}
>
{m}
</button>
))}
</div>
</div>
)}
</div>

<div>
<div className="flex items-center justify-between">
<Label>Assets</Label>
<label className="flex items-center gap-2 text-xs text-[var(--color-muted-foreground)]">
<input
type="checkbox"
checked={allChecked}
onChange={(e) => toggleAll(e.target.checked)}
data-testid="export-select-all"
/>
Select all
</label>
</div>
<div className="mt-2 max-h-64 overflow-y-auto rounded border border-[var(--color-border)] p-2">
{assets.isLoading ? (
<Spinner label="Loading assets…" />
) : (assets.data ?? []).length === 0 ? (
<p
className="text-sm text-[var(--color-muted-foreground)]"
data-testid="export-no-assets"
>
No assets in this Collection yet.
</p>
) : (
<ul className="space-y-1">
{(assets.data ?? []).map((a) => (
<li key={a.assetId}>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={selected.has(a.assetId)}
onChange={(e) =>
toggleOne(a.assetId, e.target.checked)
}
data-testid={`export-asset-${a.assetId}`}
/>
<span>{a.name}</span>
<span className="text-xs text-[var(--color-muted-foreground)]">
{a.stigs?.length ?? 0} STIG
{(a.stigs?.length ?? 0) === 1 ? '' : 's'}
</span>
</label>
</li>
))}
</ul>
)}
</div>
<p className="mt-1 text-xs text-[var(--color-muted-foreground)]">
Leave empty to export every asset.
</p>
</div>

{error && (
<p
className="text-sm text-red-500"
data-testid="export-error"
>
{error}
</p>
)}
{lastDownload && (
<p
className="text-sm text-emerald-500"
data-testid="export-success"
>
{lastDownload}
</p>
)}

<Button
type="button"
onClick={onDownload}
disabled={pending || (assets.data ?? []).length === 0}
data-testid="export-download-button"
>
{pending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
<span className="ml-2">Download archive</span>
</Button>
</CardContent>
</Card>
</div>
)
}

function Spinner({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 text-sm text-[var(--color-muted-foreground)]">
<Loader2 className="size-4 animate-spin" /> {label}
</div>
)
}
Loading
Loading