- );
- })
+
+
+ ))
)}
diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/components/NavigationProvider.dom.test.tsx
index 2a94c602..0d19f2ce 100644
--- a/frontend/src/components/NavigationProvider.dom.test.tsx
+++ b/frontend/src/components/NavigationProvider.dom.test.tsx
@@ -74,6 +74,17 @@ describe("NavigationProvider · setPage's same-page guard", () => {
expect(result.current.page).toBe("plex");
});
+ it("leaves every page's own URL keys behind, the log console's included", () => {
+ window.history.replaceState(
+ { sfPage: "logs" },
+ "",
+ "/?page=logs&lgrp=error&lreq=req-abcdef12&pscope=movie&ctab=hidden&keep=1",
+ );
+ const { result } = render();
+ act(() => result.current.setPage("plex"));
+ expect(window.location.search).toBe("?page=plex&keep=1");
+ });
+
it("pushes nothing when it is already that page", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx
index c9c526a1..82faf9a8 100644
--- a/frontend/src/components/NavigationProvider.tsx
+++ b/frontend/src/components/NavigationProvider.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { urlWithParams, type Page } from "../lib/urlState";
+import { LOGS_KEYS } from "../lib/logsUrlState";
import { pageTitleKey } from "../lib/pageMeta";
import { setAccountRaw } from "../lib/storage";
import { setNavigator } from "../lib/nav";
@@ -89,6 +90,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
ctab: null,
csort: null,
cf: null,
+ ...Object.fromEntries(LOGS_KEYS.map((k) => [k, null])),
}),
);
notifyNav();
diff --git a/frontend/src/components/moduleRegistry.tsx b/frontend/src/components/moduleRegistry.tsx
index da3aff5f..464dbba3 100644
--- a/frontend/src/components/moduleRegistry.tsx
+++ b/frontend/src/components/moduleRegistry.tsx
@@ -32,6 +32,7 @@ const Scheduler = lazyWithPreload(() => import("./Scheduler"));
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
const AdminUsers = lazyWithPreload(() => import("./AdminUsers"));
const AuditLog = lazyWithPreload(() => import("./AuditLog"));
+const AdminLogs = lazyWithPreload(() => import("./AdminLogs"));
const SettingsPanel = lazyWithPreload(() => import("./SettingsPanel"));
export type PageDef = {
@@ -63,6 +64,7 @@ export const PAGE_CONTENT: Record = {
config: { Component: ConfigPanel },
users: { Component: AdminUsers, maxWidth: "48rem" },
audit: { Component: AuditLog, fadeTop: false },
+ logs: { Component: AdminLogs },
settings: { Component: SettingsPanel },
};
diff --git a/frontend/src/i18n/locales/en/adminLogs.json b/frontend/src/i18n/locales/en/adminLogs.json
new file mode 100644
index 00000000..0a99b6c0
--- /dev/null
+++ b/frontend/src/i18n/locales/en/adminLogs.json
@@ -0,0 +1,40 @@
+{
+ "title": "Server log",
+ "intro": "The server's stored log — lines from the API, the downloader and browsers, kept until the retention period ends. A line opens in place; click its request or job id to keep only that request's or job's lines.",
+ "search": "Search the message, code, stack…",
+ "levelLabel": "Filter by level",
+ "source": "Source",
+ "account": "Account",
+ "event": "Event",
+ "window": "Period",
+ "windows": {
+ "1h": "1 hour",
+ "24h": "24 hours",
+ "7d": "7 days"
+ },
+ "any": "Any",
+ "noAccount": "No account",
+ "noEvent": "No code",
+ "live": "Live",
+ "copy": "Copy",
+ "copied_one": "Copied {{count}} line",
+ "copied_other": "Copied {{count}} lines",
+ "copyFailed": "Couldn't copy to the clipboard",
+ "pivotRequest": "request {{id}}",
+ "pivotJob": "job {{id}}",
+ "clearPivot": "Clear filter",
+ "showRequest": "Only this request's lines",
+ "showJob": "Only this job's lines",
+ "empty": "No lines match the filters.",
+ "count_one": "{{count}} line loaded · newest first",
+ "count_other": "{{count}} lines loaded · newest first",
+ "older": "Load older",
+ "fields": {
+ "when": "Time",
+ "source": "Source",
+ "account": "Account",
+ "request": "Request",
+ "job": "Job",
+ "version": "Version"
+ }
+}
diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json
index 256a8888..c29248ed 100644
--- a/frontend/src/i18n/locales/en/header.json
+++ b/frontend/src/i18n/locales/en/header.json
@@ -35,6 +35,7 @@
"addAccount": "Add another account",
"switchTo": "Switch to {{name}}",
"audit": "Audit log",
+ "logs": "Diagnostics",
"googleAddress": "Google account: {{email}}"
},
"sync": {
diff --git a/frontend/src/i18n/locales/hu/adminLogs.json b/frontend/src/i18n/locales/hu/adminLogs.json
new file mode 100644
index 00000000..e5418b73
--- /dev/null
+++ b/frontend/src/i18n/locales/hu/adminLogs.json
@@ -0,0 +1,40 @@
+{
+ "title": "Szervernapló",
+ "intro": "A szerver tárolt naplója — az API, a letöltő és a böngészők sorai, amelyeket a megőrzési idő végéig tart meg. Egy sor a helyén nyílik ki; a kérés- vagy job-azonosítójára kattintva csak az ahhoz tartozó sorok maradnak.",
+ "search": "Keresés az üzenetben, a kódban, a hívási veremben…",
+ "levelLabel": "Szűrés szintre",
+ "source": "Forrás",
+ "account": "Fiók",
+ "event": "Esemény",
+ "window": "Időszak",
+ "windows": {
+ "1h": "1 óra",
+ "24h": "24 óra",
+ "7d": "7 nap"
+ },
+ "any": "Bármi",
+ "noAccount": "Fiók nélkül",
+ "noEvent": "Kód nélkül",
+ "live": "Élő",
+ "copy": "Másolás",
+ "copied_one": "Kimásolva: {{count}} sor",
+ "copied_other": "Kimásolva: {{count}} sor",
+ "copyFailed": "Nem sikerült a vágólapra másolni",
+ "pivotRequest": "kérés {{id}}",
+ "pivotJob": "job {{id}}",
+ "clearPivot": "Szűrő törlése",
+ "showRequest": "Csak ennek a kérésnek a sorai",
+ "showJob": "Csak ennek a jobnak a sorai",
+ "empty": "Nincs a szűrésnek megfelelő sor.",
+ "count_one": "{{count}} sor betöltve · a legújabb felül",
+ "count_other": "{{count}} sor betöltve · a legújabb felül",
+ "older": "Régebbiek betöltése",
+ "fields": {
+ "when": "Időpont",
+ "source": "Forrás",
+ "account": "Fiók",
+ "request": "Kérés",
+ "job": "Job",
+ "version": "Verzió"
+ }
+}
diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json
index 3b5bb633..2c744d33 100644
--- a/frontend/src/i18n/locales/hu/header.json
+++ b/frontend/src/i18n/locales/hu/header.json
@@ -35,6 +35,7 @@
"addAccount": "Másik fiók hozzáadása",
"switchTo": "Váltás erre: {{name}}",
"audit": "Auditnapló",
+ "logs": "Diagnosztika",
"googleAddress": "Google-fiók: {{email}}"
},
"sync": {
diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts
index 3585795e..d26533fb 100644
--- a/frontend/src/lib/api/admin.ts
+++ b/frontend/src/lib/api/admin.ts
@@ -1,6 +1,8 @@
// The admin surface: discovery purge, system configuration, users & roles, the audit log, the
-// scheduler + maintenance controls, the demo-account whitelist, and access invites. Only touches
-// core's req. (Grouped by sub-area below; kept in one module as the cohesive "admin" domain.)
+// diagnostics console's server log, the scheduler + maintenance controls, the demo-account
+// whitelist, and access invites. Only touches core's req. (Grouped by sub-area below; kept in one
+// module as the cohesive "admin" domain.)
+import type { Level } from "../log";
import { req } from "./core";
export interface DemoWhitelistEntry {
@@ -115,6 +117,79 @@ export interface AuditListResult {
returned: number;
}
+/** One stored log line (`log_events`), as the diagnostics console reads it. Values the server's
+ * redactor took out arrive as `[redacted]` / `[email]`. */
+export interface ServerLogRow {
+ id: number;
+ ts: string;
+ level: Level;
+ source: string;
+ logger: string;
+ event: string | null;
+ msg: string;
+ attrs: Record | null;
+ exc: string | null;
+ request_id: string | null;
+ user_id: number | null;
+ job_id: number | null;
+ debug_session_id: string | null;
+ version: string | null;
+}
+
+/** The console's filters, as the API takes them. `none` picks the rows without an account / code. */
+export interface LogQuery {
+ levels?: readonly Level[];
+ sources?: readonly string[];
+ user?: string;
+ event?: string;
+ request_id?: string;
+ job_id?: number;
+ q?: string;
+ since?: string;
+}
+
+export interface LogPage {
+ items: ServerLogRow[];
+ /** The accounts this page names, by id; an erased one is absent. */
+ accounts: Record;
+ next_cursor: string | null;
+}
+
+export interface LogFacet {
+ value: string;
+ count: number;
+}
+
+/** Each facet is counted under every filter but its own; `total` is the all-levels count. */
+export interface LogFacets {
+ levels: LogFacet[];
+ sources: LogFacet[];
+ events: LogFacet[];
+ users: LogFacet[];
+ accounts: Record;
+ total: number;
+}
+
+function logParams(query: LogQuery, extra: Record = {}): string {
+ const p = new URLSearchParams();
+ for (const level of query.levels ?? []) p.append("levels", level);
+ for (const source of query.sources ?? []) p.append("sources", source);
+ const single: Record = {
+ user: query.user,
+ event: query.event,
+ request_id: query.request_id,
+ job_id: query.job_id,
+ q: query.q,
+ since: query.since,
+ ...extra,
+ };
+ for (const [key, value] of Object.entries(single)) {
+ if (value !== undefined && value !== null && value !== "") p.set(key, String(value));
+ }
+ const s = p.toString();
+ return s ? `?${s}` : "";
+}
+
export interface SchedulerJob {
id: string;
interval_minutes: number;
@@ -172,6 +247,11 @@ export const adminApi = {
// --- users & roles ---
adminAudit: (limit = 500): Promise => req(`/api/admin/audit?limit=${limit}`),
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
+ // --- diagnostics console ---
+ adminLogs: (query: LogQuery, before: string | null): Promise =>
+ req(`/api/admin/logs${logParams(query, { before })}`),
+ adminLogFacets: (query: LogQuery): Promise =>
+ req(`/api/admin/logs/facets${logParams(query)}`),
adminUsers: (): Promise => req("/api/admin/users"),
setUserRole: (id: number, role: "user" | "admin"): Promise =>
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
diff --git a/frontend/src/lib/log.ts b/frontend/src/lib/log.ts
index 7942ff70..d1c91d6b 100644
--- a/frontend/src/lib/log.ts
+++ b/frontend/src/lib/log.ts
@@ -31,6 +31,17 @@ export const SEVERITY: Record = {
CRITICAL: 21,
};
+/** How a log viewer groups the six levels into its filter chips — "Saját napló" and the admin
+ * console both read a log through these, so a level lands in the same chip on either screen. */
+export type LevelGroup = "all" | "error" | "warning" | "info" | "detail";
+
+export const LEVEL_GROUPS: Record, readonly Level[]> = {
+ error: ["ERROR", "CRITICAL"],
+ warning: ["WARNING"],
+ info: ["INFO"],
+ detail: ["DEBUG", "TRACE"],
+};
+
export interface LogEvent {
ts: string;
level: Level;
diff --git a/frontend/src/lib/logsUrlState.test.ts b/frontend/src/lib/logsUrlState.test.ts
new file mode 100644
index 00000000..67c80712
--- /dev/null
+++ b/frontend/src/lib/logsUrlState.test.ts
@@ -0,0 +1,42 @@
+// The admin log console's filters in the address bar: a link carries them, a hand-edited value the
+// API would refuse degrades to "no filter", and a filter at its default leaves no key behind.
+import { describe, expect, it } from "vitest";
+
+import {
+ DEFAULT_LOGS_STATE,
+ logsParamsToState,
+ logsParamUpdates,
+ type LogsUrlState,
+} from "./logsUrlState";
+
+const params = (updates: Record) =>
+ new URLSearchParams(Object.entries(updates).filter((e): e is [string, string] => e[1] !== null));
+
+describe("logsUrlState", () => {
+ it("round-trips every filter", () => {
+ const state: LogsUrlState = {
+ group: "error",
+ sources: ["worker"],
+ user: "none",
+ event: "download.job.failed",
+ requestId: "req-abcdef12",
+ jobId: 42,
+ q: "100% done",
+ window: "7d",
+ };
+ expect(logsParamsToState(params(logsParamUpdates(state)))).toEqual(state);
+ });
+
+ it("leaves no key for a filter at its default", () => {
+ expect(Object.values(logsParamUpdates(DEFAULT_LOGS_STATE)).every((v) => v === null)).toBe(true);
+ });
+
+ it("drops a value the API would refuse instead of sending it", () => {
+ const state = logsParamsToState(
+ new URLSearchParams(
+ "lgrp=loud&lsrc=api,WORKER,x y&lu=me&lev=Not.A.Code&lreq=short&ljob=0&lwin=1y",
+ ),
+ );
+ expect(state).toEqual({ ...DEFAULT_LOGS_STATE, sources: ["api"] });
+ });
+});
diff --git a/frontend/src/lib/logsUrlState.ts b/frontend/src/lib/logsUrlState.ts
new file mode 100644
index 00000000..3105af84
--- /dev/null
+++ b/frontend/src/lib/logsUrlState.ts
@@ -0,0 +1,80 @@
+// The admin diagnostics console's filters in the address bar (`?page=logs&lgrp=error&lreq=…`), so a
+// filtered view — a request's lines, say — is a link. Keys are namespaced (`l…`) like the Plex and
+// Channels ones; setPage clears them (LOGS_KEYS) and the console re-mirrors them while it is open.
+import type { LevelGroup } from "./log";
+
+export type LogWindow = "1h" | "24h" | "7d";
+
+export interface LogsUrlState {
+ group: LevelGroup;
+ sources: string[];
+ /** An account id, or `none` for the rows without one. */
+ user: string | null;
+ /** An event code, or `none` for the rows without one. */
+ event: string | null;
+ requestId: string | null;
+ jobId: number | null;
+ q: string;
+ window: LogWindow;
+}
+
+export const DEFAULT_LOGS_STATE: LogsUrlState = {
+ group: "all",
+ sources: [],
+ user: null,
+ event: null,
+ requestId: null,
+ jobId: null,
+ q: "",
+ window: "24h",
+};
+
+export const LOGS_KEYS = ["lgrp", "lsrc", "lu", "lev", "lreq", "ljob", "lq", "lwin"] as const;
+
+export const WINDOW_HOURS: Record = { "1h": 1, "24h": 24, "7d": 24 * 7 };
+
+const GROUPS: readonly LevelGroup[] = ["all", "error", "warning", "info", "detail"];
+// The same shapes the API validates: a link someone edited by hand degrades to "no filter" here
+// instead of to a 422 on every request the console makes.
+const USER_RE = /^(none|\d{1,10})$/;
+const EVENT_RE = /^(none|[a-z][a-z0-9_]*(\.[a-z0-9_]+)*)$/;
+const REQUEST_RE = /^[A-Za-z0-9._-]{8,64}$/;
+const SOURCE_RE = /^[a-z]{1,16}$/;
+const SOURCES_MAX = 8;
+const Q_MAX = 200;
+
+const pick = (value: string | null, re: RegExp, max = 64) =>
+ value && value.length <= max && re.test(value) ? value : null;
+
+export function logsParamsToState(params: URLSearchParams): LogsUrlState {
+ const group = params.get("lgrp");
+ const window = params.get("lwin");
+ const job = params.get("ljob");
+ return {
+ group: GROUPS.includes(group as LevelGroup) ? (group as LevelGroup) : DEFAULT_LOGS_STATE.group,
+ sources: (params.get("lsrc") ?? "")
+ .split(",")
+ .filter((s) => SOURCE_RE.test(s))
+ .slice(0, SOURCES_MAX),
+ user: pick(params.get("lu"), USER_RE),
+ event: pick(params.get("lev"), EVENT_RE),
+ requestId: pick(params.get("lreq"), REQUEST_RE),
+ jobId: job && /^\d{1,10}$/.test(job) && Number(job) > 0 ? Number(job) : null,
+ q: (params.get("lq") ?? "").slice(0, Q_MAX),
+ window: window && window in WINDOW_HOURS ? (window as LogWindow) : DEFAULT_LOGS_STATE.window,
+ };
+}
+
+/** The URL updates for a state: a filter at its default clears its key. */
+export function logsParamUpdates(state: LogsUrlState): Record {
+ return {
+ lgrp: state.group === DEFAULT_LOGS_STATE.group ? null : state.group,
+ lsrc: state.sources.length ? state.sources.join(",") : null,
+ lu: state.user,
+ lev: state.event,
+ lreq: state.requestId,
+ ljob: state.jobId === null ? null : String(state.jobId),
+ lq: state.q.trim() ? state.q : null,
+ lwin: state.window === DEFAULT_LOGS_STATE.window ? null : state.window,
+ };
+}
diff --git a/frontend/src/lib/modules.ts b/frontend/src/lib/modules.ts
index d1a119cb..3c9e91bd 100644
--- a/frontend/src/lib/modules.ts
+++ b/frontend/src/lib/modules.ts
@@ -8,6 +8,7 @@ import {
ListVideo,
MessageSquare,
ScrollText,
+ FileSearch,
Settings,
SlidersHorizontal,
Tv,
@@ -108,6 +109,14 @@ const MODULES: readonly ModuleDef[] = [
system: true,
gate: isAdmin,
},
+ {
+ id: "logs",
+ labelKey: "header.account.logs",
+ titleKey: "adminLogs.title",
+ icon: FileSearch,
+ system: true,
+ gate: isAdmin,
+ },
];
// Settings is reachable (its own rail button + the ◀/▶ wrap target) but is not a primary module in
diff --git a/frontend/src/lib/pageMeta.test.ts b/frontend/src/lib/pageMeta.test.ts
index eea55f63..62614de7 100644
--- a/frontend/src/lib/pageMeta.test.ts
+++ b/frontend/src/lib/pageMeta.test.ts
@@ -19,6 +19,7 @@ const EXPECTED: Record = {
config: "header.configuration",
users: "header.users",
audit: "audit.title",
+ logs: "adminLogs.title",
settings: "settings.title",
};
diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts
index deee4b24..eb1a8223 100644
--- a/frontend/src/lib/queryKeys.ts
+++ b/frontend/src/lib/queryKeys.ts
@@ -90,6 +90,10 @@ export const qk = {
adminPendingAccess: () => ["admin-pending-access"] as const,
adminConfig: () => ["admin-config"] as const,
adminAudit: () => ["admin-audit"] as const,
+ // The console's filters are part of the key; `since` is not (it moves with the clock) — the
+ // window it came from is.
+ adminLogs: (filters: object) => ["admin-logs", filters] as const,
+ adminLogFacets: (filters: object) => ["admin-log-facets", filters] as const,
adminStorage: () => ["admin-storage"] as const,
adminDownloads: () => ["admin-downloads"] as const,
adminQuota: (days: number) => ["admin-quota", days] as const,
diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts
index 871899be..179b41dc 100644
--- a/frontend/src/lib/urlState.ts
+++ b/frontend/src/lib/urlState.ts
@@ -129,6 +129,7 @@ const PAGES = [
"config",
"users",
"audit",
+ "logs",
"notifications",
"messages",
"downloads",
From 8cfab498cf89641855969e2d5d467ff46c4aae24 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 19 Sep 2026 01:58:40 +0200
Subject: [PATCH 4/9] fix(sync): try a channel's feed again before counting it
as failed
YouTube's feed endpoint answers the same channel with 404, 500 and 200 in
turn: on prod 182, then 296 of 396 channels failed a poll, and a probe of
24 channels saw 15 fail a first request while 16 had succeeded by the
fifth. A channel that failed once waited for the next poll, 20 minutes
later, so new uploads showed up late.
A feed is now tried up to three times (after 1 s and 2 s) on a transport
error, a 404, a 429 or a 5xx; a refusal such as 403 is not retried. The
last failure is what gets counted. A feed that is really gone costs two
more requests per poll.
---
backend/app/youtube/rss.py | 43 +++++++++++++++++++++-------
backend/tests/test_sync_guards.py | 47 +++++++++++++++++++++++++++++++
2 files changed, 80 insertions(+), 10 deletions(-)
diff --git a/backend/app/youtube/rss.py b/backend/app/youtube/rss.py
index e4624e76..b83d4b17 100644
--- a/backend/app/youtube/rss.py
+++ b/backend/app/youtube/rss.py
@@ -1,6 +1,7 @@
"""Free per-channel RSS feed reader (no API quota). Returns the channel's most recent
~15 uploads as lightweight stubs for fast fresh-video detection."""
+import time
from datetime import datetime, timezone
import feedparser
@@ -8,6 +9,15 @@ import httpx
RSS_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
+# The waits before a second and a third try. YouTube's feed endpoint answers the SAME channel with
+# 404, 500 and 200 in turn (measured 2026-09-19: 15 of 24 channels failed a first request, 16 of them
+# had succeeded by the fifth, one second apart), so one failure is not an answer. A feed that is
+# really gone costs two more requests per poll.
+RETRY_DELAYS = (1.0, 2.0)
+# A 404 is in the set because that is how the flaky endpoint fails most often; a 403/410 is not,
+# and neither is anything else that says the request itself is wrong.
+_RETRYABLE_STATUS = frozenset({404, 429, 500, 502, 503, 504})
+
class RssError(Exception):
"""The feed could not be read (transport failure or a non-200 answer).
@@ -24,21 +34,34 @@ class RssError(Exception):
self.reason = reason
-def fetch_channel_feed(channel_id: str) -> list[dict]:
- """The channel's recent uploads as stubs. `[]` means the feed really is empty; a failure
- raises RssError so the caller can leave the channel un-stamped and log it."""
+def _get_feed(channel_id: str) -> bytes:
+ """The feed's body, after up to `1 + len(RETRY_DELAYS)` tries; the LAST failure is raised."""
url = RSS_URL.format(channel_id=channel_id)
- try:
- resp = httpx.get(url, timeout=20.0, headers={"User-Agent": "Siftlode/1.0"})
- except httpx.HTTPError as exc:
- raise RssError(f"RSS fetch failed for {channel_id}: {exc}", "transport") from exc
- if resp.status_code != 200:
- raise RssError(
+ for attempt in range(len(RETRY_DELAYS) + 1):
+ if attempt:
+ time.sleep(RETRY_DELAYS[attempt - 1])
+ try:
+ resp = httpx.get(url, timeout=20.0, headers={"User-Agent": "Siftlode/1.0"})
+ # silent-ok: not silent — the last failure is raised after the final try.
+ except httpx.HTTPError as exc:
+ error = RssError(f"RSS fetch failed for {channel_id}: {exc}", "transport")
+ error.__cause__ = exc
+ continue
+ if resp.status_code == 200:
+ return resp.content
+ error = RssError(
f"RSS fetch for {channel_id} returned HTTP {resp.status_code}",
f"http_{resp.status_code}",
)
+ if resp.status_code not in _RETRYABLE_STATUS:
+ break
+ raise error
- parsed = feedparser.parse(resp.content)
+
+def fetch_channel_feed(channel_id: str) -> list[dict]:
+ """The channel's recent uploads as stubs. `[]` means the feed really is empty; a failure
+ (after the retries) raises RssError so the caller can leave the channel un-stamped and log it."""
+ parsed = feedparser.parse(_get_feed(channel_id))
out: list[dict] = []
for entry in parsed.entries:
video_id = entry.get("yt_videoid")
diff --git a/backend/tests/test_sync_guards.py b/backend/tests/test_sync_guards.py
index ef688baf..f58cda15 100644
--- a/backend/tests/test_sync_guards.py
+++ b/backend/tests/test_sync_guards.py
@@ -246,6 +246,53 @@ class TestRssErrorVsEmpty:
)
monkeypatch.setattr(rss.httpx, "get", fake_get)
+ monkeypatch.setattr(rss.time, "sleep", lambda s: None)
+
+ def _answers(self, monkeypatch, *answers):
+ """The endpoint answers each try with the next of `answers` (a status or an exception);
+ returns the list of tries made and the waits between them."""
+ tries, waits = [], []
+ queue = list(answers)
+
+ def fake_get(url, **_):
+ answer = queue.pop(0)
+ tries.append(answer)
+ if isinstance(answer, Exception):
+ raise answer
+ body = self.FEED if answer == 200 else b""
+ return httpx.Response(answer, content=body, request=httpx.Request("GET", url))
+
+ monkeypatch.setattr(rss.httpx, "get", fake_get)
+ monkeypatch.setattr(rss.time, "sleep", waits.append)
+ return tries, waits
+
+ FEED = b"""
+
+ abc123Hello
+ """
+
+ def test_a_feed_that_fails_and_then_answers_is_read(self, monkeypatch):
+ # The endpoint's measured behaviour: the same channel 404s, 500s, then answers.
+ tries, waits = self._answers(monkeypatch, 404, 500, 200)
+ assert [e["id"] for e in rss.fetch_channel_feed("UC_flaky")] == ["abc123"]
+ assert (len(tries), waits) == (3, list(rss.RETRY_DELAYS))
+
+ def test_a_dropped_connection_is_tried_again_too(self, monkeypatch):
+ tries, _ = self._answers(monkeypatch, httpx.ConnectError("reset"), 200)
+ assert rss.fetch_channel_feed("UC_blip")
+ assert len(tries) == 2
+
+ def test_a_feed_that_keeps_failing_raises_its_last_failure(self, monkeypatch):
+ tries, _ = self._answers(monkeypatch, 500, 404, 404)
+ with pytest.raises(rss.RssError) as err:
+ rss.fetch_channel_feed("UC_gone")
+ assert (len(tries), err.value.reason) == (3, "http_404")
+
+ def test_a_refusal_is_not_tried_again(self, monkeypatch):
+ tries, waits = self._answers(monkeypatch, 403)
+ with pytest.raises(rss.RssError) as err:
+ rss.fetch_channel_feed("UC_forbidden")
+ assert (len(tries), waits, err.value.reason) == (1, [], "http_403")
def test_non_200_raises(self, monkeypatch):
self._patch(monkeypatch, status=404)
From 142593083f1d0ad0443ccadeedbad7daab2fd36f Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 19 Sep 2026 02:02:45 +0200
Subject: [PATCH 5/9] feat(diagnostics): walk the log lines from the keyboard;
name the console "Server log"
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both log viewers put their lines in a NavGrid: j/k (and the arrows) step
from line to line, and Enter opens one exactly as a click does (the line's
button is its `data-nav-open`). A focused line is named by its code and
message.
The console's nav entry is "Szervernapló" / "Server log", its page title:
"Diagnosztika" was already the Settings tab that holds the user's own log,
and two places with one name read as one place.
---
.../src/components/AdminLogs.dom.test.tsx | 11 ++++
frontend/src/components/AdminLogs.tsx | 44 ++++++++-------
frontend/src/components/LogLine.tsx | 11 +++-
frontend/src/components/MyLogViewer.tsx | 56 ++++++++++---------
frontend/src/i18n/locales/en/header.json | 2 +-
frontend/src/i18n/locales/hu/header.json | 2 +-
6 files changed, 77 insertions(+), 49 deletions(-)
diff --git a/frontend/src/components/AdminLogs.dom.test.tsx b/frontend/src/components/AdminLogs.dom.test.tsx
index 773ec9e7..f4646334 100644
--- a/frontend/src/components/AdminLogs.dom.test.tsx
+++ b/frontend/src/components/AdminLogs.dom.test.tsx
@@ -142,6 +142,17 @@ describe("AdminLogs", () => {
).toBe("true");
});
+ it("the keyboard can walk the lines and open one (NavGrid items)", async () => {
+ logsMock.mockResolvedValue({ items: [row(2), row(1)], accounts: {}, next_cursor: null });
+ renderConsole();
+ await waitFor(() => expect(lines()).toHaveLength(2));
+
+ const items = lines().map((b) => b.parentElement!);
+ expect(items.map((el) => el.getAttribute("data-nav-index"))).toEqual(["0", "1"]);
+ expect(lines().every((b) => b.hasAttribute("data-nav-open"))).toBe(true);
+ expect(screen.getByTestId("admin-logs-lines").querySelector("[data-nav-list]")).toBeTruthy();
+ });
+
it("a failed read is an error with a retry, not an empty log", async () => {
logsMock.mockRejectedValue(new Error("502"));
renderConsole();
diff --git a/frontend/src/components/AdminLogs.tsx b/frontend/src/components/AdminLogs.tsx
index 9289de67..1d8b9992 100644
--- a/frontend/src/components/AdminLogs.tsx
+++ b/frontend/src/components/AdminLogs.tsx
@@ -24,13 +24,14 @@ import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced";
import { useUrlMirror } from "../lib/useUrlMirror";
import LogLine from "./LogLine";
+import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
import FilterSelect from "./ui/FilterSelect";
import { Section } from "./ui/form";
import { LoadingState, StateMessage } from "./ui/QueryState";
-// Admin → Diagnosztika: the server's stored log (`log_events`, E22.6). One list, newest first; a line
+// Admin → Szervernapló: the server's stored log (`log_events`, E22.6). One list, newest first; a line
// opens in place to its full record, and its request id / job id narrow the list to that request or
// job. Filters live in the URL, so a filtered view is a link. "Élő" re-reads the newest page every
// few seconds while the tab is visible — the newest page only: live is watching the top, so paging
@@ -287,25 +288,28 @@ export default function AdminLogs() {
{t("adminLogs.empty")}
) : (
- rows.map((row) => (
- setOpen(open === row.id ? null : row.id)}
- >
- pivot({ requestId, jobId: null })}
- onJob={(jobId) => pivot({ requestId: null, jobId })}
- />
-
- ))
+
+ {rows.map((row, index) => (
+ setOpen(open === row.id ? null : row.id)}
+ >
+ pivot({ requestId, jobId: null })}
+ onJob={(jobId) => pivot({ requestId: null, jobId })}
+ />
+
+ ))}
+
)}
diff --git a/frontend/src/components/LogLine.tsx b/frontend/src/components/LogLine.tsx
index 7dc63fed..bb9ea5cc 100644
--- a/frontend/src/components/LogLine.tsx
+++ b/frontend/src/components/LogLine.tsx
@@ -1,10 +1,12 @@
import type { ReactNode } from "react";
import type { Level } from "../lib/log";
+import { navItemAttrs } from "../lib/listNav";
// One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin
// diagnostics console (the server's stored log): time, level, event code, message, and the line opens
// in place to whatever detail its screen has. One component, so a level reads the same on both.
+// Inside a NavGrid the keyboard walks the lines (`j`/`k`) and Enter opens one, as a click does.
const LEVEL_TAG: Record = {
TRACE: { text: "TRACE", className: "text-muted opacity-80" },
@@ -30,6 +32,7 @@ export default function LogLine({
open,
onToggle,
title,
+ navIndex,
children,
}: {
ts: string;
@@ -40,14 +43,20 @@ export default function LogLine({
onToggle: () => void;
/** The row button's tooltip — the admin console puts the full date there. */
title?: string;
+ /** The line's place in its NavGrid. */
+ navIndex: number;
/** The detail shown while the line is open. */
children: ReactNode;
}) {
const tag = LEVEL_TAG[level];
return (
-
+
diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json
index c29248ed..19968222 100644
--- a/frontend/src/i18n/locales/en/header.json
+++ b/frontend/src/i18n/locales/en/header.json
@@ -35,7 +35,7 @@
"addAccount": "Add another account",
"switchTo": "Switch to {{name}}",
"audit": "Audit log",
- "logs": "Diagnostics",
+ "logs": "Server log",
"googleAddress": "Google account: {{email}}"
},
"sync": {
diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json
index 2c744d33..7aa1d217 100644
--- a/frontend/src/i18n/locales/hu/header.json
+++ b/frontend/src/i18n/locales/hu/header.json
@@ -35,7 +35,7 @@
"addAccount": "Másik fiók hozzáadása",
"switchTo": "Váltás erre: {{name}}",
"audit": "Auditnapló",
- "logs": "Diagnosztika",
+ "logs": "Szervernapló",
"googleAddress": "Google-fiók: {{email}}"
},
"sync": {
From 1c49870a7a3a3841d1ba5cd9713262e58415b42c Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 19 Sep 2026 03:42:26 +0200
Subject: [PATCH 6/9] fix(sync): don't try a timed-out feed again
A request that hung for the full timeout says the way out is hanging, not
that the endpoint is flaky; three of them per channel stretched a poll over
a dead egress to about three times its interval.
---
backend/app/youtube/rss.py | 9 +++++++--
backend/tests/test_sync_guards.py | 8 ++++++++
2 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/backend/app/youtube/rss.py b/backend/app/youtube/rss.py
index b83d4b17..510bc13d 100644
--- a/backend/app/youtube/rss.py
+++ b/backend/app/youtube/rss.py
@@ -12,8 +12,11 @@ RSS_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
# The waits before a second and a third try. YouTube's feed endpoint answers the SAME channel with
# 404, 500 and 200 in turn (measured 2026-09-19: 15 of 24 channels failed a first request, 16 of them
# had succeeded by the fifth, one second apart), so one failure is not an answer. A feed that is
-# really gone costs two more requests per poll.
+# really gone costs two more requests per poll. A TIMEOUT is not tried again: the flaky endpoint
+# answers fast, and a request that hung for `TIMEOUT` says the way out is hanging — three of them
+# per channel would stretch a poll over a dead egress to three times its interval.
RETRY_DELAYS = (1.0, 2.0)
+TIMEOUT = 20.0
# A 404 is in the set because that is how the flaky endpoint fails most often; a 403/410 is not,
# and neither is anything else that says the request itself is wrong.
_RETRYABLE_STATUS = frozenset({404, 429, 500, 502, 503, 504})
@@ -41,11 +44,13 @@ def _get_feed(channel_id: str) -> bytes:
if attempt:
time.sleep(RETRY_DELAYS[attempt - 1])
try:
- resp = httpx.get(url, timeout=20.0, headers={"User-Agent": "Siftlode/1.0"})
+ resp = httpx.get(url, timeout=TIMEOUT, headers={"User-Agent": "Siftlode/1.0"})
# silent-ok: not silent — the last failure is raised after the final try.
except httpx.HTTPError as exc:
error = RssError(f"RSS fetch failed for {channel_id}: {exc}", "transport")
error.__cause__ = exc
+ if isinstance(exc, httpx.TimeoutException):
+ break
continue
if resp.status_code == 200:
return resp.content
diff --git a/backend/tests/test_sync_guards.py b/backend/tests/test_sync_guards.py
index f58cda15..b36ce352 100644
--- a/backend/tests/test_sync_guards.py
+++ b/backend/tests/test_sync_guards.py
@@ -282,6 +282,14 @@ class TestRssErrorVsEmpty:
assert rss.fetch_channel_feed("UC_blip")
assert len(tries) == 2
+ def test_a_timeout_is_not_tried_again(self, monkeypatch):
+ # A hung request already cost the full timeout; retrying it would triple a poll over a
+ # dead egress.
+ tries, waits = self._answers(monkeypatch, httpx.ReadTimeout("hung"), 200)
+ with pytest.raises(rss.RssError) as err:
+ rss.fetch_channel_feed("UC_hung")
+ assert (len(tries), waits, err.value.reason) == (1, [], "transport")
+
def test_a_feed_that_keeps_failing_raises_its_last_failure(self, monkeypatch):
tries, _ = self._answers(monkeypatch, 500, 404, 404)
with pytest.raises(rss.RssError) as err:
From 8f6f89832f8fdc64129e8e32ff0293340ec6d5f7 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 19 Sep 2026 03:42:26 +0200
Subject: [PATCH 7/9] fix(diagnostics): honest live console after review
- Live stops polling on a failed read and marks the list as possibly
stale (with Retry), instead of re-raising the error dialog every tick.
- While live, a refetch reads the newest page only, whatever the cache
holds (a late "Load older", a revisited filter set).
- Copy is disabled and the list dimmed while a filter change loads.
- Dates follow the UI language; the pivot chip uses the shared .chip.
- Tests: query-string wire test, Copy, every facet's skip key.
---
backend/app/routes/admin_logs.py | 6 +-
backend/tests/test_admin_logs.py | 17 ++++
.../src/components/AdminLogs.dom.test.tsx | 80 ++++++++++++++++++-
frontend/src/components/AdminLogs.tsx | 61 +++++++++++---
frontend/src/i18n/locales/en/adminLogs.json | 1 +
frontend/src/i18n/locales/hu/adminLogs.json | 1 +
frontend/src/lib/api/adminLogs.dom.test.ts | 64 +++++++++++++++
7 files changed, 213 insertions(+), 17 deletions(-)
create mode 100644 frontend/src/lib/api/adminLogs.dom.test.ts
diff --git a/backend/app/routes/admin_logs.py b/backend/app/routes/admin_logs.py
index 01d19ef0..43ae3c65 100644
--- a/backend/app/routes/admin_logs.py
+++ b/backend/app/routes/admin_logs.py
@@ -39,6 +39,8 @@ PAGE_MAX = 200
FACET_EVENTS_MAX = 100
Level = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
+# The filters a facet can leave out of its own count (`LogFilters.conditions`).
+Facet = Literal["levels", "sources", "user", "event"]
# `none` selects the rows that have no account / no event code (a scheduler line, an anonymous
# request) — a real value to filter on, not the absence of a filter.
NONE = "none"
@@ -106,7 +108,7 @@ class LogFilters:
since: datetime | None
until: datetime | None
- def conditions(self, skip: str | None = None) -> list:
+ def conditions(self, skip: Facet | None = None) -> list:
"""The WHERE conditions, ANDed by the caller. `skip` leaves one filter out: a facet counts
its own values under every OTHER filter, so picking a level does not zero the other
levels' counts (the way back), while a request pivot does narrow them (honest counts)."""
@@ -210,7 +212,7 @@ def log_facets(
most frequent events — each counted under every filter but its own (`LogFilters.conditions`).
`total` is the "all levels" count: every filter but the level one."""
- def counts(col, skip: str, cap: int | None = None) -> list[dict]:
+ def counts(col, skip: Facet, cap: int | None = None) -> list[dict]:
n = func.count().label("n")
stmt = select(col, n).where(*filters.conditions(skip)).group_by(col).order_by(n.desc(), col)
if cap is not None:
diff --git a/backend/tests/test_admin_logs.py b/backend/tests/test_admin_logs.py
index 7d634616..87660e41 100644
--- a/backend/tests/test_admin_logs.py
+++ b/backend/tests/test_admin_logs.py
@@ -172,3 +172,20 @@ def test_a_facet_counts_under_every_other_filter_but_not_its_own(db, admin):
assert body["total"] == 2
# Every other facet counts under the level filter too.
assert body["sources"] == [{"value": "worker", "count": 1}]
+
+
+@pytest.mark.parametrize(
+ ("params", "facet", "expected"),
+ [
+ ({"sources": ["worker"]}, "sources", {"api", "worker"}),
+ ({"user": "1"}, "users", {"1", "none"}),
+ ({"event": "sync.rss.failed"}, "events", {"http.request", "sync.rss.failed"}),
+ ],
+)
+def test_every_facet_keeps_its_siblings_under_its_own_filter(db, admin, params, facet, expected):
+ # The way back from a pick: the picked facet still offers the values it was picked from.
+ _add(db, _row(user_id=1), _row(source="worker", event="sync.rss.failed"))
+
+ body = admin.get("/api/admin/logs/facets", params=params).json()
+
+ assert {f["value"] for f in body[facet]} == expected
diff --git a/frontend/src/components/AdminLogs.dom.test.tsx b/frontend/src/components/AdminLogs.dom.test.tsx
index f4646334..e06e6575 100644
--- a/frontend/src/components/AdminLogs.dom.test.tsx
+++ b/frontend/src/components/AdminLogs.dom.test.tsx
@@ -2,9 +2,10 @@
//
// E22.6: the admin log console. A request id narrows the list to that request (and says so, in the
// filter row and the URL); a filter value the facets no longer count stays pickable; "Live" watches
-// the newest page only; a failed read is an error, not an empty log.
+// the newest page only, and stops on a failed read instead of re-raising it; a failed read is an
+// error, not an empty log; Copy takes every loaded line, oldest first.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { LogFacets, LogPage, LogQuery, ServerLogRow } from "../lib/api";
@@ -35,6 +36,14 @@ vi.mock("../lib/api", async (importOriginal) => {
};
});
+const copied: string[] = [];
+vi.mock("../lib/clipboard", () => ({
+ copyText: (text: string) => {
+ copied.push(text);
+ return Promise.resolve(true);
+ },
+}));
+
import AdminLogs from "./AdminLogs";
function row(id: number, extra: Partial = {}): ServerLogRow {
@@ -75,6 +84,9 @@ function renderConsole() {
);
}
+// react-query hands its updates to React on a (real) timer tick, after the promise that caused them.
+const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 20)));
+
const lines = () =>
within(screen.getByTestId("admin-logs-lines"))
.queryAllByRole("button")
@@ -85,6 +97,7 @@ beforeEach(() => {
facetsMock.mockResolvedValue(FACETS);
});
afterEach(() => {
+ vi.useRealTimers();
cleanup();
logsMock.mockReset();
facetsMock.mockReset();
@@ -142,6 +155,69 @@ describe("AdminLogs", () => {
).toBe("true");
});
+ it("a page that lands after Live went on is dropped at the next tick", async () => {
+ let landOlder!: (page: LogPage) => void;
+ logsMock.mockImplementation((_q, before) =>
+ before === null
+ ? Promise.resolve({ items: [row(3), row(2)], accounts: {}, next_cursor: "c1" })
+ : new Promise((resolve) => (landOlder = resolve)),
+ );
+ renderConsole();
+ await waitFor(() => expect(lines()).toHaveLength(2));
+ fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
+ await waitFor(() => expect(landOlder).toBeTypeOf("function"));
+
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
+ fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
+ await act(async () => landOlder({ items: [row(1)], accounts: {}, next_cursor: null }));
+ await settle();
+ expect(lines()).toHaveLength(3);
+ const olderReads = logsMock.mock.calls.filter(([, before]) => before !== null).length;
+
+ await act(async () => vi.advanceTimersByTimeAsync(4000));
+ await settle();
+
+ expect(lines()).toHaveLength(2);
+ expect(logsMock.mock.calls.filter(([, before]) => before !== null)).toHaveLength(olderReads);
+ });
+
+ it("a failed live read stops the polling and says the list may be old", async () => {
+ logsMock.mockResolvedValueOnce({ items: [row(1)], accounts: {}, next_cursor: null });
+ renderConsole();
+ await waitFor(() => expect(lines()).toHaveLength(1));
+
+ logsMock.mockRejectedValue(new Error("500"));
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
+ fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
+ await act(async () => vi.advanceTimersByTimeAsync(4000));
+ await settle();
+
+ expect(screen.getByRole("alert").textContent).toContain("adminLogs.stale");
+ expect(lines()).toHaveLength(1);
+ const reads = logsMock.mock.calls.length;
+ await act(async () => vi.advanceTimersByTimeAsync(12000));
+ expect(logsMock.mock.calls.length).toBe(reads);
+ });
+
+ it("copies every loaded line, oldest first", async () => {
+ logsMock.mockImplementation(async (_q, before) =>
+ before === null
+ ? { items: [row(3), row(2)], accounts: {}, next_cursor: "c1" }
+ : { items: [row(1)], accounts: {}, next_cursor: null },
+ );
+ renderConsole();
+ await waitFor(() => expect(lines()).toHaveLength(2));
+ fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
+ await waitFor(() => expect(lines()).toHaveLength(3));
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "adminLogs.copy" }));
+ });
+
+ const ids = copied[copied.length - 1]!.split("\n").map((l) => JSON.parse(l).id);
+ expect(ids).toEqual([1, 2, 3]);
+ });
+
it("the keyboard can walk the lines and open one (NavGrid items)", async () => {
logsMock.mockResolvedValue({ items: [row(2), row(1)], accounts: {}, next_cursor: null });
renderConsole();
diff --git a/frontend/src/components/AdminLogs.tsx b/frontend/src/components/AdminLogs.tsx
index 1d8b9992..9608491e 100644
--- a/frontend/src/components/AdminLogs.tsx
+++ b/frontend/src/components/AdminLogs.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from "react";
+import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
keepPreviousData,
@@ -10,6 +10,7 @@ import { Copy, Radio, Search, X } from "lucide-react";
import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
import { copyText } from "../lib/clipboard";
+import { formatDateTime } from "../lib/format";
import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
import {
DEFAULT_LOGS_STATE,
@@ -35,7 +36,8 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
// opens in place to its full record, and its request id / job id narrow the list to that request or
// job. Filters live in the URL, so a filtered view is a link. "Élő" re-reads the newest page every
// few seconds while the tab is visible — the newest page only: live is watching the top, so paging
-// into older rows turns it off.
+// into older rows turns it off. A failed read stops the polling (the request already reported it
+// once) and marks the list as possibly stale, instead of re-raising the failure every few seconds.
const LIVE_MS = 4000;
const GROUPS: readonly LevelGroup[] = ["all", "error", "warning", "info", "detail"];
@@ -68,7 +70,15 @@ export default function AdminLogs() {
const [state, setState] = useState(() =>
logsParamsToState(new URLSearchParams(window.location.search)),
);
- const [live, setLive] = useState(false);
+ const [live, setLiveState] = useState(false);
+ // Read by `getNextPageParam` when a page is fetched, not when it renders: while live, a refetch
+ // stops after the newest page, however many pages the cache holds — a "Load older" that lands
+ // after Live was switched on, or a filter set revisited with older pages still cached.
+ const liveRef = useRef(false);
+ const setLive = (on: boolean) => {
+ liveRef.current = on;
+ setLiveState(on);
+ };
const [open, setOpen] = useState(null);
const q = useDebounced(state.q, 300);
useUrlMirror(logsParamUpdates(state));
@@ -85,16 +95,25 @@ export default function AdminLogs() {
queryFn: ({ pageParam }) =>
api.adminLogs({ ...query, since: sinceOf(state.window) }, pageParam),
initialPageParam: null as string | null,
- getNextPageParam: (last) => last.next_cursor ?? undefined,
+ getNextPageParam: (last) => (liveRef.current ? undefined : (last.next_cursor ?? undefined)),
placeholderData: keepPreviousData,
- refetchInterval: live ? LIVE_MS : false,
+ refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS : false),
});
const facets = useQuery({
queryKey: qk.adminLogFacets({ ...query, window: state.window }),
queryFn: () => api.adminLogFacets({ ...query, since: sinceOf(state.window) }),
placeholderData: keepPreviousData,
- refetchInterval: live ? LIVE_MS * 4 : false,
+ refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS * 4 : false),
});
+ // What "Load older" continues from — read off the data, since `hasNextPage` is off while live.
+ const olderCursor = logs.data?.pages[logs.data.pages.length - 1]?.next_cursor ?? null;
+ // A read failed while rows (or counts) were already on screen: they are kept, and said to be old.
+ const stale = (logs.isError && !!logs.data) || facets.isError;
+ const retryStale = () => {
+ if (logs.isFetchNextPageError) void logs.fetchNextPage();
+ else if (logs.isError) void logs.refetch();
+ if (facets.isError) void facets.refetch();
+ };
const rows = useMemo(() => logs.data?.pages.flatMap((p) => p.items) ?? [], [logs.data]);
const accounts = useMemo(
@@ -111,7 +130,7 @@ export default function AdminLogs() {
const toggleLive = () => {
if (!live) {
- // Watching the top: drop the older pages, or every tick would re-read all of them.
+ // Watching the top: drop the older pages now; `liveRef` keeps each tick to the first page.
qc.setQueryData(key, (d) =>
d ? { pages: d.pages.slice(0, 1), pageParams: d.pageParams.slice(0, 1) } : d,
);
@@ -249,7 +268,8 @@ export default function AdminLogs() {
variant="outline"
size="xs"
icon={}
- disabled={rows.length === 0}
+ // While a filter change loads, the rows on screen are the previous filter's.
+ disabled={rows.length === 0 || logs.isPlaceholderData}
onClick={() => void copy()}
>
{t("adminLogs.copy")}
@@ -276,7 +296,22 @@ export default function AdminLogs() {
)}
- {stale && (
+ {failure && (
- {t("adminLogs.stale")}
+ {t(`adminLogs.${failure}`)}
)}
diff --git a/frontend/src/i18n/locales/en/adminLogs.json b/frontend/src/i18n/locales/en/adminLogs.json
index fb734b2b..f121848a 100644
--- a/frontend/src/i18n/locales/en/adminLogs.json
+++ b/frontend/src/i18n/locales/en/adminLogs.json
@@ -27,6 +27,8 @@
"showJob": "Only this job's lines",
"empty": "No lines match the filters.",
"stale": "The last refresh failed; what you see may be out of date.",
+ "olderFailed": "Couldn't load the older lines.",
+ "countsFailed": "The filter counts didn't load.",
"count_one": "{{count}} line loaded · newest first",
"count_other": "{{count}} lines loaded · newest first",
"older": "Load older",
diff --git a/frontend/src/i18n/locales/hu/adminLogs.json b/frontend/src/i18n/locales/hu/adminLogs.json
index f5d31811..e8ef112e 100644
--- a/frontend/src/i18n/locales/hu/adminLogs.json
+++ b/frontend/src/i18n/locales/hu/adminLogs.json
@@ -27,6 +27,8 @@
"showJob": "Csak ennek a jobnak a sorai",
"empty": "Nincs a szűrésnek megfelelő sor.",
"stale": "Az utolsó frissítés nem sikerült, amit látsz, elavult lehet.",
+ "olderFailed": "Nem sikerült betölteni a régebbi sorokat.",
+ "countsFailed": "A szűrők számai nem töltődtek be.",
"count_one": "{{count}} sor betöltve · a legújabb felül",
"count_other": "{{count}} sor betöltve · a legújabb felül",
"older": "Régebbiek betöltése",
From 4e3e68d6e624264a6494b6bb8c148ab97b573427 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 19 Sep 2026 03:57:29 +0200
Subject: [PATCH 9/9] chore(release): 0.90.0
---
CHANGELOG.md | 21 +++++++++++++++++++++
VERSION | 2 +-
frontend/src/lib/releaseNotes.ts | 20 ++++++++++++++++++++
3 files changed, 42 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fe84175e..a6fe27d8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,27 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
+## 0.90.0 — 2026-09-19
+
+The server's log gets a screen of its own: an admin can now read what the API, the downloader and the browsers wrote down, filter it, and follow one request or job from start to end.
+
+### Features
+
+- Admin → Server log: the stored log, newest first, with a search, level chips, and source, account, event and period filters. A line opens in place to its full record; its request id or job id narrows the list to everything that request or job wrote.
+- Filters live in the address bar, so a filtered view is a link, and Back brings the previous view back.
+- Live follows the newest lines every few seconds; Copy puts the loaded lines on the clipboard, oldest first. A failed read says so instead of quietly freezing the list.
+- Both log viewers (Server log and My log) can be walked with j/k and a line opened with Enter.
+
+### Fixes
+
+- New videos no longer arrive late from channels whose YouTube feed answers 404 or 500 now and then: a feed is tried again before the channel counts as failed.
+- A % or _ typed into a search is searched for as itself, not as a wildcard.
+
+### Internal
+
+- The console's own reads are logged at debug level, so watching the log does not fill it.
+- Two adversarial review rounds: 19 findings raised, 12 survived, all fixed, and every regression test was proven to fail with its own fix removed.
+
## 0.89.0 — 2026-09-19
A release for the log rather than for the screen: what the server writes down when something fails is now complete, said once, and free of what you downloaded or searched for.
diff --git a/VERSION b/VERSION
index 5aee1345..ae02209b 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.89.0
+0.90.0
diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts
index 5306cb98..c6b60883 100644
--- a/frontend/src/lib/releaseNotes.ts
+++ b/frontend/src/lib/releaseNotes.ts
@@ -14,6 +14,26 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
+ {
+ version: "0.90.0",
+ date: "2026-09-19",
+ summary:
+ "The server's log gets a screen of its own: an admin can now read what the API, the downloader and the browsers wrote down, filter it, and follow one request or job from start to end.",
+ features: [
+ "Admin → Server log: the stored log, newest first, with a search, level chips, and source, account, event and period filters. A line opens in place to its full record; its request id or job id narrows the list to everything that request or job wrote.",
+ "Filters live in the address bar, so a filtered view is a link, and Back brings the previous view back.",
+ "Live follows the newest lines every few seconds; Copy puts the loaded lines on the clipboard, oldest first. A failed read says so instead of quietly freezing the list.",
+ "Both log viewers (Server log and My log) can be walked with j/k and a line opened with Enter.",
+ ],
+ fixes: [
+ "New videos no longer arrive late from channels whose YouTube feed answers 404 or 500 now and then: a feed is tried again before the channel counts as failed.",
+ "A % or _ typed into a search is searched for as itself, not as a wildcard.",
+ ],
+ chores: [
+ "The console's own reads are logged at debug level, so watching the log does not fill it.",
+ "Two adversarial review rounds: 19 findings raised, 12 survived, all fixed, and every regression test was proven to fail with its own fix removed.",
+ ],
+ },
{
version: "0.89.0",
date: "2026-09-19",