cm_bot_v2/web/components/accounts-table.tsx
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

493 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useOptimistic, useRef, useState, useTransition } from "react";
import type { Acc } from "@/lib/types";
import {
deleteAccount,
loadMoreAccounts,
refreshAccounts,
updateAccount,
} from "@/app/actions";
import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog";
import CreateAccountDialog from "./create-account-dialog";
import Toast, { type ToastMessage } from "./toast";
type Props = {
initial: Acc[];
initialHasMore: boolean;
prefixPattern: string;
};
type SortDir = "asc" | "desc";
type OptimisticPatch = { username: string; field: keyof Acc; value: string };
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { bg: string; fg: string; label: string }> = {
"": { bg: "bg-zinc-100", fg: "text-zinc-600", label: "available" },
wait: { bg: "bg-amber-100", fg: "text-amber-700", label: "wait" },
done: { bg: "bg-emerald-100", fg: "text-emerald-700", label: "done" },
};
const v = map[status] ?? { bg: "bg-zinc-100", fg: "text-zinc-600", label: status || "—" };
return (
<span
className={`inline-flex items-center rounded-full ${v.bg} ${v.fg} px-2 py-0.5 text-[11px] font-medium`}
>
{v.label}
</span>
);
}
function DeleteButton({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={`Delete ${label}`}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-red-50 hover:text-red-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
>
<span aria-hidden="true" className="text-base leading-none">
×
</span>
</button>
);
}
export default function AccountsTable({
initial,
initialHasMore,
prefixPattern,
}: Props) {
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [editingKey, setEditingKey] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
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>(
rows,
(state, patch) =>
state.map((row) =>
row.username === patch.username ? { ...row, [patch.field]: patch.value } : row,
),
);
function saveCell(username: string, field: keyof Acc, value: string) {
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
startTransition(async () => {
applyOptimistic({ username, field, value });
const row = rows.find((r) => r.username === username);
if (!row) return resolve({ ok: false, error: "row not found" });
const next: Acc = { ...row, [field]: value };
const result = await updateAccount(next);
if (result.ok) {
setRows((prev) => prev.map((r) => (r.username === username ? next : r)));
resolve({ ok: true });
} else {
resolve({ ok: false, error: result.error });
}
});
});
}
async function refresh() {
setRefreshing(true);
try {
const page = await refreshAccounts({ prefix: prefixPattern, dir: sortDir });
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() {
if (!deleteTarget) return;
setDeleting(true);
setDeleteError(null);
const result = await deleteAccount(deleteTarget);
setDeleting(false);
if (result.ok) {
const deleted = deleteTarget;
setRows((prev) => prev.filter((r) => r.username !== deleted));
setDeleteTarget(null);
setToast({ type: "success", message: `Account ${deleted} deleted` });
} else {
setDeleteError(result.error);
}
}
if (rows.length === 0) {
return (
<div>
<PageHead
count={0}
loaded={0}
hasMore={false}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
/>
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
<p className="text-sm text-zinc-500">
No accounts yet. Click <span className="font-medium text-zinc-700">Add</span> above to create one manually, or wait for the monitor.
</p>
</div>
<CreateAccountDialog
open={createOpen}
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}
/>
</div>
);
}
return (
<div>
<PageHead
count={optimistic.length}
loaded={optimistic.length}
hasMore={hasMore}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
/>
{/* Desktop / tablet table */}
<div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block">
<table className="w-full table-fixed border-collapse">
<thead>
<tr className="bg-zinc-50/60">
<th className="w-[18%] px-5 py-3 text-left">
<button
type="button"
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"
>
Username
<span aria-hidden="true">{sortDir === "asc" ? "↑" : "↓"}</span>
</button>
</th>
<Th>Password</Th>
<Th className="w-[16%]">Status</Th>
<Th>Link</Th>
<th className="w-12 px-3 py-3" aria-hidden="true" />
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{optimistic.map((row) => {
const k = (f: string) => `${row.username}::${f}`;
return (
<tr key={row.username} className="transition-colors hover:bg-zinc-50/60">
<td className="px-5 py-3 align-middle font-mono text-[13px] font-semibold text-zinc-900">
{row.username}
</td>
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.password}
label={`password for ${row.username}`}
isCurrentlyEditing={editingKey === k("password")}
onEditStart={() => setEditingKey(k("password"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "password", v)}
/>
</td>
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.status}
label={`status for ${row.username}`}
isCurrentlyEditing={editingKey === k("status")}
onEditStart={() => setEditingKey(k("status"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "status", v)}
renderView={(v) => <StatusBadge status={v} />}
/>
</td>
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.link}
label={`link for ${row.username}`}
isCurrentlyEditing={editingKey === k("link")}
onEditStart={() => setEditingKey(k("link"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "link", v)}
/>
</td>
<td className="px-3 py-3 text-right align-middle">
<DeleteButton
label={row.username}
onClick={() => {
setDeleteError(null);
setDeleteTarget(row.username);
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="mt-6 space-y-3 sm:hidden">
{optimistic.map((row) => {
const k = (f: string) => `${row.username}::${f}`;
return (
<div key={row.username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<span className="shrink-0 font-mono text-base font-semibold text-zinc-900">
{row.username}
</span>
<div className="min-w-0 flex-1">
<EditableCell
value={row.status}
label={`status for ${row.username}`}
isCurrentlyEditing={editingKey === k("status")}
onEditStart={() => setEditingKey(k("status"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "status", v)}
renderView={(v) => <StatusBadge status={v} />}
/>
</div>
</div>
<DeleteButton
label={row.username}
onClick={() => {
setDeleteError(null);
setDeleteTarget(row.username);
}}
/>
</div>
<dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
<CardRow label="Password">
<EditableCell
value={row.password}
label={`password for ${row.username}`}
isCurrentlyEditing={editingKey === k("password")}
onEditStart={() => setEditingKey(k("password"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "password", v)}
/>
</CardRow>
<CardRow label="Link">
<EditableCell
value={row.link}
label={`link for ${row.username}`}
isCurrentlyEditing={editingKey === k("link")}
onEditStart={() => setEditingKey(k("link"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "link", v)}
/>
</CardRow>
</dl>
</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
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={async (name) => {
setToast({ type: "success", message: `Account ${name} created` });
await refresh();
}}
prefixPattern={prefixPattern}
/>
<Toast toast={toast} onDismiss={() => setToast(null)} />
<ConfirmDialog
open={!!deleteTarget}
onCancel={() => {
if (!deleting) setDeleteTarget(null);
}}
onConfirm={confirmDelete}
title={`Delete ${deleteTarget ?? ""}?`}
message={
<>
<p>
Permanently remove{" "}
<code className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-xs text-zinc-700">
{deleteTarget}
</code>{" "}
from the accounts table. This action cannot be undone.
</p>
{deleteError && (
<p className="mt-3 font-mono text-[11px] text-red-600" role="alert">
{deleteError}
</p>
)}
</>
}
confirmLabel="Delete"
destructive
pending={deleting}
/>
</div>
);
}
function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<th
className={`px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500 ${className}`}
>
{children}
</th>
);
}
function CardRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[max-content_1fr] items-baseline gap-x-4">
<dt className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
{label}
</dt>
<dd className="min-w-0">{children}</dd>
</div>
);
}
function PageHead({
count,
loaded,
hasMore,
onRefresh,
refreshing,
onAdd,
}: {
count: 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;
return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<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">
Accounts
<span className="ml-2 align-middle text-base font-medium text-zinc-400">
{count}
{showHasMore && <span className="text-zinc-300">+</span>}
</span>
</h1>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onRefresh}
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-medium text-zinc-700 shadow-sm ring-1 ring-zinc-200 transition-colors hover:bg-zinc-50 hover:text-zinc-900 disabled:opacity-60"
>
<span aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}>
</span>
Refresh
</button>
<button
type="button"
onClick={onAdd}
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700"
>
<span aria-hidden="true">+</span>
Add
</button>
</div>
</div>
);
}