Skip to content

Commit

Permalink
feat: show feature info (#461)
Browse files Browse the repository at this point in the history
Here is how new functionality looks like:
<img width="598" alt="image"
src="https://github.com/user-attachments/assets/b3286579-0fee-4f60-9a65-834e3fb0911e"
/>
<img width="598" alt="image"
src="https://github.com/user-attachments/assets/1eb21b09-334f-473a-93d8-f456f1a4393f"
/>

Variant for the feature without `simd_link`:
<img width="598" alt="image"
src="https://github.com/user-attachments/assets/be4488fb-47d7-4614-86e8-b988d3c30874"
/>

---------

Co-authored-by: Noah Gundotra <[email protected]>
  • Loading branch information
rogaldh and ngundotra authored Feb 20, 2025
1 parent 07dc7d9 commit 449ba08
Show file tree
Hide file tree
Showing 12 changed files with 1,190 additions and 24 deletions.
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,8 @@ yarn-error.log*
next-env.d.ts

# Speedy Web Compiler
.swc/
.swc/

# vim
*.sw*
.editorconfig
35 changes: 35 additions & 0 deletions app/address/[address]/feature-gate/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { FeatureGateCard } from '@components/account/FeatureGateCard';
import getReadableTitleFromAddress, { AddressPageMetadataProps } from '@utils/get-readable-title-from-address';
import { Metadata } from 'next/types';
import ReactMarkdown from 'react-markdown';
import remarkFrontmatter from 'remark-frontmatter';
import remarkGFM from 'remark-gfm';

import { fetchFeatureGateInformation } from '@/app/features/feature-gate';
import { getFeatureInfo } from '@/app/utils/feature-gate/utils';

type Props = Readonly<{
params: {
address: string;
};
}>;

export async function generateMetadata(props: AddressPageMetadataProps): Promise<Metadata> {
return {
description: `Feature information for address ${props.params.address} on Solana`,
title: `Feature Gate | ${await getReadableTitleFromAddress(props)} | Solana`,
};
}

export default async function FeatureGatePage({ params: { address } }: Props) {
const feature = getFeatureInfo(address);
const data = await fetchFeatureGateInformation(feature);

// remark-gfm won't handle github-flavoured-markdown with a table present at it
// TODO: figure out a configuration to render GFM table correctly
return (
<FeatureGateCard>
<ReactMarkdown remarkPlugins={[remarkGFM, remarkFrontmatter]}>{data}</ReactMarkdown>
</FeatureGateCard>
);
}
41 changes: 39 additions & 2 deletions app/address/[address]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { Address } from 'web3js-experimental';
import { CompressedNftAccountHeader, CompressedNftCard } from '@/app/components/account/CompressedNftCard';
import { useCompressedNft, useMetadataJsonLink } from '@/app/providers/compressed-nft';
import { useSquadsMultisigLookup } from '@/app/providers/squadsMultisig';
import { getFeatureInfo, useFeatureInfo } from '@/app/utils/feature-gate/utils';
import { FullTokenInfo, getFullTokenInfo } from '@/app/utils/token-info';
import { MintAccountInfo } from '@/app/validators/accounts/token';

Expand Down Expand Up @@ -481,6 +482,9 @@ function InfoSection({ account, tokenInfo }: { account: Account; tokenInfo?: Ful
const parsedData = account.data.parsed;
const rawData = account.data.raw;

// get feature data from featureGates.json
const featureInfo = useFeatureInfo({ address: account.pubkey.toBase58() });

if (parsedData && parsedData.program === 'bpf-upgradeable-loader') {
return (
<UpgradeableLoaderAccountSection
Expand Down Expand Up @@ -518,7 +522,7 @@ function InfoSection({ account, tokenInfo }: { account: Account; tokenInfo?: Ful
return <AddressLookupTableAccountSection account={account} lookupTableAccount={parsedData.parsed.info} />;
} else if (rawData && isAddressLookupTableAccount(account.owner.toBase58() as Address, rawData)) {
return <AddressLookupTableAccountSection account={account} data={rawData} />;
} else if (account.owner.toBase58() === FEATURE_PROGRAM_ID) {
} else if (featureInfo || account.owner.toBase58() === FEATURE_PROGRAM_ID) {
return <FeatureAccountSection account={account} />;
} else {
const fallback = <UnknownAccountCard account={account} />;
Expand Down Expand Up @@ -564,7 +568,8 @@ export type MoreTabs =
| 'concurrent-merkle-tree'
| 'compression'
| 'verified-build'
| 'program-multisig';
| 'program-multisig'
| 'feature-gate';

function MoreSection({ children, tabs }: { children: React.ReactNode; tabs: (JSX.Element | null)[] }) {
return (
Expand Down Expand Up @@ -747,6 +752,19 @@ function getCustomLinkedTabs(pubkey: PublicKey, account: Account) {
tab: accountDataTab,
});

// Feature-specific information
if (getFeatureInfo(pubkey.toBase58())) {
const featureInfoTab: Tab = {
path: 'feature-gate',
slug: 'feature-gate',
title: 'Feature Gate',
};
tabComponents.push({
component: <FeatureGateLink key={featureInfoTab.slug} tab={featureInfoTab} address={pubkey.toString()} />,
tab: featureInfoTab,
});
}

return tabComponents;
}

Expand Down Expand Up @@ -788,6 +806,25 @@ function AccountDataLink({ address, tab, programId }: { address: string; tab: Ta
);
}

function FeatureGateLink({ address, tab }: { address: string; tab: Tab }) {
const accountDataPath = useClusterPath({ pathname: `/address/${address}/${tab.path}` });
const selectedLayoutSegment = useSelectedLayoutSegment();
const isActive = selectedLayoutSegment === tab.path;
const featureInfo = useFeatureInfo({ address });
// Do not render "Feature Gate" tab on absent feature data
if (!featureInfo) {
return null;
}

return (
<li key={tab.slug} className="nav-item">
<Link className={`${isActive ? 'active ' : ''}nav-link`} href={accountDataPath}>
{tab.title}
</Link>
</li>
);
}

// Checks that a compressed NFT exists at the given address and returns a link to the tab
function CompressedNftLink({ tab, address, pubkey }: { tab: Tab; address: string; pubkey: PublicKey }) {
const { url } = useCluster();
Expand Down
164 changes: 150 additions & 14 deletions app/components/account/FeatureAccountSection.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,39 @@
import { Address } from '@components/common/Address';
import { Slot } from '@components/common/Slot';
import { TableCardBody } from '@components/common/TableCardBody';
import { Account } from '@providers/accounts';
import { PublicKey } from '@solana/web3.js';
import { parseFeatureAccount } from '@utils/parseFeatureAccount';
import { parseFeatureAccount, useFeatureAccount } from '@utils/parseFeatureAccount';
import Link from 'next/link';
import { useMemo } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ExternalLink as ExternalLinkIcon } from 'react-feather';

import { useCluster } from '@/app/providers/cluster';
import { Cluster } from '@/app/utils/cluster';
import { FeatureInfoType } from '@/app/utils/feature-gate/types';
import { getFeatureInfo } from '@/app/utils/feature-gate/utils';

import { UnknownAccountCard } from './UnknownAccountCard';

export function FeatureAccountSection({ account }: { account: Account }) {
const address = account.pubkey.toBase58();

// Making decision about card rendering upon these factors:
// - assume that account could be parsed by its signs
// - address matches feature that is present at featureGates.json
const { isFeature } = useFeatureAccount(account);
const maybeFeatureInfo = useMemo(() => getFeatureInfo(address), [address]);

return (
<ErrorBoundary fallback={<UnknownAccountCard account={account} />}>
<FeatureCard account={account} />
{isFeature ? (
// use account-specific card that able to parse account' data
<FeatureCard account={account} />
) : (
// feature that is preset at JSON would not have data about slot. leave it as null
<BaseFeatureCard activatedAt={null} address={address} featureInfo={maybeFeatureInfo} />
)}
</ErrorBoundary>
);
}
Expand All @@ -21,13 +44,51 @@ type Props = Readonly<{

const FeatureCard = ({ account }: Props) => {
const feature = parseFeatureAccount(account);
let activatedAt;
if (feature.activatedAt) {
activatedAt = (
const featureInfo = useMemo(() => getFeatureInfo(feature.address), [feature.address]);

return <BaseFeatureCard address={feature.address} activatedAt={feature.activatedAt} featureInfo={featureInfo} />;
};

const BaseFeatureCard = ({
activatedAt,
address,
featureInfo,
}: ReturnType<typeof parseFeatureAccount> & { featureInfo?: FeatureInfoType }) => {
const { cluster } = useCluster();

let activatedAtSlot;
let clusterActivation;
let simdLink;
if (activatedAt) {
activatedAtSlot = (
<tr>
<td className="text-nowrap">Activated At Slot</td>
<td className="text-lg-end">
<Slot slot={activatedAt} link />
</td>
</tr>
);
}
if (featureInfo) {
clusterActivation = (
<tr>
<td className="text-nowrap">Cluster Activation</td>
<td className="text-lg-end">
<ClusterActivationEpochAtCluster featureInfo={featureInfo} cluster={cluster} />
</td>
</tr>
);
simdLink = (
<tr>
<td>Activated At Slot</td>
<td>SIMD</td>
<td className="text-lg-end">
<code>{feature.activatedAt}</code>
{featureInfo.simd && featureInfo.simd_link ? (
<a href={featureInfo.simd_link} target="_blank" rel="noopener noreferrer" className="">
SIMD {featureInfo.simd} <ExternalLinkIcon className="align-text-top" size={13} />
</a>
) : (
<code>No link</code>
)}
</td>
</tr>
);
Expand All @@ -36,26 +97,101 @@ const FeatureCard = ({ account }: Props) => {
return (
<div className="card">
<div className="card-header">
<h3 className="card-header-title mb-0 d-flex align-items-center">Feature Activation</h3>
<h3 className="card-header-title mb-0 d-flex align-items-center">
{featureInfo?.title ?? 'Feature Activation'}
</h3>
</div>

<TableCardBody>
<TableCardBody layout="expanded">
<tr>
<td>Address</td>
<td className="text-lg-end">
<Address pubkey={new PublicKey(feature.address)} alignRight raw />
<td>
<Address pubkey={new PublicKey(address)} alignRight raw />
</td>
</tr>

<tr>
<td>Activated?</td>
<td className="text-nowrap">Activated?</td>
<td className="text-lg-end">
<code>{feature.activatedAt === null ? 'No' : 'Yes'}</code>
{featureInfo ? (
<FeatureActivatedAtCluster featureInfo={featureInfo} cluster={cluster} />
) : (
<code>{activatedAt === null ? 'No' : 'Yes'}</code>
)}
</td>
</tr>

{activatedAt}
{activatedAtSlot}

{clusterActivation}

{featureInfo?.description && (
<tr>
<td>Description</td>
<td className="text-lg-end">{featureInfo?.description}</td>
</tr>
)}

{simdLink}
</TableCardBody>
</div>
);
};

function ClusterActivationEpochAtCluster({ featureInfo, cluster }: { featureInfo: FeatureInfoType; cluster: Cluster }) {
if (cluster === Cluster.Custom) return null;

const { mainnetActivationEpoch, devnetActivationEpoch, testnetActivationEpoch } = featureInfo;

// Show empty state unless there is any info about Activation
if (!mainnetActivationEpoch && !devnetActivationEpoch && !testnetActivationEpoch) return <code>No Epoch</code>;

return (
<>
{mainnetActivationEpoch && cluster === Cluster.MainnetBeta && (
<div>
<Link href={`/epoch/${featureInfo.mainnetActivationEpoch}?cluster=mainnet`} className="epoch-link">
Mainnet Epoch {featureInfo.mainnetActivationEpoch}
</Link>
</div>
)}
{devnetActivationEpoch && cluster === Cluster.Devnet && (
<div>
<Link href={`/epoch/${featureInfo.devnetActivationEpoch}?cluster=devnet`} className="epoch-link">
Devnet Epoch {featureInfo.devnetActivationEpoch}
</Link>
</div>
)}
{testnetActivationEpoch && cluster === Cluster.Testnet && (
<div>
<Link href={`/epoch/${featureInfo.testnetActivationEpoch}?cluster=testnet`} className="epoch-link">
Testnet Epoch {featureInfo.testnetActivationEpoch}
</Link>
</div>
)}
</>
);
}

function FeatureActivatedAtCluster({ featureInfo, cluster }: { featureInfo: FeatureInfoType; cluster: Cluster }) {
if (cluster === Cluster.Custom) return null;

const { mainnetActivationEpoch, devnetActivationEpoch, testnetActivationEpoch } = featureInfo;

// Show empty state unless there is any info about Activation
if (!mainnetActivationEpoch && !devnetActivationEpoch && !testnetActivationEpoch) return <code>Not activated</code>;

return (
<>
{cluster === Cluster.MainnetBeta && mainnetActivationEpoch && (
<span className="badge bg-success">Active on Mainnet</span>
)}
{cluster === Cluster.Devnet && devnetActivationEpoch && (
<span className="badge bg-success">Active on Devnet</span>
)}
{cluster === Cluster.Testnet && testnetActivationEpoch && (
<span className="badge bg-success">Active on Testnet</span>
)}
</>
);
}
12 changes: 12 additions & 0 deletions app/components/account/FeatureGateCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function FeatureGateCard({ children }: { children: React.ReactNode }) {
return (
<div className="card">
<div className="card-header align-items-center">
<h3 className="card-header-title">Feature Information</h3>
</div>
<div className="card-footer">
<div className="text-muted">{children}</div>
</div>
</div>
);
}
19 changes: 17 additions & 2 deletions app/components/common/TableCardBody.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import { cva, VariantProps } from 'class-variance-authority';
import React from 'react';

export function TableCardBody({ children }: { children: React.ReactNode }) {
const tableVariants = cva(['table table-sm card-table'], {
defaultVariants: {
layout: 'compact',
},
variants: {
layout: {
compact: ['table-nowrap'],
expanded: [],
},
},
});

export interface TableCardBodyProps extends VariantProps<typeof tableVariants>, React.PropsWithChildren {}

export function TableCardBody({ children, ...props }: TableCardBodyProps) {
return (
<div className="table-responsive mb-0">
<table className="table table-sm table-nowrap card-table">
<table className={tableVariants(props)}>
<tbody className="list">{children}</tbody>
</table>
</div>
Expand Down
Loading

0 comments on commit 449ba08

Please sign in to comment.