monza/explorer.go
Alex Vanin 4fc7478e1e [#1] Add '-f' flag to overwrite local cache
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>
2023-10-19 17:20:45 +03:00

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)
})
}