Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
261 changes: 197 additions & 64 deletions ui-dashboard/src/app/pool/[poolId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import { useGQL } from "@/lib/graphql";
import {
ORACLE_SNAPSHOTS,
ORACLE_SNAPSHOTS_COUNT,
OLS_LIQUIDITY_EVENTS,
OLS_POOL,
POOL_DEPLOYMENT,
Expand All @@ -44,6 +45,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 @@ -381,7 +383,6 @@ function PoolDetail() {
{tab === "oracle" && (
<OracleTab
poolId={normalizedPoolId}
limit={limit}
pool={pool}
search={activeSearch}
onSearchChange={(value) => setTabSearch("oracle", value)}
Expand Down Expand Up @@ -1168,40 +1169,101 @@ function LpsTab({ poolId, pool }: { poolId: string; pool: Pool | null }) {
);
}

// Column keys that can be sorted
type OracleSortCol =
| "timestamp"
| "oracleOk"
| "oraclePrice"
| "priceDifference"
| "numReporters"
| "blockNumber";

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.


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");

// Reset to page 1 when search changes (derived from query length changes)
const prevQueryRef = React.useRef(query);
if (prevQueryRef.current !== query) {
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 calls setPage during render. Even though guarded, it's still a render-phase state update and can cause extra render churn / strict-mode surprises.

Please move this reset into useEffect(() => setPage(1), [query]) (with guard if needed).

prevQueryRef.current = query;
if (page !== 1) setPage(1);
}

const offset = (page - 1) * ORACLE_PAGE_SIZE;

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

const { data: countData } = useGQL<{
OracleSnapshot_aggregate: { aggregate: { count: number } };
}>(ORACLE_SNAPSHOTS_COUNT, { poolId });
const total = countData?.OracleSnapshot_aggregate?.aggregate?.count ?? 0;

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]);
// Client-side sort of the current page
const sortedRows = 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.

This sorts only the currently fetched page. Because fetch pagination is still timestamp desc (limit + offset), sorting by oraclePrice, numReporters, etc. is not globally correct across the dataset.

Concrete failure mode: page 1 and page 2 are partitioned by timestamp first, then each page is locally re-ordered by the selected column, so users never get a true full-table sort.

Please move sorting into the GraphQL query (order_by driven by sort state) so offset applies to the chosen sort order.

if (!rows.length) return rows;
return [...rows].sort((a, b) => {
let av: number, bv: number;
switch (sortCol) {
case "timestamp":
av = Number(a.timestamp);
bv = Number(b.timestamp);
break;
case "oracleOk":
av = a.oracleOk ? 1 : 0;
bv = b.oracleOk ? 1 : 0;
break;
case "oraclePrice":
av = parseOraclePriceToNumber(a.oraclePrice, sym0);
bv = parseOraclePriceToNumber(b.oraclePrice, sym0);
break;
case "priceDifference":
av = Number(a.priceDifference);
bv = Number(b.priceDifference);
break;
case "numReporters":
av = a.numReporters;
bv = b.numReporters;
break;
case "blockNumber":
av = Number(a.blockNumber);
bv = Number(b.blockNumber);
break;
default:
return 0;
}
return sortDir === "asc" ? av - bv : bv - av;
});
}, [rows, sortCol, sortDir, sym0]);

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

return matchesRowSearch(query, [
r.source,
statusAliases,
Expand All @@ -1212,24 +1274,45 @@ function OracleTab({
r.blockNumber,
]);
});
}, [orderedRows, query, sym0]);
}, [sortedRows, query, sym0]);

function toggleSort(col: OracleSortCol) {
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 substantial new behavior (toggleSort, page reset, count-query-based pagination) but there are no tests covering these paths.

Please add assertions for at least:

  • sort toggle direction + default direction per column
  • page reset to 1 on search/sort changes
  • count query failure fallback behavior
  • pagination visibility when searching vs not searching

if (sortCol === col) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortCol(col);
// Sensible default directions per column
setSortDir(col === "oracleOk" ? "asc" : "desc");
}
setPage(1);
}

if (pool?.source?.includes("virtual")) {
return <EmptyBox message="VirtualPool — no oracle data available." />;
}

if (error) return <ErrorBox message={error.message} />;
if (isLoading) return <Skeleton rows={5} />;
if (rows.length === 0)
if (rows.length === 0 && !isLoading)
return (
<EmptyBox message="No oracle snapshots yet. Oracle data is captured on pool activity (swaps, rebalances)." />
);

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

// Charts always use all fetched rows (current page) for context
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

chartRows is tied to table sort state. After sorting by a non-time column, both oracle charts are no longer chronological, which misrepresents time-series data.

Charts should be based on a stable timestamp ordering independent of table sort (e.g. always timestamp asc/desc), while table rows can follow user-selected sort.

const chartRows = sortedRows;

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}
/>
Expand All @@ -1242,58 +1325,108 @@ function OracleTab({
{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
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
onClick={() => toggleSort("oraclePrice")}
className="hover:text-indigo-400 transition-colors"
>
Price ({sym0}/{sym1}){arrow("oraclePrice")}
</button>
</Th>
<Th align="right">
<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 align="right">
<button
onClick={() => toggleSort("numReporters")}
className="hover:text-indigo-400 transition-colors"
>
Reporters{arrow("numReporters")}
</button>
</Th>
<Th align="right">
<button
onClick={() => toggleSort("blockNumber")}
className="hover:text-indigo-400 transition-colors"
>
Block{arrow("blockNumber")}
</button>
</Th>
<Th>
<button
onClick={() => toggleSort("timestamp")}
className="hover:text-indigo-400 transition-colors"
>
Time{arrow("timestamp")}
</button>
</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"
}
>
{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>
<Pagination
page={page}
pageSize={ORACLE_PAGE_SIZE}
total={total}
onPageChange={setPage}
/>
</>
)}
</>
);
Expand Down
Loading
Loading