Skip to content

feat(frontend): block propagation from Xatu - #846

Merged
samcm merged 75 commits into
masterfrom
feat/xatu-block-arrival
Aug 26, 2026
Merged

feat(frontend): block propagation from Xatu#846
samcm merged 75 commits into
masterfrom
feat/xatu-block-arrival

Conversation

@samcm

@samcm samcm commented Aug 24, 2026

Copy link
Copy Markdown
Member

Adds a Propagation tab to the slot page and per-slot arrival min/p90 columns to the epoch page, read from a Xatu ClickHouse instance, so how a block spread across the network is visible in the explorer rather than only in Grafana. Reads go through the typed query builders and row structs from xatu/pkg/proto/clickhouse, so an upstream column rename or type change breaks dora's build at dep-bump time instead of returning nulls at runtime. Disabled unless xatu.enabled is set — with it off the UI is gated, no queries run, and an unreachable ClickHouse never blocks startup.

https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP

samcm added 30 commits August 20, 2026 12:13
Adds an optional Xatu ClickHouse data source (clients/xatu) using the
typed query builders and scannable row structs generated in the xatu
repository — no hand-written SQL and no untyped scanning, so a xatu
schema change surfaces as a compile error on dependency bump.

The slot page gains a lazy Propagation tab backed by
/slot/{slot}/arrival, which aggregates beacon_api_eth_v1_events_block
observations per client (first observation wins, duplicates counted),
with percentile and per-continent summaries and a 'Data from Xatu'
attribution.

Query pressure is bounded three ways: responses for slots younger than
xatu.settleDelay are never cached (the ingest pipeline may still be
receiving events) while settled slots cache for 30s/1h; the frontend
cache coalesces concurrent builds; and an optional clickhouseCachedDsn
routes settled-slot queries through a response-caching proxy (chproxy)
shared across instances.

NOTE: go.mod carries a replace directive to a local xatu checkout until
ethpandaops/xatu#886 merges and a version containing
pkg/proto/clickhouse is tagged.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The page header script strips the url fragment before inline scripts run
and stashes it in __doraInitialHash; check that too, or the tab shows a
permanent spinner when opened via /slot/N#arrival.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Adds head event, libp2p gossip and engine API newPayload series next to
the block event series, merged per observing node with a dumbbell track
visualizing the p2p -> block -> head -> newPayload sequence on a shared
time axis. Sentry names parse into group badges (EF fleet, community,
corp) with shortened display names, locations show country flags, and
first-seen values colorize by speed.

Observations arriving more than a full slot after slot start (syncing
or stalled nodes) are flagged late, listed last and excluded from the
stats and histogram so a single straggler cannot distort the scale.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Strip the network name from EF fleet node names, keep node cells on one
line, auto-hide the newPayload column and legend on networks without
engine observations, and tighten the shared time axis padding.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The tab now defaults to a timeline view: every node as a swimlane on a
shared time ruler, with the engine newPayload call drawn as a
start-to-done span so the execution import window is visible between
gossip arrival and the verified block event. A table view toggle shows
the dense numbers instead, with signed delta columns: verification
cost (p2p to block event), fork-choice lag (block to head, negative
for clients that adopt head first) and EL import duration. The view
choice persists per browser.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Drops the timeline/table toggle: the table is the one view, keeping the
per-node arrival track column. Columns are grouped by where each signal
comes from - libp2p (Gossip), beacon api (Block event, delta verify,
Head event, delta head) and engine api (newPayload) - and the legend
and tooltips name the layers the same way. The engine api group hides
on networks without snooper data.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Replaces the stat cards and chip pills with dora's standard label/value
rows (Arrival, Observations, Sources, Regions) inside a plain card
body, using the same border-bottom rows, badge pills and tooltips as
the overview tab. Also renames the libp2p series to 'block gossip' in
the column header, legend, tooltips and observation counts.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Replaces the loose text runs with stacked share bars (node share per
operator group and per continent) followed by aligned legend entries,
and terminates the table scroll area with a border before the
attribution line.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Regions now render one line per continent with a share bar, node count
and labeled first/p50 values (per-continent p50 added to the API),
instead of unlabeled numbers jammed on one row.

