Stop a hunt
Stop a hunt
Sometimes you launch a fleet sweep and immediately realize the indicator
is wrong, or coverage is already enough, or a higher-priority hunt
needs the concurrent-hunt slot. Stopping is the explicit “I’m done
here” control: it flips the hunt’s status to cancelled so the UI stops
treating it as live, releases the global concurrent-hunt slot, and lets
the timeout watcher exit on its next tick.
What it does
The Stop button (red, top-right of the active hunt detail panel, only
visible while the hunt is pending or running) calls
POST /api/v1/hunts/{id}/cancel. The endpoint is admin-only —
requireAdmin runs before any work, so non-admin operators get an HTTP
403 forbidden response and the toast surface displays the error.
The server-side flow is intentionally tight:
- Status flip.
UPDATE hunts SET status='cancelled', completed_at=NOW() WHERE id=$1 AND status IN ('pending','running'). Already-terminal hunts (completed / partial / failed / cancelled) are no-ops — the endpoint returns HTTP 409 with the error codenot_cancellable. - HTTP 204. On success the server returns no body. The UI’s poll
loop picks up the new status on its next 3-second tick and the panel
re-renders with
status: cancelled.
That’s the whole atomic operation. Notably, the server does not
explicitly tear down the in-memory watchHuntTimeout goroutine or
cascade into a campaign stop — both are out of scope for the cancel
endpoint by design (see below).
What stays vs. what stops accumulating
The status flip is the only direct side effect. Everything else is indirect:
| Already happened | Outcome on cancel |
|---|---|
| Agent replied; result ingested before cancel | Kept. Visible in the result table. |
| Agent replied; alert was created | Kept. The ioc_alerts row persists. |
| Agent reply is on the wire when cancel commits | Result row still ingests when it arrives. |
| Timeout watcher goroutine | Continues sleeping until its timer.Now() fires, then runs one UPDATE guarded by WHERE status IN ('pending','running') — the guard makes it a no-op against the already-cancelled row, so the goroutine effectively does nothing. |
| Global concurrent-hunt slot | Released — the next operator can launch. |
| Hunt result table in the UI | Stops polling once the panel re-renders with status: cancelled. |
If the hunt was launched via the campaigns subsystem (the
offline_campaigns_enabled flag), the campaign’s assignments are
not auto-cancelled by stopping the hunt. To stop the campaign too,
call the campaign-stop endpoint directly:
curl -X POST -H "Authorization: Bearer <admin-token>" \ -H 'Content-Type: application/json' \ -d '{"discard_inflight":true,"reason":"hunt cancelled"}' \ https://mimir.example.com/api/v1/campaigns/<campaign-id>/stopThe campaign-stop endpoint is what flips campaign assignments in
pending, leased, and (with discard_inflight=true) dispatched
state to cancelled. The campaign-ingestion path excludes
cancelled-status assignments from the responded_hosts / matched_hosts
counters via an AND ca.status NOT IN ('cancelled','failed') SQL guard.
Why use it
Two patterns:
- Wrong-indicator abort. You pasted the wrong hash into Quick Hunt and the fleet sweep just started. Stop immediately to free the concurrent-hunt slot and stop the UI from continuing to display the active panel.
- Sufficient coverage. A fleet hunt is running, you’ve already
seen enough matches to act, and the remaining hosts are slow.
Stop to release the slot so other operators waiting on the global
DefaultMaxConcurrentHunts = 5cap can launch.
If the hunt is already finished, there’s nothing to stop — the Stop button isn’t even visible. Closing the detail panel with ✕ is just a UI dismissal; it does not affect server-side state.
How to use it
- Open the Hunts page.
- Click into the hunt you want to stop (in the Hunt History table or directly via the active hunt panel if it’s the one you just launched).
- In the detail panel header, click the red Stop button. The button shows “Stopping…” briefly, then a toast confirms “Hunt cancelled”. The detail panel re-polls immediately so the status flip is reflected without waiting for the next regular tick.
- Confirm in the Hunt History table that the hunt now shows
cancelledstatus.
Who can do it
POST /api/v1/hunts/{id}/cancel is admin-only. The handler calls
requireAdmin before touching the database. Non-admin users — even
under MIMIR_PER_USER_HUNT_ISOLATION=true, even when they’re the
hunt’s owner — receive HTTP 403 forbidden. If your operator role
needs to cancel its own hunts in production, ask an admin to grant
the admin role or to escalate the cancel via the operator runbook.
A subtle implication: in a shared tenant any admin can cancel any hunt, even one they didn’t launch. If you’re handing out admin roles and you don’t want that, escalate the request — Mimir doesn’t have per-row authorization on cancel today.
Troubleshooting
Stop button is greyed out / shows “Stopping…” forever. The hunt
is already in a terminal status — the server saw your request but
the WHERE status IN ('pending','running') guard didn’t match. The
next poll tick will refresh the panel and the button will disappear.
If the spinner is genuinely stuck for more than 10 seconds, the
request errored; check the toast for the error message.
HTTP 403 forbidden when I try to cancel. Your session doesn’t
have the admin role. The cancel endpoint is admin-only by design.
Either re-authenticate as an admin or have one cancel on your
behalf.
HTTP 409 not_cancellable. The hunt isn’t in pending or
running — most often it just completed naturally between you
opening the panel and clicking Stop. Refresh the panel; the status
will reflect the actual terminal value.
Cancelled hunt still shows pending alerts in the feed. Two possibilities. (a) The alerts were created from results that arrived BEFORE the cancel — they’re real and stay. (b) The hunt was campaign-backed and you didn’t also stop the campaign — see the curl example above for the campaign-stop endpoint.
Stopped a hunt to free the concurrent-hunt slot but the next
launch still returns 429. The cap is global (DefaultMaxConcurrentHunts = 5 by default). Other operators may have running hunts. The cap
is admin-tunable via MIMIR_MAX_CONCURRENT_HUNTS in
mimir-server.yaml.