Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { AttestationsByEntity } from './AttestationsByEntity';

const meta = {
title: 'Components/Ethereum/AttestationsByEntity',
component: AttestationsByEntity,
parameters: {
layout: 'padded',
},
tags: ['autodocs'],
decorators: [
Story => (
<div className="min-w-[600px] rounded-sm bg-surface p-6">
<Story />
</div>
),
],
} satisfies Meta<typeof AttestationsByEntity>;

export default meta;
type Story = StoryObj<typeof meta>;

/**
* Shows the top 10 entities with missed attestations
*/
export const MissedAttestations: Story = {
args: {
data: [
{ entity: 'Lido', count: 234 },
{ entity: 'Coinbase', count: 156 },
{ entity: 'Kraken', count: 89 },
{ entity: 'Binance', count: 67 },
{ entity: 'Rocket Pool', count: 45 },
{ entity: 'Staked.us', count: 34 },
{ entity: 'Bitcoin Suisse', count: 28 },
{ entity: 'Figment', count: 23 },
{ entity: 'Stakewise', count: 18 },
{ entity: 'Allnodes', count: 12 },
],
title: 'Missed Attestations by Entity',
subtitle: '706 total missed attestations',
anchorId: 'missed-attestations',
},
};

/**
* Shows the top attesters (successful attestations)
*/
export const TopAttesters: Story = {
args: {
data: [
{ entity: 'Lido', count: 12543 },
{ entity: 'Coinbase', count: 8932 },
{ entity: 'Kraken', count: 6721 },
{ entity: 'Binance', count: 5834 },
{ entity: 'Rocket Pool', count: 4521 },
{ entity: 'Staked.us', count: 3210 },
{ entity: 'Bitcoin Suisse', count: 2567 },
{ entity: 'Figment', count: 1987 },
{ entity: 'Stakewise', count: 1543 },
{ entity: 'Allnodes', count: 1234 },
],
title: 'Top Attesters by Entity',
subtitle: '49,092 total attestations',
anchorId: 'top-attesters',
},
};

/**
* Shows a vertical bar chart orientation
*/
export const VerticalOrientation: Story = {
args: {
data: [
{ entity: 'Lido', count: 234 },
{ entity: 'Coinbase', count: 156 },
{ entity: 'Kraken', count: 89 },
{ entity: 'Binance', count: 67 },
{ entity: 'Rocket Pool', count: 45 },
],
title: 'Missed Attestations (Vertical)',
subtitle: '591 total missed',
anchorId: 'missed-vertical',
orientation: 'vertical',
},
};

/**
* Shows fewer entities (top 5 instead of 10)
*/
export const Top5Only: Story = {
args: {
data: [
{ entity: 'Lido', count: 234 },
{ entity: 'Coinbase', count: 156 },
{ entity: 'Kraken', count: 89 },
{ entity: 'Binance', count: 67 },
{ entity: 'Rocket Pool', count: 45 },
],
title: 'Top 5 Entities - Missed Attestations',
subtitle: '591 total missed attestations',
anchorId: 'top-5',
},
};

/**
* Shows empty state when no data is available
*/
export const EmptyState: Story = {
args: {
data: [],
title: 'Missed Attestations by Entity',
anchorId: 'empty-state',
emptyMessage: 'No missed attestations for this slot',
},
};

