feat(devops): diagnose slow HK requests and dependency timings
CI / check (push) Successful in 2m0s
CI / deploy (push) Successful in 1m35s
CI / publish-container (push) Successful in 46s

This commit is contained in:
Simo committed 2026-09-11 09:48:19 +02:00
1 parent 3b76d063a6
commit 00e33623f7
42 files changed
+1335 -132

No files matched your search

+4
View File
@@ -53,6 +53,7 @@ export default async function DevOpsPage() {
const t = await getTranslations("pages.admin.devops");
const data = await getDevOpsData();
const performance = await getTranslations("pages.admin.performance");
const installation = await getTranslations("pages.admin.installation");
return (
@@ -63,6 +64,9 @@ export default async function DevOpsPage() {
<Link href="/admin/devops/installation" className="btn btn-outline">
{installation("title")}
</Link>
<Link href="/admin/devops/performance" className="btn btn-outline">
{performance("title")}
</Link>
{/* Status cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Card>
@@ -0,0 +1,77 @@
import { createTranslator } from "next-intl";
import { renderToStaticMarkup } from "react-dom/server";
import { expect, it, vi } from "vitest";
import messages from "@/messages/en.json";
const mocks = vi.hoisted(() => ({ read: vi.fn(), allowed: false }));
vi.mock("@/lib/permissions", () => ({
getAdminContext: async () => ({
session: { user: { rank: 1 } },
permissions: {},
}),
canAccess: () => mocks.allowed,
PERMS: { DEVOPS_VIEW: "devops.view" },
}));
vi.mock("next/navigation", () => ({
redirect: () => {
throw new Error("redirect");
},
}));
vi.mock("@/lib/performance-store", () => ({ readPerformance: mocks.read }));
vi.mock("@/components/link", () => ({ default: () => null }));
vi.mock("next-intl/server", () => ({
getTranslations: async () =>
createTranslator({
locale: "en",
messages: messages.pages.admin.performance,
}),
}));
import Page from "./page";
it("rejects unauthorized users before reading timing records", async () => {
await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow(
"redirect",
);
expect(mocks.read).not.toHaveBeenCalled();
});
it("renders filtered slow records and explains incomplete streaming timings", async () => {
mocks.allowed = true;
const metrics = {
durationMs: 1500,
database: { count: 2, durationMs: 300, errors: 0 },
external: { count: 1, durationMs: 400, errors: 1 },
};
mocks.read.mockResolvedValue({
shared: false,
samples: [
{
operationId: "slow-op",
route: "/api/admin/studio",
method: "GET",
status: 200,
at: 1000,
streaming: true,
metrics,
},
{
operationId: "fast-op",
route: "/api/admin/users",
method: "GET",
status: 200,
at: 1001,
streaming: false,
metrics: { ...metrics, durationMs: 10 },
},
],
});
const html = renderToStaticMarkup(
await Page({ searchParams: Promise.resolve({ slow: "1" }) }),
);
expect(html).toContain("slow-op");
expect(html).not.toContain("fast-op");
expect(html).toContain("Streaming response");
expect(html).toContain("Local diagnostics");
});
+134
View File
@@ -0,0 +1,134 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { readPerformance } from "@/lib/performance-store";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function PerformancePage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
redirect("/admin");
const t = await getTranslations("pages.admin.performance");
const { samples, shared } = await readPerformance();
const params = await searchParams;
const query = (typeof params.q === "string" ? params.q : "").slice(0, 100);
const visible = samples.filter(
(sample) =>
`${sample.route} ${sample.operationId}`.includes(query) &&
(params.slow !== "1" || sample.metrics.durationMs >= 1000),
);
visible.sort((a, b) =>
params.sort === "recent"
? b.at - a.at
: b.metrics.durationMs - a.metrics.durationMs,
);
return (
<div className="min-w-0 space-y-5">
<div>
<h1 className="text-2xl font-semibold">{t("title")}</h1>
<p className="text-muted-foreground">{t("scope")}</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href="/admin/devops" className="btn btn-outline">
DevOps
</Link>
<Link href="/admin/devops/performance" className="btn btn-outline">
{t("refresh")}
</Link>
</div>
<p className="admin-card text-sm">
{t(shared ? "shared" : "local")} {t("retention")}
</p>
<form className="admin-card flex flex-wrap gap-3">
<input
name="q"
defaultValue={query}
aria-label={t("search")}
placeholder={t("search")}
className="input min-w-0 flex-1"
maxLength={100}
/>
<select
name="sort"
defaultValue={params.sort === "recent" ? "recent" : "slowest"}
aria-label={t("sort")}
className="input"
>
<option value="slowest">{t("slowest")}</option>
<option value="recent">{t("recent")}</option>
</select>
<select
name="slow"
defaultValue={params.slow === "1" ? "1" : "0"}
aria-label={t("threshold")}
className="input"
>
<option value="0">{t("all")}</option>
<option value="1">{t("slow")}</option>
</select>
<button className="btn btn-primary" type="submit">
{t("search")}
</button>
</form>
<p className="text-sm text-muted-foreground">{t("explanation")}</p>
{visible.length === 0 ? (
<p className="admin-card">{t("empty")}</p>
) : (
<ul className="space-y-3">
{visible.map((sample) => (
<li
key={sample.operationId}
className="admin-card min-w-0 space-y-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="break-all font-medium">
{sample.method} {sample.route}
</p>
<p className="text-sm text-muted-foreground">
{new Date(sample.at).toISOString()} · HTTP {sample.status}
</p>
</div>
<strong
className={
sample.metrics.durationMs >= 1000 ? "text-destructive" : ""
}
>
{Math.round(sample.metrics.durationMs)} ms
</strong>
</div>
<dl className="grid gap-3 sm:grid-cols-2">
<div>
<dt>{t("database")}</dt>
<dd>
{Math.round(sample.metrics.database.durationMs)} ms ·{" "}
{t("calls", { count: sample.metrics.database.count })} ·{" "}
{t("failures", { count: sample.metrics.database.errors })}
</dd>
</div>
<div>
<dt>{t("external")}</dt>
<dd>
{Math.round(sample.metrics.external.durationMs)} ms ·{" "}
{t("calls", { count: sample.metrics.external.count })} ·{" "}
{t("failures", { count: sample.metrics.external.errors })}
</dd>
</div>
</dl>
{sample.streaming && (
<p className="text-sm text-destructive">{t("streaming")}</p>
)}
<p className="break-all text-xs text-muted-foreground">
{t("operation")}: {sample.operationId}
</p>
</li>
))}
</ul>
)}
</div>
);
}