-
Notifications
You must be signed in to change notification settings - Fork 0
feat: oracle tab pagination + sortable columns #116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
6741b42
24d4718
1e254ee
7795e15
5ab6632
11e05e1
df26c1a
b922521
2c14db6
75f06af
8b1f2ee
d6bce4a
4e9eb57
33f2503
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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 { | ||
|
|
@@ -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}`}> | ||
|
|
@@ -381,7 +387,6 @@ function PoolDetail() { | |
| {tab === "oracle" && ( | ||
| <OracleTab | ||
| poolId={normalizedPoolId} | ||
| limit={limit} | ||
| pool={pool} | ||
| search={activeSearch} | ||
| onSearchChange={(value) => setTabSearch("oracle", value)} | ||
|
|
@@ -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; | ||
| // When search is active, fetch up to this many rows so filtering is global | ||
| const ORACLE_SEARCH_LIMIT = 500; | ||
|
||
|
|
||
| /** | ||
| * 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]; | ||
|
||
| // Secondary: timestamp desc; tertiary: id asc (unique) for full stability | ||
| return [primary, { timestamp: "desc" }, { id: "asc" }]; | ||
| } | ||
|
|
||
| function OracleTab({ | ||
| poolId, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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); | ||
|
||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Repro: user navigates to page 3, then aggregate count drops to <= 25 rows. Please clamp/reset page when total pages change (e.g. |
||
| const orderBy = useMemo( | ||
|
||
| () => 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." />; | ||
|
|
@@ -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> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This refactor removes 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pagination is currently local component state only ( Please persist |
||
| page={page} | ||
| pageSize={ORACLE_PAGE_SIZE} | ||
| total={countError ? rows.length : total} | ||
|
||
| 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> | ||
| )} | ||
| </> | ||
| )} | ||
| </> | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
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
LimitSelectin the tab header is still visible. In Oracle tab, changinglimitin 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.