diff --git a/dataService.js b/dataService.js index d0e2977..8f8a8e2 100644 --- a/dataService.js +++ b/dataService.js @@ -1283,6 +1283,74 @@ export function getRelationsById(chainId) { }; } +/** + * BFS graph traversal of chain relations starting from a given chain ID + * @param {number} startChainId - The chain ID to start traversal from + * @param {number} maxDepth - Maximum traversal depth (default: 2) + * @returns {Object|null} Traversal result with nodes and edges, or null if chain not found + */ +export function traverseRelations(startChainId, maxDepth = 2) { + if (!cachedData.indexed) return null; + + const startChain = cachedData.indexed.byChainId[startChainId]; + if (!startChain) return null; + + const visited = new Set(); + const queue = [{ chainId: startChainId, depth: 0 }]; + const nodes = []; + const edges = []; + + while (queue.length > 0) { + const { chainId, depth } = queue.shift(); + if (visited.has(chainId)) continue; + visited.add(chainId); + + const chain = cachedData.indexed.byChainId[chainId]; + if (!chain) continue; + + nodes.push({ + chainId: chain.chainId, + name: chain.name, + tags: chain.tags || [], + depth + }); + + if (depth >= maxDepth) continue; + + const relations = chain.relations || []; + for (const rel of relations) { + if (rel.chainId === undefined) continue; + + // Deduplicate bidirectional edges (A→B and B→A with same kind) + const a = Math.min(chainId, rel.chainId); + const b = Math.max(chainId, rel.chainId); + const isDuplicate = edges.some(e => Math.min(e.from, e.to) === a && Math.max(e.from, e.to) === b && e.kind === rel.kind); + if (!isDuplicate) { + edges.push({ + from: chainId, + to: rel.chainId, + kind: rel.kind, + source: rel.source + }); + } + + if (!visited.has(rel.chainId)) { + queue.push({ chainId: rel.chainId, depth: depth + 1 }); + } + } + } + + return { + startChainId, + startChainName: startChain.name, + maxDepth, + totalNodes: nodes.length, + totalEdges: edges.length, + nodes, + edges + }; +} + /** * Extract endpoints from a chain (helper function) */ diff --git a/index.js b/index.js index 7010aa2..0a68d55 100644 --- a/index.js +++ b/index.js @@ -4,7 +4,7 @@ import rateLimit from '@fastify/rate-limit'; import helmet from '@fastify/helmet'; import { readFile } from 'node:fs/promises'; import { basename, resolve } from 'node:path'; -import { loadData, initializeDataOnStartup, getCachedData, searchChains, getChainById, getAllChains, getAllRelations, getRelationsById, getEndpointsById, getAllEndpoints, getAllKeywords, validateChainData } from './dataService.js'; +import { loadData, initializeDataOnStartup, getCachedData, searchChains, getChainById, getAllChains, getAllRelations, getRelationsById, getEndpointsById, getAllEndpoints, getAllKeywords, validateChainData, traverseRelations } from './dataService.js'; import { getMonitoringResults, getMonitoringStatus, startRpcHealthCheck } from './rpcMonitor.js'; import { PORT, HOST, BODY_LIMIT, MAX_PARAM_LENGTH, @@ -180,6 +180,28 @@ export async function buildApp(options = {}) { return result; }); + /** + * BFS graph traversal of chain relations + */ + fastify.get('/relations/:id/graph', async (request, reply) => { + const chainId = parseIntParam(request.params.id); + if (chainId === null) { + return sendError(reply, 400, 'Invalid chain ID'); + } + + const depth = request.query.depth !== undefined ? parseIntParam(request.query.depth) : 2; + if (depth === null || depth < 1 || depth > 5) { + return sendError(reply, 400, 'Invalid depth. Must be between 1 and 5'); + } + + const result = traverseRelations(chainId, depth); + if (!result) { + return sendError(reply, 404, 'Chain not found'); + } + + return result; + }); + /** * Get all endpoints */ @@ -371,17 +393,54 @@ export async function buildApp(options = {}) { } const workingCount = chainResults.filter(r => r.status === 'working').length; + const failedCount = chainResults.filter(r => r.status === 'failed').length; return { chainId, chainName: chainResults[0].chainName, totalEndpoints: chainResults.length, workingEndpoints: workingCount, + failedEndpoints: failedCount, lastUpdated: results.lastUpdated, endpoints: chainResults }; }); + /** + * Get aggregate stats + */ + fastify.get('/stats', async (request, reply) => { + const chains = getAllChains(); + const monitorResults = getMonitoringResults(); + + const totalChains = chains.length; + const totalMainnets = chains.filter(c => !c.tags?.includes('Testnet') && !c.tags?.includes('L2') && !c.tags?.includes('Beacon')).length; + const totalTestnets = chains.filter(c => c.tags?.includes('Testnet')).length; + const totalL2s = chains.filter(c => c.tags?.includes('L2')).length; + const totalBeacons = chains.filter(c => c.tags?.includes('Beacon')).length; + + const rpcWorking = monitorResults.workingEndpoints; + const rpcFailed = monitorResults.failedEndpoints || 0; + const rpcTested = monitorResults.testedEndpoints; + const rpcHealthPercent = rpcTested > 0 ? Math.round((rpcWorking / rpcTested) * 10000) / 100 : null; + + return { + totalChains, + totalMainnets, + totalTestnets, + totalL2s, + totalBeacons, + rpc: { + totalEndpoints: monitorResults.totalEndpoints, + tested: rpcTested, + working: rpcWorking, + failed: rpcFailed, + healthPercent: rpcHealthPercent + }, + lastUpdated: monitorResults.lastUpdated + }; + }); + /** * Root endpoint with API information */ @@ -407,7 +466,9 @@ export async function buildApp(options = {}) { '/validate': 'Validate chain data for potential human errors', '/keywords': 'Get extracted keywords (blockchain names, network names, client names, etc.)', '/rpc-monitor': 'Get RPC endpoint monitoring results', - '/rpc-monitor/:id': 'Get RPC monitoring results for a specific chain by ID' + '/rpc-monitor/:id': 'Get RPC monitoring results for a specific chain by ID', + '/stats': 'Get aggregate stats (chain counts, RPC health percentage)', + '/relations/:id/graph?depth=N': 'BFS graph traversal of chain relations (default depth: 2)' }, dataSources: [ DATA_SOURCE_THE_GRAPH, diff --git a/mcp-tools.js b/mcp-tools.js index c023272..4c4badb 100644 --- a/mcp-tools.js +++ b/mcp-tools.js @@ -9,6 +9,7 @@ import { getAllEndpoints, getAllKeywords, validateChainData, + traverseRelations, } from './dataService.js'; import { getMonitoringResults, getMonitoringStatus } from './rpcMonitor.js'; @@ -131,6 +132,32 @@ export function getToolDefinitions() { properties: {}, }, }, + { + name: 'get_stats', + description: 'Get aggregate statistics: total chains, mainnets, testnets, L2s, beacons, and RPC health percentage', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'traverse_relations', + description: 'BFS graph traversal of chain relations from a starting chain. Returns all reachable chains (nodes) and their relationship edges up to a given depth.', + inputSchema: { + type: 'object', + properties: { + chainId: { + type: 'number', + description: 'The chain ID to start traversal from (e.g., 1 for Ethereum)', + }, + depth: { + type: 'number', + description: 'Maximum traversal depth (1-5, default: 2)', + }, + }, + required: ['chainId'], + }, + }, { name: 'get_rpc_monitor_by_id', description: 'Get RPC endpoint monitoring results for a specific chain by its chain ID', @@ -287,6 +314,56 @@ function handleValidateChains() { return textResponse(validationResults); } +function handleGetStats() { + const chains = getAllChains(); + const monitorResults = getMonitoringResults(); + + const totalChains = chains.length; + const totalMainnets = chains.filter(c => !c.tags?.includes('Testnet') && !c.tags?.includes('L2') && !c.tags?.includes('Beacon')).length; + const totalTestnets = chains.filter(c => c.tags?.includes('Testnet')).length; + const totalL2s = chains.filter(c => c.tags?.includes('L2')).length; + const totalBeacons = chains.filter(c => c.tags?.includes('Beacon')).length; + + const rpcTested = monitorResults.testedEndpoints; + const rpcWorking = monitorResults.workingEndpoints; + const rpcFailed = monitorResults.failedEndpoints || 0; + const rpcHealthPercent = rpcTested > 0 ? Math.round((rpcWorking / rpcTested) * 10000) / 100 : null; + + return textResponse({ + totalChains, + totalMainnets, + totalTestnets, + totalL2s, + totalBeacons, + rpc: { + totalEndpoints: monitorResults.totalEndpoints, + tested: rpcTested, + working: rpcWorking, + failed: rpcFailed, + healthPercent: rpcHealthPercent, + }, + lastUpdated: monitorResults.lastUpdated, + }); +} + +function handleTraverseRelations(args) { + const { chainId, depth } = args; + if (!isValidChainId(chainId)) { + return errorResponse('Invalid chain ID'); + } + + const maxDepth = depth !== undefined ? depth : 2; + if (typeof maxDepth !== 'number' || maxDepth < 1 || maxDepth > 5) { + return errorResponse('Invalid depth. Must be between 1 and 5'); + } + + const result = traverseRelations(chainId, maxDepth); + if (!result) { + return errorResponse('Chain not found'); + } + return textResponse(result); +} + function getStatusLabel(status, results) { if (status.isMonitoring) return 'Running'; if (results.testedEndpoints > 0) return 'Completed'; @@ -304,6 +381,7 @@ function formatRpcMonitorStatus(status, results) { `- Total endpoints discovered: ${results.totalEndpoints}`, `- Endpoints tested: ${results.testedEndpoints}`, `- Working endpoints: ${results.workingEndpoints}`, + `- Failed endpoints: ${results.failedEndpoints ?? 0}`, '- Use `get_rpc_monitor_by_id` for per-chain endpoint details.', ]; @@ -355,9 +433,10 @@ function handleGetRpcMonitorById(args) { ]; for (const ep of chainResults) { const block = ep.blockNumber == null ? '' : ` — block #${ep.blockNumber}`; + const latency = ep.latencyMs != null ? ` [${ep.latencyMs}ms]` : ''; const client = ep.clientVersion && ep.clientVersion !== 'unavailable' ? ` (${ep.clientVersion})` : ''; lines.push( - `- **${ep.status}** ${ep.url}${block}${client}`, + `- **${ep.status}** ${ep.url}${block}${latency}${client}`, ...(ep.error ? [` - Error: ${ep.error}`] : []) ); } @@ -376,6 +455,8 @@ const toolHandlers = { get_sources: handleGetSources, get_keywords: handleGetKeywords, validate_chains: handleValidateChains, + get_stats: handleGetStats, + traverse_relations: handleTraverseRelations, get_rpc_monitor: handleGetRpcMonitor, get_rpc_monitor_by_id: handleGetRpcMonitorById, }; diff --git a/public/app.js b/public/app.js index e756e90..0b5837b 100644 --- a/public/app.js +++ b/public/app.js @@ -1,10 +1,10 @@ // Constants for Node Colors const COLORS = { - MAINNET: '#10b981', // Emerald green - L2: '#8b5cf6', // Purple - TESTNET: '#f59e0b', // Amber - BEACON: '#ec4899', // Pink - DEFAULT: '#6b7280' // Gray + MAINNET: '#10b981', + L2: '#8b5cf6', + TESTNET: '#f59e0b', + BEACON: '#ec4899', + DEFAULT: '#6b7280' }; // Global State @@ -13,6 +13,40 @@ let filteredData = { nodes: [], links: [] }; let currentFilter = 'all'; let myGraph = null; +// ─── Utility: Debounce ─── +function debounce(fn, ms) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }; +} + +// ─── Utility: Highlight matching text safely using DOM (no innerHTML) ─── +function highlightText(container, text, query) { + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const idx = lowerText.indexOf(lowerQuery); + + if (idx === -1 || !query) { + container.textContent = text; + return; + } + + // Before match + if (idx > 0) { + container.appendChild(document.createTextNode(text.slice(0, idx))); + } + // Match (bold) + const strong = document.createElement('strong'); + strong.textContent = text.slice(idx, idx + query.length); + container.appendChild(strong); + // After match + if (idx + query.length < text.length) { + container.appendChild(document.createTextNode(text.slice(idx + query.length))); + } +} + // Initialize when DOM is ready document.addEventListener('DOMContentLoaded', () => { initUI(); @@ -23,17 +57,18 @@ function initUI() { // Filter Buttons document.querySelectorAll('.filter-btn').forEach(btn => { btn.addEventListener('click', (e) => { + const target = e.currentTarget; document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); - e.target.classList.add('active'); - - currentFilter = e.target.dataset.filter; + target.classList.add('active'); + currentFilter = target.dataset.filter; applyFilters(); }); }); - // Search Logic (Custom Dropdown) + // Search Logic const searchInput = document.getElementById('searchInput'); const searchDropdown = document.getElementById('searchDropdown'); + let activeDropdownIndex = -1; globalThis.searchAndFocus = (query) => { const q = String(query).toLowerCase().trim(); @@ -51,8 +86,6 @@ function initUI() { searchInput.value = node.name; searchDropdown.classList.add('hidden'); focusNode(node); - } else { - alert('Chain not found. Try a different ID or name.'); } }; @@ -63,15 +96,25 @@ function initUI() { } }); - searchInput.addEventListener('input', (e) => { - const query = e.target.value.toLowerCase().trim(); + // Keyboard shortcut: "/" to focus search + document.addEventListener('keydown', (e) => { + if (e.key === '/' && document.activeElement !== searchInput) { + e.preventDefault(); + searchInput.focus(); + } + if (e.key === 'Escape') { + searchDropdown.classList.add('hidden'); + searchInput.blur(); + } + }); + // Debounced search to avoid excessive DOM rebuilds + const handleSearch = debounce((query) => { if (!query) { searchDropdown.classList.add('hidden'); return; } - // Filter nodes matching search query const matches = graphData.nodes.filter(n => n.name.toLowerCase().includes(query) || n.id.toString().includes(query) || @@ -80,73 +123,105 @@ function initUI() { (n.data.tags?.some(t => t.toLowerCase().includes(query))) ); - // Sort matches to prioritize exact/closer matches + // Sort: exact/prefix matches first matches.sort((a, b) => { const aName = a.name.toLowerCase(); const bName = b.name.toLowerCase(); - - // Prioritize if the name starts with the query const aStarts = aName.startsWith(query); const bStarts = bName.startsWith(query); - if (aStarts && !bStarts) return -1; - if (!aStarts && bStarts) return 1; - - // Prioritize if the query is in the name vs tags + if (aStarts !== bStarts) return aStarts ? -1 : 1; const aInName = aName.includes(query); const bInName = bName.includes(query); - if (aInName && !bInName) return -1; - if (!aInName && bInName) return 1; - - // Fallback to alphabetical sort + if (aInName !== bInName) return aInName ? -1 : 1; return aName.localeCompare(bName); }); - const topMatches = matches.slice(0, 100); // Limit to 100 results for scrollable container + const topMatches = matches.slice(0, 50); + activeDropdownIndex = -1; - searchDropdown.innerHTML = ''; + // Build dropdown using DocumentFragment for performance + const fragment = document.createDocumentFragment(); if (topMatches.length === 0) { - searchDropdown.innerHTML = ''; + const empty = document.createElement('div'); + empty.className = 'dropdown-empty'; + empty.textContent = 'No chains found.'; + fragment.appendChild(empty); } else { - topMatches.forEach(node => { + for (const node of topMatches) { const item = document.createElement('div'); item.className = 'dropdown-item'; + item.dataset.nodeId = node.id; - const iconColor = node.color; - const initial = node.name ? node.name.charAt(0).toUpperCase() : '?'; - - // Bold the matching part of the name - const regex = new RegExp(`(${query})`, 'gi'); - const highlightedName = node.name.replace(regex, '$1'); - - // Format tags - const tagsList = (node.data.tags && node.data.tags.length > 0) - ? node.data.tags.join(', ') : node.type; - - item.innerHTML = ` - - - `; + const icon = document.createElement('div'); + icon.className = 'dropdown-icon'; + icon.style.background = `linear-gradient(135deg, ${node.color}, ${node.color}44)`; + icon.textContent = node.name ? node.name.charAt(0).toUpperCase() : '?'; + + const info = document.createElement('div'); + info.className = 'dropdown-info'; + + const nameSpan = document.createElement('span'); + nameSpan.className = 'dropdown-name'; + highlightText(nameSpan, node.name, query); + + const meta = document.createElement('div'); + meta.className = 'dropdown-meta'; + const tagsList = node.data.tags?.length > 0 ? node.data.tags.join(', ') : node.type; + meta.textContent = `ID: ${node.id} \u00b7 ${tagsList}`; + + info.appendChild(nameSpan); + info.appendChild(meta); + item.appendChild(icon); + item.appendChild(info); item.addEventListener('click', () => searchAndFocus(node.id)); - searchDropdown.appendChild(item); - }); + fragment.appendChild(item); + } } + searchDropdown.textContent = ''; + searchDropdown.appendChild(fragment); searchDropdown.classList.remove('hidden'); + }, 150); + + searchInput.addEventListener('input', (e) => { + handleSearch(e.target.value.toLowerCase().trim()); }); - searchInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') searchAndFocus(searchInput.value); + // Keyboard navigation in dropdown + searchInput.addEventListener('keydown', (e) => { + const items = searchDropdown.querySelectorAll('.dropdown-item'); + if (!items.length) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + activeDropdownIndex = Math.min(activeDropdownIndex + 1, items.length - 1); + updateActiveItem(items); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + activeDropdownIndex = Math.max(activeDropdownIndex - 1, 0); + updateActiveItem(items); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (activeDropdownIndex >= 0 && items[activeDropdownIndex]) { + const nodeId = items[activeDropdownIndex].dataset.nodeId; + searchAndFocus(nodeId); + } else { + searchAndFocus(searchInput.value); + } + } }); + function updateActiveItem(items) { + items.forEach((item, i) => { + item.classList.toggle('active', i === activeDropdownIndex); + }); + if (items[activeDropdownIndex]) { + items[activeDropdownIndex].scrollIntoView({ block: 'nearest' }); + } + } + // Close Details Panel document.getElementById('closeDetails').addEventListener('click', () => { document.getElementById('detailsPanel').classList.add('hidden'); @@ -155,16 +230,23 @@ function initUI() { async function fetchData() { try { - const res = await fetch('https://raw.githubusercontent.com/Johnaverse/chains-api/refs/heads/main/public/export.json'); + // Try local API first (/export), fall back to GitHub raw + let res; + try { + res = await fetch('/export'); + if (!res.ok) throw new Error('Local export unavailable'); + } catch { + res = await fetch('https://raw.githubusercontent.com/Johnaverse/chains-api/refs/heads/main/public/export.json'); + } const exportData = await res.json(); const chains = exportData.data.indexed.all; - // Build relations map { parentId: { childId: { kind } } } from per-chain relations arrays + // Build relations map from per-chain relations arrays const relations = {}; - chains.forEach(chain => { - if (!chain.relations) return; - chain.relations.forEach(rel => { + for (const chain of chains) { + if (!chain.relations) continue; + for (const rel of chain.relations) { if (rel.kind === 'l2Of') { if (!relations[rel.chainId]) relations[rel.chainId] = {}; relations[rel.chainId][chain.chainId] = { kind: 'l2Of' }; @@ -175,11 +257,14 @@ async function fetchData() { if (!relations[chain.chainId]) relations[chain.chainId] = {}; relations[chain.chainId][rel.chainId] = { kind: 'testnetOf' }; } - }); - }); + } + } processGraphData(chains, relations); + // Update stats line + updateStats(); + // Hide loading overlay document.getElementById('loadingOverlay').classList.add('hidden'); @@ -188,41 +273,41 @@ async function fetchData() { } catch (error) { console.error('Error fetching data:', error); const overlay = document.getElementById('loadingOverlay'); - overlay.textContent = 'Failed to load data. Ensure export.json is available.'; - overlay.style.color = '#ef4444'; + overlay.querySelector('.spinner').style.display = 'none'; + overlay.querySelector('p').textContent = 'Failed to load data.'; + overlay.querySelector('.loading-sub').textContent = 'Check your connection or ensure the API is running.'; } } +function updateStats() { + const total = graphData.nodes.length; + const mainnets = graphData.nodes.filter(n => n.type === 'Mainnet').length; + const l2s = graphData.nodes.filter(n => n.type === 'L2').length; + const testnets = graphData.nodes.filter(n => n.type === 'Testnet').length; + + const statsEl = document.getElementById('statsLine'); + statsEl.textContent = `${total} chains \u00b7 ${mainnets} mainnets \u00b7 ${l2s} L2s \u00b7 ${testnets} testnets`; +} + function processGraphData(chains, relations) { const nodes = []; const links = []; - - // Create quick lookup maps const nodeMap = new Map(); // First pass: Add all nodes - chains.forEach(c => { - // Determine node type/color based on tags + for (const c of chains) { let type = 'Mainnet'; let color = COLORS.MAINNET; let val; if (c.tags?.includes('Beacon')) { - type = 'Beacon'; - color = COLORS.BEACON; - val = 1.5; + type = 'Beacon'; color = COLORS.BEACON; val = 1.5; } else if (c.tags?.includes('L2')) { - type = 'L2'; - color = COLORS.L2; - val = 1.8; + type = 'L2'; color = COLORS.L2; val = 1.8; } else if (c.tags?.includes('Testnet')) { - type = 'Testnet'; - color = COLORS.TESTNET; - val = 1; + type = 'Testnet'; color = COLORS.TESTNET; val = 1; } else { - // Mainnets are larger val = 3; - // E.g. Ethereum is huge if (c.chainId === 1) val = 8; } @@ -234,11 +319,11 @@ function processGraphData(chains, relations) { const node = { id: c.chainId, name: displayName, - val: val, - color: color, - type: type, + val, + color, + type, data: c, - parent: null, // used for filtering mostly + parent: null, l2Parent: null, mainnetParent: null, children: [], @@ -247,18 +332,16 @@ function processGraphData(chains, relations) { }; nodes.push(node); nodeMap.set(c.chainId, node); - }); - - + } - // Second pass: Use relations API maps format { "parentID": { "childID": { ... } } } - Object.keys(relations).forEach(parentIdStr => { + // Second pass: Build links from relations + for (const parentIdStr of Object.keys(relations)) { const parentId = Number.parseInt(parentIdStr); const childrenObj = relations[parentIdStr]; - Object.keys(childrenObj).forEach(childIdStr => { + for (const childIdStr of Object.keys(childrenObj)) { const childId = Number.parseInt(childIdStr); - const relationInfo = childrenObj[childIdStr]; // e.g. { kind: "l2Of", ... } + const relationInfo = childrenObj[childIdStr]; const parentNode = nodeMap.get(parentId); const childNode = nodeMap.get(childId); @@ -267,7 +350,7 @@ function processGraphData(chains, relations) { links.push({ source: childId, target: parentId, - kind: relationInfo.kind // 'l2Of', 'testnetOf', etc. + kind: relationInfo.kind }); if (relationInfo.kind === 'l2Of' || relationInfo.kind === 'l1Of') { @@ -278,11 +361,11 @@ function processGraphData(chains, relations) { parentNode.testnetChildren.push(childNode); } - childNode.parent = parentNode; // fallback - parentNode.children.push(childNode); // fallback + childNode.parent = parentNode; + parentNode.children.push(childNode); } - }); - }); + } + } graphData = { nodes, links }; filteredData = { nodes: [...nodes], links: [...links] }; @@ -297,69 +380,62 @@ function applyFilters() { } else if (currentFilter === 'Mainnet') { const visibleNodesSet = new Set(); - // Recursively add L2 children (handles L3, L4, etc.) - // Skip testnet chains — only mainnet (production) chains belong here function addL2Tree(node) { if (node.l2Children) { - node.l2Children.forEach(child => { + for (const child of node.l2Children) { const isTestnet = child.data.tags?.includes('Testnet'); if (!visibleNodesSet.has(child) && !isTestnet) { visibleNodesSet.add(child); addL2Tree(child); } - }); + } } } - // Add all Mainnet and Beacon nodes (Beacon chains like Ethereum are also mainnets) - // and recursively add their entire L2 tree. - // Exclude nodes that are testnets (have a mainnetParent) even if they lack the Testnet tag. - graphData.nodes.forEach(n => { + for (const n of graphData.nodes) { if ((n.type === 'Mainnet' || n.type === 'Beacon') && !n.mainnetParent) { visibleNodesSet.add(n); addL2Tree(n); } - }); + } const visibleNodes = Array.from(visibleNodesSet); const visibleNodeIds = new Set(visibleNodes.map(n => n.id)); - // Include all non-testnet links between visible nodes const visibleLinks = graphData.links.filter(l => { - const sourceId = l.source.id || l.source; - const targetId = l.target.id || l.target; + const sourceId = l.source.id ?? l.source; + const targetId = l.target.id ?? l.target; return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId) && l.kind !== 'testnetOf'; }); - filteredData = { - nodes: visibleNodes, - links: visibleLinks - }; + filteredData = { nodes: visibleNodes, links: visibleLinks }; } else { const visibleNodesSet = new Set(); - // Add nodes matching filter AND their parents - graphData.nodes.forEach(n => { + for (const n of graphData.nodes) { if (n.type === currentFilter) { visibleNodesSet.add(n); - if (n.parent) { - visibleNodesSet.add(n.parent); - } + if (n.parent) visibleNodesSet.add(n.parent); } - }); + } const visibleNodes = Array.from(visibleNodesSet); const visibleNodeIds = new Set(visibleNodes.map(n => n.id)); const visibleLinks = graphData.links.filter(l => - visibleNodeIds.has(l.source.id || l.source) && - visibleNodeIds.has(l.target.id || l.target) + visibleNodeIds.has(l.source.id ?? l.source) && + visibleNodeIds.has(l.target.id ?? l.target) ); - filteredData = { - nodes: visibleNodes, - links: visibleLinks - }; + filteredData = { nodes: visibleNodes, links: visibleLinks }; + } + + // Update stats for filtered view + const statsEl = document.getElementById('statsLine'); + if (currentFilter === 'all') { + updateStats(); + } else { + statsEl.textContent = `Showing ${filteredData.nodes.length} of ${graphData.nodes.length} chains`; } if (myGraph) { @@ -375,46 +451,45 @@ function renderGraph() { .nodeLabel('name') .nodeColor('color') .nodeVal('val') - .nodeResolution(16) // Higher res spheres + .nodeResolution(12) + .nodeOpacity(0.9) .linkColor(link => { - if (link.kind === 'l2Of' || link.kind === 'l1Of') return 'rgba(139, 92, 246, 0.45)'; // Purple for L2 - if (link.kind === 'testnetOf') return 'rgba(245, 158, 11, 0.45)'; // Amber for Testnet - return 'rgba(255, 255, 255, 0.15)'; // Default + if (link.kind === 'l2Of' || link.kind === 'l1Of') return 'rgba(139, 92, 246, 0.4)'; + if (link.kind === 'testnetOf') return 'rgba(245, 158, 11, 0.4)'; + return 'rgba(255, 255, 255, 0.1)'; }) - .linkWidth(1) + .linkWidth(0.8) .linkDirectionalParticles(link => { - // Adds small moving particles to highlight relation direction (child -> parent) if (link.kind === 'l2Of' || link.kind === 'l1Of' || link.kind === 'testnetOf') return 2; return 0; }) - .linkDirectionalParticleSpeed(0.005) + .linkDirectionalParticleSpeed(0.004) + .linkDirectionalParticleWidth(1.5) .linkDirectionalParticleColor(link => { - if (link.kind === 'l2Of' || link.kind === 'l1Of') return 'rgba(139, 92, 246, 0.8)'; - if (link.kind === 'testnetOf') return 'rgba(245, 158, 11, 0.8)'; + if (link.kind === 'l2Of' || link.kind === 'l1Of') return 'rgba(139, 92, 246, 0.7)'; + if (link.kind === 'testnetOf') return 'rgba(245, 158, 11, 0.7)'; return '#ffffff'; }) - .backgroundColor('#050505') - .cooldownTicks(100) // Stop physics engine early to prevent lag - .onNodeClick(node => focusNode(node)); + .backgroundColor('#060608') + .warmupTicks(80) + .cooldownTicks(60) + .onNodeClick(node => focusNode(node)) + .onBackgroundClick(() => { + document.getElementById('detailsPanel').classList.add('hidden'); + }); } function focusNode(node) { if (!myGraph) return; - // Aim at node from outside it const distance = 150; const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z); const newPos = node.x || node.y || node.z ? { x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio } - : { x: 0, y: 0, z: distance }; // special case if node is at (0,0,0) - - myGraph.cameraPosition( - newPos, - node, // lookAt - 1500 // ms transition - ); + : { x: 0, y: 0, z: distance }; + myGraph.cameraPosition(newPos, node, 1200); showNodeDetails(node); } @@ -437,13 +512,13 @@ function showParentRow(rowId, elemId, parentNode) { } function populateChildLinks(container, children) { - children.forEach(child => { + for (const child of children) { const a = document.createElement('a'); a.href = "#"; a.textContent = child.name; a.onclick = (e) => { e.preventDefault(); searchAndFocus(child.id); }; container.appendChild(a); - }); + } } function showChildrenSection(containerId, labelId, children, label) { @@ -474,7 +549,8 @@ function showRpcEndpoints(data) { const a = document.createElement('a'); a.href = url; a.target = "_blank"; - a.textContent = url.replace('https://', ''); + a.rel = "noopener"; + a.textContent = url.replace(/^https?:\/\//, ''); rpcContainer.appendChild(a); shown++; } @@ -485,32 +561,52 @@ function showExplorers(data) { const expContainer = document.getElementById('chainExplorers'); expContainer.textContent = ''; if (data.explorers && data.explorers.length > 0) { - data.explorers.forEach(e => { + for (const e of data.explorers) { const a = document.createElement('a'); a.href = e.url; a.target = "_blank"; + a.rel = "noopener"; a.textContent = e.name; expContainer.appendChild(a); - }); + } } else { expContainer.textContent = 'None available'; } } +function getStatusClass(status) { + if (!status) return ''; + const s = status.toLowerCase(); + if (s === 'active') return 'status-active'; + if (s === 'deprecated') return 'status-deprecated'; + if (s === 'incubating') return 'status-incubating'; + return ''; +} + function showNodeDetails(node) { const panel = document.getElementById('detailsPanel'); const data = node.data; const iconElem = document.getElementById('chainIcon'); iconElem.textContent = node.name ? node.name.charAt(0).toUpperCase() : '?'; - iconElem.style.background = `linear-gradient(135deg, ${node.color}, #000)`; + iconElem.style.background = `linear-gradient(135deg, ${node.color}, ${node.color}33)`; document.getElementById('chainName').textContent = node.name || 'Unknown Chain'; document.getElementById('chainIdBadge').textContent = `ID: ${data.chainId}`; + // Status badge + const statusBadge = document.getElementById('chainStatusBadge'); + if (data.status) { + statusBadge.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1); + statusBadge.className = `badge tag-badge ${getStatusClass(data.status)}`; + statusBadge.style.display = 'inline-block'; + } else { + statusBadge.style.display = 'none'; + } + const tagsElem = document.getElementById('chainTags'); if (data.tags?.length > 0) { - tagsElem.textContent = `Tags: ${data.tags.join(', ')}`; + tagsElem.textContent = data.tags.join(', '); tagsElem.style.display = 'inline-block'; } else { tagsElem.style.display = 'none'; @@ -521,10 +617,6 @@ function showNodeDetails(node) { ? `${data.nativeCurrency.name} (${data.nativeCurrency.symbol})` : 'None'; - document.getElementById('chainStatus').textContent = data.status - ? data.status.charAt(0).toUpperCase() + data.status.slice(1) - : 'Unknown'; - const { row: rowL1, elem: l1Elem } = showParentRow('rowL1Parent', 'chainL1Parent', node.l2Parent); showParentRow('rowMainnet', 'chainMainnet', node.mainnetParent); @@ -548,12 +640,17 @@ function showNodeDetails(node) { const webElem = document.getElementById('chainWebsite'); if (data.infoURL) { - const a = document.createElement('a'); - a.href = data.infoURL; - a.target = "_blank"; - a.textContent = new URL(data.infoURL).hostname; - webElem.textContent = ''; - webElem.appendChild(a); + try { + const a = document.createElement('a'); + a.href = data.infoURL; + a.target = "_blank"; + a.rel = "noopener"; + a.textContent = new URL(data.infoURL).hostname; + webElem.textContent = ''; + webElem.appendChild(a); + } catch { + webElem.textContent = data.infoURL; + } } else { webElem.textContent = 'None available'; } diff --git a/public/index.html b/public/index.html index 1b21f4f..2286fe1 100644 --- a/public/index.html +++ b/public/index.html @@ -5,6 +5,7 @@ Blockchain Networks Relationships + @@ -23,37 +24,65 @@
-

Blockchain Networks Relationships

+
+

Blockchain Networks

+ Loading... +
- - - - - + + + + +
+ +
+
Mainnet
+
L2
+
Testnet
+
Beacon
+
+
L2 link
+
Testnet link
+
+ -
+

Loading networks...

+

Fetching chain data and building graph

@@ -109,4 +135,4 @@

Chain Name

- \ No newline at end of file + diff --git a/public/style.css b/public/style.css index 983af35..c668d62 100644 --- a/public/style.css +++ b/public/style.css @@ -1,44 +1,47 @@ :root { - --bg-color: #050505; - --panel-bg: rgba(20, 20, 25, 0.65); - --panel-border: rgba(255, 255, 255, 0.08); - --text-main: #f3f4f6; - --text-muted: #9ca3af; + --bg-color: #060608; + --panel-bg: rgba(16, 16, 22, 0.75); + --panel-border: rgba(255, 255, 255, 0.06); + --text-main: #f0f1f3; + --text-muted: #8b8fa3; --accent: #3b82f6; --accent-hover: #60a5fa; - --accent-glow: rgba(59, 130, 246, 0.5); + --accent-glow: rgba(59, 130, 246, 0.4); --color-mainnet: #10b981; --color-l2: #8b5cf6; --color-testnet: #f59e0b; --color-beacon: #ec4899; + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; } * { margin: 0; padding: 0; box-sizing: border-box; - font-family: 'Inter', sans-serif; + font-family: 'Inter', system-ui, sans-serif; } body { background-color: var(--bg-color); color: var(--text-main); overflow: hidden; - /* Hide scrollbars for the 3D canvas */ } -/* Glassmorphism Utilities */ +/* ─── Glass Panel ─── */ .glass-panel { background: var(--panel-bg); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); border: 1px solid var(--panel-border); - border-radius: 16px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + border-radius: var(--radius-lg); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.04); } -/* UI Container overlaying the canvas */ +/* ─── UI Container ─── */ #ui-container { position: absolute; top: 0; @@ -46,147 +49,203 @@ body { width: 100%; height: 100%; pointer-events: none; - /* Let clicks pass through to 3D canvas... */ z-index: 10; display: flex; flex-direction: column; - padding: 24px; + padding: 20px; } -/* Re-enable pointer events for interactive UI elements */ -#ui-container>* { +#ui-container > * { pointer-events: auto; } -/* Header */ +/* ─── Header ─── */ header { width: 100%; - max-width: 800px; + max-width: 680px; margin: 0 auto; - padding: 16px 24px; + padding: 16px 20px; display: flex; flex-direction: column; - gap: 16px; + gap: 12px; +} + +.header-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; } h1 { - font-size: 1.5rem; - font-weight: 600; - letter-spacing: -0.02em; - background: linear-gradient(135deg, #fff, #9ca3af); + font-size: 1.25rem; + font-weight: 700; + letter-spacing: -0.03em; + background: linear-gradient(135deg, #ffffff 0%, #94a3b8 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; - text-align: center; + background-clip: text; + white-space: nowrap; +} + +.subtitle { + font-size: 0.75rem; + color: var(--text-muted); + white-space: nowrap; + font-variant-numeric: tabular-nums; } -/* Search Box */ +/* ─── Search Box ─── */ .search-box { display: flex; + align-items: center; gap: 8px; position: relative; + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-sm); + padding: 0 12px; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.search-box:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); +} + +.search-icon { + color: var(--text-muted); + flex-shrink: 0; } #searchInput { flex: 1; - background: rgba(0, 0, 0, 0.4); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 8px; - padding: 12px 16px; + background: transparent; + border: none; + padding: 10px 0; color: white; - font-size: 0.95rem; - transition: all 0.2s ease; -} - -#searchInput:focus { + font-size: 0.9rem; outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); } #searchInput::placeholder { color: var(--text-muted); } -#searchBtn { - background: var(--accent); - color: white; - border: none; - border-radius: 8px; - width: 44px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s ease; -} - -#searchBtn:hover { - background: var(--accent-hover); - box-shadow: 0 0 15px var(--accent-glow); +.search-hint { + background: rgba(255, 255, 255, 0.08); + color: var(--text-muted); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.7rem; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + border: 1px solid rgba(255, 255, 255, 0.1); + line-height: 1.4; } -/* Filters */ +/* ─── Filters ─── */ .filters { display: flex; - gap: 8px; + gap: 6px; justify-content: center; flex-wrap: wrap; } .filter-btn { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); color: var(--text-muted); - padding: 6px 14px; + padding: 5px 12px; border-radius: 20px; - font-size: 0.85rem; + font-size: 0.8rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 6px; } .filter-btn:hover { - background: #333333; + background: rgba(255, 255, 255, 0.08); color: #ffffff; } .filter-btn.active { - background: #3a3a3a; + background: rgba(255, 255, 255, 0.1); color: #ffffff; - border-color: rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.2); } -.filter-btn[data-filter="Mainnet"].active { - border-color: var(--color-mainnet); - color: var(--color-mainnet); +.filter-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; } -.filter-btn[data-filter="L2"].active { - border-color: var(--color-l2); - color: var(--color-l2); +.dot-all { background: var(--text-muted); } +.dot-mainnet { background: var(--color-mainnet); } +.dot-testnet { background: var(--color-testnet); } +.dot-l2 { background: var(--color-l2); } +.dot-beacon { background: var(--color-beacon); } + +.filter-btn[data-filter="Mainnet"].active { border-color: var(--color-mainnet); color: var(--color-mainnet); } +.filter-btn[data-filter="L2"].active { border-color: var(--color-l2); color: var(--color-l2); } +.filter-btn[data-filter="Testnet"].active { border-color: var(--color-testnet); color: var(--color-testnet); } +.filter-btn[data-filter="Beacon"].active { border-color: var(--color-beacon); color: var(--color-beacon); } + +/* ─── Legend ─── */ +.legend { + position: absolute; + bottom: 20px; + left: 20px; + padding: 10px 14px; + display: flex; + gap: 12px; + align-items: center; + font-size: 0.72rem; + color: var(--text-muted); +} + +.legend-item { + display: flex; + align-items: center; + gap: 5px; + white-space: nowrap; } -.filter-btn[data-filter="Testnet"].active { - border-color: var(--color-testnet); - color: var(--color-testnet); +.legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; } -.filter-btn[data-filter="Beacon"].active { - border-color: var(--color-beacon); - color: var(--color-beacon); +.legend-line { + width: 16px; + height: 2px; + border-radius: 1px; + display: inline-block; } -/* Details Panel */ +.legend-sep { + width: 1px; + height: 14px; + background: rgba(255, 255, 255, 0.1); +} + +/* ─── Details Panel ─── */ #detailsPanel { position: absolute; - right: 24px; + right: 20px; top: 50%; transform: translateY(-50%); - width: 320px; - padding: 24px; + width: 340px; + padding: 20px; display: flex; flex-direction: column; - gap: 16px; + gap: 14px; transition: opacity 0.3s ease, transform 0.3s ease; max-height: calc(100vh - 48px); overflow-y: auto; @@ -198,85 +257,127 @@ h1 { transform: translateY(-50%) translateX(20px); } +/* Scrollbar for details panel */ +#detailsPanel::-webkit-scrollbar { width: 4px; } +#detailsPanel::-webkit-scrollbar-track { background: transparent; } +#detailsPanel::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 4px; } + .close-btn { position: absolute; - top: 16px; - right: 16px; + top: 14px; + right: 14px; background: none; border: none; color: var(--text-muted); - font-size: 1.5rem; + font-size: 1.4rem; cursor: pointer; transition: color 0.2s; + line-height: 1; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; } .close-btn:hover { color: white; + background: rgba(255, 255, 255, 0.08); +} + +.details-header { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.details-title { + flex: 1; + min-width: 0; } .chain-icon { - width: 48px; - height: 48px; - border-radius: 12px; - background: linear-gradient(135deg, var(--accent), #4f46e5); + width: 44px; + height: 44px; + border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; - font-weight: bold; - font-size: 1.2rem; - margin-bottom: 4px; + font-weight: 700; + font-size: 1.1rem; + flex-shrink: 0; + color: white; } #chainName { - font-size: 1.25rem; + font-size: 1.1rem; font-weight: 600; - line-height: 1.2; + line-height: 1.3; + margin-bottom: 6px; } .chain-meta { display: flex; - gap: 8px; + gap: 6px; flex-wrap: wrap; - padding-bottom: 16px; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .badge { - background: rgba(255, 255, 255, 0.1); - padding: 4px 8px; + background: rgba(255, 255, 255, 0.08); + padding: 2px 8px; border-radius: 6px; - font-size: 0.75rem; + font-size: 0.7rem; font-weight: 500; + color: var(--text-muted); +} + +.badge.status-active { + background: rgba(16, 185, 129, 0.15); + color: var(--color-mainnet); +} + +.badge.status-deprecated { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +.badge.status-incubating { + background: rgba(59, 130, 246, 0.15); + color: var(--accent-hover); } .details-content { display: flex; flex-direction: column; gap: 12px; + padding-top: 14px; + border-top: 1px solid rgba(255, 255, 255, 0.06); } .detail-row { display: flex; flex-direction: column; - gap: 4px; + gap: 3px; } .detail-row .label { - font-size: 0.75rem; + font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.06em; + font-weight: 500; } .detail-row .value { - font-size: 0.9rem; + font-size: 0.85rem; word-break: break-all; } .detail-row a { color: var(--accent-hover); text-decoration: none; - transition: color 0.2s; + transition: color 0.15s; } .detail-row a:hover { @@ -287,111 +388,97 @@ h1 { .links-list { display: flex; flex-direction: column; - gap: 4px; + gap: 3px; } -/* Loading Overlay */ -#loadingOverlay { +/* ─── Loading Overlay ─── */ +.loading-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); - padding: 24px 40px; + padding: 32px 48px; display: flex; flex-direction: column; align-items: center; - gap: 16px; - transition: opacity 0.3s; + gap: 14px; + transition: opacity 0.4s; + text-align: center; } -#loadingOverlay.hidden { +.loading-overlay.hidden { opacity: 0; pointer-events: none; } +.loading-sub { + font-size: 0.8rem; + color: var(--text-muted); +} + .spinner { - width: 32px; - height: 32px; - border: 3px solid rgba(255, 255, 255, 0.1); + width: 28px; + height: 28px; + border: 2.5px solid rgba(255, 255, 255, 0.08); border-top-color: var(--accent); border-radius: 50%; - animation: spin 1s linear infinite; + animation: spin 0.8s linear infinite; } @keyframes spin { - to { - transform: rotate(360deg); - } + to { transform: rotate(360deg); } } /* Remove default canvas outline */ -canvas { - outline: none; -} +canvas { outline: none; } -/* Custom Search Dropdown */ +/* ─── Search Dropdown ─── */ .search-dropdown { position: absolute; - top: calc(100% + 8px); + top: calc(100% + 6px); left: 0; width: 100%; - max-height: 300px; - background: var(--panel-bg); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); + max-height: 320px; + background: rgba(16, 16, 22, 0.92); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); border: 1px solid var(--panel-border); - border-radius: 12px; - box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); + border-radius: var(--radius-md); + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6); overflow-y: auto; z-index: 100; display: flex; flex-direction: column; opacity: 1; transform: translateY(0); - transition: opacity 0.2s ease, transform 0.2s ease; + transition: opacity 0.15s ease, transform 0.15s ease; } .search-dropdown.hidden { opacity: 0; - transform: translateY(-10px); + transform: translateY(-6px); pointer-events: none; } -/* Custom Scrollbar for Dropdown */ -.search-dropdown::-webkit-scrollbar { - width: 6px; -} - -.search-dropdown::-webkit-scrollbar-track { - background: transparent; -} - -.search-dropdown::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); - border-radius: 10px; -} - -.search-dropdown::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.3); -} +.search-dropdown::-webkit-scrollbar { width: 5px; } +.search-dropdown::-webkit-scrollbar-track { background: transparent; } +.search-dropdown::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 10px; } .dropdown-item { - padding: 12px 16px; + padding: 10px 14px; display: flex; align-items: center; - gap: 12px; + gap: 10px; cursor: pointer; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); - transition: background 0.2s ease; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + transition: background 0.15s ease; } -.dropdown-item:last-child { - border-bottom: none; -} +.dropdown-item:last-child { border-bottom: none; } .dropdown-item:hover, .dropdown-item.active { - background: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.08); } .dropdown-icon { @@ -401,9 +488,10 @@ canvas { display: flex; align-items: center; justify-content: center; - font-size: 0.8rem; - font-weight: bold; + font-size: 0.75rem; + font-weight: 700; flex-shrink: 0; + color: white; } .dropdown-info { @@ -414,7 +502,7 @@ canvas { } .dropdown-name { - font-size: 0.9rem; + font-size: 0.85rem; font-weight: 500; color: white; white-space: nowrap; @@ -423,15 +511,63 @@ canvas { } .dropdown-meta { - font-size: 0.75rem; + font-size: 0.7rem; color: var(--text-muted); display: flex; - gap: 8px; + gap: 6px; } .dropdown-empty { padding: 16px; text-align: center; color: var(--text-muted); - font-size: 0.9rem; -} \ No newline at end of file + font-size: 0.85rem; +} + +/* ─── Responsive ─── */ +@media (max-width: 768px) { + #ui-container { padding: 12px; } + + header { + max-width: 100%; + padding: 12px 14px; + gap: 10px; + } + + .header-top { + flex-direction: column; + align-items: center; + gap: 4px; + } + + h1 { font-size: 1.1rem; } + + #detailsPanel { + position: fixed; + right: 0; + left: 0; + bottom: 0; + top: auto; + transform: none; + width: 100%; + max-height: 60vh; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + } + + #detailsPanel.hidden { + transform: translateY(20px); + } + + .legend { + bottom: 12px; + left: 12px; + flex-wrap: wrap; + gap: 8px; + max-width: calc(100% - 24px); + } +} + +@media (max-width: 480px) { + .filter-btn { padding: 4px 10px; font-size: 0.75rem; } + .legend { font-size: 0.65rem; } +} diff --git a/rpcMonitor.js b/rpcMonitor.js index 5fe42d5..ff8d97e 100644 --- a/rpcMonitor.js +++ b/rpcMonitor.js @@ -1,5 +1,5 @@ import { getAllEndpoints } from './dataService.js'; -import { MAX_ENDPOINTS_PER_CHAIN } from './config.js'; +import { MAX_ENDPOINTS_PER_CHAIN, RPC_CHECK_CONCURRENCY } from './config.js'; import { jsonRpcCall } from './rpcUtil.js'; // Store monitoring results in memory @@ -8,6 +8,7 @@ let monitoringResults = { totalEndpoints: 0, testedEndpoints: 0, workingEndpoints: 0, + failedEndpoints: 0, results: [] }; @@ -62,10 +63,13 @@ async function testRpcEndpoint(url) { status: 'unknown', clientVersion: null, blockNumber: null, + latencyMs: null, error: null, testedAt: new Date().toISOString() }; - + + const start = Date.now(); + try { // Get client version try { @@ -75,52 +79,53 @@ async function testRpcEndpoint(url) { console.debug(`web3_clientVersion not supported for ${url}: ${clientVersionError.message}`); result.clientVersion = 'unavailable'; } - + // Get latest block number const blockNumberHex = await jsonRpcCall(url, 'eth_blockNumber'); - + // Convert hex to decimal with validation if (!blockNumberHex || typeof blockNumberHex !== 'string') { throw new Error('Invalid block number response'); } - + const blockNumber = Number.parseInt(blockNumberHex, 16); - + if (Number.isNaN(blockNumber)) { throw new TypeError('Failed to parse block number'); } - + result.blockNumber = blockNumber; - + result.latencyMs = Date.now() - start; result.status = 'working'; } catch (error) { + result.latencyMs = Date.now() - start; result.status = 'failed'; result.error = error.message; } - + return result; } /** - * Record a working endpoint result and update counters - * Returns true if the endpoint failed (to signal chain should stop testing) + * Record an endpoint result (working or failed) and update counters */ function recordEndpointResult(testResult, chainId, name, counters) { if (testResult.status === 'working') { counters.working++; - monitoringResults.results.push({ chainId, chainName: name, ...testResult }); + } else { + counters.failed++; } + monitoringResults.results.push({ chainId, chainName: name, ...testResult }); monitoringResults.lastUpdated = new Date().toISOString(); monitoringResults.totalEndpoints = counters.total; monitoringResults.testedEndpoints = counters.tested; monitoringResults.workingEndpoints = counters.working; + monitoringResults.failedEndpoints = counters.failed; if (counters.tested % 50 === 0) { - console.log(`Tested ${counters.tested} endpoints, ${counters.working} working...`); + console.log(`Tested ${counters.tested} endpoints, ${counters.working} working, ${counters.failed} failed...`); } - - return testResult.status === 'failed'; } /** @@ -132,13 +137,12 @@ async function testChainEndpoints(chainEndpoints, counters) { if (!rpc || rpc.length === 0) return; let chainTestedCount = 0; - let foundFailedEndpoint = false; for (const rpcEndpoint of rpc) { const url = extractUrl(rpcEndpoint); counters.total++; - if (!isValidUrl(url) || chainTestedCount >= MAX_ENDPOINTS_PER_CHAIN || foundFailedEndpoint) { + if (!isValidUrl(url) || chainTestedCount >= MAX_ENDPOINTS_PER_CHAIN) { continue; } @@ -147,7 +151,7 @@ async function testChainEndpoints(chainEndpoints, counters) { try { const testResult = await testRpcEndpoint(url); - foundFailedEndpoint = recordEndpointResult(testResult, chainId, name, counters); + recordEndpointResult(testResult, chainId, name, counters); } catch (error) { console.error(`Error testing ${url}:`, error.message); } @@ -155,27 +159,32 @@ async function testChainEndpoints(chainEndpoints, counters) { } /** - * Test all RPC endpoints for all chains + * Test all RPC endpoints for all chains with concurrency */ async function testAllEndpoints() { - console.log('Starting RPC endpoint monitoring...'); + console.log(`Starting RPC endpoint monitoring (concurrency: ${RPC_CHECK_CONCURRENCY})...`); const allEndpoints = getAllEndpoints(); - const counters = { total: 0, tested: 0, working: 0 }; + const counters = { total: 0, tested: 0, working: 0, failed: 0 }; monitoringResults = { lastUpdated: new Date().toISOString(), totalEndpoints: 0, testedEndpoints: 0, workingEndpoints: 0, + failedEndpoints: 0, results: [] }; - for (const chainEndpoints of allEndpoints) { - await testChainEndpoints(chainEndpoints, counters); + // Process chains concurrently in batches + for (let i = 0; i < allEndpoints.length; i += RPC_CHECK_CONCURRENCY) { + const batch = allEndpoints.slice(i, i + RPC_CHECK_CONCURRENCY); + await Promise.allSettled( + batch.map(chainEndpoints => testChainEndpoints(chainEndpoints, counters)) + ); } - console.log(`RPC monitoring completed. Tested ${counters.tested}/${counters.total} endpoints, ${counters.working} working.`); + console.log(`RPC monitoring completed. Tested ${counters.tested}/${counters.total} endpoints, ${counters.working} working, ${counters.failed} failed.`); return monitoringResults; } diff --git a/tests/integration/api.fuzz.test.js b/tests/integration/api.fuzz.test.js index c359622..8060bf0 100644 --- a/tests/integration/api.fuzz.test.js +++ b/tests/integration/api.fuzz.test.js @@ -80,6 +80,19 @@ vi.mock('../../dataService.js', async () => { generic: ['ethereum', 'geth'] } })), + traverseRelations: vi.fn((chainId, maxDepth) => { + const numId = Number.parseInt(chainId, 10); + if (numId === 1) return { + startChainId: 1, startChainName: 'Ethereum', maxDepth: maxDepth || 2, + totalNodes: 2, totalEdges: 1, + nodes: [ + { chainId: 1, name: 'Ethereum', tags: ['L1'], depth: 0 }, + { chainId: 137, name: 'Polygon', tags: ['L2'], depth: 1 }, + ], + edges: [{ from: 1, to: 137, kind: 'parentOf', source: 'theGraph' }], + }; + return null; + }), validateChainData: vi.fn(() => ({ totalErrors: 5, errorsByRule: { @@ -134,7 +147,7 @@ let fastify; describe('Fuzz Testing - API Endpoints', () => { beforeAll(async () => { - const { getCachedData, getAllChains, getChainById, searchChains, getAllRelations, getRelationsById, getEndpointsById, getAllEndpoints, getAllKeywords, validateChainData } = await import('../../dataService.js'); + const { getCachedData, getAllChains, getChainById, searchChains, getAllRelations, getRelationsById, getEndpointsById, getAllEndpoints, getAllKeywords, validateChainData, traverseRelations } = await import('../../dataService.js'); const { getMonitoringResults, getMonitoringStatus } = await import('../../rpcMonitor.js'); fastify = Fastify({ logger: false }); @@ -297,6 +310,36 @@ describe('Fuzz Testing - API Endpoints', () => { }; }); + fastify.get('/stats', async () => { + const chains = getAllChains(); + const totalChains = chains.length; + const totalTestnets = chains.filter(c => c.tags?.includes('Testnet')).length; + const totalL2s = chains.filter(c => c.tags?.includes('L2')).length; + const totalBeacons = chains.filter(c => c.tags?.includes('Beacon')).length; + const totalMainnets = chains.filter(c => !c.tags?.includes('Testnet') && !c.tags?.includes('L2') && !c.tags?.includes('Beacon')).length; + return { + totalChains, totalMainnets, totalTestnets, totalL2s, totalBeacons, + rpc: { totalEndpoints: 100, tested: 50, working: 30, failed: 20, healthPercent: 60 }, + lastUpdated: new Date().toISOString() + }; + }); + + fastify.get('/relations/:id/graph', async (request, reply) => { + const chainId = Number.parseInt(request.params.id, 10); + if (Number.isNaN(chainId)) { + return reply.code(400).send({ error: 'Invalid chain ID' }); + } + const depth = request.query.depth !== undefined ? Number.parseInt(request.query.depth, 10) : 2; + if (Number.isNaN(depth) || depth < 1 || depth > 5) { + return reply.code(400).send({ error: 'Invalid depth. Must be between 1 and 5' }); + } + const result = traverseRelations(chainId, depth); + if (!result) { + return reply.code(404).send({ error: 'Chain not found' }); + } + return result; + }); + fastify.get('/validate', async (_request, reply) => { const validationResults = validateChainData(); if (validationResults.error) { @@ -570,6 +613,150 @@ describe('Fuzz Testing - API Endpoints', () => { }); }); + describe('GET /stats - Fuzz Tests', () => { + it('should return aggregate statistics', async () => { + const response = await fastify.inject({ + method: 'GET', + url: '/stats' + }); + + expect(response.statusCode).toBe(200); + const data = JSON.parse(response.payload); + expect(data).toHaveProperty('totalChains'); + expect(data).toHaveProperty('totalMainnets'); + expect(data).toHaveProperty('totalTestnets'); + expect(data).toHaveProperty('totalL2s'); + expect(data).toHaveProperty('totalBeacons'); + expect(data).toHaveProperty('rpc'); + expect(data.rpc).toHaveProperty('healthPercent'); + }); + + it('should always return valid JSON', async () => { + const response = await fastify.inject({ + method: 'GET', + url: '/stats' + }); + + expect(() => JSON.parse(response.payload)).not.toThrow(); + }); + + it('should handle concurrent requests', async () => { + const requests = Array(10).fill(null).map(() => + fastify.inject({ method: 'GET', url: '/stats' }) + ); + const responses = await Promise.all(requests); + responses.forEach(response => { + expect(response.statusCode).toBe(200); + expect(() => JSON.parse(response.payload)).not.toThrow(); + }); + }); + + test.prop([fc.record({ + userAgent: fc.string(), + acceptLanguage: fc.string() + })])('should handle various header combinations', async (headers) => { + const response = await fastify.inject({ + method: 'GET', + url: '/stats', + headers: { + 'user-agent': headers.userAgent, + 'accept-language': headers.acceptLanguage + } + }); + + expect(response.statusCode).toBe(200); + }); + }); + + describe('GET /relations/:id/graph - Fuzz Tests', () => { + test.prop([fc.oneof(fc.string(), fc.integer(), fc.double(), fc.boolean())]) + ('should handle various ID inputs', async (input) => { + const response = await fastify.inject({ + method: 'GET', + url: `/relations/${encodeURIComponent(String(input))}/graph` + }); + + expect([200, 400, 404]).toContain(response.statusCode); + expect(() => JSON.parse(response.payload)).not.toThrow(); + }); + + test.prop([fc.integer()])('should handle integer chain IDs', async (id) => { + const response = await fastify.inject({ + method: 'GET', + url: `/relations/${id}/graph` + }); + + expect([200, 404]).toContain(response.statusCode); + + if (response.statusCode === 200) { + const data = JSON.parse(response.payload); + expect(data).toHaveProperty('startChainId'); + expect(data).toHaveProperty('nodes'); + expect(data).toHaveProperty('edges'); + expect(data).toHaveProperty('totalNodes'); + expect(data).toHaveProperty('totalEdges'); + } + }); + + test.prop([fc.integer({ min: 1, max: 5 })])('should accept valid depth values', async (depth) => { + const response = await fastify.inject({ + method: 'GET', + url: `/relations/1/graph?depth=${depth}` + }); + + expect([200, 404]).toContain(response.statusCode); + }); + + test.prop([fc.oneof(fc.integer({ min: -100, max: 0 }), fc.integer({ min: 6, max: 100 }))]) + ('should reject invalid depth values', async (depth) => { + const response = await fastify.inject({ + method: 'GET', + url: `/relations/1/graph?depth=${depth}` + }); + + expect(response.statusCode).toBe(400); + const data = JSON.parse(response.payload); + expect(data.error).toBe('Invalid depth. Must be between 1 and 5'); + }); + + test.prop([fc.string()])('should handle non-numeric depth values', async (depth) => { + const response = await fastify.inject({ + method: 'GET', + url: `/relations/1/graph?depth=${encodeURIComponent(depth)}` + }); + + expect([200, 400, 404]).toContain(response.statusCode); + expect(response.statusCode).not.toBe(500); + }); + + test.prop([fc.constantFrom('', ' ', '\n', '\t', '..', '../', '/', '\\')]) + ('should handle special characters in chain ID', async (input) => { + const response = await fastify.inject({ + method: 'GET', + url: `/relations/${encodeURIComponent(input)}/graph` + }); + + expect([200, 400, 404]).toContain(response.statusCode); + expect(response.statusCode).not.toBe(500); + }); + + const sqlInjectionPayloads = [ + "1' OR '1'='1", + "1; DROP TABLE chains--", + "' OR 1=1--" + ]; + + test.each(sqlInjectionPayloads)('should safely handle SQL injection: %s', async (payload) => { + const response = await fastify.inject({ + method: 'GET', + url: `/relations/${encodeURIComponent(payload)}/graph` + }); + + expect([200, 400, 404]).toContain(response.statusCode); + expect(response.statusCode).not.toBe(500); + }); + }); + describe('HTTP Method Fuzzing', () => { const endpoints = [ '/health', @@ -586,7 +773,10 @@ describe('Fuzz Testing - API Endpoints', () => { '/validate', '/keywords', '/rpc-monitor', - '/rpc-monitor/1' + '/rpc-monitor/1', + '/stats', + '/relations/1/graph', + '/relations/1/graph?depth=3' ]; test.each(endpoints)('GET %s should always return valid response', async (endpoint) => { diff --git a/tests/unit/dataService.test.js b/tests/unit/dataService.test.js index ec7746e..0b63a2f 100644 --- a/tests/unit/dataService.test.js +++ b/tests/unit/dataService.test.js @@ -432,7 +432,8 @@ import { loadData, runRpcHealthCheck, startRpcHealthCheck, - validateChainData + validateChainData, + traverseRelations } from '../../dataService.js'; describe('fetchData', () => { @@ -2610,3 +2611,135 @@ describe('Function coverage: getChainFromSource find callbacks', () => { expect(result).toHaveProperty('errorsByRule'); }); }); + +describe('traverseRelations', () => { + it('should return null for non-existent chain', () => { + // cachedData.indexed may or may not be populated from prior tests; + // either way, a non-existent chainId should return null + const result = traverseRelations(999999999); + expect(result).toBeNull(); + }); + + it('should return null for non-existent chain after data loaded', async () => { + global.fetch = vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ networks: [] }) }) + .mockResolvedValueOnce({ ok: true, json: async () => [] }) + .mockResolvedValueOnce({ ok: true, json: async () => [{ chainId: 1, name: 'Ethereum', rpc: [] }] }) + .mockResolvedValueOnce({ ok: true, text: async () => '' }); + + await loadData(); + const result = traverseRelations(999999); + expect(result).toBeNull(); + }); + + it('should return single node for chain with no relations', async () => { + global.fetch = vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ networks: [] }) }) + .mockResolvedValueOnce({ ok: true, json: async () => [] }) + .mockResolvedValueOnce({ ok: true, json: async () => [{ chainId: 1, name: 'Ethereum', rpc: [] }] }) + .mockResolvedValueOnce({ ok: true, text: async () => '' }); + + await loadData(); + const result = traverseRelations(1); + expect(result).not.toBeNull(); + expect(result.startChainId).toBe(1); + expect(result.startChainName).toBe('Ethereum'); + expect(result.totalNodes).toBe(1); + expect(result.totalEdges).toBe(0); + expect(result.nodes[0].depth).toBe(0); + }); + + it('should traverse relations to connected chains', async () => { + const chains = [ + { chainId: 1, name: 'Ethereum', rpc: [] }, + { chainId: 5, name: 'Goerli', rpc: [], parent: { type: 'testnet', chain: 'eip155-1' } }, + { chainId: 10, name: 'Optimism', rpc: [], parent: { type: 'L2', chain: 'eip155-1' } }, + ]; + + global.fetch = vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ networks: [] }) }) + .mockResolvedValueOnce({ ok: true, json: async () => [] }) + .mockResolvedValueOnce({ ok: true, json: async () => chains }) + .mockResolvedValueOnce({ ok: true, text: async () => '' }); + + await loadData(); + + // From Ethereum, should find Goerli and Optimism via reverse relations + const result = traverseRelations(1, 2); + expect(result).not.toBeNull(); + expect(result.totalNodes).toBeGreaterThanOrEqual(2); + expect(result.totalEdges).toBeGreaterThanOrEqual(1); + + const chainIds = result.nodes.map(n => n.chainId); + expect(chainIds).toContain(1); + }); + + it('should respect maxDepth parameter', async () => { + const chains = [ + { chainId: 1, name: 'Ethereum', rpc: [] }, + { chainId: 5, name: 'Goerli', rpc: [], parent: { type: 'testnet', chain: 'eip155-1' } }, + ]; + + global.fetch = vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ networks: [] }) }) + .mockResolvedValueOnce({ ok: true, json: async () => [] }) + .mockResolvedValueOnce({ ok: true, json: async () => chains }) + .mockResolvedValueOnce({ ok: true, text: async () => '' }); + + await loadData(); + + const depth1 = traverseRelations(1, 1); + const depth3 = traverseRelations(1, 3); + + // Deeper traversal should find at least as many nodes + expect(depth3.totalNodes).toBeGreaterThanOrEqual(depth1.totalNodes); + }); + + it('should include depth in node objects', async () => { + const chains = [ + { chainId: 1, name: 'Ethereum', rpc: [] }, + { chainId: 5, name: 'Goerli', rpc: [], parent: { type: 'testnet', chain: 'eip155-1' } }, + ]; + + global.fetch = vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ networks: [] }) }) + .mockResolvedValueOnce({ ok: true, json: async () => [] }) + .mockResolvedValueOnce({ ok: true, json: async () => chains }) + .mockResolvedValueOnce({ ok: true, text: async () => '' }); + + await loadData(); + + const result = traverseRelations(1, 2); + const startNode = result.nodes.find(n => n.chainId === 1); + expect(startNode.depth).toBe(0); + + // Any connected nodes should be depth >= 1 + const otherNodes = result.nodes.filter(n => n.chainId !== 1); + for (const node of otherNodes) { + expect(node.depth).toBeGreaterThanOrEqual(1); + } + }); + + it('should include edge kind and source', async () => { + const chains = [ + { chainId: 1, name: 'Ethereum', rpc: [] }, + { chainId: 10, name: 'Optimism', rpc: [], parent: { type: 'L2', chain: 'eip155-1' } }, + ]; + + global.fetch = vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ networks: [] }) }) + .mockResolvedValueOnce({ ok: true, json: async () => [] }) + .mockResolvedValueOnce({ ok: true, json: async () => chains }) + .mockResolvedValueOnce({ ok: true, text: async () => '' }); + + await loadData(); + + const result = traverseRelations(1, 2); + for (const edge of result.edges) { + expect(edge).toHaveProperty('from'); + expect(edge).toHaveProperty('to'); + expect(edge).toHaveProperty('kind'); + expect(edge).toHaveProperty('source'); + } + }); +}); diff --git a/tests/unit/mcp-tools.test.js b/tests/unit/mcp-tools.test.js index 86007e0..16dcfc6 100644 --- a/tests/unit/mcp-tools.test.js +++ b/tests/unit/mcp-tools.test.js @@ -35,6 +35,7 @@ vi.mock('../../dataService.js', () => ({ }, })), validateChainData: vi.fn(() => ({ totalErrors: 0, errorsByRule: {}, summary: {}, allErrors: [] })), + traverseRelations: vi.fn(() => null), })); // Mock rpcMonitor before importing @@ -108,10 +109,10 @@ describe('MCP Tools - Shared Module', () => { }); describe('getToolDefinitions', () => { - it('should return an array of 11 tools', () => { + it('should return an array of 13 tools', () => { const tools = getToolDefinitions(); expect(Array.isArray(tools)).toBe(true); - expect(tools.length).toBe(11); + expect(tools.length).toBe(13); }); it('should include all expected tool names', () => { @@ -126,10 +127,18 @@ describe('MCP Tools - Shared Module', () => { expect(names).toContain('get_sources'); expect(names).toContain('get_keywords'); expect(names).toContain('validate_chains'); + expect(names).toContain('get_stats'); + expect(names).toContain('traverse_relations'); expect(names).toContain('get_rpc_monitor'); expect(names).toContain('get_rpc_monitor_by_id'); }); + it('should require chainId for traverse_relations', () => { + const tools = getToolDefinitions(); + const tool = tools.find(t => t.name === 'traverse_relations'); + expect(tool.inputSchema.required).toContain('chainId'); + }); + it('should have proper schema structure for each tool', () => { const tools = getToolDefinitions(); for (const tool of tools) { @@ -563,6 +572,136 @@ describe('MCP Tools - Shared Module', () => { }); }); + describe('handleToolCall - get_stats', () => { + it('should return aggregate stats', async () => { + vi.mocked(dataService.getAllChains).mockReturnValue([ + { chainId: 1, name: 'Ethereum', tags: [] }, + { chainId: 5, name: 'Goerli', tags: ['Testnet'] }, + { chainId: 137, name: 'Polygon', tags: ['L2'] }, + { chainId: 100, name: 'Gnosis Beacon', tags: ['Beacon'] }, + ]); + vi.mocked(rpcMonitor.getMonitoringResults).mockReturnValue({ + lastUpdated: '2024-01-01T00:00:00.000Z', + totalEndpoints: 100, + testedEndpoints: 50, + workingEndpoints: 40, + failedEndpoints: 10, + results: [], + }); + + const result = await handleToolCall('get_stats', {}); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + expect(data.totalChains).toBe(4); + expect(data.totalTestnets).toBe(1); + expect(data.totalL2s).toBe(1); + expect(data.totalBeacons).toBe(1); + expect(data.totalMainnets).toBe(1); + expect(data.rpc.working).toBe(40); + expect(data.rpc.failed).toBe(10); + expect(data.rpc.healthPercent).toBe(80); + }); + + it('should return null healthPercent when no endpoints tested', async () => { + vi.mocked(dataService.getAllChains).mockReturnValue([]); + vi.mocked(rpcMonitor.getMonitoringResults).mockReturnValue({ + lastUpdated: null, + totalEndpoints: 0, + testedEndpoints: 0, + workingEndpoints: 0, + failedEndpoints: 0, + results: [], + }); + + const result = await handleToolCall('get_stats', {}); + const data = JSON.parse(result.content[0].text); + expect(data.rpc.healthPercent).toBeNull(); + }); + }); + + describe('handleToolCall - traverse_relations', () => { + it('should return traversal result for valid chain', async () => { + vi.mocked(dataService.traverseRelations).mockReturnValue({ + startChainId: 1, + startChainName: 'Ethereum', + maxDepth: 2, + totalNodes: 3, + totalEdges: 2, + nodes: [ + { chainId: 1, name: 'Ethereum', tags: [], depth: 0 }, + { chainId: 5, name: 'Goerli', tags: ['Testnet'], depth: 1 }, + { chainId: 10, name: 'Optimism', tags: ['L2'], depth: 1 }, + ], + edges: [ + { from: 1, to: 5, kind: 'mainnetOf', source: 'theGraph' }, + { from: 1, to: 10, kind: 'parentOf', source: 'theGraph' }, + ], + }); + + const result = await handleToolCall('traverse_relations', { chainId: 1 }); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + expect(data.startChainId).toBe(1); + expect(data.totalNodes).toBe(3); + expect(data.totalEdges).toBe(2); + expect(data.nodes.length).toBe(3); + }); + + it('should return error for invalid chain ID', async () => { + const result = await handleToolCall('traverse_relations', { chainId: 'invalid' }); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('Invalid chain ID'); + }); + + it('should return error for NaN chain ID', async () => { + const result = await handleToolCall('traverse_relations', { chainId: NaN }); + expect(result.isError).toBe(true); + }); + + it('should return error when chain not found', async () => { + vi.mocked(dataService.traverseRelations).mockReturnValue(null); + const result = await handleToolCall('traverse_relations', { chainId: 999999 }); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('Chain not found'); + }); + + it('should use default depth of 2', async () => { + vi.mocked(dataService.traverseRelations).mockReturnValue({ + startChainId: 1, startChainName: 'Ethereum', maxDepth: 2, + totalNodes: 1, totalEdges: 0, nodes: [], edges: [], + }); + + await handleToolCall('traverse_relations', { chainId: 1 }); + expect(dataService.traverseRelations).toHaveBeenCalledWith(1, 2); + }); + + it('should accept custom depth', async () => { + vi.mocked(dataService.traverseRelations).mockReturnValue({ + startChainId: 1, startChainName: 'Ethereum', maxDepth: 4, + totalNodes: 1, totalEdges: 0, nodes: [], edges: [], + }); + + await handleToolCall('traverse_relations', { chainId: 1, depth: 4 }); + expect(dataService.traverseRelations).toHaveBeenCalledWith(1, 4); + }); + + it('should reject depth below 1', async () => { + const result = await handleToolCall('traverse_relations', { chainId: 1, depth: 0 }); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('Invalid depth. Must be between 1 and 5'); + }); + + it('should reject depth above 5', async () => { + const result = await handleToolCall('traverse_relations', { chainId: 1, depth: 6 }); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('Invalid depth. Must be between 1 and 5'); + }); + }); + describe('handleToolCall - error handling', () => { it('should return error for unknown tool', async () => { const result = await handleToolCall('unknown_tool', {}); diff --git a/tests/unit/rpcMonitor.test.js b/tests/unit/rpcMonitor.test.js index 6d614a5..0619a56 100644 --- a/tests/unit/rpcMonitor.test.js +++ b/tests/unit/rpcMonitor.test.js @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Mock config before importing rpcMonitor vi.mock('../../config.js', () => ({ MAX_ENDPOINTS_PER_CHAIN: 5, + RPC_CHECK_CONCURRENCY: 5, PROXY_URL: '', PROXY_ENABLED: false })); @@ -121,15 +122,16 @@ describe('RPC Monitor', () => { { chainId: 1, name: 'Test', rpc: ['https://test.rpc.com'] } ]); - // First call hangs (creates the overlap window), all subsequent calls resolve immediately - let firstResolve; + // Each call resolves after a short delay, giving us time to call startMonitoring twice vi.mocked(jsonRpcCall) - .mockImplementationOnce(() => new Promise((resolve) => { firstResolve = resolve; })) - .mockResolvedValue('0x1'); + .mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve('0x1'), 100))); - // Start first monitoring (will hang on first jsonRpcCall) + // Start first monitoring (will be in-flight for ~100ms) const promise1 = startMonitoring(); + // Allow microtask queue to flush so monitoring enters the async loop + await new Promise(resolve => setTimeout(resolve, 10)); + // Second call should detect monitoring in progress const promise2 = startMonitoring(); @@ -141,8 +143,7 @@ describe('RPC Monitor', () => { 'Monitoring already in progress, returning existing operation...' ); - // Resolve the first call so monitoring can complete (remaining calls use mockResolvedValue) - firstResolve('geth/v1.0'); + // Wait for monitoring to complete await promise1; consoleSpy.mockRestore(); @@ -283,7 +284,7 @@ describe('RPC Monitor', () => { }); describe('Chain endpoint limiting', () => { - it('should stop testing after first failed endpoint for a chain', async () => { + it('should test all endpoints even if some fail', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); vi.mocked(getAllEndpoints).mockReturnValue([ @@ -299,13 +300,22 @@ describe('RPC Monitor', () => { ]); vi.mocked(jsonRpcCall) - .mockResolvedValueOnce('geth/v1.0') - .mockRejectedValueOnce(new Error('Block number failed')); + .mockResolvedValueOnce('geth/v1.0') // rpc1 web3_clientVersion + .mockRejectedValueOnce(new Error('Block number failed')) // rpc1 eth_blockNumber + .mockResolvedValueOnce('geth/v1.0') // rpc2 web3_clientVersion + .mockResolvedValueOnce('0x123') // rpc2 eth_blockNumber + .mockResolvedValueOnce('geth/v1.0') // rpc3 web3_clientVersion + .mockResolvedValueOnce('0x456'); // rpc3 eth_blockNumber await startMonitoring(); - // After first endpoint fails, should not test rpc2 and rpc3 - expect(vi.mocked(jsonRpcCall).mock.calls.length).toBe(2); + // All 3 endpoints should be tested (6 jsonRpcCalls: 2 per endpoint) + expect(vi.mocked(jsonRpcCall).mock.calls.length).toBe(6); + + const results = getMonitoringResults(); + // Should have both working and failed results + expect(results.results.filter(r => r.status === 'failed').length).toBe(1); + expect(results.results.filter(r => r.status === 'working').length).toBe(2); consoleSpy.mockRestore(); });