1import * as _ from 'lodash-es' 2import * as net from 'net' 3import * as fs from 'fs' 4import { PortForward } from '@kubernetes/client-node' 10import { DateTime } from 'luxon' 15const historyFilePath = `${ptTmpDir}/ethstatus-history.json` 16const staleImageDays = 30 18const parseHexBlock = (b: any) => typeof b === 'string' ? parseInt(b, 16) || 0 : (b || 0) 19const parseStageMap = (stages: any[]) => Object.fromEntries(stages.map((s: any) => [s.name, parseHexBlock(s.block)])) 23export const parseImageDatesFromFile = (epFilePath: string | null) => { 24 if (!epFilePath || !fs.existsSync(epFilePath)) return {} 25 const content = fs.readFileSync(epFilePath, 'utf8') 26 const result: Record<string, string | null> = {exec: null, cons: null} 27 for (const line of content.split('\n')) { 28 const dateMatch = line.match(/\/\/ (\d{6})$/) 29 if (!dateMatch) continue 30 const varMatch = line.match(/(\w+(?:V|Version)):\s*'/) 31 if (varMatch && imgToClientType[varMatch[1]]) { 32 const clientType = imgToClientType[varMatch[1]] 33 if (clientType !== 'skip') result[clientType] = dateMatch[1] 36 const imgMatch = line.match(/image:\s*'([^':]+)/) 37 if (imgMatch && imgToClientType[imgMatch[1]]) { 38 const clientType = imgToClientType[imgMatch[1]] 39 if (clientType !== 'skip') result[clientType] = dateMatch[1] 45const formatImageAge = (dateStr: string | null) => { 46 if (!dateStr) return null 47 const imgDt = DateTime.fromFormat(dateStr, 'yyLLdd') 48 if (!imgDt.isValid) return null 50 const days = Math.floor(ageDur.as('days')) 51 const ageStr = `img ${formatPodAge(ageDur)}` 55// Extract reth indexing progress from pod logs (format: "Collecting indices progress=XX.XXXX%") 56const getRethIndexingProgress = async ({cluster_name, podName, containerName}: {cluster_name: string, podName: string, containerName: string}) => { 58 let logs = await ret1PodLogs({cluster_name, podName, containerName, tailLines: 100}) 59 logs = logs.replace(/\x1B\[[0-9;]*m/g, '') // strip ANSI color codes 60 const matches = logs.match(/Collecting indices.*progress=(\d+\.?\d*)%/g) 61 if (!matches?.length) return null 62 const lastMatch = matches[matches.length - 1] 63 const pct = parseFloat(lastMatch.match(/progress=(\d+\.?\d*)%/)?.[1] || '0') 64 return pct > 0 ? pct : null 70const loadHistory = () => { 72 if (fs.existsSync(historyFilePath)) { 73 return JSON.parse(fs.readFileSync(historyFilePath, 'utf8')) 79const saveHistory = (history: Record<string, any>) => { 81 fs.writeFileSync(historyFilePath, JSON.stringify(history, null, 2)) 84// Detect if reth is in indexing state with no checkpoint (needs log-based progress) 85const isRethIndexingNoCheckpoint = (syncStatus: any) => { 86 const stages = syncStatus?.stages || [] 87 if (stages.length === 0) return false 88 const stageMap = parseStageMap(stages) 89 const headers = stageMap.Headers || 0, execution = stageMap.Execution || 0 90 const indexAccount = stageMap.IndexAccountHistory || 0, indexStorage = stageMap.IndexStorageHistory || 0 91 const execCaughtUp = execution > 0 && execution === headers 92 return execCaughtUp && Math.min(indexAccount, indexStorage) === 0 95// Extract a single trackable metric from sync status for comparison 96const extractMetric = ({syncStatus, isConsensus, logIndexingPct}: {syncStatus: any, isConsensus: boolean, logIndexingPct?: number | null}) => { 97 if (!syncStatus) return null 99 return {type: 'slot', value: syncStatus.headSlot || 0} 101 // For exec clients, track indexing progress if still indexing, otherwise block 102 const stages = syncStatus.stages || [] 103 if (stages.length > 0) { 104 const stageMap = parseStageMap(stages) 105 const headers = stageMap.Headers || 0 106 const execution = stageMap.Execution || 0 107 const finish = stageMap.Finish || 0 108 const indexAccount = stageMap.IndexAccountHistory || 0 109 const indexStorage = stageMap.IndexStorageHistory || 0 110 const minIndex = Math.min(indexAccount, indexStorage) 111 const execCaughtUp = finish > 0 || (execution > 0 && execution === headers) 112 // If indexing is behind execution, track index progress 113 if (execCaughtUp && execution > 0 && minIndex < execution && minIndex > 0) { 114 return {type: 'indexPct', value: (minIndex / execution) * 100, minIndex, execution} 116 // During indexing with no checkpoint, use log-based progress if available 117 if (execCaughtUp && execution > 0 && minIndex === 0) { 118 if (logIndexingPct != null) return {type: 'indexPct', value: logIndexingPct} 121 if (execution > 0) return {type: 'block', value: execution} 123 if (syncStatus.isSynced) return {type: 'block', value: syncStatus.currentBlock} 124 return {type: 'block', value: syncStatus.currentBlock || syncStatus.highestBlock || 0} 127const formatDelta = (prev: any, curr: any, prevTs: number) => { 128 if (!prev || !curr || prev.type !== curr.type) return null 129 const delta = curr.value - prev.value 131 const stuckMin = Math.round((Date.now() - prevTs) / 60000) 132 return {delta: 0, formatted: chalkRed(`stuck ${stuckMin}m`), isStuck: true} 134 const sign = delta > 0 ? '+' : '' 135 const minElapsed = Math.max(1, (Date.now() - prevTs) / 60000) 136 const rate = delta / minElapsed 137 if (curr.type === 'slot') { 138 const rateStr = rate >= 1 ? `${rate.toFixed(1)}/m` : '' 139 return {delta, formatted: chalkGreen(`${sign}${delta} slots${rateStr ? ' ' + rateStr : ''}`), isStuck: false} 141 if (curr.type === 'indexPct') { 142 if (delta < 0) return null // indexing reset/restart - not comparable 143 const rateStr = rate >= 0.01 ? `${rate.toFixed(2)}%/m` : '' 144 return {delta, formatted: chalkGreen(`+${delta.toFixed(2)}%${rateStr ? ' ' + rateStr : ''}`), isStuck: false} 148 const rateStr = rate >= 1 ? `${rate.toFixed(0)}/m` : '' 149 return {delta, formatted: chalkGreen(`${sign}${delta.toLocaleString()} blocks${rateStr ? ' ' + rateStr : ''}`), isStuck: false} 152let nextLocalPort = 19000 154const getNextLocalPort = () => { 155 return nextLocalPort++ 158export const withPortForward = async <T,>({cluster_name, podName, portNo, checkerFn}: {cluster_name: string, podName: string, portNo: number, checkerFn: (p: {httpHostPort: string}) => Promise<T>}): Promise<T> => { 160 const forward = new PortForward(kubeConfig) 162 const localPort = getNextLocalPort() 163 const sockets = new Set<net.Socket>() 164 const wsPromises: Promise<unknown>[] = [] 165 const server = net.createServer((socket) => { 167 socket.on('error', (err: any) => { 168 if (err.code === 'EPIPE' || err.code === 'ECONNRESET') return 169 console.error('port-forward socket error:', err.message) 171 socket.on('close', () => { sockets.delete(socket) }) 172 wsPromises.push(forward.portForward('default', podName, [portNo], socket, null, socket).catch(() => null)) 175 server.listen(localPort, '127.0.0.1') 176 const httpHostPort = `http://127.0.0.1:${localPort}` 179 return await checkerFn({httpHostPort}) 182 for (const socket of sockets) socket.destroy() 183 const wss = await Promise.all(wsPromises) 184 for (const ws of wss) { 185 try { (typeof ws === 'function' ? (ws as () => any)() : ws)?.close() } catch {} // catch:userapproved 190const statusFetchTimeout = 20000 192const jsonRpcCall = async ({httpHostPort, method, params = []}: {httpHostPort: string, method: string, params?: any[]}) => { 193 const resp = await fetch(httpHostPort, { 195 headers: {'Content-Type': 'application/json'}, 196 body: JSON.stringify({jsonrpc: '2.0', method, params, id: 1}), 197 signal: AbortSignal.timeout(statusFetchTimeout), 199 const json = await resp.json() 203const getExecSyncStatus = async ({httpHostPort}: {httpHostPort: string}) => { 204 const [syncingRes, blockNumRes, peerCountRes] = await Promise.all([ 205 jsonRpcCall({httpHostPort, method: 'eth_syncing'}), 206 jsonRpcCall({httpHostPort, method: 'eth_blockNumber'}), 207 jsonRpcCall({httpHostPort, method: 'net_peerCount'}), 210 let versionRes = null 212 versionRes = await jsonRpcCall({httpHostPort, method: 'web3_clientVersion'}) 213 } catch {} // catch:userapproved 215 const peerCount = parseInt(peerCountRes, 16) || 0 216 const currentBlockNum = parseInt(blockNumRes, 16) || 0 217 const isSynced = syncingRes === false 221 syncStatus = {isSynced: true, currentBlock: currentBlockNum} 223 const currentBlock = parseInt(syncingRes.currentBlock, 16) || 0 224 const highestBlock = parseInt(syncingRes.highestBlock, 16) || 0 225 const pct = highestBlock > 0 ? ((currentBlock / highestBlock) * 100).toFixed(2) : 0 226 // Extract reth stages for debugging 227 const stages = syncingRes.stages || [] 228 syncStatus = {isSynced: false, currentBlock, highestBlock, pct, stages} 231 return {syncStatus, version: versionRes, peerCount} 234export const getConsensusSyncStatus = async ({httpHostPort}: {httpHostPort: string}) => { 235 const [syncingResp, versionResp, peerCountResp] = await Promise.all([ 236 fetch(`${httpHostPort}/eth/v1/node/syncing`, {signal: AbortSignal.timeout(statusFetchTimeout)}), 237 fetch(`${httpHostPort}/eth/v1/node/version`, {signal: AbortSignal.timeout(statusFetchTimeout)}), 238 fetch(`${httpHostPort}/eth/v1/node/peer_count`, {signal: AbortSignal.timeout(statusFetchTimeout)}), 241 const syncingJson = await syncingResp.json() 242 const versionJson = await versionResp.json() 243 const peerCountJson = await peerCountResp.json() 245 const {head_slot, sync_distance, is_syncing} = syncingJson.data 246 const headSlot = _.toInteger(head_slot) 247 const syncDistance = _.toInteger(sync_distance) 248 const isSynced = !is_syncing && syncDistance === 0 252 syncStatus = {isSynced: true, headSlot} 254 const pct = headSlot > 0 ? ((headSlot / (headSlot + syncDistance)) * 100).toFixed(2) : 0 255 syncStatus = {isSynced: false, headSlot, syncDistance, pct} 258 const version = versionJson.data?.version || 'unknown' 259 const peerCount = _.toInteger(peerCountJson.data?.connected || 0) 261 return {syncStatus, version, peerCount, headSlot} 264const getPodAgeDur = (creationTimestamp: any) => { 265 if (!creationTimestamp) return null 266 const createdDt = DateTime.fromJSDate(new Date(creationTimestamp)) 267 if (!createdDt.isValid) return null 271const formatPodAge = (diffDur: any) => { 272 if (!diffDur) return '?' 273 const days = Math.floor(diffDur.as('days')) 274 const hours = Math.floor(diffDur.as('hours') % 24) 275 if (days > 0) return `${days}d${hours}h` 276 const mins = Math.floor(diffDur.as('minutes') % 60) 277 if (hours > 0) return `${hours}h${mins}m` 281export const getPodHealthInfo = async ({cluster_name, fuzzyPodName}: {cluster_name: string, fuzzyPodName: string}) => { 283 const activePods = _.filter(pods, (pod: any) => !_.includes(['Evicted', 'Failed'], pod.status?.phase) && pod.status?.reason !== 'Evicted') 284 const pod = _.first(activePods) as any 285 if (!pod) return {status: 'NotFound', age: '-', restarts: 0, podName: null} 287 const {metadata, status} = pod 288 const ageDur = getPodAgeDur(metadata?.creationTimestamp) 289 const age = formatPodAge(ageDur) 290 const containerStatus: any = _.first(status?.containerStatuses) || {} 291 const restarts = containerStatus.restartCount || 0 293 const lastTerm = containerStatus.lastState?.terminated 294 const lastExitReason = lastTerm?.reason 295 const lastExitAge = lastTerm?.finishedAt ? formatPodAge(getPodAgeDur(lastTerm.finishedAt)) : null 297 let displayStatus = status?.phase 298 const waitingReason = containerStatus.state?.waiting?.reason 300 displayStatus = waitingReason 303 const isInitializing = ageDur && ageDur.as('minutes') < 10 304 return {status: displayStatus, age, restarts, podName: metadata?.name, isRunning: status?.phase === 'Running' && containerStatus.ready, isInitializing, lastExitReason, lastExitAge} 307export const fmtPodDownErr = ({podHealth, errMsg}: {podHealth: any, errMsg: string}) => { 308 const {lastExitReason, lastExitAge, restarts} = podHealth || {} 309 if (!lastExitReason) return errMsg 310 const restartPart = restarts > 0 ? `, ${restarts} restarts` : '' 311 return `down (last exit ${lastExitReason} ${lastExitAge} ago${restartPart})` 314const formatSyncStatus = (syncStatus: any, isConsensus: boolean) => { 315 if (syncStatus.isSynced) { 316 if (isConsensus) return `synced (slot ${syncStatus.headSlot})` 317 return `synced (block ${syncStatus.currentBlock.toLocaleString()})` 320 // Treat syncDistance <= 0 as synced (handles -1, 0 cases) 321 if (syncStatus.syncDistance <= 0) { 322 return `synced (slot ${syncStatus.headSlot})` 324 return `syncing ${syncStatus.pct}% (slot ${syncStatus.headSlot}, -${syncStatus.syncDistance})` 326 // Show reth stages if available (reth uses staged sync) 327 const stages = syncStatus.stages || [] 328 if (stages.length > 0) { 329 const stageMap = parseStageMap(stages) 330 const headers = stageMap.Headers || 0 331 const execution = stageMap.Execution || 0 332 const finish = stageMap.Finish || 0 333 const indexAccount = stageMap.IndexAccountHistory || 0 334 const indexStorage = stageMap.IndexStorageHistory || 0 335 const minIndex = Math.min(indexAccount, indexStorage) 336 // Execution caught up to headers? 337 const execCaughtUp = finish > 0 || (execution > 0 && execution === headers) 339 // Check if indexing is still in progress (reth checkpoints lag behind actual progress) 340 if (execution > 0 && minIndex < execution) { 341 if (minIndex === 0) return `indexing (block ${execution.toLocaleString()})` // checkpoint not yet written 342 const idxPct = ((minIndex / execution) * 100).toFixed(1) 343 return `indexing ${idxPct}% (idx ${minIndex.toLocaleString()}/${execution.toLocaleString()})` 345 return `synced (block ${execution.toLocaleString()})` 347 // Otherwise show execution progress against headers 348 const pct = headers > 0 ? ((execution / headers) * 100).toFixed(1) : 0 349 return `syncing ${pct}% (exec ${execution.toLocaleString()}/${headers.toLocaleString()})` 351 // Nethermind snap sync shows currentBlock=0 while downloading state 352 if (syncStatus.currentBlock === 0 && syncStatus.highestBlock > 0) { 353 return `snap syncing (target block ${syncStatus.highestBlock.toLocaleString()})` 355 return `syncing ${syncStatus.pct}% (block ${syncStatus.currentBlock.toLocaleString()}/${syncStatus.highestBlock.toLocaleString()})` 358const getFullClientStatus = async ({cluster_name, fuzzyPodName, portNo, isConsensus}: {cluster_name: string, fuzzyPodName: string, portNo: number, isConsensus: boolean}) => { 359 const podHealth = await getPodHealthInfo({cluster_name, fuzzyPodName}) 361 if (!podHealth.isRunning || !podHealth.podName) { 362 return {podHealth, clientStatus: null} 365 const checkerFn: (p: {httpHostPort: string}) => Promise<any> = isConsensus ? getConsensusSyncStatus : getExecSyncStatus 369 clientStatus = await withPortForward({ 370 cluster_name, podName: podHealth.podName, portNo, checkerFn 373 clientStatus = {error: (err as Error).message} 376 return {podHealth, clientStatus} 379const formatClientLine = ({fuzzyPodName, podHealth, clientStatus, isConsensus, isActiveValidator, historyDelta, imageAge}: { 380 fuzzyPodName: string, podHealth: any, clientStatus: any, isConsensus: boolean, 381 isActiveValidator: boolean, historyDelta: any, imageAge: string | null, 383 const name = fuzzyPodName.padEnd(16) 384 const restartsPart = podHealth.restarts > 0 ? ` [${podHealth.restarts} restarts]` : '' 386 if (!podHealth.isRunning) { 387 return `${name} ${podHealth.status} (${podHealth.age})${restartsPart}` 390 const podPart = `Running (${podHealth.age})` 392 if (clientStatus?.error) { 393 if (podHealth.isInitializing) { 394 return `${name} ${podPart} | initializing...` 396 return `${name} ${podPart} | ERROR: ${clientStatus.error}` 400 return `${name} ${podPart} | status unavailable` 403 const syncPart = formatSyncStatus(clientStatus.syncStatus, isConsensus) 404 const peerPart = `${clientStatus.peerCount} peers` 405 const versionPart = clientStatus.version?.split('/').slice(0, 2).join('/') || '-' 406 const deltaPart = historyDelta ? ` [${historyDelta.formatted}]` : '' 407 const imgPart = imageAge ? ` | ${imageAge}` : '' 409 const validatorSuffix = isActiveValidator ? ' ' + chalkGreen('[ACTIVE VALIDATOR]') : '' 410 return `${name} ${podPart} | ${syncPart}${deltaPart} | ${peerPart} | ${versionPart}${imgPart}${validatorSuffix}` 413export const runEthStatus = async ({ethStatusCfgs, safeVal = null}: {ethStatusCfgs: any[], safeVal?: any}) => { 414 const history = loadHistory() 415 const now = Date.now() 416 const newHistory: Record<string, any> = {} 418 // Stream results per cluster - print header then status immediately 419 for (const [idx, clusterCfg] of ethStatusCfgs.entries()) { 420 const {cluster_name, epPath, epFilePath, execClient, consClient} = clusterCfg 421 const epPathPart = epPath ? ` ${epPath}` : '' 422 console.log(`${idx > 0 ? '\n' : ''}${jsObjStringify({cluster_name})}${epPathPart}`) 423 const imageDates = parseImageDatesFromFile(epFilePath) 424 const execImageAge = formatImageAge(imageDates.exec) 425 const consImageAge = formatImageAge(imageDates.cons) 429 console.log(`(offline: ${offlineReason})`) 434 const [execStatus, consStatus] = await Promise.all([ 435 getFullClientStatus({cluster_name, ...execClient, isConsensus: false}), 436 getFullClientStatus({cluster_name, ...consClient, isConsensus: true}), 439 // Extract metrics and compare to history 440 const execKey = `${cluster_name}:${execClient.fuzzyPodName}` 441 const consKey = `${cluster_name}:${consClient.fuzzyPodName}` 443 // For reth indexing with no checkpoint, fetch progress from logs 444 let logIndexingPct = null 445 if (isRethIndexingNoCheckpoint(execStatus.clientStatus?.syncStatus) && execStatus.podHealth?.podName) { 446 logIndexingPct = await getRethIndexingProgress({ 447 cluster_name, podName: execStatus.podHealth.podName, containerName: execClient.fuzzyPodName, 451 const execMetric = extractMetric({syncStatus: execStatus.clientStatus?.syncStatus, isConsensus: false, logIndexingPct}) 452 const consMetric = extractMetric({syncStatus: consStatus.clientStatus?.syncStatus, isConsensus: true}) 454 const execDelta = formatDelta(history[execKey]?.metric, execMetric, history[execKey]?.ts) 455 const consDelta = formatDelta(history[consKey]?.metric, consMetric, history[consKey]?.ts) 457 // Only update history if metric changed (to preserve "stuck since" timestamp) 459 const changed = !history[execKey] || history[execKey].metric?.value !== execMetric.value 460 newHistory[execKey] = {metric: execMetric, ts: changed ? now : history[execKey]?.ts || now} 463 const changed = !history[consKey] || history[consKey].metric?.value !== consMetric.value 464 newHistory[consKey] = {metric: consMetric, ts: changed ? now : history[consKey]?.ts || now} 467 const isActiveValidator = safeVal?.cluster_name === cluster_name && safeVal?.name === consClient.fuzzyPodName 468 console.log(formatClientLine({fuzzyPodName: execClient.fuzzyPodName, ...execStatus, isConsensus: false, isActiveValidator: false, historyDelta: execDelta, imageAge: execImageAge})) 469 console.log(formatClientLine({fuzzyPodName: consClient.fuzzyPodName, ...consStatus, isConsensus: true, isActiveValidator, historyDelta: consDelta, imageAge: consImageAge})) 471 console.log(`(skipped: ${(err as Error).message})`) 475 saveHistory(newHistory)