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.
This commit is contained in:
yiekheng 2026-05-03 11:38:38 +08:00
parent f485dc52aa
commit ee74ebda64
6 changed files with 70 additions and 31 deletions

View File

@ -122,7 +122,10 @@ class CM_API:
)
params = [limit, offset]
return jsonify(db.query(query, params))
rows = db.query(query, params)
count_rows = db.query("SELECT COUNT(*) AS c FROM acc", [])
total = int(count_rows[0]["c"]) if count_rows else 0
return jsonify({"rows": rows, "total": total})
except Exception as error:
return self._handle_error(error, "Not Found"), 404
@ -166,7 +169,10 @@ class CM_API:
)
params = [limit, offset]
return jsonify(db.query(query, params))
rows = db.query(query, params)
count_rows = db.query("SELECT COUNT(*) AS c FROM user", [])
total = int(count_rows[0]["c"]) if count_rows else 0
return jsonify({"rows": rows, "total": total})
except Exception as error:
return self._handle_error(error, "Not Found"), 404

View File

@ -9,6 +9,7 @@ export default async function AccountsPage() {
<AccountsTable
initial={page.rows}
initialHasMore={page.hasMore}
initialTotal={page.total}
prefixPattern={PREFIX_PATTERN}
/>
);

View File

@ -13,6 +13,7 @@ export default async function UsersPage() {
<UsersTable
initial={page.rows}
initialHasMore={page.hasMore}
initialTotal={page.total}
prefixPattern={PREFIX_PATTERN}
/>
);

View File

