fix(news): persist publication requests and retry cache delivery
This commit is contained in:
1 parent
c977fe95ba
commit
52f6d1491f
40 files changed
+733
-134
No files matched your search
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("news editor retries a lost response with the same request across reload and clears it after success", async ({
|
||||
page,
|
||||
}) => {
|
||||
const requests: Array<Record<string, string>> = [];
|
||||
let succeed = false;
|
||||
await page.route("**/fixture/actions/article", async (route) => {
|
||||
requests.push(route.request().postDataJSON());
|
||||
if (!succeed) await route.abort("failed");
|
||||
else
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true, data: {} }),
|
||||
});
|
||||
});
|
||||
await page.goto("/admin/articles/new");
|
||||
await expect(page.locator("iframe.tox-edit-area__iframe")).toBeVisible();
|
||||
const save = page.getByRole("button", { name: "Save article", exact: true });
|
||||
await save.click();
|
||||
await expect(page.getByRole("alert")).toBeVisible();
|
||||
await save.click();
|
||||
await expect.poll(() => requests.length).toBe(2);
|
||||
expect(requests[1].requestKey).toBe(requests[0].requestKey);
|
||||
await page.reload();
|
||||
await expect(page.locator("iframe.tox-edit-area__iframe")).toBeVisible();
|
||||
await save.click();
|
||||
await expect.poll(() => requests.length).toBe(3);
|
||||
expect(requests[2].requestKey).toBe(requests[0].requestKey);
|
||||
const stored = await page.evaluate(() =>
|
||||
sessionStorage.getItem("cms:news:request:current:new"),
|
||||
);
|
||||
expect(stored).not.toContain("Community update");
|
||||
succeed = true;
|
||||
await save.click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
sessionStorage.getItem("cms:news:request:current:new"),
|
||||
),
|
||||
)
|
||||
.toBeNull();
|
||||
expect(requests[3].requestKey).toBe(requests[0].requestKey);
|
||||
await page.locator('input[name="title"]').fill("Another article");
|
||||
await save.click();
|
||||
await expect.poll(() => requests.length).toBe(5);
|
||||
expect(requests[4].requestKey).not.toBe(requests[0].requestKey);
|
||||
});
|
||||
+4
-25
@@ -2,14 +2,9 @@ import { drainOperationEffects } from "../src/features/operations/worker";
|
||||
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
|
||||
import "./load-env";
|
||||
import { Cron } from "croner";
|
||||
import { and, eq, lt, lte, sql } from "drizzle-orm";
|
||||
import { lt, sql } from "drizzle-orm";
|
||||
import { env } from "../src/env";
|
||||
import {
|
||||
db,
|
||||
PasswordReset,
|
||||
WebsiteArticles,
|
||||
WebsiteLoginLogs,
|
||||
} from "../src/lib/db";
|
||||
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
|
||||
import { logger } from "../src/lib/logger";
|
||||
import { redis } from "../src/lib/redis";
|
||||
import {
|
||||
@@ -23,7 +18,7 @@ import {
|
||||
diskLevel,
|
||||
parseDfOutput,
|
||||
} from "../src/lib/services/disk-usage";
|
||||
import { invalidateNewsCache } from "../src/lib/services/news-cache";
|
||||
import { publishDueArticles } from "../src/lib/services/news-scheduler";
|
||||
import { rcon } from "../src/lib/services/rcon";
|
||||
|
||||
function captureWorkerError(err: unknown, context: string): void {
|
||||
@@ -347,24 +342,8 @@ async function pruneDockerCache(force = false): Promise<void> {
|
||||
|
||||
async function publishScheduledArticles(): Promise<void> {
|
||||
try {
|
||||
const now = new Date();
|
||||
const result = await db
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
status: "published",
|
||||
publishedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticles.status, "scheduled"),
|
||||
lte(WebsiteArticles.publishAt, now),
|
||||
),
|
||||
);
|
||||
const [info] = result;
|
||||
const published = info.affectedRows ?? 0;
|
||||
const published = await publishDueArticles();
|
||||
if (published > 0) {
|
||||
await invalidateNewsCache();
|
||||
logger.info(`Published ${published} scheduled article(s)`, {
|
||||
module: "jobs",
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+136
-80
File diff suppressed because it is too large.
Load diff
@@ -26,7 +26,13 @@ export default async function DeliveryPage() {
|
||||
<li key={item.id} className="admin-card min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap justify-between gap-3">
|
||||
<strong>
|
||||
{t(item.topic === "catalog.refresh" ? "hotel" : "export")}
|
||||
{t(
|
||||
item.topic === "catalog.refresh"
|
||||
? "hotel"
|
||||
: item.topic === "news.refresh"
|
||||
? "news"
|
||||
: "export",
|
||||
)}
|
||||
</strong>
|
||||
<span>{t(`states.${item.status}`)}</span>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useFormRecovery } from "@/hooks/use-form-recovery";
|
||||
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import type { ArticleDraft } from "@/lib/article-draft";
|
||||
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
import { type ArticleRequest, articleRequest } from "@/lib/article-request";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { publicationIssues } from "@/lib/publication-preflight";
|
||||
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
||||
@@ -46,6 +47,8 @@ export function ArticleForm({
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const saving = useRef(false);
|
||||
const request = useRef<ArticleRequest | undefined>(undefined);
|
||||
const requestScope = `${userId ?? "current"}:${articleKey}`;
|
||||
const editVersion = useRef(0);
|
||||
function markDirty() {
|
||||
localRecovery.changed();
|
||||
@@ -156,6 +159,19 @@ export function ArticleForm({
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await recovery.wait();
|
||||
let storage: Storage | undefined;
|
||||
try {
|
||||
storage = window.sessionStorage;
|
||||
} catch {
|
||||
/* Storage may be disabled. */
|
||||
}
|
||||
request.current = await articleRequest(
|
||||
data,
|
||||
requestScope,
|
||||
request.current,
|
||||
storage,
|
||||
);
|
||||
data.set("requestKey", request.current.key);
|
||||
const result = await action(data);
|
||||
if (result && !result.ok) {
|
||||
setSaveError(result.error);
|
||||
@@ -166,7 +182,15 @@ export function ArticleForm({
|
||||
setDirty(false);
|
||||
localRecovery.clear();
|
||||
}
|
||||
if (result?.ok) await recovery.clear().catch(() => {});
|
||||
if (result?.ok) {
|
||||
request.current = undefined;
|
||||
try {
|
||||
storage?.removeItem(`cms:news:request:${requestScope}`);
|
||||
} catch {
|
||||
/* Storage may be disabled. */
|
||||
}
|
||||
await recovery.clear().catch(() => {});
|
||||
}
|
||||
if (result?.ok && result.data?.redirectTo)
|
||||
router.push(result.data.redirectTo);
|
||||
} catch (error) {
|
||||
|
||||
@@ -53,7 +53,10 @@ export function operationHash(value: unknown) {
|
||||
export function retryDelay(attempt: number) {
|
||||
return Math.min(3600, 60 * 2 ** Math.max(0, attempt - 1));
|
||||
}
|
||||
export type EffectTopic = "catalog.refresh" | "catalog.export.request";
|
||||
export type EffectTopic =
|
||||
| "catalog.refresh"
|
||||
| "catalog.export.request"
|
||||
| "news.refresh";
|
||||
export interface EffectClaim {
|
||||
id: string;
|
||||
token: string;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
claim: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
news: vi.fn(),
|
||||
catalog: vi.fn(),
|
||||
exportEnabled: vi.fn(),
|
||||
request: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}));
|
||||
vi.mock("./server", () => ({
|
||||
effectRepository: {
|
||||
claim: mocks.claim,
|
||||
complete: mocks.complete,
|
||||
fail: mocks.fail,
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/news-cache", () => ({
|
||||
refreshNewsCacheForDelivery: mocks.news,
|
||||
}));
|
||||
vi.mock("@/features/catalog/server/sync-status", () => ({
|
||||
sendCatalogUpdate: mocks.catalog,
|
||||
}));
|
||||
vi.mock("@/lib/services/catalog-git-queue", () => ({
|
||||
catalogExportEnabled: mocks.exportEnabled,
|
||||
catalogExportQueue: () => ({ request: mocks.request }),
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: mocks.error } }));
|
||||
|
||||
import { drainOperationEffects } from "./worker";
|
||||
|
||||
const effect = {
|
||||
id: "one",
|
||||
token: "lease",
|
||||
attempts: 1,
|
||||
topic: "news.refresh",
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.claim.mockResolvedValueOnce(effect).mockResolvedValue(null);
|
||||
mocks.news.mockResolvedValue(undefined);
|
||||
});
|
||||
it("invalidates the shared news cache before acknowledging delivery", async () => {
|
||||
await drainOperationEffects();
|
||||
expect(mocks.news).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.complete).toHaveBeenCalledWith(effect);
|
||||
expect(mocks.fail).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps a failed news invalidation retryable", async () => {
|
||||
mocks.news.mockRejectedValue(new Error("Redis unavailable"));
|
||||
await drainOperationEffects();
|
||||
expect(mocks.fail).toHaveBeenCalledWith(effect);
|
||||
expect(mocks.complete).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
catalogExportEnabled,
|
||||
catalogExportQueue,
|
||||
} from "@/lib/services/catalog-git-queue";
|
||||
import { refreshNewsCacheForDelivery } from "@/lib/services/news-cache";
|
||||
import { dispatchEffects } from "./dispatcher";
|
||||
import { effectRepository } from "./server";
|
||||
|
||||
@@ -19,6 +20,8 @@ export async function drainOperationEffects() {
|
||||
throw new Error("Hotel update not delivered");
|
||||
} else if (claim.topic === "catalog.export.request") {
|
||||
if (catalogExportEnabled()) await catalogExportQueue().request();
|
||||
} else if (claim.topic === "news.refresh") {
|
||||
await refreshNewsCacheForDelivery();
|
||||
} else throw new Error("Unknown delivery topic");
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, expect, it } from "vitest";
|
||||
import { articleRequest } from "./article-request";
|
||||
|
||||
const values = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
};
|
||||
function form(body = "Private article body") {
|
||||
const form = new FormData();
|
||||
form.set("fullStory", body);
|
||||
form.set("status", "published");
|
||||
return form;
|
||||
}
|
||||
beforeEach(() => values.clear());
|
||||
it("reuses the request after a lost response and an editor reload", async () => {
|
||||
const first = await articleRequest(form(), "staff:1:new", undefined, storage);
|
||||
const retry = await articleRequest(form(), "staff:1:new", undefined, storage);
|
||||
expect(retry.key).toBe(first.key);
|
||||
expect([...values.values()].join()).not.toContain("Private article body");
|
||||
});
|
||||
it("uses a new request for changed content or a different editor", async () => {
|
||||
const first = await articleRequest(form(), "staff:1:new", undefined, storage);
|
||||
expect(
|
||||
(await articleRequest(form("Changed"), "staff:1:new", first, storage)).key,
|
||||
).not.toBe(first.key);
|
||||
expect(
|
||||
(await articleRequest(form(), "staff:2:new", undefined, storage)).key,
|
||||
).not.toBe(first.key);
|
||||
});
|
||||
it("retains the in-memory request when browser storage is unavailable", async () => {
|
||||
const blocked = {
|
||||
getItem: () => {
|
||||
throw Error("blocked");
|
||||
},
|
||||
setItem: () => {
|
||||
throw Error("blocked");
|
||||
},
|
||||
};
|
||||
const first = await articleRequest(form(), "staff:1:new", undefined, blocked);
|
||||
expect(
|
||||
(await articleRequest(form(), "staff:1:new", first, blocked)).key,
|
||||
).toBe(first.key);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface ArticleRequest {
|
||||
signature: string;
|
||||
key: string;
|
||||
}
|
||||
/** Retain only a fingerprint and request ID, never editorial content. */
|
||||
export async function articleRequest(
|
||||
form: FormData,
|
||||
scope: string,
|
||||
previous?: ArticleRequest,
|
||||
storage?: Pick<Storage, "getItem" | "setItem">,
|
||||
): Promise<ArticleRequest> {
|
||||
const entries = [...form.entries()]
|
||||
.filter(([key]) => key !== "requestKey")
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(JSON.stringify([scope, entries])),
|
||||
);
|
||||
const signature = Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const storageKey = `cms:news:request:${scope}`;
|
||||
let saved = previous;
|
||||
if (!saved) {
|
||||
try {
|
||||
saved = JSON.parse(storage?.getItem(storageKey) ?? "null") ?? undefined;
|
||||
} catch {
|
||||
/* Storage may be disabled. */
|
||||
}
|
||||
}
|
||||
if (
|
||||
saved?.signature === signature &&
|
||||
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(
|
||||
saved.key,
|
||||
)
|
||||
)
|
||||
return saved;
|
||||
const next = { signature, key: crypto.randomUUID() };
|
||||
try {
|
||||
storage?.setItem(storageKey, JSON.stringify(next));
|
||||
} catch {
|
||||
/* The editor retains the key in memory. */
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -18,7 +18,11 @@ vi.mock("@/lib/cache", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { cacheNews, invalidateNewsCache } from "./news-cache";
|
||||
import {
|
||||
cacheNews,
|
||||
invalidateNewsCache,
|
||||
refreshNewsCacheForDelivery,
|
||||
} from "./news-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
state.values.clear();
|
||||
@@ -65,3 +69,12 @@ it("reads fresh data when Redis is unavailable", async () => {
|
||||
"fresh",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps failed durable cache invalidations retryable", async () => {
|
||||
state.set.mockRejectedValue(Error("offline"));
|
||||
await expect(refreshNewsCacheForDelivery()).rejects.toThrow("offline");
|
||||
});
|
||||
it("does not acknowledge an ended Redis connection as refreshed", async () => {
|
||||
state.status = "end";
|
||||
await expect(refreshNewsCacheForDelivery()).rejects.toThrow();
|
||||
});
|
||||
@@ -30,3 +30,11 @@ export async function invalidateNewsCache(): Promise<void> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Worker delivery must fail visibly so the outbox can retry the invalidation. */
|
||||
export async function refreshNewsCacheForDelivery(): Promise<void> {
|
||||
if (!redis) return;
|
||||
if (redis.status === "end")
|
||||
throw new Error("News cache connection is closed");
|
||||
await redis.set(REVISION_KEY, randomUUID());
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
||||
import "server-only";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { operationHash } from "@/features/operations/model";
|
||||
import { enqueueEffect } from "@/features/operations/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { invalidateNewsCache } from "./news-cache";
|
||||
|
||||
/** Publish a bounded batch and persist its refresh work before committing. */
|
||||
export async function publishDueArticles(now = new Date()): Promise<number> {
|
||||
const published = await db.transaction(async (tx) => {
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT CAST(id AS CHAR) AS id,user_id AS userId,publish_at AS publishAt FROM website_articles WHERE status='scheduled' AND publish_at<=${now} ORDER BY publish_at,id LIMIT 100 FOR UPDATE`,
|
||||
);
|
||||
let published = 0;
|
||||
for (const article of rows as unknown as Array<{
|
||||
id: string;
|
||||
userId: number | null;
|
||||
publishAt: Date | string;
|
||||
}>) {
|
||||
const [result] = await tx.execute(
|
||||
sql`UPDATE website_articles SET status='published',published_at=${now},updated_at=${now} WHERE id=${article.id} AND status='scheduled' AND publish_at<=${now}`,
|
||||
);
|
||||
if ((result as unknown as { affectedRows: number }).affectedRows !== 1)
|
||||
continue;
|
||||
const operationId = randomUUID();
|
||||
const hash = operationHash({
|
||||
articleId: article.id,
|
||||
publishAt: new Date(article.publishAt).toISOString(),
|
||||
});
|
||||
// Legacy articles without an author remain attributed to system (0).
|
||||
await tx.execute(
|
||||
sql`INSERT INTO cms_operations (id,actor_id,kind,request_key,request_hash,result_json) VALUES (${operationId},${article.userId ?? 0},${"news.schedule.publish"},${randomUUID()},${hash},${JSON.stringify({ articleId: article.id, published: true })})`,
|
||||
);
|
||||
await enqueueEffect(tx, operationId, "news.refresh");
|
||||
published++;
|
||||
}
|
||||
return published;
|
||||
});
|
||||
if (published > 0) {
|
||||
try {
|
||||
await invalidateNewsCache();
|
||||
} catch (error) {
|
||||
logger.error("Scheduled news published; cache refresh remains queued", {
|
||||
module: "news",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
return published;
|
||||
}
|
||||
@@ -3552,7 +3552,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
|
||||
@@ -4375,7 +4375,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -4375,7 +4375,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -4375,7 +4375,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -4345,7 +4345,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
|
||||
@@ -4375,7 +4375,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -4548,7 +4548,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"mod": {
|
||||
|
||||
@@ -4345,7 +4345,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
|
||||
@@ -3552,7 +3552,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
|
||||
@@ -4345,7 +4345,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
|
||||
@@ -4375,7 +4375,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -4375,7 +4375,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -4517,7 +4517,8 @@
|
||||
"running": "In consegna",
|
||||
"done": "Consegnata",
|
||||
"failed": "Da verificare"
|
||||
}
|
||||
},
|
||||
"news": "Aggiornamento news"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
|
||||
@@ -3552,7 +3552,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
|
||||
@@ -4548,7 +4548,8 @@
|
||||
"running": "Wordt afgeleverd",
|
||||
"done": "Afgeleverd",
|
||||
"failed": "Aandacht nodig"
|
||||
}
|
||||
},
|
||||
"news": "Nieuws bijwerken"
|
||||
}
|
||||
},
|
||||
"mod": {
|
||||
|
||||
@@ -4375,7 +4375,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -4375,7 +4375,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -4375,7 +4375,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -4375,7 +4375,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -4373,7 +4373,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -4375,7 +4375,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -4375,7 +4375,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -4375,7 +4375,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -4375,7 +4375,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -4375,7 +4375,8 @@
|
||||
"running": "Delivering",
|
||||
"done": "Delivered",
|
||||
"failed": "Needs attention"
|
||||
}
|
||||
},
|
||||
"news": "News cache refresh"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
Reference in new issue
Block a user