fix: the diagnostics tab keeps its focus, its level and its consent

Deep-review round 1 of E22.

- After a 429 the stream sent one POST per new event while a full batch
  waited; it now stays quiet for 15 s. Past the queue cap the oldest
  events go, as the comment always said, a refused batch included.
- A batch refused after the level was lowered came back into the queue
  of a later consent in the same tab; each consent is its own session.
- A `me` fetch that left before a level or preference change could put the
  old value back for up to a minute. `updateMeCache` writes the cache and
  cancels that fetch; usePref goes through it too.
- The consent box's buttons and "revert now" took the focus with them to
  <body>; it returns to the level picker. Watched until the button is
  gone — in Chrome the saved level arrives in two renders, and a one-shot
  check spent itself on the first (jsdom never showed it).
- The countdown line is no longer a live region re-read every minute.
- "Saját napló" rows were keyed by index on a newest-first list, so every
  new event remounted every row and dropped the focus off a row button.
This commit is contained in:
2026-09-17 03:53:40 +02:00
parent 4bfba4aa86
commit f92cd9017f
8 changed files with 220 additions and 30 deletions
+56 -2
View File
@@ -130,12 +130,66 @@ describe("diagnostics level", () => {
d.log.debug("ui.click", "kept for later");
await vi.advanceTimersByTimeAsync(5_000);
answer = () => Promise.reject(new d.HttpError(409, "off"));
await vi.advanceTimersByTimeAsync(5_000);
expect(codes(sent[1])).toEqual(codes(sent[0])); // the same batch, retried after the 429
await vi.advanceTimersByTimeAsync(15_000);
expect(codes(sent[1])).toEqual(codes(sent[0])); // the same batch, retried after the back-off
expect(lapsed).toHaveBeenCalledOnce();
expect(d.enabled("DEBUG")).toBe(false);
});
it("after a 429 nothing is sent until the back-off is over, however full the queue", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), () => {});
answer = () => Promise.reject(new d.HttpError(429, "slow down"));
for (let i = 0; i < 99; i++) d.log.debug("ui.click", `click ${i}`);
await vi.advanceTimersByTimeAsync(0);
expect(sent).toHaveLength(1); // the full batch went at once, and was refused
answer = () => Promise.resolve({ ok: true, stored: 0 });
for (let i = 0; i < 50; i++) d.log.debug("ui.click", `more ${i}`);
await vi.advanceTimersByTimeAsync(14_000);
expect(sent).toHaveLength(1); // one POST per new event is exactly the storm to avoid
await vi.advanceTimersByTimeAsync(1_000);
expect(codes(sent[1])).toEqual(codes(sent[0]));
});
it("past the queue cap the oldest events go, a refused batch included", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), () => {});
answer = () => Promise.reject(new d.HttpError(429, "slow down"));
for (let i = 0; i < 99; i++) d.log.debug("ui.click", `click ${i}`);
await vi.advanceTimersByTimeAsync(0);
answer = () => Promise.resolve({ ok: true, stored: 0 });
for (let i = 99; i < 1_100; i++) d.log.debug("ui.click", `click ${i}`);
await vi.advanceTimersByTimeAsync(60_000);
const msgs = sent
.slice(1)
.flat()
.map((e) => (e as { msg: string }).msg);
expect(msgs).toHaveLength(1_000);
expect(msgs[0]).toBe("click 100");
expect(msgs[msgs.length - 1]).toBe("click 1099");
});
it("a batch refused after the level was lowered is not sent under a later consent", async () => {
const d = await fresh();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), () => {});
d.log.debug("ui.click", "under the first consent");
await vi.advanceTimersByTimeAsync(5_000); // in flight
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
refuse(new d.HttpError(429, "slow down"));
await vi.advanceTimersByTimeAsync(0);
answer = () => Promise.resolve({ ok: true, stored: 0 });
d.applyDiagnostics(raised("TRACE"), () => {});
await vi.advanceTimersByTimeAsync(60_000);
const later = sent
.slice(1)
.flat()
.map((e) => (e as { msg: string }).msg);
expect(later).not.toContain("under the first consent");
expect(later).toContain("diagnostics level raised");
});
it("a closing tab sends what is still waiting with a keepalive request", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), () => {});
+24 -6
View File
@@ -10,7 +10,8 @@
// refetch), or when the server answers that it is off.
// A lost batch is dropped, not retried in a loop: this is a diagnostic stream, not a ledger, and a
// retry storm against a struggling server is the last thing a diagnosis needs. The one exception is
// being told to slow down (429): that batch goes back to the front of the queue.
// being told to slow down (429): that batch goes back to the front of the queue, and nothing is sent
// for BACKOFF_MS — not even when a full batch is waiting, or every new event would send one.
import { api, DIAGNOSTICS_EVENTS_PATH, HttpError, type DiagnosticsState } from "./api";
import { log, setLevel, subscribe, type LogEvent } from "./log";
@@ -19,6 +20,8 @@ const FLUSH_MS = 5_000;
const BATCH_MAX = 100;
/** Waiting events kept while the server is slow or away; past it the oldest go. */
const QUEUE_MAX = 1_000;
/** Quiet time after a 429. The server counts batches per minute, per account — every tab of it. */
const BACKOFF_MS = 15_000;
/** The level actually in force: the consented one until it lapses. */
export function levelInForce(state: DiagnosticsState | null | undefined, now = Date.now()) {
@@ -74,16 +77,27 @@ let queue: LogEvent[] = [];
let unsubscribe: (() => void) | null = null;
let flushTimer: number | null = null;
let inFlight = false;
let quietUntil = 0;
// Which consent a batch was taken under. A batch still in flight when the level is lowered must not
// come back into the queue of a LATER consent: it was captured under the one just withdrawn.
let session = 0;
const isOwnRequest = (event: LogEvent) =>
event.event === "http.request" && event.attrs?.path === DIAGNOSTICS_EVENTS_PATH;
const keepNewest = () => {
if (queue.length > QUEUE_MAX) queue.splice(0, queue.length - QUEUE_MAX);
};
function startStreaming(): void {
if (unsubscribe) return;
session += 1;
queue = [];
quietUntil = 0;
unsubscribe = subscribe((event) => {
if (!event || isOwnRequest(event)) return;
queue.push(event);
if (queue.length > QUEUE_MAX) queue.splice(0, queue.length - QUEUE_MAX);
keepNewest();
if (queue.length >= BATCH_MAX) void flush();
});
flushTimer = window.setInterval(() => void flush(), FLUSH_MS);
@@ -101,16 +115,20 @@ function stopStreaming(): void {
}
async function flush(): Promise<void> {
if (inFlight || !queue.length) return;
if (inFlight || !queue.length || Date.now() < quietUntil) return;
inFlight = true;
const takenIn = session;
const batch = queue.splice(0, BATCH_MAX);
try {
await api.sendLogEvents(batch);
} catch (err) {
if (err instanceof HttpError && err.status === 429) {
queue.unshift(...batch);
if (queue.length > QUEUE_MAX) queue.length = QUEUE_MAX;
} else if (err instanceof HttpError && err.status === 409) {
quietUntil = Date.now() + BACKOFF_MS;
if (takenIn === session && unsubscribe) {
queue.unshift(...batch);
keepNewest();
}
} else if (err instanceof HttpError && err.status === 409 && takenIn === session) {
lapse(); // the server has it off: withdrawn in another tab, or lapsed by its clock
}
} finally {
+11 -1
View File
@@ -1,7 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, type QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type Me } from "./api";
/** Write a change the SERVER already has (or is about to) into the `me` cache — and cancel a `me`
* fetch still in flight. That fetch may have left before the change was saved (BootGate's 60 s poll,
* a refetch on window focus); answering after the write, it would put the old value back and hold it
* until the next poll. Cancelling reverts the query to its latest state, which is this write:
* react-query takes a manual write as the state to revert to. */
export function updateMeCache(qc: QueryClient, update: (me: Me) => Me): void {
qc.setQueryData<Me | null>(qk.me(), (me) => (me ? update(me) : me));
void qc.cancelQueries({ queryKey: qk.me() });
}
// The signed-in account, read from the qk.me() query cache. BootGate owns the FETCHING query (gated on
// setup state) and App renders modules only once it has resolved to a real user — so anywhere inside the
// authenticated module tree the cache is always populated. `enabled:false` makes this a passive
+2 -3
View File
@@ -5,6 +5,7 @@ import { api } from "./api";
import type { Me } from "./api/account";
import { readAccount, writeAccount } from "./storage";
import { notifyActionError } from "./actionErrors";
import { updateMeCache } from "./useMe";
import i18n from "../i18n";
// The local state + cache already show the new value, so a refused save is otherwise invisible until
@@ -45,9 +46,7 @@ function readServerPref(me: Me | null | undefined, key: string): unknown {
// (their sole difference is WHEN the server write fires — immediately vs debounced), so the cache-merge
// shape lives in ONE place.
function mergePrefsCache(qc: QueryClient, patch: Record<string, unknown>): void {
qc.setQueryData<Me | null>(qk.me(), (m) =>
m ? { ...m, preferences: { ...m.preferences, ...patch } } : m,
);
updateMeCache(qc, (m) => ({ ...m, preferences: { ...m.preferences, ...patch } }));
}
/** The raw preference persister: save an arbitrary partial preferences patch to the server and merge