Skip to content

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

Draft
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
Copilot wants to merge 2 commits into
masterfrom
copilot/add-per-node-first-seen-timestamp

Conversation

Copy link
Copy Markdown

Copilot AI commented Mar 5, 2026

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.jsx
const times = this.props.chaintip.nodes
  .map(n => n.block_first_seen_at).filter(Boolean)
  .map(t => new Date(t).getTime());
let color = 'black';
if (times.length > 1) {
  if (myTime === Math.min(...times)) color = 'green';
  else if (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: true

class CreateNodeBlocks < ActiveRecord::Migration[6.1]
  def change
    create_table :node_blocks do |t|
      t.references :node, null: false, foreign_key: true
      t.references :block, null: false, foreign_key: true
      t.datetime :first_seen_at, null: false

      t.timestamps
    end

    add_index :node_blocks, [:node_id, :block_id], unique: true
  end
end

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

# frozen_string_literal: true

class NodeBlock < ApplicationRecord
  belongs_to :node
  belongs_to :block
end

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.now
end

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.now
end

5. Update app/models/node.rb as_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.rb nodes_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:

        First seen: <Moment format="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 before NodeInflation, 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>:

        <td align="right">
          { this.props.chaintip && this.props.chaintip.block && this.props.node.block_first_seen_at &&
            (() => {
              const times = this.props.chaintip.nodes
                .map(n => n.block_first_seen_at)
                .filter(Boolean)
                .map(t => new Date(t).getTime());
              const minTime = Math.min(...times);
              const maxTime = Math.max(...times);
              const myTime = new Date(this.props.node.block_first_seen_at).getTime();
              let color = 'black';
              if (times.length > 1) {
                if (myTime === minTime) color = 'green';
                else if (myTime === maxTime) color = 'red';
              }
              return (
                <span style={{ color }}>
                  First seen: <Moment format="HH:mm:ss" tz="UTC">{ this.props.node.block_first_seen_at }</Moment> UTC
                </span>
              );
            })()
          }
          { this.props.node.has_mirror_node &&
            <NodeInflation
              node={ 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)  coding agent works faster and does higher quality work when set up for your repo.

- 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>
Copilot AI changed the title [WIP] Add per-node first seen timestamp to node list Add per-node first-seen timestamp via NodeBlock join model Mar 5, 2026
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