-
Notifications
You must be signed in to change notification settings - Fork 3
feat: stats page v2 #322
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
feat: stats page v2 #322
Changes from all commits
278a514
7ba73c3
07efb3f
1521e17
a8e57e0
876e87c
c6aa1c8
4015e34
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 |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| 'use client'; | ||
|
|
||
| /** | ||
| * Stats V2 Dashboard (Experimental) | ||
| * | ||
| * This page uses a new cross-chain indexer API that provides Monarch transaction | ||
| * data across all chains with a single API call. | ||
| * | ||
| * NOTE: This API is experimental and may be reverted due to cost concerns. | ||
| * The old stats page at /admin/stats should be kept as a fallback. | ||
| * | ||
| * Features: | ||
| * - Cross-chain volume aggregation | ||
| * - Volume breakdown by chain | ||
| * - Supply and withdraw transaction tables | ||
| * - ETH/BTC price estimation for USD values | ||
| */ | ||
|
|
||
| import { useState } from 'react'; | ||
| import { Button } from '@/components/ui/button'; | ||
| import ButtonGroup from '@/components/ui/button-group'; | ||
| import { Spinner } from '@/components/ui/spinner'; | ||
| import Header from '@/components/layout/header/Header'; | ||
| import { PasswordGate } from '@/features/admin-v2/components/password-gate'; | ||
| import { StatsOverviewCards } from '@/features/admin-v2/components/stats-overview-cards'; | ||
| import { StatsVolumeChart } from '@/features/admin-v2/components/stats-volume-chart'; | ||
| import { ChainVolumeChart } from '@/features/admin-v2/components/chain-volume-chart'; | ||
| import { StatsTransactionsTable } from '@/features/admin-v2/components/stats-transactions-table'; | ||
| import { useMonarchTransactions, type TimeFrame } from '@/hooks/useMonarchTransactions'; | ||
| import { useAdminAuth } from '@/stores/useAdminAuth'; | ||
|
|
||
| function StatsV2Content() { | ||
| const [timeframe, setTimeframe] = useState<TimeFrame>('30D'); | ||
| const { logout } = useAdminAuth(); | ||
|
|
||
| const { | ||
| transactions, | ||
| supplies, | ||
| withdraws, | ||
| chainStats, | ||
| dailyVolumes, | ||
| totalSupplyVolumeUsd, | ||
| totalWithdrawVolumeUsd, | ||
| totalVolumeUsd, | ||
| isLoading, | ||
| error, | ||
| } = useMonarchTransactions(timeframe); | ||
|
|
||
| const timeframeOptions = [ | ||
| { key: '1D', label: '1D', value: '1D' }, | ||
| { key: '7D', label: '7D', value: '7D' }, | ||
| { key: '30D', label: '30D', value: '30D' }, | ||
| { key: '90D', label: '90D', value: '90D' }, | ||
| { key: 'ALL', label: 'ALL', value: 'ALL' }, | ||
| ]; | ||
|
|
||
| if (error) { | ||
| return ( | ||
| <div className="flex w-full flex-col font-inter"> | ||
| <Header /> | ||
| <div className="container mx-auto px-4 py-8"> | ||
| <div className="rounded-lg border border-red-500/20 bg-red-500/10 p-6 text-center"> | ||
| <h2 className="font-zen text-lg text-red-500">Error Loading Data</h2> | ||
| <p className="mt-2 text-sm text-secondary">{error.message}</p> | ||
| <Button | ||
| onClick={() => window.location.reload()} | ||
| className="mt-4" | ||
| > | ||
| Retry | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="flex w-full flex-col font-inter"> | ||
| <Header /> | ||
| <div className="container mx-auto px-4 py-8"> | ||
| {/* Header */} | ||
| <div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> | ||
| <div> | ||
| <div className="flex items-center gap-3"> | ||
| <h1 className="font-zen text-2xl font-bold">Monarch Stats</h1> | ||
| </div> | ||
| </div> | ||
| <div className="flex items-center gap-4 font-zen"> | ||
| <ButtonGroup | ||
| options={timeframeOptions} | ||
| value={timeframe} | ||
| onChange={(value) => setTimeframe(value as TimeFrame)} | ||
| size="sm" | ||
| variant="default" | ||
| /> | ||
| <Button | ||
| variant="default" | ||
| onClick={() => void logout()} | ||
| size="sm" | ||
| > | ||
| Logout | ||
| </Button> | ||
| </div> | ||
| </div> | ||
|
|
||
| {isLoading ? ( | ||
| <div className="flex h-64 w-full items-center justify-center"> | ||
| <Spinner size={40} /> | ||
| </div> | ||
| ) : ( | ||
| <div className="space-y-6"> | ||
| {/* Overview Cards */} | ||
| <StatsOverviewCards | ||
| totalSupplyVolumeUsd={totalSupplyVolumeUsd} | ||
| totalWithdrawVolumeUsd={totalWithdrawVolumeUsd} | ||
| totalVolumeUsd={totalVolumeUsd} | ||
| supplyCount={supplies.length} | ||
| withdrawCount={withdraws.length} | ||
| chainStats={chainStats} | ||
| /> | ||
|
|
||
| {/* Charts Grid */} | ||
| <div className="grid gap-6 lg:grid-cols-1"> | ||
| {/* Aggregated Volume Chart */} | ||
| <StatsVolumeChart | ||
| dailyVolumes={dailyVolumes} | ||
| totalSupplyVolumeUsd={totalSupplyVolumeUsd} | ||
| totalWithdrawVolumeUsd={totalWithdrawVolumeUsd} | ||
| isLoading={isLoading} | ||
| /> | ||
|
|
||
| {/* Chain Breakdown Chart */} | ||
| <ChainVolumeChart | ||
| dailyVolumes={dailyVolumes} | ||
| chainStats={chainStats} | ||
| isLoading={isLoading} | ||
| /> | ||
| </div> | ||
|
|
||
| {/* Transactions Table */} | ||
| <StatsTransactionsTable | ||
| transactions={transactions} | ||
| isLoading={isLoading} | ||
| /> | ||
| </div> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default function StatsV2Page() { | ||
| return ( | ||
| <PasswordGate> | ||
| <StatsV2Content /> | ||
| </PasswordGate> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import { type NextRequest, NextResponse } from 'next/server'; | ||
| import { cookies } from 'next/headers'; | ||
|
|
||
| /** | ||
| * Admin authentication endpoint | ||
| * | ||
| * POST: Login - validates password, sets httpOnly cookie | ||
| * DELETE: Logout - clears the cookie | ||
| * | ||
| * Environment variable (server-side only): | ||
| * - ADMIN_V2_PASSWORD_HASH: Expected password hash | ||
| * | ||
| * To generate a hash, run in Node: | ||
| * node -e "let h=0;for(const c of 'your-password'){h=(h<<5)-h+c.charCodeAt(0);h=h&h;}console.log(h.toString(16))" | ||
| */ | ||
|
|
||
| const EXPECTED_HASH = process.env.ADMIN_V2_PASSWORD_HASH; | ||
| const COOKIE_NAME = 'monarch_admin_session'; | ||
|
|
||
| function hashPassword(password: string): string { | ||
| let hash = 0; | ||
| for (const char of password) { | ||
| const charCode = char.charCodeAt(0); | ||
| hash = (hash << 5) - hash + charCode; | ||
| hash &= hash; | ||
| } | ||
| return hash.toString(16); | ||
| } | ||
|
|
||
| export async function POST(request: NextRequest) { | ||
| if (!EXPECTED_HASH) { | ||
| return NextResponse.json({ error: 'Authentication not configured' }, { status: 500 }); | ||
| } | ||
|
|
||
| try { | ||
| const { password } = await request.json(); | ||
|
|
||
| if (!password || typeof password !== 'string') { | ||
| return NextResponse.json({ error: 'Password required' }, { status: 400 }); | ||
| } | ||
|
|
||
| const hash = hashPassword(password); | ||
|
|
||
| if (hash !== EXPECTED_HASH) { | ||
| await new Promise((resolve) => setTimeout(resolve, 200)); | ||
| return NextResponse.json({ error: 'Invalid password' }, { status: 401 }); | ||
| } | ||
|
|
||
| // Set httpOnly cookie - client can't read this via JS | ||
| const cookieStore = await cookies(); | ||
| cookieStore.set(COOKIE_NAME, hash, { | ||
| httpOnly: true, | ||
| secure: process.env.NODE_ENV === 'production', | ||
| sameSite: 'strict', | ||
| path: '/api/admin', | ||
| maxAge: 60 * 60 * 24 * 7, // 7 days | ||
| }); | ||
|
Comment on lines
+49
to
+57
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. Cookie stores the password hash directly. If an attacker obtains 🤖 Prompt for AI Agents |
||
|
|
||
| return NextResponse.json({ success: true }); | ||
| } catch { | ||
| return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); | ||
| } | ||
| } | ||
|
|
||
| export async function DELETE() { | ||
| const cookieStore = await cookies(); | ||
| cookieStore.delete(COOKIE_NAME); | ||
| return NextResponse.json({ success: true }); | ||
| } | ||
|
|
||
| export async function GET() { | ||
| // Check if user is authenticated | ||
| const cookieStore = await cookies(); | ||
| const session = cookieStore.get(COOKIE_NAME); | ||
|
|
||
| if (!session?.value || session.value !== EXPECTED_HASH) { | ||
| return NextResponse.json({ authenticated: false }); | ||
| } | ||
|
|
||
| return NextResponse.json({ authenticated: true }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,53 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| import { type NextRequest, NextResponse } from 'next/server'; | ||||||||||||||||||||||||||||||||||||||||||||||
| import { cookies } from 'next/headers'; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||
| * Proxy API route for Monarch Indexer | ||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||
| * Validates auth via httpOnly cookie, then proxies to the actual indexer. | ||||||||||||||||||||||||||||||||||||||||||||||
| * The real endpoint URL is never exposed to the client. | ||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||
| * Environment variables (server-side only): | ||||||||||||||||||||||||||||||||||||||||||||||
| * - MONARCH_INDEXER_ENDPOINT: The actual GraphQL endpoint URL | ||||||||||||||||||||||||||||||||||||||||||||||
| * - ADMIN_V2_PASSWORD_HASH: Expected password hash (for cookie validation) | ||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const INDEXER_ENDPOINT = process.env.MONARCH_INDEXER_ENDPOINT; | ||||||||||||||||||||||||||||||||||||||||||||||
| const EXPECTED_HASH = process.env.ADMIN_V2_PASSWORD_HASH; | ||||||||||||||||||||||||||||||||||||||||||||||
| const COOKIE_NAME = 'monarch_admin_session'; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| export async function POST(request: NextRequest) { | ||||||||||||||||||||||||||||||||||||||||||||||
| // Validate auth cookie | ||||||||||||||||||||||||||||||||||||||||||||||
| const cookieStore = await cookies(); | ||||||||||||||||||||||||||||||||||||||||||||||
| const session = cookieStore.get(COOKIE_NAME); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if (!EXPECTED_HASH || !session?.value || session.value !== EXPECTED_HASH) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
antoncoding marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if (!INDEXER_ENDPOINT) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: 'Indexer endpoint not configured' }, { status: 500 }); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||
| const body = await request.json(); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
antoncoding marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||
| const response = await fetch(INDEXER_ENDPOINT, { | ||||||||||||||||||||||||||||||||||||||||||||||
| method: 'POST', | ||||||||||||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||||||||||||
| 'Content-Type': 'application/json', | ||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||
| body: JSON.stringify(body), | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+35
to
+41
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. Add a timeout to the upstream fetch. Unbounded external calls can hang a request thread and pile up. Proposed fix- const response = await fetch(INDEXER_ENDPOINT, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(body),
- });
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10_000);
+ let response: Response;
+ try {
+ response = await fetch(INDEXER_ENDPOINT, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ });
+ } finally {
+ clearTimeout(timeout);
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if (!response.ok) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: `Indexer request failed: ${response.status}` }, { status: response.status }); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const data = await response.json(); | ||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json(data); | ||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||
| console.error('Monarch indexer proxy error:', error); | ||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| /** | ||
| * Monarch Indexer GraphQL Fetcher | ||
| * | ||
| * Calls our internal API route which proxies to the actual indexer. | ||
| * Auth is handled via httpOnly cookie (set during login). | ||
| * | ||
| * NOTE: This API is experimental and may be reverted due to cost concerns. | ||
| * The old stats page at /admin/stats should be kept as a fallback. | ||
| */ | ||
|
|
||
| type GraphQLVariables = Record<string, unknown>; | ||
|
|
||
| /** | ||
| * Fetches data from the monarch indexer via our API proxy. | ||
| * Requires valid session cookie (set via /api/admin/auth). | ||
| */ | ||
| export async function monarchIndexerFetcher<T>(query: string, variables?: GraphQLVariables): Promise<T> { | ||
| const response = await fetch('/api/admin/monarch-indexer', { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| credentials: 'same-origin', // Include cookies | ||
| body: JSON.stringify({ | ||
| query, | ||
| variables, | ||
| }), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| if (response.status === 401) { | ||
| throw new Error('Unauthorized - please log in again'); | ||
| } | ||
| throw new Error(`Monarch Indexer request failed: ${response.status} ${response.statusText}`); | ||
| } | ||
|
|
||
| const result = await response.json(); | ||
|
|
||
| if (result.errors) { | ||
| throw new Error(`Monarch Indexer GraphQL error: ${JSON.stringify(result.errors)}`); | ||
| } | ||
|
|
||
| return result as T; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| /** | ||
| * Monarch Indexer Data Source | ||
| * | ||
| * A new cross-chain indexer API for fetching Monarch transactions | ||
| * across all chains with a single API call. | ||
| * | ||
| * NOTE: This API is experimental and may be reverted due to cost concerns. | ||
| * The old stats page at /admin/stats should be kept as a fallback. | ||
| */ | ||
|
|
||
| export { monarchIndexerFetcher } from './fetchers'; | ||
| export { | ||
| fetchMonarchTransactions, | ||
| type MonarchSupplyTransaction, | ||
| type MonarchWithdrawTransaction, | ||
| type TimeRange, | ||
| } from './transactions'; |
Uh oh!
There was an error while loading. Please reload this page.