Skip to content

Add per-node first-seen timestamp via NodeBlock join model - #258

Draft
jonathanbier with Copilot wants to merge 2 commits into
masterfrom
copilot/add-per-node-first-seen-timestamp
Draft

Add per-node first-seen timestamp via NodeBlock join model#258
jonathanbier with Copilot wants to merge 2 commits into
masterfrom
copilot/add-per-node-first-seen-timestamp

Conversation

CopilotAI commented Mar 5, 2026

Copy link
Copy Markdown

Previously, only a global block.created_at and block.first_seen_by_id existed — no per-node record of when each node first saw a block. This introduces a NodeBlock join model to track per-node first-seen timestamps, surfaces them in the node list UI with colour coding (earliest=green, latest=red, others=black), and removes the now-redundant global "First seen" line from BlockInfo.

Backend

  • New node_blocks tablenode_id, block_id, first_seen_at (not null), unique index on [node_id, block_id]
  • NodeBlock model — simple join model with belongs_to :node and belongs_to :block
  • Block.create_or_update_with / create_headers_only — call NodeBlock.find_or_create_by(node:, block:) with first_seen_at: Time.current after block creation
  • Node#as_json — adds block_first_seen_at by looking up the NodeBlock for the node's active chaintip block
  • Chaintip#nodes_for_identical_chaintips — adds .includes(node: :node_blocks) to avoid N+1 queries when serialising nodes

Frontend

  • node.jsx — renders per-node First seen: HH:mm:ss UTC in the right-hand <td>, colour-coded by comparing against the min/max block_first_seen_at across all nodes sharing the same chaintip; single-node groups render in black
  • blockInfo.jsx — removes the global First seen line
// Colour logic in node.jsxconsttimes=this.props.chaintip.nodes.map(n=>n.block_first_seen_at).filter(Boolean).map(t=>newDate(t).getTime());letcolor='black';if(times.length>1){if(myTime===Math.min(...times))color='green';elseif(myTime===Math.max(...times))color='red';}
Original prompt

Summary

Add a per-node "First seen" timestamp to the node list on the main page. The earliest node to see a block should have the time shown in green, the latest in red, and the rest in black. The global "First seen" line in BlockInfo should be removed since it will now be shown per-node.

Currently there is no per-node first-seen timestamp — only a global one (block.created_at) and which node was first (block.first_seen_by_id). This PR introduces a new NodeBlock join model to track when each individual node first saw each block.


Changes Required

1. New migration: create node_blocks table

Create db/migrate/TIMESTAMP_create_node_blocks.rb:

# frozen_string_literal: trueclassCreateNodeBlocks < ActiveRecord::Migration[6.1]defchangecreate_table:node_blocksdo |t|
t.references:node,null: false,foreign_key: truet.references:block,null: false,foreign_key: truet.datetime:first_seen_at,null: falset.timestampsendadd_index:node_blocks,[:node_id,:block_id],unique: trueendend

2. New model: app/models/node_block.rb

# frozen_string_literal: trueclassNodeBlock < ApplicationRecordbelongs_to:nodebelongs_to:blockend

3. Update app/models/node.rb

Add association:

has_many:node_blocks,dependent: :destroy

4. Update app/models/block.rb

Add association:

has_many:node_blocks,dependent: :destroy

Update create_or_update_with to record per-node first-seen timestamp. After Block.find_or_create_by(...), add:

NodeBlock.find_or_create_by(node: node,block: block)do |nb|
nb.first_seen_at=Time.nowend

Also update create_headers_only similarly — after Block.create(...), add:

NodeBlock.find_or_create_by(node: node,block: block)do |nb|
nb.first_seen_at=Time.nowend

5. Update app/models/node.rbas_json

Add block_first_seen_at to the merged hash in as_json. This requires looking up the NodeBlock for the node's active chaintip block:

block_first_seen_at: active_chaintip&.block&.node_blocks&.find_by(node: self)&.first_seen_at,

6. Update app/models/chaintip.rbnodes_for_identical_chaintips

The method currently returns an array of Node objects. The nodes need to carry their first_seen_at for the block. The cleanest way is to keep returning Node objects from as_json but include block_first_seen_at via node.as_json (which is already done via step 5 above — since Node#as_json will now include block_first_seen_at).

Make sure nodes_for_identical_chaintips eager loads node_blocks to avoid N+1:

chaintip_nodes=Chaintip.joins(:node).where('nodes.enabled = ? AND chaintips.status = ? AND chaintips.block_id = ?',true,status,block_id).includes(node: :node_blocks).order(client_type: :asc,name: :asc,version: :desc)

7. Update app/javascript/packs/forkMonitorApp/components/blockInfo.jsx

Remove the "First seen" line and its <br/>:

Remove lines:

Firstseen: <Momentformat="HH:mm:ss"tz="UTC">{this.props.block.created_at}</Moment>UTC<br/>

8. Update app/javascript/packs/forkMonitorApp/components/node.jsx

Add import 'moment-timezone'; after the existing import Moment from 'react-moment'; line.

In the right-hand <td align="right">, add the per-node first-seen time beforeNodeInflation, with colour coding based on min/max across all nodes in the chaintip group.

The colour logic needs the list of all nodes' block_first_seen_at values. These are available via this.props.chaintip.nodes (the sibling nodes). So compute min/max from that array and colour accordingly.

Replace the existing right-hand <td>:

<tdalign="right">{this.props.chaintip&&this.props.chaintip.block&&this.props.node.block_first_seen_at&&(()=>{consttimes=this.props.chaintip.nodes.map(n=>n.block_first_seen_at).filter(Boolean).map(t=>newDate(t).getTime());constminTime=Math.min(...times);constmaxTime=Math.max(...times);constmyTime=newDate(this.props.node.block_first_seen_at).getTime();letcolor='black';if(times.length>1){if(myTime===minTime)color='green';elseif(myTime===maxTime)color='red';}return(<spanstyle={{ color }}>
First seen: <Momentformat="HH:mm:ss"tz="UTC">{this.props.node.block_first_seen_at}</Moment> UTC
</span>);})()}{this.props.node.has_mirror_node&&<NodeInflationnode={this.props.node}txOutset={this.props.node.tx_outset}...
</details>
<!-- START COPILOT CODING AGENT SUFFIX -->
*This pull request was created from Copilot chat.*
>
<!-- START COPILOT CODING AGENT TIPS -->
---
✨ Let Copilot coding agent [set things up for you](https://github.com/jonathanbier/forkmonitor/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot)codingagentworksfasteranddoeshigherqualityworkwhensetupforyourrepo.

- Create node_blocks migration and model (node_id, block_id, first_seen_at)
- Add has_many :node_blocks to Node and Block models
- Populate NodeBlock in Block.create_or_update_with and create_headers_only
- Add block_first_seen_at to Node#as_json
- Add includes(node: :node_blocks) in Chaintip#nodes_for_identical_chaintips
- Remove global First seen from blockInfo.jsx
- Add per-node first-seen with colour coding in node.jsx
Assisted-by: GitHub Copilot
Assisted-by: OpenAI GPT-5-Codex
Co-authored-by: jonathanbier <42411042+jonathanbier@users.noreply.github.com>
CopilotAI changed the title [WIP] Add per-node first seen timestamp to node listAdd per-node first-seen timestamp via NodeBlock join modelMar 5, 2026
Sign up for freeto 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

@jonathanbier