The newPayload span was drawn one call-duration too late: the snooper
emits its event after the call completes and event_date_time stamps
that receipt, so the call start is the observed completion minus the
duration. Before this fix the span could end after the node's block
event, which is impossible for non-optimistically imported blocks; now
every row is causally ordered.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Layer is now a hue, event a shape: libp2p purple (gossip diamond),
beacon api blue (block event circle, head event triangle - previously
green), engine api amber (newPayload span). The column group headers
carry matching colored underline bands and the legend groups its
entries under colored layer labels.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Replaces the colored header bands with neutral structure: muted group
labels underlined to their extent and hairline vertical rules between
the libp2p, beacon api and engine api column groups, carried down the
table body. Head event marker returns to green.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The legend wrapped on narrower windows; instead each column header now
carries its marker glyph, and table cells no longer wrap so narrow
viewports scroll the table horizontally.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Drops the vertical rules between the layer groups in favor of wider
padding at each group start, and gives the arrival track column its
own muted timeline group header.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Centers the Timeline label on the column header row, backfills the
client implementation from head events and shows an explanatory dash
for pure gossip listeners, and upgrades the regions breakdown with
full continent names plus p90 and max columns.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Gossip-only nodes now take their client from the libp2p rows'
meta_client_implementation (Xatu Sidecar names reduced to the attached
client). The delta head column is removed: block-to-head lag is ~0ms
for nearly every node, so it read as noise while the head event column
and the timeline already carry the exception cases. Header marker
glyphs sit on the text's optical center.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Every column header sorts on click (second click reverses) with an
arrow on the active column; missing values always sort last and the
earliest arrival breaks ties. Default stays earliest-first.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Adds a lazy /epoch/{epoch}/arrival endpoint that fetches the epoch's
block gossip, block event and head event observations in three
range queries, keeps each node's earliest arrival per slot (late
observations excluded) and returns min/p90 per slot. The epoch slot
table shows the two columns colorized when xatu is configured, filled
in after page load. Settled epochs cache for 30s/1h.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The epoch table's two arrival columns merge into one 'Arrival min /
p90' column, matching the Txs / Blobs and Dep / Ex idiom, hidden below
md like the other secondary columns. The propagation tab's region
lines adapt on small screens: flexible name width, share bar and max
from md up, p50/p90 from sm up, keeping the page free of horizontal
overflow while the data table scrolls within its own container.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The not-indexed row branch kept colspan 6 while the arrival column
made the table 7 data columns wide, shifting every cell after the
proposer on pages mixing branches and making values appear under the
wrong headers.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Tracks get a soft rounded lane background, the gossip-to-block-event
verify span draws as a purple-to-blue gradient connector (falling back
to the neutral range line when either end is missing), the newPayload
bar sits on top, and the column header carries a real axis with start
and end time labels aligned to the lane extent. The timeline group
label returns to the group row.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The shared axis now ends just past p90 instead of 1.6x p90, so the
data stops hiding in the left tenth of the lane, and rows with both
gossip and block event render one pipeline bar - gradient from gossip
arrival to block event with the amber newPayload chunk overlaid and
the head triangle on top - instead of four overlapping glyphs.
Individual markers remain only for rows missing a bar endpoint.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The newPayload window is clipped inside the pipeline bar so corners
and edges stay shared, the head event renders as a milestone tick
instead of a triangle lumped on the bar (header glyph matches), and
the empty lane pills are gone - bars float on the row and the header
axis carries the extent.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The axis now ends just past the median instead of p90, so the bulk of
rows use most of the lane and per-client differences are visible;
every row gets a thin lead-in line from the axis start to its first
observation, anchoring all rows to a common baseline; the lane is
wider; and rows continuing past the axis end show a chevron.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The arrival min/p90 values now render with the epoch page instead of
popping in from a lazy fetch: the page build pulls them through the
same cached arrival aggregation (settled epochs cached for an hour, a
10s query budget on the build path, dashes on failure), and the JSON
endpoint and client-side fill are removed.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The lane axis floated between the fastest node and p90, so a bar's length
carried no meaning and rows took different shapes depending on which events
a node reported. Anchor every lane at slot start on round steps, and give
each row one bar whose length is the node's time-to-block, split at the real
gossip timestamp.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
…ult config

