feat(cms): improve catalog, editorial recovery and operations
CI / check (push) Successful in 52s
CI / deploy (push) Successful in 2m10s
CI / publish-container (push) Failing after 1m18s

This commit is contained in:
Simo committed 2026-09-09 19:36:15 +02:00
1 parent 2fead134e6
commit c389c3893d
122 files changed
+8137 -703

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
import { expect, it } from "vitest";
import { migrationState, workerState } from "./installation-state";
it("distinguishes missing, malformed and stale worker evidence", () => {
const now = Date.parse("2026-09-09T12:00:00Z");
expect(workerState(null, now)).toBe("unknown");
expect(workerState("bad", now)).toBe("unknown");
expect(workerState("2026-09-09T11:59:30Z", now)).toBe("ok");
expect(workerState("2026-09-09T11:55:00Z", now)).toBe("failed");
});
it("never treats missing migration history as up to date", () => {
expect(migrationState(["0026"], null)).toEqual({
state: "unknown",
pending: null,
});
expect(migrationState(["0025", "0026"], ["0025"])).toEqual({
state: "failed",
pending: 1,
});
expect(migrationState(["0026"], ["0026"])).toEqual({
state: "ok",
pending: 0,
});
});
+20
View File
@@ -0,0 +1,20 @@
export type DiagnosticState = "ok" | "failed" | "unknown" | "missing";
export function workerState(
value: string | null,
now = Date.now(),
): DiagnosticState {
if (!value) return "unknown";
const time = Date.parse(value);
if (!Number.isFinite(time) || time > now + 60000) return "unknown";
return now - time <= 120000 ? "ok" : "failed";
}
export function migrationState(
expected: string[],
applied: string[] | null,
): { state: DiagnosticState; pending: number | null } {
if (!expected.length || applied === null)
return { state: "unknown", pending: null };
const done = new Set(applied);
const pending = expected.filter((name) => !done.has(name)).length;
return { state: pending ? "failed" : "ok", pending };
}
+111
View File
@@ -0,0 +1,111 @@
import "server-only";
import { constants } from "node:fs";
import { access, readdir } from "node:fs/promises";
import path from "node:path";
import { sql } from "drizzle-orm";
import {
type DiagnosticState,
migrationState,
workerState,
} from "@/lib/admin/installation-state";
import { fetchOpsHealth } from "@/lib/admin/ops-health";
import { db } from "@/lib/db";
import { redis } from "@/lib/redis";
import { siteSettings } from "@/lib/services/site-settings";
export async function inspectInstallation() {
const started = performance.now();
const [health, storage, expected, applied, heartbeat, renderer] =
await Promise.all([
fetchOpsHealth(),
Promise.all(
["storage", "public/nitro-assets", "public/swf"].map(async (name) => ({
name,
state: await access(
path.join(process.cwd(), name),
constants.R_OK | constants.W_OK,
)
.then(() => "ok" as const)
.catch(() => "failed" as const),
})),
),
readdir(path.join(process.cwd(), "drizzle/migrations"))
.then((files) =>
files
.filter((name) => name.endsWith(".sql"))
.map((name) => name.slice(0, -4)),
)
.catch(() => []),
db
.execute(sql`SELECT migration FROM cms_migrations`)
.then(([rows]) =>
(rows as unknown as { migration: string }[]).map(
(row) => row.migration,
),
)
.catch(() => null),
redis
? redis.get("cms:jobs-worker:heartbeat").catch(() => null)
: Promise.resolve(null),
siteSettings.get("nitro_client_url", "").catch(() => ""),
]);
const migration = migrationState(expected, applied);
const release = process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown";
const rows: Array<{ key: string; state: DiagnosticState; detail?: string }> =
[
{
key: "release",
state: /^[a-f0-9]{40}$/i.test(release) ? "ok" : "unknown",
detail: /^[a-f0-9]{40}$/i.test(release) ? release : undefined,
},
{
key: "database",
state: health.dbOk ? "ok" : "failed",
detail: health.dbOk ? `${health.dbLatencyMs} ms` : undefined,
},
{
key: "redis",
state:
health.redisOk === null
? "missing"
: health.redisOk
? "ok"
: "failed",
},
{ key: "emulator", state: health.emulatorOk ? "ok" : "failed" },
{
key: "storage",
state: storage.every((item) => item.state === "ok") ? "ok" : "failed",
detail:
storage
.filter((item) => item.state !== "ok")
.map((item) => item.name)
.join(", ") || undefined,
},
{
key: "migrations",
state: migration.state,
detail:
migration.pending === null ? undefined : String(migration.pending),
},
{
key: "worker",
state: workerState(heartbeat),
detail:
heartbeat && Number.isFinite(Date.parse(heartbeat))
? new Date(heartbeat).toISOString()
: undefined,
},
{
key: "renderer",
state: health.dbOk ? "ok" : "unknown",
detail: health.dbOk && !renderer ? "/nitro-client/" : undefined,
},
{ key: "registry", state: "unknown" },
];
return {
rows,
checkedAt: new Date().toISOString(),
durationMs: Math.round(performance.now() - started),
};
}
+35
View File
@@ -0,0 +1,35 @@
import { expect, it, vi } from "vitest";
import { collectOperations } from "./operations-inbox";
it("does not load unauthorized sources and reports failed sources separately", async () => {
const denied = vi.fn();
const result = await collectOperations([
{ source: "errors", allowed: false, load: denied },
{
source: "imports",
allowed: true,
load: async () => {
throw Error("offline");
},
},
]);
expect(denied).not.toHaveBeenCalled();
expect(result).toEqual({ items: [], unavailable: ["imports"] });
});
it("deduplicates, prioritizes and excludes empty work", async () => {
const item = {
id: "one",
source: "errors" as const,
href: "/admin/devops/cms-errors",
count: 2,
severity: 3,
};
const result = await collectOperations([
{
source: "errors",
allowed: true,
load: async () => [item, item, { ...item, id: "empty", count: 0 }],
},
]);
expect(result.items).toEqual([item]);
});
+42
View File
@@ -0,0 +1,42 @@
export type OperationsSource =
| "errors"
| "imports"
| "tickets"
| "publications";
export interface OperationsItem {
id: string;
source: OperationsSource;
href: string;
count: number;
severity: number;
}
export interface OperationsLoader {
source: OperationsSource;
allowed: boolean;
load: () => Promise<OperationsItem[]>;
}
/** Authorization precedes loading; a failed source is never reported as empty. */
export async function collectOperations(loaders: OperationsLoader[]) {
const available = loaders.filter((loader) => loader.allowed);
const results = await Promise.allSettled(
available.map((loader) => loader.load()),
);
const unique = new Map<string, OperationsItem>();
const unavailable: OperationsSource[] = [];
for (const [index, result] of results.entries()) {
if (result.status === "rejected") {
unavailable.push(available[index].source);
continue;
}
for (const item of result.value) {
const key = `${item.source}:${item.id}`;
if (item.count > 0 && !unique.has(key)) unique.set(key, item);
}
}
return {
items: [...unique.values()]
.sort((a, b) => b.severity - a.severity || a.id.localeCompare(b.id))
.slice(0, 12),
unavailable,
};
}
+40 -37
View File
@@ -1,6 +1,7 @@
import "server-only";
import { count, eq, sql } from "drizzle-orm";
import { cache } from "react";
import { env } from "@/env";
import { db, User } from "@/lib/db";
import { redis } from "@/lib/redis";
@@ -19,40 +20,42 @@ export type OpsHealth = {
* Shared DB + Redis + emulator health probe for CommandoCentrum, DevOps, and APIs.
* Semantics match `/api/health` for Redis (null when not configured).
*/
export async function fetchOpsHealth(): Promise<OpsHealth> {
const [dbProbe, redisOk, emulatorOk, onlineUsers] = await Promise.all([
(async () => {
try {
const start = Date.now();
await db.execute(sql`SELECT 1`);
return { dbOk: true, dbLatencyMs: Date.now() - start };
} catch {
return { dbOk: false, dbLatencyMs: 0 };
}
})(),
(async (): Promise<boolean | null> => {
if (!env.REDIS_URL) return null;
if (!redis) return false;
try {
const pong = await redis.ping();
return pong === "PONG";
} catch {
return false;
}
})(),
rcon.send("ping", null).catch(() => false),
db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
]);
return {
dbOk: dbProbe.dbOk,
dbLatencyMs: dbProbe.dbLatencyMs,
redisOk,
emulatorOk: Boolean(emulatorOk),
onlineUsers,
};
}
export const fetchOpsHealth = cache(
async function fetchOpsHealth(): Promise<OpsHealth> {
const [dbProbe, redisOk, emulatorOk, onlineUsers] = await Promise.all([
(async () => {
try {
const start = Date.now();
await db.execute(sql`SELECT 1`);
return { dbOk: true, dbLatencyMs: Date.now() - start };
} catch {
return { dbOk: false, dbLatencyMs: 0 };
}
})(),
(async (): Promise<boolean | null> => {
if (!env.REDIS_URL) return null;
if (!redis) return false;
try {
const pong = await redis.ping();
return pong === "PONG";
} catch {
return false;
}
})(),
rcon.send("ping", null).catch(() => false),
db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
]);
return {
dbOk: dbProbe.dbOk,
dbLatencyMs: dbProbe.dbLatencyMs,
redisOk,
emulatorOk: Boolean(emulatorOk),
onlineUsers,
};
},
);