perf : increase admin page speed
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: admin-performance
|
||||
description: Preserve Buzz Sheet admin performance when adding or changing admin routes, editor data loading, catalog payloads, navigation, or refresh behavior. Diagnose slow admin navigation with measurements and targeted fixes.
|
||||
---
|
||||
|
||||
# Admin performance
|
||||
|
||||
Read the relevant installed Next.js guides in `node_modules/next/dist/docs/` before changing routing, caching, streaming, or data fetching. This project enables Cache Components; do not assume older Next.js conventions.
|
||||
|
||||
## Data boundaries
|
||||
|
||||
- Trace the route through authentication, queries, Server Component props, and the actual editor save action before narrowing its data. Keep authorization and version-conflict checks intact.
|
||||
- Use `getCatalogOptions()` in `lib/guides/queries.ts` for label/icon pickers. Use `getActiveCatalog()` only when the screen consumes full character details or materials. Full character records contain nested talent, constellation, ascension, leveling, and stat data; avoid sending those records for every picker option.
|
||||
- Section editors call `getAdminGuideContent(slug, page)` using the actual route slug, including custom extra slugs. Omitting the section intentionally loads the complete guide for preview/validation callers. Do not narrow those callers accidentally.
|
||||
- Get the current character's talents and constellations from the loaded guide content, not a second full catalog read. Preserve glossary aliases in picker data.
|
||||
- Catalog data reads must not depend on object-storage version metadata that the screen does not use. The dashboard's explicit version display is a separate consumer.
|
||||
- Keep independent work concurrent after authorization, while respecting the existing bounded query concurrency and per-pod database pool. Avoid N+1 queries and unbounded Promise.all over records.
|
||||
|
||||
## Navigation and freshness
|
||||
|
||||
Keep `app/admin/loading.tsx` as lightweight, non-sensitive feedback while protected content loads. A loading state improves feedback; it does not prove the editor becomes usable sooner. Evaluate link prefetch changes against server load, especially lists with many guides. Do not enable full-route prefetch everywhere as a substitute for fixing expensive reads.
|
||||
|
||||
Admin edits must remain immediately visible. Before adding cross-request caching, identify every invalidation path: autosave, glossary changes, catalog sync, trash/restore, and live refresh as applicable. Request-scoped deduplication and smaller queries are preferable when invalidation cannot be established. Never cache authorization globally.
|
||||
|
||||
## Measure and verify
|
||||
|
||||
For reported slowness, establish the affected route and whether it occurs in development or production. Separate cold compilation from production latency. Capture navigation-to-usable time, response/payload size, query time/count, and external service waits where accessible. Compare repeated warm runs and a cold run under equivalent conditions. Do not treat TTFB or a spinner as completion.
|
||||
|
||||
Read-only production diagnostics may inspect pod resources and time bounded database/storage reads when accessible. Output aggregate timings and sizes only; avoid credentials, session tokens, or private content. Do not deploy, migrate, or change infrastructure merely to benchmark.
|
||||
|
||||
Run `bun run typecheck` and focused tests for affected query/auth/save behavior. `lib/guides/queries.test.ts` guards section-specific reads and complete-guide compatibility. For route or streaming changes, also verify a production build. Verify edits retain selected-section data, custom extras, aliases, and conflict handling. Report measured improvements separately from expected gains; explicitly state when authenticated browser timings were unavailable.
|
||||
@@ -7,3 +7,7 @@ This version has breaking changes — APIs, conventions, and file structure may
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
## Admin performance
|
||||
|
||||
For changes to admin routes, editor data loading, catalog payloads, navigation, or refresh behavior, use `.agents/skills/admin-performance/SKILL.md` to preserve load performance and edit freshness.
|
||||
|
||||
@@ -11,7 +11,7 @@ import { EditorPresence } from "@/components/admin/editor-presence";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
import {
|
||||
getActiveCatalog,
|
||||
getCatalogOptions,
|
||||
getAdminGuideContent,
|
||||
getGuideEditorHeader,
|
||||
} from "@/lib/guides/queries";
|
||||
@@ -53,13 +53,11 @@ export default async function StructuredEditorPage({
|
||||
?? guide.sections.find((section) => section.slug === page)?.title
|
||||
?? page;
|
||||
const [catalog, content] = await Promise.all([
|
||||
getActiveCatalog(),
|
||||
getAdminGuideContent(guide.slug),
|
||||
getCatalogOptions(),
|
||||
getAdminGuideContent(guide.slug, page),
|
||||
]);
|
||||
if (!catalog || !content) notFound();
|
||||
const guideCharacter = catalog.characters.find(
|
||||
(item) => item.key === guide.characterKey,
|
||||
);
|
||||
const guideCharacter = content.character;
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<AdminHeader />
|
||||
|
||||
@@ -5,13 +5,13 @@ import { AdminHeader } from "@/components/admin/admin-header";
|
||||
import { GlossaryManager } from "@/components/admin/glossary-manager";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
import { listGlossaryAliases } from "@/lib/glossary-repository";
|
||||
import { getActiveCatalog } from "@/lib/guides/queries";
|
||||
import { getCatalogOptions } from "@/lib/guides/queries";
|
||||
|
||||
export default async function GlossaryPage() {
|
||||
await connection();
|
||||
if (!(await getAdminSession())) redirect("/admin/login");
|
||||
const [catalog, aliases] = await Promise.all([
|
||||
getActiveCatalog(),
|
||||
getCatalogOptions(),
|
||||
listGlossaryAliases(),
|
||||
]);
|
||||
if (!catalog) redirect("/admin");
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-svh w-full max-w-7xl items-start p-4 sm:p-6 lg:p-8">
|
||||
<p role="status" className="text-sm text-muted-foreground">กำลังโหลด…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ reads: [] as unknown[], rows: new Map<unknown, unknown[]>() }));
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/media/storage", () => ({ publicMediaUrl: (key: string) => `/media/${key}` }));
|
||||
vi.mock("@/db", () => ({
|
||||
getDb: () => ({
|
||||
select: () => {
|
||||
let table: unknown;
|
||||
const query = {
|
||||
from(value: unknown) { table = value; return query; },
|
||||
innerJoin() { return query; },
|
||||
leftJoin() { return query; },
|
||||
where() { return query; },
|
||||
orderBy() { return query; },
|
||||
limit() { return query; },
|
||||
then(resolve: (rows: unknown[]) => unknown) {
|
||||
mocks.reads.push(table);
|
||||
return Promise.resolve(mocks.rows.get(table) ?? []).then(resolve);
|
||||
},
|
||||
};
|
||||
return query;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
artifactConditions, constellationConditions, extraSectionRows,
|
||||
guides, guideSections, teamConditions, weaponConditions,
|
||||
catalogMaterials, guidePosterBleeding,
|
||||
} from "@/db/schema";
|
||||
import { getAdminGuideContent } from "./queries";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.reads.length = 0;
|
||||
mocks.rows.clear();
|
||||
mocks.rows.set(guides, [{
|
||||
guide: { id: "guide", slug: "test", posterArtMediaId: null },
|
||||
cover: { objectKey: "cover", status: "ready" },
|
||||
character: { key: "test", imageKey: "character", constellations: [], talents: [], ascension: [], leveling: [] },
|
||||
}]);
|
||||
mocks.rows.set(guideSections, [
|
||||
{ id: "extra-a", slug: "extra-a", kind: "extra" },
|
||||
{ id: "extra-b", slug: "extra-b", kind: "extra" },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("admin section reads", () => {
|
||||
it.each([
|
||||
["weapon", weaponConditions],
|
||||
["artifact", artifactConditions],
|
||||
["constellations", constellationConditions],
|
||||
["team", teamConditions],
|
||||
])("loads only the %s section's conditions", async (section, expectedTable) => {
|
||||
const result = await getAdminGuideContent("test", section);
|
||||
expect(result).not.toBeNull();
|
||||
expect(mocks.reads).toContain(expectedTable);
|
||||
for (const table of [weaponConditions, artifactConditions, constellationConditions, teamConditions]) {
|
||||
if (table !== expectedTable) expect(mocks.reads).not.toContain(table);
|
||||
}
|
||||
expect(mocks.reads).not.toContain(extraSectionRows);
|
||||
expect(mocks.reads).not.toContain(catalogMaterials);
|
||||
expect(mocks.reads).not.toContain(guidePosterBleeding);
|
||||
});
|
||||
|
||||
it("loads just the selected extra section", async () => {
|
||||
const result = await getAdminGuideContent("test", "extra-b");
|
||||
expect(result?.extras.map((extra) => extra.id)).toEqual(["extra-b"]);
|
||||
expect(mocks.reads).toContain(extraSectionRows);
|
||||
expect(mocks.reads).not.toContain(weaponConditions);
|
||||
expect(mocks.reads).not.toContain(teamConditions);
|
||||
});
|
||||
|
||||
it("preserves full-guide reads for existing preview and validation callers", async () => {
|
||||
const result = await getAdminGuideContent("test");
|
||||
for (const table of [weaponConditions, artifactConditions, constellationConditions, teamConditions, extraSectionRows]) {
|
||||
expect(mocks.reads).toContain(table);
|
||||
}
|
||||
expect(result?.extras).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
+35
-16
@@ -1,7 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, asc, desc, eq, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import * as z from "zod";
|
||||
|
||||
import { getDb } from "@/db";
|
||||
import {
|
||||
@@ -27,11 +26,10 @@ import {
|
||||
weaponRecommendations,
|
||||
weaponConditions,
|
||||
} from "@/db/schema";
|
||||
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
|
||||
import { publicMediaUrl } from "@/lib/media/storage";
|
||||
import { aliasesByItem } from "@/lib/glossary";
|
||||
import type { PosterBlendMode } from "@/components/public/material-poster";
|
||||
|
||||
const catalogVersionSchema = z.object({ version: z.string() });
|
||||
const posterBlendModes = new Set(["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"]);
|
||||
|
||||
// Leave most of the per-pod pool available to other visitors during cache misses.
|
||||
@@ -121,17 +119,7 @@ export async function getActiveCatalog() {
|
||||
if (!characters.length) return null;
|
||||
const groupedAliases = aliasesByItem(aliases);
|
||||
const materialByKey = new Map(materials.map((item) => [item.key, item]));
|
||||
let version = "unknown";
|
||||
try {
|
||||
const storage = await getMediaStorage();
|
||||
version = catalogVersionSchema.parse(
|
||||
await storage.file("catalog/current.json").json(),
|
||||
).version;
|
||||
} catch (error) {
|
||||
console.error("Unable to read the active catalog version", error);
|
||||
}
|
||||
return {
|
||||
version,
|
||||
characters: characters.map((item) => ({ ...catalogCharacterWithUrls(item, materialByKey), aliases: groupedAliases.character.get(item.key) ?? [] })),
|
||||
weapons: weapons.map((item) => ({ ...item, aliases: groupedAliases.weapon.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })),
|
||||
artifacts: artifacts.map((item) => ({ ...item, aliases: groupedAliases.artifact.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })),
|
||||
@@ -139,6 +127,37 @@ export async function getActiveCatalog() {
|
||||
};
|
||||
}
|
||||
|
||||
// Picker data deliberately excludes character descriptions, talents and materials.
|
||||
// Editors obtain the selected character's details from their guide content.
|
||||
export async function getCatalogOptions() {
|
||||
const [characters, weapons, artifacts, aliases] = await runWithConcurrency([
|
||||
() => getDb().select({
|
||||
key: catalogCharacters.key,
|
||||
name: catalogCharacters.name,
|
||||
rarity: catalogCharacters.rarity,
|
||||
weaponType: catalogCharacters.weaponType,
|
||||
element: catalogCharacters.element,
|
||||
imageKey: catalogCharacters.imageKey,
|
||||
elementIconKey: catalogCharacters.elementIconKey,
|
||||
}).from(catalogCharacters).orderBy(asc(catalogCharacters.name)),
|
||||
() => getDb().select().from(catalogWeapons).orderBy(asc(catalogWeapons.name)),
|
||||
() => getDb().select().from(catalogArtifacts).orderBy(asc(catalogArtifacts.name)),
|
||||
() => getDb().select().from(glossaryAliases),
|
||||
]);
|
||||
if (!characters.length) return null;
|
||||
const groupedAliases = aliasesByItem(aliases);
|
||||
return {
|
||||
characters: characters.map((item) => ({
|
||||
...item,
|
||||
imageUrl: publicMediaUrl(item.imageKey),
|
||||
elementIconUrl: item.elementIconKey ? publicMediaUrl(item.elementIconKey) : null,
|
||||
aliases: groupedAliases.character.get(item.key) ?? [],
|
||||
})),
|
||||
weapons: weapons.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey), aliases: groupedAliases.weapon.get(item.key) ?? [] })),
|
||||
artifacts: artifacts.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey), aliases: groupedAliases.artifact.get(item.key) ?? [] })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listGuideCards(includeTrash = false) {
|
||||
const rows = await getDb()
|
||||
.select({
|
||||
@@ -375,7 +394,7 @@ async function getGuideContent(slug: string, publicOnly: boolean, selectedSectio
|
||||
if (!row) return null;
|
||||
const availableSections = publicOnly ? await populatedSections(row.guide.id) : undefined;
|
||||
if (publicOnly && selectedSection !== "overview" && !availableSections?.some((section) => section.slug === selectedSection)) return null;
|
||||
const wants = (section: string) => !publicOnly || selectedSection === section;
|
||||
const wants = (section: string) => selectedSection === undefined || selectedSection === section;
|
||||
const materialKeys = [...new Set(
|
||||
[...row.character.ascension, ...row.character.leveling].flatMap((group) => group.items.map((item) => item.key)),
|
||||
)];
|
||||
@@ -506,8 +525,8 @@ export function getPublicGuide(slug: string, section = "overview") {
|
||||
return getGuideContent(slug, true, section);
|
||||
}
|
||||
|
||||
export function getAdminGuideContent(slug: string) {
|
||||
return getGuideContent(slug, false);
|
||||
export function getAdminGuideContent(slug: string, section?: string) {
|
||||
return getGuideContent(slug, false, section);
|
||||
}
|
||||
|
||||
export async function getAdminGuideContentById(guideId: string) {
|
||||
|
||||
Reference in New Issue
Block a user