Three things stopped this being safe to run outside a demo.

An unreachable ClickHouse took the whole explorer down: connect() blocked
on a 15s Ping and main treated the failure as fatal, so a blip during a
rollout meant dora would not boot. Reachability is now checked in the
background and logged; only invalid configuration is fatal, and queries
recover on their own when ClickHouse returns.

Every env override was doubled to XATU_XATU_*, because the leaf tags
repeated a prefix envconfig already derives from the parent field, so
XATU_CLICKHOUSE_DSN never bound.

Connection settings move under a raw source block, so xatu-cbt's
transformed models - which live on a different cluster with a per-network
database - can be added as a sibling rather than a breaking change. The
block ships in the default config, disabled, so the feature is
discoverable without being opt-out.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The epoch aggregation asked for a single 10000-row page and ignored the
page token, so on a network with enough observing nodes an epoch's later
slots fell off the end and the truncated min/p90 was then cached for an
hour. Reading only the first page is worse than being slow, so reads now
follow the tokens through a QueryPaged helper, bounded at 10 pages.
Aggregation cannot be pushed into ClickHouse because the generated
builders express filters and ordering but not aggregates.

QueryPaged also checks rows.Err(), which the epoch loops never did: a
mid-stream failure was indistinguishable from a short final page, so
partial data could be cached as if complete.

A failed lookup is now briefly negative cached, so an outage costs one
query per interval rather than one per page build.

Also removes an arrival histogram that was computed and serialized but
never rendered, and an unused Database accessor; adds tests for the two
sentry name parsers, which encode naming conventions and would otherwise
misclassify silently when those change.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The epoch aggregation was paginated but the four slot readers were not,
so they carried the same silent truncation at the builders' 10000-row
ceiling. A single slot exceeding that in one series is implausible, but
"implausible" is how the epoch bug was justified too, and the helper
already existed.

Tests pin the paging contract at the SQL level: a page token is a row
offset, page N yields OFFSET N*pageSize, and 10001 is rejected so
MaxQueryPageSize cannot drift away from the real ceiling.

Also cross-references the arrival color thresholds, which are duplicated
in Go for the epoch page and in JS for the propagation tab, and passes
the network into buildArrivalAggregates instead of reaching back through
the package singleton.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
samcm added 2 commits August 25, 2026 17:38
A grid of 128 cells was a wall of identical squares whenever the slot
was healthy. The heatmap keeps its per-column cells and arrival-time
colors but drops gaps and height: the sources bar's pill shape,
subdivided per column. A healthy slot collapses into one solid green
bar; late, unseen and probe-failed columns surface as slivers in place.
The legend renders only for states the slot actually has.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Every per-column rendering of network-first times failed the same way:
128 near-identical values make any faithful visual either a uniform
field or amplified noise. The lab's data column view solves it by
plotting per-column percentiles across every node's sighting - the
by-node table carries ~3.5k observations per slot, and their p90
genuinely varies per column. The loader now reduces min/p50/p90 per
column from fct_block_data_column_sidecar_first_seen_by_node, and the
row draws the lab's chart in dora's skin: column index across, arrival
time up, min and median lines under a p90 band.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
samcm added 9 commits August 26, 2026 11:07
The propagation tab describes how the network saw the slot, not what
the block contains, so it moves out of the block-property tabs to the
right edge next to download. In the node table, the observing node
whose name matches the slot's proposer gets a proposer badge - on
devnets validators carry node names, so the row shows how the proposer
itself experienced its own block.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
On gloas networks the execution payload gossips separately from the
block, and the gap between them is the point of ePBS - so the node
table gets a payload column and a timeline mark for when each node
first saw the revealed payload on the wire. Plain SQL rather than a
generated builder: the table only exists on networks carrying the gloas
schema, which xatu master's generated package cannot include yet, and a
missing table reads as no observations so pre-gloas networks are
untouched. The column hides itself when the slot has no payload series.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The attestation chart now answers when the block became safe and when
its payload landed. The waves response carries the slot's expected
attester count (in-memory epoch stats first, the epochs table for
history) and the chart marks where cumulative votes for the block pass
60% of it. On gloas networks it also marks the median payload arrival,
reduced from the payload gossip series; the marker query degrades
silently where the series does not exist. Markers stack their labels so
close ones stay readable.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Gloas payload timeliness committee votes are separate messages from
committee attestations and were absent from the attestation wave, which
only carries the regular attestation series. They now get their own
summary row: unique PTC votes deduplicated to first sighting, bucketed
to 50ms, split by the payload_present verdict and drawn on the same
full-slot axis with the payload median marked. Plain SQL with
missing-table gating like the other gloas series, so pre-gloas networks
never show the row.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
ATTESTATION_DUE_BPS_GLOAS moves the attestation deadline to 25% of the
slot, so the marker was a second late on gloas networks. The PTC chart
gains its own deadline at 75% (PAYLOAD_ATTESTATION_DUE_BPS), which
explains its two spikes: some clients vote the moment they see the
payload, the rest wait for the deadline.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The engine column only read the snooper series, which most devnets do
not feed, so the execution timings sat empty there despite
execution_engine_new_payload carrying them for every node. The EL
captures are keyed by block number and hash rather than slot, so the
page passes the execution block identity along with the root, and the
loader merges rows into the beacon sentry nodes by display identity -
the capture names its clients differently. The snooper observation
wins where both exist; requested_date_time is the call start, so no
completion-minus-duration derivation is needed for this series.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
@redpandabot

