Skip to content

v1.3.38.5 -- Session identity, skeletons, server-paged Threats, warm-set margin

Sixth release of the v1.3.38 series ("dashboard paints in under a second"; scope in docs/operations/ux-performance-review.md). No schema change. Backend changes are confined to the warm-set ticker and the pagination of GET /api/threats/decisions; bans, reconcilers, AppSec and auth are untouched beyond the browser-side client of /api/auth/me. argosVersion 1.3.38.4 -> 1.3.38.5.

What ships

  1. Warm set ticks at ⅘ of the TTL (Cache.WarmInterval, 24 s at the 30 s default). v1.3.38.4's regression run caught /api/dashboard/health served as X-Argos-Cache: stale (age 30.x s): the ticker fired every TTL and the four pinned refreshes run one after the other on the single SQLite connection, so a value was past its TTL for as long as its refresh took. With the tick at ⅘ TTL the pinned set may take up to TTL/5 (6 s) before any value ages out; the operator's 3 s rule for the set already guarantees that with room for contention. TestCacheWarmIntervalKeepsPinnedInsideTTL reproduces the finding (tick every TTL + a 20 ms refresh = stale serves), shows the fix (tick at ⅘ TTL, none) and documents the bound (a pinned set slower than TTL/5 is stale again).
  2. /api/auth/me once per session, in memory. api.me() remembers the user; concurrent callers share the in-flight promise; forgetSession() drops it on logout and on any 401 (together with the last-known page data). ProtectedRoute starts authed when the identity is known. No blank screen on route change: the Suspense that wrapped <Routes> replaced the whole Layout with a full-screen spinner on the first visit to each lazy page; it now sits inside the Layout, so the header and drawer stay while the content area shows the fallback.
  3. Skeletons + last-known data on Dashboard (all five sections), Threats, Security overview and Logs. Each view keeps its last response in memory (api/lastKnown.ts) keyed by its query; a revisit or a range already seen paints at once and refreshes behind, and a skeleton (components/Skeleton.tsx, data-skeleton) only shows for a view the session has never had. On a filter change the previous rows stay while the new ones load.
  4. Threats server-paged. GET /api/threats/decisions?page=N returns {decisions, total, page, per_page, pages} (100 per page by default, 1000 max) with every filter applied server-side over the LAPI list the client caches for 15 s: origin, type, search (value or scenario), ip (value contains), country (two-letter code, Ip scope), scenario; all text matches are case-insensitive substrings. Only the page's rows are geo-enriched. Without page= the response is the flat array it always was. The page debounces text filters (300 ms), keeps an out-of-order guard, and its 15 s auto-refresh re-requests only the page in view and only while document.visibilityState is visible (a return to the tab refreshes once at the moment).
  5. "whitelist" button renamed to what it does: unban, with a confirmation that says the decision is removed and CrowdSec can ban the value again. The Whitelist itself lives under Security.

Measured EFFECT (demo stack, 7,400 decisions)

Browser runs stay off prod (operator rule); the demo at ~/argos-demo ran the 1.3.38.4 image, then argos-prod-argos:1.3.38.5-rc built from this tree, with 5,000 cscli decisions import rows on top of the demo seed's 2,400. Same host, same passive tap on the demo's docker bridge (scripts/smoke/threats-page-bytes.sh), same headless tab held on /threats for 60 s.

Probe 1.3.38.4 1.3.38.5
GET /api/threats/decisions body (curl) 2,026,974 B flat 26,156 B (page=1&per_page=100); flat without page= unchanged, 2,024,574 B
Tab on /threats 60 s: decisions responses, decoded body 5, 10.12 MB 4, 104.5 KB
Same, on the wire (gzip), 75 s window, passive tap 5 req, 1,661,813 B, FAIL 5 req, 106,489 B, PASS
Time to first table rows 2,126 ms (7,400 rows in the DOM) 236-315 ms (two runs; 100 rows)
Tab hidden for 35 s (visibilityState forced) keeps polling (1 in 20 s on 1.3.35.4) 0 requests; 1 within 1.5 s of becoming visible
/api/auth/me after login + 6 drawer route changes (incl. /appsec) 3 on 1.3.35.4 1
Frames without the Layout header during those route changes 1-2 per route on localhost 0 (Layout stays; content-area fallback only)

