Skip to content

fix(overview): the attack map never re-measured the card it was drawn into - #1937

Merged
Xore merged 3 commits into
mainfrom
attack-map-1565
Aug 25, 2026
Merged

Xore merged 3 commits into
mainfrom
attack-map-1565

Conversation

@Xore

@Xore Xore commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Closes out #1565 — the residue that was explicitly waiting on a rendered page. #1828 landed the design lab, so these could finally be looked at rather than reasoned about.

The one real bug: map gutters

Leaflet sizes its tile grid once, against whatever the container measured at construction time, and the map is built before the card has settled at its final width. invalidateSize() was never called.

Measured at 1854px viewport, 1442px card — six 256px tile columns from the wrong origin:

before after
gutter left −186 −75
gutter right +92 (bare card) −19
gutter top — −179
gutter bottom — −69

A ResizeObserver repeats the re-measure whenever the card changes width — a fixed zoom covers a fixed pixel width, and the card's width is not fixed, so a window resize otherwise puts the strip straight back. Verified narrowed to a 488px card: −552 / −496 / −249 / −139.

Rejected alternative: fitting the world to cover the card (getBoundsZoom(WORLD, true)). It works, but zooms to 3 and crops the southern hemisphere — Australia, NZ and southern Africa all had markers. That trades data for coverage. Keeping zoom 2 and simply not lying to leaflet about the card size gives full coverage and the whole world.

Everything else was already fixed — verified, not assumed

Activity heatmap "one global colour scale" — it is already per-row normalised, and has been. The max in dashboard.rs is computed inside the per-sensor map. Confirmed against the live payload: all 30 sensor rows reach pct: 100, including wordpot whose busiest hour holds 2 events and elasticpot at 4, alongside suricata at 1,095,300.

The previous audit declined to make this change because per-row normalisation "changes what a cell's colour means". That is already the shipped behaviour — so there was no design decision to make, only a measurement to take.

Campaigns score column is 100 for every row — previously unverifiable because the index was empty. It now holds 50 documents: 13 distinct scores spanning 62–75. The column carries signal.

"Network/provider classes" (4 rows) beside a packed "Top autonomous systems" — they are not beside each other. Every card on the threat-landscape tab is full-width and stacked (all 1492px at an 1854px viewport). The imbalance the issue describes cannot occur in the current layout.

Header copy "Every column, every network →" — no longer present anywhere in src/.

Attribution / "World" control colliding with the card edge — attribution sits 33px inside the card edge; there is no "World" reset control any more.

Checks

48 frontend tests, tsc --noEmit clean. Map behaviour verified on a rendered page against live Elasticsearch data at two viewport widths.

Closes #1565

Xore added 3 commits August 25, 2026 14:46
…e to write

The lab's whole premise is that a theme is reviewed against real captured
data rather than fixtures -- both previous design refreshes were run that
way and that is what shipped. The harness that made it possible went with
the Go dashboard in cb77cdf, so the pattern branding/design-lab/ documents
has not been executable since.

The Go version got its safety from constructing the server with nil
write-services. frontend-next has no such handle: it reaches data over HTTP
through two bases, and one of them is write-capable by definition. So the
guarantee is made at that seam instead --

  BACKEND_URL         a gate forwarding GET/HEAD, refusing the rest with 405
  BACKEND_MOUNTED_URL nothing at all; every request 503s

-- which is stronger than what it replaces, because it is enforced per
request rather than by remembering to pass nil.

That is not a formality. The Rust tier really exposes stores.rs's
generic_delete, preferences.rs's put, the honeyfs implant writer and the
canarytoken minter, so a reviewer clicking through a variant would
otherwise delete real documents and mint real tokens against live
infrastructure. lab.test.mjs drives the actual harness against a recording
stand-in backend and asserts a write never arrives; it runs in CI, because
the guarantee is the point of the tool and a silent regression would be
indistinguishable from it working.

Variant stylesheets are symlinked into the dev server's public/ tree, so a
variant layers on the vendored theme.css with no rebuild and no change to
the shipped vite config -- and saving the source file is enough to see it.
Ports are the ones the lab has always used, so the review log's links keep
resolving.

Verified end to end against the homeserver's real backend: the page renders
cowrie, dionaea, wordpot, galah and suricata with live states, carries both
stylesheets in the right order, and the gate refuses a delete without it
reaching Elasticsearch.

