Files
AgentHub/client/src/pages/Sessions.tsx
T
nntrivi2001 c8ea18f594 fix(client): a developer's Sessions page no longer asks for remote sources (403)
/api/remote-sources is STAFF_ONLY; Sessions loaded its labels for every
viewer, so each developer page load logged a 403.
2026-10-12 14:19:42 +07:00

742 lines
30 KiB
TypeScript

/**
* @file Sessions.tsx
* @description Displays a list of all recorded sessions with filtering, searching, and pagination features; a row's right-click opens `SessionRowMenu`. Sessions are updated in real-time based on events received from the event bus.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `../lib/api`
* - `../lib/eventBus`
* - `../lib/dataScope`
* - `../components/StatusBadge`
* - `../components/EmptyState`
* - `../components/Skeleton`
* - `../lib/format`
* - `../lib/types`
*
* ## Public surface
* - `Sessions` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **Sessions**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import { useEffect, useMemo, useRef, useState, useCallback, useSyncExternalStore } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
FolderOpen,
Search,
ChevronRight,
RefreshCw,
SortDesc,
SortAsc,
Play,
Server,
Download,
AlertCircle,
} from "lucide-react";
import { api } from "../lib/api";
import type { RemoteSource, RunHandle } from "../lib/api";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
import { useSpin } from "../hooks/useSpin";
import { useAiTools } from "../hooks/useAiTools";
import { useLanes } from "../hooks/useLanes";
import { toast } from "../lib/toast";
import { downloadText, exportName, toCsv } from "../lib/exportData";
import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import { SessionStatusBadge } from "../components/StatusBadge";
import { SessionToolBadge } from "../components/SessionToolBadge";
import { EmptyState } from "../components/EmptyState";
import { TableRowSkeleton } from "../components/Skeleton";
import { useContextMenu } from "../components/ContextMenu";
import { SessionRowMenu, workspaceHref } from "../components/SessionRowMenu";
import { formatDateTime, formatDuration, truncate, fmtCost, isoOrUndefined } from "../lib/format";
import { effectiveSessionStatus, sessionAwaitingReason } from "../lib/types";
import type { Session, DashboardEvent } from "../lib/types";
import { usePermissions } from "../components/AuthGate";
const PAGE_SIZE = 10;
export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
const navigate = useNavigate();
const rowCtx = useContextMenu<Session>();
const { t } = useTranslation("sessions");
const { tools } = useAiTools();
const { lanes } = useLanes();
// Filters, sort and page live in the URL (replace, no history spam), so a
// reload, the back button and a shared link all land on the same view.
const [params, setParams] = useSearchParams();
const filter = params.get("status") ?? "";
const search = params.get("q") ?? "";
const cwd = params.get("cwd") ?? "";
const tool = params.get("tool") ?? "";
const lane = params.get("lane") ?? "";
const from = params.get("from") ?? "";
const to = params.get("to") ?? "";
const sortBy = params.get("sort") ?? "time";
const sortDesc = params.get("asc") !== "1";
const page = Math.max(0, parseInt(params.get("page") ?? "0", 10) || 0);
/** Writes params; any change but the page itself goes back to page 0 in the
* SAME update, so one filter change is one load. */
const update = useCallback(
(patch: Record<string, string | null>) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
for (const [k, v] of Object.entries(patch)) {
if (v) next.set(k, v);
else next.delete(k);
}
if (!("page" in patch)) next.delete("page");
return next;
},
{ replace: true }
),
[setParams]
);
const setPage = (fn: (p: number) => number) => update({ page: String(fn(page) || "") });
const [sessions, setSessions] = useState<Session[]>([]);
const [total, setTotal] = useState(0);
// `searchInput` is what the user types; `q` in the URL is the debounced value
// actually sent to the server.
const [searchInput, setSearchInput] = useState(search);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const [directories, setDirectories] = useState<string[]>([]);
// Global data scope (which source machines to show). Included in `load`'s deps
// so switching scope re-fetches; the actual `sources` param is injected by the
// api layer (see lib/api.ts applyScope).
const [scope] = useDataScope();
// source id → label, so remote-origin rows show a friendly badge.
const [sourceLabels, setSourceLabels] = useState<Map<string, string>>(() => new Map());
// Live dashboard runs, so a row a pane holds links straight to that pane.
const [runs, setRuns] = useState<RunHandle[]>([]);
const FILTER_OPTIONS: Array<{ label: string; value: string }> = [
{ label: t("filterAll"), value: "" },
{ label: t("filterActive"), value: "active" },
{ label: t("filterWaiting"), value: "waiting" },
{ label: t("filterCompleted"), value: "completed" },
{ label: t("filterError"), value: "error" },
{ label: t("filterAbandoned"), value: "abandoned" },
];
// Debounce the search input → 300 ms after the user stops typing, the
// committed value flips and triggers a fresh fetch.
useEffect(() => {
const id = window.setTimeout(() => {
if (searchInput.trim() !== search) update({ q: searchInput.trim() || null });
}, 300);
return () => window.clearTimeout(id);
}, [searchInput, search, update]);
// `?q=` changed from outside (a link to plain /sessions, back/forward): the
// box follows, so the debounce above never writes the old text back.
useEffect(() => {
setSearchInput((cur) => (cur.trim() === search ? cur : search));
}, [search]);
useEffect(() => {
api.sessions
.facets()
.then((res) => {
setDirectories(res.cwds);
})
.catch(console.error);
}, []);
// Load remote-source labels so remote-origin rows can show a friendly badge
// instead of a raw `src_…` id. Refreshed when a source's status changes.
// Staff only: a developer's /api/remote-sources is a 403 (STAFF_ONLY_PREFIXES).
const { seesAll } = usePermissions();
const loadSourceLabels = useCallback(() => {
if (!seesAll) return;
api.remoteSources
.list()
.then((res: { sources: RemoteSource[] }) => {
setSourceLabels(new Map(res.sources.map((s) => [s.id, s.label])));
})
.catch(() => undefined);
}, [seesAll]);
useEffect(() => {
loadSourceLabels();
}, [loadSourceLabels]);
/** The list query for the current filters (paging added by the caller). */
const query = useMemo(
() => ({
// "Waiting" is an overlay on active sessions (awaiting_input_since);
// the server filters it before the limit so paging stays exact.
status: filter === "waiting" ? "active" : filter || undefined,
awaiting: filter === "waiting" || undefined,
q: search || undefined,
cwd: cwd || undefined,
tool: tool || undefined,
lane: lane ? Number(lane) : undefined,
// A date input is a local calendar day: `to` covers that whole day.
from: isoOrUndefined(from && `${from}T00:00:00`),
to: isoOrUndefined(to && `${to}T23:59:59.999`),
sort_by: sortBy,
sort_desc: sortDesc,
}),
[filter, search, cwd, tool, lane, from, to, sortBy, sortDesc]
);
// Server-side pagination: only the visible page is fetched. A newer request
// (filter typed while a load is in flight) wins; older responses are dropped.
const loadSeq = useRef(0);
const load = useCallback(async () => {
const seq = ++loadSeq.current;
try {
const res = await api.sessions.list({
...query,
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
});
if (seq !== loadSeq.current) return;
setSessions(res.sessions);
setTotal(res.total);
setError(null);
} catch (err) {
if (seq === loadSeq.current) setError(err instanceof Error ? err.message : t("failedList"));
} finally {
if (seq === loadSeq.current) setLoading(false);
}
// `scope` is a dep so a data-scope change re-fetches; the api layer injects
// the matching `sources` param.
}, [query, page, scope, t]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
}, [load]);
// Pull live Run handles so a row a dashboard pane holds can open that pane.
const loadDashboardRuns = useCallback(() => {
api.run
.list()
.then((r) => setRuns(r.items))
.catch(() => undefined);
}, []);
useEffect(() => {
loadDashboardRuns();
}, [loadDashboardRuns]);
const [runChanged] = useState(() => laneChangeMatcher(runKeyOf));
useLiveRefresh(loadDashboardRuns, {
intervalMs: 15000,
// A pane starting/stopping re-broadcasts its lane (`lane_update`) — only
// then, not on every hook's re-broadcast; the server sends no run_status
// any more. The 15 s poll covers extra slots.
match: runChanged,
});
useLiveRefresh(load, {
match: (msg) => {
if (msg.type === "session_created" || msg.type === "session_updated") return true;
if (msg.type === "new_event") {
const ev = msg.data as DashboardEvent;
return ev.event_type === "Stop" || ev.event_type === "SessionEnd";
}
// A remote source finished syncing: new remote sessions may have landed.
return isRemoteDataRefreshMessage(msg);
},
});
useEffect(
() =>
eventBus.subscribe((msg) => {
if (msg.type === "remote_source.status") loadSourceLabels();
}),
[loadSourceLabels]
);
async function handleExport(kind: "csv" | "json") {
setExporting(true);
try {
const res = await api.sessions.list({ ...query, limit: 10000 });
const name = exportName("sessions", kind);
if (kind === "json")
downloadText(name, JSON.stringify(res.sessions, null, 2), "application/json");
else
downloadText(
name,
toCsv(res.sessions, [
"id",
"name",
"status",
"tool",
"cwd",
"model",
"started_at",
"ended_at",
"agent_count",
"cost",
"lane_id",
]),
"text/csv"
);
// The server answers at most 10 000 rows: say the file is not all of them.
if (res.total > res.sessions.length)
toast.info(t("exportCapped", { shown: res.sessions.length, total: res.total }));
} catch (err) {
toast.error(err);
} finally {
setExporting(false);
}
}
const hasFilters = !!(search || filter || cwd || tool || lane || from || to);
// A `?page=` past the end (a stale link, rows deleted meanwhile) goes back to
// the first page rather than showing an empty table.
const pageCount = Math.ceil(total / PAGE_SIZE);
useEffect(() => {
if (!loading && pageCount > 0 && page >= pageCount) update({ page: null });
}, [loading, page, pageCount, update]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// The server already paginates, so the rendered page IS the loaded list.
const paged = sessions;
const filtered = sessions; // kept for empty-state checks below
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
return (
<div className="animate-fade-in">
{rowCtx.menu && (
<SessionRowMenu
session={rowCtx.menu.item}
x={rowCtx.menu.x}
y={rowCtx.menu.y}
onClose={rowCtx.close}
/>
)}
<div className="flex flex-wrap items-center justify-between gap-3 mb-8">
<div className="flex items-center gap-3">
{!embedded && (
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<FolderOpen className="w-4.5 h-4.5 text-accent" />
</div>
)}
<div>
<div className="flex items-center gap-2">
{/* Under History's tab bar the tab already names the page. */}
<h1 className={embedded ? "sr-only" : "text-lg font-semibold text-fg-primary"}>
{t("title")}
</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{t("common:live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{t("common:offline")}
</span>
)}
</div>
<p className="text-xs text-fg-muted">
{t("sessionCount", { count: total })}
{filter
? ` · ${FILTER_OPTIONS.find((o) => o.value === filter)?.label ?? filter}`
: ""}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => handleExport("csv")}
className="btn-ghost"
disabled={exporting || total === 0}
>
<Download className="w-4 h-4" /> {t("exportCsv")}
</button>
<button
onClick={() => handleExport("json")}
className="btn-ghost"
disabled={exporting || total === 0}
>
<Download className="w-4 h-4" /> {t("exportJson")}
</button>
<button onClick={refresh} disabled={spinning} aria-busy={spinning} className="btn-ghost">
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap lg:flex-nowrap items-center gap-3 mb-6 bg-surface-2/40 p-2 rounded-xl border border-border w-full">
{/* Search */}
<div className="relative flex-1 min-w-[180px] max-w-[340px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-fg-muted" />
<input
type="text"
placeholder={t("searchPlaceholder")}
aria-label={t("searchPlaceholder")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="input w-full pl-10"
/>
</div>
{/* Directory Selector */}
<div className="relative shrink-0 w-[180px]">
<select
value={cwd}
onChange={(e) => update({ cwd: e.target.value || null })}
aria-label={t("filterDirectory")}
className="select w-full text-ellipsis"
>
<option value="">{t("allDirectories")}</option>
{directories.map((d) => (
<option key={d} value={d} title={d}>
{truncate(d, 30)}
</option>
))}
</select>
</div>
{/* Sort Controls */}
<div className="flex items-center gap-1.5 bg-surface-1 px-1.5 py-1 rounded-lg border border-border h-[38px] flex-1 min-w-[180px]">
<div className="relative flex-1">
<select
value={sortBy}
onChange={(e) => update({ sort: e.target.value === "time" ? null : e.target.value })}
aria-label={t("sortLabel")}
className="select w-full border-0 bg-transparent py-0 whitespace-nowrap"
>
<option value="time">{sortDesc ? t("sortTimeDesc") : t("sortTimeAsc")}</option>
<option value="duration">
{sortDesc ? t("sortDurationDesc") : t("sortDurationAsc")}
</option>
<option value="price">{sortDesc ? t("sortPriceDesc") : t("sortPriceAsc")}</option>
</select>
</div>
<div className="w-px h-4 bg-border mx-1" />
<button
onClick={() => update({ asc: sortDesc ? "1" : null })}
className="p-1.5 rounded hover:bg-surface-3 text-fg-secondary hover:text-fg-primary transition-colors shrink-0"
title={sortDesc ? t("descending") : t("ascending")}
aria-label={sortDesc ? t("descending") : t("ascending")}
>
{sortDesc ? <SortDesc className="w-4 h-4" /> : <SortAsc className="w-4 h-4" />}
</button>
</div>
{/* Status Filters */}
<div className="flex gap-1 bg-surface-1 rounded-lg p-1 border border-border ml-auto shrink-0">
{FILTER_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => update({ status: opt.value || null })}
aria-pressed={filter === opt.value}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors whitespace-nowrap ${
filter === opt.value
? "bg-surface-4 text-fg-secondary"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Tool / workspace / date range */}
<div className="flex flex-wrap items-center gap-3 -mt-3 mb-6 text-xs">
<select
value={tool}
onChange={(e) => update({ tool: e.target.value || null })}
aria-label={t("filterTool")}
className="select py-1 text-xs w-auto"
>
<option value="">{t("allTools")}</option>
{tools
.filter((x) => x.installed || x.id === tool)
.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
<select
value={lane}
onChange={(e) => update({ lane: e.target.value || null })}
aria-label={t("filterWorkspace")}
className="select py-1 text-xs w-auto max-w-[16rem]"
>
<option value="">{t("allWorkspaces")}</option>
{lanes.map((l) => (
<option key={l.id} value={String(l.id)}>
{l.title || l.cwd}
</option>
))}
</select>
<label className="flex items-center gap-1.5 text-fg-muted">
{t("dateFrom")}
<input
type="date"
value={from}
max={to || undefined}
onChange={(e) => update({ from: e.target.value || null })}
className="input py-1 text-xs w-auto"
/>
</label>
<label className="flex items-center gap-1.5 text-fg-muted">
{t("dateTo")}
<input
type="date"
value={to}
min={from || undefined}
onChange={(e) => update({ to: e.target.value || null })}
className="input py-1 text-xs w-auto"
/>
</label>
{hasFilters && (
<button
onClick={() => {
setSearchInput("");
update({
status: null,
q: null,
cwd: null,
tool: null,
lane: null,
from: null,
to: null,
});
}}
className="text-fg-muted hover:text-fg-primary underline"
>
{t("clearFilters")}
</button>
)}
</div>
{error && (
<div
role="alert"
className="card flex flex-wrap items-center gap-3 p-3 mb-4 border-status-danger/40 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">
{t("failedList")}: {error}
</span>
<button onClick={load} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
)}
{!loading && !error && filtered.length === 0 ? (
<EmptyState
icon={FolderOpen}
title={t("noSessions")}
description={hasFilters ? t("noSessionsDesc") : t("noSessionsHint")}
/>
) : (
<>
<div
className="card overflow-x-auto"
tabIndex={0}
role="region"
aria-label={t("tableRegion")}
>
<table className="w-full min-w-[800px]">
<thead>
<tr className="border-b border-border text-left">
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableSession")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableStatus")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableLastActive")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableDuration")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableAgents")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableCost")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableDirectory")}
</th>
<th className="w-10"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading && paged.length === 0
? Array.from({ length: 8 }).map((_, i) => (
<TableRowSkeleton
key={`sk-${i}`}
columns={8}
widths={["w-40", "w-20", "w-28", "w-20", "w-10", "w-16", "w-44", "w-4"]}
/>
))
: null}
{paged.map((session) => (
<tr
key={session.id}
// Mouse convenience; the session name below is the real,
// focusable link (a click on a link or button inside wins).
onClick={(e) => {
if ((e.target as HTMLElement).closest("a,button")) return;
navigate(`/sessions/${session.id}`);
}}
onContextMenu={(e) => rowCtx.open(e, session)}
className="hover:bg-surface-4 transition-colors cursor-pointer group"
>
<td className="px-5 py-4">
<div>
<div className="flex items-center gap-2">
<Link
to={`/sessions/${session.id}`}
className="text-sm font-medium text-fg-secondary hover:text-fg-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent rounded"
>
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</Link>
<SessionToolBadge tool={session.tool} />
{session.source && session.source !== "local" && (
<span
className="inline-flex items-center gap-1 text-[10px] font-semibold text-syntax-builtin bg-surface-3 border border-border px-1.5 py-0.5 rounded-full"
title={t("remoteSourceBadgeTitle", "Collected from a remote machine")}
>
<Server className="w-2.5 h-2.5" />
{sourceLabels.get(session.source) || session.source}
</span>
)}
{(() => {
// A live dashboard pane holds it: open that pane.
const run = runs.find((r) => r.sessionId === session.id);
const href = run ? workspaceHref(session, runs) : null;
return href ? (
<Link
to={href}
className="inline-flex items-center gap-1 text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-1.5 py-0.5 rounded-full transition-colors"
title={t("openInWorkspaceTitle")}
>
<Play className="w-2.5 h-2.5" />
{t("openInWorkspace")}
</Link>
) : null;
})()}
</div>
<p className="text-[11px] text-fg-muted font-mono">
{session.id.slice(0, 12)}
</p>
</div>
</td>
<td className="px-5 py-4">
<SessionStatusBadge
status={effectiveSessionStatus(session)}
reason={sessionAwaitingReason(session)}
/>
</td>
<td className="px-5 py-4 text-sm text-fg-secondary">
{formatDateTime(session.last_activity || session.started_at)}
</td>
<td className="px-5 py-4 text-sm text-fg-secondary font-mono">
{session.ended_at
? formatDuration(session.started_at, session.ended_at)
: t("common:running")}
</td>
<td className="px-5 py-4 text-sm text-fg-secondary">
{session.agent_count ?? "-"}
</td>
<td className="px-5 py-4 text-sm text-fg-secondary font-mono">
{session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"}
</td>
<td
className="px-5 py-4 text-[11px] text-fg-muted font-mono"
title={session.cwd || undefined}
>
{session.cwd ? truncate(session.cwd, 30) : "-"}
</td>
<td className="px-3 py-4">
<ChevronRight className="w-4 h-4 text-fg-muted group-hover:text-fg-secondary transition-colors" />
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 px-1">
<span className="text-xs text-fg-muted">
{t("common:pagination.showing", {
from: page * PAGE_SIZE + 1,
to: Math.min((page + 1) * PAGE_SIZE, total),
total,
})}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed"
>
{t("common:pagination.previous")}
</button>
<span className="px-3 py-1.5 text-xs text-fg-muted">
{page + 1} / {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed"
>
{t("common:pagination.next")}
</button>
</div>
</div>
)}
</>
)}
</div>
);
}