feat(devops): diagnose slow HK requests and dependency timings
This commit is contained in:
1 parent
3b76d063a6
commit
00e33623f7
42 files changed
+1335
-132
No files matched your search
@@ -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");
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user