feat(frontend): block propagation from Xatu - #846
Merged
Conversation
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
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
pk910
approved these changes
Aug 26, 2026
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
Claude-Session: https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP # Conflicts: # templates/slot/arrival.html
This comment has been minimized.
This comment has been minimized.
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
This comment has been minimized.
This comment has been minimized.
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
This comment has been minimized.
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
SummaryAdds 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
Reviewed @ |
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 unlessxatu.enabledis set — with it off the UI is gated, no queries run, and an unreachable ClickHouse never blocks startup.https://claude.ai/code/session_01PqWvkAjYgCjjhtu83gJeXP