@ -16,6 +16,7 @@ import Toast, { type ToastMessage } from "./toast";
type Props = {
initial: Acc[];
initialHasMore: boolean;
initialTotal: number;
prefixPattern: string;
};
type SortDir = "asc" | "desc";
@ -61,6 +62,7 @@ function DeleteButton({
export default function AccountsTable({
initial,
initialHasMore,
initialTotal,
prefixPattern,
}: Props) {
const [sortDir, setSortDir] = useState<SortDir>("desc");
@ -77,6 +79,11 @@ export default function AccountsTable({
// Accumulated rows from initial server-side fetch + every loadMore.
const [rows, setRows] = useState<Acc[]>(initial);
const [hasMore, setHasMore] = useState<boolean>(initialHasMore);
// Total row count in the DB; updated on every page fetch so it stays
// fresh as external writes (the cm99 monitor) add rows. Mutations
// adjust it locally so the header doesn't flash a stale count between
// the optimistic update and the next page-fresh count.
const [total, setTotal] = useState<number>(initialTotal);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
@ -111,6 +118,7 @@ export default function AccountsTable({
const page = await refreshAccounts({ prefix: prefixPattern, dir: sortDir });
setRows(page.rows);
setHasMore(page.hasMore);
setTotal(page.total);
} finally {
setRefreshing(false);
}
@ -128,6 +136,7 @@ export default function AccountsTable({
});
setRows(page.rows);
setHasMore(page.hasMore);
setTotal(page.total);
} finally {
setLoadingMore(false);
}
@ -153,6 +162,7 @@ export default function AccountsTable({
.then((page) => {
setRows((prev) => [...prev, ...page.rows]);
setHasMore(page.hasMore);
setTotal(page.total);
})
.catch((err) => console.error("loadMoreAccounts failed:", err))
.finally(() => setLoadingMore(false));
@ -172,6 +182,7 @@ export default function AccountsTable({
if (result.ok) {
const deleted = deleteTarget;
setRows((prev) => prev.filter((r) => r.username !== deleted));
setTotal((t) => Math.max(0, t - 1));
setDeleteTarget(null);
setToast({ type: "success", message: `Account ${deleted} deleted` });
} else {
@ -183,9 +194,8 @@ export default function AccountsTable({
return (
<div>
<PageHead
count={0}
total={total}
loaded={0}
hasMore={false}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
@ -213,9 +223,8 @@ export default function AccountsTable({
return (
<div>
<PageHead
count={optimistic.length}
total={total}
loaded={optimistic.length}
hasMore={hasMore}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
@ -435,23 +444,22 @@ function CardRow({ label, children }: { label: string; children: React.ReactNode
}
function PageHead({
count,
total,
loaded,
hasMore,
onRefresh,
refreshing,
onAdd,
}: {
count: number;
total: number;
loaded: number;
hasMore: boolean;
onRefresh: () => void;
refreshing: boolean;
onAdd: () => void;
}) {
// count == loaded for now; kept separate so a future "showing X of Y"
// header (when we surface a server-side total) drops in cleanly.
const showHasMore = hasMore && loaded > 0;
// total = COUNT(*) from the API; loaded = how many rows the user has
// scrolled in so far. Show the partial count only while it differs
// from the total, so a fully-scrolled list reads cleanly.
const showLoaded = loaded > 0 && loaded < total;
return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
@ -461,10 +469,14 @@ function PageHead({
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
Accounts
<span className="ml-2 align-middle text-base font-medium text-zinc-400">
{count}
{showHasMore && <span className="text-zinc-300">+</span>}
{total}
</span>
</h1>
{showLoaded && (
<p className="mt-1 text-[11px] text-zinc-400">
Showing {loaded} of {total} keep scrolling to load more
</p>
)}
</div>
<div className="flex items-center gap-2">
<button

View File

@ -16,6 +16,7 @@ import Toast, { type ToastMessage } from "./toast";
type Props = {
initial: User[];
initialHasMore: boolean;
initialTotal: number;
prefixPattern: string;
};
type SortDir = "asc" | "desc";
@ -62,6 +63,7 @@ function DeleteButton({
export default function UsersTable({
initial,
initialHasMore,
initialTotal,
prefixPattern,
}: Props) {
const [sortKey, setSortKey] = useState<SortKey>("last_update_time");
@ -78,6 +80,7 @@ export default function UsersTable({
const [rows, setRows] = useState<User[]>(initial);
const [hasMore, setHasMore] = useState<boolean>(initialHasMore);
const [total, setTotal] = useState<number>(initialTotal);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
@ -130,6 +133,7 @@ export default function UsersTable({
});
setRows(page.rows);
setHasMore(page.hasMore);
setTotal(page.total);
} finally {
setRefreshing(false);
}
@ -154,6 +158,7 @@ export default function UsersTable({
});
setRows(page.rows);
setHasMore(page.hasMore);
setTotal(page.total);
} finally {
setLoadingMore(false);
}
@ -176,6 +181,7 @@ export default function UsersTable({
.then((page) => {
setRows((prev) => [...prev, ...page.rows]);
setHasMore(page.hasMore);
setTotal(page.total);
})
.catch((err) => console.error("loadMoreUsers failed:", err))
.finally(() => setLoadingMore(false));
@ -195,6 +201,7 @@ export default function UsersTable({
if (result.ok) {
const deleted = deleteTarget;
setRows((prev) => prev.filter((r) => r.f_username !== deleted));
setTotal((t) => Math.max(0, t - 1));
setDeleteTarget(null);
setToast({ type: "success", message: `User ${deleted} deleted` });
} else {
@ -222,8 +229,8 @@ export default function UsersTable({
return (
<div>
<PageHead
count={0}
hasMore={false}
total={total}
loaded={0}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
@ -249,8 +256,8 @@ export default function UsersTable({
return (
<div>
<PageHead
count={optimistic.length}
hasMore={hasMore}
total={total}
loaded={optimistic.length}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
@ -459,19 +466,19 @@ function MobileRow({ label, children }: { label: string; children: React.ReactNo
}
function PageHead({
count,
hasMore,
total,
loaded,
onRefresh,
refreshing,
onAdd,
}: {
count: number;
hasMore: boolean;
total: number;
loaded: number;
onRefresh: () => void;
refreshing: boolean;
onAdd: () => void;
}) {
const showHasMore = hasMore && count > 0;
const showLoaded = loaded > 0 && loaded < total;
return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
@ -479,10 +486,14 @@ function PageHead({
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
Users
<span className="ml-2 align-middle text-base font-medium text-zinc-400">
{count}
{showHasMore && <span className="text-zinc-300">+</span>}
{total}
</span>
</h1>
{showLoaded && (
<p className="mt-1 text-[11px] text-zinc-400">
Showing {loaded} of {total} keep scrolling to load more
</p>
)}
</div>
<div className="flex items-center gap-2">
<button

View File

@ -15,7 +15,7 @@ export const USERS_TAG = "users";
// in well under one frame even on phones.
export const PAGE_SIZE = 200;
export type Page<T> = { rows: T[]; hasMore: boolean };
export type Page<T> = { rows: T[]; hasMore: boolean; total: number };
export type AccountsPageOpts = {
offset?: number;
@ -82,13 +82,21 @@ function buildUsersUrl(opts: UsersPageOpts): string {
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 };
})) 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 User[];
return { rows: data, hasMore: data.length === PAGE_SIZE };
})) as { rows: User[]; total: number };
return {
rows: data.rows,
hasMore: data.rows.length === PAGE_SIZE,
total: data.total,
};
}