From ee74ebda64e9f2fc83cd6cc7ca73b85ccb272ade Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 3 May 2026 11:38:38 +0800 Subject: [PATCH] feat(web): show real DB total in table header (replaces '200+') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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 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 '' 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. --- app/cm_api.py | 10 ++++++-- web/app/page.tsx | 1 + web/app/users/page.tsx | 1 + web/components/accounts-table.tsx | 38 ++++++++++++++++++++----------- web/components/users-table.tsx | 33 ++++++++++++++++++--------- web/lib/api.ts | 18 +++++++++++---- 6 files changed, 70 insertions(+), 31 deletions(-) diff --git a/app/cm_api.py b/app/cm_api.py index bea4d1b..05c8dc4 100644 --- a/app/cm_api.py +++ b/app/cm_api.py @@ -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 diff --git a/web/app/page.tsx b/web/app/page.tsx index dc7a7fd..7a11641 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -9,6 +9,7 @@ export default async function AccountsPage() { ); diff --git a/web/app/users/page.tsx b/web/app/users/page.tsx index d5c9a3d..c085485 100644 --- a/web/app/users/page.tsx +++ b/web/app/users/page.tsx @@ -13,6 +13,7 @@ export default async function UsersPage() { ); diff --git a/web/components/accounts-table.tsx b/web/components/accounts-table.tsx index 66f8025..f4d89ee 100644 --- a/web/components/accounts-table.tsx +++ b/web/components/accounts-table.tsx @@ -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("desc"); @@ -77,6 +79,11 @@ export default function AccountsTable({ // Accumulated rows from initial server-side fetch + every loadMore. const [rows, setRows] = useState(initial); const [hasMore, setHasMore] = useState(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(initialTotal); const sentinelRef = useRef(null); const [optimistic, applyOptimistic] = useOptimistic( @@ -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 (
setCreateOpen(true)} @@ -213,9 +223,8 @@ export default function AccountsTable({ return (
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 (
@@ -461,10 +469,14 @@ function PageHead({

Accounts - {count} - {showHasMore && +} + {total}

+ {showLoaded && ( +

+ Showing {loaded} of {total} — keep scrolling to load more +

+ )}