Booting it also surfaced that routeShape.test.ts was being scanned as a
route file and warned on every dev-server start; renamed to the generator's
ignore prefix, which vitest's own include pattern is unaffected by.

Closes #1828
…t have

Chasing #1566's "near-duplicate rows" on /ml-anomalies turned up why they
looked the way they did: the list was not in any order at all.

Store pages sort with `unmapped_type` set, so a store whose index does not
exist yet returns an empty list instead of an error. The same setting means
a field the documents do not have sorts every hit as null rather than
failing -- and nothing anywhere says so. Three stores shipped that way:
ml-anomalies asked for `timestamp` where the worker writes `@timestamp`,
auth-events asked for `last_seen` where Keycloak writes `@timestamp`, and
static-analysis asked for `Analysis.GeneratedUTC`, which its documents do
not carry in any spelling -- they hold only Analysis and Fingerprint, and no
time field whatsoever.

Measured on the live index, the first page of /ml-anomalies read 20:58,
20:58, 20:59, 20:59, 20:59, 20:57, 20:57, 20:58. The newest document was
third by luck.

Worse than the disorder: an all-null sort is one enormous tie, and
`from`/`size` over a tie leaves the order inside it undefined. Paging could
hand back the same document twice and never show another. So every store
sort now carries `_doc` as a tiebreak -- which the numeric sorts needed
anyway, since campaigns-by-score and attackers-by-events tie constantly --
and each declares its field's real type, because an `unmapped_type` that
disagrees with the mapping is the same silent no-op for keyword sorts.

CI cannot see Elasticsearch, so scripts/check-store-sorts.py is the guard:
it checks all 23 declared sort fields against a live mapping dump. Run
against the homeserver it passes, and re-introducing either bug class fails
it with the reason.

With the order fixed, the duplicates #1566 actually reported are adjacent
for the first time, so they can be folded. 66% of the 7,305 live anomalies
sit in an (address, same second) bucket with siblings; the worst single
burst is 48 rows for one IP inside one second, every one scoring exactly
0.8000. Consecutive rows from one address in one second scoring within 0.01
now collapse to one row badged with what it stands for.

Acknowledging a folded row acknowledges all of it -- otherwise it would
return as open on the next refresh and the button would look broken while
behaving exactly as written -- and the status badge reports "3 of 48 open"
rather than speaking only for whichever row represents the run.

Folding is deliberately page-local and says so in the badge: doing it in
the Rust tier would change the shape of the shared /api/v1/store endpoint
for every other consumer, and a burst longer than one page still splits at
the boundary.

Also verified while here, and already correct: page-title casing is
uniformly sentence case across all 27 headers, and /campaigns renders 7
columns rather than 15 -- the rest are behind `detail: true`.

Closes #1566
… into

Leaflet sizes its tile grid once, against whatever the container measured
at construction time, and the map is built before the card has settled at
its final width. Measured at an 1854px viewport: six 256px tile columns
laid out from the wrong origin for a 1442px container, leaving a 92px
strip of bare card down the right-hand edge. It reads as a rendering
fault rather than as ocean, which is how #1565 reported it.

invalidateSize() re-measures and recomputes the grid. A ResizeObserver
repeats that whenever the card changes width, because a fixed zoom covers
a fixed pixel width and the card's width is not fixed -- a window resize
puts the strip straight back otherwise.

Verified on a rendered page against live data, at two widths. Before, at
1442px: tiles ended 92px short on the right. After: every edge overflows
the container (-75 left, -19 right, -179 top, -69 bottom) and the whole
world is still in frame, Australia and southern Africa included. Narrowed
to a 488px card the observer holds it (-552/-496/-249/-139).

Fitting the world to *cover* the card was the other candidate and is
worse: it zooms to 3 and crops the southern hemisphere, trading data for
coverage. This keeps zoom 2 and simply stops lying to leaflet about how
big the card is.

Closes #1565
@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@Xore
Xore merged commit 121797a into main Aug 25, 2026
89 checks passed
@Xore
Xore deleted the attack-map-1565 branch August 25, 2026 13:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

overview: design polish — recent-events JSON wall, map gutters, heatmap scale, chart nits

1 participant