[#1223] lens/tui: Add TUI app to explore metabase
Signed-off-by: Aleksey Savchuk <a.savchuk@yadro.com>
This commit is contained in:
parent
d13ccbac4c
commit
b99d75f08e
14 changed files with 1653 additions and 1 deletions
|
@ -32,6 +32,7 @@ func init() {
|
|||
inspectCMD,
|
||||
listGraveyardCMD,
|
||||
listGarbageCMD,
|
||||
tuiCMD,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
82
cmd/frostfs-lens/internal/meta/tui.go
Normal file
82
cmd/frostfs-lens/internal/meta/tui.go
Normal file
|
@ -0,0 +1,82 @@
|
|||
package meta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
common "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal"
|
||||
schema "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/schema/metabase"
|
||||
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/tui"
|
||||
"github.com/rivo/tview"
|
||||
"github.com/spf13/cobra"
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
var tuiCMD = &cobra.Command{
|
||||
Use: "explore",
|
||||
Short: "Metabase exploration with a terminal UI",
|
||||
Long: `Launch a terminal UI to explore metabase and search for data.
|
||||
|
||||
Available search filters:
|
||||
- cid CID
|
||||
- oid OID
|
||||
- addr CID/OID
|
||||
- attr key[/value]
|
||||
`,
|
||||
Run: tuiFunc,
|
||||
}
|
||||
|
||||
var initialPrompt string
|
||||
|
||||
func init() {
|
||||
common.AddComponentPathFlag(tuiCMD, &vPath)
|
||||
|
||||
tuiCMD.Flags().StringVar(
|
||||
&initialPrompt,
|
||||
"filter",
|
||||
"",
|
||||
"Filter prompt to start with, format 'tag:value [+ tag:value]...'",
|
||||
)
|
||||
}
|
||||
|
||||
func tuiFunc(cmd *cobra.Command, _ []string) {
|
||||
common.ExitOnErr(cmd, runTUI(cmd))
|
||||
}
|
||||
|
||||
func runTUI(cmd *cobra.Command) error {
|
||||
db, err := openDB(false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't open database: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Need if app was stopped with Ctrl-C.
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
app := tview.NewApplication()
|
||||
ui := tui.NewUI(ctx, app, db, schema.MetabaseParser, nil)
|
||||
|
||||
_ = ui.AddFilter("cid", tui.CIDParser, "CID")
|
||||
_ = ui.AddFilter("oid", tui.OIDParser, "OID")
|
||||
_ = ui.AddCompositeFilter("addr", tui.AddressParser, "CID/OID")
|
||||
_ = ui.AddCompositeFilter("attr", tui.AttributeParser, "key[/value]")
|
||||
|
||||
err = ui.WithPrompt(initialPrompt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid filter prompt: %w", err)
|
||||
}
|
||||
|
||||
app.SetRoot(ui, true).SetFocus(ui)
|
||||
return app.Run()
|
||||
}
|
||||
|
||||
func openDB(writable bool) (*bbolt.DB, error) {
|
||||
db, err := bbolt.Open(vPath, 0o600, &bbolt.Options{
|
||||
ReadOnly: !writable,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
257
cmd/frostfs-lens/internal/tui/buckets.go
Normal file
257
cmd/frostfs-lens/internal/tui/buckets.go
Normal file
|
@ -0,0 +1,257 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/schema/common"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
type BucketsView struct {
|
||||
*tview.Box
|
||||
|
||||
mu sync.Mutex
|
||||
|
||||
view *tview.TreeView
|
||||
nodeToUpdate *tview.TreeNode
|
||||
|
||||
ui *UI
|
||||
filter *Filter
|
||||
}
|
||||
|
||||
type bucketNode struct {
|
||||
bucket *Bucket
|
||||
filter *Filter
|
||||
}
|
||||
|
||||
func NewBucketsView(ui *UI, filter *Filter) *BucketsView {
|
||||
return &BucketsView{
|
||||
Box: tview.NewBox(),
|
||||
view: tview.NewTreeView(),
|
||||
ui: ui,
|
||||
filter: filter,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *BucketsView) Mount(_ context.Context) error {
|
||||
root := tview.NewTreeNode(".")
|
||||
root.SetExpanded(false)
|
||||
root.SetSelectable(false)
|
||||
root.SetReference(&bucketNode{
|
||||
bucket: &Bucket{NextParser: v.ui.rootParser},
|
||||
filter: v.filter,
|
||||
})
|
||||
|
||||
v.nodeToUpdate = root
|
||||
|
||||
v.view.SetRoot(root)
|
||||
v.view.SetCurrentNode(root)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *BucketsView) Update(ctx context.Context) error {
|
||||
if v.nodeToUpdate == nil {
|
||||
return nil
|
||||
}
|
||||
defer func() { v.nodeToUpdate = nil }()
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
ready := make(chan struct{})
|
||||
errCh := make(chan error)
|
||||
|
||||
tmp := tview.NewTreeNode(v.nodeToUpdate.GetText())
|
||||
tmp.SetReference(v.nodeToUpdate.GetReference())
|
||||
|
||||
node := v.nodeToUpdate.GetReference().(*bucketNode)
|
||||
|
||||
go func() {
|
||||
defer close(ready)
|
||||
|
||||
hasBuckets, err := HasBuckets(ctx, v.ui.db, node.bucket.Path)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
|
||||
// Show the selected bucket's records instead.
|
||||
if !hasBuckets && node.bucket.NextParser != nil {
|
||||
v.ui.moveNextPage(NewRecordsView(v.ui, node.bucket, node.filter))
|
||||
}
|
||||
|
||||
if v.nodeToUpdate.IsExpanded() {
|
||||
return
|
||||
}
|
||||
|
||||
err = v.loadNodeChildren(ctx, tmp, node.filter)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-ready:
|
||||
v.mu.Lock()
|
||||
v.nodeToUpdate.SetChildren(tmp.GetChildren())
|
||||
v.nodeToUpdate.SetExpanded(!v.nodeToUpdate.IsExpanded())
|
||||
v.mu.Unlock()
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *BucketsView) Unmount() {
|
||||
}
|
||||
|
||||
func (v *BucketsView) Draw(screen tcell.Screen) {
|
||||
x, y, width, height := v.GetInnerRect()
|
||||
v.view.SetRect(x, y, width, height)
|
||||
|
||||
v.view.Draw(screen)
|
||||
}
|
||||
|
||||
func (v *BucketsView) loadNodeChildren(
|
||||
ctx context.Context, node *tview.TreeNode, filter *Filter,
|
||||
) error {
|
||||
parentBucket := node.GetReference().(*bucketNode).bucket
|
||||
|
||||
path := parentBucket.Path
|
||||
parser := parentBucket.NextParser
|
||||
|
||||
buffer, err := LoadBuckets(ctx, v.ui.db, path, v.ui.loadBufferSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for item := range buffer {
|
||||
if item.err != nil {
|
||||
return item.err
|
||||
}
|
||||
bucket := item.val
|
||||
|
||||
bucket.Entry, bucket.NextParser, err = parser(bucket.Name, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
satisfies, err := v.bucketSatisfiesFilter(ctx, bucket, filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !satisfies {
|
||||
continue
|
||||
}
|
||||
|
||||
child := tview.NewTreeNode(bucket.Entry.String()).
|
||||
SetSelectable(true).
|
||||
SetExpanded(false).
|
||||
SetReference(&bucketNode{
|
||||
bucket: bucket,
|
||||
filter: filter.Apply(bucket.Entry),
|
||||
})
|
||||
|
||||
node.AddChild(child)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *BucketsView) bucketSatisfiesFilter(
|
||||
ctx context.Context, bucket *Bucket, filter *Filter,
|
||||
) (bool, error) {
|
||||
// Does the current bucket satisfies the filter?
|
||||
filter = filter.Apply(bucket.Entry)
|
||||
|
||||
if filter.Result() == common.Yes {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if filter.Result() == common.No {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Check the current bucket's nested buckets if exist
|
||||
bucketsBuffer, err := LoadBuckets(ctx, v.ui.db, bucket.Path, v.ui.loadBufferSize)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for item := range bucketsBuffer {
|
||||
if item.err != nil {
|
||||
return false, item.err
|
||||
}
|
||||
b := item.val
|
||||
|
||||
b.Entry, b.NextParser, err = bucket.NextParser(b.Name, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
satisfies, err := v.bucketSatisfiesFilter(ctx, b, filter)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if satisfies {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check the current bucket's nested records if exist
|
||||
recordsBuffer, err := LoadRecords(ctx, v.ui.db, bucket.Path, v.ui.loadBufferSize)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for item := range recordsBuffer {
|
||||
if item.err != nil {
|
||||
return false, item.err
|
||||
}
|
||||
r := item.val
|
||||
|
||||
r.Entry, _, err = bucket.NextParser(r.Key, r.Value)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if filter.Apply(r.Entry).Result() == common.Yes {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (v *BucketsView) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
return v.WrapInputHandler(func(event *tcell.EventKey, _ func(tview.Primitive)) {
|
||||
currentNode := v.view.GetCurrentNode()
|
||||
if currentNode == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch event.Key() {
|
||||
case tcell.KeyEnter:
|
||||
// Expand or collapse the selected bucket's nested buckets,
|
||||
// otherwise, navigate to that bucket's records.
|
||||
v.nodeToUpdate = currentNode
|
||||
case tcell.KeyCtrlR:
|
||||
// Navigate to the selected bucket's records.
|
||||
bucketNode := currentNode.GetReference().(*bucketNode)
|
||||
v.ui.moveNextPage(NewRecordsView(v.ui, bucketNode.bucket, bucketNode.filter))
|
||||
case tcell.KeyCtrlD:
|
||||
// Navigate to the selected bucket's detailed view.
|
||||
bucketNode := currentNode.GetReference().(*bucketNode)
|
||||
v.ui.moveNextPage(NewDetailedView(bucketNode.bucket.Entry.DetailedString()))
|
||||
default:
|
||||
v.view.InputHandler()(event, func(tview.Primitive) {})
|
||||
}
|
||||
})
|
||||
}
|
160
cmd/frostfs-lens/internal/tui/db.go
Normal file
160
cmd/frostfs-lens/internal/tui/db.go
Normal file
|
@ -0,0 +1,160 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
type Item[T any] struct {
|
||||
val T
|
||||
err error
|
||||
}
|
||||
|
||||
func resolvePath(tx *bbolt.Tx, path [][]byte) (*bbolt.Bucket, error) {
|
||||
if len(path) == 0 {
|
||||
return nil, errors.New("can't find bucket without path")
|
||||
}
|
||||
|
||||
name := path[0]
|
||||
bucket := tx.Bucket(name)
|
||||
if bucket == nil {
|
||||
return nil, fmt.Errorf("no bucket with name %s", name)
|
||||
}
|
||||
for _, name := range path[1:] {
|
||||
bucket = bucket.Bucket(name)
|
||||
if bucket == nil {
|
||||
return nil, fmt.Errorf("no bucket with name %s", name)
|
||||
}
|
||||
}
|
||||
return bucket, nil
|
||||
}
|
||||
|
||||
func load[T any](
|
||||
ctx context.Context, db *bbolt.DB, path [][]byte, bufferSize int,
|
||||
filter func(key, value []byte) bool, transform func(key, value []byte) T,
|
||||
) (<-chan Item[T], error) {
|
||||
buffer := make(chan Item[T], bufferSize)
|
||||
|
||||
go func() {
|
||||
defer close(buffer)
|
||||
|
||||
err := db.View(func(tx *bbolt.Tx) error {
|
||||
var cursor *bbolt.Cursor
|
||||
if len(path) == 0 {
|
||||
cursor = tx.Cursor()
|
||||
} else {
|
||||
bucket, err := resolvePath(tx, path)
|
||||
if err != nil {
|
||||
buffer <- Item[T]{err: fmt.Errorf("can't find bucket: %w", err)}
|
||||
return nil
|
||||
}
|
||||
cursor = bucket.Cursor()
|
||||
}
|
||||
|
||||
key, value := cursor.First()
|
||||
for {
|
||||
if key == nil {
|
||||
return nil
|
||||
}
|
||||
if filter != nil && !filter(key, value) {
|
||||
key, value = cursor.Next()
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case buffer <- Item[T]{val: transform(key, value)}:
|
||||
key, value = cursor.Next()
|
||||
}
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
buffer <- Item[T]{err: err}
|
||||
}
|
||||
}()
|
||||
|
||||
return buffer, nil
|
||||
}
|
||||
|
||||
func LoadBuckets(
|
||||
ctx context.Context, db *bbolt.DB, path [][]byte, bufferSize int,
|
||||
) (<-chan Item[*Bucket], error) {
|
||||
buffer, err := load(
|
||||
ctx, db, path, bufferSize,
|
||||
func(_, value []byte) bool {
|
||||
return value == nil
|
||||
},
|
||||
func(key, _ []byte) *Bucket {
|
||||
base := make([][]byte, 0, len(path))
|
||||
base = append(base, path...)
|
||||
|
||||
return &Bucket{
|
||||
Name: key,
|
||||
Path: append(base, key),
|
||||
}
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't start iterating bucket: %w", err)
|
||||
}
|
||||
|
||||
return buffer, nil
|
||||
}
|
||||
|
||||
func LoadRecords(
|
||||
ctx context.Context, db *bbolt.DB, path [][]byte, bufferSize int,
|
||||
) (<-chan Item[*Record], error) {
|
||||
buffer, err := load(
|
||||
ctx, db, path, bufferSize,
|
||||
func(_, value []byte) bool {
|
||||
return value != nil
|
||||
},
|
||||
func(key, value []byte) *Record {
|
||||
base := make([][]byte, 0, len(path))
|
||||
base = append(base, path...)
|
||||
|
||||
return &Record{
|
||||
Key: key,
|
||||
Value: value,
|
||||
Path: append(base, key),
|
||||
}
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't start iterating bucket: %w", err)
|
||||
}
|
||||
|
||||
return buffer, nil
|
||||
}
|
||||
|
||||
// HasBuckets checks if a bucket has nested buckets. It relies on assumption
|
||||
// that a bucket can have either nested buckets or records but not both.
|
||||
func HasBuckets(ctx context.Context, db *bbolt.DB, path [][]byte) (bool, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
buffer, err := load(
|
||||
ctx, db, path, 1,
|
||||
nil,
|
||||
func(_, value []byte) []byte { return value },
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
x, ok := <-buffer
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if x.err != nil {
|
||||
return false, err
|
||||
}
|
||||
if x.val != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
24
cmd/frostfs-lens/internal/tui/detailed.go
Normal file
24
cmd/frostfs-lens/internal/tui/detailed.go
Normal file
|
@ -0,0 +1,24 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
type DetailedView struct {
|
||||
*tview.TextView
|
||||
}
|
||||
|
||||
func NewDetailedView(detailed string) *DetailedView {
|
||||
v := &DetailedView{
|
||||
TextView: tview.NewTextView(),
|
||||
}
|
||||
v.SetDynamicColors(true)
|
||||
v.SetText(detailed)
|
||||
return v
|
||||
}
|
||||
|
||||
func (v *DetailedView) Mount(_ context.Context) error { return nil }
|
||||
func (v *DetailedView) Update(_ context.Context) error { return nil }
|
||||
func (v *DetailedView) Unmount() {}
|
44
cmd/frostfs-lens/internal/tui/filter.go
Normal file
44
cmd/frostfs-lens/internal/tui/filter.go
Normal file
|
@ -0,0 +1,44 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"maps"
|
||||
|
||||
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/schema/common"
|
||||
)
|
||||
|
||||
type Filter struct {
|
||||
values map[string]any
|
||||
results map[string]common.FilterResult
|
||||
}
|
||||
|
||||
func NewFilter(values map[string]any) *Filter {
|
||||
f := &Filter{
|
||||
values: maps.Clone(values),
|
||||
results: make(map[string]common.FilterResult),
|
||||
}
|
||||
for tag := range values {
|
||||
f.results[tag] = common.No
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *Filter) Apply(e common.SchemaEntry) *Filter {
|
||||
filter := &Filter{
|
||||
values: f.values,
|
||||
results: maps.Clone(f.results),
|
||||
}
|
||||
|
||||
for tag, value := range filter.values {
|
||||
filter.results[tag] = max(filter.results[tag], e.Filter(tag, value))
|
||||
}
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
func (f *Filter) Result() common.FilterResult {
|
||||
current := common.Yes
|
||||
for _, r := range f.results {
|
||||
current = min(r, current)
|
||||
}
|
||||
return current
|
||||
}
|
77
cmd/frostfs-lens/internal/tui/input.go
Normal file
77
cmd/frostfs-lens/internal/tui/input.go
Normal file
|
@ -0,0 +1,77 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
type InputFieldWithHistory struct {
|
||||
*tview.InputField
|
||||
history []string
|
||||
historyLimit int
|
||||
historyPointer int
|
||||
currentContent string
|
||||
}
|
||||
|
||||
func NewInputFieldWithHistory(historyLimit int) *InputFieldWithHistory {
|
||||
return &InputFieldWithHistory{
|
||||
InputField: tview.NewInputField(),
|
||||
historyLimit: historyLimit,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *InputFieldWithHistory) AddToHistory(s string) {
|
||||
// Stop scrolling history on history change, need to start scrolling again.
|
||||
defer func() { f.historyPointer = len(f.history) }()
|
||||
|
||||
// Used history data for search prompt, so just make that data recent.
|
||||
if f.historyPointer != len(f.history) && s == f.history[f.historyPointer] {
|
||||
f.history = append(f.history[:f.historyPointer], f.history[f.historyPointer+1:]...)
|
||||
f.history = append(f.history, s)
|
||||
}
|
||||
|
||||
if len(f.history) == f.historyLimit {
|
||||
f.history = f.history[1:]
|
||||
}
|
||||
f.history = append(f.history, s)
|
||||
}
|
||||
|
||||
func (f *InputFieldWithHistory) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
return f.WrapInputHandler(func(event *tcell.EventKey, _ func(tview.Primitive)) {
|
||||
switch event.Key() {
|
||||
case tcell.KeyDown:
|
||||
if len(f.history) == 0 {
|
||||
return
|
||||
}
|
||||
// Need to start iterating before.
|
||||
if f.historyPointer == len(f.history) {
|
||||
return
|
||||
}
|
||||
// Iterate to most recent prompts.
|
||||
f.historyPointer++
|
||||
// Stop iterating over history.
|
||||
if f.historyPointer == len(f.history) {
|
||||
f.InputField.SetText(f.currentContent)
|
||||
return
|
||||
}
|
||||
f.InputField.SetText(f.history[f.historyPointer])
|
||||
case tcell.KeyUp:
|
||||
if len(f.history) == 0 {
|
||||
return
|
||||
}
|
||||
// Start iterating over history.
|
||||
if f.historyPointer == len(f.history) {
|
||||
f.currentContent = f.InputField.GetText()
|
||||
}
|
||||
// End of history.
|
||||
if f.historyPointer == 0 {
|
||||
return
|
||||
}
|
||||
// Iterate to least recent prompts.
|
||||
f.historyPointer--
|
||||
f.InputField.SetText(f.history[f.historyPointer])
|
||||
default:
|
||||
f.InputField.InputHandler()(event, func(tview.Primitive) {})
|
||||
}
|
||||
})
|
||||
}
|
72
cmd/frostfs-lens/internal/tui/loading.go
Normal file
72
cmd/frostfs-lens/internal/tui/loading.go
Normal file
|
@ -0,0 +1,72 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
type LoadingBar struct {
|
||||
*tview.Box
|
||||
view *tview.TextView
|
||||
secondsElapsed atomic.Int64
|
||||
needDrawFunc func()
|
||||
reset func()
|
||||
}
|
||||
|
||||
func NewLoadingBar(needDrawFunc func()) *LoadingBar {
|
||||
b := &LoadingBar{
|
||||
Box: tview.NewBox(),
|
||||
view: tview.NewTextView(),
|
||||
needDrawFunc: needDrawFunc,
|
||||
}
|
||||
b.view.SetBackgroundColor(tview.Styles.PrimaryTextColor)
|
||||
b.view.SetTextColor(b.GetBackgroundColor())
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *LoadingBar) Start(ctx context.Context) {
|
||||
ctx, b.reset = context.WithCancel(ctx)
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
b.secondsElapsed.Store(0)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
b.secondsElapsed.Add(1)
|
||||
b.needDrawFunc()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (b *LoadingBar) Stop() {
|
||||
b.reset()
|
||||
}
|
||||
|
||||
func (b *LoadingBar) Draw(screen tcell.Screen) {
|
||||
seconds := b.secondsElapsed.Load()
|
||||
|
||||
var time string
|
||||
switch {
|
||||
case seconds < 60:
|
||||
time = fmt.Sprintf("%ds", seconds)
|
||||
default:
|
||||
time = fmt.Sprintf("%dm%ds", seconds/60, seconds%60)
|
||||
}
|
||||
b.view.SetText(fmt.Sprintf(" Loading... %s (press Escape to cancel) ", time))
|
||||
|
||||
x, y, width, _ := b.GetInnerRect()
|
||||
b.view.SetRect(x, y, width, 1)
|
||||
b.view.Draw(screen)
|
||||
}
|
271
cmd/frostfs-lens/internal/tui/records.go
Normal file
271
cmd/frostfs-lens/internal/tui/records.go
Normal file
|
@ -0,0 +1,271 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/schema/common"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
type updateType int
|
||||
|
||||
const (
|
||||
other updateType = iota
|
||||
moveToPrevPage
|
||||
moveToNextPage
|
||||
moveUp
|
||||
moveDown
|
||||
moveHome
|
||||
moveEnd
|
||||
)
|
||||
|
||||
type RecordsView struct {
|
||||
*tview.Box
|
||||
|
||||
mu sync.RWMutex
|
||||
|
||||
onUnmount func()
|
||||
|
||||
bucket *Bucket
|
||||
records []*Record
|
||||
|
||||
buffer chan *Record
|
||||
|
||||
firstRecordIndex int
|
||||
lastRecordIndex int
|
||||
selectedRecordIndex int
|
||||
|
||||
updateType updateType
|
||||
|
||||
ui *UI
|
||||
filter *Filter
|
||||
}
|
||||
|
||||
func NewRecordsView(ui *UI, bucket *Bucket, filter *Filter) *RecordsView {
|
||||
return &RecordsView{
|
||||
Box: tview.NewBox(),
|
||||
bucket: bucket,
|
||||
ui: ui,
|
||||
filter: filter,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *RecordsView) Mount(ctx context.Context) error {
|
||||
if v.onUnmount != nil {
|
||||
return errors.New("try to mount already mounted component")
|
||||
}
|
||||
|
||||
ctx, v.onUnmount = context.WithCancel(ctx)
|
||||
|
||||
tempBuffer, err := LoadRecords(ctx, v.ui.db, v.bucket.Path, v.ui.loadBufferSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
v.buffer = make(chan *Record, v.ui.loadBufferSize)
|
||||
go func() {
|
||||
defer close(v.buffer)
|
||||
|
||||
for item := range tempBuffer {
|
||||
if item.err != nil {
|
||||
v.ui.stopOnError(err)
|
||||
break
|
||||
}
|
||||
record := item.val
|
||||
|
||||
record.Entry, _, err = v.bucket.NextParser(record.Key, record.Value)
|
||||
if err != nil {
|
||||
v.ui.stopOnError(err)
|
||||
break
|
||||
}
|
||||
|
||||
if v.filter.Apply(record.Entry).Result() != common.Yes {
|
||||
continue
|
||||
}
|
||||
|
||||
v.buffer <- record
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *RecordsView) Unmount() {
|
||||
if v.onUnmount == nil {
|
||||
panic("try to unmount not mounted component")
|
||||
}
|
||||
v.onUnmount()
|
||||
v.onUnmount = nil
|
||||
}
|
||||
|
||||
func (v *RecordsView) Update(ctx context.Context) error {
|
||||
_, _, _, recordsPerPage := v.GetInnerRect()
|
||||
firstRecordIndex, lastRecordIndex, selectedRecordIndex := v.getNewIndexes()
|
||||
|
||||
loop:
|
||||
for len(v.records) < lastRecordIndex {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case record, ok := <-v.buffer:
|
||||
if !ok {
|
||||
break loop
|
||||
}
|
||||
v.records = append(v.records, record)
|
||||
}
|
||||
}
|
||||
|
||||
// Set the update type to its default value after some specific key event
|
||||
// has been handled.
|
||||
v.updateType = other
|
||||
|
||||
firstRecordIndex = max(0, min(firstRecordIndex, len(v.records)-recordsPerPage))
|
||||
lastRecordIndex = min(firstRecordIndex+recordsPerPage, len(v.records))
|
||||
selectedRecordIndex = min(selectedRecordIndex, lastRecordIndex-1)
|
||||
|
||||
v.mu.Lock()
|
||||
v.firstRecordIndex = firstRecordIndex
|
||||
v.lastRecordIndex = lastRecordIndex
|
||||
v.selectedRecordIndex = selectedRecordIndex
|
||||
v.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *RecordsView) getNewIndexes() (int, int, int) {
|
||||
v.mu.RLock()
|
||||
firstRecordIndex := v.firstRecordIndex
|
||||
lastRecordIndex := v.lastRecordIndex
|
||||
selectedRecordIndex := v.selectedRecordIndex
|
||||
v.mu.RUnlock()
|
||||
|
||||
_, _, _, recordsPerPage := v.GetInnerRect()
|
||||
|
||||
switch v.updateType {
|
||||
case moveUp:
|
||||
if selectedRecordIndex != firstRecordIndex {
|
||||
selectedRecordIndex--
|
||||
break
|
||||
}
|
||||
firstRecordIndex = max(0, firstRecordIndex-1)
|
||||
lastRecordIndex = min(firstRecordIndex+recordsPerPage, len(v.records))
|
||||
selectedRecordIndex = firstRecordIndex
|
||||
case moveToPrevPage:
|
||||
if selectedRecordIndex != firstRecordIndex {
|
||||
selectedRecordIndex = firstRecordIndex
|
||||
break
|
||||
}
|
||||
firstRecordIndex = max(0, firstRecordIndex-recordsPerPage)
|
||||
lastRecordIndex = firstRecordIndex + recordsPerPage
|
||||
selectedRecordIndex = firstRecordIndex
|
||||
case moveDown:
|
||||
if selectedRecordIndex != lastRecordIndex-1 {
|
||||
selectedRecordIndex++
|
||||
break
|
||||
}
|
||||
firstRecordIndex++
|
||||
lastRecordIndex++
|
||||
selectedRecordIndex++
|
||||
case moveToNextPage:
|
||||
if selectedRecordIndex != lastRecordIndex-1 {
|
||||
selectedRecordIndex = lastRecordIndex - 1
|
||||
break
|
||||
}
|
||||
firstRecordIndex += recordsPerPage
|
||||
lastRecordIndex = firstRecordIndex + recordsPerPage
|
||||
selectedRecordIndex = lastRecordIndex - 1
|
||||
case moveHome:
|
||||
firstRecordIndex = 0
|
||||
lastRecordIndex = firstRecordIndex + recordsPerPage
|
||||
selectedRecordIndex = 0
|
||||
case moveEnd:
|
||||
lastRecordIndex = math.MaxInt32
|
||||
firstRecordIndex = lastRecordIndex - recordsPerPage
|
||||
selectedRecordIndex = lastRecordIndex - 1
|
||||
default:
|
||||
lastRecordIndex = firstRecordIndex + recordsPerPage
|
||||
}
|
||||
|
||||
return firstRecordIndex, lastRecordIndex, selectedRecordIndex
|
||||
}
|
||||
|
||||
func (v *RecordsView) GetInnerRect() (int, int, int, int) {
|
||||
x, y, width, height := v.Box.GetInnerRect()
|
||||
|
||||
// Left padding.
|
||||
x = min(x+3, x+width-1)
|
||||
width = max(width-3, 0)
|
||||
|
||||
return x, y, width, height
|
||||
}
|
||||
|
||||
func (v *RecordsView) Draw(screen tcell.Screen) {
|
||||
v.mu.RLock()
|
||||
firstRecordIndex := v.firstRecordIndex
|
||||
lastRecordIndex := v.lastRecordIndex
|
||||
selectedRecordIndex := v.selectedRecordIndex
|
||||
records := v.records
|
||||
v.mu.RUnlock()
|
||||
|
||||
v.DrawForSubclass(screen, v)
|
||||
|
||||
x, y, width, height := v.GetInnerRect()
|
||||
if height == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// No records in that bucket.
|
||||
if firstRecordIndex == lastRecordIndex {
|
||||
tview.Print(
|
||||
screen, "Empty Bucket", x, y, width, tview.AlignCenter, tview.Styles.PrimaryTextColor,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
for index := firstRecordIndex; index < lastRecordIndex; index++ {
|
||||
result := records[index].Entry
|
||||
text := result.String()
|
||||
|
||||
if index == selectedRecordIndex {
|
||||
text = fmt.Sprintf("[:white]%s[:-]", text)
|
||||
tview.Print(screen, text, x, y, width, tview.AlignLeft, tview.Styles.PrimitiveBackgroundColor)
|
||||
} else {
|
||||
tview.Print(screen, text, x, y, width, tview.AlignLeft, tview.Styles.PrimaryTextColor)
|
||||
}
|
||||
|
||||
y++
|
||||
}
|
||||
}
|
||||
|
||||
func (v *RecordsView) InputHandler() func(event *tcell.EventKey, _ func(p tview.Primitive)) {
|
||||
return v.WrapInputHandler(func(event *tcell.EventKey, _ func(p tview.Primitive)) {
|
||||
switch m, k := event.Modifiers(), event.Key(); {
|
||||
case m == 0 && k == tcell.KeyPgUp:
|
||||
v.updateType = moveToPrevPage
|
||||
case m == 0 && k == tcell.KeyPgDn:
|
||||
v.updateType = moveToNextPage
|
||||
case m == 0 && k == tcell.KeyUp:
|
||||
v.updateType = moveUp
|
||||
case m == 0 && k == tcell.KeyDown:
|
||||
v.updateType = moveDown
|
||||
case m == 0 && k == tcell.KeyHome:
|
||||
v.updateType = moveHome
|
||||
case m == 0 && k == tcell.KeyEnd:
|
||||
v.updateType = moveEnd
|
||||
case k == tcell.KeyEnter:
|
||||
v.mu.RLock()
|
||||
selectedRecordIndex := v.selectedRecordIndex
|
||||
records := v.records
|
||||
v.mu.RUnlock()
|
||||
if len(records) != 0 {
|
||||
current := records[selectedRecordIndex]
|
||||
v.ui.moveNextPage(NewDetailedView(current.Entry.DetailedString()))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
18
cmd/frostfs-lens/internal/tui/types.go
Normal file
18
cmd/frostfs-lens/internal/tui/types.go
Normal file
|
@ -0,0 +1,18 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/schema/common"
|
||||
)
|
||||
|
||||
type Bucket struct {
|
||||
Name []byte
|
||||
Path [][]byte
|
||||
Entry common.SchemaEntry
|
||||
NextParser common.Parser
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
Key, Value []byte
|
||||
Path [][]byte
|
||||
Entry common.SchemaEntry
|
||||
}
|
548
cmd/frostfs-lens/internal/tui/ui.go
Normal file
548
cmd/frostfs-lens/internal/tui/ui.go
Normal file
|
@ -0,0 +1,548 @@
|
|||
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
|
||||
}
|
97
cmd/frostfs-lens/internal/tui/util.go
Normal file
97
cmd/frostfs-lens/internal/tui/util.go
Normal file
|
@ -0,0 +1,97 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
|
||||
oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id"
|
||||
"github.com/mr-tron/base58"
|
||||
)
|
||||
|
||||
func CIDParser(s string) (any, error) {
|
||||
data, err := base58.Decode(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var id cid.ID
|
||||
if err = id.Decode(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func OIDParser(s string) (any, error) {
|
||||
data, err := base58.Decode(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var id oid.ID
|
||||
if err = id.Decode(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func AddressParser(s string) (map[string]any, error) {
|
||||
m := make(map[string]any)
|
||||
|
||||
parts := strings.Split(s, "/")
|
||||
if len(parts) != 2 {
|
||||
return nil, errors.New("expected <cid>/<oid>")
|
||||
}
|
||||
cnr, err := CIDParser(parts[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj, err := OIDParser(parts[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m["cid"] = cnr
|
||||
m["oid"] = obj
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func keyParser(s string) (any, error) {
|
||||
if s == "" {
|
||||
return nil, errors.New("empty attribute key")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func valueParser(s string) (any, error) {
|
||||
if s == "" {
|
||||
return nil, errors.New("empty attribute value")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func AttributeParser(s string) (map[string]any, error) {
|
||||
m := make(map[string]any)
|
||||
|
||||
parts := strings.Split(s, "/")
|
||||
if len(parts) != 1 && len(parts) != 2 {
|
||||
return nil, errors.New("expected <key> or <key>/<value>")
|
||||
}
|
||||
|
||||
key, err := keyParser(parts[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m["key"] = key
|
||||
|
||||
if len(parts) == 1 {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
value, err := valueParser(parts[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m["value"] = value
|
||||
|
||||
return m, nil
|
||||
}
|
3
go.mod
3
go.mod
|
@ -32,6 +32,7 @@ require (
|
|||
github.com/olekukonko/tablewriter v0.0.5
|
||||
github.com/panjf2000/ants/v2 v2.9.0
|
||||
github.com/prometheus/client_golang v1.19.0
|
||||
github.com/rivo/tview v0.0.0-20240625185742-b0a7293b8130
|
||||
github.com/spf13/cast v1.6.0
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/pflag v1.0.5
|
||||
|
@ -106,7 +107,7 @@ require (
|
|||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
|
|
BIN
go.sum
BIN
go.sum
Binary file not shown.
Loading…
Reference in a new issue