Add mobile ops surface nav and footer public API links.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 14s
Validate Explorer / frontend (push) Successful in 1m32s
Validate Explorer / smoke-e2e (push) Failing after 1m52s

Operations pages get collapsible surface navigation on small screens and a shared action-card accordion; the footer surfaces read-only JSON endpoints with e2e coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-05-22 21:39:08 -07:00
parent 847cfeb48b
commit 991d1bb07c
11 changed files with 276 additions and 100 deletions

View File

@@ -1,4 +1,5 @@
import Link from 'next/link'
import { explorerPublicApiLinks } from '@/data/explorerOperations'
const footerLinkClass =
'text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors'
@@ -9,7 +10,7 @@ export default function Footer() {
return (
<footer className="mt-auto border-t border-gray-200 dark:border-gray-700 bg-white/90 dark:bg-gray-900/90 backdrop-blur">
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="grid gap-4 sm:gap-6 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)]">
<div className="grid gap-4 sm:gap-6 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
<div className="text-base font-semibold text-gray-900 dark:text-white sm:text-lg">
DBIS Explorer
@@ -46,6 +47,26 @@ export default function Footer() {
</ul>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Public APIs
</div>
<ul className="space-y-3 text-sm">
{explorerPublicApiLinks.map((link) => (
<li key={link.href}>
<a className={footerLinkClass} href={link.href} target="_blank" rel="noopener noreferrer">
{link.label}
</a>
<p className="mt-0.5 text-xs leading-5 text-gray-500 dark:text-gray-500">{link.description}</p>
</li>
))}
</ul>
<p className="mt-3 text-xs leading-5 text-gray-500 dark:text-gray-500">
Read-only JSON endpoints on the public explorer domain. No API key required.
</p>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Contact

View File

@@ -19,6 +19,8 @@ import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import { bridgeRoutesApi, normalizeBridgeRouteEntries, type BridgeRoutesResponse } from '@/services/api/bridgeRoutes'
import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
import { HOME_DASHBOARD_REFRESH_MS } from '@/utils/featuredTokens'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import OperationsActionGrid from './OperationsActionGrid'
type FeedState = 'connecting' | 'live' | 'fallback'
@@ -327,6 +329,8 @@ export default function BridgeMonitoringPage({
</Card>
) : null}
<OperationsSurfaceNav />
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Bridge Freshness Context" />
<FreshnessTrustNote
@@ -493,27 +497,7 @@ export default function BridgeMonitoringPage({
</Card>
) : null}
<div className="grid gap-4 lg:grid-cols-2">
{page.actions.map((action) => (
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
<div className="flex h-full flex-col">
<div className="text-base font-semibold text-gray-900 dark:text-white">
{action.title}
</div>
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
{action.description}
</p>
<div className="mt-4">
<ActionLink
href={action.href}
label={action.label}
external={'external' in action ? action.external : undefined}
/>
</div>
</div>
</Card>
))}
</div>
<OperationsActionGrid actions={page.actions} />
</div>
)
}

View File

@@ -24,6 +24,7 @@ import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import {
formatCurrency,
@@ -278,6 +279,8 @@ export default function LiquidityOperationsPage({
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
</div>
<OperationsSurfaceNav />
{loadingError ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>

View File

@@ -0,0 +1,73 @@
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import type { ExplorerFeatureAction } from '@/data/explorerOperations'
export function OperationsActionLink({ action }: { action: ExplorerFeatureAction }) {
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
const label = `${action.label} ->`
if (action.external) {
return (
<a href={action.href} className={className} target="_blank" rel="noopener noreferrer">
{label}
</a>
)
}
return (
<Link href={action.href} className={className}>
{label}
</Link>
)
}
function ActionCard({ action }: { action: ExplorerFeatureAction }) {
return (
<Card className="border border-gray-200 dark:border-gray-700">
<div className="flex h-full flex-col">
<div className="text-base font-semibold text-gray-900 dark:text-white">{action.title}</div>
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">{action.description}</p>
<div className="mt-4">
<OperationsActionLink action={action} />
</div>
</div>
</Card>
)
}
export default function OperationsActionGrid({
actions,
title = 'Quick actions',
}: {
actions: ExplorerFeatureAction[]
title?: string
}) {
if (actions.length === 0) return null
return (
<>
<details className="group mb-6 rounded-2xl border border-gray-200 bg-gray-50/80 dark:border-gray-800 dark:bg-gray-900/40 md:hidden">
<summary className="cursor-pointer list-none px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white [&::-webkit-details-marker]:hidden">
<span className="flex items-center justify-between gap-3">
{title}
<span className="text-xs font-normal uppercase tracking-wide text-gray-500">
{actions.length} links · <span className="group-open:hidden">Show</span>
<span className="hidden group-open:inline">Hide</span>
</span>
</span>
</summary>
<div className="space-y-3 border-t border-gray-200 px-3 py-3 dark:border-gray-800">
{actions.map((action) => (
<ActionCard key={`${action.title}-${action.href}`} action={action} />
))}
</div>
</details>
<div className="hidden gap-4 md:grid lg:grid-cols-2">
{actions.map((action) => (
<ActionCard key={`${action.title}-${action.href}`} action={action} />
))}
</div>
</>
)
}

View File

@@ -12,6 +12,8 @@ import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
import OperationsActionGrid from '@/components/explorer/OperationsActionGrid'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
@@ -192,6 +194,8 @@ export default function OperationsHubPage({
</Card>
) : null}
<OperationsSurfaceNav />
{loadingError ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
@@ -338,27 +342,7 @@ export default function OperationsHubPage({
</Card>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{page.actions.map((action) => (
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
<div className="flex h-full flex-col">
<div className="text-base font-semibold text-gray-900 dark:text-white">
{action.title}
</div>
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
{action.description}
</p>
<div className="mt-4">
<ActionLink
href={action.href}
label={action.label}
external={'external' in action ? action.external : undefined}
/>
</div>
</div>
</Card>
))}
</div>
<OperationsActionGrid actions={page.actions} />
</div>
)
}

View File

@@ -1,29 +1,11 @@
import type { ReactNode } from 'react'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import type { ExplorerFeatureAction, ExplorerFeaturePage } from '@/data/explorerOperations'
import type { ExplorerFeaturePage } from '@/data/explorerOperations'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import OperationsActionGrid from './OperationsActionGrid'
export type StatusTone = 'normal' | 'warning' | 'danger'
function ActionLink({ action }: { action: ExplorerFeatureAction }) {
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
const label = `${action.label} ->`
if (action.external) {
return (
<a href={action.href} className={className} target="_blank" rel="noopener noreferrer">
{label}
</a>
)
}
return (
<Link href={action.href} className={className}>
{label}
</Link>
)
}
export function relativeAge(isoString?: string): string {
if (!isoString) return 'Unknown'
const parsed = Date.parse(isoString)
@@ -126,23 +108,11 @@ export default function OperationsPageShell({
</Card>
) : null}
<OperationsSurfaceNav />
{children}
<div className="grid gap-4 lg:grid-cols-2">
{page.actions.map((action) => (
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
<div className="flex h-full flex-col">
<div className="text-base font-semibold text-gray-900 dark:text-white">{action.title}</div>
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
{action.description}
</p>
<div className="mt-4">
<ActionLink action={action} />
</div>
</div>
</Card>
))}
</div>
<OperationsActionGrid actions={page.actions} />
</div>
)
}

View File

@@ -0,0 +1,79 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/router'
import clsx from 'clsx'
import { explorerOperationsSurfaces } from '@/data/explorerOperations'
function normalizePath(path: string): string {
if (path.length > 1 && path.endsWith('/')) {
return path.slice(0, -1)
}
return path
}
export default function OperationsSurfaceNav({ className }: { className?: string }) {
const router = useRouter()
const currentPath = normalizePath(router.pathname)
return (
<nav aria-label="Operations surfaces" className={clsx('mb-6', className)}>
<details className="group rounded-2xl border border-gray-200 bg-gray-50/80 dark:border-gray-800 dark:bg-gray-900/40 md:hidden">
<summary className="cursor-pointer list-none px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white [&::-webkit-details-marker]:hidden">
<span className="flex items-center justify-between gap-3">
Jump to operations surface
<span className="text-xs font-normal uppercase tracking-wide text-gray-500 group-open:hidden">Show</span>
<span className="hidden text-xs font-normal uppercase tracking-wide text-gray-500 group-open:inline">Hide</span>
</span>
</summary>
<ul className="space-y-1 border-t border-gray-200 px-2 py-2 dark:border-gray-800">
{explorerOperationsSurfaces.map((surface) => {
const active = currentPath === surface.href
return (
<li key={surface.href}>
<Link
href={surface.href}
className={clsx(
'block rounded-xl px-3 py-2 transition',
active
? 'bg-primary-50 text-primary-700 dark:bg-primary-950/40 dark:text-primary-200'
: 'text-gray-700 hover:bg-white dark:text-gray-300 dark:hover:bg-gray-950/60',
)}
>
<div className="text-sm font-semibold">{surface.label}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{surface.description}</div>
</Link>
</li>
)
})}
</ul>
</details>
<div className="hidden md:block">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">
Operations surfaces
</div>
<div className="mt-3 flex flex-wrap gap-2">
{explorerOperationsSurfaces.map((surface) => {
const active = currentPath === surface.href
return (
<Link
key={surface.href}
href={surface.href}
title={surface.description}
className={clsx(
'rounded-full border px-3 py-1.5 text-sm font-medium transition',
active
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-400 dark:bg-primary-950/40 dark:text-primary-200'
: 'border-gray-200 text-gray-700 hover:border-primary-300 hover:text-primary-600 dark:border-gray-700 dark:text-gray-300 dark:hover:border-primary-500 dark:hover:text-primary-300',
)}
>
{surface.label}
</Link>
)
})}
</div>
</div>
</nav>
)
}

View File

@@ -6,6 +6,7 @@ import { Card } from '@/libs/frontend-ui-primitives'
import { type TokenListResponse } from '@/services/api/config'
import { tokensApi } from '@/services/api/tokens'
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import {
aggregateLiquidityPools,
getRouteBackedPoolAddresses,
@@ -105,6 +106,8 @@ export default function PoolsOperationsPage() {
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
</div>
<OperationsSurfaceNav />
{loadingError ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>

View File

@@ -16,6 +16,8 @@ import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import OperationsActionGrid from './OperationsActionGrid'
interface RoutesMonitoringPageProps {
initialRouteMatrix?: RouteMatrixResponse | null
@@ -224,6 +226,8 @@ export default function RoutesMonitoringPage({
</Card>
) : null}
<OperationsSurfaceNav />
{loadingError ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
@@ -438,27 +442,7 @@ export default function RoutesMonitoringPage({
</Card>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{page.actions.map((action) => (
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
<div className="flex h-full flex-col">
<div className="text-base font-semibold text-gray-900 dark:text-white">
{action.title}
</div>
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
{action.description}
</p>
<div className="mt-4">
<ActionLink
href={action.href}
label={action.label}
external={Boolean((action as { external?: boolean }).external)}
/>
</div>
</div>
</Card>
))}
</div>
<OperationsActionGrid actions={page.actions} />
</div>
)
}

View File

@@ -296,3 +296,65 @@ export const explorerFeaturePages = {
],
},
} as const satisfies Record<string, ExplorerFeaturePage>
export interface ExplorerOperationsSurface {
href: string
label: string
description: string
}
export const explorerOperationsSurfaces: ExplorerOperationsSurface[] = [
{
href: '/operations',
label: 'Operations hub',
description: 'Consolidated monitoring, config, and route inventory.',
},
{
href: '/bridge',
label: 'Bridge',
description: 'Relay lanes, mission-control feed, and CCIP routes.',
},
{
href: '/routes',
label: 'Routes',
description: 'Live route matrix and execution paths.',
},
{
href: '/liquidity',
label: 'Liquidity',
description: 'PMM access points and planner capabilities.',
},
{
href: '/pools',
label: 'Pools',
description: 'Mission-control pool inventory snapshot.',
},
{
href: '/system',
label: 'System',
description: 'Networks, RPC methods, and topology inventory.',
},
]
export const explorerPublicApiLinks = [
{
href: '/api/v2/stats',
label: 'Blockscout stats',
description: 'Chain head, gas, and indexer summary.',
},
{
href: '/explorer-api/v1/track1/bridge/status',
label: 'Bridge status JSON',
description: 'Mission-control relay posture snapshot.',
},
{
href: '/token-aggregation/api/v1/routes/matrix?includeNonLive=true',
label: 'Route matrix',
description: 'Token-aggregation live and planned routes.',
},
{
href: '/api/config/networks',
label: 'Wallet networks',
description: 'Published chain metadata for wallet onboarding.',
},
] as const

View File

@@ -37,4 +37,17 @@ test.describe('Explorer sprint smoke', () => {
await expect(page.getByRole('heading', { name: /Bridge & Relay Monitoring/i })).toBeVisible({ timeout: 15000 })
await expect(page.getByText(/CCIP route catalog/i).first()).toBeVisible({ timeout: 15000 })
})
test('operations hub shows surface navigation', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 720 })
await page.goto(`${EXPLORER_URL}/operations`, { waitUntil: 'domcontentloaded', timeout: 30000 })
await expect(page.getByRole('navigation', { name: /Operations surfaces/i })).toBeVisible({ timeout: 10000 })
await expect(page.getByRole('link', { name: /Bridge/i }).first()).toBeVisible({ timeout: 10000 })
})
test('footer lists public API endpoints', async ({ page }) => {
await page.goto(`${EXPLORER_URL}/`, { waitUntil: 'domcontentloaded', timeout: 20000 })
await expect(page.getByRole('contentinfo').getByText(/Public APIs/i)).toBeVisible({ timeout: 10000 })
await expect(page.getByRole('contentinfo').getByRole('link', { name: /Blockscout stats/i })).toBeVisible({ timeout: 10000 })
})
})