diff --git a/README.md b/README.md index 27a6a3f..d246fa1 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ `lntop` is an interactive text-mode channels viewer for Unix systems. - ![lntop-v0.0.0](http://paris.iiens.net/lntop-v0.0.0.png?) - *lntop-v0.0.0* + ![lntop-v0.1.0](http://paris.iiens.net/lntop-v0.1.0.png) + *lntop-v0.1.0* ## Install @@ -22,6 +22,9 @@ cd lntop && export GO111MODULE=on && go install -mod=vendor ./... First time `lntop` is used a config file `.lntop/config.toml` is created in the user home directory. + +Change macaroon path according to your network. + ```toml [logger] type = "production" @@ -37,8 +40,40 @@ macaroon_timeout = 60 max_msg_recv_size = 52428800 conn_timeout = 1000000 pool_capacity = 3 + +[views] +# views.channels is the view displaying channel list. +[views.channels] +# It is possible to add, remove and order columns of the +# table with the array columns. The available values are: +columns = [ + "STATUS", # status of the channel + "ALIAS", # alias of the channel node + "GAUGE", # ascii bar with percent local/capacity + "LOCAL", # the local amount of the channel + "CAP", # the total capacity of the channel + "SENT", # the total amount sent + "RECEIVED", # the total amount received + "HTLC", # the number of pending HTLC + "UNSETTLED", # the amount unsettled in the channel + "CFEE", # the commit fee + "LAST UPDATE", # last update of the channel + "PRIVATE", # true if channel is private + "ID", # the id of the channel +] + +[views.transactions] +# It is possible to add, remove and order columns of the +# table with the array columns. The available values are: +columns = [ + "DATE", # date of the transaction + "HEIGHT", # block height of the transaction + "CONFIR", # number of confirmations + "AMOUNT", # amount moved by the transaction + "FEE", # fee of the transaction + "ADDRESSES", # number of transaction output addresses +] ``` -Change macaroon path according to your network. ## Docker diff --git a/cli/cli.go b/cli/cli.go index f00b335..d84c1ba 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -15,7 +15,7 @@ import ( "github.com/edouardparis/lntop/ui" ) -const version = "v0.0.3" +const version = "v0.1.0" // New creates a new cli app. func New() *cli.App { diff --git a/config/config.go b/config/config.go index 80fee82..9a0498e 100644 --- a/config/config.go +++ b/config/config.go @@ -14,6 +14,7 @@ import ( type Config struct { Logger Logger `toml:"logger"` Network Network `toml:"network"` + Views Views `toml:"views"` } type Logger struct { @@ -34,6 +35,15 @@ type Network struct { PoolCapacity int `toml:"pool_capacity"` } +type Views struct { + Channels *View `toml:"channels"` + Transactions *View `toml:"transactions"` +} + +type View struct { + Columns []string `toml:"columns"` +} + func Load(path string) (*Config, error) { c := &Config{} diff --git a/config/default.go b/config/default.go index 24596d1..896734d 100644 --- a/config/default.go +++ b/config/default.go @@ -23,6 +23,39 @@ macaroon_timeout = %[8]d max_msg_recv_size = %[9]d conn_timeout = %[10]d pool_capacity = %[11]d + +[views] +# views.channels is the view displaying channel list. +[views.channels] +# It is possible to add, remove and order columns of the +# table with the array columns. The available values are: +columns = [ + "STATUS", # status of the channel + "ALIAS", # alias of the channel node + "GAUGE", # ascii bar with percent local/capacity + "LOCAL", # the local amount of the channel + "CAP", # the total capacity of the channel + "SENT", # the total amount sent + "RECEIVED", # the total amount received + "HTLC", # the number of pending HTLC + "UNSETTLED", # the amount unsettled in the channel + "CFEE", # the commit fee + "LAST UPDATE", # last update of the channel + "PRIVATE", # true if channel is private + "ID", # the id of the channel +] + +[views.transactions] +# It is possible to add, remove and order columns of the +# table with the array columns. The available values are: +columns = [ + "DATE", # date of the transaction + "HEIGHT", # block height of the transaction + "CONFIR", # number of confirmations + "AMOUNT", # amount moved by the transaction + "FEE", # fee of the transaction + "ADDRESSES", # number of transaction output addresses +] `, cfg.Logger.Type, cfg.Logger.Dest, diff --git a/events/events.go b/events/events.go index dc5c1d9..3e675a2 100644 --- a/events/events.go +++ b/events/events.go @@ -1,14 +1,15 @@ package events const ( - PeerUpdated = "peer.updated" BlockReceived = "block.received" - InvoiceCreated = "invoice.created" - InvoiceSettled = "invoice.settled" - ChannelPending = "channel.pending" ChannelActive = "channel.active" - ChannelInactive = "channel.inactive" ChannelBalanceUpdated = "channel.balance.updated" + ChannelInactive = "channel.inactive" + ChannelPending = "channel.pending" + InvoiceCreated = "invoice.created" + InvoiceSettled = "invoice.settled" + PeerUpdated = "peer.updated" + TransactionCreated = "transaction.created" WalletBalanceUpdated = "wallet.balance.updated" ) diff --git a/network/backend/backend.go b/network/backend/backend.go index e2c68cb..1318ec7 100644 --- a/network/backend/backend.go +++ b/network/backend/backend.go @@ -35,4 +35,8 @@ type Backend interface { DecodePayReq(context.Context, string) (*models.PayReq, error) SendPayment(context.Context, *models.PayReq) (*models.Payment, error) + + GetTransactions(context.Context) ([]*models.Transaction, error) + + SubscribeTransactions(context.Context, chan *models.Transaction) error } diff --git a/network/backend/lnd/lnd.go b/network/backend/lnd/lnd.go index 78788ce..f872a89 100644 --- a/network/backend/lnd/lnd.go +++ b/network/backend/lnd/lnd.go @@ -97,6 +97,38 @@ func (l Backend) SubscribeInvoice(ctx context.Context, channelInvoice chan *mode } } +func (l Backend) SubscribeTransactions(ctx context.Context, channel chan *models.Transaction) error { + clt, err := l.Client(ctx) + if err != nil { + return err + } + defer clt.Close() + + cltTransactions, err := clt.SubscribeTransactions(ctx, &lnrpc.GetTransactionsRequest{}) + if err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + break + default: + transaction, err := cltTransactions.Recv() + if err != nil { + st, ok := status.FromError(err) + if ok && st.Code() == codes.Canceled { + l.logger.Debug("stopping subscribe transactions: context canceled") + return nil + } + return err + } + + channel <- protoToTransaction(transaction) + } + } +} + func (l Backend) SubscribeChannels(ctx context.Context, events chan *models.ChannelUpdate) error { _, err := l.Client(ctx) if err != nil { @@ -134,6 +166,23 @@ func (l Backend) NewClientConn() (*grpc.ClientConn, error) { return newClientConn(l.cfg) } +func (l Backend) GetTransactions(ctx context.Context) ([]*models.Transaction, error) { + l.logger.Debug("Get transactions...") + clt, err := l.Client(ctx) + if err != nil { + return nil, err + } + defer clt.Close() + + req := &lnrpc.GetTransactionsRequest{} + resp, err := clt.GetTransactions(ctx, req) + if err != nil { + return nil, errors.WithStack(err) + } + + return protoToTransactions(resp), nil +} + func (l Backend) GetWalletBalance(ctx context.Context) (*models.WalletBalance, error) { l.logger.Debug("Retrieve wallet balance...") diff --git a/network/backend/lnd/proto.go b/network/backend/lnd/proto.go index e00d27f..c75a08e 100644 --- a/network/backend/lnd/proto.go +++ b/network/backend/lnd/proto.go @@ -284,3 +284,28 @@ func protoToRoutingPolicy(resp *lnrpc.RoutingPolicy) *models.RoutingPolicy { Disabled: resp.Disabled, } } + +func protoToTransactions(resp *lnrpc.TransactionDetails) []*models.Transaction { + if resp == nil { + return nil + } + + transactions := make([]*models.Transaction, len(resp.Transactions)) + for i := range resp.Transactions { + transactions[i] = protoToTransaction(resp.Transactions[i]) + } + return transactions +} + +func protoToTransaction(resp *lnrpc.Transaction) *models.Transaction { + return &models.Transaction{ + TxHash: resp.TxHash, + Amount: resp.Amount, + NumConfirmations: resp.NumConfirmations, + BlockHash: resp.BlockHash, + BlockHeight: resp.BlockHeight, + Date: time.Unix(int64(resp.TimeStamp), 0), + TotalFees: resp.TotalFees, + DestAddresses: resp.DestAddresses, + } +} diff --git a/network/backend/mock/mock.go b/network/backend/mock/mock.go index 46b0097..10e507b 100644 --- a/network/backend/mock/mock.go +++ b/network/backend/mock/mock.go @@ -46,7 +46,11 @@ func (b *Backend) SubscribeChannels(context.Context, chan *models.ChannelUpdate) return nil } -func (l *Backend) GetNode(ctx context.Context, pubkey string) (*models.Node, error) { +func (b *Backend) SubscribeTransactions(ctx context.Context, channel chan *models.Transaction) error { + return nil +} + +func (b *Backend) GetNode(ctx context.Context, pubkey string) (*models.Node, error) { return &models.Node{}, nil } @@ -54,6 +58,10 @@ func (b *Backend) GetWalletBalance(ctx context.Context) (*models.WalletBalance, return &models.WalletBalance{}, nil } +func (b *Backend) GetTransactions(ctx context.Context) ([]*models.Transaction, error) { + return []*models.Transaction{}, nil +} + func (b *Backend) GetChannelsBalance(ctx context.Context) (*models.ChannelsBalance, error) { return &models.ChannelsBalance{}, nil } diff --git a/network/models/transaction.go b/network/models/transaction.go new file mode 100644 index 0000000..e2bc1c6 --- /dev/null +++ b/network/models/transaction.go @@ -0,0 +1,22 @@ +package models + +import "time" + +type Transaction struct { + // / The transaction hash + TxHash string + // / The transaction amount, denominated in satoshis + Amount int64 + // / The number of confirmations + NumConfirmations int32 + // / The hash of the block this transaction was included in + BlockHash string + // / The height of the block this transaction was included in + BlockHeight int32 + // / Timestamp of this transaction + Date time.Time + // / Fees paid for this transaction + TotalFees int64 + // / Addresses that received funds for this transaction + DestAddresses []string +} diff --git a/pubsub/pubsub.go b/pubsub/pubsub.go index b372a88..dc840ff 100644 --- a/pubsub/pubsub.go +++ b/pubsub/pubsub.go @@ -59,6 +59,35 @@ func (p *PubSub) invoices(ctx context.Context, sub chan *events.Event) { }() } +func (p *PubSub) transactions(ctx context.Context, sub chan *events.Event) { + p.wg.Add(3) + transactions := make(chan *models.Transaction) + ctx, cancel := context.WithCancel(ctx) + + go func() { + for tx := range transactions { + p.logger.Debug("receive transaction", logging.String("tx_hash", tx.TxHash)) + sub <- events.New(events.TransactionCreated) + } + p.wg.Done() + }() + + go func() { + err := p.network.SubscribeTransactions(ctx, transactions) + if err != nil { + p.logger.Error("SubscribeTransactions returned an error", logging.Error(err)) + } + p.wg.Done() + }() + + go func() { + <-p.stop + cancel() + close(transactions) + p.wg.Done() + }() +} + func (p *PubSub) Stop() { p.stop <- true close(p.stop) @@ -69,10 +98,12 @@ func (p *PubSub) Run(ctx context.Context, sub chan *events.Event) { p.logger.Debug("Starting...") p.invoices(ctx, sub) + p.transactions(ctx, sub) p.ticker(ctx, sub, withTickerInfo(), withTickerChannelsBalance(), - withTickerWalletBalance(), + // no need for ticker Wallet balance, transactions subscriber is enough + // withTickerWalletBalance(), ) <-p.stop diff --git a/ui/color/color.go b/ui/color/color.go index a06a667..60b6926 100644 --- a/ui/color/color.go +++ b/ui/color/color.go @@ -5,13 +5,104 @@ import "github.com/fatih/color" type Color color.Color var ( - Yellow = color.New(color.FgYellow).SprintFunc() - Green = color.New(color.FgGreen).SprintFunc() - GreenBg = color.New(color.BgGreen, color.FgBlack).SprintFunc() - Red = color.New(color.FgRed).SprintFunc() - RedBg = color.New(color.BgRed, color.FgBlack).SprintFunc() - Cyan = color.New(color.FgCyan).SprintFunc() - CyanBg = color.New(color.BgCyan, color.FgBlack).SprintFunc() - WhiteBg = color.New(color.BgWhite, color.FgBlack).SprintFunc() - BlackBg = color.New(color.BgBlack, color.FgWhite).SprintFunc() + yellow = color.New(color.FgYellow).SprintFunc() + yellowBold = color.New(color.FgYellow, color.Bold).SprintFunc() + green = color.New(color.FgGreen).SprintFunc() + greenBold = color.New(color.FgGreen, color.Bold).SprintFunc() + greenBg = color.New(color.FgBlack, color.BgGreen).SprintFunc() + magentaBg = color.New(color.FgBlack, color.BgMagenta).SprintFunc() + red = color.New(color.FgRed).SprintFunc() + redBold = color.New(color.FgRed, color.Bold).SprintFunc() + cyan = color.New(color.FgCyan).SprintFunc() + cyanBold = color.New(color.FgCyan, color.Bold).SprintFunc() + cyanBg = color.New(color.BgCyan, color.FgBlack).SprintFunc() + white = color.New().SprintFunc() + whiteBold = color.New(color.Bold).SprintFunc() + blackBg = color.New(color.BgBlack, color.FgWhite).SprintFunc() + black = color.New(color.FgBlack).SprintFunc() ) + +type Option func(*options) + +type options struct { + bold bool + bg bool +} + +func newOptions(opts []Option) options { + options := options{} + for i := range opts { + if opts[i] == nil { + continue + } + opts[i](&options) + } + return options +} + +func Bold(o *options) { o.bold = true } +func Background(o *options) { o.bg = true } + +func Yellow(opts ...Option) func(a ...interface{}) string { + options := newOptions(opts) + if options.bold { + return yellowBold + } + return yellow +} + +func Green(opts ...Option) func(a ...interface{}) string { + options := newOptions(opts) + if options.bold { + return greenBold + } + + if options.bg { + return greenBg + } + + return green +} + +func Red(opts ...Option) func(a ...interface{}) string { + options := newOptions(opts) + if options.bold { + return redBold + } + return red +} + +func White(opts ...Option) func(a ...interface{}) string { + options := newOptions(opts) + if options.bold { + return whiteBold + } + return white +} + +func Cyan(opts ...Option) func(a ...interface{}) string { + options := newOptions(opts) + if options.bold { + return cyanBold + } + if options.bg { + return cyanBg + } + return cyan +} + +func Black(opts ...Option) func(a ...interface{}) string { + options := newOptions(opts) + if options.bg { + return blackBg + } + return black +} + +func Magenta(opts ...Option) func(a ...interface{}) string { + options := newOptions(opts) + if options.bg { + return magentaBg + } + return magentaBg +} diff --git a/ui/controller.go b/ui/controller.go index 519de62..c676b6c 100644 --- a/ui/controller.go +++ b/ui/controller.go @@ -8,6 +8,7 @@ import ( "github.com/edouardparis/lntop/app" "github.com/edouardparis/lntop/events" "github.com/edouardparis/lntop/logging" + "github.com/edouardparis/lntop/ui/cursor" "github.com/edouardparis/lntop/ui/models" "github.com/edouardparis/lntop/ui/views" ) @@ -26,7 +27,7 @@ func (c *controller) layout(g *gocui.Gui) error { func (c *controller) cursorDown(g *gocui.Gui, v *gocui.View) error { view := c.views.Get(v) if view != nil { - return view.CursorDown() + return cursor.Down(view) } return nil } @@ -34,7 +35,7 @@ func (c *controller) cursorDown(g *gocui.Gui, v *gocui.View) error { func (c *controller) cursorUp(g *gocui.Gui, v *gocui.View) error { view := c.views.Get(v) if view != nil { - return view.CursorUp() + return cursor.Up(view) } return nil } @@ -42,7 +43,7 @@ func (c *controller) cursorUp(g *gocui.Gui, v *gocui.View) error { func (c *controller) cursorRight(g *gocui.Gui, v *gocui.View) error { view := c.views.Get(v) if view != nil { - return view.CursorRight() + return cursor.Right(view) } return nil } @@ -50,7 +51,7 @@ func (c *controller) cursorRight(g *gocui.Gui, v *gocui.View) error { func (c *controller) cursorLeft(g *gocui.Gui, v *gocui.View) error { view := c.views.Get(v) if view != nil { - return view.CursorLeft() + return cursor.Left(view) } return nil } @@ -71,6 +72,11 @@ func (c *controller) SetModels(ctx context.Context) error { return err } + err = c.models.RefreshTransactions(ctx) + if err != nil { + return err + } + return c.models.RefreshChannels(ctx) } @@ -89,12 +95,22 @@ func (c *controller) Listen(ctx context.Context, g *gocui.Gui, sub chan *events. for event := range sub { c.logger.Debug("event received", logging.String("type", event.Type)) switch event.Type { + case events.TransactionCreated: + refresh( + c.models.RefreshInfo, + c.models.RefreshWalletBalance, + c.models.RefreshTransactions, + ) case events.BlockReceived: - refresh(c.models.RefreshInfo) + refresh( + c.models.RefreshInfo, + c.models.RefreshTransactions, + ) case events.WalletBalanceUpdated: refresh( c.models.RefreshInfo, c.models.RefreshWalletBalance, + c.models.RefreshTransactions, ) case events.ChannelBalanceUpdated: refresh( @@ -132,10 +148,6 @@ func (c *controller) Listen(ctx context.Context, g *gocui.Gui, sub chan *events. } } -func quit(g *gocui.Gui, v *gocui.View) error { - return gocui.ErrQuit -} - func (c *controller) Help(g *gocui.Gui, v *gocui.View) error { maxX, maxY := g.Size() view := c.views.Get(g.CurrentView()) @@ -144,117 +156,148 @@ func (c *controller) Help(g *gocui.Gui, v *gocui.View) error { } if view.Name() != views.HELP { - c.views.SetPrevious(view) + c.views.Main = view return c.views.Help.Set(g, 0, -1, maxX, maxY) } - err := g.DeleteView(views.HELP) + err := view.Delete(g) if err != nil { return err } - if c.views.Previous != nil { - _, err := g.SetCurrentView(c.views.Previous.Name()) + if c.views.Main != nil { + _, err := g.SetCurrentView(c.views.Main.Name()) return err } return nil } -func (c *controller) OnEnter(g *gocui.Gui, v *gocui.View) error { +func (c *controller) Menu(g *gocui.Gui, v *gocui.View) error { maxX, maxY := g.Size() - view := c.views.Get(v) - if view == nil { + if v.Name() == c.views.Help.Name() { return nil } - switch view.Name() { - case views.CHANNELS: - c.views.SetPrevious(view) - index := c.views.Channels.Index() - err := c.models.SetCurrentChannel(context.Background(), index) - if err != nil { - return err - } - - err = c.views.Channel.Set(g, 0, 6, maxX-1, maxY) + if v.Name() != c.views.Menu.Name() { + err := c.views.Menu.Set(g, 0, 6, 10, maxY) if err != nil { return err } - _, err = g.SetCurrentView(c.views.Channel.Name()) - return err - case views.CHANNEL: - err := c.views.Channel.Delete(g) + err = c.views.Main.Set(g, 11, 6, maxX-1, maxY) if err != nil { return err } - if c.views.Previous != nil { - _, err := g.SetCurrentView(c.views.Previous.Name()) - return err - } - - err = c.views.Channels.Set(g, 0, 6, maxX-1, maxY) - if err != nil { - return err - } - } - return nil -} - -func (c *controller) setKeyBinding(g *gocui.Gui) error { - err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit) - if err != nil { + _, err = g.SetCurrentView(c.views.Menu.Name()) return err } - err = g.SetKeybinding("", gocui.KeyF10, gocui.ModNone, quit) + err := c.views.Menu.Delete(g) if err != nil { return err } - err = g.SetKeybinding("", 'q', gocui.ModNone, quit) - if err != nil { + if c.views.Main != nil { + _, err := g.SetCurrentView(c.views.Main.Name()) return err } - err = g.SetKeybinding("", gocui.KeyArrowUp, gocui.ModNone, c.cursorUp) - if err != nil { - return err - } + return nil +} - err = g.SetKeybinding("", gocui.KeyArrowDown, gocui.ModNone, c.cursorDown) - if err != nil { - return err +func (c *controller) Order(order models.Order) func(*gocui.Gui, *gocui.View) error { + return func(g *gocui.Gui, v *gocui.View) error { + view := c.views.Get(v) + if view == nil { + return nil + } + switch view.Name() { + case views.CHANNELS: + c.views.Channels.Sort("", order) + case views.TRANSACTIONS: + c.views.Transactions.Sort("", order) + } + return nil } +} - err = g.SetKeybinding("", gocui.KeyArrowLeft, gocui.ModNone, c.cursorLeft) - if err != nil { - return err +func (c *controller) OnEnter(g *gocui.Gui, v *gocui.View) error { + maxX, maxY := g.Size() + view := c.views.Get(v) + if view == nil { + return nil } - err = g.SetKeybinding("", gocui.KeyArrowRight, gocui.ModNone, c.cursorRight) - if err != nil { - return err - } + switch view.Name() { + case views.CHANNELS: + index := c.views.Channels.Index() + c.models.Channels.SetCurrent(index) + c.views.Main = c.views.Channel + return ToggleView(g, view, c.views.Channels) - err = g.SetKeybinding("", gocui.KeyEnter, gocui.ModNone, c.OnEnter) - if err != nil { - return err + case views.CHANNEL: + c.views.Main = c.views.Channels + return ToggleView(g, view, c.views.Channels) + + case views.MENU: + current := c.views.Menu.Current() + if c.views.Main.Name() == current { + return nil + } + + switch current { + case views.TRANSACTIONS: + err := c.views.Main.Delete(g) + if err != nil { + return err + } + + c.views.Main = c.views.Transactions + err = c.views.Transactions.Set(g, 11, 6, maxX-1, maxY) + if err != nil { + return err + } + case views.CHANNELS: + err := c.views.Main.Delete(g) + if err != nil { + return err + } + + c.views.Main = c.views.Channels + err = c.views.Channels.Set(g, 11, 6, maxX-1, maxY) + if err != nil { + return err + } + } + case views.TRANSACTIONS: + index := c.views.Transactions.Index() + c.models.Transactions.SetCurrent(index) + c.views.Main = c.views.Transaction + return ToggleView(g, view, c.views.Transaction) + + case views.TRANSACTION: + c.views.Main = c.views.Transactions + return ToggleView(g, view, c.views.Transactions) } + return nil +} - err = g.SetKeybinding("", gocui.KeyF1, gocui.ModNone, c.Help) +func ToggleView(g *gocui.Gui, v1, v2 views.View) error { + maxX, maxY := g.Size() + err := v1.Delete(g) if err != nil { return err } - err = g.SetKeybinding("", 'h', gocui.ModNone, c.Help) + err = v2.Set(g, 0, 6, maxX-1, maxY) if err != nil { return err } - return nil + _, err = g.SetCurrentView(v2.Name()) + return err } func newController(app *app.App) *controller { @@ -262,6 +305,6 @@ func newController(app *app.App) *controller { return &controller{ logger: app.Logger.With(logging.String("logger", "controller")), models: m, - views: views.New(m), + views: views.New(app.Config.Views, m), } } diff --git a/ui/cursor/cursor.go b/ui/cursor/cursor.go new file mode 100644 index 0000000..8d9991d --- /dev/null +++ b/ui/cursor/cursor.go @@ -0,0 +1,84 @@ +package cursor + +type View interface { + Cursor() (int, int) + Origin() (int, int) + Speed() (int, int, int, int) + SetCursor(int, int) error + SetOrigin(int, int) error +} + +func Down(v View) error { + if v == nil { + return nil + } + cx, cy := v.Cursor() + _, _, sy, _ := v.Speed() + err := v.SetCursor(cx, cy+sy) + if err != nil { + ox, oy := v.Origin() + err := v.SetOrigin(ox, oy+sy) + if err != nil { + return err + } + } + return nil +} + +func Up(v View) error { + if v == nil { + return nil + } + ox, oy := v.Origin() + cx, cy := v.Cursor() + _, _, _, sy := v.Speed() + err := v.SetCursor(cx, cy-sy) + if err != nil && oy >= sy { + err := v.SetOrigin(ox, oy-sy) + if err != nil { + return err + } + } + return nil +} + +func Right(v View) error { + if v == nil { + return nil + } + cx, cy := v.Cursor() + sx, _, _, _ := v.Speed() + err := v.SetCursor(cx+sx, cy) + if err != nil { + ox, oy := v.Origin() + err := v.SetOrigin(ox+sx, oy) + if err != nil { + return err + } + } + return nil +} + +func Left(v View) error { + if v == nil { + return nil + } + ox, oy := v.Origin() + cx, cy := v.Cursor() + _, sx, _, _ := v.Speed() + err := v.SetCursor(cx-sx, cy) + if err != nil { + err := v.SetCursor(0, cy) + if err != nil { + return err + } + + if ox >= sx-cx { + err := v.SetOrigin(ox-sx+cx, oy) + if err != nil { + return err + } + } + } + return nil +} diff --git a/ui/keybindings.go b/ui/keybindings.go new file mode 100644 index 0000000..1a2e26e --- /dev/null +++ b/ui/keybindings.go @@ -0,0 +1,84 @@ +package ui + +import ( + "github.com/edouardparis/lntop/ui/models" + "github.com/jroimartin/gocui" +) + +func quit(g *gocui.Gui, v *gocui.View) error { + return gocui.ErrQuit +} + +func setKeyBinding(c *controller, g *gocui.Gui) error { + err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit) + if err != nil { + return err + } + + err = g.SetKeybinding("", gocui.KeyF10, gocui.ModNone, quit) + if err != nil { + return err + } + + err = g.SetKeybinding("", 'q', gocui.ModNone, quit) + if err != nil { + return err + } + + err = g.SetKeybinding("", gocui.KeyArrowUp, gocui.ModNone, c.cursorUp) + if err != nil { + return err + } + + err = g.SetKeybinding("", gocui.KeyArrowDown, gocui.ModNone, c.cursorDown) + if err != nil { + return err + } + + err = g.SetKeybinding("", gocui.KeyArrowLeft, gocui.ModNone, c.cursorLeft) + if err != nil { + return err + } + + err = g.SetKeybinding("", gocui.KeyArrowRight, gocui.ModNone, c.cursorRight) + if err != nil { + return err + } + + err = g.SetKeybinding("", gocui.KeyEnter, gocui.ModNone, c.OnEnter) + if err != nil { + return err + } + + err = g.SetKeybinding("", gocui.KeyF1, gocui.ModNone, c.Help) + if err != nil { + return err + } + + err = g.SetKeybinding("", 'h', gocui.ModNone, c.Help) + if err != nil { + return err + } + + err = g.SetKeybinding("", gocui.KeyF2, gocui.ModNone, c.Menu) + if err != nil { + return err + } + + err = g.SetKeybinding("", 'm', gocui.ModNone, c.Menu) + if err != nil { + return err + } + + err = g.SetKeybinding("", 'a', gocui.ModNone, c.Order(models.Asc)) + if err != nil { + return err + } + + err = g.SetKeybinding("", 'd', gocui.ModNone, c.Order(models.Desc)) + if err != nil { + return err + } + + return nil +} diff --git a/ui/models/channels.go b/ui/models/channels.go index fac7d07..130af71 100644 --- a/ui/models/channels.go +++ b/ui/models/channels.go @@ -1,15 +1,20 @@ package models import ( + "sort" "sync" "github.com/edouardparis/lntop/network/models" ) +type ChannelsSort func(*models.Channel, *models.Channel) bool + type Channels struct { - index map[string]*models.Channel - list []*models.Channel - mu sync.RWMutex + current *models.Channel + index map[string]*models.Channel + list []*models.Channel + sort ChannelsSort + mu sync.RWMutex } func (c *Channels) List() []*models.Channel { @@ -20,6 +25,30 @@ func (c *Channels) Len() int { return len(c.list) } +func (c *Channels) Swap(i, j int) { + c.list[i], c.list[j] = c.list[j], c.list[i] +} + +func (c *Channels) Less(i, j int) bool { + return c.sort(c.list[i], c.list[j]) +} + +func (c *Channels) Sort(s ChannelsSort) { + if s == nil { + return + } + c.sort = s + sort.Sort(c) +} + +func (c *Channels) Current() *models.Channel { + return c.current +} + +func (c *Channels) SetCurrent(index int) { + c.current = c.Get(index) +} + func (c *Channels) Get(index int) *models.Channel { if index < 0 || index > len(c.list)-1 { return nil @@ -54,6 +83,9 @@ func (c *Channels) Update(newChannel *models.Channel) { oldChannel, ok := c.index[newChannel.ChannelPoint] if !ok { c.Add(newChannel) + if c.sort != nil { + sort.Sort(c) + } return } @@ -91,7 +123,3 @@ func NewChannels() *Channels { index: make(map[string]*models.Channel), } } - -type Channel struct { - Item *models.Channel -} diff --git a/ui/models/models.go b/ui/models/models.go index 4b17e1d..ca7af8a 100644 --- a/ui/models/models.go +++ b/ui/models/models.go @@ -15,9 +15,9 @@ type Models struct { network *network.Network Info *Info Channels *Channels - CurrentChannel *Channel WalletBalance *WalletBalance ChannelsBalance *ChannelsBalance + Transactions *Transactions } func New(app *app.App) *Models { @@ -28,7 +28,7 @@ func New(app *app.App) *Models { Channels: NewChannels(), WalletBalance: &WalletBalance{}, ChannelsBalance: &ChannelsBalance{}, - CurrentChannel: &Channel{}, + Transactions: &Transactions{}, } } @@ -78,15 +78,6 @@ func (m *Models) RefreshChannels(ctx context.Context) error { return nil } -func (m *Models) SetCurrentChannel(ctx context.Context, index int) error { - channel := m.Channels.Get(index) - if channel == nil { - return nil - } - *m.CurrentChannel = Channel{Item: channel} - return nil -} - type WalletBalance struct { *models.WalletBalance } diff --git a/ui/models/sort.go b/ui/models/sort.go new file mode 100644 index 0000000..54c4f7d --- /dev/null +++ b/ui/models/sort.go @@ -0,0 +1,47 @@ +package models + +import "time" + +type Order int + +const ( + Asc Order = iota + Desc +) + +func IntSort(a, b int, o Order) bool { + if o == Asc { + return a < b + } + return a > b +} + +func Int32Sort(a, b int32, o Order) bool { + if o == Asc { + return a < b + } + return a > b +} + +func Int64Sort(a, b int64, o Order) bool { + if o == Asc { + return a < b + } + return a > b +} + +func DateSort(a, b *time.Time, o Order) bool { + if o == Desc { + if a == nil || b == nil { + return b == nil + } + + return a.After(*b) + } + + if a == nil || b == nil { + return a == nil + } + + return a.Before(*b) +} diff --git a/ui/models/transactions.go b/ui/models/transactions.go new file mode 100644 index 0000000..687d920 --- /dev/null +++ b/ui/models/transactions.go @@ -0,0 +1,119 @@ +package models + +import ( + "context" + "sort" + "sync" + + "github.com/edouardparis/lntop/network/models" +) + +type TransactionsSort func(*models.Transaction, *models.Transaction) bool + +type Transactions struct { + current *models.Transaction + list []*models.Transaction + sort TransactionsSort + mu sync.RWMutex +} + +func (t *Transactions) Current() *models.Transaction { + return t.current +} + +func (t *Transactions) SetCurrent(index int) { + t.current = t.Get(index) +} + +func (t *Transactions) List() []*models.Transaction { + return t.list +} + +func (t *Transactions) Len() int { + return len(t.list) +} + +func (t *Transactions) Swap(i, j int) { + t.list[i], t.list[j] = t.list[j], t.list[i] +} + +func (t *Transactions) Less(i, j int) bool { + return t.sort(t.list[i], t.list[j]) +} + +func (t *Transactions) Sort(s TransactionsSort) { + if s == nil { + return + } + t.sort = s + sort.Sort(t) +} + +func (t *Transactions) Get(index int) *models.Transaction { + if index < 0 || index > len(t.list)-1 { + return nil + } + + return t.list[index] +} + +func (t *Transactions) Contains(tx *models.Transaction) bool { + if tx == nil { + return false + } + for i := range t.list { + if t.list[i].TxHash == tx.TxHash { + return true + } + } + return false +} + +func (t *Transactions) Add(tx *models.Transaction) { + t.mu.Lock() + defer t.mu.Unlock() + if t.Contains(tx) { + return + } + t.list = append(t.list, tx) + if t.sort != nil { + sort.Sort(t) + } +} + +func (t *Transactions) Update(tx *models.Transaction) { + if tx == nil { + return + } + if !t.Contains(tx) { + t.Add(tx) + return + } + + t.mu.Lock() + defer t.mu.Unlock() + + for i := range t.list { + if t.list[i].TxHash == tx.TxHash { + t.list[i].NumConfirmations = tx.NumConfirmations + t.list[i].BlockHeight = tx.BlockHeight + } + } + + if t.sort != nil { + sort.Sort(t) + } +} + +func (m *Models) RefreshTransactions(ctx context.Context) error { + transactions, err := m.network.GetTransactions(ctx) + if err != nil { + return err + } + + for i := range transactions { + m.Transactions.Update(transactions[i]) + } + + return nil +} diff --git a/ui/ui.go b/ui/ui.go index 281181f..f7ee586 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -4,6 +4,7 @@ import ( "context" "github.com/jroimartin/gocui" + "github.com/pkg/errors" "github.com/edouardparis/lntop/app" "github.com/edouardparis/lntop/events" @@ -25,7 +26,7 @@ func Run(ctx context.Context, app *app.App, sub chan *events.Event) error { g.SetManagerFunc(ctrl.layout) - err = ctrl.setKeyBinding(g) + err = setKeyBinding(ctrl, g) if err != nil { return err } @@ -35,5 +36,5 @@ func Run(ctx context.Context, app *app.App, sub chan *events.Event) error { err = g.MainLoop() close(sub) - return err + return errors.WithStack(err) } diff --git a/ui/views/channel.go b/ui/views/channel.go index 09755c5..2afb696 100644 --- a/ui/views/channel.go +++ b/ui/views/channel.go @@ -18,8 +18,8 @@ const ( ) type Channel struct { - view *gocui.View - channel *models.Channel + view *gocui.View + channels *models.Channels } func (c Channel) Name() string { @@ -27,28 +27,32 @@ func (c Channel) Name() string { } func (c Channel) Empty() bool { - return c.channel == nil + return c.channels == nil } -func (c *Channel) Wrap(v *gocui.View) view { +func (c *Channel) Wrap(v *gocui.View) View { c.view = v return c } -func (c *Channel) CursorDown() error { - return cursorDown(c.view, 1) +func (c Channel) Origin() (int, int) { + return c.view.Origin() } -func (c *Channel) CursorUp() error { - return cursorUp(c.view, 1) +func (c Channel) Cursor() (int, int) { + return c.view.Cursor() } -func (c *Channel) CursorRight() error { - return cursorRight(c.view, 1) +func (c Channel) Speed() (int, int, int, int) { + return 1, 1, 1, 1 } -func (c *Channel) CursorLeft() error { - return cursorLeft(c.view, 1) +func (c *Channel) SetCursor(x, y int) error { + return c.view.SetCursor(x, y) +} + +func (c *Channel) SetOrigin(x, y int) error { + return c.view.SetOrigin(x, y) } func (c *Channel) Set(g *gocui.Gui, x0, y0, x1, y1 int) error { @@ -84,10 +88,12 @@ func (c *Channel) Set(g *gocui.Gui, x0, y0, x1, y1 int) error { footer.BgColor = gocui.ColorCyan footer.FgColor = gocui.ColorBlack footer.Clear() - fmt.Fprintln(footer, fmt.Sprintf("%s%s %s%s %s%s", - color.BlackBg("F1"), "Help", - color.BlackBg("Enter"), "Channels", - color.BlackBg("F10"), "Quit", + blackBg := color.Black(color.Background) + fmt.Fprintln(footer, fmt.Sprintf("%s%s %s%s %s%s %s%s", + blackBg("F1"), "Help", + blackBg("F2"), "Menu", + blackBg("Enter"), "Channels", + blackBg("F10"), "Quit", )) return nil } @@ -110,67 +116,70 @@ func (c *Channel) display() { p := message.NewPrinter(language.English) v := c.view v.Clear() - channel := c.channel.Item - fmt.Fprintln(v, color.Green(" [ Channel ]")) + channel := c.channels.Current() + green := color.Green() + cyan := color.Cyan() + red := color.Red() + fmt.Fprintln(v, green(" [ Channel ]")) fmt.Fprintln(v, fmt.Sprintf("%s %s", - color.Cyan(" Status:"), status(channel))) + cyan(" Status:"), status(channel))) fmt.Fprintln(v, fmt.Sprintf("%s %d", - color.Cyan(" ID:"), channel.ID)) + cyan(" ID:"), channel.ID)) fmt.Fprintln(v, p.Sprintf("%s %d", - color.Cyan(" Capacity:"), channel.Capacity)) + cyan(" Capacity:"), channel.Capacity)) fmt.Fprintln(v, p.Sprintf("%s %d", - color.Cyan(" Local Balance:"), channel.LocalBalance)) + cyan(" Local Balance:"), channel.LocalBalance)) fmt.Fprintln(v, p.Sprintf("%s %d", - color.Cyan(" Remote Balance:"), channel.RemoteBalance)) + cyan(" Remote Balance:"), channel.RemoteBalance)) fmt.Fprintln(v, fmt.Sprintf("%s %s", - color.Cyan(" Channel Point:"), channel.ChannelPoint)) + cyan(" Channel Point:"), channel.ChannelPoint)) fmt.Fprintln(v, "") - fmt.Fprintln(v, color.Green(" [ Node ]")) - fmt.Fprintln(v, fmt.Sprintf("%s %s", - color.Cyan(" Alias:"), alias(channel))) + fmt.Fprintln(v, green(" [ Node ]")) fmt.Fprintln(v, fmt.Sprintf("%s %s", - color.Cyan(" PubKey:"), channel.RemotePubKey)) + cyan(" PubKey:"), channel.RemotePubKey)) if channel.Node != nil { + fmt.Fprintln(v, fmt.Sprintf("%s %s", + cyan(" Alias:"), channel.Node.Alias)) fmt.Fprintln(v, p.Sprintf("%s %d", - color.Cyan(" Total Capacity:"), channel.Node.TotalCapacity)) + cyan(" Total Capacity:"), channel.Node.TotalCapacity)) fmt.Fprintln(v, p.Sprintf("%s %d", - color.Cyan(" Total Channels:"), channel.Node.NumChannels)) + cyan(" Total Channels:"), channel.Node.NumChannels)) } if channel.Policy1 != nil { fmt.Fprintln(v, "") - fmt.Fprintln(v, color.Green(" [ Forward Policy Node1 ]")) + fmt.Fprintln(v, green(" [ Forward Policy Node1 ]")) if channel.Policy1.Disabled { - fmt.Fprintln(v, color.Red("disabled")) + fmt.Fprintln(v, red("disabled")) } fmt.Fprintln(v, p.Sprintf("%s %d", - color.Cyan(" Time lock delta:"), channel.Policy1.TimeLockDelta)) + cyan(" Time lock delta:"), channel.Policy1.TimeLockDelta)) fmt.Fprintln(v, p.Sprintf("%s %d", - color.Cyan(" Min htlc:"), channel.Policy1.MinHtlc)) + cyan(" Min htlc:"), channel.Policy1.MinHtlc)) fmt.Fprintln(v, p.Sprintf("%s %d", - color.Cyan(" Fee base msat:"), channel.Policy1.FeeBaseMsat)) + cyan(" Fee base msat:"), channel.Policy1.FeeBaseMsat)) fmt.Fprintln(v, p.Sprintf("%s %d", - color.Cyan("Fee rate milli msat:"), channel.Policy1.FeeRateMilliMsat)) + cyan("Fee rate milli msat:"), channel.Policy1.FeeRateMilliMsat)) } if channel.Policy2 != nil { fmt.Fprintln(v, "") - fmt.Fprintln(v, color.Green(" [ Forward Policy Node 2 ]")) + fmt.Fprintln(v, green(" [ Forward Policy Node 2 ]")) if channel.Policy2.Disabled { - fmt.Fprintln(v, color.Red("disabled")) + fmt.Fprintln(v, red("disabled")) } fmt.Fprintln(v, p.Sprintf("%s %d", - color.Cyan(" Time lock delta:"), channel.Policy2.TimeLockDelta)) + cyan(" Time lock delta:"), channel.Policy2.TimeLockDelta)) fmt.Fprintln(v, p.Sprintf("%s %d", - color.Cyan(" Min htlc:"), channel.Policy2.MinHtlc)) + cyan(" Min htlc:"), channel.Policy2.MinHtlc)) fmt.Fprintln(v, p.Sprintf("%s %d", - color.Cyan(" Fee base msat:"), channel.Policy2.FeeBaseMsat)) + cyan(" Fee base msat:"), channel.Policy2.FeeBaseMsat)) fmt.Fprintln(v, p.Sprintf("%s %d", - color.Cyan("Fee rate milli msat:"), channel.Policy2.FeeRateMilliMsat)) + cyan("Fee rate milli msat:"), channel.Policy2.FeeRateMilliMsat)) } } -func NewChannel(channel *models.Channel) *Channel { - return &Channel{channel: channel} +func NewChannel(channels *models.Channels) *Channel { + return &Channel{channels: channels} } diff --git a/ui/views/channels.go b/ui/views/channels.go index 5cfa65d..2094ccb 100644 --- a/ui/views/channels.go +++ b/ui/views/channels.go @@ -8,6 +8,7 @@ import ( "golang.org/x/text/language" "golang.org/x/text/message" + "github.com/edouardparis/lntop/config" netmodels "github.com/edouardparis/lntop/network/models" "github.com/edouardparis/lntop/ui/color" "github.com/edouardparis/lntop/ui/models" @@ -19,81 +20,190 @@ const ( CHANNELS_FOOTER = "channels_footer" ) +var DefaultChannelsColumns = []string{ + "STATUS", + "ALIAS", + "GAUGE", + "LOCAL", + "CAP", + "SENT", + "RECEIVED", + "HTLC", + "UNSETTLED", + "CFEE", + "LAST UPDATE", + "PRIVATE", + "ID", +} + type Channels struct { - columns *gocui.View - view *gocui.View - channels *models.Channels + cfg *config.View + + columns []channelsColumn + + columnsView *gocui.View + view *gocui.View + channels *models.Channels + + ox, oy int + cx, cy int } -func (c Channels) Index() int { - _, oy := c.view.Origin() - _, cy := c.view.Cursor() - return cy + oy +type channelsColumn struct { + name string + width int + sorted bool + sort func(models.Order) models.ChannelsSort + display func(*netmodels.Channel, ...color.Option) string } func (c Channels) Name() string { return CHANNELS } -func (c *Channels) Wrap(v *gocui.View) view { +func (c *Channels) Wrap(v *gocui.View) View { c.view = v return c } -func (c *Channels) CursorDown() error { - return cursorDown(c.view, 1) +func (c Channels) currentColumnIndex() int { + x := c.ox + c.cx + index := 0 + sum := 0 + for i := range c.columns { + sum += c.columns[i].width + 1 + if x < sum { + return index + } + index++ + } + return index } -func (c *Channels) CursorUp() error { - return cursorUp(c.view, 1) +func (c Channels) Sort(column string, order models.Order) { + if column == "" { + index := c.currentColumnIndex() + col := c.columns[index] + if col.sort == nil { + return + } + + c.channels.Sort(col.sort(order)) + for i := range c.columns { + c.columns[i].sorted = (i == index) + } + } +} + +func (c Channels) Origin() (int, int) { + return c.ox, c.oy +} + +func (c Channels) Cursor() (int, int) { + return c.cx, c.cy } -func (c *Channels) CursorRight() error { - err := cursorRight(c.columns, 2) +func (c *Channels) SetCursor(cx, cy int) error { + err := c.columnsView.SetCursor(cx, 0) + if err != nil { + return err + } + + err = c.view.SetCursor(cx, cy) if err != nil { return err } - return cursorRight(c.view, 2) + c.cx, c.cy = cx, cy + return nil +} + +func (c *Channels) SetOrigin(ox, oy int) error { + err := c.columnsView.SetOrigin(ox, 0) + if err != nil { + return err + } + err = c.view.SetOrigin(ox, oy) + if err != nil { + return err + } + + c.ox, c.oy = ox, oy + return nil +} + +func (c *Channels) Speed() (int, int, int, int) { + current := c.currentColumnIndex() + if current > len(c.columns)-1 { + return 0, c.columns[current-1].width + 1, 1, 1 + } + if current == 0 { + return c.columns[0].width + 1, 0, 1, 1 + } + return c.columns[current].width + 1, + c.columns[current-1].width + 1, + 1, 1 } -func (c *Channels) CursorLeft() error { - err := cursorLeft(c.columns, 2) +func (c Channels) Index() int { + _, oy := c.Origin() + _, cy := c.Cursor() + return cy + oy +} + +func (c Channels) Delete(g *gocui.Gui) error { + err := g.DeleteView(CHANNELS_COLUMNS) + if err != nil { + return err + } + + err = g.DeleteView(CHANNELS) if err != nil { return err } - return cursorLeft(c.view, 2) + return g.DeleteView(CHANNELS_FOOTER) } func (c *Channels) Set(g *gocui.Gui, x0, y0, x1, y1 int) error { var err error - c.columns, err = g.SetView(CHANNELS_COLUMNS, x0-1, y0, x1+2, y0+2) + setCursor := false + c.columnsView, err = g.SetView(CHANNELS_COLUMNS, x0-1, y0, x1+2, y0+2) if err != nil { if err != gocui.ErrUnknownView { return err } + setCursor = true } - c.columns.Frame = false - c.columns.BgColor = gocui.ColorGreen - c.columns.FgColor = gocui.ColorBlack - displayChannelsColumns(c.columns) + c.columnsView.Frame = false + c.columnsView.BgColor = gocui.ColorGreen + c.columnsView.FgColor = gocui.ColorBlack c.view, err = g.SetView(CHANNELS, x0-1, y0+1, x1+2, y1-1) if err != nil { if err != gocui.ErrUnknownView { return err } - _, err = g.SetCurrentView(CHANNELS) - if err != nil { - return err - } + setCursor = true } c.view.Frame = false c.view.Autoscroll = false c.view.SelBgColor = gocui.ColorCyan c.view.SelFgColor = gocui.ColorBlack c.view.Highlight = true + if setCursor { + ox, oy := c.Origin() + err := c.SetOrigin(ox, oy) + if err != nil { + return err + } + + cx, cy := c.Cursor() + err = c.SetCursor(cx, cy) + if err != nil { + return err + } + } c.display() @@ -107,120 +217,278 @@ func (c *Channels) Set(g *gocui.Gui, x0, y0, x1, y1 int) error { footer.BgColor = gocui.ColorCyan footer.FgColor = gocui.ColorBlack footer.Clear() - fmt.Fprintln(footer, fmt.Sprintf("%s%s %s%s %s%s", - color.BlackBg("F1"), "Help", - color.BlackBg("Enter"), "Channel", - color.BlackBg("F10"), "Quit", + blackBg := color.Black(color.Background) + fmt.Fprintln(footer, fmt.Sprintf("%s%s %s%s %s%s %s%s", + blackBg("F1"), "Help", + blackBg("F2"), "Menu", + blackBg("Enter"), "Channel", + blackBg("F10"), "Quit", )) return nil } -func displayChannelsColumns(v *gocui.View) { - v.Clear() - fmt.Fprintln(v, fmt.Sprintf("%-13s %-25s %-21s %12s %12s %5s %-10s %-6s %-15s %s %-19s", - "STATUS", - "ALIAS", - "GAUGE", - "LOCAL", - "CAP", - "HTLC", - "UNSETTLED", - "CFEE", - "Last Update", - "PRIVATE", - "ID", - )) -} - func (c *Channels) display() { - p := message.NewPrinter(language.English) - c.view.Clear() - for _, item := range c.channels.List() { - line := fmt.Sprintf("%s %-25s %s %s %s %5d %s %s %s %s %19s %500s", - status(item), - alias(item), - gauge(item), - color.Cyan(p.Sprintf("%12d", item.LocalBalance)), - p.Sprintf("%12d", item.Capacity), - len(item.PendingHTLC), - color.Yellow(p.Sprintf("%10d", item.UnsettledBalance)), - p.Sprintf("%6d", item.CommitFee), - lastUpdate(item), - channelPrivate(item), - channelID(item), - "", - ) - fmt.Fprintln(c.view, line) + c.columnsView.Clear() + var buffer bytes.Buffer + currentColumnIndex := c.currentColumnIndex() + for i := range c.columns { + if currentColumnIndex == i { + buffer.WriteString(color.Cyan(color.Background)(c.columns[i].name)) + buffer.WriteString(" ") + continue + } else if c.columns[i].sorted { + buffer.WriteString(color.Magenta(color.Background)(c.columns[i].name)) + buffer.WriteString(" ") + continue + } + buffer.WriteString(c.columns[i].name) + buffer.WriteString(" ") } -} + fmt.Fprintln(c.columnsView, buffer.String()) -func channelPrivate(c *netmodels.Channel) string { - if c.Private { - return color.Red("private") + c.view.Clear() + for _, item := range c.channels.List() { + var buffer bytes.Buffer + for i := range c.columns { + var opt color.Option + if currentColumnIndex == i { + opt = color.Bold + } + buffer.WriteString(c.columns[i].display(item, opt)) + buffer.WriteString(" ") + } + fmt.Fprintln(c.view, buffer.String()) } - - return color.Green("public ") } -func channelID(c *netmodels.Channel) string { - if c.ID == 0 { - return "" +func NewChannels(cfg *config.View, chans *models.Channels) *Channels { + channels := &Channels{ + cfg: cfg, + channels: chans, } - return fmt.Sprintf("%d", c.ID) -} + printer := message.NewPrinter(language.English) -func alias(c *netmodels.Channel) string { - if c.Node == nil || c.Node.Alias == "" { - return c.RemotePubKey[:24] - } else if len(c.Node.Alias) > 25 { - return c.Node.Alias[:24] + columns := DefaultChannelsColumns + if cfg != nil && len(cfg.Columns) != 0 { + columns = cfg.Columns } - return c.Node.Alias -} + channels.columns = make([]channelsColumn, len(columns)) -func lastUpdate(c *netmodels.Channel) string { - if c.LastUpdate != nil { - return color.Cyan( - fmt.Sprintf("%15s", c.LastUpdate.Format("15:04:05 Jan _2")), - ) + for i := range columns { + switch columns[i] { + case "STATUS": + channels.columns[i] = channelsColumn{ + width: 13, + name: fmt.Sprintf("%-13s", columns[i]), + display: status, + } + case "ALIAS": + channels.columns[i] = channelsColumn{ + width: 25, + name: fmt.Sprintf("%-25s", columns[i]), + display: func(c *netmodels.Channel, opts ...color.Option) string { + var alias string + if c.Node == nil || c.Node.Alias == "" { + alias = c.RemotePubKey[:24] + } else if len(c.Node.Alias) > 25 { + alias = c.Node.Alias[:24] + } else { + alias = c.Node.Alias + } + return color.White(opts...)(fmt.Sprintf("%-25s", alias)) + }, + } + case "GAUGE": + channels.columns[i] = channelsColumn{ + width: 21, + name: fmt.Sprintf("%-21s", columns[i]), + sort: func(order models.Order) models.ChannelsSort { + return func(c1, c2 *netmodels.Channel) bool { + return models.Int64Sort( + c1.LocalBalance*100/c1.Capacity, + c2.LocalBalance*100/c2.Capacity, + order) + } + }, + display: func(c *netmodels.Channel, opts ...color.Option) string { + index := int(c.LocalBalance * int64(15) / c.Capacity) + var buffer bytes.Buffer + cyan := color.Cyan(opts...) + white := color.White(opts...) + for i := 0; i < 15; i++ { + if i < index { + buffer.WriteString(cyan("|")) + continue + } + buffer.WriteString(" ") + } + return fmt.Sprintf("%s%s%s", + white("["), + buffer.String(), + white(fmt.Sprintf("] %2d%%", c.LocalBalance*100/c.Capacity))) + }, + } + case "LOCAL": + channels.columns[i] = channelsColumn{ + width: 12, + name: fmt.Sprintf("%12s", columns[i]), + sort: func(order models.Order) models.ChannelsSort { + return func(c1, c2 *netmodels.Channel) bool { + return models.Int64Sort(c1.LocalBalance, c2.LocalBalance, order) + } + }, + display: func(c *netmodels.Channel, opts ...color.Option) string { + return color.Cyan(opts...)(printer.Sprintf("%12d", c.LocalBalance)) + }, + } + case "CAP": + channels.columns[i] = channelsColumn{ + width: 12, + name: fmt.Sprintf("%12s", columns[i]), + sort: func(order models.Order) models.ChannelsSort { + return func(c1, c2 *netmodels.Channel) bool { + return models.Int64Sort(c1.Capacity, c2.Capacity, order) + } + }, + display: func(c *netmodels.Channel, opts ...color.Option) string { + return color.White(opts...)(printer.Sprintf("%12d", c.Capacity)) + }, + } + case "SENT": + channels.columns[i] = channelsColumn{ + width: 12, + name: fmt.Sprintf("%12s", columns[i]), + sort: func(order models.Order) models.ChannelsSort { + return func(c1, c2 *netmodels.Channel) bool { + return models.Int64Sort(c1.TotalAmountSent, c2.TotalAmountSent, order) + } + }, + display: func(c *netmodels.Channel, opts ...color.Option) string { + return color.Cyan(opts...)(printer.Sprintf("%12d", c.TotalAmountSent)) + }, + } + case "RECEIVED": + channels.columns[i] = channelsColumn{ + width: 12, + name: fmt.Sprintf("%12s", columns[i]), + sort: func(order models.Order) models.ChannelsSort { + return func(c1, c2 *netmodels.Channel) bool { + return models.Int64Sort(c1.TotalAmountReceived, c2.TotalAmountReceived, order) + } + }, + display: func(c *netmodels.Channel, opts ...color.Option) string { + return color.Cyan(opts...)(printer.Sprintf("%12d", c.TotalAmountReceived)) + }, + } + case "HTLC": + channels.columns[i] = channelsColumn{ + width: 5, + name: fmt.Sprintf("%5s", columns[i]), + sort: func(order models.Order) models.ChannelsSort { + return func(c1, c2 *netmodels.Channel) bool { + return models.IntSort(len(c1.PendingHTLC), len(c2.PendingHTLC), order) + } + }, + display: func(c *netmodels.Channel, opts ...color.Option) string { + return color.Yellow(opts...)(fmt.Sprintf("%5d", len(c.PendingHTLC))) + }, + } + case "UNSETTLED": + channels.columns[i] = channelsColumn{ + width: 10, + name: fmt.Sprintf("%-10s", columns[i]), + sort: func(order models.Order) models.ChannelsSort { + return func(c1, c2 *netmodels.Channel) bool { + return models.Int64Sort(c1.UnsettledBalance, c2.UnsettledBalance, order) + } + }, + display: func(c *netmodels.Channel, opts ...color.Option) string { + return color.Yellow(opts...)(printer.Sprintf("%10d", c.UnsettledBalance)) + }, + } + case "CFEE": + channels.columns[i] = channelsColumn{ + width: 6, + name: fmt.Sprintf("%-6s", columns[i]), + sort: func(order models.Order) models.ChannelsSort { + return func(c1, c2 *netmodels.Channel) bool { + return models.Int64Sort(c1.CommitFee, c2.CommitFee, order) + } + }, + display: func(c *netmodels.Channel, opts ...color.Option) string { + return color.White(opts...)(printer.Sprintf("%6d", c.CommitFee)) + }, + } + case "LAST UPDATE": + channels.columns[i] = channelsColumn{ + width: 15, + name: fmt.Sprintf("%-15s", columns[i]), + sort: func(order models.Order) models.ChannelsSort { + return func(c1, c2 *netmodels.Channel) bool { + return models.DateSort(c1.LastUpdate, c2.LastUpdate, order) + } + }, + display: func(c *netmodels.Channel, opts ...color.Option) string { + if c.LastUpdate != nil { + return color.Cyan(opts...)( + fmt.Sprintf("%15s", c.LastUpdate.Format("15:04:05 Jan _2")), + ) + } + return fmt.Sprintf("%15s", "") + }, + } + case "PRIVATE": + channels.columns[i] = channelsColumn{ + width: 7, + name: fmt.Sprintf("%-7s", columns[i]), + display: func(c *netmodels.Channel, opts ...color.Option) string { + if c.Private { + return color.Red(opts...)("private") + } + return color.Green(opts...)("public ") + }, + } + case "ID": + channels.columns[i] = channelsColumn{ + width: 19, + name: fmt.Sprintf("%-19s", columns[i]), + display: func(c *netmodels.Channel, opts ...color.Option) string { + if c.ID == 0 { + return fmt.Sprintf("%-19s", "") + } + return color.White(opts...)(fmt.Sprintf("%d", c.ID)) + }, + } + default: + channels.columns[i] = channelsColumn{ + width: 21, + name: fmt.Sprintf("%-21s", columns[i]), + display: func(c *netmodels.Channel, opts ...color.Option) string { + return "column does not exist" + }, + } + } } - return fmt.Sprintf("%15s", "") + return channels } -func status(c *netmodels.Channel) string { +func status(c *netmodels.Channel, opts ...color.Option) string { switch c.Status { case netmodels.ChannelActive: - return color.Green(fmt.Sprintf("%-13s", "active")) + return color.Green(opts...)(fmt.Sprintf("%-13s", "active")) case netmodels.ChannelInactive: - return color.Red(fmt.Sprintf("%-13s", "inactive")) + return color.Red(opts...)(fmt.Sprintf("%-13s", "inactive")) case netmodels.ChannelOpening: - return color.Yellow(fmt.Sprintf("%-13s", "opening")) + return color.Yellow(opts...)(fmt.Sprintf("%-13s", "opening")) case netmodels.ChannelClosing: - return color.Yellow(fmt.Sprintf("%-13s", "closing")) + return color.Yellow(opts...)(fmt.Sprintf("%-13s", "closing")) case netmodels.ChannelForceClosing: - return color.Yellow(fmt.Sprintf("%-13s", "force closing")) + return color.Yellow(opts...)(fmt.Sprintf("%-13s", "force closing")) case netmodels.ChannelWaitingClose: - return color.Yellow(fmt.Sprintf("%-13s", "waiting close")) + return color.Yellow(opts...)(fmt.Sprintf("%-13s", "waiting close")) } return "" } - -func gauge(c *netmodels.Channel) string { - index := int(c.LocalBalance * int64(15) / c.Capacity) - var buffer bytes.Buffer - for i := 0; i < 15; i++ { - if i < index { - buffer.WriteString(color.Cyan("|")) - continue - } - buffer.WriteString(" ") - } - return fmt.Sprintf("[%s] %2d%%", buffer.String(), c.LocalBalance*100/c.Capacity) -} - -func NewChannels(channels *models.Channels) *Channels { - return &Channels{channels: channels} -} diff --git a/ui/views/cursor.go b/ui/views/cursor.go deleted file mode 100644 index 634fe62..0000000 --- a/ui/views/cursor.go +++ /dev/null @@ -1,67 +0,0 @@ -package views - -import "github.com/jroimartin/gocui" - -func cursorDown(v *gocui.View, speed int) error { - if v == nil { - return nil - } - cx, cy := v.Cursor() - err := v.SetCursor(cx, cy+speed) - if err != nil { - ox, oy := v.Origin() - err := v.SetOrigin(ox, oy+speed) - if err != nil { - return err - } - } - return nil -} - -func cursorUp(v *gocui.View, speed int) error { - if v == nil { - return nil - } - ox, oy := v.Origin() - cx, cy := v.Cursor() - err := v.SetCursor(cx, cy-speed) - if err != nil && oy >= speed { - err := v.SetOrigin(ox, oy-speed) - if err != nil { - return err - } - } - return nil -} - -func cursorRight(v *gocui.View, speed int) error { - if v == nil { - return nil - } - cx, cy := v.Cursor() - err := v.SetCursor(cx+speed, cy) - if err != nil { - ox, oy := v.Origin() - err := v.SetOrigin(ox+speed, oy) - if err != nil { - return err - } - } - return nil -} - -func cursorLeft(v *gocui.View, speed int) error { - if v == nil { - return nil - } - ox, oy := v.Origin() - cx, cy := v.Cursor() - err := v.SetCursor(cx-speed, cy) - if err != nil && ox >= speed { - err := v.SetOrigin(ox-speed, oy) - if err != nil { - return err - } - } - return nil -} diff --git a/ui/views/header.go b/ui/views/header.go index 382b5d3..e33d3aa 100644 --- a/ui/views/header.go +++ b/ui/views/header.go @@ -44,19 +44,20 @@ func (h *Header) Set(g *gocui.Gui, x0, y0, x1, y1 int) error { network = "mainnet" } - sync := color.Yellow("[syncing]") + sync := color.Yellow()("[syncing]") if h.Info.Synced { - sync = color.Green("[synced]") + sync = color.Green()("[synced]") } v.Clear() + cyan := color.Cyan() fmt.Fprintln(v, fmt.Sprintf("%s %s %s %s %s %s", - color.CyanBg(h.Info.Alias), - color.Cyan(fmt.Sprintf("%s-v%s", "lnd", version)), + color.Cyan(color.Background)(h.Info.Alias), + cyan(fmt.Sprintf("%s-v%s", "lnd", version)), fmt.Sprintf("%s %s", chain, network), sync, - fmt.Sprintf("%s %d", color.Cyan("height:"), h.Info.BlockHeight), - fmt.Sprintf("%s %d", color.Cyan("peers:"), h.Info.NumPeers), + fmt.Sprintf("%s %d", cyan("height:"), h.Info.BlockHeight), + fmt.Sprintf("%s %d", cyan("peers:"), h.Info.NumPeers), )) return nil } diff --git a/ui/views/help.go b/ui/views/help.go index cd5be00..f74fa32 100644 --- a/ui/views/help.go +++ b/ui/views/help.go @@ -9,7 +9,7 @@ import ( ) const ( - version = "v0.0.3" + version = "v0.1.0" HELP = "help" ) @@ -21,25 +21,33 @@ func (h Help) Name() string { return HELP } -func (h *Help) Wrap(v *gocui.View) view { +func (h *Help) Wrap(v *gocui.View) View { h.view = v return h } -func (h *Help) CursorDown() error { - return cursorDown(h.view, 1) +func (h Help) Delete(g *gocui.Gui) error { + return g.DeleteView(HELP) } -func (h *Help) CursorUp() error { - return cursorUp(h.view, 1) +func (h Help) Origin() (int, int) { + return h.view.Origin() } -func (h *Help) CursorRight() error { - return cursorRight(h.view, 1) +func (h Help) Cursor() (int, int) { + return h.view.Cursor() } -func (h *Help) CursorLeft() error { - return cursorLeft(h.view, 1) +func (h Help) Speed() (int, int, int, int) { + return 1, 1, 1, 1 +} + +func (h *Help) SetCursor(x, y int) error { + return h.view.SetCursor(x, y) +} + +func (h *Help) SetOrigin(x, y int) error { + return h.view.SetOrigin(x, y) } func (h Help) Set(g *gocui.Gui, x0, y0, x1, y1 int) error { @@ -51,13 +59,20 @@ func (h Help) Set(g *gocui.Gui, x0, y0, x1, y1 int) error { } } h.view.Frame = false + cyan := color.Cyan() fmt.Fprintln(h.view, fmt.Sprintf("lntop %s - (C) 2019 Edouard Paris", version)) fmt.Fprintln(h.view, "Released under the MIT License") fmt.Fprintln(h.view, "") fmt.Fprintln(h.view, fmt.Sprintf("%6s %s", - color.Cyan("F1 h:"), "show/close this help screen")) + cyan("F1 h:"), "show/close this help screen")) + fmt.Fprintln(h.view, fmt.Sprintf("%6s %s", + cyan("F2 m:"), "show/close the menu sidebar")) + fmt.Fprintln(h.view, fmt.Sprintf("%6s %s", + cyan("F10 q:"), "quit")) + + fmt.Fprintln(h.view, "") fmt.Fprintln(h.view, fmt.Sprintf("%6s %s", - color.Cyan("F10 q:"), "quit")) + cyan(" a d:"), "apply asc/desc order to the rows according to the selected column value")) _, err = g.SetCurrentView(HELP) return err } diff --git a/ui/views/menu.go b/ui/views/menu.go new file mode 100644 index 0000000..ffd9aee --- /dev/null +++ b/ui/views/menu.go @@ -0,0 +1,162 @@ +package views + +import ( + "fmt" + + "github.com/edouardparis/lntop/ui/color" + "github.com/jroimartin/gocui" +) + +const ( + MENU = "menu" + MENU_HEADER = "menu_header" + MENU_FOOTER = "menu_footer" +) + +var menu = []string{ + "CHANNEL", + "TRANSAC", +} + +type Menu struct { + view *gocui.View + + cy, oy int +} + +func (h Menu) Name() string { + return MENU +} + +func (h *Menu) Wrap(v *gocui.View) View { + h.view = v + return h +} + +func (h Menu) Origin() (int, int) { + return 0, h.oy +} + +func (h Menu) Cursor() (int, int) { + return 0, h.cy +} + +func (h Menu) Speed() (int, int, int, int) { + return 0, 0, 1, 1 +} + +func (h *Menu) SetCursor(x, y int) error { + err := h.view.SetCursor(x, y) + if err != nil { + return err + } + h.cy = y + return nil +} + +func (h *Menu) SetOrigin(x, y int) error { + err := h.view.SetOrigin(x, y) + if err != nil { + return err + } + + h.oy = y + return nil +} + +func (h Menu) Current() string { + _, y := h.view.Cursor() + if y < len(menu) { + switch menu[y] { + case "CHANNEL": + return CHANNELS + case "TRANSAC": + return TRANSACTIONS + } + } + return "" +} + +func (c Menu) Delete(g *gocui.Gui) error { + err := g.DeleteView(MENU_HEADER) + if err != nil { + return err + } + + err = g.DeleteView(MENU_FOOTER) + if err != nil { + return err + } + + return g.DeleteView(MENU) +} + +func (h Menu) Set(g *gocui.Gui, x0, y0, x1, y1 int) error { + setCursor := false + header, err := g.SetView(MENU_HEADER, x0-1, y0, x1, y0+2) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + setCursor = true + } + header.Frame = false + header.BgColor = gocui.ColorGreen + header.FgColor = gocui.ColorBlack + + header.Clear() + fmt.Fprintln(header, " MENU") + + h.view, err = g.SetView(MENU, x0-1, y0+1, x1, y1-2) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + setCursor = true + } + + h.view.Frame = false + h.view.Highlight = true + h.view.SelBgColor = gocui.ColorCyan + h.view.SelFgColor = gocui.ColorBlack + if setCursor { + ox, oy := h.Origin() + err := h.SetOrigin(ox, oy) + if err != nil { + return err + } + + cx, cy := h.Cursor() + err = h.SetCursor(cx, cy) + if err != nil { + return err + } + } + + h.view.Clear() + for i := range menu { + fmt.Fprintln(h.view, fmt.Sprintf(" %-9s", menu[i])) + } + _, err = g.SetCurrentView(MENU) + if err != nil { + return err + } + + footer, err := g.SetView(MENU_FOOTER, x0-1, y1-2, x1, y1) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + } + footer.Frame = false + footer.BgColor = gocui.ColorCyan + footer.FgColor = gocui.ColorBlack + footer.Clear() + blackBg := color.Black(color.Background) + fmt.Fprintln(footer, fmt.Sprintf("%s%s", + blackBg("F2"), "Close", + )) + return nil +} + +func NewMenu() *Menu { return &Menu{} } diff --git a/ui/views/summary.go b/ui/views/summary.go index 46a87da..a4b7a36 100644 --- a/ui/views/summary.go +++ b/ui/views/summary.go @@ -53,31 +53,35 @@ func (s *Summary) Set(g *gocui.Gui, x0, y0, x1, y1 int) error { func (s *Summary) display() { s.left.Clear() p := message.NewPrinter(language.English) - fmt.Fprintln(s.left, color.Green("[ Channels ]")) + green := color.Green() + yellow := color.Yellow() + cyan := color.Cyan() + red := color.Red() + fmt.Fprintln(s.left, green("[ Channels ]")) fmt.Fprintln(s.left, p.Sprintf("%s %d (%s|%s)", - color.Cyan("balance:"), + cyan("balance:"), s.channelsBalance.Balance+s.channelsBalance.PendingOpenBalance, - color.Green(p.Sprintf("%d", s.channelsBalance.Balance)), - color.Yellow(p.Sprintf("%d", s.channelsBalance.PendingOpenBalance)), + green(p.Sprintf("%d", s.channelsBalance.Balance)), + yellow(p.Sprintf("%d", s.channelsBalance.PendingOpenBalance)), )) fmt.Fprintln(s.left, fmt.Sprintf("%s %d %s %d %s %d %s", - color.Cyan("state :"), - s.info.NumActiveChannels, color.Green("active"), - s.info.NumPendingChannels, color.Yellow("pending"), - s.info.NumInactiveChannels, color.Red("inactive"), + cyan("state :"), + s.info.NumActiveChannels, green("active"), + s.info.NumPendingChannels, yellow("pending"), + s.info.NumInactiveChannels, red("inactive"), )) fmt.Fprintln(s.left, fmt.Sprintf("%s %s", - color.Cyan("gauge :"), + cyan("gauge :"), gaugeTotal(s.channelsBalance.Balance, s.channels.List()), )) s.right.Clear() - fmt.Fprintln(s.right, color.Green("[ Wallet ]")) + fmt.Fprintln(s.right, green("[ Wallet ]")) fmt.Fprintln(s.right, p.Sprintf("%s %d (%s|%s)", - color.Cyan("balance:"), + cyan("balance:"), s.walletBalance.TotalBalance, - color.Green(p.Sprintf("%d", s.walletBalance.ConfirmedBalance)), - color.Yellow(p.Sprintf("%d", s.walletBalance.UnconfirmedBalance)), + green(p.Sprintf("%d", s.walletBalance.ConfirmedBalance)), + yellow(p.Sprintf("%d", s.walletBalance.UnconfirmedBalance)), )) } @@ -93,9 +97,10 @@ func gaugeTotal(balance int64, channels []*netmodels.Channel) string { index := int(balance * int64(20) / capacity) var buffer bytes.Buffer + cyan := color.Cyan() for i := 0; i < 20; i++ { if i < index { - buffer.WriteString(color.Cyan("|")) + buffer.WriteString(cyan("|")) continue } buffer.WriteString(" ") diff --git a/ui/views/transaction.go b/ui/views/transaction.go new file mode 100644 index 0000000..be6e09f --- /dev/null +++ b/ui/views/transaction.go @@ -0,0 +1,148 @@ +package views + +import ( + "fmt" + + "github.com/jroimartin/gocui" + "golang.org/x/text/language" + "golang.org/x/text/message" + + "github.com/edouardparis/lntop/ui/color" + "github.com/edouardparis/lntop/ui/models" +) + +const ( + TRANSACTION = "transaction" + TRANSACTION_HEADER = "transaction_header" + TRANSACTION_FOOTER = "transaction_footer" +) + +type Transaction struct { + view *gocui.View + transactions *models.Transactions +} + +func (c Transaction) Name() string { + return TRANSACTION +} + +func (c Transaction) Empty() bool { + return c.transactions == nil +} + +func (c *Transaction) Wrap(v *gocui.View) View { + c.view = v + return c +} + +func (c Transaction) Origin() (int, int) { + return c.view.Origin() +} + +func (c Transaction) Cursor() (int, int) { + return c.view.Cursor() +} + +func (c Transaction) Speed() (int, int, int, int) { + return 1, 1, 1, 1 +} + +func (c *Transaction) SetCursor(x, y int) error { + return c.view.SetCursor(x, y) +} + +func (c *Transaction) SetOrigin(x, y int) error { + return c.view.SetOrigin(x, y) +} + +func (c *Transaction) Set(g *gocui.Gui, x0, y0, x1, y1 int) error { + header, err := g.SetView(TRANSACTION_HEADER, x0-1, y0, x1+2, y0+2) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + } + header.Frame = false + header.BgColor = gocui.ColorGreen + header.FgColor = gocui.ColorBlack | gocui.AttrBold + header.Clear() + fmt.Fprintln(header, "Transaction") + + v, err := g.SetView(TRANSACTION, x0-1, y0+1, x1+2, y1-1) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + } + v.Frame = false + c.view = v + c.display() + + footer, err := g.SetView(TRANSACTION_FOOTER, x0-1, y1-2, x1, y1) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + } + footer.Frame = false + footer.BgColor = gocui.ColorCyan + footer.FgColor = gocui.ColorBlack + footer.Clear() + blackBg := color.Black(color.Background) + fmt.Fprintln(footer, fmt.Sprintf("%s%s %s%s %s%s %s%s", + blackBg("F1"), "Help", + blackBg("F2"), "Menu", + blackBg("Enter"), "Transactions", + blackBg("F10"), "Quit", + )) + return nil +} + +func (c Transaction) Delete(g *gocui.Gui) error { + err := g.DeleteView(TRANSACTION_HEADER) + if err != nil { + return err + } + + err = g.DeleteView(TRANSACTION) + if err != nil { + return err + } + + return g.DeleteView(TRANSACTION_FOOTER) +} + +func (c *Transaction) display() { + p := message.NewPrinter(language.English) + v := c.view + v.Clear() + transaction := c.transactions.Current() + green := color.Green() + cyan := color.Cyan() + fmt.Fprintln(v, green(" [ Transaction ]")) + fmt.Fprintln(v, fmt.Sprintf("%s %s", + cyan(" Date:"), transaction.Date.Format("15:04:05 Jan _2"))) + fmt.Fprintln(v, p.Sprintf("%s %d", + cyan(" Amount:"), transaction.Amount)) + fmt.Fprintln(v, p.Sprintf("%s %d", + cyan(" Fee:"), transaction.TotalFees)) + fmt.Fprintln(v, p.Sprintf("%s %d", + cyan(" BlockHeight:"), transaction.BlockHeight)) + fmt.Fprintln(v, p.Sprintf("%s %d", + cyan("NumConfirmations:"), transaction.NumConfirmations)) + fmt.Fprintln(v, p.Sprintf("%s %s", + cyan(" BlockHash:"), transaction.BlockHash)) + fmt.Fprintln(v, fmt.Sprintf("%s %s", + cyan(" TxHash:"), transaction.TxHash)) + fmt.Fprintln(v, "") + fmt.Fprintln(v, green("[ addresses ]")) + for i := range transaction.DestAddresses { + fmt.Fprintln(v, fmt.Sprintf("%s %s", + cyan(" -"), transaction.DestAddresses[i])) + } + +} + +func NewTransaction(transactions *models.Transactions) *Transaction { + return &Transaction{transactions: transactions} +} diff --git a/ui/views/transactions.go b/ui/views/transactions.go new file mode 100644 index 0000000..95a48a4 --- /dev/null +++ b/ui/views/transactions.go @@ -0,0 +1,384 @@ +package views + +import ( + "bytes" + "fmt" + + "github.com/jroimartin/gocui" + "golang.org/x/text/language" + "golang.org/x/text/message" + + "github.com/edouardparis/lntop/config" + netmodels "github.com/edouardparis/lntop/network/models" + "github.com/edouardparis/lntop/ui/color" + "github.com/edouardparis/lntop/ui/models" +) + +const ( + TRANSACTIONS = "transactions" + TRANSACTIONS_COLUMNS = "transactions_columns" + TRANSACTIONS_FOOTER = "transactions_footer" +) + +var DefaultTransactionsColumns = []string{ + "DATE", + "HEIGHT", + "CONFIR", + "AMOUNT", + "FEE", + "ADDRESSES", +} + +type Transactions struct { + cfg *config.View + + columns []transactionsColumn + columnsView *gocui.View + view *gocui.View + transactions *models.Transactions + + ox, oy int + cx, cy int +} + +type transactionsColumn struct { + name string + width int + sorted bool + sort func(models.Order) models.TransactionsSort + display func(*netmodels.Transaction, ...color.Option) string +} + +func (c Transactions) Index() int { + _, oy := c.view.Origin() + _, cy := c.view.Cursor() + return cy + oy +} + +func (c Transactions) Name() string { + return TRANSACTIONS +} + +func (c *Transactions) Wrap(v *gocui.View) View { + c.view = v + return c +} + +func (c Transactions) currentColumnIndex() int { + x := c.ox + c.cx + index := 0 + sum := 0 + for i := range c.columns { + sum += c.columns[i].width + 1 + if x < sum { + return index + } + index++ + } + return index +} + +func (c Transactions) Origin() (int, int) { + return c.ox, c.oy +} + +func (c Transactions) Cursor() (int, int) { + return c.cx, c.cy +} + +func (c *Transactions) SetCursor(cx, cy int) error { + err := c.columnsView.SetCursor(cx, 0) + if err != nil { + return err + } + + err = c.view.SetCursor(cx, cy) + if err != nil { + return err + } + + c.cx, c.cy = cx, cy + return nil +} + +func (c *Transactions) SetOrigin(ox, oy int) error { + err := c.columnsView.SetOrigin(ox, 0) + if err != nil { + return err + } + err = c.view.SetOrigin(ox, oy) + if err != nil { + return err + } + + c.ox, c.oy = ox, oy + return nil +} + +func (c *Transactions) Speed() (int, int, int, int) { + current := c.currentColumnIndex() + if current > len(c.columns)-1 { + return 0, c.columns[current-1].width + 1, 1, 1 + } + if current == 0 { + return c.columns[0].width + 1, 0, 1, 1 + } + return c.columns[current].width + 1, + c.columns[current-1].width + 1, + 1, 1 +} + +func (c *Transactions) Sort(column string, order models.Order) { + if column == "" { + index := c.currentColumnIndex() + col := c.columns[index] + if col.sort == nil { + return + } + + c.transactions.Sort(col.sort(order)) + for i := range c.columns { + c.columns[i].sorted = (i == index) + } + } +} + +func (c Transactions) Delete(g *gocui.Gui) error { + err := g.DeleteView(TRANSACTIONS_COLUMNS) + if err != nil { + return err + } + + err = g.DeleteView(TRANSACTIONS) + if err != nil { + return err + } + + return g.DeleteView(TRANSACTIONS_FOOTER) +} + +func (c *Transactions) Set(g *gocui.Gui, x0, y0, x1, y1 int) error { + var err error + setCursor := false + c.columnsView, err = g.SetView(TRANSACTIONS_COLUMNS, x0-1, y0, x1+2, y0+2) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + setCursor = true + } + c.columnsView.Frame = false + c.columnsView.BgColor = gocui.ColorGreen + c.columnsView.FgColor = gocui.ColorBlack + + c.view, err = g.SetView(TRANSACTIONS, x0-1, y0+1, x1+2, y1-1) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + setCursor = true + } + c.view.Frame = false + c.view.Autoscroll = false + c.view.SelBgColor = gocui.ColorCyan + c.view.SelFgColor = gocui.ColorBlack + c.view.Highlight = true + if setCursor { + ox, oy := c.Origin() + err := c.SetOrigin(ox, oy) + if err != nil { + return err + } + + cx, cy := c.Cursor() + err = c.SetCursor(cx, cy) + if err != nil { + return err + } + } + + c.display() + + footer, err := g.SetView(TRANSACTIONS_FOOTER, x0-1, y1-2, x1+2, y1) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + } + footer.Frame = false + footer.BgColor = gocui.ColorCyan + footer.FgColor = gocui.ColorBlack + footer.Clear() + blackBg := color.Black(color.Background) + fmt.Fprintln(footer, fmt.Sprintf("%s%s %s%s %s%s %s%s", + blackBg("F1"), "Help", + blackBg("F2"), "Menu", + blackBg("Enter"), "Transaction", + blackBg("F10"), "Quit", + )) + return nil +} + +func (c *Transactions) display() { + c.columnsView.Clear() + var buffer bytes.Buffer + current := c.currentColumnIndex() + for i := range c.columns { + if current == i { + buffer.WriteString(color.Cyan(color.Background)(c.columns[i].name)) + buffer.WriteString(" ") + continue + } else if c.columns[i].sorted { + buffer.WriteString(color.Magenta(color.Background)(c.columns[i].name)) + buffer.WriteString(" ") + continue + } + buffer.WriteString(c.columns[i].name) + buffer.WriteString(" ") + } + fmt.Fprintln(c.columnsView, buffer.String()) + + c.view.Clear() + for _, item := range c.transactions.List() { + var buffer bytes.Buffer + for i := range c.columns { + var opt color.Option + if current == i { + opt = color.Bold + } + buffer.WriteString(c.columns[i].display(item, opt)) + buffer.WriteString(" ") + } + fmt.Fprintln(c.view, buffer.String()) + } +} + +func NewTransactions(cfg *config.View, txs *models.Transactions) *Transactions { + transactions := &Transactions{ + cfg: cfg, + transactions: txs, + } + + printer := message.NewPrinter(language.English) + + columns := DefaultTransactionsColumns + if cfg != nil && len(cfg.Columns) != 0 { + columns = cfg.Columns + } + + transactions.columns = make([]transactionsColumn, len(columns)) + + for i := range columns { + switch columns[i] { + case "DATE": + transactions.columns[i] = transactionsColumn{ + name: fmt.Sprintf("%-15s", columns[i]), + width: 15, + sort: func(order models.Order) models.TransactionsSort { + return func(tx1, tx2 *netmodels.Transaction) bool { + return models.DateSort(&tx1.Date, &tx2.Date, order) + } + }, + display: func(tx *netmodels.Transaction, opts ...color.Option) string { + return color.Cyan(opts...)( + fmt.Sprintf("%15s", tx.Date.Format("15:04:05 Jan _2")), + ) + }, + } + case "HEIGHT": + transactions.columns[i] = transactionsColumn{ + name: fmt.Sprintf("%8s", columns[i]), + width: 8, + sort: func(order models.Order) models.TransactionsSort { + return func(tx1, tx2 *netmodels.Transaction) bool { + return models.Int32Sort(tx1.BlockHeight, tx2.BlockHeight, order) + } + }, + display: func(tx *netmodels.Transaction, opts ...color.Option) string { + return color.White(opts...)(fmt.Sprintf("%8d", tx.BlockHeight)) + }, + } + case "ADDRESSES": + transactions.columns[i] = transactionsColumn{ + name: fmt.Sprintf("%10s", columns[i]), + width: 10, + sort: func(order models.Order) models.TransactionsSort { + return func(tx1, tx2 *netmodels.Transaction) bool { + return models.IntSort(len(tx1.DestAddresses), len(tx2.DestAddresses), order) + } + }, + display: func(tx *netmodels.Transaction, opts ...color.Option) string { + return color.White(opts...)(fmt.Sprintf("%10d", len(tx.DestAddresses))) + }, + } + case "FEE": + transactions.columns[i] = transactionsColumn{ + name: fmt.Sprintf("%8s", columns[i]), + width: 8, + sort: func(order models.Order) models.TransactionsSort { + return func(tx1, tx2 *netmodels.Transaction) bool { + return models.Int64Sort(tx1.TotalFees, tx2.TotalFees, order) + } + }, + display: func(tx *netmodels.Transaction, opts ...color.Option) string { + return color.White(opts...)(fmt.Sprintf("%8d", tx.TotalFees)) + }, + } + case "CONFIR": + transactions.columns[i] = transactionsColumn{ + name: fmt.Sprintf("%8s", columns[i]), + width: 8, + sort: func(order models.Order) models.TransactionsSort { + return func(tx1, tx2 *netmodels.Transaction) bool { + return models.Int32Sort(tx1.NumConfirmations, tx2.NumConfirmations, order) + } + }, + display: func(tx *netmodels.Transaction, opts ...color.Option) string { + n := fmt.Sprintf("%8d", tx.NumConfirmations) + if tx.NumConfirmations < 6 { + return color.Yellow(opts...)(n) + } + return color.Green(opts...)(n) + }, + } + case "TXHASH": + transactions.columns[i] = transactionsColumn{ + name: fmt.Sprintf("%-64s", columns[i]), + width: 64, + display: func(tx *netmodels.Transaction, opts ...color.Option) string { + return color.White(opts...)(fmt.Sprintf("%13s", tx.TxHash)) + }, + } + case "BLOCKHASH": + transactions.columns[i] = transactionsColumn{ + name: fmt.Sprintf("%-64s", columns[i]), + display: func(tx *netmodels.Transaction, opts ...color.Option) string { + return color.White(opts...)(fmt.Sprintf("%13s", tx.TxHash)) + }, + } + case "AMOUNT": + transactions.columns[i] = transactionsColumn{ + name: fmt.Sprintf("%13s", columns[i]), + width: 13, + sort: func(order models.Order) models.TransactionsSort { + return func(tx1, tx2 *netmodels.Transaction) bool { + return models.Int64Sort(tx1.Amount, tx2.Amount, order) + } + }, + display: func(tx *netmodels.Transaction, opts ...color.Option) string { + return color.White(opts...)(printer.Sprintf("%13d", tx.Amount)) + }, + } + default: + transactions.columns[i] = transactionsColumn{ + name: fmt.Sprintf("%-21s", columns[i]), + width: 21, + display: func(tx *netmodels.Transaction, opts ...color.Option) string { + return "column does not exist" + }, + } + } + + } + return transactions +} diff --git a/ui/views/views.go b/ui/views/views.go index aa7ea6b..cde8fe0 100644 --- a/ui/views/views.go +++ b/ui/views/views.go @@ -1,30 +1,36 @@ package views import ( - "github.com/edouardparis/lntop/ui/models" "github.com/jroimartin/gocui" + "github.com/pkg/errors" + + "github.com/edouardparis/lntop/config" + "github.com/edouardparis/lntop/ui/cursor" + "github.com/edouardparis/lntop/ui/models" ) -type view interface { +type View interface { Set(*gocui.Gui, int, int, int, int) error - Wrap(*gocui.View) view - CursorLeft() error - CursorRight() error - CursorUp() error - CursorDown() error + Delete(*gocui.Gui) error + Wrap(*gocui.View) View Name() string + cursor.View } type Views struct { - Previous view - Help *Help - Header *Header - Summary *Summary - Channels *Channels - Channel *Channel + Main View + + Help *Help + Header *Header + Menu *Menu + Summary *Summary + Channels *Channels + Channel *Channel + Transactions *Transactions + Transaction *Transaction } -func (v Views) Get(vi *gocui.View) view { +func (v Views) Get(vi *gocui.View) View { if vi == nil { return nil } @@ -33,17 +39,19 @@ func (v Views) Get(vi *gocui.View) view { return v.Channels.Wrap(vi) case HELP: return v.Help.Wrap(vi) + case MENU: + return v.Menu.Wrap(vi) case CHANNEL: return v.Channel.Wrap(vi) + case TRANSACTIONS: + return v.Transactions.Wrap(vi) + case TRANSACTION: + return v.Transaction.Wrap(vi) default: return nil } } -func (v *Views) SetPrevious(p view) { - v.Previous = p -} - func (v *Views) Layout(g *gocui.Gui, maxX, maxY int) error { err := v.Header.Set(g, 0, -1, maxX, 1) if err != nil { @@ -55,15 +63,49 @@ func (v *Views) Layout(g *gocui.Gui, maxX, maxY int) error { return err } - return v.Channels.Set(g, 0, 6, maxX-1, maxY) + current := g.CurrentView() + if current != nil { + switch current.Name() { + case v.Help.Name(): + return nil + case v.Menu.Name(): + err = v.Menu.Set(g, 0, 6, 10, maxY) + if err != nil { + return err + } + + err = v.Main.Set(g, 11, 6, maxX-1, maxY) + if err != nil { + return err + } + return nil + } + } + + err = v.Main.Set(g, 0, 6, maxX-1, maxY) + if err != nil && err != gocui.ErrUnknownView { + return err + } + + _, err = g.SetCurrentView(v.Main.Name()) + if err != nil { + return errors.WithStack(err) + } + + return nil } -func New(m *models.Models) *Views { +func New(cfg config.Views, m *models.Models) *Views { + main := NewChannels(cfg.Channels, m.Channels) return &Views{ - Header: NewHeader(m.Info), - Help: NewHelp(), - Summary: NewSummary(m.Info, m.ChannelsBalance, m.WalletBalance, m.Channels), - Channels: NewChannels(m.Channels), - Channel: NewChannel(m.CurrentChannel), + Header: NewHeader(m.Info), + Help: NewHelp(), + Menu: NewMenu(), + Summary: NewSummary(m.Info, m.ChannelsBalance, m.WalletBalance, m.Channels), + Channels: main, + Channel: NewChannel(m.Channels), + Transactions: NewTransactions(cfg.Transactions, m.Transactions), + Transaction: NewTransaction(m.Transactions), + Main: main, } }