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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions docs/internal/admin-placeholder-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<CreatePackWizard>` 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 `<ImportManifestWizard>` 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
Expand All @@ -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 `<ImportManifestWizard>` 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.
Expand Down
245 changes: 243 additions & 2 deletions packages/admin/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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}`);
Comment on lines +4714 to +4721
} 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' },
Comment on lines +4735 to +4739
body: JSON.stringify({ yaml: trimmed, ...(force ? { force: true } : {}) }),
});
const text = await res.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
Comment on lines +4742 to +4746
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 (
<Modal
open={open}
onClose={handleClose}
title={result ? 'Manifest imported' : 'Import pack manifest'}
sub={
result
? `${result.pack?.name ?? 'unknown'} v${result.pack?.version ?? '?'} is now registered. The pack file tree itself (scenarios, risks) must be in place on disk for aqa run to discover it.`
: 'Paste a pack.yaml manifest or load one from disk. The server parses YAML, validates against @aqa/schemas/PackManifest, then installs into the store.'
}
size="md"
footer={
result ? (
<button className="btn" onClick={handleClose} data-testid="import-manifest-done">
Done
</button>
) : (
<>
<button className="btn" onClick={handleClose} disabled={submitting}>
Cancel
</button>
<button
className="btn primary"
data-testid="import-manifest-submit"
onClick={handleSubmit}
disabled={!canSubmit}
>
{submitting ? (
'Importing…'
) : (
<>
<I.Upload size={12} />
Import
</>
)}
</button>
</>
)
}
>
{result ? (
<div className="col gap-12" data-testid="import-manifest-result">
<Alert kind="success" title="Manifest imported">
<div className="col gap-4">
<div>
<strong>Name:</strong>{' '}
<span className="mono">{result.pack?.name}</span>
</div>
<div>
<strong>Version:</strong>{' '}
<span className="mono">{result.pack?.version}</span>
</div>
</div>
</Alert>
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
Next: make sure the matching pack directory exists at{' '}
<code>&lt;project&gt;/packs/{result.pack?.name}/</code> with scenarios + risks. The
store records the manifest; the file tree on disk is what <code>aqa run</code> reads.
</div>
</div>
) : (
<div className="col gap-12">
{error && (
<Alert kind="error" title="Import failed">
{error}
</Alert>
)}
<div className="field-row">
<label className="field-label" htmlFor="im-file">
Load from disk (optional)
</label>
<input
id="im-file"
type="file"
accept=".yaml,.yml,application/yaml,text/yaml,text/plain"
data-testid="import-manifest-file"
onChange={handleFileChange}
/>
<div className="field-hint">
Reads the file into the textarea below — submit happens on Import, not on selection.
</div>
</div>
<div className="field-row">
<label className="field-label" htmlFor="im-yaml">
Manifest YAML *
</label>
<textarea
id="im-yaml"
className="textarea mono"
data-testid="import-manifest-yaml"
placeholder={`schema_version: "1"\nname: pack-myapp\nversion: 0.1.0\ndescription: …\napplies_when:\n sut_type: [api]\nscenarios: []\nrisks: []`}
style={{ minHeight: 220, fontSize: 11.5 }}
value={yamlText}
onChange={(e) => setYamlText(e.target.value)}
/>
<div className="field-hint">
Required. The server validates against{' '}
<code>@aqa/schemas/PackManifest</code> — see{' '}
<a
href="https://github.com/padosoft/agentic-qa-kit/blob/main/docs/PACK-AUTHORING.md"
target="_blank"
rel="noreferrer"
>
docs/PACK-AUTHORING.md
</a>{' '}
for the schema.
</div>
</div>
<label className="row gap-8" style={{ alignItems: 'center', cursor: 'pointer', fontSize: 12 }}>
<input
type="checkbox"
data-testid="import-manifest-force"
checked={force}
onChange={(e) => setForce(e.target.checked)}
/>
<span>
<strong>Force overwrite</strong> — replace an existing pack with the same{' '}
<code>name</code>. Without this, the server returns 409 Conflict for a duplicate.
</span>
</label>
</div>
)}
</Modal>
);
}
Object.assign(window, { ImportManifestWizard });

// ============ shell.jsx ============
// =============================================================
// agentic-qa-kit · admin panel — Shell (Sidebar + TopBar + Palette)
Expand Down Expand Up @@ -7419,14 +7654,19 @@ Object.assign(window, { PageFindings, PageFindingDetail, PageRiskMap, PageRiskEd
// ---------------- Packs ----------------
function PagePacks({ onNavigate, onOpenPack }) {
const [createOpen, setCreateOpen] = React.useState(false);
const [importOpen, setImportOpen] = React.useState(false);
return (
<div className="page" data-screen-label="09 Packs">
<PageHeader
title="Packs"
sub={`${PACKS.length} installed · ${PACKS.filter((p) => p.signed).length} signed · ${PACKS.filter((p) => !p.signed).length} unsigned`}
actions={
<>
<button className="btn sm">
<button
className="btn sm"
data-testid="packs-import-btn"
onClick={() => setImportOpen(true)}
>
<I.Upload size={12} />
Import manifest
</button>
Expand All @@ -7442,6 +7682,7 @@ function PagePacks({ onNavigate, onOpenPack }) {
}
/>
<CreatePackWizard open={createOpen} onClose={() => setCreateOpen(false)} />
<ImportManifestWizard open={importOpen} onClose={() => setImportOpen(false)} />

<Alert kind="warning" title="One pack is unsigned">
<span className="mono">community-stripe@0.3.1</span> is installed without signature
Expand Down
Loading
Loading