At prod density (24k CAPI rows, 7.2 MB per flat response) the page is ~30 KB regardless: the paged body scales with per_page, not with the list.

Captures before/after (dashboard-overview, threats-decisions, logs-browser) taken with the capture spec in demo mode; the 1.3.38.5 Threats capture shows the filter row, the 7,400 count beside the heading and the unban buttons; Dashboard and Logs are unchanged at rest (skeletons only appear before the first data).

Measured after deploy (prod, 2026-09-26)

Deploy with make deploy-prod (panel started 07:51:03Z, image sha256:a82d13... -> sha256:1530ca..., healthy in 10 s, verify-deploy PASS 1.3.38.5).

Smoke Result
threats-page-bytes.sh (operator's tab on /threats, 24k CAPI decisions) PASS: 6 requests, 180,376 B on the wire in 60 s, 30,062 B per response (1.3.38.4: 7.2 MB decoded per response)
dashboard-latency.sh MAX_SECONDS=1 EXPECT_CACHE=hit concurrent with range-7d-latency.sh (the contention of the v1.3.38.4 finding) PASS: health 0.0009 s X-Argos-Cache: hit, overview 0.0076 s. Clean pass afterwards: 0.001 s hit, 0.002 s. The warm-set finding is closed on prod
range-7d-latency.sh PASS: traffic?range=7d 0.0016 s (served from the cache, requested within the last 5 min so the refresher kept it), logs/stats 7d 0.67 s cold, busiest host 7d 0.001 s
panel-boot.sh PASS: boot-to-listen 0.14 s, first purge 123.8 s after listen

Known issues

  • Threats renders expired in the Until column for decisions imported with cscli decisions import, on 1.3.38.4 and 1.3.38.5 alike (not introduced here). Hypothesis: the bouncer endpoint GET /v1/decisions returns duration and no until, so Decision.Until is the zero time and the page treats it as a past date; likely every row, not only the imported ones that were on screen. Fix: derive the remaining time from duration server-side. Separate item.
  • Closed here: the v1.3.38.4 warm-set stale-by-a-hair finding (Cache.WarmInterval).

Smoke gate

  • scripts/smoke/threats-page-bytes.sh (new): passive AF_PACKET tap like dashboard-refetch-loop.sh, attributes the panel's reply bytes to GET /api/threats/decisions per connection; PASS when <= MAX_BYTES (1 MB) and <= MAX_REQUESTS (8) in 60 s with the tab in the foreground. Baseline on 1.3.38.4 at demo density: 1.66 MB on the wire (10.1 MB decoded) and FAIL.
  • dashboard-latency.sh with EXPECT_CACHE=hit stays strict; with the ⅘ tick it should not flag stale under a concurrent 7 d smoke. To re-run on prod after deploy together with threats-page-bytes.sh.

Gates before commit: gofmt -l clean, go vet ./..., go test ./..., npm run build, npm run lint, check-no-personal-data.sh, mkdocs build --strict.

Files changed

  • backend/internal/dashboard/cache.go (+ cache_test.go), backend/internal/api/dashboard.go.
  • backend/internal/api/threats.go (+ threats_test.go).
  • frontend/src/api/client.ts, frontend/src/api/lastKnown.ts, frontend/src/components/ProtectedRoute.tsx, Skeleton.tsx, Pagination.tsx, frontend/src/App.tsx, frontend/src/pages/Threats.tsx, Dashboard.tsx, SecurityOverview.tsx, Logs.tsx.
  • scripts/smoke/threats-page-bytes.sh, docs/reference/api.md, CHANGELOG.md, docs/release-notes/v1.3.38.5.md, mkdocs.yml, docs/operations/verification-report.md, backend/cmd/argos/main.go, frontend/package.json.