logtui
Version:
A terminal-based UI for monitoring blockchain events using Hypersync
726 lines (639 loc) • 22.1 kB
JavaScript
/**
* Core scanner module for logtui
* Handles connecting to Hypersync and displaying the TUI
*/
import { keccak256, toHex } from "viem";
import {
HypersyncClient,
Decoder,
LogField,
JoinMode,
BlockField,
TransactionField,
} from "@envio-dev/hypersync-client";
import blessed from "blessed";
import contrib from "blessed-contrib";
import chalk from "chalk";
import figlet from "figlet";
import supportsColor from "supports-color";
// Force terminal compatibility mode - stronger settings
// process.env.FORCE_COLOR = "3"; // Force full true color support - REMOVING THIS
process.env.NCURSES_NO_UTF8_ACS = "1";
process.env.TERM = "xterm-256color"; // Use more compatible terminal type
// Ensure chalk uses normal level for auto-detection
// chalk.level = 3; // REMOVING FORCED LEVEL
// Detect actual terminal color support
const hasColorSupport = !!supportsColor.stdout;
const has256ColorSupport = !!(
supportsColor.stdout && supportsColor.stdout.has256
);
const hasTrueColorSupport = !!(
supportsColor.stdout && supportsColor.stdout.has16m
);
// Log color support detection for verbose mode
if (process.env.DEBUG) {
console.log(`Terminal color support detected:
- Basic colors: ${hasColorSupport}
- 256 colors: ${has256ColorSupport}
- True colors: ${hasTrueColorSupport}
`);
}
// Apply completely silent error handling for Blessed/Terminal issues
const originalConsoleError = console.error;
console.error = function (...args) {
// Check if this is a terminal capability error
if (args.length > 0 && typeof args[0] === "string") {
const errorMsg = args[0];
if (
errorMsg.includes("Error on xterm") ||
errorMsg.includes("Setulc") ||
errorMsg.includes("stack") ||
errorMsg.includes("term") ||
errorMsg.includes("escape sequence")
) {
return; // Silently ignore these errors
}
}
originalConsoleError.apply(console, args);
};
// Apply monkey patch to process.stderr.write to catch any remaining errors
const originalStderrWrite = process.stderr.write;
process.stderr.write = function (buffer, encoding, fd) {
const str = buffer.toString();
if (
str.includes("Error on xterm") ||
str.includes("Setulc") ||
str.includes("stack") ||
str.includes("var v") ||
str.includes("terminal capability") ||
str.includes("xterm-256color") ||
str.toLowerCase().includes("setulc")
) {
return true; // Pretend we wrote it but don't actually write
}
return originalStderrWrite.apply(process.stderr, arguments);
};
/**
* Format numbers with commas
* @param {number|string} num - Number to format
* @returns {string} Formatted number
*/
const formatNumber = (num) => {
if (num === null || num === undefined) return "0";
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
/**
* Safe JSON stringify that handles circular references
* @param {Object} obj - Object to stringify
* @param {number} maxLength - Maximum length before truncating
* @returns {string} Stringified object
*/
const safeStringify = (obj, maxLength = 100) => {
try {
if (!obj) return "null";
const str = JSON.stringify(obj);
if (str.length <= maxLength) return str;
return str.substring(0, maxLength) + "...";
} catch (err) {
return `[Object: stringify failed]`;
}
};
/**
* Create and run the scanner with TUI
* @param {Object} options - Scanner options
* @param {string} options.networkUrl - Hypersync network URL
* @param {Array<string>} options.eventSignatures - Event signatures to scan for
* @param {string} options.title - Title for the TUI
* @returns {Promise<void>}
*/
export async function createScanner({
networkUrl,
eventSignatures,
title = "Event Scanner",
}) {
// Initialize Hypersync client
const client = HypersyncClient.new({
url: networkUrl,
bearerToken:
process.env.HYPERSYNC_BEARER_TOKEN ||
"74e5e8e9-5bb7-43fc-856e-4a2dbb9fc237", // Default token if not provided
});
// Create topic0 hashes from event signatures
const topic0_list = eventSignatures.map((sig) => keccak256(toHex(sig)));
// Define the Hypersync query to get events we're interested in
let query = {
fromBlock: 0,
logs: [
{
// Get all events that have any of the topic0 values we want
topics: [topic0_list],
},
],
fieldSelection: {
log: [LogField.Topic0],
},
joinMode: JoinMode.JoinTransactions,
};
// Track event counts - will be populated dynamically
const eventCounts = {
Total: 0,
Unknown: 0,
};
// Create a mapping of topic0 hash to event name
const topic0ToName = {};
// Initialize event counts for each signature
eventSignatures.forEach((sig) => {
const name = sig.split("(")[0];
const topic0 = keccak256(toHex(sig));
topic0ToName[topic0] = name;
eventCounts[name] = 0;
});
//=============================================================================
// TUI SETUP
//=============================================================================
// Create blessed screen with improved compatibility settings
const screen = blessed.screen({
smartCSR: true,
title,
dockBorders: true,
fullUnicode: true,
forceUnicode: true,
autoPadding: true,
terminal: "xterm-color", // Use simpler terminal type
fastCSR: true,
useBCE: true, // Use Background Color Erase for better rendering
});
// Define UI color scheme with multiple fallback options tailored to detected capabilities
// A selection of beautiful baby blue options
const colorOptions = {
trueColor: {
primary: "#FFFFFF", // Pure white for logo
secondary: "#00BFFF", // Keep baby blue for borders and other elements
tertiary: "#87CEFA", // Light sky blue as another option
},
ansi256: {
// These are the closest ANSI 256 color codes
primary: 15, // White
secondary: 39, // Closest to deep sky blue
tertiary: 45, // A lighter baby blue
},
basic: {
// When only basic ANSI colors are available
primary: "white", // Simple white
secondary: "cyanBright", // For non-logo elements
fallback: "cyan",
},
};
// Select the best color scheme based on terminal capabilities
const getOptimalColorScheme = () => {
if (hasTrueColorSupport) {
return {
type: "hex",
primary: colorOptions.trueColor.primary,
secondary: colorOptions.trueColor.secondary,
};
} else if (has256ColorSupport) {
return {
type: "ansi256",
primary: colorOptions.ansi256.primary,
secondary: colorOptions.ansi256.secondary,
};
} else {
return {
type: "basic",
primary: colorOptions.basic.primary,
secondary: colorOptions.basic.secondary,
};
}
};
// Get the best color scheme for this terminal
const colorScheme = getOptimalColorScheme();
// Set the active color for border references - still using baby blue for borders
let uiColor =
colorScheme.type === "hex"
? colorOptions.trueColor.secondary
: colorOptions.trueColor.secondary;
// Create enhanced color function with multiple fallbacks based on terminal capabilities
const safeHexColor = (text) => {
try {
if (colorScheme.type === "hex") {
return chalk.hex(colorScheme.primary)(text);
} else if (colorScheme.type === "ansi256") {
return chalk.ansi256(colorScheme.primary)(text);
} else {
// Basic color mode
return chalk[colorScheme.primary](text);
}
} catch (e) {
// Ultimate fallback - use white which should be available everywhere
return chalk.white(text);
}
};
// Create specific color function for borders and other non-logo elements
const borderColor = (text) => {
try {
if (colorScheme.type === "hex") {
return chalk.hex(colorScheme.secondary)(text);
} else if (colorScheme.type === "ansi256") {
return chalk.ansi256(colorScheme.secondary)(text);
} else {
return chalk[colorScheme.secondary](text);
}
} catch (e) {
return chalk.cyanBright(text);
}
};
// Create a grid layout
const grid = new contrib.grid({
rows: 12,
cols: 12,
screen: screen,
});
// Create ASCII logo box
const logo = grid.set(0, 0, 3, 12, blessed.box, {
tags: true,
align: "center",
valign: "middle",
border: {
type: "line",
fg: uiColor,
},
});
// Define a direct neon cyan color using ANSI escape sequences
// This should be universally visible in all terminals
const NEON_CYAN = "\x1b[38;5;51m"; // Bright neon cyan (ANSI 256 color)
const NEON_CYAN_BG = "\x1b[48;5;51m"; // Bright neon cyan background
const RESET = "\x1b[0m"; // Reset to default color
// Create a function to apply the neon cyan color to text - for consistency
const neonCyanText = (text) => `${NEON_CYAN}${text}${RESET}`;
// Create ASCII logo with direct approach
try {
// Generate the text first
const logoText = figlet.textSync("ENVIO.DEV", {
font: "ANSI Shadow",
horizontalLayout: "full",
});
// Color it directly with neon cyan - should work universally
const coloredLogo = NEON_CYAN + logoText + RESET;
// Set the content directly
logo.setContent(coloredLogo);
} catch (error) {
// Emergency fallback to plain text if all else fails
logo.setContent(chalk.cyan.bold("ENVIO.DEV"));
}
// Create subtitle
const subtitle = grid.set(3, 0, 1, 12, blessed.box, {
content: chalk.yellow(` ${title} - Powered by Envio `),
tags: true,
align: "center",
valign: "middle",
style: {
fg: "yellow",
bold: true,
},
});
// Create a custom progress bar
const progressBox = grid.set(4, 0, 1, 12, blessed.box, {
label: " Scanning Progress ",
tags: true,
border: {
type: "line",
fg: uiColor,
},
style: {
fg: "white",
},
});
// Create stats display
const stats = grid.set(5, 0, 2, 6, blessed.box, {
label: "Stats",
tags: true,
border: {
type: "line",
fg: uiColor,
},
style: {
fg: "white",
},
});
// Create event distribution display
const eventDistribution = grid.set(5, 6, 2, 6, blessed.box, {
label: "Event Distribution",
tags: true,
border: {
type: "line",
fg: uiColor,
},
style: {
fg: "white",
},
});
// Create log window
const logWindow = grid.set(7, 0, 4, 12, contrib.log, {
label: "Event Log",
tags: true,
border: {
type: "line",
fg: uiColor,
},
style: {
fg: "green",
},
bufferLength: 30,
});
// Exit on Escape, q, or Ctrl+C
screen.key(["escape", "q", "C-c"], function (ch, key) {
return process.exit(0);
});
// Custom function to update the progress bar display
const updateProgressBar = (progress, label = "") => {
try {
// Calculate the width of the progress bar (accounting for borders and label)
const width = progressBox.width - 4;
const filledWidth = Math.floor(width * progress);
const emptyWidth = width - filledWidth;
// Create the progress bar with our neon cyan color
const filledBar = NEON_CYAN_BG + " ".repeat(filledWidth) + RESET;
const emptyBar = chalk.bgBlack(" ".repeat(emptyWidth));
// Update the progress box content
progressBox.setContent(
`${filledBar}${emptyBar} ${(progress * 100).toFixed(2)}% ${label}`
);
} catch (err) {
// Silently handle errors
}
};
// Function to update event distribution display using ASCII bars
const updateEventDistribution = (eventCounts) => {
try {
// Make sure we have some events before calculating percentages
const hasEvents = eventCounts.Total > 0;
// Get all event names (excluding Unknown and Total)
const eventNames = Object.keys(eventCounts).filter(
(key) => key !== "Unknown" && key !== "Total"
);
// Calculate the total (excluding unknown and total)
const knownTotal = Math.max(
eventNames.reduce((sum, name) => sum + eventCounts[name], 0),
1 // Ensure we don't divide by zero
);
// Find max count for scaling
const maxCount = Math.max(
...eventNames.map((name) => eventCounts[name]),
1
);
// Create percentage bars for each event type - using simpler color approach
const createBar = (count, color, maxWidth = 30) => {
// Calculate scaled width to ensure small values are visible
let width = 0;
let percentage = 0;
if (hasEvents) {
percentage = count / knownTotal;
// Use a logarithmic scale for better visualization when values are lopsided
if (maxCount > 0 && count > 0) {
// Ensure small values get at least a small bar
const logScale = Math.log(count + 1) / Math.log(maxCount + 1);
width = Math.max(1, Math.floor(logScale * maxWidth));
}
}
// Use a more visible character for the bar
const barChar = "■";
// Use safe color approach
return (
color(barChar.repeat(width)) +
` ${formatNumber(count)} (${(percentage * 100).toFixed(1)}%)`
);
};
// Add extra spacing for better readability
const labelWidth = 12;
// Use basic colors for better terminal compatibility
const colors = [
chalk.green, // Green
chalk.yellow, // Yellow
chalk.blue, // Blue
chalk.red, // Red
chalk.magenta, // Magenta
chalk.cyan, // Cyan
chalk.white, // White
];
// Set the content with each bar on its own line
const content = eventNames
.map((name, index) => {
const color = colors[index % colors.length];
// Use neonCyanText for event names for consistency with other labels
return `${neonCyanText(name.padEnd(labelWidth))} ${createBar(
eventCounts[name],
color
)}`;
})
.join("\n");
eventDistribution.setContent(content);
} catch (err) {
// Silently handle errors
}
};
// Render the screen
screen.render();
//=============================================================================
// MAIN FUNCTION
//=============================================================================
const startTime = performance.now();
// Log startup
logWindow.log(chalk.yellow(`Initializing Event Scanner...`));
screen.render();
try {
//=========================================================================
// STEP 1: Get blockchain height using Hypersync
//=========================================================================
const height = await client.getHeight();
logWindow.log(
`Starting scan from block ${safeHexColor("0")} to ${safeHexColor(
formatNumber(height)
)}`
);
screen.render();
//=========================================================================
// STEP 2: Create a decoder for the event signatures
//=========================================================================
const decoder = Decoder.fromSignatures(eventSignatures);
logWindow.log("Event decoder initialized");
screen.render();
//=========================================================================
// STEP 3: Stream events from Hypersync
//=========================================================================
logWindow.log(chalk.green("Starting event stream..."));
screen.render();
const stream = await client.stream(query, {});
// Update subtitle to show network
subtitle.setContent(
chalk.yellow(` ${title} - Block Height: ${formatNumber(height)} `)
);
screen.render();
//=========================================================================
// STEP 4: Process streaming data
//=========================================================================
let lastLogUpdate = 0;
let lastDistributionUpdate = 0;
// Initialize progress bar
updateProgressBar(0, `Block: 0/${formatNumber(height)}`);
// Initialize distribution display
updateEventDistribution(eventCounts);
screen.render();
while (true) {
// Get the next batch of data from Hypersync
const res = await stream.recv();
// Quit if we reached the tip of the blockchain
if (res === null) {
logWindow.log(chalk.green("✓ Reached the tip of the blockchain!"));
updateProgressBar(
1,
`Block: ${formatNumber(height)}/${formatNumber(height)}`
);
screen.render();
break;
}
// Make sure we have a nextBlock value
if (!res.nextBlock) {
logWindow.log(chalk.yellow("Warning: Missing nextBlock in response"));
continue;
}
// Process logs if any exist in this batch
if (
res.data &&
res.data.logs &&
Array.isArray(res.data.logs) &&
res.data.logs.length > 0
) {
// Process logs based on their topic0 value
res.data.logs.forEach((log) => {
if (!log) return; // Skip if log is null
eventCounts.Total++;
if (!log.topics || !Array.isArray(log.topics) || !log.topics[0]) {
eventCounts.Unknown++;
return;
}
const topic0 = log.topics[0];
const eventName = topic0ToName[topic0] || "Unknown";
if (eventName === "Unknown") {
eventCounts.Unknown++;
} else {
eventCounts[eventName] = (eventCounts[eventName] || 0) + 1;
}
});
// Log a decoded event sample occasionally
try {
if (eventCounts.Total % 1000 === 0 && res.data.logs[0]) {
const decodedLogs = await decoder.decodeLogs([res.data.logs[0]]);
if (
decodedLogs &&
Array.isArray(decodedLogs) &&
decodedLogs.length > 0 &&
decodedLogs[0]
) {
const eventInfo = decodedLogs[0].event
? safeStringify(decodedLogs[0].event)
: "No event data";
logWindow.log(
neonCyanText(
`Sample event at block ${res.nextBlock}: ${eventInfo}`
)
);
screen.render();
}
}
} catch (decodeError) {
logWindow.log(chalk.yellow(`Decode warning: ${decodeError.message}`));
}
}
// Update the fromBlock for the next iteration
if (res.nextBlock) {
query.fromBlock = res.nextBlock;
}
// Calculate time stats
const currentTime = performance.now();
const seconds = Math.max((currentTime - startTime) / 1000, 0.1); // Avoid division by zero
const eventsPerSecond = (eventCounts.Total / seconds).toFixed(1);
// Calculate progress
const progress = Math.min(res.nextBlock / height, 1);
// Update the progress bar
updateProgressBar(
progress,
`Block: ${formatNumber(res.nextBlock)}/${formatNumber(height)}`
);
// Update stats display
try {
stats.setContent(
`${neonCyanText("Current Block")}: ${formatNumber(res.nextBlock)}\n` +
`${neonCyanText("Progress")}: ${(progress * 100).toFixed(2)}%\n` +
`${neonCyanText("Total Events")}: ${formatNumber(
eventCounts.Total
)}\n` +
`${neonCyanText("Elapsed Time")}: ${seconds.toFixed(1)}s\n` +
`${neonCyanText("Speed")}: ${formatNumber(
eventsPerSecond
)} events/s`
);
} catch (statsError) {
// Silently handle errors
}
// Update event distribution periodically
if (res.nextBlock - lastDistributionUpdate >= 10000) {
updateEventDistribution(eventCounts);
lastDistributionUpdate = res.nextBlock;
}
// Log progress periodically to avoid too many updates
if (res.nextBlock - lastLogUpdate >= 50000) {
logWindow.log(
`${neonCyanText("Block")} ${formatNumber(
res.nextBlock
)} | ${formatNumber(
eventCounts.Total
)} events | ${eventsPerSecond} events/s`
);
lastLogUpdate = res.nextBlock;
}
// Render the updated screen
screen.render();
}
//=========================================================================
// Final summary
//=========================================================================
const totalTime = Math.max((performance.now() - startTime) / 1000, 0.1); // Avoid division by zero
// Update final stats
stats.setContent(
`${neonCyanText("Blocks Scanned")}: ${formatNumber(height)}\n` +
`${neonCyanText("Total Events")}: ${formatNumber(
eventCounts.Total
)}\n` +
`${neonCyanText("Elapsed Time")}: ${totalTime.toFixed(1)}s\n` +
`${neonCyanText("Avg Speed")}: ${formatNumber(
Math.round(eventCounts.Total / totalTime)
)} events/s`
);
// Final distribution update
updateEventDistribution(eventCounts);
// Log completion
logWindow.log(chalk.green("✓ Scan complete!"));
logWindow.log(
chalk.yellow(`Total processing time: ${totalTime.toFixed(2)} seconds`)
);
logWindow.log(
chalk.yellow(
`Average speed: ${formatNumber(
Math.round(eventCounts.Total / totalTime)
)} events/second`
)
);
// Bold final message
subtitle.setContent(chalk.green.bold(" Scan Complete - Press Q to Exit "));
// Render final screen
screen.render();
// Wait for user to exit
await new Promise((resolve) => setTimeout(resolve, 1000000000));
} catch (error) {
logWindow.log(chalk.red(`Error: ${error.message}`));
screen.render();
await new Promise((resolve) => setTimeout(resolve, 5000));
process.exit(1);
}
}