Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions app/api/custom-stat/route.ts
Original file line number Diff line number Diff line change
@@ -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<CustomStatConfig>;
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<CustomStatConfig> : [ 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 });
}
}
1 change: 1 addition & 0 deletions client/shared/analytics/mixpanel/get-page-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/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']) {
Expand Down
1 change: 1 addition & 0 deletions client/shared/metadata/get-page-og-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/api/csrf': 'Regular page',
'/api/healthz': 'Regular page',
'/api/config': 'Regular page',
'/api/custom-stat': 'Regular page',
};

export default function getPageOgType(pathname: Route['pathname']) {
Expand Down
1 change: 1 addition & 0 deletions client/shared/metadata/templates/description.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/api/csrf': DEFAULT_TEMPLATE,
'/api/healthz': DEFAULT_TEMPLATE,
'/api/config': DEFAULT_TEMPLATE,
'/api/custom-stat': DEFAULT_TEMPLATE,
};

const TEMPLATE_MAP_ENHANCED: Partial<Record<Route['pathname'], string>> = {
Expand Down
1 change: 1 addition & 0 deletions client/shared/metadata/templates/title.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/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<Record<Route['pathname'], string>> = {
Expand Down
112 changes: 112 additions & 0 deletions client/slices/home/pages/index/stats/CustomStatWidget.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[key]
: undefined,
data,
);
}

interface SingleStatProps {
config: CustomStatConfig;
index: number;
}

const SingleCustomStat = ({ config, index }: SingleStatProps) => {
const [ value, setValue ] = React.useState<string>('...');
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<unknown>;
})
.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 (
<StatsWidget
label={ config.label || 'Custom Stat' }
value={ value }
icon={ config.icon }
isLoading={ isLoading }
/>
);
};

const CustomStatWidget = () => {
const [ configs, setConfigs ] = React.useState<Array<CustomStatConfig> | null>(null);

React.useEffect(() => {
fetch('/custom-config.json')
.then((r) => r.json() as Promise<CustomStatConfig | Array<CustomStatConfig>>)
.then((data) => {
setConfigs(Array.isArray(data) ? data : [ data ]);
})
.catch(() => setConfigs([]));
}, []);

if (!configs) {
// Show a single loading placeholder while config is being fetched
return <StatsWidget label="Custom Stat" value="..." isLoading={ true }/>;
}

if (configs.length === 0) {
return null;
}

return (
<>
{ configs.map((cfg, i) => (
<SingleCustomStat key={ i } config={ cfg } index={ i }/>
)) }
</>
);
};

export default CustomStatWidget;
2 changes: 2 additions & 0 deletions client/slices/home/pages/index/stats/Stats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -216,6 +217,7 @@ const Stats = () => {
/>
);
}) }
<CustomStatWidget/>
</Grid>
);
};
Expand Down