This comment has been minimized.

samcm added 5 commits August 26, 2026 13:01
The deadlines and the vote threshold were hardcoded ratios sitting next
to the real values: dora's chain spec already parses ATTESTATION_DUE_BPS,
ATTESTATION_DUE_BPS_GLOAS and PAYLOAD_ATTESTATION_DUE_BPS from the
config endpoint. The markers now resolve those basis points against the
slot duration, and the vote threshold reads the newly-parsed
BUILDER_PAYMENT_THRESHOLD fraction instead of a magic 0.6 - its label
prints whatever the chain says. The spec defaults remain only as
fallbacks for nodes that do not serve a key, and the mainnet attestation
marker moves from a rounded 4000ms to the spec's exact 3999ms.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
A gloas block is two objects with votes on each, and separate histogram
rows made their interplay hard to read - the payload median marker in
particular said little. The attestations and PTC rows become one
Timeline row: cumulative curves for attestations (of expected
attesters), PTC votes (of PTC_SIZE), payload seen and head adopted (of
observing nodes), stepped per 50ms on the full-slot axis with the spec
deadlines and the builder payment quorum marked. The payload and head
series are first-seen-per-node reductions capped to the slot, and the
waves fetch now logs a rendering failure instead of hiding it behind
the same blank space as missing data.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The timeline nearly doubles in height, names each curve at its right
end in its own color instead of tiny legend swatches, and the summary
line colors the series names directly. A fifth curve lands: block
execution, from the execution-side newPayload captures - call start
plus duration per node, so the payload-seen to executed gap is the
network's execution time. The spec deadline markers turn red and
thicker; grey dashes disappeared into the gridlines.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The default pool ran dry once a slot view fanned out to several series,
and an exhausted pool surfaces as an acquire timeout that reads like an
outage. Sized above the per-client concurrency limit.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
@redpandabot

This comment has been minimized.

samcm added 5 commits August 26, 2026 13:53
The y labels move out of the plot into a proper axis gutter, the curves
get headroom so the 100% level and the marker labels stop fighting, the
redundant end-of-line label pile is gone in favor of the colored legend
names, marker labels share the top row unless they would actually
collide, bare counts gain units, legend items no longer wrap
mid-phrase, and a curve can no longer poke past 100% when the estimated
expected-attester count undershoots the observed votes.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The quorum is a crossing on the attestations curve, but its green read
as the head adopted series.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
Clicking a legend entry in the timeline now shows only that series,
with its own deadline/quorum markers; the axes stay fixed to the slot
and 0-100% so isolating never rescales the chart. Clicking again
restores all series.

