Files
siftlode/frontend/src/components/DiagnosticsSettings.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

194 lines
7.9 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DiagnosticsLevel } from "../lib/api";
import { levelInForce } from "../lib/diagnostics";
import { focusIsUnclaimed } from "../lib/useFocusKeeper";
import { updateMeCache, useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import SegmentedToggle from "./ui/SegmentedToggle";
import { Section, SettingRow } from "./ui/form";
import MyLogViewer from "./MyLogViewer";
import { RecorderSettings } from "./DebugRecorder";
import { formatUnit } from "../lib/format";
const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"];
/** Hours and minutes until `until`, refreshed every 30 s — the status line counts down. */
function useRemaining(until: string | null): { h: number; m: number } | null {
// The clock is read at render, and the interval only re-renders: a `now` held in state was the
// moment the page MOUNTED, so a level raised two minutes after opening Settings read "24 h 2 m"
// until the first tick (prod UAT 2026-09-19).
const [, tick] = useState(0);
useEffect(() => {
if (!until) return;
const id = window.setInterval(() => tick((n) => n + 1), 30_000);
return () => window.clearInterval(id);
}, [until]);
if (!until) return null;
// eslint-disable-next-line react-hooks/purity -- same as DebugRecorder's elapsed clock, and the comment above records the prod UAT finding that a state-held `now` produced: a level raised two minutes after opening Settings read "24 h 2 m" until the first tick.
const minutes = Math.max(0, Math.ceil((Date.parse(until) - Date.now()) / 60_000));
return { h: Math.floor(minutes / 60), m: minutes % 60 };
}
// Settings → Diagnosztika: the consented diagnostics level and the tab's own log.
//
// There is no separate on/off switch: Alap IS off. Choosing Részletes or Teljes does not change
// anything yet — it opens the consent box under the picker, which says what will be recorded, that
// the server keeps it and for how long, and that it lapses; only its button raises the level.
// Returning to Alap needs no consent and happens at once.
export default function DiagnosticsSettings() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const me = useMe();
const qc = useQueryClient();
const state = me.diagnostics;
const current = levelInForce(state);
const [pending, setPending] = useState<DiagnosticsLevel | null>(null);
const remaining = useRemaining(current === "INFO" ? null : (state?.until ?? null));
// The consent box and the status line each go away with the button that closed them — the one
// holding the focus. Put it back on the level picker (its chosen option) rather than let it fall
// to <body>, where the next Tab starts over from the top of the page. Only when it did fall: a
// user who moved on while the request was out keeps their place.
//
// Watched until that button is actually GONE, not for one render: a saved level arrives as two
// updates (the `me` cache, then the closed box), and the first render still has the button in the
// page — measured in Chrome, where a one-shot check spent itself there and the focus then fell.
const pickerRef = useRef<HTMLDivElement>(null);
const closedFrom = useRef<Element | null>(null);
useEffect(() => {
const from = closedFrom.current;
if (!from || from.isConnected) return;
closedFrom.current = null;
if (!focusIsUnclaimed()) return;
pickerRef.current?.querySelector<HTMLElement>('[aria-pressed="true"]')?.focus();
});
const watchFocus = () => {
closedFrom.current = document.activeElement;
};
const change = useMutation({
mutationFn: api.setDiagnosticsLevel,
meta: { failTitle: "settings.diagnostics.failed" },
onSuccess: (result) => {
watchFocus();
updateMeCache(qc, (prev) => ({ ...prev, diagnostics: result.diagnostics }));
setPending(null);
},
});
const cancel = () => {
watchFocus();
setPending(null);
};
const choose = (level: DiagnosticsLevel) => {
if (level === "INFO") {
setPending(null);
if (current !== "INFO") change.mutate("INFO");
} else {
setPending(level === current ? null : level);
}
};
const levelName = (level: DiagnosticsLevel) => t(`settings.diagnostics.levels.${level}`);
return (
<>
<Section title={t("settings.diagnostics.title")}>
<SettingRow
label={t("settings.diagnostics.level")}
hint={t("settings.diagnostics.levelHint")}
>
<div ref={pickerRef}>
<SegmentedToggle
size="md"
ariaLabel={t("settings.diagnostics.level")}
value={pending ?? current}
onChange={choose}
options={LEVELS.map((level) => ({ value: level, label: levelName(level) }))}
/>
</div>
</SettingRow>
{pending && (
<div
data-testid="diagnostics-consent"
className="mt-2 rounded-xl border border-border bg-accent/5 p-3 text-sm"
>
<p className="font-semibold">{t(`settings.diagnostics.consent.title.${pending}`)}</p>
<ul className="mt-1.5 mb-3 list-disc space-y-1 pl-5 text-muted">
<li>{t(`settings.diagnostics.consent.what.${pending}`)}</li>
<li>
{t("settings.diagnostics.consent.server", {
count: state?.retention_days ?? 7,
})}
</li>
<li>{t("settings.diagnostics.consent.never")}</li>
<li>
{t("settings.diagnostics.consent.lapse")}{" "}
<a
href="/privacy#diagnostics"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline"
>
{t("settings.diagnostics.consent.policy")}
</a>
</li>
</ul>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="ghost" size="sm" onClick={cancel}>
{t("settings.diagnostics.consent.cancel")}
</Button>
<Button
size="sm"
data-testid="diagnostics-consent-confirm"
loading={change.isPending}
onClick={() => change.mutate(pending)}
>
{t("settings.diagnostics.consent.confirm")}
</Button>
</div>
</div>
)}
{current !== "INFO" && !pending && remaining && (
// Not a live region: the countdown changes every minute, and a screen reader would read
// the whole line out again each time for as long as the page is open.
<div
data-testid="diagnostics-status"
className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm"
>
<span className="inline-flex items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full bg-warn" aria-hidden />
<span>
{t("settings.diagnostics.status", {
level: levelName(current),
time:
remaining.h > 0
? `${formatUnit(remaining.h, "hour", lang)} ${formatUnit(remaining.m, "minute", lang)}`
: formatUnit(remaining.m, "minute", lang),
})}
</span>
</span>
<Button variant="link" loading={change.isPending} onClick={() => change.mutate("INFO")}>
{t("settings.diagnostics.revert")}
</Button>
</div>
)}
</Section>
<Section title={t("recorder.settings.title")}>
<RecorderSettings />
</Section>
<Section title={t("settings.myLog.title")}>
<MyLogViewer />
</Section>
</>
);
}