Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 225 additions & 74 deletions ui-dashboard/src/app/pool/[poolId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
import { useGQL } from "@/lib/graphql";
import {
ORACLE_SNAPSHOTS,
ORACLE_SNAPSHOTS_CHART,
ORACLE_SNAPSHOTS_COUNT,
OLS_LIQUIDITY_EVENTS,
OLS_POOL,
POOL_DEPLOYMENT,
Expand All @@ -44,6 +46,7 @@ import {
POOL_SWAPS,
TRADING_LIMITS,
} from "@/lib/queries";
import { Pagination } from "@/components/pagination";
import { computeHealthStatus, computeRebalancerLiveness } from "@/lib/health";
import { isFpmm, poolName, tokenSymbol, USDM_SYMBOLS } from "@/lib/tokens";
import {
Expand Down Expand Up @@ -333,13 +336,16 @@ function PoolDetail() {
{getTabLabel(t)}
</button>
))}
<div className="ml-auto hidden sm:flex items-center">
<LimitSelect
id="tab-limit"
value={limit}
onChange={(l) => setURL(tab, l)}
/>
</div>
{/* Oracle tab manages its own page size — hide the global limit selector */}
{tab !== "oracle" && (
<div className="ml-auto hidden sm:flex items-center">
<LimitSelect
id="tab-limit"
value={limit}
onChange={(l) => setURL(tab, l)}
/>
</div>
)}
</div>

<div role="tabpanel" id={`panel-${tab}`} aria-labelledby={`tab-${tab}`}>
Expand Down Expand Up @@ -381,7 +387,6 @@ function PoolDetail() {
{tab === "oracle" && (
<OracleTab
poolId={normalizedPoolId}
limit={limit}
pool={pool}
search={activeSearch}
onSearchChange={(value) => setTabSearch("oracle", value)}
Expand Down Expand Up @@ -1168,51 +1173,131 @@ function LpsTab({ poolId, pool }: { poolId: string; pool: Pool | null }) {
);
}

// Server-sortable columns (mapped to Hasura order_by fields)
type OracleSortCol =
| "timestamp"
| "oracleOk"
| "oraclePrice"
| "priceDifference";

const ORACLE_PAGE_SIZE = 25;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oracle now has a fixed page size, but the shared LimitSelect in the tab header is still visible. In Oracle tab, changing limit in URL/UI has no effect, which is inconsistent with other tabs.

Either wire Oracle pagination to the same limit state or hide/disable the limit control for Oracle.

// When search is active, fetch up to this many rows so filtering is global
const ORACLE_SEARCH_LIMIT = 500;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ORACLE_SEARCH_LIMIT = 500 combined with suppressed pagination in search mode means search is silently incomplete once a pool has >500 snapshots.

This can produce false negatives ("No oracle snapshots match your search.") even when matches exist outside the first 500 rows. Please paginate search server-side or explicitly handle/signal truncation.


/**
* Build a stable Hasura order_by array. The primary sort is the chosen column;
* timestamp+id are always appended as tiebreakers so page boundaries remain
* deterministic even for non-unique fields (oracleOk, numReporters, etc.).
*/
function buildOrderBy(
col: OracleSortCol,
dir: "asc" | "desc",
): Array<Partial<Record<string, "asc" | "desc">>> {
const primary: Partial<Record<string, "asc" | "desc">> = { [col]: dir };
if (col === "timestamp") return [primary];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timestamp sort currently returns only [primary]. With offset pagination, this is non-deterministic when multiple rows share the same timestamp (common for on-chain events), which can cause duplicate/missing rows across pages.

Please always include a stable unique tie-breaker for timestamp sorting too (e.g. append { id: "asc" }).

// Secondary: timestamp desc; tertiary: id asc (unique) for full stability
return [primary, { timestamp: "desc" }, { id: "asc" }];
}

function OracleTab({
poolId,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Large behavior change (sorting, pagination, count fallback, search-mode fetch behavior), but no corresponding oracle-tab regression tests were added.

Please add coverage in page.test.tsx for: sort toggle correctness, page navigation, count error fallback behavior, and search behavior when result set exceeds the fetch cap.

limit,
pool,
search,
onSearchChange,
}: {
poolId: string;
limit: number;
pool: Pool | null;
search: string;
onSearchChange: (value: string) => void;
}) {
const { network } = useNetwork();
const query = normalizeSearch(search);

const [page, setPage] = React.useState(1);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces new stateful behavior (pagination/sorting) but there are no assertions covering it in the pool page tests. Please add tests for sort toggling, page transitions, and count-error fallback so these regressions are caught automatically.

const [sortCol, setSortCol] = React.useState<OracleSortCol>("timestamp");
const [sortDir, setSortDir] = React.useState<"asc" | "desc">("desc");

// Wrap search handler so changing the query always resets to page 1
const handleSearchChange = React.useCallback(
(value: string) => {
onSearchChange(value);
setPage(1);
},
[onSearchChange],
);

// When search is active: fetch a large window at offset 0 so filtering is
// global, not limited to the current page. Pagination is suppressed.
const isSearching = query.length > 0;
const fetchLimit = isSearching ? ORACLE_SEARCH_LIMIT : ORACLE_PAGE_SIZE;
const fetchOffset = isSearching ? 0 : (page - 1) * ORACLE_PAGE_SIZE;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchOffset uses the current page, but page is never clamped when total shrinks.

Repro: user navigates to page 3, then aggregate count drops to <= 25 rows. offset remains 50, query returns empty, and the component later renders the pool-level empty state (No oracle snapshots yet) even though rows still exist on earlier pages.

Please clamp/reset page when total pages change (e.g. setPage((p) => Math.min(p, Math.max(totalPages, 1)))).

const orderBy = useMemo(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

searchFetchLimit is presented as a recency cap ("most recent ... snapshots"), but search queries still use the interactive orderBy from sortCol/sortDir. That means once sort is changed, the search window is no longer "most recent" and can silently miss matches.

Please either:

  1. force search-mode fetch ordering to timestamp desc (and keep table sorting as a separate client-side step for the bounded set), or
  2. change UX copy to explicitly state search is limited to the first N rows in the current sort order.

Right now behavior and user messaging disagree.

() => buildOrderBy(sortCol, sortDir),
[sortCol, sortDir],
);

const { data, error, isLoading } = useGQL<{
OracleSnapshot: OracleSnapshot[];
}>(ORACLE_SNAPSHOTS, { poolId, limit });
}>(ORACLE_SNAPSHOTS, {
poolId,
limit: fetchLimit,
offset: fetchOffset,
orderBy,
});

const { data: countData, error: countError } = useGQL<{
OracleSnapshot_aggregate: { aggregate: { count: number } };
}>(ORACLE_SNAPSHOTS_COUNT, { poolId });
// Preserve last known total on count error so pagination stays visible
const lastKnownTotalRef = React.useRef(0);
const rawTotal = countData?.OracleSnapshot_aggregate?.aggregate?.count ?? 0;
if (rawTotal > 0) lastKnownTotalRef.current = rawTotal;
const total = countError ? lastKnownTotalRef.current : rawTotal;

const rows = data?.OracleSnapshot ?? [];

const sym0 = tokenSymbol(network, pool?.token0 ?? null);
const sym1 = tokenSymbol(network, pool?.token1 ?? null);

// Reverse once to get newest-first order, then filter
const orderedRows = useMemo(() => [...rows].reverse(), [rows]);
// Charts use a dedicated query (200 most recent rows) so they always show
// full history context regardless of table pagination or sort state.
const { data: chartData } = useGQL<{ OracleSnapshot: OracleSnapshot[] }>(
ORACLE_SNAPSHOTS_CHART,
{ poolId, limit: 200 },
);
const chartRows = useMemo(() => {
const raw = chartData?.OracleSnapshot ?? [];
return [...raw].sort((a, b) => Number(a.timestamp) - Number(b.timestamp));
}, [chartData]);

const filteredRows = useMemo(() => {
if (!query) return orderedRows;
return orderedRows.filter((r) => {
if (!query) return rows;
return rows.filter((r) => {
const statusAliases = r.oracleOk
? "ok true healthy pass good ✓"
: "fail false unhealthy bad ✗";

return matchesRowSearch(query, [
r.source,
statusAliases,
parseOraclePriceToNumber(r.oraclePrice, sym0).toFixed(6),
Number(r.priceDifference) > 0 ? r.priceDifference : null,
r.rebalanceThreshold > 0 ? String(r.rebalanceThreshold) : null,
r.numReporters,
r.blockNumber,
]);
});
}, [orderedRows, query, sym0]);
}, [rows, query, sym0]);

const toggleSort = React.useCallback(
(col: OracleSortCol) => {
if (sortCol === col) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortCol(col);
setSortDir(col === "oracleOk" ? "asc" : "desc");
}
setPage(1);
},
[sortCol],
);

if (pool?.source?.includes("virtual")) {
return <EmptyBox message="VirtualPool — no oracle data available." />;
Expand All @@ -1225,75 +1310,141 @@ function OracleTab({
<EmptyBox message="No oracle snapshots yet. Oracle data is captured on pool activity (swaps, rebalances)." />
);

const arrow = (col: OracleSortCol) =>
sortCol === col ? (sortDir === "asc" ? " ↑" : " ↓") : "";

return (
<>
<OracleChart snapshots={rows} token0Symbol={sym0} token1Symbol={sym1} />
<OracleChart
snapshots={chartRows}
token0Symbol={sym0}
token1Symbol={sym1}
/>
<OraclePriceChart
snapshots={rows}
snapshots={chartRows}
token0={pool?.token0 ?? null}
token1={pool?.token1 ?? null}
/>
<TableSearch
value={search}
onChange={onSearchChange}
placeholder="Search oracle rows by source, status, price, or block…"
onChange={handleSearchChange}
placeholder="Search oracle rows by source, status, price, or tx hash…"
ariaLabel="Search oracle"
/>
{filteredRows.length === 0 ? (
<EmptyBox message="No oracle snapshots match your search." />
) : (
<Table>
<thead>
<tr className="border-b border-slate-800 bg-slate-900/50">
<Th>Source</Th>
<Th align="right">Oracle OK</Th>
<Th align="right">
Price ({sym0}/{sym1})
</Th>
<Th align="right">Price Diff</Th>
<Th align="right">Threshold</Th>
<Th align="right">Reporters</Th>
<Th align="right">Block</Th>
<Th>Time</Th>
</tr>
</thead>
<tbody>
{filteredRows.map((r) => (
<Row key={r.id}>
<Td small>
<span className="rounded bg-slate-800 px-1.5 py-0.5 text-xs text-slate-300 font-mono">
{r.source}
</span>
</Td>
<Td small align="right">
<span
className={r.oracleOk ? "text-emerald-400" : "text-red-400"}
<>
<Table>
<thead>
<tr className="border-b border-slate-800 bg-slate-900/50">
<Th>Source</Th>
<Th align="right">
<button
type="button"
onClick={() => toggleSort("oracleOk")}
className="hover:text-indigo-400 transition-colors"
>
{r.oracleOk ? "✓" : "✗"}
</span>
</Td>
<Td mono small align="right">
{parseOraclePriceToNumber(r.oraclePrice, sym0).toFixed(6)}
</Td>
<Td mono small align="right">
{Number(r.priceDifference) > 0 ? r.priceDifference : "—"}
</Td>
<Td mono small align="right">
{r.rebalanceThreshold > 0 ? r.rebalanceThreshold : "—"}
</Td>
<Td mono small align="right">
{r.numReporters}
</Td>
<Td mono small muted align="right">
{formatBlock(r.blockNumber)}
</Td>
<Td small muted title={formatTimestamp(r.timestamp)}>
{relativeTime(r.timestamp)}
</Td>
</Row>
))}
</tbody>
</Table>
Oracle OK{arrow("oracleOk")}
</button>
</Th>
<Th align="right">
<button
type="button"
onClick={() => toggleSort("oraclePrice")}
className="hover:text-indigo-400 transition-colors"
>
Price ({sym0}/{sym1}){arrow("oraclePrice")}
</button>
</Th>
<Th align="right">
<button
type="button"
onClick={() => toggleSort("priceDifference")}
className="hover:text-indigo-400 transition-colors"
>
Price Diff{arrow("priceDifference")}
</button>
</Th>
<Th align="right">Threshold</Th>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This refactor removes Reporters and Block from the oracle table output (they were present before), so users lose those data points in the detail view. If the removal is intentional, it should be explicitly called out as a scope change; otherwise this is a regression.

Given the PR description also says those columns are sortable, I think this should be restored (or the scope/docs updated to match reality).

<Th>
<button
type="button"
onClick={() => toggleSort("timestamp")}
className="hover:text-indigo-400 transition-colors"
>
Time{arrow("timestamp")}
</button>
</Th>
</tr>
</thead>
<tbody>
{filteredRows.map((r) => {
const diffBps = Number(r.priceDifference);
const thresholdBps = r.rebalanceThreshold;
const diffPct =
diffBps > 0 && thresholdBps > 0
? ((diffBps / thresholdBps) * 100).toFixed(1)
: null;
return (
<Row key={r.id}>
<Td small>
<span className="rounded bg-slate-800 px-1.5 py-0.5 text-xs text-slate-300 font-mono">
{r.source}
</span>
</Td>
<Td small align="right">
<span
className={
r.oracleOk ? "text-emerald-400" : "text-red-400"
}
>
{r.oracleOk ? "✓" : "✗"}
</span>
</Td>
<Td mono small align="right">
{parseOraclePriceToNumber(r.oraclePrice, sym0).toFixed(6)}
</Td>
<Td mono small align="right">
{diffBps > 0 ? (
<span title={`${diffBps.toLocaleString()} bps`}>
{diffPct !== null ? `${diffPct}%` : `${diffBps} bps`}
</span>
) : (
"—"
)}
</Td>
<Td mono small align="right">
{thresholdBps > 0 ? (
<span title={`${thresholdBps.toLocaleString()} bps`}>
{(thresholdBps / 100).toFixed(2)}%
</span>
) : (
"—"
)}
</Td>
<Td small muted title={formatTimestamp(r.timestamp)}>
{relativeTime(r.timestamp)}
</Td>
</Row>
);
})}
</tbody>
</Table>
{!isSearching && (
<Pagination
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pagination is currently local component state only (page), unlike existing URL-backed tab/search/limit state on this page. This makes sort/page non-shareable and breaks expected back/forward behavior.

Please persist page/sort params in URL (same pattern used by setURL/setTabSearch) so state is durable and consistent across tabs/features.

page={page}
pageSize={ORACLE_PAGE_SIZE}
total={countError ? rows.length : total}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rows.length here is page-local, not dataset-total. When count fails, this can hide pagination and make older pages unreachable even though data exists. Please change fallback logic to preserve navigation (e.g. keep last known total, or switch to has-next pagination semantics on count error).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On countError, this falls back to rows.length instead of the total value you already preserve via lastKnownTotalRef. That collapses pagination to a single page during transient count failures and can hide older data.

Please use the preserved total fallback here and keep a warning whenever count is unavailable.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When countError is set, using rows.length as total (current page size only) can collapse pagination and make later pages unreachable without any clear signal. This should preserve navigability semantics (or explicitly disable paging with a visible warning) for all count-error states, not only when total === 0.

onPageChange={setPage}
/>
)}
{countError && !isSearching && total === 0 && (
<p className="px-1 pt-1 text-xs text-amber-400">
Could not load total count — pagination may be incomplete.
</p>
)}
</>
)}
</>
);
Expand Down
Loading
Loading