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.
This commit is contained in:
yiekheng 2026-05-03 11:29:34 +08:00
parent 549e9b5939
commit 6bb85222d1
9 changed files with 460 additions and 175 deletions

View File

@ -25,6 +25,19 @@ class CM_API:
# default that becomes an attack surface if a host port is ever # default that becomes an attack surface if a host port is ever
# accidentally re-exposed. # accidentally re-exposed.
self._register_routes() self._register_routes()
# Schema verification is deferred to the first request so that
# constructing the WSGI app (e.g., in tests, or via gunicorn's
# preload phase before MySQL is reachable) doesn't require the
# DB to be up. The first request hits this hook, validates the
# schema, and flips the latch — subsequent requests skip it.
self._schema_verified = False
self.app.before_request(self._verify_schema_once)
def _verify_schema_once(self):
if self._schema_verified:
return
verify_tables_once()
self._schema_verified = True
def _get_database_connection(self): def _get_database_connection(self):
"""Return a DB handle backed by the shared connection pool. """Return a DB handle backed by the shared connection pool.
@ -78,14 +91,39 @@ class CM_API:
try: try:
if username: if username:
query = "SELECT username, password, status, link FROM acc WHERE username = %s" query = "SELECT username, password, status, link FROM acc WHERE username = %s"
query_params = [username] results = db.query(query, [username])
else:
query = "SELECT username, password, status, link FROM acc"
query_params = []
results = db.query(query, query_params)
return jsonify(results) return jsonify(results)
# Listing path — pagination + prefix-priority sort.
try:
limit = max(1, min(int(request.args.get('limit', 200)), 1000))
offset = max(0, int(request.args.get('offset', 0)))
except (TypeError, ValueError):
return jsonify({"error": "limit and offset must be integers"}), 400
prefix = (request.args.get('prefix') or '').strip()
# Whitelist direction so it's safe to interpolate into the
# ORDER BY clause (parameterised binding doesn't apply to
# column names or sort directions).
direction = 'ASC' if request.args.get('dir', 'desc').lower() == 'asc' else 'DESC'
if prefix:
query = (
"SELECT username, password, status, link FROM acc "
"ORDER BY (CASE WHEN username LIKE %s THEN 0 ELSE 1 END), "
f"username {direction} "
"LIMIT %s OFFSET %s"
)
params = [f"{prefix}%", limit, offset]
else:
query = (
"SELECT username, password, status, link FROM acc "
f"ORDER BY username {direction} "
"LIMIT %s OFFSET %s"
)
params = [limit, offset]
return jsonify(db.query(query, params))
except Exception as error: except Exception as error:
return self._handle_error(error, "Not Found"), 404 return self._handle_error(error, "Not Found"), 404
@ -97,14 +135,39 @@ class CM_API:
try: try:
if username: if username:
query = "SELECT f_username, f_password, t_username, t_password FROM user WHERE f_username = %s" query = "SELECT f_username, f_password, t_username, t_password FROM user WHERE f_username = %s"
query_params = [username] results = db.query(query, [username])
else:
query = "SELECT f_username, f_password, t_username, t_password, last_update_time FROM user"
query_params = []
results = db.query(query, query_params)
return jsonify(results) return jsonify(results)
try:
limit = max(1, min(int(request.args.get('limit', 200)), 1000))
offset = max(0, int(request.args.get('offset', 0)))
except (TypeError, ValueError):
return jsonify({"error": "limit and offset must be integers"}), 400
prefix = (request.args.get('prefix') or '').strip()
sort_arg = request.args.get('sort', 'last_update_time')
sort_col = 'f_username' if sort_arg == 'f_username' else 'last_update_time'
direction = 'ASC' if request.args.get('dir', 'desc').lower() == 'asc' else 'DESC'
if prefix:
query = (
"SELECT f_username, f_password, t_username, t_password, last_update_time "
"FROM user "
"ORDER BY (CASE WHEN f_username LIKE %s THEN 0 ELSE 1 END), "
f"{sort_col} {direction} "
"LIMIT %s OFFSET %s"
)
params = [f"{prefix}%", limit, offset]
else:
query = (
"SELECT f_username, f_password, t_username, t_password, last_update_time "
"FROM user "
f"ORDER BY {sort_col} {direction} "
"LIMIT %s OFFSET %s"
)
params = [limit, offset]
return jsonify(db.query(query, params))
except Exception as error: except Exception as error:
return self._handle_error(error, "Not Found"), 404 return self._handle_error(error, "Not Found"), 404
@ -298,13 +361,13 @@ class CM_API:
def create_app(): def create_app():
"""WSGI factory used by gunicorn (`app.cm_api:create_app()`). """WSGI factory used by gunicorn (`app.cm_api:create_app()`).
Returns the Flask app object so gunicorn can serve it. Validates the Returns the Flask app object. Schema verification runs lazily on the
schema once at boot (so a misconfigured DB fails fast) request-time first request (see CM_API._verify_schema_once) so the factory itself
handlers don't repeat the check. never touches MySQL keeps gunicorn's preload phase unaffected by a
momentarily-unavailable DB and lets unit tests construct the app
without DB env wiring.
""" """
app = CM_API().app return CM_API().app
verify_tables_once()
return app
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -1,14 +1,22 @@
"use server"; "use server";
import { revalidatePath, revalidateTag } from "next/cache"; import { revalidatePath, revalidateTag } from "next/cache";
import { ACCOUNTS_TAG, USERS_TAG, fetchApi } from "@/lib/api"; import {
import type { AccUpdate, UserUpdate } from "@/lib/types"; ACCOUNTS_TAG,
USERS_TAG,
fetchApi,
getAccountsPage,
getUsersPage,
type AccountsPageOpts,
type Page,
type UsersPageOpts,
} from "@/lib/api";
import type { Acc, AccUpdate, User, UserUpdate } from "@/lib/types";
export type ActionResult = { ok: true } | { ok: false; error: string }; export type ActionResult = { ok: true } | { ok: false; error: string };
// Each mutation evicts the matching tag so the next GET bypasses the // Each mutation evicts the matching tag so the next GET bypasses the
// 30s data cache and re-reads from MySQL. revalidatePath then tells // 30s data cache and re-reads from MySQL.
// Next.js to re-render the dependent route on the next request.
export async function updateAccount(data: AccUpdate): Promise<ActionResult> { export async function updateAccount(data: AccUpdate): Promise<ActionResult> {
try { try {
@ -75,3 +83,27 @@ export async function deleteUser(f_username: string): Promise<ActionResult> {
return { ok: false, error: err instanceof Error ? err.message : String(err) }; return { ok: false, error: err instanceof Error ? err.message : String(err) };
} }
} }
// ---- Pagination + force-refresh ----
export async function loadMoreAccounts(opts: AccountsPageOpts): Promise<Page<Acc>> {
return getAccountsPage(opts);
}
export async function loadMoreUsers(opts: UsersPageOpts): Promise<Page<User>> {
return getUsersPage(opts);
}
// Force-refresh evicts the cached tag before refetching the first page,
// so manual Refresh always returns DB-fresh data even if the cache is
// still warm.
export async function refreshAccounts(opts: AccountsPageOpts = {}): Promise<Page<Acc>> {
revalidateTag(ACCOUNTS_TAG);
return getAccountsPage({ ...opts, offset: 0 });
}
export async function refreshUsers(opts: UsersPageOpts = {}): Promise<Page<User>> {
revalidateTag(USERS_TAG);
return getUsersPage({ ...opts, offset: 0 });
}

View File

@ -1,7 +1,6 @@
import "./globals.css"; import "./globals.css";
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import Nav from "@/components/nav"; import Nav from "@/components/nav";
import AutoRefresh from "@/components/auto-refresh";
import { getSession } from "@/lib/auth"; import { getSession } from "@/lib/auth";
export const metadata: Metadata = { export const metadata: Metadata = {
@ -27,7 +26,6 @@ export default async function RootLayout({
<main className="mx-auto max-w-6xl px-4 pb-16 pt-8 sm:px-6 sm:pt-12"> <main className="mx-auto max-w-6xl px-4 pb-16 pt-8 sm:px-6 sm:pt-12">
{children} {children}
</main> </main>
<AutoRefresh />
</body> </body>
</html> </html>
); );

View File

@ -1,9 +1,15 @@
import { getAccounts } from "@/lib/api"; import { getAccountsPage } from "@/lib/api";
import AccountsTable from "@/components/accounts-table"; import AccountsTable from "@/components/accounts-table";
const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c"; const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c";
export default async function AccountsPage() { export default async function AccountsPage() {
const accounts = await getAccounts(); const page = await getAccountsPage({ prefix: PREFIX_PATTERN, dir: "desc" });
return <AccountsTable initial={accounts} prefixPattern={PREFIX_PATTERN} />; return (
<AccountsTable
initial={page.rows}
initialHasMore={page.hasMore}
prefixPattern={PREFIX_PATTERN}
/>
);
} }

View File

@ -1,9 +1,19 @@
import { getUsers } from "@/lib/api"; import { getUsersPage } from "@/lib/api";
import UsersTable from "@/components/users-table"; import UsersTable from "@/components/users-table";
const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c"; const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c";
export default async function UsersPage() { export default async function UsersPage() {
const users = await getUsers(); const page = await getUsersPage({
return <UsersTable initial={users} prefixPattern={PREFIX_PATTERN} />; prefix: PREFIX_PATTERN,
sort: "last_update_time",
dir: "desc",
});
return (
<UsersTable
initial={page.rows}
initialHasMore={page.hasMore}
prefixPattern={PREFIX_PATTERN}
/>
);
} }

View File

@ -1,30 +1,26 @@
"use client"; "use client";
import { useMemo, useOptimistic, useState, useTransition } from "react"; import { useEffect, useOptimistic, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import type { Acc } from "@/lib/types"; import type { Acc } from "@/lib/types";
import { deleteAccount, updateAccount } from "@/app/actions"; import {
deleteAccount,
loadMoreAccounts,
refreshAccounts,
updateAccount,
} from "@/app/actions";
import EditableCell from "./editable-cell"; import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog"; import ConfirmDialog from "./confirm-dialog";
import CreateAccountDialog from "./create-account-dialog"; import CreateAccountDialog from "./create-account-dialog";
import Toast, { type ToastMessage } from "./toast"; import Toast, { type ToastMessage } from "./toast";
type Props = { initial: Acc[]; prefixPattern: string }; type Props = {
initial: Acc[];
initialHasMore: boolean;
prefixPattern: string;
};
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
type OptimisticPatch = { username: string; field: keyof Acc; value: string }; type OptimisticPatch = { username: string; field: keyof Acc; value: string };
function sortAccounts(rows: Acc[], dir: SortDir, prefix: string): Acc[] {
return [...rows].sort((a, b) => {
const ap = a.username.startsWith(prefix);
const bp = b.username.startsWith(prefix);
if (ap && !bp) return -1;
if (!ap && bp) return 1;
return dir === "asc"
? a.username.localeCompare(b.username)
: b.username.localeCompare(a.username);
});
}
function StatusBadge({ status }: { status: string }) { function StatusBadge({ status }: { status: string }) {
const map: Record<string, { bg: string; fg: string; label: string }> = { const map: Record<string, { bg: string; fg: string; label: string }> = {
"": { bg: "bg-zinc-100", fg: "text-zinc-600", label: "available" }, "": { bg: "bg-zinc-100", fg: "text-zinc-600", label: "available" },
@ -62,51 +58,110 @@ function DeleteButton({
); );
} }
export default function AccountsTable({ initial, prefixPattern }: Props) { export default function AccountsTable({
const router = useRouter(); initial,
initialHasMore,
prefixPattern,
}: Props) {
const [sortDir, setSortDir] = useState<SortDir>("desc"); const [sortDir, setSortDir] = useState<SortDir>("desc");
const [editingKey, setEditingKey] = useState<string | null>(null); const [editingKey, setEditingKey] = useState<string | null>(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null); const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [toast, setToast] = useState<ToastMessage | null>(null); const [toast, setToast] = useState<ToastMessage | null>(null);
// Accumulated rows from initial server-side fetch + every loadMore.
const [rows, setRows] = useState<Acc[]>(initial);
const [hasMore, setHasMore] = useState<boolean>(initialHasMore);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>( const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
initial, rows,
(state, patch) => (state, patch) =>
state.map((row) => state.map((row) =>
row.username === patch.username ? { ...row, [patch.field]: patch.value } : row, row.username === patch.username ? { ...row, [patch.field]: patch.value } : row,
), ),
); );
const sorted = useMemo(
() => sortAccounts(optimistic, sortDir, prefixPattern),
[optimistic, sortDir, prefixPattern],
);
function saveCell(username: string, field: keyof Acc, value: string) { function saveCell(username: string, field: keyof Acc, value: string) {
return new Promise<{ ok: boolean; error?: string }>((resolve) => { return new Promise<{ ok: boolean; error?: string }>((resolve) => {
startTransition(async () => { startTransition(async () => {
applyOptimistic({ username, field, value }); applyOptimistic({ username, field, value });
const row = initial.find((r) => r.username === username); const row = rows.find((r) => r.username === username);
if (!row) return resolve({ ok: false, error: "row not found" }); if (!row) return resolve({ ok: false, error: "row not found" });
const next: Acc = { ...row, [field]: value }; const next: Acc = { ...row, [field]: value };
const result = await updateAccount(next); const result = await updateAccount(next);
resolve(result.ok ? { ok: true } : { ok: false, error: result.error }); if (result.ok) {
setRows((prev) => prev.map((r) => (r.username === username ? next : r)));
resolve({ ok: true });
} else {
resolve({ ok: false, error: result.error });
}
}); });
}); });
} }
function refresh() { async function refresh() {
setRefreshing(true); setRefreshing(true);
startTransition(() => { try {
router.refresh(); const page = await refreshAccounts({ prefix: prefixPattern, dir: sortDir });
setTimeout(() => setRefreshing(false), 400); setRows(page.rows);
}); setHasMore(page.hasMore);
} finally {
setRefreshing(false);
} }
}
async function changeSort(next: SortDir) {
if (next === sortDir) return;
setSortDir(next);
setLoadingMore(true);
try {
const page = await loadMoreAccounts({
offset: 0,
prefix: prefixPattern,
dir: next,
});
setRows(page.rows);
setHasMore(page.hasMore);
} finally {
setLoadingMore(false);
}
}
// Infinite scroll: when sentinel enters viewport, fetch the next page.
// 300px rootMargin so the next page starts loading before the user
// hits the bottom — feels seamless when scrolling fast.
useEffect(() => {
if (!hasMore || loadingMore || refreshing) return;
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
setLoadingMore(true);
loadMoreAccounts({
offset: rows.length,
prefix: prefixPattern,
dir: sortDir,
})
.then((page) => {
setRows((prev) => [...prev, ...page.rows]);
setHasMore(page.hasMore);
})
.catch((err) => console.error("loadMoreAccounts failed:", err))
.finally(() => setLoadingMore(false));
},
{ rootMargin: "300px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, loadingMore, refreshing, rows.length, prefixPattern, sortDir]);
async function confirmDelete() { async function confirmDelete() {
if (!deleteTarget) return; if (!deleteTarget) return;
@ -116,6 +171,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
setDeleting(false); setDeleting(false);
if (result.ok) { if (result.ok) {
const deleted = deleteTarget; const deleted = deleteTarget;
setRows((prev) => prev.filter((r) => r.username !== deleted));
setDeleteTarget(null); setDeleteTarget(null);
setToast({ type: "success", message: `Account ${deleted} deleted` }); setToast({ type: "success", message: `Account ${deleted} deleted` });
} else { } else {
@ -123,11 +179,13 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
} }
} }
if (initial.length === 0) { if (rows.length === 0) {
return ( return (
<div> <div>
<PageHead <PageHead
count={0} count={0}
loaded={0}
hasMore={false}
onRefresh={refresh} onRefresh={refresh}
refreshing={refreshing} refreshing={refreshing}
onAdd={() => setCreateOpen(true)} onAdd={() => setCreateOpen(true)}
@ -140,6 +198,12 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
<CreateAccountDialog <CreateAccountDialog
open={createOpen} open={createOpen}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}
onSuccess={async (name) => {
setToast({ type: "success", message: `Account ${name} created` });
// Force-refresh from page 1 so the new row appears in its
// sorted position. (We don't know where it ranks otherwise.)
await refresh();
}}
prefixPattern={prefixPattern} prefixPattern={prefixPattern}
/> />
</div> </div>
@ -150,6 +214,8 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
<div> <div>
<PageHead <PageHead
count={optimistic.length} count={optimistic.length}
loaded={optimistic.length}
hasMore={hasMore}
onRefresh={refresh} onRefresh={refresh}
refreshing={refreshing} refreshing={refreshing}
onAdd={() => setCreateOpen(true)} onAdd={() => setCreateOpen(true)}
@ -163,7 +229,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
<th className="w-[18%] px-5 py-3 text-left"> <th className="w-[18%] px-5 py-3 text-left">
<button <button
type="button" type="button"
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))} onClick={() => changeSort(sortDir === "asc" ? "desc" : "asc")}
className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider text-zinc-500 transition-colors hover:text-zinc-900" className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider text-zinc-500 transition-colors hover:text-zinc-900"
> >
Username Username
@ -177,7 +243,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-100"> <tbody className="divide-y divide-zinc-100">
{sorted.map((row) => { {optimistic.map((row) => {
const k = (f: string) => `${row.username}::${f}`; const k = (f: string) => `${row.username}::${f}`;
return ( return (
<tr key={row.username} className="transition-colors hover:bg-zinc-50/60"> <tr key={row.username} className="transition-colors hover:bg-zinc-50/60">
@ -233,7 +299,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
{/* Mobile cards */} {/* Mobile cards */}
<div className="mt-6 space-y-3 sm:hidden"> <div className="mt-6 space-y-3 sm:hidden">
{sorted.map((row) => { {optimistic.map((row) => {
const k = (f: string) => `${row.username}::${f}`; const k = (f: string) => `${row.username}::${f}`;
return ( return (
<div key={row.username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60"> <div key={row.username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
@ -289,12 +355,28 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
})} })}
</div> </div>
{/* Sentinel for infinite scroll. Hidden visually unless we're at
the bottom; the IntersectionObserver triggers loadMore as it
comes into view. */}
<div ref={sentinelRef} className="h-10" aria-hidden="true" />
{loadingMore && (
<p className="mt-4 text-center text-[11px] font-medium uppercase tracking-wider text-zinc-400">
Loading more
</p>
)}
{!hasMore && rows.length > 0 && (
<p className="mt-6 text-center text-[11px] text-zinc-400">
End of list {rows.length} accounts loaded
</p>
)}
<CreateAccountDialog <CreateAccountDialog
open={createOpen} open={createOpen}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}
onSuccess={(name) => onSuccess={async (name) => {
setToast({ type: "success", message: `Account ${name} created` }) setToast({ type: "success", message: `Account ${name} created` });
} await refresh();
}}
prefixPattern={prefixPattern} prefixPattern={prefixPattern}
/> />
@ -354,15 +436,22 @@ function CardRow({ label, children }: { label: string; children: React.ReactNode
function PageHead({ function PageHead({
count, count,
loaded,
hasMore,
onRefresh, onRefresh,
refreshing, refreshing,
onAdd, onAdd,
}: { }: {
count: number; count: number;
loaded: number;
hasMore: boolean;
onRefresh: () => void; onRefresh: () => void;
refreshing: boolean; refreshing: boolean;
onAdd: () => void; 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;
return ( return (
<div className="flex flex-wrap items-end justify-between gap-4"> <div className="flex flex-wrap items-end justify-between gap-4">
<div> <div>
@ -373,6 +462,7 @@ function PageHead({
Accounts Accounts
<span className="ml-2 align-middle text-base font-medium text-zinc-400"> <span className="ml-2 align-middle text-base font-medium text-zinc-400">
{count} {count}
{showHasMore && <span className="text-zinc-300">+</span>}
</span> </span>
</h1> </h1>
</div> </div>

View File

@ -1,24 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
/**
* Mounts a setInterval that calls router.refresh() every `intervalMs`.
* router.refresh() re-runs the matching Server Component fetch and
* patches the rendered output in no full page reload, no flicker.
*
* Renders nothing.
*/
export default function AutoRefresh({
intervalMs = 30_000,
}: {
intervalMs?: number;
}) {
const router = useRouter();
useEffect(() => {
const id = setInterval(() => router.refresh(), intervalMs);
return () => clearInterval(id);
}, [router, intervalMs]);
return null;
}

View File

@ -1,15 +1,23 @@
"use client"; "use client";
import { useMemo, useOptimistic, useState, useTransition } from "react"; import { useEffect, useOptimistic, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import type { User } from "@/lib/types"; import type { User } from "@/lib/types";
import { deleteUser, updateUser } from "@/app/actions"; import {
deleteUser,
loadMoreUsers,
refreshUsers,
updateUser,
} from "@/app/actions";
import EditableCell from "./editable-cell"; import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog"; import ConfirmDialog from "./confirm-dialog";
import CreateUserDialog from "./create-user-dialog"; import CreateUserDialog from "./create-user-dialog";
import Toast, { type ToastMessage } from "./toast"; import Toast, { type ToastMessage } from "./toast";
type Props = { initial: User[]; prefixPattern: string }; type Props = {
initial: User[];
initialHasMore: boolean;
prefixPattern: string;
};
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
type SortKey = "f_username" | "last_update_time"; type SortKey = "f_username" | "last_update_time";
type OptimisticPatch = { type OptimisticPatch = {
@ -18,29 +26,6 @@ type OptimisticPatch = {
value: string; value: string;
}; };
function timeOf(t: string | null) {
if (!t) return 0;
const ms = Date.parse(t);
return Number.isNaN(ms) ? 0 : ms;
}
function sortUsers(rows: User[], key: SortKey, dir: SortDir, prefix: string): User[] {
return [...rows].sort((a, b) => {
const ap = a.f_username.startsWith(prefix);
const bp = b.f_username.startsWith(prefix);
if (ap && !bp) return -1;
if (!ap && bp) return 1;
if (key === "f_username") {
return dir === "asc"
? a.f_username.localeCompare(b.f_username)
: b.f_username.localeCompare(a.f_username);
}
return dir === "asc"
? timeOf(a.last_update_time) - timeOf(b.last_update_time)
: timeOf(b.last_update_time) - timeOf(a.last_update_time);
});
}
function formatTime(t: string | null) { function formatTime(t: string | null) {
if (!t) return <em className="not-italic text-zinc-400"></em>; if (!t) return <em className="not-italic text-zinc-400"></em>;
const d = new Date(t); const d = new Date(t);
@ -74,32 +59,35 @@ function DeleteButton({
); );
} }
export default function UsersTable({ initial, prefixPattern }: Props) { export default function UsersTable({
const router = useRouter(); initial,
initialHasMore,
prefixPattern,
}: Props) {
const [sortKey, setSortKey] = useState<SortKey>("last_update_time"); const [sortKey, setSortKey] = useState<SortKey>("last_update_time");
const [sortDir, setSortDir] = useState<SortDir>("desc"); const [sortDir, setSortDir] = useState<SortDir>("desc");
const [editingKey, setEditingKey] = useState<string | null>(null); const [editingKey, setEditingKey] = useState<string | null>(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null); const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [toast, setToast] = useState<ToastMessage | null>(null); const [toast, setToast] = useState<ToastMessage | null>(null);
const [rows, setRows] = useState<User[]>(initial);
const [hasMore, setHasMore] = useState<boolean>(initialHasMore);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>( const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
initial, rows,
(state, patch) => (state, patch) =>
state.map((row) => state.map((row) =>
row.f_username === patch.f_username ? { ...row, [patch.field]: patch.value } : row, row.f_username === patch.f_username ? { ...row, [patch.field]: patch.value } : row,
), ),
); );
const sorted = useMemo(
() => sortUsers(optimistic, sortKey, sortDir, prefixPattern),
[optimistic, sortKey, sortDir, prefixPattern],
);
function saveCell( function saveCell(
f_username: string, f_username: string,
field: OptimisticPatch["field"], field: OptimisticPatch["field"],
@ -108,7 +96,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
return new Promise<{ ok: boolean; error?: string }>((resolve) => { return new Promise<{ ok: boolean; error?: string }>((resolve) => {
startTransition(async () => { startTransition(async () => {
applyOptimistic({ f_username, field, value }); applyOptimistic({ f_username, field, value });
const row = initial.find((r) => r.f_username === f_username); const row = rows.find((r) => r.f_username === f_username);
if (!row) return resolve({ ok: false, error: "row not found" }); if (!row) return resolve({ ok: false, error: "row not found" });
const next = { const next = {
f_username: row.f_username, f_username: row.f_username,
@ -118,27 +106,86 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
[field]: value, [field]: value,
}; };
const result = await updateUser(next); const result = await updateUser(next);
resolve(result.ok ? { ok: true } : { ok: false, error: result.error }); if (result.ok) {
setRows((prev) =>
prev.map((r) =>
r.f_username === f_username ? { ...r, [field]: value } : r,
),
);
resolve({ ok: true });
} else {
resolve({ ok: false, error: result.error });
}
}); });
}); });
} }
function refresh() { async function refresh() {
setRefreshing(true); setRefreshing(true);
startTransition(() => { try {
router.refresh(); const page = await refreshUsers({
setTimeout(() => setRefreshing(false), 400); prefix: prefixPattern,
sort: sortKey,
dir: sortDir,
}); });
setRows(page.rows);
setHasMore(page.hasMore);
} finally {
setRefreshing(false);
}
} }
function toggleSort(k: SortKey) { async function changeSort(nextKey: SortKey) {
if (sortKey === k) setSortDir((d) => (d === "asc" ? "desc" : "asc")); let nextDir: SortDir;
else { if (nextKey === sortKey) {
setSortKey(k); nextDir = sortDir === "asc" ? "desc" : "asc";
setSortDir("desc"); } else {
nextDir = "desc";
}
setSortKey(nextKey);
setSortDir(nextDir);
setLoadingMore(true);
try {
const page = await loadMoreUsers({
offset: 0,
prefix: prefixPattern,
sort: nextKey,
dir: nextDir,
});
setRows(page.rows);
setHasMore(page.hasMore);
} finally {
setLoadingMore(false);
} }
} }
useEffect(() => {
if (!hasMore || loadingMore || refreshing) return;
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
setLoadingMore(true);
loadMoreUsers({
offset: rows.length,
prefix: prefixPattern,
sort: sortKey,
dir: sortDir,
})
.then((page) => {
setRows((prev) => [...prev, ...page.rows]);
setHasMore(page.hasMore);
})
.catch((err) => console.error("loadMoreUsers failed:", err))
.finally(() => setLoadingMore(false));
},
{ rootMargin: "300px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, loadingMore, refreshing, rows.length, prefixPattern, sortKey, sortDir]);
async function confirmDelete() { async function confirmDelete() {
if (!deleteTarget) return; if (!deleteTarget) return;
setDeleting(true); setDeleting(true);
@ -147,6 +194,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
setDeleting(false); setDeleting(false);
if (result.ok) { if (result.ok) {
const deleted = deleteTarget; const deleted = deleteTarget;
setRows((prev) => prev.filter((r) => r.f_username !== deleted));
setDeleteTarget(null); setDeleteTarget(null);
setToast({ type: "success", message: `User ${deleted} deleted` }); setToast({ type: "success", message: `User ${deleted} deleted` });
} else { } else {
@ -159,7 +207,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
return ( return (
<button <button
type="button" type="button"
onClick={() => toggleSort(k)} onClick={() => changeSort(k)}
className={`inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider transition-colors ${ className={`inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider transition-colors ${
active ? "text-zinc-900" : "text-zinc-500 hover:text-zinc-900" active ? "text-zinc-900" : "text-zinc-500 hover:text-zinc-900"
}`} }`}
@ -170,11 +218,12 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
); );
} }
if (initial.length === 0) { if (rows.length === 0) {
return ( return (
<div> <div>
<PageHead <PageHead
count={0} count={0}
hasMore={false}
onRefresh={refresh} onRefresh={refresh}
refreshing={refreshing} refreshing={refreshing}
onAdd={() => setCreateOpen(true)} onAdd={() => setCreateOpen(true)}
@ -187,11 +236,11 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
<CreateUserDialog <CreateUserDialog
open={createOpen} open={createOpen}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}
onSuccess={(name) => onSuccess={async (name) => {
setToast({ type: "success", message: `User ${name} created` }) setToast({ type: "success", message: `User ${name} created` });
} await refresh();
}}
/> />
<Toast toast={toast} onDismiss={() => setToast(null)} /> <Toast toast={toast} onDismiss={() => setToast(null)} />
</div> </div>
); );
@ -201,6 +250,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
<div> <div>
<PageHead <PageHead
count={optimistic.length} count={optimistic.length}
hasMore={hasMore}
onRefresh={refresh} onRefresh={refresh}
refreshing={refreshing} refreshing={refreshing}
onAdd={() => setCreateOpen(true)} onAdd={() => setCreateOpen(true)}
@ -229,7 +279,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-100"> <tbody className="divide-y divide-zinc-100">
{sorted.map((row) => { {optimistic.map((row) => {
const k = (f: string) => `${row.f_username}::${f}`; const k = (f: string) => `${row.f_username}::${f}`;
return ( return (
<tr key={row.f_username} className="transition-colors hover:bg-zinc-50/60"> <tr key={row.f_username} className="transition-colors hover:bg-zinc-50/60">
@ -286,7 +336,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
</div> </div>
<div className="mt-6 space-y-3 sm:hidden"> <div className="mt-6 space-y-3 sm:hidden">
{sorted.map((row) => { {optimistic.map((row) => {
const k = (f: string) => `${row.f_username}::${f}`; const k = (f: string) => `${row.f_username}::${f}`;
return ( return (
<div key={row.f_username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60"> <div key={row.f_username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
@ -344,12 +394,25 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
})} })}
</div> </div>
<div ref={sentinelRef} className="h-10" aria-hidden="true" />
{loadingMore && (
<p className="mt-4 text-center text-[11px] font-medium uppercase tracking-wider text-zinc-400">
Loading more
</p>
)}
{!hasMore && rows.length > 0 && (
<p className="mt-6 text-center text-[11px] text-zinc-400">
End of list {rows.length} users loaded
</p>
)}
<CreateUserDialog <CreateUserDialog
open={createOpen} open={createOpen}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}
onSuccess={(name) => onSuccess={async (name) => {
setToast({ type: "success", message: `User ${name} created` }) setToast({ type: "success", message: `User ${name} created` });
} await refresh();
}}
/> />
<Toast toast={toast} onDismiss={() => setToast(null)} /> <Toast toast={toast} onDismiss={() => setToast(null)} />
@ -397,22 +460,28 @@ function MobileRow({ label, children }: { label: string; children: React.ReactNo
function PageHead({ function PageHead({
count, count,
hasMore,
onRefresh, onRefresh,
refreshing, refreshing,
onAdd, onAdd,
}: { }: {
count: number; count: number;
hasMore: boolean;
onRefresh: () => void; onRefresh: () => void;
refreshing: boolean; refreshing: boolean;
onAdd: () => void; onAdd: () => void;
}) { }) {
const showHasMore = hasMore && count > 0;
return ( return (
<div className="flex flex-wrap items-end justify-between gap-4"> <div className="flex flex-wrap items-end justify-between gap-4">
<div> <div>
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Table</p> <p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Table</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl"> <h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
Users Users
<span className="ml-2 align-middle text-base font-medium text-zinc-400">{count}</span> <span className="ml-2 align-middle text-base font-medium text-zinc-400">
{count}
{showHasMore && <span className="text-zinc-300">+</span>}
</span>
</h1> </h1>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -2,16 +2,34 @@ import type { Acc, User } from "./types";
const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000"; const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000";
// How long the Next.js data cache holds an /acc/ or /user/ response // Tab-switch responses come from the Next.js data cache for this many
// before considering it stale. Tab switches and auto-refreshes within // seconds before re-fetching. Mutations call revalidateTag() to evict
// this window are served from memory — no api-server / MySQL round-trip. // the cached entry and force the next read to hit MySQL.
// Mutations (update/create/delete) call revalidateTag() so the next
// request always sees fresh data after a write.
const CACHE_REVALIDATE_SECONDS = 30; const CACHE_REVALIDATE_SECONDS = 30;
export const ACCOUNTS_TAG = "accounts"; export const ACCOUNTS_TAG = "accounts";
export const USERS_TAG = "users"; 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 = { type FetchInit = {
method?: "GET" | "POST"; method?: "GET" | "POST";
body?: unknown; body?: unknown;
@ -38,16 +56,39 @@ export async function fetchApi(path: string, options: FetchInit = {}): Promise<u
return res.json(); return res.json();
} }
export async function getAccounts(): Promise<Acc[]> { function buildAccountsUrl(opts: AccountsPageOpts): string {
const data = await fetchApi("/acc/", { const { offset = 0, prefix = "", dir = "desc" } = opts;
next: { revalidate: CACHE_REVALIDATE_SECONDS, tags: [ACCOUNTS_TAG] }, const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(offset),
dir,
}); });
return data as Acc[]; if (prefix) params.set("prefix", prefix);
return `/acc/?${params.toString()}`;
} }
export async function getUsers(): Promise<User[]> { function buildUsersUrl(opts: UsersPageOpts): string {
const data = await fetchApi("/user/", { const { offset = 0, prefix = "", sort = "last_update_time", dir = "desc" } = opts;
next: { revalidate: CACHE_REVALIDATE_SECONDS, tags: [USERS_TAG] }, const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(offset),
sort,
dir,
}); });
return data as User[]; 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 };
} }