cm_bot_v2/web/lib/api.ts
yiekheng ee74ebda64 feat(web): show real DB total in table header (replaces '200+')
The header used to show '200+' once the user had loaded a partial set
of pages — opaque, useless for an operator who actually needs to know
'how many accounts are in the system right now'.

Server (app/cm_api.py):
- /acc/ and /user/ list responses now wrap the rows alongside a
  COUNT(*) of the table: { rows: [...], total: N }. The single-row
  /acc/<username> path is unchanged (still returns Acc[] with one row).
- Each list request issues both queries (the page SELECT and the COUNT)
  on the same pooled connection. COUNT(*) on a 3k-row table is sub-ms;
  even when the cache misses, total request latency stays well under
  20ms on warm-cache MySQL.

Web client:
- web/lib/api.ts: Page<T> gains a  field; getAccountsPage and
  getUsersPage parse the new wrapped response.
- web/app/page.tsx + users/page.tsx: pass page.total down as
  initialTotal.
- web/components/{accounts,users}-table.tsx: hold total in state, sync
  it from every page fetch (initial, loadMore, sort change, force
  refresh) so cm99 monitor inserts during the session bump it correctly.
  Delete decrements it by 1 immediately so the header doesn't lie
  between the optimistic delete and the next refresh.
- PageHead now shows '<total>' as the big number. When loaded < total,
  a small zinc-400 line below reads 'Showing X of N — keep scrolling
  to load more'. Once the user reaches the end, the line goes away.

No new round trips for the count: it piggybacks on the same /acc/?...
or /user/?... request that already fetches the page. The 30s cache
covers the count too — so tab switches still don't hit MySQL.
2026-05-03 11:38:38 +08:00

103 lines
3.1 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; total: number };
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 { rows: Acc[]; total: number };
return {
rows: data.rows,
hasMore: data.rows.length === PAGE_SIZE,
total: data.total,
};
}
export async function getUsersPage(opts: UsersPageOpts = {}): Promise<Page<User>> {
const data = (await fetchApi(buildUsersUrl(opts), {
next: { revalidate: CACHE_REVALIDATE_SECONDS, tags: [USERS_TAG] },
})) as { rows: User[]; total: number };
return {
rows: data.rows,
hasMore: data.rows.length === PAGE_SIZE,
total: data.total,
};
}