forked from TrueCloudLab/monza
521 lines
13 KiB
Go
521 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.frostfs.info/TrueCloudLab/monza/internal/bytecode"
|
|
"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
|
|
}
|
|
|
|
res += fmt.Sprintf("\nContract calls:\n")
|
|
calls, err := e.chain.Calls(txHash)
|
|
for _, call := range calls {
|
|
res += formatCall(call)
|
|
}
|
|
|
|
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)
|
|
_, err = e.chain.Calls(*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 formatCall(syscallParams bytecode.SyscallParameters) string {
|
|
return fmt.Sprintf("Contract: %s; Method: %s\n", syscallParams.Contract, syscallParams.Method)
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|