mission canvas: security-scan + benchmark trigger buttons

Fills the frontend gap after slices 7 + 8 shipped the backend runners
without triggers. Each phase card in the Phases tab now grows an
action row when the mission is running or completed:
  - security_scan phase → "Run scan" button (POSTs /security-scan)
  - benchmark phase → "Baseline" + "After" buttons, iteration auto-
    derived from the current benchmark_snapshots count

Findings from security_scan already surface in the Tasks tab via
the task-card parser (external_id = cargo_audit:RUSTSEC-... etc);
the tool prefix in the badge is enough to distinguish sources
without a separate grouped view.

Closes tasks #17 + #18.
This commit is contained in:
Omar Sobh
2026-07-19 18:42:40 -07:00
parent a3d5a5a96d
commit 267b28a762
@@ -15,6 +15,8 @@ import {
getMission,
refineMission,
setMissionStatus,
triggerBenchmark,
triggerSecurityScan,
type MissionDetail,
type MissionStatus,
type PhaseKind,
@@ -80,6 +82,7 @@ export function MissionCanvas({
const [tab, setTab] = useState<Tab>("overview");
const [launching, setLaunching] = useState(false);
const [refining, setRefining] = useState(false);
const [phaseBusy, setPhaseBusy] = useState<string | null>(null);
const load = useCallback(async () => {
if (!selectedId) {
@@ -118,6 +121,45 @@ export function MissionCanvas({
}
}, [mission, onChanged, load]);
const runSecurityScan = useCallback(
async (phaseId: string) => {
if (!mission) return;
setPhaseBusy(`sec:${phaseId}`);
setError(null);
try {
await triggerSecurityScan(mission.id, phaseId);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "security scan failed");
} finally {
setPhaseBusy(null);
}
},
[mission, load],
);
const runBenchmark = useCallback(
async (phaseId: string, slot: "baseline" | "after") => {
if (!mission) return;
setPhaseBusy(`bench:${phaseId}:${slot}`);
setError(null);
try {
await triggerBenchmark(
mission.id,
phaseId,
slot,
slot === "after" ? Math.max(1, mission.benchmarks.length) : undefined,
);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "benchmark failed");
} finally {
setPhaseBusy(null);
}
},
[mission, load],
);
const launch = useCallback(async () => {
if (!mission) return;
setLaunching(true);
@@ -398,6 +440,53 @@ export function MissionCanvas({
: ""}
</span>
)}
{mission.status === "running" || mission.status === "completed" ? (
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
{p.kind === "security_scan" && (
<button
type="button"
onClick={() => runSecurityScan(p.id)}
disabled={phaseBusy === `sec:${p.id}`}
style={{
...secondaryBtn,
opacity: phaseBusy === `sec:${p.id}` ? 0.5 : 1,
}}
>
{phaseBusy === `sec:${p.id}` ? "Scanning…" : "Run scan"}
</button>
)}
{p.kind === "benchmark" && (
<>
<button
type="button"
onClick={() => runBenchmark(p.id, "baseline")}
disabled={phaseBusy === `bench:${p.id}:baseline`}
style={{
...secondaryBtn,
opacity:
phaseBusy === `bench:${p.id}:baseline` ? 0.5 : 1,
}}
>
{phaseBusy === `bench:${p.id}:baseline`
? "Running…"
: "Baseline"}
</button>
<button
type="button"
onClick={() => runBenchmark(p.id, "after")}
disabled={phaseBusy === `bench:${p.id}:after`}
style={{
...secondaryBtn,
opacity:
phaseBusy === `bench:${p.id}:after` ? 0.5 : 1,
}}
>
{phaseBusy === `bench:${p.id}:after` ? "Running…" : "After"}
</button>
</>
)}
</div>
) : null}
</div>
))
)}