diff --git a/demo/proto-devnet/config/dashboards/proto-devnet-throughput.json b/demo/proto-devnet/config/dashboards/proto-devnet-throughput.json index 822a67724..59be131ef 100644 --- a/demo/proto-devnet/config/dashboards/proto-devnet-throughput.json +++ b/demo/proto-devnet/config/dashboards/proto-devnet-throughput.json @@ -569,6 +569,167 @@ } ], "type": "timeseries" + }, + { + "id": 5, + "type": "histogram", + "title": "Announcement age upon arrival", + "gridPos": { + "x": 0, + "y": 16, + "h": 8, + "w": 12 + }, + "fieldConfig": { + "defaults": { + "custom": { + "stacking": { + "mode": "normal", + "group": "A" + }, + "lineWidth": 1, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + } + }, + "color": { + "mode": "palette-classic-by-name" + }, + "unit": "s", + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "age Node1" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#5794F2" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "age Node2" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#FF9830" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "age Node3" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#73BF69" + } + } + ] + } + ] + }, + "transformations": [ + { + "id": "extractFields", + "options": { + "delimiter": ",", + "format": "json", + "jsonPaths": [ + { + "path": "age" + }, + { + "path": "instance" + } + ], + "source": "labels" + } + }, + { + "id": "convertFieldType", + "options": { + "conversions": [ + { + "destinationType": "number", + "targetField": "age" + } + ], + "fields": {} + } + }, + { + "id": "partitionByValues", + "options": { + "fields": [ + "instance" + ], + "keepFields": false + } + } + ], + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "direction": "backward", + "editorMode": "code", + "expr": "{kind=\"LeiosAnnouncementAccepted\"} | json age=\"announcementAgeSeconds\" | age != \"\"", + "legendFormat": "{{instance}}", + "queryType": "range", + "refId": "A" + } + ], + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "options": { + "tooltip": { + "mode": "single", + "sort": "none", + "hideZeros": false + }, + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom", + "calcs": [] + } + } } ], "preload": false, diff --git a/demo/proto-devnet/run.sh b/demo/proto-devnet/run.sh index 981b96193..ad87fd5f9 100755 --- a/demo/proto-devnet/run.sh +++ b/demo/proto-devnet/run.sh @@ -39,6 +39,10 @@ fi # X-ray observability (on by default, disable with XRAY=0) : "${XRAY:=1}" : "${XRAY_SOURCE_DIR:="${SOURCE_DIR}/../extras/x-ray"}" +# Network topology: "mesh" (default, all-to-all) or "line" (Node1-Node2-Node3 +# with no direct Node1<->Node3 edge, so Node2 is the only path between the +# ends). Opt in with TOPOLOGY=line. +: "${TOPOLOGY:=mesh}" set +a # Check for required commands @@ -119,10 +123,16 @@ for i in "${nodes[@]}"; do yq ".TraceOptions.\"\".backends[1] = \"PrometheusSimple 0.0.0.0 $((12900 + "$i"))\"" \ >"$NODE_DIR/config.yaml" - # Generate upstream endpoints to other nodes + # Generate upstream endpoints. "mesh": every other node. "line": only + # adjacent nodes (|i-j| == 1), i.e. Node1-Node2-Node3 with no Node1<->Node3. + # These localRoots are the whole enforcement: config.yaml sets + # PeerSharing: false with no public/ledger peers, so a node only ever + # connects to the peers listed here (re-enabling PeerSharing would let the + # line collapse back toward a mesh). accessPoints=$(for j in "${nodes[@]}"; do - # Except self - if [ "$i" -ne "$j" ]; then + absdiff=$((i - j)); absdiff=${absdiff#-} + if { [ "$TOPOLOGY" = "line" ] && [ "$absdiff" -eq 1 ]; } \ + || { [ "$TOPOLOGY" != "line" ] && [ "$i" -ne "$j" ]; }; then port="PORT_NODE$j" address="IP_NODE$j" echo "{ \"port\": ${!port}, \"address\": \"${!address}\" }" @@ -154,6 +164,7 @@ export ALLOY_CONFIG="${WORKING_DIR}/config.alloy" envsubst <"${CONFIG_DIR}/alloy.template" >"${ALLOY_CONFIG}" echo "Starting proto-devnet ..." +echo " Topology: ${TOPOLOGY}" # Traffic control integration TC_COMPOSE=() if [ "$TC" = "1" ]; then diff --git a/ui/schema/trace.ui.d.ts b/ui/schema/trace.ui.d.ts index 4522ac992..0a6c8126d 100644 --- a/ui/schema/trace.ui.d.ts +++ b/ui/schema/trace.ui.d.ts @@ -212,6 +212,29 @@ export interface UITxsReceived { num_txs?: number; } +// --- Network: EB announcements (prototype only) --------------------------- + +/** Sent event for a `MsgLeiosBlockAnnouncement` relay hop over LeiosNotify. + * The message is an RB header; `id`/`slot` identify the EB it announces, so + * an announcement's diffusion can be linked to its endorser block. */ +export interface UIAnnouncementSent { + type: "AnnouncementSent"; + /** Hash of the announced EB, extracted from the relayed RB header. */ + id: string; + sender: string; + recipient: string; + /** Slot of the announced EB. */ + slot: number; +} + +export interface UIAnnouncementReceived { + type: "AnnouncementReceived"; + id: string; + recipient: string; + sender?: string; + slot: number; +} + // --- Union ---------------------------------------------------------------- /** Union of every message shape the UI renders. */ @@ -227,7 +250,9 @@ export type UIMessage = | UIRBReceived | UIEBReceived | UIVotesReceived - | UITxsReceived; + | UITxsReceived + | UIAnnouncementSent + | UIAnnouncementReceived; /** Set of `message.type` strings the UI renders — also used by CI to filter * a trace before validating it against this schema. */ diff --git a/ui/src/components/Graph/hooks/useHandlers.ts b/ui/src/components/Graph/hooks/useHandlers.ts index 7c51e20c0..2611eea68 100644 --- a/ui/src/components/Graph/hooks/useHandlers.ts +++ b/ui/src/components/Graph/hooks/useHandlers.ts @@ -11,6 +11,7 @@ const getHighestPriorityMessageType = (counts: { const MESSAGE_PRIORITY_ORDER = [ EMessageType.RB, // Highest priority EMessageType.EB, + EMessageType.Announcement, EMessageType.Votes, EMessageType.Txs, // Lowest priority ]; @@ -151,6 +152,9 @@ export const useHandlers = () => { case EMessageType.Votes: context.strokeStyle = EMessageColor.VOTES; break; + case EMessageType.Announcement: + context.strokeStyle = EMessageColor.ANNOUNCEMENT; + break; default: context.strokeStyle = ELinkColor.LINK_DEFAULT; } @@ -213,6 +217,9 @@ export const useHandlers = () => { case EMessageType.Votes: context.fillStyle = EMessageColor.VOTES; break; + case EMessageType.Announcement: + context.fillStyle = EMessageColor.ANNOUNCEMENT; + break; default: context.fillStyle = node.data.stake ? ENodeColor.STAKE_NODE @@ -256,10 +263,17 @@ export const useHandlers = () => { case EMessageType.RB: context.fillStyle = EMessageColor.RB; break; + case EMessageType.Announcement: + context.fillStyle = EMessageColor.ANNOUNCEMENT; + break; } - // Votes: draw as small circles (fixed size, no bandwidth scaling) - if (message.type === EMessageType.Votes) { + // Votes and announcements: small fixed-size circles (no bandwidth + // scaling) — both are lightweight control messages, not bulk data. + if ( + message.type === EMessageType.Votes || + message.type === EMessageType.Announcement + ) { const radius = Math.min((0.4 / canvasScale) * 6, 0.4); context.beginPath(); context.arc(x, y, radius, 0, 2 * Math.PI); diff --git a/ui/src/components/Graph/modules/EdgeStats.tsx b/ui/src/components/Graph/modules/EdgeStats.tsx index 5d35ee6ff..d64d73b50 100644 --- a/ui/src/components/Graph/modules/EdgeStats.tsx +++ b/ui/src/components/Graph/modules/EdgeStats.tsx @@ -8,6 +8,7 @@ const messageTypeColor: Record = { [EMessageType.EB]: EMessageColor.EB, [EMessageType.Votes]: EMessageColor.VOTES, [EMessageType.RB]: EMessageColor.RB, + [EMessageType.Announcement]: EMessageColor.ANNOUNCEMENT, }; const messageTypeLabel: Record = { @@ -15,6 +16,7 @@ const messageTypeLabel: Record = { [EMessageType.EB]: "EB", [EMessageType.Votes]: "Vote", [EMessageType.RB]: "RB", + [EMessageType.Announcement]: "Announce", }; const shortBits = (bytes: number): string => { diff --git a/ui/src/components/Graph/modules/NodeStats.tsx b/ui/src/components/Graph/modules/NodeStats.tsx index 5ff5898e5..4f3229a0f 100644 --- a/ui/src/components/Graph/modules/NodeStats.tsx +++ b/ui/src/components/Graph/modules/NodeStats.tsx @@ -48,6 +48,11 @@ export const NodeStats: FC = () => { { name: "Endorser Blocks", ...getCounts(EMessageType.EB), color: EMessageColor.EB }, { name: "Votes", ...getCounts(EMessageType.Votes), color: EMessageColor.VOTES }, { name: "Blocks", ...getCounts(EMessageType.RB), color: EMessageColor.RB }, + { + name: "Announcements", + ...getCounts(EMessageType.Announcement), + color: EMessageColor.ANNOUNCEMENT, + }, ]; return ( diff --git a/ui/src/components/Sim/hooks/lokiParsers.ts b/ui/src/components/Sim/hooks/lokiParsers.ts index 5af5bb946..ab835afcb 100644 --- a/ui/src/components/Sim/hooks/lokiParsers.ts +++ b/ui/src/components/Sim/hooks/lokiParsers.ts @@ -7,6 +7,8 @@ import { EServerMessageType, + IAnnouncementReceived, + IAnnouncementSent, IEndorserBlockGenerated, IEndorserBlockReceived, IEndorserBlockSent, @@ -365,6 +367,81 @@ const parseEndorserBlockReceived = ( return null; }; +const parseAnnouncementSent = ( + timestamp: number, + logLine: string, +): IServerMessage | null => { + try { + const log = JSON.parse(logLine); + + // {"kind":"Send","msg":{"kind":"MsgLeiosBlockAnnouncement","ebSlot":...,"ebHash":"...","ebBodySize":...},"peer":{"connectionId":"..."}} + // The message is an RB header; the node renders the announced EB point so + // the hop can be keyed by (and linked to) the endorser block. + if ( + (log.direction || log.kind) === "Send" && + log.msg && + log.msg.kind === "MsgLeiosBlockAnnouncement" + ) { + const [sender, recipient] = getNodesFromConnection( + log.peer?.connectionId || log.connectionId, + ); + + const message: IAnnouncementSent = { + type: EServerMessageType.AnnouncementSent, + slot: log.msg.ebSlot ?? 0, + id: + log.msg.ebHash ?? `announcement-${sender}-${recipient}-${timestamp}`, + sender, + recipient, + }; + + return { time_s: timestamp, message }; + } + } catch (error) { + console.error("Failed to parse AnnouncementSent log line:", logLine, error); + } + + return null; +}; + +const parseAnnouncementReceived = ( + timestamp: number, + logLine: string, +): IServerMessage | null => { + try { + const log = JSON.parse(logLine); + + if ( + log.kind === "Recv" && + log.msg && + log.msg.kind === "MsgLeiosBlockAnnouncement" + ) { + const [recipient, sender] = getNodesFromConnection( + log.peer?.connectionId || log.connectionId, + ); + + const message: IAnnouncementReceived = { + type: EServerMessageType.AnnouncementReceived, + slot: log.msg.ebSlot ?? 0, + id: + log.msg.ebHash ?? `announcement-${sender}-${recipient}-${timestamp}`, + sender, + recipient, + }; + + return { time_s: timestamp, message }; + } + } catch (error) { + console.warn( + "Failed to parse AnnouncementReceived log line:", + logLine, + error, + ); + } + + return null; +}; + const txsId = (msg: any): string => { const bitmapStr = (msg.bitmaps || []).join(","); return `txs-${msg.ebHash}-${bitmapStr}`; @@ -571,6 +648,8 @@ export const parseStreamValue = ( parseEndorserBlockGenerated(streamLabels, timestamp, logLine) || parseEndorserBlockSent(timestamp, logLine) || parseEndorserBlockReceived(timestamp, logLine) || + parseAnnouncementSent(timestamp, logLine) || + parseAnnouncementReceived(timestamp, logLine) || parseTxsSent(timestamp, logLine) || parseTxsReceived(timestamp, logLine) || parseVotesGenerated(streamLabels, timestamp, logLine) || diff --git a/ui/src/components/Sim/hooks/worker.ts b/ui/src/components/Sim/hooks/worker.ts index 95cd51740..e9e08665a 100644 --- a/ui/src/components/Sim/hooks/worker.ts +++ b/ui/src/components/Sim/hooks/worker.ts @@ -110,6 +110,8 @@ const BASE_VISUALIZATION_EVENTS = new Set([ EServerMessageType.VotesGenerated, EServerMessageType.VotesSent, EServerMessageType.VotesReceived, + EServerMessageType.AnnouncementSent, + EServerMessageType.AnnouncementReceived, ]); // Txs events (optional) diff --git a/ui/src/components/Sim/modules/Stats.tsx b/ui/src/components/Sim/modules/Stats.tsx index 0029c3d28..5fedd859e 100644 --- a/ui/src/components/Sim/modules/Stats.tsx +++ b/ui/src/components/Sim/modules/Stats.tsx @@ -17,6 +17,8 @@ const EVENT_TYPE_COLOR: Partial> = { [EServerMessageType.VotesGenerated]: EMessageColor.VOTES, [EServerMessageType.VotesSent]: EMessageColor.VOTES, [EServerMessageType.VotesReceived]: EMessageColor.VOTES, + [EServerMessageType.AnnouncementSent]: EMessageColor.ANNOUNCEMENT, + [EServerMessageType.AnnouncementReceived]: EMessageColor.ANNOUNCEMENT, }; export const Stats: FC = () => { diff --git a/ui/src/components/Sim/types.ts b/ui/src/components/Sim/types.ts index 043e5e3e1..427806929 100644 --- a/ui/src/components/Sim/types.ts +++ b/ui/src/components/Sim/types.ts @@ -74,6 +74,8 @@ import type { UITxsGenerated, UITxsSent, UITxsReceived, + UIAnnouncementSent, + UIAnnouncementReceived, UIMessage, UITraceEvent, } from "@/schema/trace.ui"; @@ -92,6 +94,8 @@ export enum EServerMessageType { RBGenerated = "RBGenerated", RBReceived = "RBReceived", RBSent = "RBSent", + AnnouncementSent = "AnnouncementSent", + AnnouncementReceived = "AnnouncementReceived", } export type IVote = Vote; @@ -107,6 +111,8 @@ export type IEndorserBlockSent = UIEBSent; export type IVotesGenerated = UIVotesGenerated; export type IVotesReceived = UIVotesReceived; export type IVotesSent = UIVotesSent; +export type IAnnouncementSent = UIAnnouncementSent; +export type IAnnouncementReceived = UIAnnouncementReceived; export type TServerMessageType = UIMessage; diff --git a/ui/src/contexts/SimContext/types.ts b/ui/src/contexts/SimContext/types.ts index 434a6b82e..0e15b1ad9 100644 --- a/ui/src/contexts/SimContext/types.ts +++ b/ui/src/contexts/SimContext/types.ts @@ -7,6 +7,7 @@ export enum EMessageType { RB = "rb", Txs = "txs", Votes = "votes", + Announcement = "announcement", } export enum ActivityAction { @@ -95,6 +96,7 @@ export interface IMessageTypeCounts { [EMessageType.EB]: number; [EMessageType.Votes]: number; [EMessageType.Txs]: number; + [EMessageType.Announcement]: number; } export interface IEdgeState { diff --git a/ui/src/utils/colors.ts b/ui/src/utils/colors.ts index 15ee88b8e..99942d7dd 100644 --- a/ui/src/utils/colors.ts +++ b/ui/src/utils/colors.ts @@ -6,6 +6,7 @@ export enum EMessageColor { EB = "#4b7bec", VOTES = "#9b59b6", RB = "#fc5c65", + ANNOUNCEMENT = "#f7b731", } export enum ENodeColor { diff --git a/ui/src/utils/timelineAggregation.ts b/ui/src/utils/timelineAggregation.ts index 9e2bcbcfc..5e2e950c8 100644 --- a/ui/src/utils/timelineAggregation.ts +++ b/ui/src/utils/timelineAggregation.ts @@ -40,6 +40,7 @@ const createEmptyMessageTypeCounts = (): IMessageTypeCounts => ({ [EMessageType.EB]: 0, [EMessageType.Votes]: 0, [EMessageType.Txs]: 0, + [EMessageType.Announcement]: 0, }); const getTotalActiveCount = (counts: IMessageTypeCounts): number => { @@ -313,6 +314,7 @@ export const computeAggregatedDataAtTime = ( case EServerMessageType.EBSent: case EServerMessageType.RBSent: case EServerMessageType.VotesSent: + case EServerMessageType.AnnouncementSent: return { sender: (message as any).sender, recipient: (message as any).recipient, @@ -376,7 +378,10 @@ export const computeAggregatedDataAtTime = ( (messageType === EServerMessageType.RBSent && futureEvent.message.type === EServerMessageType.RBReceived) || (messageType === EServerMessageType.VotesSent && - futureEvent.message.type === EServerMessageType.VotesReceived); + futureEvent.message.type === EServerMessageType.VotesReceived) || + (messageType === EServerMessageType.AnnouncementSent && + futureEvent.message.type === + EServerMessageType.AnnouncementReceived); if ( isMatchingReceived && @@ -585,6 +590,78 @@ export const computeAggregatedDataAtTime = ( break; } + case EServerMessageType.AnnouncementSent: { + const msgBytes = getMessageBytes(EMessageType.Announcement, message.id); + const stats = nodeStats.get(message.sender); + if (stats) { + if (!stats.sent.has(EMessageType.Announcement)) { + stats.sent.set(EMessageType.Announcement, { count: 0, bytes: 0 }); + } + const sentStats = stats.sent.get(EMessageType.Announcement)!; + sentStats.count += 1; + sentStats.bytes += msgBytes; + stats.bytesSent += msgBytes; + } + + // Track last activity for node coloring + updateLastActivity( + nodeStats, + message.sender, + EMessageType.Announcement, + ActivityAction.Sent, + event.time_s, + ); + + // Calculate travel time with 3-tier fallback + const travelTime = calculateTravelTime( + event, + i, + 0.3, // fallback for a lightweight announcement + ); + + // Create animation with calculated travel time + createMessageAnimation( + result, + EMessageType.Announcement, + message.id, + message.sender, + message.recipient, + event.time_s, + targetTime, + travelTime, + msgBytes, + { slot: message.slot }, + ); + break; + } + + case EServerMessageType.AnnouncementReceived: { + const msgBytes = getMessageBytes(EMessageType.Announcement, message.id); + const stats = nodeStats.get(message.recipient); + if (stats) { + if (!stats.received.has(EMessageType.Announcement)) { + stats.received.set(EMessageType.Announcement, { + count: 0, + bytes: 0, + }); + } + const receivedStats = stats.received.get(EMessageType.Announcement)!; + receivedStats.count += 1; + receivedStats.bytes += msgBytes; + stats.bytesReceived += msgBytes; + } + + // Track last activity for node coloring + updateLastActivity( + nodeStats, + message.recipient, + EMessageType.Announcement, + ActivityAction.Received, + event.time_s, + ); + break; + } + case EServerMessageType.RBGenerated: { setMessageBytes(EMessageType.RB, message.id, message.size_bytes); const stats = nodeStats.get(message.producer);