feat(cms): improve catalog, editorial recovery and operations
This commit is contained in:
1 parent
2fead134e6
commit
c389c3893d
122 files changed
+8137
-703
No files matched your search
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user