/**
* Shows custom empty message
*/
export const CustomEmptyMessage: Story = {
args: {
data: [],
title: 'Attestations by Entity',
anchorId: 'custom-empty',
emptyMessage: 'Perfect! All validators attested successfully.',
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { JSX } from 'react';
import { useMemo } from 'react';
import { PopoutCard } from '@/components/Layout/PopoutCard';
import { BarChart } from '@/components/Charts/Bar';
import type { AttestationsByEntityProps } from './AttestationsByEntity.types';

/**
* AttestationsByEntity - Generic component for displaying entity-based attestation metrics
*
* Displays a bar chart showing counts by entity (e.g., staking providers). Can be used for
* missed attestations, successful attestations, or any other entity-based metric.
*
* @example
* ```tsx
* // Missed attestations
* <AttestationsByEntity
* data={[
* { entity: 'Lido', count: 45 },
* { entity: 'Coinbase', count: 23 },
* ]}
* title="Missed Attestations by Entity"
* subtitle="68 total missed"
* anchorId="missed-attestations"
* />
*
* // Successful attestations
* <AttestationsByEntity
* data={[
* { entity: 'Lido', count: 2500 },
* { entity: 'Coinbase', count: 1800 },
* ]}
* title="Top Attesters"
* subtitle="15,234 total attestations"
* anchorId="top-attesters"
* />
* ```
*/
export function AttestationsByEntity({
data,
title = 'Attestations by Entity',
subtitle,
anchorId = 'attestations-by-entity',
orientation = 'horizontal',
barWidth = '60%',
emptyMessage = 'No data available',
}: AttestationsByEntityProps): JSX.Element {
// Prepare data for the bar chart
const { values, labels } = useMemo(() => {
if (data.length === 0) {
return { values: [], labels: [] };
}

return {
values: data.map(item => item.count),
labels: data.map(item => item.entity),
};
}, [data]);

// Handle empty data
if (data.length === 0) {
return (
<PopoutCard title={title} anchorId={anchorId} modalSize="xl">
{({ inModal }) => (
<div
className={
inModal
? 'flex h-96 items-center justify-center text-muted'
: 'flex h-64 items-center justify-center text-muted'
}
>
<p>{emptyMessage}</p>
</div>
)}
</PopoutCard>
);
}

return (
<PopoutCard title={title} anchorId={anchorId} subtitle={subtitle} modalSize="xl">
{({ inModal }) => (
<div className={inModal ? 'h-96' : 'h-64'}>
<BarChart
data={values}
labels={labels}
height="100%"
orientation={orientation}
barWidth={barWidth}
showLabel={true}
labelPosition={orientation === 'horizontal' ? 'right' : 'top'}
animationDuration={150}
categoryLabelInterval={0}
/>
</div>
)}
</PopoutCard>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export interface EntityCountItem {
/**
* Entity name (e.g., staking provider name)
*/
entity: string;
/**
* Count/value for this entity
*/
count: number;
}

export interface AttestationsByEntityProps {
/**
* Entity data to display (already filtered and sorted)
*/
data: EntityCountItem[];
/**
* Chart title
* @default "Attestations by Entity"
*/
title?: string;
/**
* Optional subtitle (e.g., total count summary)
*/
subtitle?: string;
/**
* Anchor ID for scroll navigation and popout
* @default "attestations-by-entity"
*/
anchorId?: string;
/**
* Chart orientation
* @default "horizontal"
*/
orientation?: 'horizontal' | 'vertical';
/**
* Bar width
* @default "60%"
*/
barWidth?: string | number;
/**
* Empty state message
* @default "No data available"
*/
emptyMessage?: string;
}
2 changes: 2 additions & 0 deletions src/components/Ethereum/AttestationsByEntity/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { AttestationsByEntity } from './AttestationsByEntity';
export type { AttestationsByEntityProps, EntityCountItem } from './AttestationsByEntity.types';
13 changes: 13 additions & 0 deletions src/pages/ethereum/slots/DetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { SlotBasicInfoCard } from './components/SlotBasicInfoCard';
import { AttestationArrivalsChart } from './components/AttestationArrivalsChart';
import { AttestationParticipationCard } from './components/AttestationParticipationCard';
import { AttestationHeadCorrectnessCard } from './components/AttestationHeadCorrectnessCard';
import { AttestationsByEntity } from '@/components/Ethereum/AttestationsByEntity';
import { BlockPropagationChart } from './components/BlockPropagationChart';
import { BlobPropagationChart } from './components/BlobPropagationChart';
import { MevBiddingTimelineChart } from './components/MevBiddingTimelineChart';
Expand Down Expand Up @@ -138,6 +139,11 @@ export function DetailPage(): JSX.Element {
}
: null;

// Calculate total missed attestations for subtitle
const totalMissedAttestations = data.missedAttestations.reduce((sum, item) => sum + item.count, 0);
const missedAttestationsSubtitle =
totalMissedAttestations > 0 ? `${totalMissedAttestations.toLocaleString()} total missed attestations` : undefined;

// Transform MEV bidding data for chart
const mevBiddingData = data.mevBidding.map(item => ({
chunk_slot_start_diff: item.chunk_slot_start_diff ?? 0,
Expand Down Expand Up @@ -201,6 +207,13 @@ export function DetailPage(): JSX.Element {
/>
<AttestationParticipationCard correctnessData={attestationCorrectnessData} />
<AttestationHeadCorrectnessCard correctnessData={attestationCorrectnessData} />
<AttestationsByEntity
data={data.missedAttestations}
title="Missed Attestations by Entity"
subtitle={missedAttestationsSubtitle}
anchorId="missed-attestations"
emptyMessage="No missed attestations for this slot"
/>
</div>

{/* Block Propagation Section */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
fctBlockBlobFirstSeenByNodeServiceListOptions,
fctAttestationFirstSeenChunked50MsServiceListOptions,
fctAttestationCorrectnessHeadServiceListOptions,
fctAttestationLivenessByEntityHeadServiceListOptions,
fctMevBidHighestValueByBuilderChunked50MsServiceListOptions,
fctMevBidCountByRelayServiceListOptions,
fctMevBidCountByBuilderServiceListOptions,
Expand All @@ -26,6 +27,7 @@ import type {
FctBlockBlobFirstSeenByNode,
FctAttestationFirstSeenChunked50Ms,
FctAttestationCorrectnessHead,
FctAttestationLivenessByEntityHead,
FctMevBidHighestValueByBuilderChunked50Ms,
FctMevBidCountByRelay,
FctMevBidCountByBuilder,
Expand All @@ -34,6 +36,11 @@ import type {
FctBlockProposerEntity,
} from '@/api/types.gen';

export interface MissedAttestationEntity {
entity: string;
count: number;
}

export interface SlotDetailData {
blockHead: FctBlockHead[];
blockProposer: FctBlockProposer[];
Expand All @@ -43,6 +50,8 @@ export interface SlotDetailData {
blobPropagation: FctBlockBlobFirstSeenByNode[];
attestations: FctAttestationFirstSeenChunked50Ms[];
attestationCorrectness: FctAttestationCorrectnessHead[];
attestationLiveness: FctAttestationLivenessByEntityHead[];
missedAttestations: MissedAttestationEntity[];
mevBidding: FctMevBidHighestValueByBuilderChunked50Ms[];
relayBids: FctMevBidCountByRelay[];
builderBids: FctMevBidCountByBuilder[];
Expand Down Expand Up @@ -218,6 +227,16 @@ export function useSlotDetailData(slot: number): UseSlotDetailDataResult {
}),
enabled: !!currentNetwork && slotTimestamp > 0,
},
// Attestation liveness by entity data
{
...fctAttestationLivenessByEntityHeadServiceListOptions({
query: {
slot_start_date_time_eq: slotTimestamp,
page_size: 10000,
},
}),
enabled: !!currentNetwork && slotTimestamp > 0,
},
],
});

Expand All @@ -237,6 +256,20 @@ export function useSlotDetailData(slot: number): UseSlotDetailDataResult {
};
}

// Process attestation liveness data to get top 10 missed attestations
const attestationLivenessData: FctAttestationLivenessByEntityHead[] =
queries[14].data?.fct_attestation_liveness_by_entity_head ?? [];

// Filter to only missed attestations and sort by count
const missedAttestations: MissedAttestationEntity[] = attestationLivenessData
.filter(record => record.status?.toLowerCase() === 'missed')
.sort((a, b) => (b.attestation_count ?? 0) - (a.attestation_count ?? 0))
.slice(0, 10)
.map(record => ({
entity: record.entity ?? 'unknown',
count: record.attestation_count ?? 0,
}));

// Combine all data
const data: SlotDetailData = {
blockHead: queries[0].data?.fct_block_head ?? [],
Expand All @@ -247,6 +280,8 @@ export function useSlotDetailData(slot: number): UseSlotDetailDataResult {
blobPropagation: queries[5].data?.fct_block_blob_first_seen_by_node ?? [],
attestations: queries[6].data?.fct_attestation_first_seen_chunked_50ms ?? [],
attestationCorrectness: queries[7].data?.fct_attestation_correctness_head ?? [],
attestationLiveness: attestationLivenessData,
missedAttestations,
mevBidding: queries[8].data?.fct_mev_bid_highest_value_by_builder_chunked_50ms ?? [],
committees: queries[9].data?.int_beacon_committee_head ?? [],
preparedBlocks: queries[10].data?.fct_prepared_block ?? [],
Expand Down