fix(scheduler,i18n): a failure a job names reads in the reader's language
The audit log showed a failed RSS poll as the server's English sentence under a Hungarian UI. A job can now raise progress.JobFailed(reason, message, **params): the scheduler stores the reason and params as the run's data (dashboard, manual-run notice, audit row) beside the English line, and the client renders scheduler.failure.<reason>. The RSS outage names its first channel and the reason code (HTTP 404 vs unreachable). An upstream's own error text stays as raised (diagnostic).
This commit is contained in:
@@ -35,7 +35,7 @@ import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { jobResultText } from "../lib/jobResult";
|
||||
import { jobFailureText, jobResultText } from "../lib/jobResult";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
|
||||
@@ -257,7 +257,10 @@ function JobRow({
|
||||
? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang) })
|
||||
: t("scheduler.neverRun")}
|
||||
{job.last_error ? (
|
||||
<span className="text-danger"> · {job.last_error}</span>
|
||||
<span className="text-danger">
|
||||
{" · "}
|
||||
{jobFailureText(t, job.result, lang) ?? job.last_error}
|
||||
</span>
|
||||
) : job.result ? (
|
||||
<span> · {jobResultText(t, job.result, lang)}</span>
|
||||
) : job.last_result ? (
|
||||
|
||||
@@ -186,5 +186,14 @@
|
||||
"lowquota": "low quota",
|
||||
"noreadscope": "no YouTube read access",
|
||||
"busy": "another sync is running"
|
||||
},
|
||||
"failure": {
|
||||
"rss_all_failed": "The RSS feed answered for none of the {{total}} channels — the first: {{channel}}, {{cause}}. Read through the API instead: {{read}}"
|
||||
},
|
||||
"rssCause": {
|
||||
"http": "HTTP {{status}}",
|
||||
"transport": "unreachable",
|
||||
"apply": "could not be saved",
|
||||
"error": "unexpected error"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,5 +186,14 @@
|
||||
"lowquota": "kevés a kvóta",
|
||||
"noreadscope": "nincs YouTube-olvasási jogosultság",
|
||||
"busy": "egy másik szinkron fut"
|
||||
},
|
||||
"failure": {
|
||||
"rss_all_failed": "Az RSS-hírcsatorna egyik csatornánál sem válaszolt ({{total}}) — az első: {{channel}}, {{cause}}. Az API-n át beolvasva helyette: {{read}}"
|
||||
},
|
||||
"rssCause": {
|
||||
"http": "HTTP {{status}}",
|
||||
"transport": "nem érhető el",
|
||||
"apply": "nem sikerült menteni",
|
||||
"error": "váratlan hiba"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// one-liner was "recent={'channels_processed': 0, …}, deep={…}" — English keys and a Python repr.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import i18n, { setLanguage } from "../i18n";
|
||||
import { jobResultText, parseSummary } from "./jobResult";
|
||||
import { jobFailureText, jobResultText, jobRunText, parseSummary } from "./jobResult";
|
||||
|
||||
afterEach(() => setLanguage("en"));
|
||||
|
||||
@@ -65,3 +65,34 @@ describe("an older notice's one-liner", () => {
|
||||
expect(parseSummary(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// E31: the audit log showed a failed RSS poll as the server's English sentence ("RSS poll failed for
|
||||
// all 399 channels — first: …") under a Hungarian UI. A failure the job names rides along as data.
|
||||
describe("a failed run", () => {
|
||||
const run = {
|
||||
job_id: "rss_poll",
|
||||
status: "error",
|
||||
summary:
|
||||
"RSS poll failed for all 399 channels — first: UCx: RSS fetch for UCx returned HTTP 404",
|
||||
result: { failure: "rss_all_failed", total: 1399, channel: "UCx", cause: "http_404", read: 0 },
|
||||
};
|
||||
|
||||
it("says the failure the job named in the reader's language", () => {
|
||||
const t = i18n.getFixedT("hu");
|
||||
expect(jobRunText(t, run, "hu").body).toBe(
|
||||
"Az RSS-hírcsatorna egyik csatornánál sem válaszolt (1399) — az első: UCx, HTTP 404. " +
|
||||
"Az API-n át beolvasva helyette: 0",
|
||||
);
|
||||
expect(jobFailureText(t, { ...run.result, cause: "transport" }, "hu")).toContain(
|
||||
"UCx, nem érhető el.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps any other failure's own text", () => {
|
||||
const t = i18n.getFixedT("hu");
|
||||
expect(jobRunText(t, { ...run, summary: "Plex returned 500", result: null }, "hu").body).toBe(
|
||||
"Plex returned 500",
|
||||
);
|
||||
expect(jobFailureText(t, { failure: "moon_is_full" }, "hu")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,10 +38,30 @@ export function jobResultText(t: TFunction, result: JobResult, lang: string): st
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
/** A failure the job named (the server's `progress.JobFailed`: `{failure: <reason>, …params}`) in the
|
||||
* reader's language (E31); null for any other failure, which keeps the upstream's own text. */
|
||||
export function jobFailureText(t: TFunction, result: unknown, lang: string): string | null {
|
||||
if (!result || typeof result !== "object") return null;
|
||||
const { failure, ...params } = result as Record<string, unknown>;
|
||||
if (typeof failure !== "string") return null;
|
||||
const values: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(params))
|
||||
values[k] = typeof v === "number" ? formatNumber(v, lang) : String(v);
|
||||
if (typeof params.cause === "string") values.cause = rssCause(t, params.cause);
|
||||
return t(`scheduler.failure.${failure}`, { ...values, defaultValue: "" }) || null;
|
||||
}
|
||||
|
||||
/** Why a feed did not answer, from the poll's reason code (`http_404`, `transport`, …). */
|
||||
function rssCause(t: TFunction, cause: string): string {
|
||||
const http = /^http_(\d+)$/.exec(cause);
|
||||
if (http) return t("scheduler.rssCause.http", { status: http[1] });
|
||||
return t(`scheduler.rssCause.${cause}`, { defaultValue: cause });
|
||||
}
|
||||
|
||||
/** What a job run said, as the inbox notice and the audit row both show it (E30): a title naming the job
|
||||
* and how the run ended, and its result in the reader's language — from the data when it rode along,
|
||||
* else read back from the English one-liner. A skipped run names only why; a failed run keeps the
|
||||
* upstream's own error text (it is diagnostic). */
|
||||
* else read back from the English one-liner. A skipped run names only why; a failed run says the
|
||||
* failure the job named (`jobFailureText`, E31), else keeps the upstream's own text (diagnostic). */
|
||||
export function jobRunText(
|
||||
t: TFunction,
|
||||
run: { job_id: string; status?: unknown; summary?: unknown; result?: unknown },
|
||||
@@ -65,10 +85,12 @@ export function jobRunText(
|
||||
}
|
||||
// A skipped run's title already says "skipped": its body is only the reason, not "skipped: <reason>".
|
||||
const reason = run.status === "skipped" ? onlySkipReason(result) : null;
|
||||
const failure = run.status === "error" ? jobFailureText(t, run.result, lang) : null;
|
||||
return {
|
||||
title: t(titleKey, { job }),
|
||||
body:
|
||||
reason !== null
|
||||
body: failure
|
||||
? failure
|
||||
: reason !== null
|
||||
? t(`scheduler.skip.${vocabKey(reason)}`, { defaultValue: reason })
|
||||
: result
|
||||
? jobResultText(t, result, lang)
|
||||
|
||||
Reference in New Issue
Block a user