forked from TrueCloudLab/frostfs-node
549 lines
12 KiB
Go
549 lines
12 KiB
Go
|
package tui
|
||
|
|
||
|
import (
|
||
|
"context"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"strings"
|
||
|
"sync/atomic"
|
||
|
"time"
|
||
|
|
||
|
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/schema/common"
|
||
|
"github.com/davecgh/go-spew/spew"
|
||
|
"github.com/gdamore/tcell/v2"
|
||
|
"github.com/rivo/tview"
|
||
|
"go.etcd.io/bbolt"
|
||
|
)
|
||
|
|
||
|
type Config struct {
|
||
|
LoadBufferSize int
|
||
|
SearchHistorySize int
|
||
|
LoadingIndicatorLag time.Duration
|
||
|
}
|
||
|
|
||
|
var DefaultConfig = Config{
|
||
|
LoadBufferSize: 100,
|
||
|
SearchHistorySize: 100,
|
||
|
LoadingIndicatorLag: 500 * time.Millisecond,
|
||
|
}
|
||
|
|
||
|
type Primitive interface {
|
||
|
tview.Primitive
|
||
|
|
||
|
Mount(ctx context.Context) error
|
||
|
Update(ctx context.Context) error
|
||
|
Unmount()
|
||
|
}
|
||
|
|
||
|
type UI struct {
|
||
|
*tview.Box
|
||
|
|
||
|
// Need to use context while updating pages those read data from a database.
|
||
|
// Context should be shared among all mount and updates. Current TUI library
|
||
|
// doesn't use contexts at all, so I do that feature by myself.
|
||
|
//nolint:containedctx
|
||
|
ctx context.Context
|
||
|
onStop func()
|
||
|
|
||
|
app *tview.Application
|
||
|
db *bbolt.DB
|
||
|
|
||
|
pageHistory []Primitive
|
||
|
mountedPage Primitive
|
||
|
|
||
|
pageToMount Primitive
|
||
|
|
||
|
pageStub tview.Primitive
|
||
|
|
||
|
infoBar *tview.TextView
|
||
|
searchBar *InputFieldWithHistory
|
||
|
loadingBar *LoadingBar
|
||
|
helpBar *tview.TextView
|
||
|
|
||
|
searchErrorBar *tview.TextView
|
||
|
|
||
|
isSearching bool
|
||
|
isLoading atomic.Bool
|
||
|
isShowingError bool
|
||
|
isShowingHelp bool
|
||
|
|
||
|
loadBufferSize int
|
||
|
|
||
|
rootParser common.Parser
|
||
|
|
||
|
loadingIndicatorLag time.Duration
|
||
|
|
||
|
cancelLoading func()
|
||
|
|
||
|
filters map[string]func(string) (any, error)
|
||
|
compositeFilters map[string]func(string) (map[string]any, error)
|
||
|
filterHints map[string]string
|
||
|
}
|
||
|
|
||
|
func NewUI(
|
||
|
ctx context.Context,
|
||
|
app *tview.Application,
|
||
|
db *bbolt.DB,
|
||
|
rootParser common.Parser,
|
||
|
cfg *Config,
|
||
|
) *UI {
|
||
|
spew.Config.DisableMethods = true
|
||
|
|
||
|
if cfg == nil {
|
||
|
cfg = &DefaultConfig
|
||
|
}
|
||
|
|
||
|
ui := &UI{
|
||
|
Box: tview.NewBox(),
|
||
|
|
||
|
app: app,
|
||
|
db: db,
|
||
|
rootParser: rootParser,
|
||
|
|
||
|
filters: make(map[string]func(string) (any, error)),
|
||
|
compositeFilters: make(map[string]func(string) (map[string]any, error)),
|
||
|
filterHints: make(map[string]string),
|
||
|
|
||
|
loadBufferSize: cfg.LoadBufferSize,
|
||
|
loadingIndicatorLag: cfg.LoadingIndicatorLag,
|
||
|
}
|
||
|
|
||
|
ui.ctx, ui.onStop = context.WithCancel(ctx)
|
||
|
|
||
|
backgroundColor := ui.GetBackgroundColor()
|
||
|
textColor := tview.Styles.PrimaryTextColor
|
||
|
|
||
|
inverseBackgroundColor := textColor
|
||
|
inverseTextColor := backgroundColor
|
||
|
|
||
|
alertTextColor := tcell.ColorRed
|
||
|
|
||
|
ui.pageStub = tview.NewBox()
|
||
|
|
||
|
ui.infoBar = tview.NewTextView()
|
||
|
ui.infoBar.SetBackgroundColor(inverseBackgroundColor)
|
||
|
ui.infoBar.SetTextColor(inverseTextColor)
|
||
|
ui.infoBar.SetText(
|
||
|
fmt.Sprintf(" %s (press h for help, q to quit) ", db.Path()),
|
||
|
)
|
||
|
|
||
|
ui.searchBar = NewInputFieldWithHistory(cfg.SearchHistorySize)
|
||
|
ui.searchBar.SetFieldBackgroundColor(backgroundColor)
|
||
|
ui.searchBar.SetFieldTextColor(textColor)
|
||
|
ui.searchBar.SetLabelColor(textColor)
|
||
|
ui.searchBar.Focus(nil)
|
||
|
ui.searchBar.SetLabel("/")
|
||
|
|
||
|
ui.searchErrorBar = tview.NewTextView()
|
||
|
ui.searchErrorBar.SetBackgroundColor(backgroundColor)
|
||
|
ui.searchErrorBar.SetTextColor(alertTextColor)
|
||
|
|
||
|
ui.helpBar = tview.NewTextView()
|
||
|
ui.helpBar.SetBackgroundColor(inverseBackgroundColor)
|
||
|
ui.helpBar.SetTextColor(inverseTextColor)
|
||
|
ui.helpBar.SetText(" Press Enter for next page or Escape to exit help ")
|
||
|
|
||
|
ui.loadingBar = NewLoadingBar(ui.triggerDraw)
|
||
|
|
||
|
ui.pageToMount = NewBucketsView(ui, NewFilter(nil))
|
||
|
|
||
|
return ui
|
||
|
}
|
||
|
|
||
|
func (ui *UI) checkFilterExists(typ string) bool {
|
||
|
if _, ok := ui.filters[typ]; ok {
|
||
|
return true
|
||
|
}
|
||
|
if _, ok := ui.compositeFilters[typ]; ok {
|
||
|
return true
|
||
|
}
|
||
|
return false
|
||
|
}
|
||
|
|
||
|
func (ui *UI) AddFilter(
|
||
|
typ string,
|
||
|
parser func(string) (any, error),
|
||
|
helpHint string,
|
||
|
) error {
|
||
|
if ui.checkFilterExists(typ) {
|
||
|
return fmt.Errorf("filter %s already exists", typ)
|
||
|
}
|
||
|
ui.filters[typ] = parser
|
||
|
ui.filterHints[typ] = helpHint
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (ui *UI) AddCompositeFilter(
|
||
|
typ string,
|
||
|
parser func(string) (map[string]any, error),
|
||
|
helpHint string,
|
||
|
) error {
|
||
|
if ui.checkFilterExists(typ) {
|
||
|
return fmt.Errorf("filter %s already exists", typ)
|
||
|
}
|
||
|
ui.compositeFilters[typ] = parser
|
||
|
ui.filterHints[typ] = helpHint
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (ui *UI) stopOnError(err error) {
|
||
|
if err != nil {
|
||
|
ui.onStop()
|
||
|
ui.app.QueueEvent(tcell.NewEventError(err))
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (ui *UI) stop() {
|
||
|
ui.onStop()
|
||
|
ui.app.Stop()
|
||
|
}
|
||
|
|
||
|
func (ui *UI) movePrevPage() {
|
||
|
if len(ui.pageHistory) != 0 {
|
||
|
ui.mountedPage.Unmount()
|
||
|
ui.mountedPage = ui.pageHistory[len(ui.pageHistory)-1]
|
||
|
ui.pageHistory = ui.pageHistory[:len(ui.pageHistory)-1]
|
||
|
ui.triggerDraw()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (ui *UI) moveNextPage(page Primitive) {
|
||
|
ui.pageToMount = page
|
||
|
ui.triggerDraw()
|
||
|
}
|
||
|
|
||
|
func (ui *UI) triggerDraw() {
|
||
|
go ui.app.QueueUpdateDraw(func() {})
|
||
|
}
|
||
|
|
||
|
func (ui *UI) Draw(screen tcell.Screen) {
|
||
|
if ui.isLoading.Load() {
|
||
|
ui.draw(screen)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
ui.isLoading.Store(true)
|
||
|
|
||
|
ctx, cancel := context.WithCancel(ui.ctx)
|
||
|
|
||
|
ready := make(chan struct{})
|
||
|
go func() {
|
||
|
ui.load(ctx)
|
||
|
|
||
|
cancel()
|
||
|
close(ready)
|
||
|
ui.isLoading.Store(false)
|
||
|
}()
|
||
|
|
||
|
select {
|
||
|
case <-ready:
|
||
|
case <-time.After(ui.loadingIndicatorLag):
|
||
|
ui.loadingBar.Start(ui.ctx)
|
||
|
ui.cancelLoading = cancel
|
||
|
|
||
|
go func() {
|
||
|
<-ready
|
||
|
ui.loadingBar.Stop()
|
||
|
ui.triggerDraw()
|
||
|
}()
|
||
|
}
|
||
|
|
||
|
ui.draw(screen)
|
||
|
}
|
||
|
|
||
|
func (ui *UI) load(ctx context.Context) {
|
||
|
if ui.mountedPage == nil && ui.pageToMount == nil {
|
||
|
ui.stop()
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if ui.pageToMount != nil {
|
||
|
ui.mountAndUpdate(ctx)
|
||
|
} else {
|
||
|
ui.update(ctx)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (ui *UI) draw(screen tcell.Screen) {
|
||
|
ui.DrawForSubclass(screen, ui)
|
||
|
x, y, width, height := ui.GetInnerRect()
|
||
|
|
||
|
var (
|
||
|
pageToDraw tview.Primitive
|
||
|
barToDraw tview.Primitive
|
||
|
)
|
||
|
|
||
|
switch {
|
||
|
case ui.isShowingHelp:
|
||
|
pageToDraw = ui.pageStub
|
||
|
case ui.mountedPage != nil:
|
||
|
pageToDraw = ui.mountedPage
|
||
|
default:
|
||
|
pageToDraw = ui.pageStub
|
||
|
}
|
||
|
|
||
|
pageToDraw.SetRect(x, y, width, height-1)
|
||
|
pageToDraw.Draw(screen)
|
||
|
|
||
|
// Search bar uses cursor and we need to hide it when another bar is drawn.
|
||
|
screen.HideCursor()
|
||
|
|
||
|
switch {
|
||
|
case ui.isLoading.Load():
|
||
|
barToDraw = ui.loadingBar
|
||
|
case ui.isSearching:
|
||
|
barToDraw = ui.searchBar
|
||
|
case ui.isShowingError:
|
||
|
barToDraw = ui.searchErrorBar
|
||
|
case ui.isShowingHelp:
|
||
|
barToDraw = ui.helpBar
|
||
|
default:
|
||
|
barToDraw = ui.infoBar
|
||
|
}
|
||
|
|
||
|
barToDraw.SetRect(x, y+height-1, width, 1)
|
||
|
barToDraw.Draw(screen)
|
||
|
}
|
||
|
|
||
|
func (ui *UI) mountAndUpdate(ctx context.Context) {
|
||
|
defer func() {
|
||
|
// Operation succeeded or was canceled, either way reset page to mount.
|
||
|
ui.pageToMount = nil
|
||
|
}()
|
||
|
|
||
|
// Mount should use app global context.
|
||
|
//nolint:contextcheck
|
||
|
err := ui.pageToMount.Mount(ui.ctx)
|
||
|
if err != nil {
|
||
|
ui.stopOnError(err)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
x, y, width, height := ui.GetInnerRect()
|
||
|
ui.pageToMount.SetRect(x, y, width, height-1)
|
||
|
|
||
|
s := loadOp(ctx, ui.pageToMount.Update)
|
||
|
if s.err != nil {
|
||
|
ui.pageToMount.Unmount()
|
||
|
ui.stopOnError(s.err)
|
||
|
return
|
||
|
}
|
||
|
// Update was canceled.
|
||
|
if !s.done {
|
||
|
ui.pageToMount.Unmount()
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if ui.mountedPage != nil {
|
||
|
ui.pageHistory = append(ui.pageHistory, ui.mountedPage)
|
||
|
}
|
||
|
ui.mountedPage = ui.pageToMount
|
||
|
}
|
||
|
|
||
|
func (ui *UI) update(ctx context.Context) {
|
||
|
x, y, width, height := ui.GetInnerRect()
|
||
|
ui.mountedPage.SetRect(x, y, width, height-1)
|
||
|
|
||
|
s := loadOp(ctx, ui.mountedPage.Update)
|
||
|
if s.err != nil {
|
||
|
ui.stopOnError(s.err)
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
type status struct {
|
||
|
done bool
|
||
|
err error
|
||
|
}
|
||
|
|
||
|
func loadOp(ctx context.Context, op func(ctx context.Context) error) status {
|
||
|
errCh := make(chan error)
|
||
|
go func() {
|
||
|
errCh <- op(ctx)
|
||
|
}()
|
||
|
|
||
|
select {
|
||
|
case <-ctx.Done():
|
||
|
return status{done: false, err: nil}
|
||
|
case err := <-errCh:
|
||
|
return status{done: true, err: err}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (ui *UI) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||
|
return ui.WrapInputHandler(func(event *tcell.EventKey, _ func(tview.Primitive)) {
|
||
|
switch {
|
||
|
case ui.isLoading.Load():
|
||
|
ui.handleInputOnLoading(event)
|
||
|
case ui.isShowingHelp:
|
||
|
ui.handleInputOnShowingHelp(event)
|
||
|
case ui.isShowingError:
|
||
|
ui.handleInputOnShowingError()
|
||
|
case ui.isSearching:
|
||
|
ui.handleInputOnSearching(event)
|
||
|
default:
|
||
|
ui.handleInput(event)
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
|
||
|
func (ui *UI) handleInput(event *tcell.EventKey) {
|
||
|
m, k, r := event.Modifiers(), event.Key(), event.Rune()
|
||
|
|
||
|
switch {
|
||
|
case k == tcell.KeyEsc:
|
||
|
ui.movePrevPage()
|
||
|
case m == 0 && k == tcell.KeyRune && r == 'h':
|
||
|
ui.isShowingHelp = true
|
||
|
case m == 0 && k == tcell.KeyRune && r == '/':
|
||
|
ui.isSearching = true
|
||
|
case m == 0 && k == tcell.KeyRune && r == 'q':
|
||
|
ui.stop()
|
||
|
default:
|
||
|
if ui.mountedPage != nil {
|
||
|
ui.mountedPage.InputHandler()(event, func(tview.Primitive) {})
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (ui *UI) handleInputOnLoading(event *tcell.EventKey) {
|
||
|
switch k, r := event.Key(), event.Rune(); {
|
||
|
case k == tcell.KeyEsc:
|
||
|
ui.cancelLoading()
|
||
|
case k == tcell.KeyRune && r == 'q':
|
||
|
ui.stop()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (ui *UI) handleInputOnShowingError() {
|
||
|
ui.isShowingError = false
|
||
|
ui.isSearching = true
|
||
|
}
|
||
|
|
||
|
func (ui *UI) handleInputOnShowingHelp(event *tcell.EventKey) {
|
||
|
k, r := event.Key(), event.Rune()
|
||
|
|
||
|
switch {
|
||
|
case k == tcell.KeyEsc:
|
||
|
ui.isShowingHelp = false
|
||
|
case k == tcell.KeyRune && r == 'q':
|
||
|
ui.stop()
|
||
|
default:
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (ui *UI) handleInputOnSearching(event *tcell.EventKey) {
|
||
|
m, k := event.Modifiers(), event.Key()
|
||
|
|
||
|
switch {
|
||
|
case k == tcell.KeyEnter:
|
||
|
prompt := ui.searchBar.GetText()
|
||
|
|
||
|
res, err := ui.processPrompt(prompt)
|
||
|
if err != nil {
|
||
|
ui.isShowingError = true
|
||
|
ui.isSearching = false
|
||
|
ui.searchErrorBar.SetText(err.Error() + " (press any key to continue)")
|
||
|
return
|
||
|
}
|
||
|
|
||
|
switch ui.mountedPage.(type) {
|
||
|
case *BucketsView:
|
||
|
ui.moveNextPage(NewBucketsView(ui, res))
|
||
|
case *RecordsView:
|
||
|
bucket := ui.mountedPage.(*RecordsView).bucket
|
||
|
ui.moveNextPage(NewRecordsView(ui, bucket, res))
|
||
|
}
|
||
|
|
||
|
if ui.searchBar.GetText() != "" {
|
||
|
ui.searchBar.AddToHistory(ui.searchBar.GetText())
|
||
|
}
|
||
|
|
||
|
ui.searchBar.SetText("")
|
||
|
ui.isSearching = false
|
||
|
case k == tcell.KeyEsc:
|
||
|
ui.isSearching = false
|
||
|
case (k == tcell.KeyBackspace2 || m&tcell.ModCtrl != 0 && k == tcell.KeyETB) && len(ui.searchBar.GetText()) == 0:
|
||
|
ui.isSearching = false
|
||
|
default:
|
||
|
ui.searchBar.InputHandler()(event, func(tview.Primitive) {})
|
||
|
}
|
||
|
|
||
|
ui.Box.MouseHandler()
|
||
|
}
|
||
|
|
||
|
func (ui *UI) WithPrompt(prompt string) error {
|
||
|
filter, err := ui.processPrompt(prompt)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
ui.pageToMount = NewBucketsView(ui, filter)
|
||
|
|
||
|
if prompt != "" {
|
||
|
ui.searchBar.AddToHistory(prompt)
|
||
|
}
|
||
|
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (ui *UI) processPrompt(prompt string) (filter *Filter, err error) {
|
||
|
if prompt == "" {
|
||
|
return NewFilter(nil), nil
|
||
|
}
|
||
|
|
||
|
filterMap := make(map[string]any)
|
||
|
|
||
|
for _, filterString := range strings.Split(prompt, "+") {
|
||
|
parts := strings.Split(filterString, ":")
|
||
|
if len(parts) != 2 {
|
||
|
return nil, errors.New("expected 'tag:value [+ tag:value]...'")
|
||
|
}
|
||
|
|
||
|
filterTag := strings.TrimSpace(parts[0])
|
||
|
filterValueString := strings.TrimSpace(parts[1])
|
||
|
|
||
|
if _, exists := filterMap[filterTag]; exists {
|
||
|
return nil, fmt.Errorf("duplicate filter tag '%s'", filterTag)
|
||
|
}
|
||
|
|
||
|
parser, ok := ui.filters[filterTag]
|
||
|
if ok {
|
||
|
filterValue, err := parser(filterValueString)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("can't parse '%s' filter value: %w", filterTag, err)
|
||
|
}
|
||
|
|
||
|
filterMap[filterTag] = filterValue
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
compositeParser, ok := ui.compositeFilters[filterTag]
|
||
|
if ok {
|
||
|
compositeFilterValue, err := compositeParser(filterValueString)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf(
|
||
|
"can't parse '%s' filter value '%s': %w",
|
||
|
filterTag, filterValueString, err,
|
||
|
)
|
||
|
}
|
||
|
|
||
|
for tag, value := range compositeFilterValue {
|
||
|
if _, exists := filterMap[tag]; exists {
|
||
|
return nil, fmt.Errorf(
|
||
|
"found duplicate filter tag '%s' while processing composite filter with tag '%s'",
|
||
|
tag, filterTag,
|
||
|
)
|
||
|
}
|
||
|
|
||
|
filterMap[tag] = value
|
||
|
}
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
return nil, fmt.Errorf("unknown filter tag '%s'", filterTag)
|
||
|
}
|
||
|
|
||
|
return NewFilter(filterMap), nil
|
||
|
}
|