fix(activity): an old audit/log link opens its tab, times read relative, one name for admin actions
Seen in the browser, not in the page's own tests: the navigation provider rewrites a retired page id to `activity` before the page's chunk reads the URL, so `?page=audit` landed on the Runs tab. The retired-page map now carries the params that open the successor's part (`at`), and the provider writes them as it rewrites the page — on load and on Back alike. A list time is relative with the exact second on hover (the DESIGN copy rule); a run moved over from the audit log, whose start and end are one instant, shows no length instead of an invented "1 sec". The audit log is called "admin actions" everywhere it is named (the clear button and its confirm, the Configuration group, the cleanup job, which also prunes the run history its description left out).
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
// The activity page around its run and audit lists (the server-log tab has its own file): the view
|
||||
// is the URL — a link narrows the runs to one job, a status chip narrows them further; a run opens
|
||||
// to its log lines and an admin action to its request's, each a new history entry that Back undoes;
|
||||
// a link to the retired audit page opens the admin tab; the account filter reaches the quota tab; a
|
||||
// the account filter reaches the quota tab; a
|
||||
// failed read is an error with a retry, never an empty list.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
@@ -168,11 +168,10 @@ describe("Activity", () => {
|
||||
expect(screen.getByText("quotaExceeded")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a link to the retired audit page opens the admin tab, and an action opens its request's lines", async () => {
|
||||
window.history.replaceState(null, "", "/?page=audit");
|
||||
it("the admin tab lists the actions, and an action opens its request's lines", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity&at=admin");
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy());
|
||||
expect(url().get("page")).toBe("activity");
|
||||
expect(runsMock).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(rowButton(/auditActions\.scheduler_interval/));
|
||||
@@ -197,6 +196,24 @@ describe("Activity", () => {
|
||||
expect(Math.round(days)).toBe(30);
|
||||
});
|
||||
|
||||
// The runs moved over from the audit log kept one instant for start and end: their length is
|
||||
// unknown, and a "1 sec" would be invented.
|
||||
it("a run whose start and end are one instant has no length", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity");
|
||||
runsMock.mockResolvedValue(
|
||||
page([
|
||||
{ ...RUN, id: 1, status: "ok", error: null, finished_at: RUN.started_at, summary: "moved" },
|
||||
{ ...RUN, id: 2, status: "ok", error: null, summary: "timed" },
|
||||
]),
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByText("moved")).toBeTruthy());
|
||||
const cells = (text: string) =>
|
||||
[...rowButton(new RegExp(text)).children].map((c) => c.textContent);
|
||||
expect(cells("moved")).toContain("—");
|
||||
expect(cells("timed")).not.toContain("—");
|
||||
});
|
||||
|
||||
it("a failed read is an error with a retry, not an empty list", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity");
|
||||
runsMock.mockRejectedValue(new Error("502"));
|
||||
|
||||
@@ -7,7 +7,7 @@ import { api, type AuditQuery, type AuditRow } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { sinceOf } from "../lib/activityUrlState";
|
||||
import { auditActionLabel, auditText } from "../lib/auditText";
|
||||
import { formatDateTime, formatDateTimeSec } from "../lib/format";
|
||||
import { formatDateTimeSec } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useHistoryList } from "../lib/useHistoryList";
|
||||
@@ -15,6 +15,7 @@ import type { Shared } from "./ActivityRuns";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import HistoryList from "./HistoryList";
|
||||
import HistoryRow from "./HistoryRow";
|
||||
import { RelativeTime } from "./ui/RelativeTime";
|
||||
import { Button } from "./ui/Button";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
|
||||
@@ -134,17 +135,21 @@ export default function ActivityAudit({
|
||||
cells={
|
||||
<>
|
||||
<span className="truncate font-medium">{auditActionLabel(t, row.action)}</span>
|
||||
<span className="tabular-nums text-muted sm:hidden">
|
||||
{formatDateTime(row.created_at, lang)}
|
||||
</span>
|
||||
<RelativeTime
|
||||
at={row.created_at}
|
||||
seconds
|
||||
className="tabular-nums text-muted sm:hidden"
|
||||
/>
|
||||
<span
|
||||
className={`hidden truncate sm:block ${who ? "text-muted" : "italic text-muted"}`}
|
||||
>
|
||||
{who ?? t("audit.system")}
|
||||
</span>
|
||||
<span className="hidden tabular-nums text-muted sm:block">
|
||||
{formatDateTime(row.created_at, lang)}
|
||||
</span>
|
||||
<RelativeTime
|
||||
at={row.created_at}
|
||||
seconds
|
||||
className="hidden tabular-nums text-muted sm:block"
|
||||
/>
|
||||
<span className="col-span-2 truncate text-muted sm:col-span-1">
|
||||
{[head, sub].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
type ActivityUrlState,
|
||||
type RunStatusFilter,
|
||||
} from "../lib/activityUrlState";
|
||||
import { formatDateTime, formatDateTimeSec, formatNumber, formatRuntime } from "../lib/format";
|
||||
import { formatDateTimeSec, formatNumber, formatRuntime } from "../lib/format";
|
||||
import { jobFailureText, jobResultText } from "../lib/jobResult";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useHistoryList } from "../lib/useHistoryList";
|
||||
import HistoryList from "./HistoryList";
|
||||
import HistoryRow from "./HistoryRow";
|
||||
import { RelativeTime } from "./ui/RelativeTime";
|
||||
import { StatusDot } from "./RunStatusDot";
|
||||
import { Button } from "./ui/Button";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
@@ -140,6 +141,8 @@ export default function ActivityRuns({
|
||||
);
|
||||
}
|
||||
|
||||
/** How long the run took; null while it runs, 0 when its start and end are one instant (the runs
|
||||
* moved over from the audit log kept only when they were written — the length is unknown). */
|
||||
function seconds(run: RunRow): number | null {
|
||||
if (!run.finished_at) return null;
|
||||
return (new Date(run.finished_at).getTime() - new Date(run.started_at).getTime()) / 1000;
|
||||
@@ -175,18 +178,18 @@ function RunCells({ run, actor }: { run: RunRow; actor: string | null | undefine
|
||||
{run.target_title && <span className="font-normal text-muted"> · {run.target_title}</span>}
|
||||
</span>
|
||||
{/* A phone has room for the name and the time only; the row opens to the rest. */}
|
||||
<span className="tabular-nums text-muted sm:hidden">
|
||||
{formatDateTime(run.started_at, lang)}
|
||||
</span>
|
||||
<RelativeTime at={run.started_at} seconds className="tabular-nums text-muted sm:hidden" />
|
||||
<span className="hidden truncate text-muted sm:block">
|
||||
{t(`scheduler.trigger.${run.trigger}`)}
|
||||
{actor && ` · ${actor}`}
|
||||
</span>
|
||||
<RelativeTime
|
||||
at={run.started_at}
|
||||
seconds
|
||||
className="hidden tabular-nums text-muted sm:block"
|
||||
/>
|
||||
<span className="hidden tabular-nums text-muted sm:block">
|
||||
{formatDateTime(run.started_at, lang)}
|
||||
</span>
|
||||
<span className="hidden tabular-nums text-muted sm:block">
|
||||
{secs === null ? "—" : formatRuntime(Math.max(1, secs), lang)}
|
||||
{secs ? formatRuntime(Math.max(1, secs), lang) : "—"}
|
||||
</span>
|
||||
<span className="hidden tabular-nums text-muted sm:block">
|
||||
{formatNumber(run.quota_units, lang)}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//
|
||||
// The activity page's server-log tab, rendered through the page (which owns the shared filters and
|
||||
// the pivots). A request or run id narrows the list to that request or run (and says so, in the
|
||||
// filter row and the URL), and Back returns to the view the pivot was made from; an old link to the
|
||||
// retired log page opens this tab; the account in use stays pickable; "Live" watches 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.
|
||||
// filter row and the URL), and Back returns to the view the pivot was made from; the account in use
|
||||
// stays pickable; "Live" watches 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 { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -199,20 +199,6 @@ describe("AdminLogs", () => {
|
||||
expect(new URLSearchParams(window.location.search).get("lrun")).toBe("77");
|
||||
});
|
||||
|
||||
it("an old link to the server log page opens this tab with its filter", async () => {
|
||||
window.history.replaceState(null, "", "/?page=logs&lreq=req-00000009");
|
||||
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
|
||||
renderConsole();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ request_id: "req-00000009" }),
|
||||
null,
|
||||
),
|
||||
);
|
||||
expect(new URLSearchParams(window.location.search).get("page")).toBe("activity");
|
||||
});
|
||||
|
||||
it("keeps the account in use offered when the account list does not have it", async () => {
|
||||
window.history.replaceState(null, "", "/?page=activity&at=logs&au=7");
|
||||
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
|
||||
|
||||
@@ -219,6 +219,37 @@ describe("NavigationProvider · Back", () => {
|
||||
expect(window.location.search).toBe("?page=files&keep=1");
|
||||
});
|
||||
|
||||
// The audit log and the server log became the activity page's tabs: an old link, bookmark or entry
|
||||
// opens that tab, with the filters it carried.
|
||||
it("opens a retired page's tab on the page that replaced it", () => {
|
||||
window.history.replaceState(null, "", "/?page=audit&keep=1");
|
||||
const { result } = render();
|
||||
expect(result.current.page).toBe("activity");
|
||||
expect(window.location.search).toBe("?page=activity&keep=1&at=admin");
|
||||
});
|
||||
|
||||
it("keeps a retired page's own filter as it opens its tab", () => {
|
||||
window.history.replaceState(null, "", "/?page=logs&lreq=req-00000009");
|
||||
render();
|
||||
const url = new URLSearchParams(window.location.search);
|
||||
expect([url.get("page"), url.get("at"), url.get("lreq")]).toEqual([
|
||||
"activity",
|
||||
"logs",
|
||||
"req-00000009",
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens a retired page's tab when Back lands on its entry", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.setPage("plex"));
|
||||
act(() => {
|
||||
window.history.replaceState({ sfPage: "audit" }, "", "/?page=audit");
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "audit" } }));
|
||||
});
|
||||
expect(result.current.page).toBe("activity");
|
||||
expect(window.location.search).toBe("?page=activity&at=admin");
|
||||
});
|
||||
|
||||
// lib/history's own listener takes a spent focus stamp off the entry it lands on; the rewrite must
|
||||
// not put it back, or the next landing there (an overlay closing) would refocus a stale item.
|
||||
it("does not put back the focus stamp the landing spent", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
|
||||
import { useLatest } from "../lib/useLatest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { resolvePage, urlWithParams, type Page } from "../lib/urlState";
|
||||
import { resolvePage, retiredParams, urlWithParams, type Page } from "../lib/urlState";
|
||||
import { ACTIVITY_KEYS } from "../lib/activityUrlState";
|
||||
import { pageTitleKey } from "../lib/pageMeta";
|
||||
import { useDocumentObjectTitle } from "../lib/documentTitle";
|
||||
@@ -193,7 +193,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
|
||||
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
|
||||
// ?chan / ?v / ?pl / filter params the loaders already read.
|
||||
const url = urlWithParams({ page: mountPage === "feed" ? null : mountPage });
|
||||
const url = urlWithParams({
|
||||
// A retired page's link also opens its part of the page that replaced it (an `at` tab).
|
||||
...retiredParams(new URLSearchParams(window.location.search).get("page")),
|
||||
page: mountPage === "feed" ? null : mountPage,
|
||||
});
|
||||
if (st._yt && st._ytScrape) {
|
||||
window.history.replaceState({ ...st, sfPage: mountPage }, "", url);
|
||||
} else {
|
||||
@@ -213,7 +217,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
window.history.replaceState(
|
||||
{ ...window.history.state, sfPage: p },
|
||||
"",
|
||||
urlWithParams({ page: p === "feed" ? null : p }),
|
||||
urlWithParams({ ...retiredParams(sf), page: p === "feed" ? null : p }),
|
||||
);
|
||||
setPageState(p);
|
||||
setAccountRaw(PAGE_KEY, p);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"empty": "No audit entries yet.",
|
||||
"empty": "No admin actions match the filters.",
|
||||
"system": "System",
|
||||
"clear": "Clear audit log",
|
||||
"clearTitle": "Clear the audit log?",
|
||||
"clearConfirm": "Permanently delete all audit entries? This can't be undone. (One entry recording that you cleared it is kept.)",
|
||||
"cleared_one": "Cleared {{count}} audit entry",
|
||||
"cleared_other": "Cleared {{count}} audit entries",
|
||||
"clearFailed": "Couldn't clear the audit log",
|
||||
"clear": "Clear admin actions",
|
||||
"clearTitle": "Clear the admin actions?",
|
||||
"clearConfirm": "Permanently delete every admin action entry? This can't be undone. (One entry recording that you cleared them is kept.)",
|
||||
"cleared_one": "Cleared {{count}} admin action entry",
|
||||
"cleared_other": "Cleared {{count}} admin action entries",
|
||||
"clearFailed": "Couldn't clear the admin actions",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"default": "default",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"maintenance_tune": "Maintenance tuned",
|
||||
"sync_pause": "Sync paused",
|
||||
"sync_resume": "Sync resumed",
|
||||
"audit_clear": "Audit log cleared",
|
||||
"audit_clear": "Admin actions cleared",
|
||||
"download_credential_set": "Download cookies set",
|
||||
"download_credential_delete": "Download cookies deleted",
|
||||
"invite_delete": "Whitelist entry deleted",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"downloads": "Downloads",
|
||||
"plex": "Plex",
|
||||
"tmdb": "TMDB",
|
||||
"audit": "Audit log",
|
||||
"audit": "Activity",
|
||||
"diagnostics": "Diagnostics",
|
||||
"issues": "Bug reports"
|
||||
},
|
||||
@@ -163,8 +163,8 @@
|
||||
"hint": "How many downloads the worker processes in parallel."
|
||||
},
|
||||
"audit_retention_days": {
|
||||
"label": "Retention (days)",
|
||||
"hint": "How long admin audit-log entries are kept before the audit-log cleanup job prunes them. 0 = keep forever. Default 180."
|
||||
"label": "Admin actions (days)",
|
||||
"hint": "How long admin action entries are kept before the cleanup job prunes them. 0 = keep forever. Default 180."
|
||||
},
|
||||
"job_run_retention_days": {
|
||||
"label": "Run history (days)",
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.",
|
||||
"plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.",
|
||||
"plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back.",
|
||||
"audit_gc": "Prunes admin audit-log entries and YouTube quota history older than their retention windows (Configuration → Audit log, Quota). If it stops, both just keep growing — harmless but unbounded.",
|
||||
"audit_gc": "Prunes admin action entries, background-run history and YouTube quota history older than their retention windows (Configuration → Activity, Quota). If it stops, all three just keep growing — harmless but unbounded.",
|
||||
"log_gc": "Deletes stored log events older than the retention window (Configuration → Diagnostics). Runs hourly so nothing outlives the promised limit by more than an hour.",
|
||||
"tmdb_enrich": "Enriches the mirrored Plex catalogue from TMDB (keywords, crew, budget/revenue, TMDb score, recommendations, artwork), matched by each title's canonical id and served from the app's own database. Optional — a no-op until a TMDB API key is set. If it stops, new Plex titles just miss their TMDB extras until it runs again.",
|
||||
"people_enrich": "Makes the people in the Plex library local: fetches each person's biography from TMDB and caches their portrait, resized through Plex, the most-credited people first. A person page and a cast strip then open from the app's own data. The TMDB half does nothing without a TMDB API key, the portrait half nothing without Plex. If it stops, a person page fetches its own bio when opened, a moment later.",
|
||||
@@ -97,7 +97,7 @@
|
||||
"plex_watch_sync": "Plex watch sync (incremental)",
|
||||
"plex_watch_reconcile": "Plex watch reconcile (full)",
|
||||
"tmdb_enrich": "TMDB enrichment (Plex)",
|
||||
"audit_gc": "Audit-log and quota-history cleanup",
|
||||
"audit_gc": "Activity and quota-history cleanup",
|
||||
"log_gc": "Log cleanup",
|
||||
"people_enrich": "People details and portraits (Plex)",
|
||||
"channel_backfill": "A channel's full history"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"empty": "Még nincs naplóbejegyzés.",
|
||||
"empty": "Nincs a szűrőknek megfelelő admin művelet.",
|
||||
"system": "Rendszer",
|
||||
"clear": "Eseménynapló ürítése",
|
||||
"clearTitle": "Üríted az eseménynaplót?",
|
||||
"clearConfirm": "Véglegesen törlöd az összes naplóbejegyzést? Ez nem vonható vissza. (Egy bejegyzés arról, hogy te ürítetted, megmarad.)",
|
||||
"cleared_one": "{{count}} naplóbejegyzés törölve",
|
||||
"cleared_other": "{{count}} naplóbejegyzés törölve",
|
||||
"clearFailed": "Nem sikerült üríteni az eseménynaplót",
|
||||
"clear": "Admin műveletek ürítése",
|
||||
"clearTitle": "Üríted az admin műveleteket?",
|
||||
"clearConfirm": "Véglegesen törlöd az összes admin művelet bejegyzését? Ez nem vonható vissza. (Egy bejegyzés arról, hogy te ürítetted, megmarad.)",
|
||||
"cleared_one": "{{count}} bejegyzés törölve",
|
||||
"cleared_other": "{{count}} bejegyzés törölve",
|
||||
"clearFailed": "Nem sikerült üríteni az admin műveleteket",
|
||||
"on": "Be",
|
||||
"off": "Ki",
|
||||
"default": "alapérték",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"maintenance_tune": "Karbantartás hangolva",
|
||||
"sync_pause": "Szinkron szüneteltetve",
|
||||
"sync_resume": "Szinkron folytatva",
|
||||
"audit_clear": "Eseménynapló ürítve",
|
||||
"audit_clear": "Admin műveletek ürítve",
|
||||
"download_credential_set": "Letöltési cookie-k beállítva",
|
||||
"download_credential_delete": "Letöltési cookie-k törölve",
|
||||
"invite_delete": "Fehérlista-bejegyzés törölve",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"downloads": "Letöltések",
|
||||
"plex": "Plex",
|
||||
"tmdb": "TMDB",
|
||||
"audit": "Eseménynapló",
|
||||
"audit": "Napló",
|
||||
"diagnostics": "Diagnosztika",
|
||||
"issues": "Hibajegyek"
|
||||
},
|
||||
@@ -163,8 +163,8 @@
|
||||
"hint": "Hány letöltést dolgoz fel egyszerre a letöltő folyamat."
|
||||
},
|
||||
"audit_retention_days": {
|
||||
"label": "Megőrzés (nap)",
|
||||
"hint": "Meddig maradnak meg az admin-naplóbejegyzések, mielőtt az eseménynapló-takarító feladat törli őket. 0 = örökre. Alapérték 180."
|
||||
"label": "Admin műveletek (nap)",
|
||||
"hint": "Meddig maradnak meg az admin műveletek bejegyzései, mielőtt a takarító feladat törli őket. 0 = örökre. Alapérték 180."
|
||||
},
|
||||
"job_run_retention_days": {
|
||||
"label": "Futási előzmények (nap)",
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infóoldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő szinkron után jelennek meg.",
|
||||
"plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes egyeztetések között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.",
|
||||
"plex_watch_reconcile": "A megtekintési állapot teljes egyeztetése, amely minden könyvtárat újraszkennel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben levett megtekintve jel nem tükröződik vissza.",
|
||||
"audit_gc": "Törli a megőrzési időnél régebbi admin-naplóbejegyzéseket és YouTube-kvótaelőzményeket (Konfiguráció → Eseménynapló, Kvóta). Ha leáll, mindkettő csak nő — ártalmatlan, de korlátlanul.",
|
||||
"audit_gc": "Törli a megőrzési időnél régebbi admin műveleteket, háttérfutás-előzményeket és YouTube-kvótaelőzményeket (Konfiguráció → Napló, Kvóta). Ha leáll, mindhárom csak nő — ártalmatlan, de korlátlanul.",
|
||||
"log_gc": "Törli a megőrzési időnél (Konfiguráció → Diagnosztika) régebbi tárolt naplóeseményeket. Óránként fut, így semmi sem marad meg egy óránál tovább az ígért határidőn túl.",
|
||||
"tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül.",
|
||||
"people_enrich": "Helyivé teszi a Plex-könyvtár szereplőit: a TMDB-ből letölti az életrajzukat, a portréjukat pedig a Plexszel kicsinyítve elmenti, a legtöbbet szereplőkkel kezdve. A személyoldal és a szereplősáv így az app saját adataiból nyílik meg. A TMDB-rész TMDB API-kulcs nélkül nem csinál semmit, a portrérész Plex nélkül. Ha leáll, a személyoldal megnyitáskor maga kéri le az életrajzot, egy pillanattal később.",
|
||||
@@ -97,7 +97,7 @@
|
||||
"plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)",
|
||||
"plex_watch_reconcile": "Plex megtekintés-egyeztetés (teljes)",
|
||||
"tmdb_enrich": "TMDB-dúsítás (Plex)",
|
||||
"audit_gc": "Eseménynapló- és kvótaelőzmény-takarítás",
|
||||
"audit_gc": "Napló- és kvótaelőzmény-takarítás",
|
||||
"log_gc": "Naplótakarítás",
|
||||
"people_enrich": "Személyadatok és portrék (Plex)",
|
||||
"channel_backfill": "Egy csatorna teljes előzménye"
|
||||
|
||||
@@ -45,13 +45,6 @@ describe("activityUrlState", () => {
|
||||
expect(state).toEqual(DEFAULT_ACTIVITY_STATE);
|
||||
});
|
||||
|
||||
it("opens the tab of the page a link still names", () => {
|
||||
expect(activityParamsToState(new URLSearchParams("page=audit")).tab).toBe("admin");
|
||||
expect(activityParamsToState(new URLSearchParams("page=logs")).tab).toBe("logs");
|
||||
// A tab the link names wins over the retired page's.
|
||||
expect(activityParamsToState(new URLSearchParams("page=logs&at=quota")).tab).toBe("quota");
|
||||
});
|
||||
|
||||
it("clears its own keys and the server log's on a page change", () => {
|
||||
expect(ACTIVITY_KEYS).toEqual(expect.arrayContaining(["at", "aw", "au", "aq", "rjob", "lreq"]));
|
||||
});
|
||||
|
||||
@@ -54,10 +54,6 @@ export const DEFAULT_ACTIVITY_STATE: ActivityUrlState = {
|
||||
const OWN_KEYS = ["at", "aw", "au", "aq", "rjob", "rst", "aact"] as const;
|
||||
export const ACTIVITY_KEYS = [...OWN_KEYS, ...LOGS_KEYS] as const;
|
||||
|
||||
// The pages this one replaced: a link or a bookmark to one opens its tab here (urlState's RETIRED
|
||||
// sends the page id itself here).
|
||||
const RETIRED_TAB: Readonly<Record<string, ActivityTab>> = { audit: "admin", logs: "logs" };
|
||||
|
||||
// The same shapes the API validates: a hand-edited link degrades to "no filter" here instead of to
|
||||
// a 422 on every request the page makes.
|
||||
const USER_RE = /^(none|\d{1,10})$/;
|
||||
@@ -71,10 +67,8 @@ const oneOf = <T extends string>(value: string | null, list: readonly T[]): T |
|
||||
|
||||
export function activityParamsToState(params: URLSearchParams): ActivityUrlState {
|
||||
return {
|
||||
tab:
|
||||
oneOf(params.get("at"), ACTIVITY_TABS) ??
|
||||
RETIRED_TAB[params.get("page") ?? ""] ??
|
||||
DEFAULT_ACTIVITY_STATE.tab,
|
||||
// A link to a page this one replaced arrives with its tab already set (urlState's RETIRED).
|
||||
tab: oneOf(params.get("at"), ACTIVITY_TABS) ?? DEFAULT_ACTIVITY_STATE.tab,
|
||||
window: oneOf(params.get("aw"), ACTIVITY_WINDOWS) ?? DEFAULT_ACTIVITY_STATE.window,
|
||||
user: pick(params.get("au"), USER_RE, 10),
|
||||
q: (params.get("aq") ?? "").slice(0, Q_MAX),
|
||||
|
||||
@@ -141,22 +141,30 @@ function isPage(p: string | null | undefined): p is Page {
|
||||
return !!p && (PAGES as readonly string[]).includes(p);
|
||||
}
|
||||
|
||||
// A page this build no longer has, and the page that took its place. A bookmark, the remembered last
|
||||
// page and an old history entry can still name one; all three resolve through `resolvePage`, so a
|
||||
// retired page lands where its content went instead of on the feed.
|
||||
const RETIRED: Readonly<Record<string, Page>> = {
|
||||
// A page this build no longer has, the page that took its place, and the params that open its part
|
||||
// of that page. A bookmark, the remembered last page and an old history entry can still name one; all
|
||||
// three resolve through `resolvePage`, so a retired page lands where its content went instead of on
|
||||
// the feed, and the navigation provider writes `params` into the address bar as it rewrites the page.
|
||||
const RETIRED: Readonly<Record<string, { page: Page; params?: Record<string, string> }>> = {
|
||||
// The download queue became the tray; what it downloaded lives in Files.
|
||||
downloads: "files",
|
||||
// The audit log and the server log became tabs of the activity page (activityUrlState opens the
|
||||
// right tab from the old page id).
|
||||
audit: "activity",
|
||||
logs: "activity",
|
||||
downloads: { page: "files" },
|
||||
// The audit log and the server log became tabs of the activity page (its `at` key).
|
||||
audit: { page: "activity", params: { at: "admin" } },
|
||||
logs: { page: "activity", params: { at: "logs" } },
|
||||
};
|
||||
|
||||
const retired = (p: string | null | undefined) =>
|
||||
p && Object.prototype.hasOwnProperty.call(RETIRED, p) ? RETIRED[p]! : null;
|
||||
|
||||
/** A page id as stored or linked: a live page, a retired page's successor, else null. */
|
||||
export function resolvePage(p: string | null | undefined): Page | null {
|
||||
if (isPage(p)) return p;
|
||||
return p && Object.prototype.hasOwnProperty.call(RETIRED, p) ? RETIRED[p]! : null;
|
||||
return retired(p)?.page ?? null;
|
||||
}
|
||||
|
||||
/** The params that open a retired page's part of its successor (none for a live page). */
|
||||
export function retiredParams(p: string | null | undefined): Record<string, string> {
|
||||
return isPage(p) ? {} : (retired(p)?.params ?? {});
|
||||
}
|
||||
|
||||
export function readPage(): Page {
|
||||
|
||||
Reference in New Issue
Block a user