feature/m5-templates #2

Merged
knacky merged 6 commits from feature/m5-templates into main 2026-05-13 09:19:54 +00:00
2 changed files with 14 additions and 3 deletions
Showing only changes of commit a7e5bc030f - Show all commits

View File

@@ -31,6 +31,10 @@ All notable changes to this project will be documented here. Format: [Keep a Cha
- **`LogRecord` key collision**: `log.info(..., extra={"name": ...})` raises `KeyError("Attempt to overwrite 'name' in LogRecord")` because `name` is reserved by Python's stdlib logging. Renamed to `template_name`.
- **React `currentTarget` null in deferred state updaters**: `onChange={(e) => setX((prev) => ({ ...prev, q: e.currentTarget.value }))}` blanked the page on the first user input because `currentTarget` is cleared after the listener bubble ends, before React invokes the updater. Switched all M5 handlers to `e.target.value`, which persists on the synthetic event.
### Fixed (post-M5 — scenario reorder 500 + cross-worker lock correctness)
- **`PUT /scenario-templates/{id}/tests` returned 500** (`backend/app/services/scenario_templates.py:218`): the two-argument form `pg_advisory_xact_lock(:n, :m)` failed with `function pg_advisory_xact_lock(smallint, bigint) does not exist`. Postgres only provides `(int4, int4)` and `(bigint)` overloads — psycopg promoted `m = hash(uuid) & 0xFFFFFFFF` (up to 2^32-1) to bigint and there's no matching overload. Switched to the single-argument bigint form with `CAST(:key AS bigint)`.
- **Cross-worker lock was a no-op** (same site): Python's built-in `hash()` is randomised per process via `PYTHONHASHSEED`, so each gunicorn worker computed a different key for the same `scenario_id`, and concurrent reorders on different workers acquired independent locks — defeating the serialisation. Replaced with `blake2b(scenario_id.bytes, digest_size=8)` interpreted as a signed int64. Stable, deterministic, fits in `bigint`.
### Fixed (post-M5 UI — modal layout for the test-template editor)
- **Modal box capped its width at `max-w-2xl` and had no vertical scroll** (`frontend/src/components/ui/Modal.tsx`): opening **+ New test** rendered the 15-column MITRE matrix inside a 672 px frame with no height cap, so the matrix spilled to the right and the form bottom dropped below the viewport — buttons unreachable, no scroll. Added a `size` prop (default `2xl` for back-compat), `max-h-[calc(100vh-2rem)]` + `flex flex-col` on the dialog, and an inner `min-w-0 flex-1 overflow-y-auto` body so the header stays pinned while the form scrolls inside the modal.
- **MITRE matrix overflow-x failed to scroll inside the modal body** (`frontend/src/components/MitreTagPicker.tsx`): `overflow-x-auto` sat directly on the grid element, but the grid's intrinsic min-width (`15 × minmax(7rem, …)` = 1680 px) prevented it from shrinking below its content, so the grid spilled outside its parent instead of scrolling. Wrapped the grid in a dedicated `overflow-x-auto rounded min-w-0 w-full` scroller and added `min-w-0` to the picker root so the constraint propagates from the modal body. The grid now scrolls horizontally inside the modal.

View File

@@ -12,6 +12,7 @@ The same test_template may legitimately appear multiple times in a scenario
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -217,10 +218,16 @@ def set_scenario_tests(
"""
with session_scope() as s:
# Lock keyed on the scenario UUID — different scenarios don't block
# each other. Two-int form: high-32 = constant, low-32 = hash of UUID.
# each other. Single bigint form so we don't have to juggle int32
# signed ranges. blake2b is used instead of Python's built-in hash()
# because the latter is randomised per-process (PYTHONHASHSEED), so
# two gunicorn workers would compute different keys for the same
# scenario and the lock wouldn't serialise across them.
digest = hashlib.blake2b(scenario_id.bytes, digest_size=8).digest()
lock_key = int.from_bytes(digest, "big", signed=True)
s.execute(
text("SELECT pg_advisory_xact_lock(:n, :m)"),
{"n": 0x5C3, "m": hash(scenario_id) & 0xFFFFFFFF},
text("SELECT pg_advisory_xact_lock(CAST(:key AS bigint))"),
{"key": lock_key},
)
sc = s.get(ScenarioTemplate, scenario_id)
if sc is None or sc.deleted_at is not None: