diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000000..600641cf2a --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,40 @@ +name: Build and push Docker image + +on: + push: + branches: [ dmd ] + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ghcr.io/msalman6/blockscout-frontend:dmd + ghcr.io/msalman6/blockscout-frontend:latest + build-args: | + GIT_COMMIT_SHA=${{ github.sha }} + GIT_TAG=dmd + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/app/api/custom-stat/route.ts b/app/api/custom-stat/route.ts new file mode 100644 index 0000000000..349fe3405a --- /dev/null +++ b/app/api/custom-stat/route.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import { NextRequest, NextResponse } from 'next/server'; + +interface CustomStatConfig { + url: string; + label: string; + jsonPath: string; +} + +export async function GET(req: NextRequest) { + let configs: Array; + try { + // eslint-disable-next-line no-restricted-properties + const path = require('path'); + // eslint-disable-next-line no-restricted-properties + const { readFileSync } = require('fs'); + const configPath = path.resolve('public', 'custom-config.json'); + const raw = JSON.parse(readFileSync(configPath, 'utf-8')) as unknown; + configs = Array.isArray(raw) ? raw as Array : [ raw as CustomStatConfig ]; + } catch (e) { + return NextResponse.json({ error: 'Failed to read config' }, { status: 500 }); + } + + const { searchParams } = new URL(req.url); + const indexParam = searchParams.get('index'); + const index = indexParam !== null ? Number(indexParam) : 0; + + if (isNaN(index) || index < 0 || index >= configs.length) { + return NextResponse.json({ error: 'Stat index out of range' }, { status: 404 }); + } + + const config = configs[index]; + + if (!config.url) { + return new NextResponse(null, { status: 204 }); + } + + try { + const upstream = await fetch(config.url); + if (!upstream.ok) { + return NextResponse.json({ error: `Upstream returned ${ upstream.status }` }, { status: upstream.status }); + } + const data = await upstream.json() as unknown; + const normalized = (typeof data === 'object' && data !== null) ? data : { value: data }; + return NextResponse.json(normalized); + } catch { + return NextResponse.json({ error: 'Failed to fetch upstream' }, { status: 502 }); + } +} diff --git a/client/shared/analytics/mixpanel/get-page-type.ts b/client/shared/analytics/mixpanel/get-page-type.ts index ffcbec6f5a..21132dbb0e 100644 --- a/client/shared/analytics/mixpanel/get-page-type.ts +++ b/client/shared/analytics/mixpanel/get-page-type.ts @@ -96,6 +96,7 @@ export const PAGE_TYPE_DICT: Record = { '/api/csrf': 'Node API: CSRF token', '/api/healthz': 'Node API: Health check', '/api/config': 'Node API: App config', + '/api/custom-stat': 'Node API: Custom stat', }; export default function getPageType(pathname: Route['pathname']) { diff --git a/client/shared/metadata/get-page-og-type.ts b/client/shared/metadata/get-page-og-type.ts index 75fc3f9f4a..a177c96f0e 100644 --- a/client/shared/metadata/get-page-og-type.ts +++ b/client/shared/metadata/get-page-og-type.ts @@ -98,6 +98,7 @@ const OG_TYPE_DICT: Record = { '/api/csrf': 'Regular page', '/api/healthz': 'Regular page', '/api/config': 'Regular page', + '/api/custom-stat': 'Regular page', }; export default function getPageOgType(pathname: Route['pathname']) { diff --git a/client/shared/metadata/templates/description.ts b/client/shared/metadata/templates/description.ts index 4f53a40bcb..4af15f7b30 100644 --- a/client/shared/metadata/templates/description.ts +++ b/client/shared/metadata/templates/description.ts @@ -101,6 +101,7 @@ const TEMPLATE_MAP: Record = { '/api/csrf': DEFAULT_TEMPLATE, '/api/healthz': DEFAULT_TEMPLATE, '/api/config': DEFAULT_TEMPLATE, + '/api/custom-stat': DEFAULT_TEMPLATE, }; const TEMPLATE_MAP_ENHANCED: Partial> = { diff --git a/client/shared/metadata/templates/title.ts b/client/shared/metadata/templates/title.ts index cef8c5046d..afaee6c147 100644 --- a/client/shared/metadata/templates/title.ts +++ b/client/shared/metadata/templates/title.ts @@ -104,6 +104,7 @@ const TEMPLATE_MAP: Record = { '/api/csrf': '%network_name% node API CSRF token', '/api/healthz': '%network_name% node API health check', '/api/config': '%network_name% node API app config', + '/api/custom-stat': '%network_name% node API custom stat', }; const TEMPLATE_MAP_ENHANCED: Partial> = { diff --git a/client/slices/home/pages/index/stats/CustomStatWidget.tsx b/client/slices/home/pages/index/stats/CustomStatWidget.tsx new file mode 100644 index 0000000000..2390265b2f --- /dev/null +++ b/client/slices/home/pages/index/stats/CustomStatWidget.tsx @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import React from 'react'; + +import type { IconName } from 'ui/shared/IconSvg'; +import StatsWidget from 'ui/shared/stats/StatsWidget'; + +interface CustomStatConfig { + url: string; + label: string; + jsonPath: string; + icon?: IconName; +} + +function getNestedValue(data: unknown, path: string): unknown { + return path.split('.').reduce( + (obj: unknown, key: string) => + obj !== null && typeof obj === 'object' + ? (obj as Record)[key] + : undefined, + data, + ); +} + +interface SingleStatProps { + config: CustomStatConfig; + index: number; +} + +const SingleCustomStat = ({ config, index }: SingleStatProps) => { + const [ value, setValue ] = React.useState('...'); + const [ isLoading, setIsLoading ] = React.useState(true); + const [ hidden, setHidden ] = React.useState(false); + + React.useEffect(() => { + const controller = new AbortController(); + + fetch(`/node-api/custom-stat?index=${ index }`, { signal: controller.signal }) + .then((res) => { + if (res.status === 204) { + setHidden(true); + setIsLoading(false); + return null; + } + if (!res.ok) { + throw new Error(`HTTP ${ res.status }`); + } + return res.json() as Promise; + }) + .then((data) => { + if (data === null) { + return; + } + const val = getNestedValue(data, config.jsonPath || 'value'); + setValue(val !== undefined && val !== null ? String(val) : 'N/A'); + setIsLoading(false); + }) + .catch((err: unknown) => { + if ((err as { name?: string }).name !== 'AbortError') { + setValue('N/A'); + setIsLoading(false); + } + }); + + return () => controller.abort(); + }, [ config.jsonPath, index ]); + + if (hidden) { + return null; + } + + return ( + + ); +}; + +const CustomStatWidget = () => { + const [ configs, setConfigs ] = React.useState | null>(null); + + React.useEffect(() => { + fetch('/custom-config.json') + .then((r) => r.json() as Promise>) + .then((data) => { + setConfigs(Array.isArray(data) ? data : [ data ]); + }) + .catch(() => setConfigs([])); + }, []); + + if (!configs) { + // Show a single loading placeholder while config is being fetched + return ; + } + + if (configs.length === 0) { + return null; + } + + return ( + <> + { configs.map((cfg, i) => ( + + )) } + + ); +}; + +export default CustomStatWidget; diff --git a/client/slices/home/pages/index/stats/Stats.tsx b/client/slices/home/pages/index/stats/Stats.tsx index 0b11fbdffb..77f05a9a44 100644 --- a/client/slices/home/pages/index/stats/Stats.tsx +++ b/client/slices/home/pages/index/stats/Stats.tsx @@ -24,6 +24,7 @@ import { WEI } from 'ui/shared/value/utils'; import LatestBatchStatsWidget from './LatestBatchStatsWidget'; import LatestBlockStatsWidget from './LatestBlockStatsWidget'; import StatsDegraded from './StatsDegraded'; +import CustomStatWidget from './CustomStatWidget'; const rollupFeature = config.features.rollup; const isOptimisticRollup = rollupFeature.isEnabled && rollupFeature.type === 'optimistic'; @@ -216,6 +217,7 @@ const Stats = () => { /> ); }) } + ); };