[#1223] lens/tui: add records view and detailed view

Signed-off-by: Aleksey Savchuk <a.savchuk@yadro.com>
This commit is contained in:
Aleksey Savchuk 2024-08-06 01:10:55 +03:00
parent 172492a9be
commit b05fb6b767
No known key found for this signature in database
19 changed files with 764 additions and 320 deletions

View file

@ -1,6 +1,7 @@
package meta
import (
"context"
"fmt"
common "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal"
@ -54,12 +55,15 @@ func runTUI(cmd *cobra.Command) error {
common.ExitOnErr(cmd, err)
defer db.Close()
// ctx, cancel := context.WithCancel(cmd.Context())
// defer cancel()
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
app := tview.NewApplication()
ui := lib.NewUI(ctx, app, db)
ui.AddFilter("cid", func(s string) (any, error) { return s, nil })
ui.AddFilter("oid", func(s string) (any, error) { return s, nil })
ui := lib.NewUI(app, db)
app.SetRoot(ui, true).SetFocus(ui)
return app.Run()

View file

@ -1,9 +1,7 @@
package common
import (
"fmt"
"github.com/mr-tron/base58/base58"
"github.com/davecgh/go-spew/spew"
)
type Raw struct {
@ -17,9 +15,42 @@ func rawParser(key, value []byte) (SchemaEntry, Parser, error) {
}
func (r *Raw) String() string {
return fmt.Sprintf("[red]%+v %+v[white]", base58.Encode(r.key), base58.Encode(r.value))
return "[red]can't parse[white]"
}
func (r *Raw) DetailedString() string {
return spew.Sdump(r)
}
func (r *Raw) Filter(string, any) FilterResult {
return No
}
func WithErrorHandling(parser Parser) Parser {
if parser == nil {
return rawParser
}
return func(key, value []byte) (SchemaEntry, Parser, error) {
entry, next, err := parser(key, value)
if err != nil {
return &Error{err: err}, rawParser, nil
}
return entry, WithErrorHandling(next), err
}
}
type Error struct {
err error
}
func (e *Error) String() string {
return e.err.Error()
}
func (e *Error) DetailedString() string {
return spew.Sdump(e)
}
func (e *Error) Filter(string, any) FilterResult {
return No
}

View file

@ -25,6 +25,7 @@ func IfThenElse(condition bool, onSuccess, onFailure FilterResult) FilterResult
type SchemaEntry interface {
String() string
DetailedString() string
Filter(typ string, val any) FilterResult
}

View file

@ -2,7 +2,6 @@ package metabase
import (
"errors"
"fmt"
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/schema/common"
cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
@ -169,96 +168,3 @@ func NewValueBucketParser(next common.Parser, resolvers Resolvers) common.Parser
return &b, next, nil
}
}
func (b *PrefixBucket) String() string {
return b.prefix.String()
}
func (b *PrefixBucket) Filter(typ string, _ any) common.FilterResult {
switch typ {
case "cid":
return b.resolvers.cidResolver(false)
case "oid":
return b.resolvers.oidResolver(false)
default:
return common.No
}
}
func (b *PrefixContainerBucket) String() string {
return fmt.Sprintf("%s CID %s", b.prefix, b.id)
}
func (b *PrefixContainerBucket) Filter(typ string, val any) common.FilterResult {
switch typ {
case "cid":
id := val.(cid.ID)
return b.resolvers.cidResolver(b.id.Equals(id))
case "oid":
return b.resolvers.oidResolver(false)
default:
return common.No
}
}
func (b *PrefixContainerKeyBucket) String() string {
return fmt.Sprintf("%s CID %s %s", b.prefix, b.id, b.key)
}
func (b *PrefixContainerKeyBucket) Filter(typ string, val any) common.FilterResult {
switch typ {
case "cid":
id := val.(cid.ID)
return b.resolvers.cidResolver(b.id.Equals(id))
case "oid":
return b.resolvers.oidResolver(false)
default:
return common.No
}
}
func (b *UserBucket) String() string {
return fmt.Sprintf("UID %s", b.id)
}
func (b *UserBucket) Filter(typ string, _ any) common.FilterResult {
switch typ {
case "cid":
return b.resolvers.cidResolver(false)
case "oid":
return b.resolvers.oidResolver(false)
default:
return common.No
}
}
func (b *ContainerBucket) String() string {
return fmt.Sprintf("CID %s", b.id)
}
func (b *ContainerBucket) Filter(typ string, val any) common.FilterResult {
switch typ {
case "cid":
id := val.(cid.ID)
return b.resolvers.cidResolver(b.id.Equals(id))
case "oid":
return b.resolvers.oidResolver(false)
default:
return common.No
}
}
func (b *ValueBucket) String() string {
return b.value
}
func (b *ValueBucket) Filter(typ string, _ any) common.FilterResult {
switch typ {
case "cid":
return b.resolvers.cidResolver(false)
case "oid":
return b.resolvers.oidResolver(false)
default:
return common.No
}
}

View file

@ -0,0 +1,27 @@
package metabase
import "fmt"
func (b *PrefixBucket) DetailedString() string {
return b.prefix.String()
}
func (b *PrefixContainerBucket) DetailedString() string {
return fmt.Sprintf("%s CID [red]%s[white]", b.prefix, b.id)
}
func (b *PrefixContainerKeyBucket) DetailedString() string {
return fmt.Sprintf("%s CID [red]%-44s[white] %s", b.prefix, b.id, b.key)
}
func (b *UserBucket) DetailedString() string {
return fmt.Sprintf("UID [red]%s[white]", b.id)
}
func (b *ContainerBucket) DetailedString() string {
return fmt.Sprintf("CID [red]%s[white]", b.id)
}
func (b *ValueBucket) DetailedString() string {
return b.value
}

View file

@ -0,0 +1,75 @@
package metabase
import (
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/schema/common"
cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
)
func (b *PrefixBucket) Filter(typ string, _ any) common.FilterResult {
switch typ {
case "cid":
return b.resolvers.cidResolver(false)
case "oid":
return b.resolvers.oidResolver(false)
default:
return common.No
}
}
func (b *PrefixContainerBucket) Filter(typ string, val any) common.FilterResult {
switch typ {
case "cid":
id := val.(cid.ID)
return b.resolvers.cidResolver(b.id.Equals(id))
case "oid":
return b.resolvers.oidResolver(false)
default:
return common.No
}
}
func (b *PrefixContainerKeyBucket) Filter(typ string, val any) common.FilterResult {
switch typ {
case "cid":
id := val.(cid.ID)
return b.resolvers.cidResolver(b.id.Equals(id))
case "oid":
return b.resolvers.oidResolver(false)
default:
return common.No
}
}
func (b *UserBucket) Filter(typ string, _ any) common.FilterResult {
switch typ {
case "cid":
return b.resolvers.cidResolver(false)
case "oid":
return b.resolvers.oidResolver(false)
default:
return common.No
}
}
func (b *ContainerBucket) Filter(typ string, val any) common.FilterResult {
switch typ {
case "cid":
id := val.(cid.ID)
return b.resolvers.cidResolver(b.id.Equals(id))
case "oid":
return b.resolvers.oidResolver(false)
default:
return common.No
}
}
func (b *ValueBucket) Filter(typ string, _ any) common.FilterResult {
switch typ {
case "cid":
return b.resolvers.cidResolver(false)
case "oid":
return b.resolvers.oidResolver(false)
default:
return common.No
}
}

View file

@ -6,6 +6,28 @@ import (
)
var (
// MetabaseParser = common.WithErrorHandling(
// common.Any(
// GraveyardParser,
// GarbageParser,
// ContainerVolumeParser,
// LockedParser,
// ShardInfoParser,
// PrimaryParser,
// LockersParser,
// TombstoneParser,
// SmallParser,
// RootParser,
// OwnerParser,
// UserAttributeParser,
// PayloadHashParser,
// ParentParser,
// SplitParser,
// ContainerCountersParser,
// ECInfoParser,
// ),
// )
MetabaseParser = common.WithFallback(
common.Any(
GraveyardParser,

View file

@ -0,0 +1,65 @@
package records
import (
"github.com/davecgh/go-spew/spew"
)
func (r *GraveyardRecord) DetailedString() string {
return spew.Sdump(r)
}
func (r *GarbageRecord) DetailedString() string {
return spew.Sdump(r)
}
func (r *ContainerVolumeRecord) DetailedString() string {
return spew.Sdump(r)
}
func (r *LockedRecord) DetailedString() string {
return spew.Sdump(r)
}
func (r *ShardInfoRecord) DetailedString() string {
return spew.Sdump(r)
}
func (r *ObjectRecord) DetailedString() string {
return spew.Sdump(r)
}
func (r *SmallRecord) DetailedString() string {
return spew.Sdump(r)
}
func (r *RootRecord) DetailedString() string {
return spew.Sdump(r)
}
func (r *OwnerRecord) DetailedString() string {
return spew.Sdump(r)
}
func (r *UserAttributeRecord) DetailedString() string {
return spew.Sdump(r)
}
func (r *PayloadHashRecord) DetailedString() string {
return spew.Sdump(r)
}
func (r *ParentRecord) DetailedString() string {
return spew.Sdump(r)
}
func (r *SplitRecord) DetailedString() string {
return spew.Sdump(r)
}
func (r *ContainerCountersRecord) DetailedString() string {
return spew.Sdump(r)
}
func (r *ECInfoRecord) DetailedString() string {
return spew.Sdump(r)
}

View file

@ -3,6 +3,8 @@ package records
import (
"encoding/binary"
"errors"
"fmt"
"os"
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/schema/common"
cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
@ -48,9 +50,6 @@ func GarbageRecordParser(key, value []byte) (common.SchemaEntry, common.Parser,
if len(key) != 64 {
return nil, nil, ErrInvalidKeyLength
}
if len(value) != 0 {
return nil, nil, ErrInvalidValueLength
}
var (
cnr cid.ID
obj oid.ID
@ -101,6 +100,7 @@ func ShardInfoRecordParser(key, value []byte) (common.SchemaEntry, common.Parser
return nil, nil, ErrInvalidKeyLength
}
if len(value) != 8 {
fmt.Fprintln(os.Stderr, key, value, string(key), string(value))
return nil, nil, ErrInvalidValueLength
}
var r ShardInfoRecord
@ -152,9 +152,6 @@ func RootRecordParser(key, value []byte) (common.SchemaEntry, common.Parser, err
}
func OwnerRecordParser(key, value []byte) (common.SchemaEntry, common.Parser, error) {
if len(value) != 0 {
return nil, nil, ErrInvalidValueLength
}
var r OwnerRecord
if err := r.id.Decode(key); err != nil {
return nil, nil, err
@ -163,9 +160,6 @@ func OwnerRecordParser(key, value []byte) (common.SchemaEntry, common.Parser, er
}
func UserAttributeRecordParser(key, value []byte) (common.SchemaEntry, common.Parser, error) {
if len(value) != 0 {
return nil, nil, ErrInvalidValueLength
}
var r UserAttributeRecord
if err := r.id.Decode(key); err != nil {
return nil, nil, err

View file

@ -3,35 +3,41 @@ package records
import "fmt"
func (r *GraveyardRecord) String() string {
return fmt.Sprintf("%s %s", r.object, r.tombstone)
return fmt.Sprintf(
"Object CID [red]%-44s[white] OID [red]%-44s[white] | Tombstone CID [red]%-44s[white] OID [red]%-44s[white]",
r.object.Container(), r.object.Object(), r.tombstone.Container(), r.object.Object(),
)
}
func (r *GarbageRecord) String() string {
return r.addr.String()
return fmt.Sprintf(
"CID [red]%-44s[white] OID [red]%-44s[white]",
r.addr.Container(), r.addr.Object(),
)
}
func (r *ContainerVolumeRecord) String() string {
return fmt.Sprintf("%s %d", r.id, r.volume)
return fmt.Sprintf("CID [red]%-44s[white] | %d", r.id, r.volume)
}
func (r *LockedRecord) String() string {
return fmt.Sprintf("%s %v", r.id, r.ids)
return fmt.Sprintf("Locker OID [red]%-44s[white] | Locked [%d]OID {...}", r.id, len(r.ids))
}
func (r *ShardInfoRecord) String() string {
return fmt.Sprintf("%s %d", r.label, r.value)
return fmt.Sprintf("%-13s | %d", r.label, r.value)
}
func (r *ObjectRecord) String() string {
return fmt.Sprintf("%s %+v", r.id, r.object)
return fmt.Sprintf("OID [red]%-44s[white] | Object {...}", r.id)
}
func (r *SmallRecord) String() string {
var s string
if r.storageID != nil {
s = fmt.Sprintf("OID %s %s", r.id, *r.storageID)
s = fmt.Sprintf("OID [red]%-44s[white] | Path %s", r.id, *r.storageID)
} else {
s = fmt.Sprintf("OID %s", r.id)
s = fmt.Sprintf("OID [red]%-44s[white]", r.id)
}
return s
}
@ -39,37 +45,40 @@ func (r *SmallRecord) String() string {
func (r *RootRecord) String() string {
var s string
if r.info != nil {
s = fmt.Sprintf("OID %s %v", r.id, *r.info)
s = fmt.Sprintf("Root OID [red]%-44s[white] | Split info {...}", r.id)
} else {
s = fmt.Sprintf("OID %s", r.id)
s = fmt.Sprintf("Root OID [red]%-44s[white]", r.id)
}
return s
}
func (r *OwnerRecord) String() string {
return fmt.Sprintf("OID %s", r.id)
return fmt.Sprintf("OID [red]%s[white]", r.id)
}
func (r *UserAttributeRecord) String() string {
return fmt.Sprintf("OID %s", r.id)
return fmt.Sprintf("OID [red]%s[white]", r.id)
}
func (r *PayloadHashRecord) String() string {
return fmt.Sprintf("%s [...]", r.checksum)
return fmt.Sprintf("Checksum [red]%32s[white] | [%d]OID {...}", r.checksum, len(r.ids))
}
func (r *ParentRecord) String() string {
return fmt.Sprintf("OID %s %v", r.parent, r.ids)
return fmt.Sprintf("Parent OID [red]%-44s[white] | [%d]OID {...}", r.parent, len(r.ids))
}
func (r *SplitRecord) String() string {
return fmt.Sprintf("Split ID %s %v", r.id, r.ids)
return fmt.Sprintf("Split ID [red]%32s[white] | [%d]OID {...}", r.id, len(r.ids))
}
func (r *ContainerCountersRecord) String() string {
return fmt.Sprintf("%s %d %d %d", r.id, r.logical, r.physical, r.user)
return fmt.Sprintf(
"CID [red]%-44s[white] | logical %d, physical %d, user %d",
r.id, r.logical, r.physical, r.user,
)
}
func (r *ECInfoRecord) String() string {
return fmt.Sprintf("%s %v", r.id, r.ids)
return fmt.Sprintf("OID [red]%-44s[white] | [%d]OID {...}", r.id, len(r.ids))
}

View file

@ -0,0 +1,27 @@
package metabase
import "fmt"
func (b *PrefixBucket) String() string {
return b.prefix.String()
}
func (b *PrefixContainerBucket) String() string {
return fmt.Sprintf("%s CID [red]%s[white]", b.prefix, b.id)
}
func (b *PrefixContainerKeyBucket) String() string {
return fmt.Sprintf("%s CID [red]%-44s[white] %s", b.prefix, b.id, b.key)
}
func (b *UserBucket) String() string {
return fmt.Sprintf("UID [red]%s[white]", b.id)
}
func (b *ContainerBucket) String() string {
return fmt.Sprintf("CID [red]%s[white]", b.id)
}
func (b *ValueBucket) String() string {
return b.value
}

View file

@ -14,15 +14,6 @@ import (
"go.etcd.io/bbolt"
)
// 29NkY3QtH6r8Hd9RBejwtUuju1ViAQ6jZPR7pjuTnkoH
// 1QVJo3LCACFnbeqJ7uHz97PyoPrkZr4BnZ9cVYAhRPx
// 6E6b24qy32p3L3wjRSUEGNFHMxkUsaqj7udwWVKLzkU
// 1GcnNjFnPof2YbPi2RBi3Sjy3qeR2cymc9BR6i9U2Kt
// 9wynaSKbhrkpQe7knLZvX8XQDW2hk3ZHJnjaLpXsWHtz
// h9MW74svQ8hufDVcaAdyYiaSDZWeTEWo32aqKbTUqAu
type Bucket struct {
Name []byte
Path [][]byte
@ -32,10 +23,10 @@ type Bucket struct {
}
type Record struct {
key []byte
value []byte
path [][]byte
result common.SchemaEntry
Key []byte
Value []byte
Path [][]byte
Result common.SchemaEntry
}
type Application struct {

View file

@ -126,9 +126,9 @@ func LoadRecords(ctx context.Context, db *bbolt.DB, path [][]byte) (<-chan *Reco
base = append(base, path...)
return &Record{
key: key,
value: value,
path: append(base, key),
Key: key,
Value: value,
Path: append(base, key),
}
},
)

View file

@ -84,7 +84,11 @@ func (v *BucketsView) Update(ctx context.Context) error {
}
func (v *BucketsView) Unmount() {
if v.onUnmount == nil {
panic("try to unmount not mounted component")
}
v.onUnmount()
v.onUnmount = nil
}
func (v *BucketsView) Draw(screen tcell.Screen) {
@ -109,7 +113,7 @@ func (v *BucketsView) getChildren(ctx context.Context, parent *tview.TreeNode) e
for bucket := range buffer {
// if parent.GetText() != "." {
// <-time.After(500 * time.Millisecond)
// <-time.After(10 * time.Millisecond)
// }
res, next, err := handler(bucket.Name, nil)
@ -137,8 +141,19 @@ func (v *BucketsView) InputHandler() func(event *tcell.EventKey, setFocus func(p
switch m, k := event.Modifiers(), event.Key(); {
case k == tcell.KeyEnter:
v.needExpand = true
case m&tcell.ModAlt == 0 && k == tcell.KeyRight:
v.ui.moveNextPage(nil)
case k == tcell.KeyRune && event.Rune() == 'z':
current := v.view.GetCurrentNode()
if current == nil {
return
}
v.ui.moveNextPage(NewRecordsView(v.ui, current.GetReference().(*tuiutil.Bucket)))
case m&tcell.ModCtrl != 0 && k == tcell.KeyRight:
current := v.view.GetCurrentNode()
if current == nil {
return
}
bucket := current.GetReference().(*tuiutil.Bucket)
v.ui.moveNextPage(NewRecordsView(v.ui, bucket))
default:
v.view.InputHandler()(event, func(tview.Primitive) {})
}

View file

@ -0,0 +1,45 @@
package lib
import (
"context"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type DetailedView struct {
*tview.Box
view *tview.TextView
}
func NewDetailedView(detailed string) *DetailedView {
v := &DetailedView{
Box: tview.NewBox(),
view: tview.NewTextView(),
}
v.view.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() {}
func (v *DetailedView) Draw(screen tcell.Screen) {
x, y, width, height := v.GetInnerRect()
v.view.SetRect(x, y, width, height)
v.view.Draw(screen)
}
func (v *DetailedView) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
return v.WrapInputHandler(func(event *tcell.EventKey, _ func(tview.Primitive)) {
v.view.InputHandler()(event, func(tview.Primitive) {})
})
}

View file

@ -3,90 +3,63 @@ package lib
import (
"context"
"fmt"
"sync/atomic"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type LoadingScreen struct {
type LoadingBar struct {
*tview.Box
view *tview.TextView
startTime time.Time
timeElapsed time.Duration
onUnmount func()
onCancel func()
onUpdate func()
view *tview.TextView
secondsElapsed atomic.Int64
needDrawFunc func()
reset func()
}
func NewLoadingScreen(onCancel func(), onUpdate func()) *LoadingScreen {
ls := &LoadingScreen{
Box: tview.NewBox(),
view: tview.NewTextView(),
onCancel: onCancel,
onUpdate: onUpdate,
func NewLoadingBar(needDrawFunc func()) *LoadingBar {
b := &LoadingBar{
Box: tview.NewBox(),
view: tview.NewTextView(),
needDrawFunc: needDrawFunc,
}
ls.view.SetBackgroundColor(tview.Styles.PrimaryTextColor)
ls.view.SetTextColor(ls.GetBackgroundColor())
b.view.SetBackgroundColor(tview.Styles.PrimaryTextColor)
b.view.SetTextColor(b.GetBackgroundColor())
return ls
return b
}
func (ls *LoadingScreen) Mount(ctx context.Context) error {
ctx, ls.onUnmount = context.WithCancel(ctx)
onCancel := ls.onCancel
ls.onCancel = func() {
ls.onUnmount()
onCancel()
}
ls.startTime = time.Now()
func (b *LoadingBar) Start(ctx context.Context) {
ctx, b.reset = context.WithCancel(ctx)
go func() {
ticker := time.NewTicker(time.Second)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
b.secondsElapsed.Store(0)
for {
select {
case <-ctx.Done():
ls.onUpdate()
return
case <-ticker.C:
ls.onUpdate()
b.secondsElapsed.Add(1)
b.needDrawFunc()
}
}
}()
return nil
}
func (ls *LoadingScreen) Unmount() {
ls.onUnmount()
func (b *LoadingBar) Stop() {
b.reset()
}
func (ls *LoadingScreen) Update(_ context.Context) error {
ls.timeElapsed = time.Since(ls.startTime)
return nil
}
func (ls *LoadingScreen) Draw(screen tcell.Screen) {
ls.DrawForSubclass(screen, ls)
text := fmt.Sprintf(" Loading... %.0fs (press Escape to cancel) ", ls.timeElapsed.Seconds())
ls.view.SetText(text)
x, y, width, height := ls.GetInnerRect()
ls.view.SetRect(x, y, min(len(text), width), height)
ls.view.Draw(screen)
}
func (ls *LoadingScreen) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
return ls.WrapInputHandler(func(event *tcell.EventKey, _ func(tview.Primitive)) {
if event.Key() == tcell.KeyEsc {
ls.onCancel()
}
})
func (b *LoadingBar) Draw(screen tcell.Screen) {
b.view.SetText(fmt.Sprintf(
" Loading... %ds (press Escape to cancel) ", b.secondsElapsed.Load(),
))
x, y, width, _ := b.GetInnerRect()
b.view.SetRect(x, y, width, 1)
b.view.Draw(screen)
}

View file

@ -1 +1,210 @@
package lib
import (
"context"
"fmt"
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/tuiutil"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type RecordsView struct {
*tview.Box
ui *UI
onUnmount func()
bucket *tuiutil.Bucket
records []*tuiutil.Record
buffer chan *tuiutil.Record
firstRecordIndex int
lastRecordIndex int
selectedRecordIndex int
mainTextStyle tcell.Style
selectedTextStyle tcell.Style
}
func NewRecordsView(ui *UI, bucket *tuiutil.Bucket) *RecordsView {
view := &RecordsView{
Box: tview.NewBox(),
ui: ui,
bucket: bucket,
}
// view.mainTextStyle = tcell.StyleDefault.
// Background(ui.GetBackgroundColor()).
// Foreground(tview.Styles.PrimaryTextColor)
// view.selectedTextStyle = tcell.StyleDefault.
// Background(tview.Styles.PrimaryTextColor).
// Foreground(ui.GetBackgroundColor())
return view
}
func (v *RecordsView) Mount(ctx context.Context) error {
ctx, v.onUnmount = context.WithCancel(ctx)
tempBuffer, err := tuiutil.LoadRecords(ctx, v.ui.db, v.bucket.Path)
if err != nil {
return err
}
v.buffer = make(chan *tuiutil.Record, 100)
go func() {
defer close(v.buffer)
for record := range tempBuffer {
result, _, err := v.bucket.NextHandler(record.Key, record.Value)
if err != nil {
panic(err)
}
record.Result = result
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()
newLastRecordIndex := v.firstRecordIndex + recordsPerPage
// The terminal's been resized and become shorter.
if v.lastRecordIndex > newLastRecordIndex {
v.lastRecordIndex = newLastRecordIndex
// If the selected record is invisible, select the last one visible.
v.selectedRecordIndex = min(v.selectedRecordIndex, v.lastRecordIndex-1)
return nil
}
for len(v.records) < newLastRecordIndex {
record, ok := <-v.buffer
if !ok {
break
}
v.records = append(v.records, record)
}
v.lastRecordIndex = min(len(v.records), newLastRecordIndex)
return nil
}
func (v *RecordsView) Draw(screen tcell.Screen) {
x, y, width, height := v.GetInnerRect()
if height == 0 {
v.DrawForSubclass(screen, v)
return
}
// pageNum := v.firstRecordIndex/height + 1
// title := v.GetTitle()
// if title != "" {
// v.SetTitle(fmt.Sprintf("%s: page %d", title, pageNum))
// } else {
// v.SetTitle(fmt.Sprintf("page %d", pageNum))
// }
v.DrawForSubclass(screen, v)
for index := v.firstRecordIndex; index < v.lastRecordIndex; index++ {
result := v.records[index].Result
text := result.String()
if index == v.selectedRecordIndex {
text = fmt.Sprintf("[:white]%s[:black]", text)
}
tview.Print(screen, text, x, y, width, tview.AlignLeft, tview.Styles.PrimaryTextColor)
y++
}
// v.SetTitle(title)
}
func (v *RecordsView) moveToPrevPage() {
// On the first page.
if v.firstRecordIndex == 0 {
return
}
_, _, _, recordsPerPage := v.GetInnerRect()
v.firstRecordIndex -= recordsPerPage
v.lastRecordIndex = v.firstRecordIndex + recordsPerPage
v.selectFirstRecord()
}
func (v *RecordsView) moveToNextPage() {
_, _, _, recordsPerPage := v.GetInnerRect()
// On the last page.
if v.firstRecordIndex+recordsPerPage > v.lastRecordIndex {
return
}
v.firstRecordIndex += recordsPerPage
v.lastRecordIndex += recordsPerPage
v.selectFirstRecord()
}
func (v *RecordsView) selectFirstRecord() {
v.selectedRecordIndex = v.firstRecordIndex
}
func (v *RecordsView) selectLastRecord() {
v.selectedRecordIndex = v.lastRecordIndex - 1
}
func (v *RecordsView) InputHandler() func(event *tcell.EventKey, _ func(p tview.Primitive)) {
return v.WrapInputHandler(func(event *tcell.EventKey, _ func(p tview.Primitive)) {
switch _, k := event.Modifiers(), event.Key(); {
case k == tcell.KeyLeft:
v.moveToPrevPage()
case k == tcell.KeyRight:
v.moveToNextPage()
case k == tcell.KeyDown:
// Need to move onto the next page.
if v.selectedRecordIndex+1 == v.lastRecordIndex {
v.moveToNextPage()
// Now is on the next page, otherwise it was the last page.
if v.selectedRecordIndex+1 != v.lastRecordIndex {
v.selectedRecordIndex++
}
} else {
v.selectedRecordIndex++
}
case k == tcell.KeyUp:
v.selectedRecordIndex = max(v.selectedRecordIndex-1, 0)
if v.selectedRecordIndex < v.firstRecordIndex {
v.moveToPrevPage()
v.selectLastRecord()
}
case k == tcell.KeyRune && event.Rune() == 'd':
current := v.getSelectedItem()
if current != nil {
v.ui.moveNextPage(NewDetailedView(current.Result.DetailedString()))
}
}
})
}
func (v *RecordsView) getSelectedItem() *tuiutil.Record {
if v.selectedRecordIndex < len(v.records) {
return v.records[v.selectedRecordIndex]
}
return nil
}

View file

@ -8,6 +8,7 @@ import (
"sync/atomic"
"time"
"github.com/davecgh/go-spew/spew"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"go.etcd.io/bbolt"
@ -23,55 +24,58 @@ type Primitive interface {
Unmount()
}
const (
EventStayPage = iota
EventMoveBack
EventMoveForth
)
type UI struct {
*tview.Box
ctx context.Context
app *tview.Application
db *bbolt.DB
pages []Primitive
pageHistory []Primitive
mountedPage Primitive
pageToMount Primitive
pageStub tview.Primitive
needInit bool
infoBar *tview.TextView
searchBar *tview.InputField
loadingBar *LoadingBar
helpView *tview.TextView
isFirstMount bool
isSearching bool
isLoading atomic.Bool
isSearching bool
searchInput *tview.InputField
isLoading atomic.Bool
loadingScreen *LoadingScreen
activePage Primitive
pageToMove Primitive
cancelLoading func()
filters map[string]func(string) (any, error)
}
func NewUI(app *tview.Application, db *bbolt.DB) *UI {
func NewUI(ctx context.Context, app *tview.Application, db *bbolt.DB) *UI {
spew.Config.DisableMethods = true
ui := &UI{
Box: tview.NewBox(),
app: app,
db: db,
helpView: tview.NewTextView(),
searchInput: tview.NewInputField(),
filters: make(map[string]func(string) (any, error)),
ctx: ctx,
Box: tview.NewBox(),
app: app,
db: db,
isFirstMount: true,
infoBar: tview.NewTextView(),
pageStub: tview.NewBox(),
searchBar: tview.NewInputField(),
filters: make(map[string]func(string) (any, error)),
}
ui.activePage = NewBucketsView(ui)
ui.activePage.Mount(context.Background())
ui.searchInput.SetFieldBackgroundColor(ui.GetBackgroundColor())
ui.searchInput.SetFieldTextColor(tview.Styles.PrimaryTextColor)
ui.loadingBar = NewLoadingBar(ui.triggerDraw)
ui.helpView.SetBackgroundColor(tview.Styles.PrimaryTextColor)
ui.helpView.SetTextColor(ui.GetBackgroundColor())
ui.pageToMount = NewBucketsView(ui)
ui.helpView.SetText(fmt.Sprintf(" %s (press h for help, / to search or q to quit) ", db.Path()))
ui.searchBar.SetFieldBackgroundColor(ui.GetBackgroundColor())
ui.searchBar.SetFieldTextColor(tview.Styles.PrimaryTextColor)
ui.infoBar.SetBackgroundColor(tview.Styles.PrimaryTextColor)
ui.infoBar.SetTextColor(ui.GetBackgroundColor())
ui.infoBar.SetText(fmt.Sprintf(" %s (press h for help, / to search or q to quit) ", db.Path()))
return ui
}
@ -90,79 +94,107 @@ func (ui *UI) stopOnError(err error) {
}
}
func (ui *UI) movePrevPage(page Primitive) {
if len(ui.pages) == 0 {
return
}
ui.activePage.Unmount()
ui.activePage = ui.pages[len(ui.pages)-1]
ui.pages = ui.pages[:len(ui.pages)-1]
ui.redraw()
func (ui *UI) movePrevPage() {
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.pages = append(ui.pages, ui.activePage)
ui.activePage = page
ui.needInit = true
ui.redraw()
ui.pageToMount = page
ui.triggerDraw()
}
func (ui *UI) redraw() {
ui.app.QueueUpdateDraw(func() {})
func (ui *UI) triggerDraw() {
go ui.app.QueueUpdateDraw(func() {})
}
func (ui *UI) Draw(screen tcell.Screen) {
if ui.isLoading.Load() {
ui.loadingScreen.Update(context.Background())
ui.drawLoading(screen)
ui.draw(screen)
return
}
if ui.needInit {
ui.activePage.Mount(context.Background())
ui.needInit = false
}
ctx, cancel := context.WithCancel(context.Background())
ui.isLoading.Store(true)
ctx, cancel := context.WithCancel(ui.ctx)
ready := make(chan struct{})
go func() {
defer cancel()
defer close(ready)
ui.activePage.Update(ctx)
ui.load(ctx)
ui.isLoading.Store(false)
if ui.loadingScreen != nil {
ui.loadingScreen.Unmount()
ui.loadingScreen = nil
}
}()
select {
case <-ready:
ui.draw(screen)
case <-time.After(100 * time.Millisecond):
ui.loadingScreen = NewLoadingScreen(cancel, ui.redraw)
ui.loadingScreen.Mount(ctx)
ui.drawLoading(screen)
ui.loadingBar.Start(ctx)
ui.cancelLoading = cancel
go func() {
<-ready
ui.loadingBar.Stop()
ui.triggerDraw()
}()
}
ui.draw(screen)
}
func (ui *UI) drawLoading(screen tcell.Screen) {
ui.DrawForSubclass(screen, ui)
x, y, width, height := ui.GetInnerRect()
func (ui *UI) load(ctx context.Context) {
if ui.mountedPage == nil && ui.pageToMount == nil {
return
}
// Pending mount either fails w/o retry or succeeds.
// Either way the page to mount need to be reset.
defer func() {
ui.pageToMount = nil
ui.isFirstMount = false
}()
ui.activePage.SetRect(x, y, width, height-1)
ui.activePage.Draw(screen)
pageToUpdate := ui.mountedPage
if ui.loadingScreen != nil {
ui.loadingScreen.SetRect(x, y+height-1, width, 1)
ui.loadingScreen.Draw(screen)
if ui.pageToMount != nil {
ready := make(chan struct{})
go func() {
defer close(ready)
ui.pageToMount.Mount(ctx)
}()
select {
case <-ctx.Done():
return
case <-ready:
}
pageToUpdate = ui.pageToMount
}
ready := make(chan struct{})
go func() {
defer close(ready)
x, y, w, h := ui.GetInnerRect()
pageToUpdate.SetRect(x, y, w, h-1)
pageToUpdate.Update(ctx)
}()
select {
case <-ctx.Done():
return
case <-ready:
}
if ui.pageToMount != nil {
if ui.mountedPage != nil {
ui.pageHistory = append(ui.pageHistory, ui.mountedPage)
}
ui.mountedPage = ui.pageToMount
}
}
@ -170,34 +202,47 @@ func (ui *UI) draw(screen tcell.Screen) {
ui.DrawForSubclass(screen, ui)
x, y, width, height := ui.GetInnerRect()
if ui.isSearching {
ui.activePage.SetRect(x, y, width, height-1)
ui.activePage.Draw(screen)
ui.searchInput.SetRect(x, y+height-1, width, 1)
ui.searchInput.Draw(screen)
var pageToDraw tview.Primitive
if ui.mountedPage != nil {
pageToDraw = ui.mountedPage
} else {
ui.activePage.SetRect(x, y, width, height-1)
ui.activePage.Draw(screen)
text := ui.helpView.GetText(true)
ui.helpView.SetRect(x, y+height-1, min(len(text), width), 1)
ui.helpView.Draw(screen)
pageToDraw = ui.pageStub
}
pageToDraw.SetRect(x, y, width, height-1)
pageToDraw.Draw(screen)
var barToDraw tview.Primitive
switch {
case ui.isLoading.Load():
barToDraw = ui.loadingBar
case ui.isSearching:
barToDraw = ui.searchBar
default:
barToDraw = ui.infoBar
}
barToDraw.SetRect(x, y+height-1, width, 1)
barToDraw.Draw(screen)
}
func (ui *UI) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
return ui.WrapInputHandler(func(event *tcell.EventKey, _ func(tview.Primitive)) {
if ui.isLoading.Load() {
ui.loadingScreen.InputHandler()(event, func(tview.Primitive) {})
return
k, r := event.Key(), event.Rune()
if k != tcell.KeyEsc && r != 'q' {
return
}
ui.cancelLoading()
if r == 'q' || ui.isFirstMount {
ui.app.Stop()
}
}
if ui.isSearching {
switch event.Key() {
case tcell.KeyEnter:
res, err := processPrompt(ui.searchInput.GetText(), ui.filters)
res, err := processPrompt(ui.searchBar.GetText()[1:], ui.filters)
fmt.Fprintln(os.Stderr, res, err)
if err != nil {
@ -205,58 +250,63 @@ func (ui *UI) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.P
}
ui.isSearching = false
ui.searchInput.SetText("")
ui.searchBar.SetText("")
case tcell.KeyEsc:
ui.isSearching = false
ui.searchInput.SetText("")
ui.searchBar.SetText("")
default:
ui.searchInput.InputHandler()(event, func(tview.Primitive) {})
if len(ui.searchInput.GetText()) == 0 {
ui.searchBar.InputHandler()(event, func(tview.Primitive) {})
if len(ui.searchBar.GetText()) == 0 {
ui.isSearching = false
}
}
return
}
switch k, r := event.Key(), event.Rune(); {
switch m, k, r := event.Modifiers(), event.Key(), event.Rune(); {
case k == tcell.KeyRune && r == '/':
ui.isSearching = true
ui.searchInput.SetText("/")
ui.searchBar.SetText("/")
case k == tcell.KeyRune && r == 'q':
ui.app.Stop()
case m&tcell.ModCtrl != 0 && k == tcell.KeyLeft:
if len(ui.pageHistory) != 0 {
ui.movePrevPage()
}
default:
ui.activePage.InputHandler()(event, func(tview.Primitive) {})
if ui.mountedPage != nil {
ui.mountedPage.InputHandler()(event, func(tview.Primitive) {})
} else {
ui.pageStub.InputHandler()(event, func(tview.Primitive) {})
}
}
})
}
func processPrompt(
prompt string,
filters map[string]func(string) (any, error),
) (map[string]any, error) {
ans := make(map[string]any)
func processPrompt(prompt string, filters map[string]func(string) (any, error)) (m map[string]any, err error) {
m = make(map[string]any)
prompt = strings.TrimSpace(prompt)
for _, word := range strings.Split(prompt, " ") {
for _, word := range strings.Split(prompt, "+") {
word = strings.TrimSpace(word)
if word == "" {
continue
}
ws := strings.Split(word, ":")
if len(ws) != 2 {
return nil, fmt.Errorf("invalid filter %s", word)
filter := strings.Split(word, ":")
if len(filter) != 2 {
return nil, fmt.Errorf("invalid syntax '%s'", word)
}
filterKey := strings.TrimSpace(filter[0])
filterValue := strings.TrimSpace(filter[1])
f, ok := filters[ws[0]]
f, ok := filters[filterKey]
if !ok {
return nil, fmt.Errorf("unknown filter %s", word)
return nil, fmt.Errorf("unknown filter '%s'", word)
}
res, err := f(ws[1])
if err != nil {
if m[filterKey], err = f(filterValue); err != nil {
return nil, err
}
ans[ws[0]] = res
}
return ans, nil
return m, nil
}

View file

@ -22,7 +22,7 @@ func (a *Application) newRecordsView(ctx context.Context, bkt *Bucket) (tview.Pr
defer close(records)
for record := range temp {
res, _, err := bkt.NextHandler(record.key, record.value)
res, _, err := bkt.NextHandler(record.Key, record.Value)
// if errors.Is(err, handlers.ErrFilter) {
// continue
// }
@ -32,7 +32,7 @@ func (a *Application) newRecordsView(ctx context.Context, bkt *Bucket) (tview.Pr
// return
}
// record.nextHandler = next
record.result = res
record.Result = res
records <- record
}
}()
@ -114,7 +114,7 @@ func (v *RecordsView) draw(screen tcell.Screen) {
v.DrawForSubclass(screen, v)
for index := v.firstRecordIndex; index < v.lastRecordIndex; index++ {
result := v.records[index].result
result := v.records[index].Result
text := result.String()
if index == v.selectedRecordIndex {
text = fmt.Sprintf("[:white]%s[:black]", text)