The regions rows previously repeated tiny first/p50/p90/max labels on
every line without saying what the numbers were. A single header row
now names the columns with the same vocabulary as the arrival stats
(fastest/median/p90/slowest), making it clear the section is those
stats split by continent. Each row's tooltip carries the node share.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The regions rows show block arrival stats, but nothing said whether
the times were block, head or payload sightings. A BLOCK ARRIVAL
group caption now spans the four time columns, using the same group
header grammar as the node table below, with a tooltip spelling out
that head adoption, payload and execution times are not included.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
The regions section now shows the two observable halves of the slot
per continent: when nodes adopted the block as head and when they
first saw the execution payload, each as fastest/p50/p90 under its
own group caption. The payload group only renders on networks that
observe payload gossip, so pre-gloas networks show head arrival
alone. Payload-only nodes also no longer register as 0ms arrivals:
the earliest-observation fallback chain now includes the payload
sighting.

The lane ruler dropped gridline labels only outside a fixed window,
so a label near the window's edge could still collide with the
right-anchored end label. Labels now render only when their
estimated extent clears both end labels and the previous label,
sized against the column's minimum width.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
@redpandabot

This comment has been minimized.

The arrival table and the wave panels loaded from two endpoints and
each rendered when its own fetch resolved, so on a cold cache the
timeline and data column sections popped in seconds after the table
and shifted it under the reader. The fetches still run concurrently,
but rendering now waits for both and paints everything at once. A
failed waves fetch still renders the arrival panel alone.

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
@redpandabot

redpandabot Bot commented Aug 26, 2026

Copy link
Copy Markdown

Summary

Adds a xatu-backed Propagation tab to the slot page and per-slot arrival min/p90 columns to the epoch page, with a new ClickHouse client (raw + cbt), typed query builders from xatu/xatu-cbt, and strong tests for cache nil fidelity, page tokens, and template execution. The engine-timing units, paging loop and template .Block gating all check out. No blockers; one test guards the wrong builder and the slot-side failure path skips the negative caching the epoch side deliberately uses.

Issues

  • 🟢 handlers/slot_waves_test.go:26primary-key guard tests the wrong builder — TestCbtQueriesFilterBothPrimaryKeys claims to cover the runtime queries, but for 'column first seen' it builds ListFctBlockDataColumnSidecarFirstSeenQuery while loadColumnWave actually runs BuildListFctBlockDataColumnSidecarFirstSeenByNodeQuery (handlers/slot_waves.go:628). If the ByNode builder ever drops a primary-key filter, the force_primary_key failure the test exists to catch would slip through to production; swap the test to the ByNode request/builder.
  • 🟡 handlers/slot_arrival.go:60slot arrival failures are not negative-cached and read as 'no data yet' — On build error the slot arrival (and waves) handlers set CacheTimeout = -1 and return an empty {slot:N} response, so during a ClickHouse outage every new tab open re-runs the full set of up to six multi-page queries, and the JSON renders as 'No propagation observations for this slot yet' — indistinguishable from normal ingest lag. getEpochArrivalData (handlers/epoch_arrival.go:44) explicitly negative-caches 30s for exactly this scenario; the higher-traffic slot path should do the same, or at least surface the failure instead of a plausible-looking empty result.

Reviewed @ 073826f4
"Firefox" is another name for the red panda — yes, that Firefox.

The timeline lane column only sorted by earliest block arrival, and
ordering the lanes by when nodes received the payload meant knowing
the Payload column header is clickable. The timeline group caption
now carries explicit block/payload sort toggles; the payload toggle
hides with the payload column on networks without payload gossip,
where the caption collapses back to plain "timeline".

Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP
@samcm
samcm merged commit 84bf52d into master Aug 26, 2026
5 checks passed
@samcm
samcm deleted the feat/xatu-block-arrival branch August 26, 2026 07:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants