diff --git a/AGENTS.md b/AGENTS.md index f34485f2..37cc6ed6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,26 @@ pnpm monorepo with three packages: - `indexer-envio/` — Envio HyperIndex indexer for Celo v3 FPMM pools - `ui-dashboard/` — Next.js 16 + Plotly.js monitoring dashboard +## Operating Rule (read this before opening PRs) + +> **Any PR that adds or changes stateful data flow across layers must ship with explicit invariants, degraded-mode behavior, and interaction tests before opening.** + +This repo has already paid the tax for learning this the hard way. + +If your change touches any combination of: + +- Envio schema/entities +- event handlers / entity writers +- generated types / GraphQL queries / dashboard types +- paginated or sortable UI state +- partial failure behavior (missing counts, stale RPC, missing txHash, etc.) + +then you are expected to run the dedicated PR checklist before opening or updating the PR: + +- **Checklist:** `docs/pr-checklists/stateful-data-ui.md` + +Do not rely on PR review to finish the design. Reviews should catch misses, not define the invariants for the first time. + ## Quick Commands ```bash @@ -165,6 +185,10 @@ pnpm indexer:codegen # Validates Envio can parse handler entry point + module pnpm --filter @mento-protocol/ui-dashboard test:coverage ``` +Before pushing any cross-layer or stateful UI change, also read and apply: + +- **`docs/pr-checklists/stateful-data-ui.md`** + **Common traps:** - `codespell` flags short variable names that match common abbreviations (e.g. a two-letter loop var that looks like a misspelling). Use descriptive names like `netData` to avoid this. diff --git a/docs/pr-checklists/stateful-data-ui.md b/docs/pr-checklists/stateful-data-ui.md new file mode 100644 index 00000000..4e887a52 --- /dev/null +++ b/docs/pr-checklists/stateful-data-ui.md @@ -0,0 +1,205 @@ +# Stateful Data + UI PR Checklist + +Use this checklist for any PR that changes stateful data flow across layers. + +## Operating rule + +> **Any PR that adds or changes stateful data flow across layers must ship with explicit invariants, degraded-mode behavior, and interaction tests before opening.** + +If the change touches any combination of: + +- Envio schema/entities +- event handlers / entity writers +- generated types / GraphQL queries / dashboard types +- paginated, sortable, filterable, or searchable UI state +- partial failure behavior (count query failure, stale RPC, missing metadata, old rows after schema rollout) + +then this checklist is mandatory. + +--- + +## 1. Define invariants first + +Write down the rules the system must obey before coding. + +Examples: + +- Every event/snapshot entity must persist `txHash` +- Charts must not depend on paginated table slices +- Paginated tables must have deterministic ordering +- Aggregate-query failure must degrade visibly, not silently +- Client-side search over large datasets must be bounded and disclosed + +If you cannot state the invariant in one sentence, the design is not ready. + +--- + +## 2. Cross-layer audit + +For every new field / changed field / changed behavior, walk the full path: + +### Schema / source of truth + +- [ ] `schema.graphql` updated if entity shape changed +- [ ] field names/types/nullability are intentional +- [ ] backward-compatibility / rollout behavior considered for old rows + +### Writers + +- [ ] every entity constructor / writer is updated consistently +- [ ] all event handlers that produce the entity were checked, not just the obvious one +- [ ] generated/codegen artifacts refreshed where applicable + +### Readers + +- [ ] GraphQL queries updated +- [ ] dashboard/runtime types updated +- [ ] derived formatting / rendering logic updated +- [ ] search/sort/filter fields updated intentionally + +### Tests + +- [ ] producer/indexer tests updated +- [ ] consumer/UI tests updated +- [ ] fixtures reflect the new schema reality + +If one layer is missing, stop and fix it before opening the PR. + +--- + +## 3. Stateful table rubric + +If the PR touches a table with pagination, sort, filter, search, or linked charts, answer all of these explicitly. + +### Sorting + +- [ ] Is sorting server-side, client-side, or hybrid? +- [ ] Are page boundaries deterministic for non-unique sort fields? +- [ ] Is there a unique tiebreaker (`id`, tx hash, composite key, etc.)? +- [ ] Do headers expose sort state accessibly (`aria-sort`)? + +### Pagination + +- [ ] What determines total row count? +- [ ] What happens when count/aggregate fails? +- [ ] Does pagination remain usable after transient failure? +- [ ] Are controls actual buttons with `type="button"`? + +### Search / filtering + +- [ ] Does search operate on current page, fetched window, or full dataset? +- [ ] Is that behavior documented in code comments and PR notes? +- [ ] If bounded, is the cap explicit and user-visible? +- [ ] If unbounded, can the backend/query path actually support it? + +### Coupled visualizations + +- [ ] Do charts use dedicated queries instead of inheriting paginated/sorted table state? +- [ ] If not, is that coupling intentional and documented? + +### URL / local state + +- [ ] Is table state URL-backed or intentionally local? +- [ ] If local-only, is that explicitly called out as an intentional scope decision? + +--- + +## 4. Degraded-mode checklist + +For each non-happy path, decide the behavior explicitly. + +- [ ] count query fails +- [ ] chart query fails +- [ ] some rows predate a new schema field +- [ ] RPC-derived metadata is missing +- [ ] total dataset is much larger than the current happy-path sample +- [ ] search term matches data outside the currently fetched window +- [ ] empty state vs loading state vs partial-data state are distinct + +The key question: + +> What will the user see, and will they understand that the data is partial or degraded? + +Silent degradation is not acceptable. + +--- + +## 5. Required test matrix + +For nontrivial stateful data/UI changes, tests must cover all 3 buckets: + +### Happy path + +- [ ] normal render / query wiring +- [ ] new field is displayed/used correctly + +### State transition + +- [ ] sort toggle changes query/order state +- [ ] page transition changes offset/page state +- [ ] search input resets/updates the right state +- [ ] links/actions resolve to the expected target + +### Failure / degraded mode + +- [ ] count error fallback +- [ ] capped search behavior +- [ ] missing field / legacy row behavior +- [ ] user-visible warning or fallback state + +If the risky behavior is interactive, a static markup assertion is not enough. + +--- + +## 6. PR description requirements + +Before opening the PR, include these sections: + +### What this PR changes + +Short factual summary. + +### Invariants + +List the system rules this PR relies on or introduces. + +### Degraded behavior + +What happens on count/query/RPC failure, old rows, large datasets, etc. + +### Intentional non-goals + +Examples: + +- URL-backed sort/page state deferred +- full server-side search deferred +- abstraction cleanup out of scope + +This prevents reviews from repeatedly rediscovering scope boundaries. + +--- + +## 7. Repo-specific lessons already paid for + +These are not theoretical. + +- New UI fields must not assume schema support without verifying all writers. +- Shared presentational components should forward DOM props unless intentionally constrained. +- Count fallback must preserve prior total, not collapse to current page length. +- Search behavior must be bounded and disclosed when not truly global. +- Charts and tables should usually be decoupled. +- Cross-layer features need both indexer and UI regression coverage. + +--- + +## 8. Final pre-PR questions + +If you answer “no” to any of these, do not open yet. + +- [ ] Could another engineer explain the invariants from the PR description alone? +- [ ] Would a transient backend failure produce a sensible UI instead of a misleading one? +- [ ] Are the largest-cardinality paths still bounded? +- [ ] Do tests prove behavior, not just markup? +- [ ] Did review stop being the place where design gets finished? + +If not, one more local pass is cheaper than three more review rounds. diff --git a/indexer-envio/AGENTS.md b/indexer-envio/AGENTS.md index d400f433..630f8462 100644 --- a/indexer-envio/AGENTS.md +++ b/indexer-envio/AGENTS.md @@ -4,6 +4,14 @@ Envio HyperIndex indexer for Mento v3 FPMM (Fixed Product Market Maker) pools on Celo + Monad (multichain). +## Before Opening PRs + +If your indexer change propagates into Hasura/UI behavior — schema changes, entity additions, new fields on existing entities, degraded RPC/error handling, or any stateful dashboard behavior fed by indexer data — read and apply: + +- `../docs/pr-checklists/stateful-data-ui.md` + +This is mandatory for cross-layer/stateful data work. Do not assume the UI/query layer will “just catch up” later. + ## Key Files - `config.multichain.mainnet.yaml` — **Default** mainnet config (Celo + Monad) diff --git a/indexer-envio/schema.graphql b/indexer-envio/schema.graphql index 9837f7ad..f502eb9e 100644 --- a/indexer-envio/schema.graphql +++ b/indexer-envio/schema.graphql @@ -60,6 +60,7 @@ type OracleSnapshot @index(fields: ["poolId", "timestamp"]) { rebalanceThreshold: Int! source: String! blockNumber: BigInt! + txHash: String! } type PoolSnapshot @index(fields: ["poolId", "timestamp"]) { diff --git a/indexer-envio/src/handlers/fpmm.ts b/indexer-envio/src/handlers/fpmm.ts index b06e01cb..dce873f5 100644 --- a/indexer-envio/src/handlers/fpmm.ts +++ b/indexer-envio/src/handlers/fpmm.ts @@ -532,6 +532,7 @@ FPMM.UpdateReserves.handler(async ({ event, context }) => { rebalanceThreshold: pool.rebalanceThreshold, source: "update_reserves", blockNumber, + txHash: event.transaction.hash, }; context.OracleSnapshot.set(snapshot); } @@ -623,6 +624,7 @@ FPMM.Rebalanced.handler(async ({ event, context }) => { rebalanceThreshold: pool.rebalanceThreshold, source: "rebalanced", blockNumber, + txHash: event.transaction.hash, }; context.OracleSnapshot.set(snapshot); } diff --git a/indexer-envio/src/handlers/sortedOracles.ts b/indexer-envio/src/handlers/sortedOracles.ts index 833bd597..ab34123a 100644 --- a/indexer-envio/src/handlers/sortedOracles.ts +++ b/indexer-envio/src/handlers/sortedOracles.ts @@ -77,6 +77,7 @@ SortedOracles.OracleReported.handler(async ({ event, context }) => { rebalanceThreshold: existing.rebalanceThreshold, source: "oracle_reported", blockNumber, + txHash: event.transaction.hash, }; context.OracleSnapshot.set(snapshot); } @@ -140,6 +141,7 @@ SortedOracles.MedianUpdated.handler(async ({ event, context }) => { rebalanceThreshold: existing.rebalanceThreshold, source: "oracle_median_updated", blockNumber, + txHash: event.transaction.hash, }; context.OracleSnapshot.set(snapshot); } diff --git a/indexer-envio/test/Test.ts b/indexer-envio/test/Test.ts index 3741ad78..c4ef0cbf 100644 --- a/indexer-envio/test/Test.ts +++ b/indexer-envio/test/Test.ts @@ -401,6 +401,7 @@ type OracleSnapshotEntity = { rebalanceThreshold: number; source: string; blockNumber: bigint; + txHash: string; }; type LiquidityPositionEntity = { @@ -923,6 +924,11 @@ describe("Envio Celo indexer handlers", () => { 7, "MedianUpdated snapshot preserves the DB-seeded reporter count", ); + assert.equal( + snapshot.txHash, + pool.oracleTxHash, + "MedianUpdated snapshot should persist the triggering tx hash", + ); }); // --------------------------------------------------------------------------- @@ -996,6 +1002,11 @@ describe("Envio Celo indexer handlers", () => { "Snapshot priceDifference must match pool priceDifference", ); assert.equal(snapshot!.source, "oracle_reported"); + assert.equal( + snapshot!.txHash, + pool.oracleTxHash, + "OracleReported snapshot should persist the triggering tx hash", + ); }); it("MedianUpdated: stores priceDifference computed from event oracle + existing reserves", async () => { @@ -1056,6 +1067,11 @@ describe("Envio Celo indexer handlers", () => { "Snapshot priceDifference must match pool priceDifference", ); assert.equal(snapshot!.source, "oracle_median_updated"); + assert.equal( + snapshot!.txHash, + pool.oracleTxHash, + "MedianUpdated snapshot should persist the triggering tx hash", + ); }); // --------------------------------------------------------------------------- @@ -1374,6 +1390,8 @@ describe("Envio Celo indexer handlers", () => { source: "fpmm_update_reserves", }); + const UPDATE_TX_HASH = + "0x000000000000000000000000000000000000000000000000000000000000b001"; const updateEvent = FPMM.UpdateReserves.createMockEvent({ reserve0: 40_000_000_000_000_000_000_000n, reserve1: 60_000_000_000_000_000_000_000n, @@ -1382,6 +1400,7 @@ describe("Envio Celo indexer handlers", () => { logIndex: 11, srcAddress: POOL_ADDR, block: { number: 801, timestamp: 1_700_006_100 }, + transaction: { hash: UPDATE_TX_HASH }, }, }); mockDb = await FPMM.UpdateReserves.processEvent({ @@ -1397,6 +1416,22 @@ describe("Envio Celo indexer handlers", () => { CONTRACT_PRICE_DIFF, `expected contract priceDifference ${CONTRACT_PRICE_DIFF} bps, got ${pool.priceDifference}`, ); + + // OracleSnapshot must be written with the triggering tx hash + const snapshotId = `${42220}_${801}_${11}`; + const snapshot = mockDb.entities.OracleSnapshot.get(snapshotId) as + | OracleSnapshotEntity + | undefined; + assert.ok( + snapshot, + "UpdateReserves snapshot must be written when rebalancingState is available", + ); + assert.equal( + snapshot!.txHash, + UPDATE_TX_HASH, + "UpdateReserves snapshot must carry the triggering tx hash", + ); + assert.equal(snapshot!.source, "update_reserves"); }); // --------------------------------------------------------------------------- @@ -1554,6 +1589,8 @@ describe("Envio Celo indexer handlers", () => { source: "fpmm_update_reserves", }); + const REBALANCED_TX_HASH = + "0x000000000000000000000000000000000000000000000000000000000000b002"; const rebalancedEvent = FPMM.Rebalanced.createMockEvent({ sender: "0x0000000000000000000000000000000000000099", priceDifferenceBefore: 3333n, @@ -1563,6 +1600,7 @@ describe("Envio Celo indexer handlers", () => { logIndex: 11, srcAddress: POOL_ADDR, block: { number: 901, timestamp: 1_700_007_100 }, + transaction: { hash: REBALANCED_TX_HASH }, }, }); mockDb = await FPMM.Rebalanced.processEvent({ @@ -1579,6 +1617,22 @@ describe("Envio Celo indexer handlers", () => { `expected event priceDifference ${EVENT_PRICE_DIFF} bps, got ${pool.priceDifference} (RPC was ${RPC_PRICE_DIFF})`, ); assert.equal(pool.rebalanceCount, 1); + + // OracleSnapshot must be written with the triggering tx hash + const snapshotId = `${42220}_${901}_${11}`; + const snapshot = mockDb.entities.OracleSnapshot.get(snapshotId) as + | OracleSnapshotEntity + | undefined; + assert.ok( + snapshot, + "Rebalanced snapshot must be written when rebalancingState is available", + ); + assert.equal( + snapshot!.txHash, + REBALANCED_TX_HASH, + "Rebalanced snapshot must carry the triggering tx hash", + ); + assert.equal(snapshot!.source, "rebalanced"); }); // --------------------------------------------------------------------------- diff --git a/ui-dashboard/AGENTS.md b/ui-dashboard/AGENTS.md index 8d863ee0..e2334743 100644 --- a/ui-dashboard/AGENTS.md +++ b/ui-dashboard/AGENTS.md @@ -4,6 +4,14 @@ Next.js 16 monitoring dashboard for Mento v3 pools. Displays real-time pool data (reserves, swaps, mints, burns) using Plotly.js charts, sourced from Hasura GraphQL. +## Before Opening PRs + +If your dashboard change touches stateful data flow — pagination, sort, search, charts tied to table state, GraphQL shape changes, degraded/error behavior, or any indexer→query→UI field path — read and apply: + +- `../docs/pr-checklists/stateful-data-ui.md` + +This is mandatory for cross-layer/stateful UI work. The checklist exists because this repo repeatedly burned review cycles on exactly these failure modes. + ## Key Files - `src/app/` — Next.js App Router pages and layouts diff --git a/ui-dashboard/src/app/pool/[poolId]/page.test.tsx b/ui-dashboard/src/app/pool/[poolId]/page.test.tsx index 87d06bfb..80cf2da7 100644 --- a/ui-dashboard/src/app/pool/[poolId]/page.test.tsx +++ b/ui-dashboard/src/app/pool/[poolId]/page.test.tsx @@ -15,6 +15,8 @@ import type { } from "@/lib/types"; import { ORACLE_SNAPSHOTS, + ORACLE_SNAPSHOTS_CHART, + ORACLE_SNAPSHOTS_COUNT, POOL_DEPLOYMENT, POOL_DETAIL_WITH_HEALTH, POOL_LIQUIDITY, @@ -138,8 +140,24 @@ vi.mock("@/components/table", () => ({ Table: ({ children }: { children: React.ReactNode }) => ( {children}
), - Td: ({ children }: { children: React.ReactNode }) => {children}, - Th: ({ children }: { children: React.ReactNode }) => {children}, + Td: ( + props: React.ComponentPropsWithoutRef<"td"> & { + small?: boolean; + mono?: boolean; + muted?: boolean; + align?: "left" | "right"; + }, + ) => { + const { children, small, mono, muted, align, ...domProps } = props; + void small; + void mono; + void muted; + void align; + return {children}; + }, + Th: ({ children, ...props }: React.ComponentPropsWithoutRef<"th">) => ( + {children} + ), })); vi.mock("@/components/tx-hash-cell", () => ({ TxHashCell: ({ txHash }: { txHash: string }) => {txHash}, @@ -150,6 +168,8 @@ import PoolDetailPage from "./page"; let currentSearchParams = new URLSearchParams(); let interactiveContainer: HTMLDivElement | null = null; let interactiveRoot: Root | null = null; +let oracleCount = 51; +let oracleCountError = false; const basePool: Pool = { id: "pool-1", @@ -244,6 +264,7 @@ const oracleRows: OracleSnapshot[] = [ numReporters: 5, blockNumber: "5001", timestamp: "1700000600", + txHash: "0xabc123def456", }, ]; @@ -262,27 +283,44 @@ beforeEach(() => { useGQLMock.mockReset(); getLabelMock.mockClear(); currentSearchParams = new URLSearchParams(); + oracleCount = 51; + oracleCountError = false; window.history.replaceState({}, "", "/pool/pool-1"); - useGQLMock.mockImplementation((query: unknown) => { - if (query === POOL_DETAIL_WITH_HEALTH) - return makeGqlResult({ Pool: [basePool] }); - if (query === TRADING_LIMITS) - return makeGqlResult({ TradingLimit: [] satisfies TradingLimit[] }); - if (query === POOL_DEPLOYMENT) - return makeGqlResult({ FactoryDeployment: [{ txHash: "0xdeploy" }] }); - if (query === POOL_SWAPS) return makeGqlResult({ SwapEvent: swaps }); - if (query === POOL_SNAPSHOTS) return makeGqlResult({ PoolSnapshot: [] }); - if (query === POOL_RESERVES) - return makeGqlResult({ ReserveUpdate: reserves }); - if (query === POOL_REBALANCES) - return makeGqlResult({ RebalanceEvent: rebalances }); - if (query === POOL_LIQUIDITY) - return makeGqlResult({ LiquidityEvent: liquidity }); - if (query === ORACLE_SNAPSHOTS) - return makeGqlResult({ OracleSnapshot: oracleRows }); - return makeGqlResult({}); - }); + useGQLMock.mockImplementation( + (query: unknown, variables?: { offset?: number; limit?: number }) => { + if (query === POOL_DETAIL_WITH_HEALTH) + return makeGqlResult({ Pool: [basePool] }); + if (query === TRADING_LIMITS) + return makeGqlResult({ TradingLimit: [] satisfies TradingLimit[] }); + if (query === POOL_DEPLOYMENT) + return makeGqlResult({ FactoryDeployment: [{ txHash: "0xdeploy" }] }); + if (query === POOL_SWAPS) return makeGqlResult({ SwapEvent: swaps }); + if (query === POOL_SNAPSHOTS) return makeGqlResult({ PoolSnapshot: [] }); + if (query === POOL_RESERVES) + return makeGqlResult({ ReserveUpdate: reserves }); + if (query === POOL_REBALANCES) + return makeGqlResult({ RebalanceEvent: rebalances }); + if (query === POOL_LIQUIDITY) + return makeGqlResult({ LiquidityEvent: liquidity }); + if (query === ORACLE_SNAPSHOTS) + return makeGqlResult({ + OracleSnapshot: oracleRows.map((row, index) => ({ + ...row, + id: `oracle-${(variables?.offset ?? 0) + index + 1}`, + })), + }); + if (query === ORACLE_SNAPSHOTS_CHART) + return makeGqlResult({ OracleSnapshot: oracleRows }); + if (query === ORACLE_SNAPSHOTS_COUNT) + return oracleCountError + ? { data: null, error: new Error("count failed"), isLoading: false } + : makeGqlResult({ + OracleSnapshot_aggregate: { aggregate: { count: oracleCount } }, + }); + return makeGqlResult({}); + }, + ); }); afterEach(() => { @@ -381,6 +419,189 @@ describe("Pool detail tab search", () => { expect(html).toContain("No oracle snapshots match your search."); }); + it("links oracle source and time to the transaction explorer URL", () => { + const html = renderWithParams({ tab: "oracle" }); + expect(html).toContain('href="https://celoscan.io/tx/0xabc123def456"'); + expect(html).toContain(">median-feed"); + }); + + it("loads chart and count oracle queries and renders pagination metadata", () => { + const html = renderWithParams({ tab: "oracle" }); + expect(useGQLMock).toHaveBeenCalledWith( + ORACLE_SNAPSHOTS_COUNT, + expect.objectContaining({ poolId: "pool-1" }), + ); + expect(useGQLMock).toHaveBeenCalledWith( + ORACLE_SNAPSHOTS_CHART, + expect.objectContaining({ poolId: "pool-1", limit: 200 }), + ); + expect(html).toContain("51 total"); + expect(html).toContain("page 1 of 3"); + }); + + it("exposes aria-sort on sortable oracle headers", () => { + const html = renderWithParams({ tab: "oracle" }); + expect(html).toContain('aria-sort="descending"'); + expect(html).toContain("Time ↓"); + expect(html).toContain('aria-label="First page"'); + }); + + it("updates aria-sort when oracle sort changes", () => { + const container = renderInteractive({ tab: "oracle" }); + const priceDiffButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("Price Diff")) as + | HTMLButtonElement + | undefined; + + expect(priceDiffButton).toBeTruthy(); + + act(() => { + priceDiffButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + const ascendingHeaders = Array.from( + container.querySelectorAll("th"), + ).filter((th) => th.getAttribute("aria-sort") === "ascending"); + expect(ascendingHeaders).toHaveLength(0); + const descendingHeaders = Array.from( + container.querySelectorAll("th"), + ).filter((th) => th.getAttribute("aria-sort") === "descending"); + expect( + descendingHeaders.some((th) => th.textContent?.includes("Price Diff")), + ).toBe(true); + }); + + it("updates oracle query offset when pagination changes page", () => { + const container = renderInteractive({ tab: "oracle" }); + const nextButton = container.querySelector( + 'button[aria-label="Next page"]', + ) as HTMLButtonElement; + + expect(nextButton).toBeTruthy(); + expect(useGQLMock).toHaveBeenCalledWith( + ORACLE_SNAPSHOTS, + expect.objectContaining({ offset: 0, limit: 25 }), + ); + + act(() => { + nextButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(useGQLMock).toHaveBeenCalledWith( + ORACLE_SNAPSHOTS, + expect.objectContaining({ offset: 25, limit: 25 }), + ); + expect(container.textContent).toContain("page 2 of 3"); + }); + + it("preserves pagination metadata when the count query later fails", () => { + const container = renderInteractive({ tab: "oracle" }); + expect(container.textContent).toContain("51 total · page 1 of 3"); + + oracleCountError = true; + act(() => { + interactiveRoot?.render(); + }); + + expect(container.textContent).toContain("51 total · page 1 of 3"); + expect(container.textContent).toContain( + "Could not load total count — pagination may be incomplete.", + ); + }); + + it("shows degraded search warning when count fails before first success", () => { + oracleCountError = true; + const html = renderWithParams({ tab: "oracle", oracleQ: "median" }); + expect(html).toContain( + "Could not load total count — search covers the most recent 500 snapshots only.", + ); + }); + + it("caps oracle search fetch size and shows a warning for large result sets", () => { + oracleCount = 5000; + const html = renderWithParams({ tab: "oracle", oracleQ: "median" }); + + expect(useGQLMock).toHaveBeenCalledWith( + ORACLE_SNAPSHOTS, + expect.objectContaining({ offset: 0, limit: 2000 }), + ); + expect(html).toContain( + "Search is limited to the most recent 2,000 snapshots.", + ); + }); + + it("clamps oracle fetch offset when total count shrinks below current page", () => { + // Start on page 3 of 3 with count=51 + const container = renderInteractive({ tab: "oracle" }); + const nextButton = container.querySelector( + 'button[aria-label="Next page"]', + ) as HTMLButtonElement; + act(() => { + nextButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + act(() => { + nextButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(container.textContent).toContain("page 3 of 3"); + // On page 3, offset should be 50 + expect(useGQLMock).toHaveBeenCalledWith( + ORACLE_SNAPSHOTS, + expect.objectContaining({ offset: 50 }), + ); + + // Count shrinks — only 1 page now + oracleCount = 20; + useGQLMock.mockClear(); + act(() => { + interactiveRoot?.render(); + }); + + // rawPage is still 3, but totalPages is now 1 — clamped page = 1 → offset = 0. + // Pagination hides when there's only 1 page, so we assert on the query offset. + expect(useGQLMock).toHaveBeenCalledWith( + ORACLE_SNAPSHOTS, + expect.objectContaining({ offset: 0 }), + ); + // Pagination is hidden (single page) + expect(container.textContent).not.toContain("page 3"); + }); + + it("aria-sort is none for all headers while search is active", () => { + // Default sort is timestamp desc — aria-sort should be hidden during search + const html = renderWithParams({ tab: "oracle", oracleQ: "median" }); + // No header should advertise a sort while search overrides it + expect(html).not.toContain('aria-sort="descending"'); + expect(html).not.toContain('aria-sort="ascending"'); + // All should be none + const noneMatches = (html.match(/aria-sort="none"/g) ?? []).length; + expect(noneMatches).toBeGreaterThan(0); + }); + + it("oracle search always uses timestamp desc order regardless of active table sort", () => { + // With a non-default sort active, a search must still fetch the most recent + // window (timestamp desc) so the "most recent N" warning text is accurate. + // We test this by rendering with oracleQ already set AND checking what + // orderBy was passed to ORACLE_SNAPSHOTS when search is active. + const html = renderWithParams({ tab: "oracle", oracleQ: "median" }); + expect(html).toContain("median-feed"); // search found a result + + // All ORACLE_SNAPSHOTS calls while searching must use timestamp desc + const oracleCalls = useGQLMock.mock.calls.filter( + (call) => call[0] === ORACLE_SNAPSHOTS, + ); + expect(oracleCalls.length).toBeGreaterThan(0); + for (const [, vars] of oracleCalls) { + const orderJson = JSON.stringify(vars?.orderBy ?? []); + expect(orderJson).toContain("timestamp"); + expect(orderJson).not.toContain("priceDifference"); + expect(orderJson).not.toContain("oracleOk"); + expect(orderJson).not.toContain("oraclePrice"); + } + }); + it("preserves newer url params when a debounced search commit fires later", () => { const container = renderInteractive(); const input = container.querySelector( diff --git a/ui-dashboard/src/app/pool/[poolId]/page.tsx b/ui-dashboard/src/app/pool/[poolId]/page.tsx index b82f7474..cce42347 100644 --- a/ui-dashboard/src/app/pool/[poolId]/page.tsx +++ b/ui-dashboard/src/app/pool/[poolId]/page.tsx @@ -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)} ))} -
- setURL(tab, l)} - /> -
+ {/* Oracle tab manages its own page size — hide the global limit selector */} + {tab !== "oracle" && ( +
+ setURL(tab, l)} + /> +
+ )}
@@ -381,7 +387,6 @@ function PoolDetail() { {tab === "oracle" && ( setTabSearch("oracle", value)} @@ -1168,15 +1173,42 @@ 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; +// Before the aggregate count arrives, fetch a bounded first window so search +// works immediately. +const ORACLE_SEARCH_BOOTSTRAP_LIMIT = 500; +// Keep client-side search bounded even after count resolves. +const ORACLE_SEARCH_MAX_LIMIT = 2000; + +/** + * Build a stable Hasura order_by array. The primary sort is the chosen column; + * id is always appended as a unique tiebreaker so page boundaries remain + * deterministic even when multiple rows share the same timestamp or sort key. + */ +function buildOrderBy( + col: OracleSortCol, + dir: "asc" | "desc", +): Array>> { + const primary: Partial> = { [col]: dir }; + if (col === "timestamp") return [primary, { id: "asc" }]; + // Secondary: timestamp desc; tertiary: id asc (unique) for full stability. + return [primary, { timestamp: "desc" }, { id: "asc" }]; +} + function OracleTab({ poolId, - limit, pool, search, onSearchChange, }: { poolId: string; - limit: number; pool: Pool | null; search: string; onSearchChange: (value: string) => void; @@ -1184,35 +1216,111 @@ function OracleTab({ const { network } = useNetwork(); const query = normalizeSearch(search); + const [rawPage, setRawPage] = React.useState(1); + const [sortCol, setSortCol] = React.useState("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); + setRawPage(1); + }, + [onSearchChange], + ); + + 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; + + // Clamp page to valid range once total is known, so a stale page + // index never leaves the user stranded past the last page. + const totalPages = total > 0 ? Math.ceil(total / ORACLE_PAGE_SIZE) : 1; + const page = Math.max(1, Math.min(rawPage, totalPages)); + const setPage = React.useCallback((p: number) => setRawPage(p), []); + + // When search is active: fetch from offset 0 so filtering spans a large + // bounded window rather than just the current page. Bootstrap before count + // resolves, then expand up to a capped maximum to avoid unbounded pulls. + // Always use timestamp desc for search queries so "most recent N" is accurate + // regardless of the current table sort column. + const isSearching = query.length > 0; + const searchFetchLimit = + total > 0 + ? Math.min(total, ORACLE_SEARCH_MAX_LIMIT) + : ORACLE_SEARCH_BOOTSTRAP_LIMIT; + const fetchLimit = isSearching ? searchFetchLimit : ORACLE_PAGE_SIZE; + const isSearchCapped = isSearching && total > ORACLE_SEARCH_MAX_LIMIT; + const fetchOffset = isSearching ? 0 : (page - 1) * ORACLE_PAGE_SIZE; + // Table sort (user-controlled) + const tableOrderBy = useMemo( + () => buildOrderBy(sortCol, sortDir), + [sortCol, sortDir], + ); + // Search always uses newest-first so the bounded window is chronologically + // consistent with what the warning text says ("most recent N snapshots") + const searchOrderBy = useMemo(() => buildOrderBy("timestamp", "desc"), []); + const orderBy = isSearching ? searchOrderBy : tableOrderBy; + const { data, error, isLoading } = useGQL<{ OracleSnapshot: OracleSnapshot[]; - }>(ORACLE_SNAPSHOTS, { poolId, limit }); + }>(ORACLE_SNAPSHOTS, { + poolId, + limit: fetchLimit, + offset: fetchOffset, + orderBy, + }); + 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, + r.txHash, ]); }); - }, [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"); + } + setRawPage(1); + }, + [sortCol], + ); if (pool?.source?.includes("virtual")) { return ; @@ -1225,75 +1333,189 @@ function OracleTab({ ); + // Arrows and aria-sort are suppressed during search: sort controls remain + // clickable (to stage a sort for when search is cleared) but the UI does not + // announce a sort that isn't currently applied to the visible rows. + const arrow = (col: OracleSortCol) => + !isSearching && sortCol === col ? (sortDir === "asc" ? " ↑" : " ↓") : ""; + const ariaSortFor = ( + col: OracleSortCol, + ): "ascending" | "descending" | "none" => + !isSearching && sortCol === col + ? sortDir === "asc" + ? "ascending" + : "descending" + : "none"; + return ( <> - + {filteredRows.length === 0 ? ( ) : ( - - - - - - - - - - - - - - - {filteredRows.map((r) => ( - - - + + + + + + + {filteredRows.map((r) => { + const txUrl = r.txHash + ? `${network.explorerBaseUrl}/tx/${r.txHash}` + : null; + const diffBps = Number(r.priceDifference); + const thresholdBps = r.rebalanceThreshold; + const diffPct = + diffBps > 0 && thresholdBps > 0 + ? ((diffBps / thresholdBps) * 100).toFixed(1) + : null; + return ( + + + + + + + + + ); + })} + +
SourceOracle OK - Price ({sym0}/{sym1}) - Price DiffThresholdReportersBlockTime
- - {r.source} - - - + + + + + - - - - - - - ))} - -
Source + - {parseOraclePriceToNumber(r.oraclePrice, sym0).toFixed(6)} - - {Number(r.priceDifference) > 0 ? r.priceDifference : "—"} - - {r.rebalanceThreshold > 0 ? r.rebalanceThreshold : "—"} - - {r.numReporters} - - {formatBlock(r.blockNumber)} - - {relativeTime(r.timestamp)} -
+ Oracle OK{arrow("oracleOk")} + + +
+ + + + Threshold + +
+ {txUrl ? ( + + {r.source} + + ) : ( + + {r.source} + + )} + + + {r.oracleOk ? "✓" : "✗"} + + + {parseOraclePriceToNumber(r.oraclePrice, sym0).toFixed(6)} + + {diffBps > 0 ? ( + + {diffPct !== null ? `${diffPct}%` : `${diffBps} bps`} + + ) : ( + "—" + )} + + {thresholdBps > 0 ? ( + + {(thresholdBps / 100).toFixed(2)}% + + ) : ( + "—" + )} + + {txUrl ? ( + + {relativeTime(r.timestamp)} + + ) : ( + relativeTime(r.timestamp) + )} +
+ {!isSearching && ( + + )} + {isSearchCapped && ( +

+ Search is limited to the most recent{" "} + {ORACLE_SEARCH_MAX_LIMIT.toLocaleString()} snapshots. +

+ )} + {countError && isSearching && ( +

+ Could not load total count — search covers the most recent{" "} + {ORACLE_SEARCH_BOOTSTRAP_LIMIT.toLocaleString()} snapshots only. +

+ )} + {countError && !isSearching && ( +

+ Could not load total count — pagination may be incomplete. +

+ )} + )} ); diff --git a/ui-dashboard/src/components/pagination.tsx b/ui-dashboard/src/components/pagination.tsx new file mode 100644 index 00000000..801a5e21 --- /dev/null +++ b/ui-dashboard/src/components/pagination.tsx @@ -0,0 +1,74 @@ +"use client"; + +interface PaginationProps { + page: number; // 1-indexed + pageSize: number; + total: number; + onPageChange: (page: number) => void; +} + +export function Pagination({ + page, + pageSize, + total, + onPageChange, +}: PaginationProps) { + if (pageSize <= 0 || total <= 0) return null; + const totalPages = Math.ceil(total / pageSize); + if (totalPages <= 1) return null; + + const canPrev = page > 1; + const canNext = page < totalPages; + + const btnBase = + "px-2.5 py-1 text-xs font-medium rounded border transition-colors"; + const btnActive = + "border-slate-600 text-slate-300 hover:border-indigo-500 hover:text-indigo-400"; + const btnDisabled = "border-slate-800 text-slate-600 cursor-not-allowed"; + + return ( +
+ + {total.toLocaleString()} total · page {page} of {totalPages} + +
+ + + + +
+
+ ); +} diff --git a/ui-dashboard/src/components/table.tsx b/ui-dashboard/src/components/table.tsx index b1788946..8c98dd14 100644 --- a/ui-dashboard/src/components/table.tsx +++ b/ui-dashboard/src/components/table.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { ComponentPropsWithoutRef, ReactNode } from "react"; export function Table({ children }: { children: ReactNode }) { return ( @@ -19,14 +19,23 @@ export function Row({ children }: { children: ReactNode }) { export function Th({ children, align = "left", + className, + ...props }: { children: ReactNode; align?: "left" | "right"; -}) { +} & ComponentPropsWithoutRef<"th">) { return ( {children} diff --git a/ui-dashboard/src/lib/queries.ts b/ui-dashboard/src/lib/queries.ts index 71a6b926..7e0606a7 100644 --- a/ui-dashboard/src/lib/queries.ts +++ b/ui-dashboard/src/lib/queries.ts @@ -184,11 +184,12 @@ export const TRADING_LIMITS = ` `; export const ORACLE_SNAPSHOTS = ` - query OracleSnapshots($poolId: String!, $limit: Int!) { + query OracleSnapshots($poolId: String!, $limit: Int!, $offset: Int!, $orderBy: [OracleSnapshot_order_by!]!) { OracleSnapshot( where: { poolId: { _eq: $poolId } } - order_by: { timestamp: asc } + order_by: $orderBy limit: $limit + offset: $offset ) { id chainId poolId @@ -200,6 +201,40 @@ export const ORACLE_SNAPSHOTS = ` rebalanceThreshold source blockNumber + txHash + } + } +`; + +// Separate query for charts — always fetches the most recent N snapshots +// ordered by timestamp desc, then reversed client-side for chronological display. +// Decoupled from table pagination so charts always show full history context. +export const ORACLE_SNAPSHOTS_CHART = ` + query OracleSnapshotsChart($poolId: String!, $limit: Int!) { + OracleSnapshot( + where: { poolId: { _eq: $poolId } } + order_by: { timestamp: desc } + limit: $limit + ) { + id chainId + poolId + timestamp + oraclePrice + oracleOk + numReporters + priceDifference + rebalanceThreshold + source + blockNumber + txHash + } + } +`; + +export const ORACLE_SNAPSHOTS_COUNT = ` + query OracleSnapshotsCount($poolId: String!) { + OracleSnapshot_aggregate(where: { poolId: { _eq: $poolId } }) { + aggregate { count } } } `; diff --git a/ui-dashboard/src/lib/types.ts b/ui-dashboard/src/lib/types.ts index 95f3f981..eb2c21c4 100644 --- a/ui-dashboard/src/lib/types.ts +++ b/ui-dashboard/src/lib/types.ts @@ -57,6 +57,7 @@ export type OracleSnapshot = { rebalanceThreshold: number; source: string; blockNumber: string; + txHash: string; }; export type SwapEvent = {