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

505 lines
18 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;
initialTotal: number;
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,
initialTotal,
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);
// 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>(
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);
setTotal(page.total);
} 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);
setTotal(page.total);
} 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);
setTotal(page.total);
})
.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));
setTotal((t) => Math.max(0, t - 1));
setDeleteTarget(null);
setToast({ type: "success", message: `Account ${deleted} deleted` });
} else {
setDeleteError(result.error);
}
}
if (rows.length === 0) {
return (
<div>
<PageHead
total={total}
loaded={0}
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
total={total}
loaded={optimistic.length}
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({
total,
loaded,
onRefresh,
refreshing,
onAdd,
}: {
total: number;
loaded: number;
onRefresh: () => void;
refreshing: boolean;
onAdd: () => void;
}) {
// 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>
<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">
{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
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>
);
}