Alex Vanin
4fc7478e1e
Sometimes multiple environments have blockchains with the same magic number. In this case, user should not reuse cached chain, because cache contains magic number to identify different chains. With new '-f' flag user will be able to repopulate cache with new data for the chain with the same magic number. Signed-off-by: Alex Vanin <a.vanin@yadro.com>
508 lines
12 KiB
Go
508 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.frostfs.info/TrueCloudLab/monza/internal/chain"
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/nspcc-dev/neo-go/pkg/core/block"
|
|
"github.com/nspcc-dev/neo-go/pkg/core/state"
|
|
"github.com/nspcc-dev/neo-go/pkg/util"
|
|
"github.com/nspcc-dev/neo-go/pkg/vm/stackitem"
|
|
"github.com/rivo/tview"
|
|
"github.com/urfave/cli/v2"
|
|
)
|
|
|
|
type (
|
|
Explorer struct {
|
|
ctx context.Context
|
|
chain *chain.Chain
|
|
endpoint string
|
|
app *tview.Application
|
|
|
|
jobCh chan fetchTask
|
|
errCh chan error
|
|
wg sync.WaitGroup
|
|
|
|
searchErrFlag bool
|
|
}
|
|
|
|
fetchTask struct {
|
|
txHash *util.Uint256
|
|
index uint32
|
|
}
|
|
)
|
|
|
|
const defaultExploreWorkers = 100
|
|
|
|
func explorer(c *cli.Context) (err error) {
|
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
|
|
// parse blockchain info
|
|
cacheDir := c.String(cacheFlagKey)
|
|
if len(cacheDir) == 0 {
|
|
cacheDir, err = defaultConfigDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
endpoint := c.String(endpointFlagKey)
|
|
blockchain, err := chain.Open(ctx, cacheDir, endpoint, false)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot initialize remote blockchain client: %w", err)
|
|
}
|
|
defer func() {
|
|
blockchain.Close()
|
|
cancel()
|
|
}()
|
|
|
|
e := Explorer{
|
|
ctx: ctx,
|
|
chain: blockchain,
|
|
endpoint: endpoint,
|
|
app: tview.NewApplication(),
|
|
jobCh: make(chan fetchTask),
|
|
errCh: make(chan error),
|
|
}
|
|
e.startWorkers(defaultExploreWorkers)
|
|
return e.Run()
|
|
}
|
|
|
|
func (e *Explorer) Run() error {
|
|
// UI basic elements
|
|
blockList := tview.NewList()
|
|
searchInput := tview.NewInputField()
|
|
blockInfo := tview.NewTextView()
|
|
txCounter := tview.NewTextView()
|
|
txList := tview.NewList()
|
|
notifications := tview.NewTextView()
|
|
|
|
// UI containers
|
|
blockFlex := tview.NewFlex().
|
|
SetDirection(tview.FlexRow).
|
|
AddItem(blockInfo, 0, 1, false).
|
|
AddItem(txCounter, 2, 2, false).
|
|
AddItem(txList, 5, 3, false)
|
|
appGrid := tview.NewGrid().
|
|
SetRows(15, -1, 1).
|
|
SetColumns(-1, -2).
|
|
AddItem(blockList, 0, 0, 2, 1, 0, 0, false).
|
|
AddItem(searchInput, 2, 0, 1, 2, 0, 0, false).
|
|
AddItem(blockFlex, 0, 1, 1, 1, 0, 0, false).
|
|
AddItem(notifications, 1, 1, 1, 1, 0, 0, false)
|
|
|
|
// Initialize element style
|
|
blockList.ShowSecondaryText(false).SetWrapAround(false)
|
|
blockList.SetBorder(true).SetTitle("Blocks")
|
|
searchInput.SetFieldBackgroundColor(tcell.ColorBlack)
|
|
txList.ShowSecondaryText(false)
|
|
setFocusColorStyle(blockList.Box, blockList.Box)
|
|
setFocusColorStyle(blockFlex.Box, txList.Box)
|
|
setFocusColorStyle(notifications.Box, notifications.Box)
|
|
|
|
// Handle redrawing events
|
|
blockList.SetDrawFunc(func(_ tcell.Screen, _, _, _, _ int) (int, int, int, int) {
|
|
posX, posY, width, height := blockList.GetInnerRect()
|
|
e.redrawBlockList(blockList, height)
|
|
return posX, posY, width, height
|
|
})
|
|
|
|
// Handle non-default keyboard events
|
|
appGrid.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
if event.Rune() == '/' {
|
|
e.searchStatusBar(searchInput)
|
|
e.app.SetFocus(searchInput)
|
|
return nil
|
|
}
|
|
return event
|
|
})
|
|
blockList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
switch event.Rune() {
|
|
case 'q':
|
|
e.app.Stop()
|
|
return nil
|
|
case 'r':
|
|
e.refillBlockList(blockList)
|
|
return nil
|
|
default:
|
|
return event
|
|
}
|
|
})
|
|
searchInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
if !e.searchErrFlag {
|
|
return event
|
|
}
|
|
e.searchErrFlag = false
|
|
e.defaultStatusBar(searchInput, blockList.GetItemCount())
|
|
e.app.SetFocus(blockList)
|
|
return nil
|
|
})
|
|
txList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
if event.Key() == tcell.KeyTAB || event.Key() == tcell.KeyEnter {
|
|
e.app.SetFocus(notifications)
|
|
return nil
|
|
}
|
|
if event.Rune() == 'q' {
|
|
e.hideBlockFlex(blockFlex, blockInfo, txCounter, notifications, txList)
|
|
e.app.SetFocus(blockList)
|
|
return nil
|
|
}
|
|
return event
|
|
})
|
|
notifications.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
if event.Key() == tcell.KeyTAB || event.Rune() == 'q' {
|
|
e.app.SetFocus(txList)
|
|
return nil
|
|
}
|
|
return event
|
|
})
|
|
|
|
// Handle search input logic
|
|
searchInput.SetDoneFunc(func(key tcell.Key) {
|
|
e.hideBlockFlex(blockFlex, blockInfo, txCounter, notifications, txList)
|
|
e.refillBlockList(blockList)
|
|
|
|
input := searchInput.GetText()
|
|
blockCount := blockList.GetItemCount()
|
|
|
|
// Try parse as lookingBlock index
|
|
lookingBlock, err := strconv.Atoi(input)
|
|
if err == nil {
|
|
if lookingBlock < 0 || lookingBlock >= blockCount {
|
|
e.errorStatusBar(searchInput, "invalid block number")
|
|
return
|
|
}
|
|
from, to := blockIndexRange(lookingBlock, blockCount, 50)
|
|
e.cacheBlocks(from, to)
|
|
e.defaultStatusBar(searchInput, blockCount)
|
|
blockList.SetCurrentItem(-lookingBlock - 1)
|
|
e.app.SetFocus(blockList)
|
|
return
|
|
}
|
|
// Try parse as transaction hash
|
|
h, err := util.Uint256DecodeStringLE(input)
|
|
if err == nil {
|
|
appLog, err := e.chain.Client.GetApplicationLog(h, nil)
|
|
if err != nil {
|
|
e.errorStatusBar(searchInput, "tx hash not found")
|
|
return
|
|
}
|
|
chainBlock, err := e.chain.BlockByHash(appLog.Container)
|
|
if err != nil {
|
|
e.errorStatusBar(searchInput, "can't get block of specified tx")
|
|
return
|
|
}
|
|
from, to := blockIndexRange(int(chainBlock.Index), blockCount, 50)
|
|
e.cacheBlocks(from, to)
|
|
e.defaultStatusBar(searchInput, blockCount)
|
|
blockList.SetCurrentItem(-int(chainBlock.Index) - 1)
|
|
e.app.SetFocus(blockList)
|
|
return
|
|
}
|
|
e.errorStatusBar(searchInput, "invalid input, expect valid block number or tx hash")
|
|
})
|
|
|
|
// Handle selecting block in block list
|
|
blockList.SetSelectedFunc(func(i int, s1, s2 string, r rune) {
|
|
chainBlock, err := e.chain.Block(uint32(blockList.GetItemCount() - i - 1))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
e.cacheNotifications(chainBlock)
|
|
e.displayBlockFlex(blockFlex, blockInfo, txCounter, notifications, txList, chainBlock)
|
|
e.app.SetFocus(txList)
|
|
})
|
|
|
|
// Handle select transaction in block flex
|
|
txList.SetChangedFunc(func(index int, mainText, secondaryText string, shortcut rune) {
|
|
txCounter.SetText(fmt.Sprintf("Transaction %d of %d\n---", index+1, txList.GetItemCount()))
|
|
txHash, err := util.Uint256DecodeStringLE(mainText)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
appLog, err := e.chain.ApplicationLog(txHash)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
events := make([]state.NotificationEvent, 0)
|
|
for _, execution := range appLog.Executions {
|
|
events = append(events, execution.Events...)
|
|
}
|
|
|
|
var res string
|
|
for _, event := range events {
|
|
v, err := formatNotification(event)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
res += v
|
|
}
|
|
notifications.SetText(res)
|
|
notifications.ScrollToBeginning()
|
|
})
|
|
|
|
// Initialize element data
|
|
e.fillBlockList(blockList)
|
|
e.defaultStatusBar(searchInput, blockList.GetItemCount())
|
|
|
|
// Start UI
|
|
e.app.SetRoot(appGrid, true).SetFocus(blockList)
|
|
return e.app.Run()
|
|
}
|
|
|
|
func (e *Explorer) fillBlockList(list *tview.List) {
|
|
to, err := e.chain.Client.GetBlockCount()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// adding is much faster than inserting
|
|
// so we reverse the order to use that
|
|
for i := int64(to) - 1; i >= 0; i-- {
|
|
line := fmt.Sprintf("#%d", i)
|
|
list.AddItem(line, "", 0, nil)
|
|
}
|
|
|
|
// cache some blocks upfront for smoother scrolling
|
|
var from uint32
|
|
if to > 100 {
|
|
from = to - 100
|
|
}
|
|
e.cacheBlocks(from, to-1)
|
|
}
|
|
|
|
func (e *Explorer) refillBlockList(list *tview.List) {
|
|
from := uint32(list.GetItemCount())
|
|
|
|
to, err := e.chain.Client.GetBlockCount()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
for i := from; i < to; i++ {
|
|
line := fmt.Sprintf("#%d", i)
|
|
list.InsertItem(0, line, "", 0, nil)
|
|
}
|
|
|
|
e.cacheBlocks(from, to-1)
|
|
}
|
|
|
|
func (e *Explorer) redrawBlockList(list *tview.List, height int) {
|
|
currentItemIndex := list.GetCurrentItem()
|
|
itemCount := list.GetItemCount()
|
|
|
|
// range of blocks for detailed info in listbox
|
|
// to avoid whole blockchain fetching, app works
|
|
// with small ranges of visible blocks on the screen
|
|
fromIndex := currentItemIndex - height
|
|
if fromIndex < 0 {
|
|
fromIndex = 0
|
|
}
|
|
|
|
toIndex := currentItemIndex + height
|
|
if toIndex > itemCount {
|
|
toIndex = itemCount
|
|
}
|
|
|
|
e.cacheBlocks(toBlockIndex(toIndex, itemCount), toBlockIndex(fromIndex, itemCount))
|
|
for i := fromIndex; i < toIndex; i++ {
|
|
elem, _ := list.GetItemText(i)
|
|
// ignore blocks that has been parsed
|
|
if strings.Contains(elem, "tx") {
|
|
continue
|
|
}
|
|
blockIndex := toBlockIndex(i, itemCount)
|
|
chainBlock, err := e.chain.Block(blockIndex)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
ts := time.Unix(int64(chainBlock.Timestamp/1e3), 0)
|
|
richText := fmt.Sprintf("#%d [%s] txs:%d", blockIndex, ts.Format(time.RFC3339), len(chainBlock.Transactions))
|
|
list.SetItemText(i, richText, "")
|
|
}
|
|
}
|
|
|
|
func (e *Explorer) startWorkers(amount int) {
|
|
worker := func(ctx context.Context, ch <-chan fetchTask, out chan<- error) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case task, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
var err error
|
|
if task.txHash != nil {
|
|
_, err = e.chain.ApplicationLog(*task.txHash)
|
|
} else {
|
|
_, err = e.chain.Block(task.index)
|
|
}
|
|
if err != nil {
|
|
out <- err
|
|
return
|
|
}
|
|
e.wg.Done()
|
|
}
|
|
}
|
|
}
|
|
|
|
for i := 0; i < amount; i++ {
|
|
go worker(e.ctx, e.jobCh, e.errCh)
|
|
}
|
|
}
|
|
|
|
func (e *Explorer) cacheBlocks(from, to uint32) {
|
|
for i := from; i <= to; i++ {
|
|
e.wg.Add(1)
|
|
select {
|
|
case <-e.ctx.Done():
|
|
return
|
|
case err := <-e.errCh:
|
|
panic(err)
|
|
case e.jobCh <- fetchTask{index: i}:
|
|
}
|
|
}
|
|
|
|
wgCh := make(chan struct{})
|
|
|
|
go func() {
|
|
e.wg.Wait()
|
|
close(wgCh)
|
|
}()
|
|
|
|
select {
|
|
case <-e.ctx.Done():
|
|
return
|
|
case err := <-e.errCh:
|
|
panic(err)
|
|
case <-wgCh:
|
|
return
|
|
}
|
|
}
|
|
|
|
func (e *Explorer) cacheNotifications(block *block.Block) {
|
|
for _, tx := range block.Transactions {
|
|
h := tx.Hash()
|
|
e.wg.Add(1)
|
|
select {
|
|
case <-e.ctx.Done():
|
|
return
|
|
case err := <-e.errCh:
|
|
panic(err)
|
|
case e.jobCh <- fetchTask{txHash: &h}:
|
|
}
|
|
}
|
|
|
|
wgCh := make(chan struct{})
|
|
|
|
go func() {
|
|
e.wg.Wait()
|
|
close(wgCh)
|
|
}()
|
|
|
|
select {
|
|
case <-e.ctx.Done():
|
|
return
|
|
case err := <-e.errCh:
|
|
panic(err)
|
|
case <-wgCh:
|
|
return
|
|
}
|
|
}
|
|
|
|
func (e *Explorer) defaultStatusBar(input *tview.InputField, blocks int) {
|
|
message := fmt.Sprintf("Endpoint: %s Blocks: %d | Press q to back, / to search, r to resync.",
|
|
e.endpoint,
|
|
blocks)
|
|
input.SetText("").
|
|
SetLabelColor(tcell.ColorWhite).
|
|
SetLabel(message)
|
|
}
|
|
|
|
func (e *Explorer) searchStatusBar(input *tview.InputField) {
|
|
input.SetText("").
|
|
SetLabelColor(tcell.ColorGreen).
|
|
SetLabel("Search block or transaction: ")
|
|
}
|
|
|
|
func (e *Explorer) errorStatusBar(input *tview.InputField, message string) {
|
|
e.searchErrFlag = true
|
|
input.SetText("").
|
|
SetLabelColor(tcell.ColorRed).
|
|
SetLabel(message)
|
|
}
|
|
|
|
func (e *Explorer) displayBlockFlex(f *tview.Flex, info, counter, notif *tview.TextView, list *tview.List, b *block.Block) {
|
|
ln := min(len(b.Transactions), 7)
|
|
for _, tx := range b.Transactions {
|
|
list.AddItem(tx.Hash().StringLE(), "", 0, nil)
|
|
}
|
|
info.SetText(fmt.Sprintf("Exploring block #%d", b.Index))
|
|
counter.SetText(fmt.Sprintf("Transaction %d of %d\n---", min(1, ln), list.GetItemCount()))
|
|
notif.SetBorder(true).SetTitle("Notifications")
|
|
f.Clear()
|
|
f.SetBorder(true)
|
|
f.AddItem(info, 0, 1, false)
|
|
f.AddItem(counter, 2, 2, false)
|
|
f.AddItem(list, ln, 3, false)
|
|
}
|
|
|
|
func (e *Explorer) hideBlockFlex(flex *tview.Flex, info, counter, notif *tview.TextView, list *tview.List) {
|
|
info.SetText("")
|
|
counter.SetText("")
|
|
list.Clear()
|
|
flex.SetBorder(false)
|
|
notif.SetText("").SetBorder(false).SetTitle("")
|
|
}
|
|
|
|
func toBlockIndex(index, length int) uint32 {
|
|
return uint32(length - index - 1)
|
|
}
|
|
|
|
func blockIndexRange(index, length, delta int) (uint32, uint32) {
|
|
from := index - delta
|
|
to := index + delta
|
|
if from < 0 {
|
|
from = 0
|
|
}
|
|
if to >= length {
|
|
to = length - 1
|
|
}
|
|
return uint32(from), uint32(to)
|
|
}
|
|
|
|
func formatNotification(event state.NotificationEvent) (string, error) {
|
|
data, err := stackitem.ToJSONWithTypes(event.Item)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var formatted bytes.Buffer
|
|
err = json.Indent(&formatted, data, "", " ")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("%s\n---\n%s\n\n", event.Name, formatted.String()), nil
|
|
}
|
|
|
|
func setFocusColorStyle(target, focus *tview.Box) {
|
|
focus.SetFocusFunc(func() {
|
|
target.SetBorderColor(tcell.ColorGreen)
|
|
target.SetBorderAttributes(tcell.AttrBold)
|
|
})
|
|
focus.SetBlurFunc(func() {
|
|
target.SetBorderColor(tcell.ColorDefault)
|
|
target.SetBorderAttributes(tcell.AttrNone)
|
|
})
|
|
}
|