cm_bot_v2/web/lib/api.ts
yiekheng 6bb85222d1 perf(web): server-side pagination + infinite-scroll for accounts/users
For 3k+ row deployments, returning the full table in one shot is the
bottleneck — the JSON payload alone is hundreds of KB and the client
mounts thousands of EditableCell instances on every visit. Pagination
with auto-fetch on scroll shrinks both the wire payload and the initial
render to a single page (200 rows).

Server (app/cm_api.py):
- /acc/ and /user/ accept ?limit, ?offset, ?prefix, ?dir, plus ?sort on
  /user/ (f_username | last_update_time). Defaults: limit=200 (capped at
  1000), offset=0, dir=desc.
- ORDER BY done in SQL with prefix-priority: rows whose username starts
  with the configured CM_PREFIX_PATTERN come first, then asc/desc by the
  sort column. The 'dir' value is whitelisted to ASC|DESC before string
  interpolation; everything else goes through parameterised binding.
- Schema verification (verify_tables_once) deferred to first request via
  a Flask before_request hook — keeps create_app() free of MySQL touches
  so unit tests + gunicorn preload still work without a live DB.

Web client:
- web/lib/api.ts: getAccountsPage / getUsersPage return { rows, hasMore }.
  hasMore = (rows.length === PAGE_SIZE), so the client knows when to
  stop fetching. Each page is its own Next.js cache entry (the URL is
  the cache key) — caching from the previous commit still applies.
- web/app/actions.ts: loadMoreAccounts / loadMoreUsers Server Actions
  for next-page requests; refreshAccounts / refreshUsers force-evict the
  cache via revalidateTag before refetching page 1.
- web/app/page.tsx + users/page.tsx: only fetch the first page now.
- web/components/{accounts,users}-table.tsx: rewrote state model. Rows
  accumulate as the user scrolls. An IntersectionObserver on a sentinel
  div near the bottom triggers loadMore when it enters the viewport
  (300px rootMargin so the next page starts loading before the user
  reaches the end). useOptimistic wraps the accumulated rows for in-
  flight edits; on success the row is committed locally so the change
  survives even though we no longer router.refresh.
- Sort toggle now refetches from page 1 with the new dir/sort param.
  Local sort over a partial set would be inconsistent.
- Mutations: delete filters from local state; create + refresh both
  reset to page 1 so the row appears in its sorted position.
- Header count shows '<loaded>+' when more pages exist so the operator
  knows what they're seeing isn't the full table.

Removed AutoRefresh:
- web/app/layout.tsx no longer mounts AutoRefresh.
- web/components/auto-refresh.tsx deleted.
- Reason: router.refresh every 30s would yank the user back to page 1
  every time, losing scroll position and accumulated rows. Manual
  Refresh button replaces it (now wired to refreshAccounts/refreshUsers
  which evict cache + refetch).

Tests: deferred verify_tables_once() means tests.test_bot_cli's
CreateAppFactoryTests pass without DB env vars again. All 38 existing
tests pass.
2026-05-03 11:29:34 +08:00

95 lines
2.9 KiB
TypeScript

import type { Acc, User } from "./types";
const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000";
// Tab-switch responses come from the Next.js data cache for this many
// seconds before re-fetching. Mutations call revalidateTag() to evict
// the cached entry and force the next read to hit MySQL.
const CACHE_REVALIDATE_SECONDS = 30;
export const ACCOUNTS_TAG = "accounts";
export const USERS_TAG = "users";
// Page size used for both initial server-side fetch and infinite scroll
// on the client. 200 keeps each cached payload under ~50KB and renders
// in well under one frame even on phones.
export const PAGE_SIZE = 200;
export type Page<T> = { rows: T[]; hasMore: boolean };
export type AccountsPageOpts = {
offset?: number;
prefix?: string;
dir?: "asc" | "desc";
};
export type UsersPageOpts = {
offset?: number;
prefix?: string;
sort?: "f_username" | "last_update_time";
dir?: "asc" | "desc";
};
type FetchInit = {
method?: "GET" | "POST";
body?: unknown;
cache?: RequestCache;
next?: { revalidate?: number; tags?: string[] };
};
export async function fetchApi(path: string, options: FetchInit = {}): Promise<unknown> {
const url = `${API_BASE_URL}${path}`;
const init: RequestInit & { next?: FetchInit["next"] } = {
method: options.method ?? "GET",
headers: options.body ? { "content-type": "application/json" } : undefined,
body: options.body ? JSON.stringify(options.body) : undefined,
};
if (options.next) {
init.next = options.next;
} else {
init.cache = options.cache ?? "no-store";
}
const res = await fetch(url, init);
if (!res.ok) {
throw new Error(`API ${options.method ?? "GET"} ${path} failed: ${res.status}`);
}
return res.json();
}
function buildAccountsUrl(opts: AccountsPageOpts): string {
const { offset = 0, prefix = "", dir = "desc" } = opts;
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(offset),
dir,
});
if (prefix) params.set("prefix", prefix);
return `/acc/?${params.toString()}`;
}
function buildUsersUrl(opts: UsersPageOpts): string {
const { offset = 0, prefix = "", sort = "last_update_time", dir = "desc" } = opts;
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(offset),
sort,
dir,
});
if (prefix) params.set("prefix", prefix);
return `/user/?${params.toString()}`;
}
export async function getAccountsPage(opts: AccountsPageOpts = {}): Promise<Page<Acc>> {
const data = (await fetchApi(buildAccountsUrl(opts), {
next: { revalidate: CACHE_REVALIDATE_SECONDS, tags: [ACCOUNTS_TAG] },
})) as Acc[];
return { rows: data, hasMore: data.length === PAGE_SIZE };
}
export async function getUsersPage(opts: UsersPageOpts = {}): Promise<Page<User>> {
const data = (await fetchApi(buildUsersUrl(opts), {
next: { revalidate: CACHE_REVALIDATE_SECONDS, tags: [USERS_TAG] },
})) as User[];
return { rows: data, hasMore: data.length === PAGE_SIZE };
}