diff --git a/bun.lock b/bun.lock index d957745..d41687e 100644 --- a/bun.lock +++ b/bun.lock @@ -159,6 +159,7 @@ "@aqa/kit": "workspace:*", "@aqa/schemas": "workspace:*", "@aqa/store": "workspace:*", + "yaml": "^2.9.0", }, }, "packages/store": { diff --git a/docs/internal/admin-placeholder-audit.md b/docs/internal/admin-placeholder-audit.md index ac60d3e..4992a8d 100644 --- a/docs/internal/admin-placeholder-audit.md +++ b/docs/internal/admin-placeholder-audit.md @@ -54,7 +54,7 @@ The line numbers below were captured against commit `~v1.6.0` (post-merge of PR ### Packs (lines ~6850-7040) -- ~~`6850`, `6853` — top-bar "Import manifest" / "Install pack" (slice 3 wires "Install pack" via the new wizard)~~ **DONE (slice 3, PR #26):** the "Install pack" placeholder was renamed to "Create pack" and wired to the new `` component that POSTs to `/api/packs/scaffold`. "Import manifest" remains a placeholder for slice 4b. +- ~~`6850`, `6853` — top-bar "Import manifest" / "Install pack"~~ **DONE:** "Install pack" → "Create pack" wizard (slice 3, PR #26) wired to `POST /api/packs/scaffold`; "Import manifest" wired in slice 4b (PR #28) to a new `` that calls `POST /api/packs/import` — server parses YAML, validates against `@aqa/schemas/PackManifest`, installs via store. 4 Playwright e2e tests cover open/disabled, happy-path 201, schema-validation 400, 409-duplicate-with-force-retry path. 8 server unit tests cover the endpoint contract. - `6910`, `6914`, `6925` — pack row actions (toggle / inspect) - `6986` — pack detail actions - `7028`, `7033`, `7038` — scenario picker inside pack detail @@ -72,7 +72,7 @@ Not yet line-counted — comes after the first 49 fit. Doing all 81 in one PR is unreviewable. Plan: **one PR per page** so each is a manageable review surface. 1. ~~**slice 4a — Findings page actions** (verify, reject, mark-fixed, mark-duplicate row actions). Needs `PATCH /api/findings/:id/status` (already exists in `packages/server` from v1.4). Just wire the UI buttons.~~ **SHIPPED (PR #27).** Kanban terminal-transition modal wired to `POST /api/findings/:id/status`. Drag-and-drop, required-reason capture, error-on-fail, optimistic UI only after server confirmation. The server *persists* the new status to the store; appending a corresponding `finding.status_changed` event to the audit chain is a separate task tracked as a v1.7.x follow-up (requires extending the `EventKind` enum in `@aqa/schemas` and the store's `updateFindingStatus` to append the event). Remaining row actions in clusters/list views (verify/reject inline buttons) also deferred to a follow-up PR — today the kanban is the canonical status-change surface. -2. **slice 4b — Packs page** (Import manifest, Install pack). "Install pack" delegates to the wizard from slice 3; "Import manifest" needs a `POST /api/packs/import` route. +2. ~~**slice 4b — Packs page** (Import manifest, Install pack). "Install pack" delegates to the wizard from slice 3; "Import manifest" needs a `POST /api/packs/import` route.~~ **SHIPPED (PR #28).** `POST /api/packs/import` accepts YAML text, parses + validates against the canonical `PackManifest` schema, installs via store. Admin `` lets users paste YAML or load a file from disk, surfaces 4xx/5xx errors inline, and offers a Force-overwrite path on 409. 3. **slice 4c — Scenarios / Risks / Profiles CRUD** (edit/save/clone/delete). Heaviest slice — needs full CRUD flows on three resources. 4. **slice 4d — Agents page** (install-instructions copy, "Install for X"). Mostly client-side (copy to clipboard, download files). 5. **slice 4e — Replay / Audit / Cost / Queue / Notifications** (export CSV, mark-read, drain, pause). Mix of W + C. diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index 49862b7..c90c60d 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -4434,7 +4434,7 @@ function CreatePackWizard({ open, onClose }) { ...(license.trim() ? { license: license.trim() } : {}), ...(force ? { force: true } : {}), }; - const res = await fetch('/api/packs/scaffold', { + const res = await fetch(apiUrl('/api/packs/scaffold'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -4671,6 +4671,241 @@ function CreatePackWizard({ open, onClose }) { } Object.assign(window, { CreatePackWizard }); +// ============================================================= +// Import-manifest wizard (v1.7 slice 4b) +// ============================================================= +// Front-end for POST /api/packs/import. Lets a maintainer paste a +// pack.yaml manifest text (or load a file from disk via the native +// file input), then POSTs the YAML body to the server which parses, +// validates against `@aqa/schemas/PackManifest`, and installs into +// the store. The wizard is deliberately thin — the server is the +// source of truth for validation; client only does empty/whitespace +// checks to avoid wasting a round-trip on obvious failures. +function ImportManifestWizard({ open, onClose }) { + const [yamlText, setYamlText] = React.useState(''); + const [force, setForce] = React.useState(false); + const [submitting, setSubmitting] = React.useState(false); + const [error, setError] = React.useState(null); + const [result, setResult] = React.useState(null); + const toast = useToast(); + + const trimmed = yamlText.trim(); + const canSubmit = trimmed !== '' && !submitting; + + function reset() { + setYamlText(''); + setForce(false); + setError(null); + setResult(null); + setSubmitting(false); + } + function handleClose() { + if (submitting) return; + reset(); + onClose?.(); + } + + async function handleFileChange(e) { + const fileInput = e.target; + const file = fileInput.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + setYamlText(text); + // Clear any stale "could not read file" error from a previous + // failed selection so the user isn't confused by an obsolete + // message that's no longer relevant. + setError(null); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(`Could not read file: ${msg}`); + } finally { + // Reset the input value so selecting the same file again + // reliably re-fires `onChange` in every browser. Without this, + // Chrome/Edge skip the event on identical re-selection (the + // input still references the previous File object). + fileInput.value = ''; + } + } + + async function handleSubmit() { + if (!canSubmit) return; + setSubmitting(true); + setError(null); + const reqUrl = apiUrl('/api/packs/import'); + try { + const res = await fetch(reqUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ yaml: trimmed, ...(force ? { force: true } : {}) }), + }); + const text = await res.text(); + let parsed = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = null; + } + if (!res.ok) { + const msg = parsed?.error ?? `HTTP ${res.status}`; + setError(msg); + toast.push({ kind: 'error', title: 'Import manifest failed', body: msg }); + return; + } + // 2xx but body is empty / not JSON / missing the documented + // `pack` shape is treated as an integration failure rather + // than silently dropping to the form state. Otherwise the + // toast would announce success while the wizard stays in + // form mode (since `result` is falsy), confusing the user. + if (!parsed || typeof parsed !== 'object' || !('pack' in parsed)) { + const msg = `Server returned ${res.status} but the response body is missing the expected \`pack\` object (got ${text ? text.slice(0, 80) : 'empty body'}).`; + setError(msg); + toast.push({ kind: 'error', title: 'Import manifest failed', body: msg }); + return; + } + setResult(parsed); + toast.push({ + kind: 'success', + title: 'Pack imported', + body: parsed?.pack?.name ?? 'unknown pack', + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const full = `Could not reach ${reqUrl} (${msg}). The admin is in mock-data mode or the server is down — the manifest was not imported.`; + setError(full); + toast.push({ kind: 'error', title: 'Import manifest failed', body: full }); + } finally { + setSubmitting(false); + } + } + + return ( + + Done + + ) : ( + <> + + + + ) + } + > + {result ? ( +
+ +
+
+ Name:{' '} + {result.pack?.name} +
+
+ Version:{' '} + {result.pack?.version} +
+
+
+
+ Next: make sure the matching pack directory exists at{' '} + <project>/packs/{result.pack?.name}/ with scenarios + risks. The + store records the manifest; the file tree on disk is what aqa run reads. +
+
+ ) : ( +
+ {error && ( + + {error} + + )} +
+ + +
+ Reads the file into the textarea below — submit happens on Import, not on selection. +
+
+
+ +