[#1223] lens/tui: Add search by CID and OID
Signed-off-by: Aleksey Savchuk <a.savchuk@yadro.com>
This commit is contained in:
parent
c65b3a9ba9
commit
ec66b4cee2
12 changed files with 648 additions and 505 deletions
|
@ -3,6 +3,9 @@ package meta
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/pprof"
|
||||
"time"
|
||||
|
||||
common "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal"
|
||||
"git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/tuiutil"
|
||||
|
@ -22,7 +25,32 @@ func init() {
|
|||
}
|
||||
|
||||
func tuiFunc(cmd *cobra.Command, _ []string) {
|
||||
err := runTUI(cmd)
|
||||
cpuProfile, err := os.Create(fmt.Sprintf("tui-cpu-%s.prof", time.Now()))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer cpuProfile.Close()
|
||||
|
||||
err = pprof.StartCPUProfile(cpuProfile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer pprof.StopCPUProfile()
|
||||
|
||||
memProfile, err := os.Create(fmt.Sprintf("tui-mem-%s.prof", time.Now()))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer memProfile.Close()
|
||||
defer func() {
|
||||
err = pprof.WriteHeapProfile(memProfile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
|
||||
err = runTUI(cmd)
|
||||
|
||||
common.ExitOnErr(cmd, err)
|
||||
}
|
||||
|
||||
|
|
|
@ -2,19 +2,24 @@ package tuiutil
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
handlers "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/schema"
|
||||
cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
|
||||
oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id"
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/mr-tron/base58"
|
||||
"github.com/rivo/tview"
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// 29NkY3QtH6r8Hd9RBejwtUuju1ViAQ6jZPR7pjuTnkoH
|
||||
// 1QVJo3LCACFnbeqJ7uHz97PyoPrkZr4BnZ9cVYAhRPx
|
||||
|
||||
// 6E6b24qy32p3L3wjRSUEGNFHMxkUsaqj7udwWVKLzkU
|
||||
// 1GcnNjFnPof2YbPi2RBi3Sjy3qeR2cymc9BR6i9U2Kt
|
||||
|
||||
type Bucket struct {
|
||||
name []byte
|
||||
path [][]byte
|
||||
|
@ -48,7 +53,7 @@ func NewApplication(ctx context.Context, db *bbolt.DB) (*Application, error) {
|
|||
a.nav = NewNavigation(a.app)
|
||||
|
||||
a.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Rune() == 'q' {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
a.app.Stop()
|
||||
return nil
|
||||
}
|
||||
|
@ -74,227 +79,74 @@ func (a *Application) stopOnErr(err error) {
|
|||
}
|
||||
|
||||
func (a *Application) newIntoView(ctx context.Context) (tview.Primitive, error) {
|
||||
view := tview.NewBox()
|
||||
view.SetBorder(true).SetTitle("Intro")
|
||||
form := tview.NewForm()
|
||||
|
||||
view.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEnter {
|
||||
err := a.nav.Push(ctx, a.newBucketsView)
|
||||
a.stopOnErr(err)
|
||||
return nil
|
||||
unsetErrMsg := func() {
|
||||
if form.GetFormItemCount() >= 4 {
|
||||
form.RemoveFormItem(3)
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (a *Application) newBucketsView(ctx context.Context) (tview.Primitive, error) {
|
||||
tree := tview.NewTreeView()
|
||||
tree.
|
||||
SetBorder(true).
|
||||
SetTitle(a.db.Path())
|
||||
|
||||
// id := cid.ID{}
|
||||
// data, _ := base58.Decode("29NkY3QtH6r8Hd9RBejwtUuju1ViAQ6jZPR7pjuTnkoH")
|
||||
// id.Decode(data)
|
||||
|
||||
handler := handlers.GetHandler(
|
||||
// handlers.WithCID(id),
|
||||
)
|
||||
|
||||
root := tview.NewTreeNode(".")
|
||||
root.
|
||||
SetSelectable(false).
|
||||
SetExpanded(true).
|
||||
SetReference(&Bucket{nextHandler: handler})
|
||||
|
||||
err := a.getChildren(ctx, root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setErrMsg := func(msg string) {
|
||||
form.AddTextView("", fmt.Sprintf("[red]%s[white]", msg), 45, 1, true, false)
|
||||
}
|
||||
|
||||
tree.
|
||||
SetRoot(root).
|
||||
SetCurrentNode(root)
|
||||
var cidText, oidText string
|
||||
|
||||
tree.SetSelectedFunc(func(node *tview.TreeNode) {
|
||||
if node.IsExpanded() {
|
||||
node.ClearChildren()
|
||||
} else {
|
||||
err := a.getChildren(ctx, node)
|
||||
a.stopOnErr(err)
|
||||
}
|
||||
node.SetExpanded(!node.IsExpanded())
|
||||
})
|
||||
form.
|
||||
AddInputField("CID", "", 45, nil, func(text string) { cidText = text }).
|
||||
AddInputField("OID", "", 45, nil, func(text string) { oidText = text }).
|
||||
AddButton("Search", func() {
|
||||
unsetErrMsg()
|
||||
|
||||
a.setBucketsInputCapture(ctx, tree)
|
||||
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
func (a *Application) getChildren(ctx context.Context, parent *tview.TreeNode) error {
|
||||
parentBucket := parent.GetReference().(*Bucket)
|
||||
|
||||
path := parentBucket.path
|
||||
handler := parentBucket.nextHandler
|
||||
|
||||
buffer, err := LoadBuckets(ctx, a.db, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for bucket := range buffer {
|
||||
repr, next, err := handler(bucket.name, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err.Error())
|
||||
}
|
||||
if errors.Is(err, handlers.ErrFilter) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bucket.nextHandler = next
|
||||
bucket.result = repr
|
||||
|
||||
child := tview.NewTreeNode(repr.Key.String())
|
||||
|
||||
child.SetSelectable(true)
|
||||
child.SetExpanded(false)
|
||||
child.SetReference(bucket)
|
||||
|
||||
parent.AddChild(child)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Application) setBucketsInputCapture(ctx context.Context, view *tview.TreeView) {
|
||||
view.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyTab:
|
||||
err := a.nav.Push(ctx, func(ctx context.Context) (tview.Primitive, error) {
|
||||
ref := view.GetCurrentNode().GetReference().(*Bucket)
|
||||
return a.NewRecordsView(ctx, ref), nil
|
||||
})
|
||||
cidVal := &cid.ID{}
|
||||
data, err := base58.Decode(cidText)
|
||||
if err != nil {
|
||||
a.stopOnErr(err)
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return event
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Application) NewRecordsView(ctx context.Context, bkt *Bucket) tview.Primitive {
|
||||
view := NewMultipageView(a.app)
|
||||
view.SetBorder(true)
|
||||
|
||||
buffer, err := LoadRecords(ctx, a.db, bkt.path)
|
||||
if err != nil {
|
||||
a.stopOnErr(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
view.SetNextItemFunc(func() *MultipageItem {
|
||||
record, ok := <-buffer
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
repr, next, err := bkt.nextHandler(record.key, record.value)
|
||||
if errors.Is(err, handlers.ErrFilter) {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
a.stopOnErr(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
record.nextHandler = next
|
||||
record.result = repr
|
||||
|
||||
return &MultipageItem{
|
||||
Text: repr.Key.String() + " " + repr.Val.String(),
|
||||
Reference: record,
|
||||
}
|
||||
})
|
||||
|
||||
view.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
a.nav.Pop()
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyTab {
|
||||
item := view.GetSelectedItem()
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := a.nav.Push(ctx, func(_ context.Context) (tview.Primitive, error) {
|
||||
rec := item.Reference.(*Record)
|
||||
|
||||
text := tview.NewTextView()
|
||||
text.SetBorder(true).SetTitle(rec.result.Key.String())
|
||||
text.SetText(rec.result.Val.DetailedString())
|
||||
text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
a.nav.Pop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return text, nil
|
||||
})
|
||||
if err != nil {
|
||||
a.stopOnErr(err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
view.OnLoading(
|
||||
func() {
|
||||
err := a.nav.Push(ctx, a.NewLoadingScreen)
|
||||
if err != nil {
|
||||
a.stopOnErr(err)
|
||||
}
|
||||
},
|
||||
func() { a.nav.Pop() },
|
||||
)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func (a *Application) NewLoadingScreen(ctx context.Context) (tview.Primitive, error) {
|
||||
view := tview.NewBox()
|
||||
|
||||
view.SetBorder(true)
|
||||
|
||||
var seconds atomic.Int32
|
||||
view.SetTitle(fmt.Sprintf("Loading... %ds", seconds.Load()))
|
||||
go func() {
|
||||
tick := time.NewTicker(1 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
setErrMsg("not a valid CID")
|
||||
return
|
||||
case <-tick.C:
|
||||
a.app.QueueUpdateDraw(func() {
|
||||
seconds.Add(1)
|
||||
view.SetTitle(fmt.Sprintf("Loading... %ds", seconds.Load()))
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
err = cidVal.Decode(data)
|
||||
if err != nil {
|
||||
setErrMsg("not a valid CID")
|
||||
return
|
||||
}
|
||||
|
||||
oidVal := &oid.ID{}
|
||||
data, err = base58.Decode(oidText)
|
||||
if err != nil {
|
||||
setErrMsg("not a valid OID")
|
||||
return
|
||||
}
|
||||
err = oidVal.Decode(data)
|
||||
if err != nil {
|
||||
setErrMsg("not a valid OID")
|
||||
return
|
||||
}
|
||||
|
||||
err = a.nav.Push(ctx, func(ctx context.Context) (tview.Primitive, error) {
|
||||
return a.newBucketsView(ctx, cidVal, oidVal)
|
||||
})
|
||||
a.stopOnErr(err)
|
||||
}).
|
||||
AddButton("Continue w/o search", func() {
|
||||
err := a.nav.Push(ctx, func(ctx context.Context) (tview.Primitive, error) {
|
||||
return a.newBucketsView(ctx, nil, nil)
|
||||
})
|
||||
a.stopOnErr(err)
|
||||
})
|
||||
|
||||
form.SetBorder(true)
|
||||
|
||||
view := tview.NewFlex().
|
||||
SetDirection(tview.FlexRow).
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(
|
||||
tview.NewFlex().
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(form, 54, 0, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false),
|
||||
13, 0, true,
|
||||
).
|
||||
AddItem(tview.NewBox(), 0, 1, false)
|
||||
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// func (a *Application) NewDetailedView(_ context.Context, rec *Record) (tview.Primitive, error) {
|
||||
|
||||
// }
|
||||
|
|
111
cmd/frostfs-lens/internal/tuiutil/buckets.go
Normal file
111
cmd/frostfs-lens/internal/tuiutil/buckets.go
Normal file
|
@ -0,0 +1,111 @@
|
|||
package tuiutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
handlers "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/schema"
|
||||
cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
|
||||
oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
func (a *Application) newBucketsView(ctx context.Context, cnr *cid.ID, obj *oid.ID) (tview.Primitive, error) {
|
||||
tree := tview.NewTreeView()
|
||||
tree.
|
||||
SetBorder(true).
|
||||
SetTitle(a.db.Path())
|
||||
|
||||
var filters []handlers.Filter
|
||||
if cnr != nil {
|
||||
filters = append(filters, handlers.WithCID(*cnr))
|
||||
}
|
||||
if obj != nil {
|
||||
filters = append(filters, handlers.WithOID(*obj))
|
||||
}
|
||||
|
||||
handler := handlers.GetHandler(filters...)
|
||||
|
||||
root := tview.NewTreeNode(".")
|
||||
root.
|
||||
SetSelectable(false).
|
||||
SetExpanded(true).
|
||||
SetReference(&Bucket{nextHandler: handler})
|
||||
|
||||
err := a.getChildren(ctx, root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tree.
|
||||
SetRoot(root).
|
||||
SetCurrentNode(root).
|
||||
SetSelectedFunc(nil)
|
||||
|
||||
tree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
node := tree.GetCurrentNode()
|
||||
|
||||
switch event.Key() {
|
||||
case tcell.KeyEnter:
|
||||
if node.IsExpanded() {
|
||||
node.ClearChildren()
|
||||
} else {
|
||||
err := a.getChildren(ctx, node)
|
||||
a.stopOnErr(err)
|
||||
}
|
||||
node.SetExpanded(!node.IsExpanded())
|
||||
case tcell.KeyTab:
|
||||
err := a.nav.Push(ctx, func(ctx context.Context) (tview.Primitive, error) {
|
||||
return a.newRecordsView(ctx, node.GetReference().(*Bucket))
|
||||
})
|
||||
a.stopOnErr(err)
|
||||
case tcell.KeyBacktab:
|
||||
a.nav.Pop()
|
||||
default:
|
||||
return event
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
func (a *Application) getChildren(ctx context.Context, parent *tview.TreeNode) error {
|
||||
parentBucket := parent.GetReference().(*Bucket)
|
||||
|
||||
path := parentBucket.path
|
||||
handler := parentBucket.nextHandler
|
||||
|
||||
buffer, err := LoadBuckets(ctx, a.db, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for bucket := range buffer {
|
||||
repr, next, err := handler(bucket.name, nil)
|
||||
if errors.Is(err, handlers.ErrFilter) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bucket.nextHandler = next
|
||||
bucket.result = repr
|
||||
|
||||
child := tview.NewTreeNode(repr.Key.String())
|
||||
|
||||
child.SetSelectable(true)
|
||||
child.SetExpanded(false)
|
||||
child.SetReference(bucket)
|
||||
|
||||
parent.AddChild(child)
|
||||
|
||||
err = a.getChildren(ctx, child)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -43,6 +43,7 @@ func load(
|
|||
|
||||
go func() {
|
||||
// TODO how to handle an error
|
||||
// return two channels, one for result, another for error?
|
||||
_ = db.View(func(tx *bbolt.Tx) error {
|
||||
defer close(buffer)
|
||||
|
||||
|
@ -60,23 +61,18 @@ func load(
|
|||
|
||||
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
|
||||
default:
|
||||
// End of iteration
|
||||
if key == nil {
|
||||
return nil
|
||||
}
|
||||
if filter != nil && !filter(key, value) {
|
||||
key, value = cursor.Next()
|
||||
continue
|
||||
}
|
||||
if len(buffer) == bufferSize {
|
||||
continue
|
||||
}
|
||||
|
||||
buffer <- transform(key, value)
|
||||
case buffer <- transform(key, value):
|
||||
key, value = cursor.Next()
|
||||
}
|
||||
}
|
||||
|
@ -97,13 +93,11 @@ func LoadBuckets(ctx context.Context, db *bbolt.DB, path [][]byte) (<-chan *Buck
|
|||
},
|
||||
func(key, _ []byte) any {
|
||||
base := make([][]byte, 0, len(path))
|
||||
for _, name := range path {
|
||||
base = append(base, name[:])
|
||||
}
|
||||
base = append(base, path...)
|
||||
|
||||
return &Bucket{
|
||||
name: key[:],
|
||||
path: append(base, key[:]),
|
||||
name: key,
|
||||
path: append(base, key),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
@ -129,14 +123,12 @@ func LoadRecords(ctx context.Context, db *bbolt.DB, path [][]byte) (<-chan *Reco
|
|||
},
|
||||
func(key, value []byte) any {
|
||||
base := make([][]byte, 0, len(path))
|
||||
for _, name := range path {
|
||||
base = append(base, name[:])
|
||||
}
|
||||
base = append(base, path...)
|
||||
|
||||
return &Record{
|
||||
key: key[:],
|
||||
value: value[:],
|
||||
path: append(base, key[:]),
|
||||
key: key,
|
||||
value: value,
|
||||
path: append(base, key),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
28
cmd/frostfs-lens/internal/tuiutil/details.go
Normal file
28
cmd/frostfs-lens/internal/tuiutil/details.go
Normal file
|
@ -0,0 +1,28 @@
|
|||
package tuiutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
func (a *Application) newDetailedView(_ context.Context, rec *Record) (tview.Primitive, error) {
|
||||
view := tview.NewTextView()
|
||||
|
||||
view.
|
||||
SetText(rec.result.Val.DetailedString()).
|
||||
SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyBacktab {
|
||||
a.nav.Pop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
view.
|
||||
SetBorder(true).
|
||||
SetTitle(rec.result.Key.String())
|
||||
|
||||
return view, nil
|
||||
}
|
49
cmd/frostfs-lens/internal/tuiutil/loading.go
Normal file
49
cmd/frostfs-lens/internal/tuiutil/loading.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
package tuiutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
type LoadingScreen struct {
|
||||
*tview.Box
|
||||
|
||||
app *tview.Application
|
||||
count atomic.Int64
|
||||
}
|
||||
|
||||
func NewLoadingScreen(app *tview.Application) (*LoadingScreen, error) {
|
||||
view := &LoadingScreen{
|
||||
Box: tview.NewBox(),
|
||||
app: app,
|
||||
}
|
||||
|
||||
view.
|
||||
SetBorder(true).
|
||||
SetTitle(fmt.Sprintf("Loading... %ds", view.count.Load()))
|
||||
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (s *LoadingScreen) Start(ctx context.Context) *LoadingScreen {
|
||||
go func() {
|
||||
tick := time.NewTicker(1 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
s.app.QueueUpdateDraw(func() {
|
||||
s.count.Add(1)
|
||||
s.SetTitle(fmt.Sprintf("Loading... %ds", s.count.Load()))
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return s
|
||||
}
|
|
@ -1,217 +0,0 @@
|
|||
package tuiutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
type MultipageItem struct {
|
||||
Text string
|
||||
Reference any
|
||||
}
|
||||
|
||||
type MultipageView struct {
|
||||
*tview.Box
|
||||
|
||||
app *tview.Application
|
||||
|
||||
items []*MultipageItem
|
||||
nextItemFunc func() *MultipageItem
|
||||
|
||||
firstItemIndex int
|
||||
lastItemIndex int
|
||||
selectedItemIndex int
|
||||
|
||||
mainTextStyle tcell.Style
|
||||
selectedTextStyle tcell.Style
|
||||
|
||||
loading atomic.Bool
|
||||
|
||||
setOnLoading func()
|
||||
unsetOnLoading func()
|
||||
}
|
||||
|
||||
func NewMultipageView(app *tview.Application) *MultipageView {
|
||||
view := &MultipageView{Box: tview.NewBox(), app: app}
|
||||
|
||||
view.mainTextStyle = tcell.StyleDefault.
|
||||
Foreground(tview.Styles.PrimaryTextColor).
|
||||
Background(tview.Styles.PrimitiveBackgroundColor)
|
||||
|
||||
view.selectedTextStyle = tcell.StyleDefault.
|
||||
Foreground(tview.Styles.PrimitiveBackgroundColor).
|
||||
Background(tview.Styles.PrimaryTextColor)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func (v *MultipageView) SetNextItemFunc(nextItemFunc func() *MultipageItem) *MultipageView {
|
||||
v.nextItemFunc = nextItemFunc
|
||||
return v
|
||||
}
|
||||
|
||||
func (v *MultipageView) OnLoading(setOnLoading, unsetOnLoading func()) *MultipageView {
|
||||
v.setOnLoading = setOnLoading
|
||||
v.unsetOnLoading = unsetOnLoading
|
||||
return v
|
||||
}
|
||||
|
||||
func (v *MultipageView) process() {
|
||||
_, _, _, itemsPerPage := v.GetInnerRect()
|
||||
|
||||
// The terminal's been resized and become shorter
|
||||
if v.lastItemIndex > v.firstItemIndex+itemsPerPage {
|
||||
v.lastItemIndex = v.firstItemIndex + itemsPerPage
|
||||
// If the selected item is invisible, select the last one visible
|
||||
v.selectedItemIndex = min(v.selectedItemIndex, v.lastItemIndex-1)
|
||||
return
|
||||
}
|
||||
|
||||
newLastIndex := v.firstItemIndex + itemsPerPage
|
||||
|
||||
for v.nextItemFunc != nil && len(v.items) < newLastIndex {
|
||||
item := v.nextItemFunc()
|
||||
// if item != nil {
|
||||
// <-time.After(1000 * time.Millisecond)
|
||||
// }
|
||||
|
||||
if item == nil {
|
||||
break
|
||||
}
|
||||
v.items = append(v.items, item)
|
||||
}
|
||||
|
||||
v.lastItemIndex = min(len(v.items), newLastIndex)
|
||||
}
|
||||
|
||||
func (v *MultipageView) moveToPrevPage() {
|
||||
// We'are on the first page
|
||||
if v.firstItemIndex == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_, _, _, itemsPerPage := v.GetInnerRect()
|
||||
v.firstItemIndex -= itemsPerPage
|
||||
v.lastItemIndex = v.firstItemIndex + itemsPerPage
|
||||
|
||||
v.selectFirstItem()
|
||||
}
|
||||
|
||||
func (v *MultipageView) moveToNextPage() {
|
||||
_, _, _, itemsPerPage := v.GetInnerRect()
|
||||
|
||||
// We're on the last page
|
||||
if v.firstItemIndex+itemsPerPage > v.lastItemIndex {
|
||||
return
|
||||
}
|
||||
|
||||
v.firstItemIndex += itemsPerPage
|
||||
v.lastItemIndex += itemsPerPage
|
||||
|
||||
v.selectFirstItem()
|
||||
}
|
||||
|
||||
func (v *MultipageView) selectFirstItem() {
|
||||
v.selectedItemIndex = v.firstItemIndex
|
||||
}
|
||||
|
||||
func (v *MultipageView) selectLastItem() {
|
||||
v.selectedItemIndex = v.lastItemIndex - 1
|
||||
}
|
||||
|
||||
func (v *MultipageView) draw(screen tcell.Screen) {
|
||||
x, y, width, height := v.GetInnerRect()
|
||||
|
||||
if height == 0 {
|
||||
v.DrawForSubclass(screen, v)
|
||||
return
|
||||
}
|
||||
|
||||
pageNum := v.firstItemIndex/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.firstItemIndex; index < v.lastItemIndex; index++ {
|
||||
text := v.items[index].Text
|
||||
if index == v.selectedItemIndex {
|
||||
text = fmt.Sprintf("[:white]%s[:black]", text)
|
||||
}
|
||||
tview.Print(screen, text, x, y, width, tview.AlignLeft, tcell.ColorWhite)
|
||||
|
||||
y++
|
||||
}
|
||||
v.SetTitle(title)
|
||||
}
|
||||
|
||||
func (v *MultipageView) Draw(screen tcell.Screen) {
|
||||
ready := make(chan struct{})
|
||||
v.loading.Store(true)
|
||||
go func() {
|
||||
v.process()
|
||||
ready <- struct{}{}
|
||||
}()
|
||||
select {
|
||||
case <-ready:
|
||||
v.draw(screen)
|
||||
v.loading.Store(false)
|
||||
case <-time.After(1 * time.Second):
|
||||
v.DrawForSubclass(screen, v)
|
||||
go func() {
|
||||
v.setOnLoading()
|
||||
<-ready
|
||||
v.loading.Store(false)
|
||||
v.unsetOnLoading()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (v *MultipageView) InputHandler() func(event *tcell.EventKey, _ func(p tview.Primitive)) {
|
||||
return v.WrapInputHandler(func(event *tcell.EventKey, _ func(p tview.Primitive)) {
|
||||
// The view is loading, prevent key events from being handled
|
||||
if v.loading.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
switch event.Key() {
|
||||
case tcell.KeyLeft:
|
||||
v.moveToPrevPage()
|
||||
case tcell.KeyRight:
|
||||
v.moveToNextPage()
|
||||
case tcell.KeyDown:
|
||||
if v.selectedItemIndex+1 == v.lastItemIndex {
|
||||
v.moveToNextPage()
|
||||
// The page's been switched
|
||||
if v.selectedItemIndex+1 != v.lastItemIndex {
|
||||
v.selectedItemIndex++
|
||||
}
|
||||
} else {
|
||||
v.selectedItemIndex++
|
||||
}
|
||||
case tcell.KeyUp:
|
||||
v.selectedItemIndex = max(v.selectedItemIndex-1, 0)
|
||||
if v.selectedItemIndex < v.firstItemIndex {
|
||||
v.moveToPrevPage()
|
||||
v.selectLastItem()
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (v *MultipageView) GetSelectedItem() *MultipageItem {
|
||||
if v.selectedItemIndex < len(v.items) {
|
||||
return v.items[v.selectedItemIndex]
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -2,8 +2,8 @@ package tuiutil
|
|||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
|
@ -25,24 +25,59 @@ func (n *Navigation) Push(
|
|||
ctx context.Context,
|
||||
init func(context.Context) (tview.Primitive, error),
|
||||
) error {
|
||||
ready := make(chan struct{})
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
page, err := init(ctx)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
|
||||
var page tview.Primitive
|
||||
var err error
|
||||
go func() {
|
||||
page, err = init(ctx)
|
||||
if err != nil {
|
||||
cancel()
|
||||
panic(err)
|
||||
}
|
||||
ready <- struct{}{}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ready:
|
||||
n.cancels = append(n.cancels, cancel)
|
||||
n.pages = append(n.pages, n.current)
|
||||
|
||||
n.current = page
|
||||
|
||||
n.app.
|
||||
SetRoot(n.current, true).
|
||||
SetFocus(n.current)
|
||||
|
||||
go n.app.QueueUpdateDraw(func() {})
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
go func() {
|
||||
loadingScreen, _ := NewLoadingScreen(n.app)
|
||||
loadingScreen.Start(ctx)
|
||||
|
||||
n.app.
|
||||
SetRoot(loadingScreen, true).
|
||||
SetFocus(loadingScreen)
|
||||
|
||||
go n.app.QueueUpdateDraw(func() {})
|
||||
|
||||
<-ready
|
||||
|
||||
n.cancels = append(n.cancels, cancel)
|
||||
n.pages = append(n.pages, n.current)
|
||||
|
||||
n.current = page
|
||||
|
||||
n.app.
|
||||
SetRoot(n.current, true).
|
||||
SetFocus(n.current)
|
||||
|
||||
go n.app.QueueUpdateDraw(func() {})
|
||||
}()
|
||||
}
|
||||
|
||||
n.cancels = append(n.cancels, cancel)
|
||||
n.pages = append(n.pages, n.current)
|
||||
|
||||
n.current = page
|
||||
|
||||
n.app.
|
||||
SetRoot(n.current, true).
|
||||
SetFocus(n.current)
|
||||
|
||||
n.app.QueueEvent(tcell.NewEventInterrupt(nil))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -63,5 +98,5 @@ func (n *Navigation) Pop() {
|
|||
SetRoot(n.current, true).
|
||||
SetFocus(n.current)
|
||||
|
||||
n.app.QueueEvent(tcell.NewEventInterrupt(nil))
|
||||
go n.app.QueueUpdateDraw(func() {})
|
||||
}
|
||||
|
|
249
cmd/frostfs-lens/internal/tuiutil/records.go
Normal file
249
cmd/frostfs-lens/internal/tuiutil/records.go
Normal file
|
@ -0,0 +1,249 @@
|
|||
package tuiutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
handlers "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/schema"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
func (a *Application) newRecordsView(ctx context.Context, bkt *Bucket) (tview.Primitive, error) {
|
||||
temp, err := LoadRecords(ctx, a.db, bkt.path)
|
||||
a.stopOnErr(err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
records := make(chan *Record, 100)
|
||||
go func() {
|
||||
defer close(records)
|
||||
|
||||
for record := range temp {
|
||||
result, next, err := bkt.nextHandler(record.key, record.value)
|
||||
if errors.Is(err, handlers.ErrFilter) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
a.stopOnErr(err)
|
||||
return
|
||||
}
|
||||
record.nextHandler = next
|
||||
record.result = result
|
||||
records <- record
|
||||
}
|
||||
}()
|
||||
|
||||
box := tview.NewBox().
|
||||
SetBorder(true).
|
||||
SetTitle(bkt.result.Key.String())
|
||||
|
||||
return &RecordsView{
|
||||
Box: box,
|
||||
app: a,
|
||||
buffer: records,
|
||||
mainTextStyle: tcell.StyleDefault.Foreground(tview.Styles.PrimaryTextColor).Background(tview.Styles.PrimitiveBackgroundColor),
|
||||
selectedTextStyle: tcell.StyleDefault.Foreground(tview.Styles.PrimitiveBackgroundColor).Background(tview.Styles.PrimaryTextColor),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type RecordsView struct {
|
||||
*tview.Box
|
||||
|
||||
app *Application
|
||||
|
||||
records []*Record
|
||||
buffer <-chan *Record
|
||||
|
||||
firstRecordIndex int
|
||||
lastRecordIndex int
|
||||
selectedRecordIndex int
|
||||
|
||||
mainTextStyle tcell.Style
|
||||
selectedTextStyle tcell.Style
|
||||
|
||||
isLoading atomic.Bool
|
||||
}
|
||||
|
||||
func (v *RecordsView) process() {
|
||||
v.isLoading.Store(true)
|
||||
defer v.isLoading.Store(false)
|
||||
|
||||
_, _, _, recordsPerPage := v.GetInnerRect()
|
||||
|
||||
// The terminal's been resized and become shorter
|
||||
if v.lastRecordIndex > v.firstRecordIndex+recordsPerPage {
|
||||
v.lastRecordIndex = v.firstRecordIndex + recordsPerPage
|
||||
// If the selected record is invisible, select the last one visible
|
||||
v.selectedRecordIndex = min(v.selectedRecordIndex, v.lastRecordIndex-1)
|
||||
return
|
||||
}
|
||||
|
||||
newLastIndex := v.firstRecordIndex + recordsPerPage
|
||||
|
||||
for len(v.records) < newLastIndex {
|
||||
record, ok := <-v.buffer
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, record.result.Key)
|
||||
v.records = append(v.records, record)
|
||||
}
|
||||
|
||||
v.lastRecordIndex = min(len(v.records), newLastIndex)
|
||||
}
|
||||
|
||||
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 := fmt.Sprintf("%s | %s", result.Key, result.Val)
|
||||
if index == v.selectedRecordIndex {
|
||||
text = fmt.Sprintf("[:white]%s[:black]", text)
|
||||
}
|
||||
tview.Print(screen, text, x, y, width, tview.AlignLeft, tcell.ColorWhite)
|
||||
|
||||
y++
|
||||
}
|
||||
v.SetTitle(title)
|
||||
}
|
||||
|
||||
func (v *RecordsView) Draw(screen tcell.Screen) {
|
||||
ready := make(chan struct{}, 1)
|
||||
go func() {
|
||||
v.process()
|
||||
ready <- struct{}{}
|
||||
fmt.Fprintln(os.Stderr, "done")
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ready:
|
||||
v.draw(screen)
|
||||
return
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
loadingScreen, _ := NewLoadingScreen(v.app.app)
|
||||
|
||||
loadingScreen.Focus(func(_ tview.Primitive) {})
|
||||
x, y, w, h := v.GetRect()
|
||||
loadingScreen.SetRect(x, y, w, h)
|
||||
loadingScreen.Draw(screen)
|
||||
|
||||
go func() {
|
||||
_ = v.app.nav.Push(context.Background(), func(ctx context.Context) (tview.Primitive, error) {
|
||||
return loadingScreen.Start(ctx), nil
|
||||
})
|
||||
fmt.Fprintln(os.Stderr, "pushed")
|
||||
<-ready
|
||||
v.app.nav.Pop()
|
||||
fmt.Fprintln(os.Stderr, "pop")
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (v *RecordsView) moveToPrevPage() {
|
||||
// We'are 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()
|
||||
|
||||
// We're 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)) {
|
||||
// The view is loading, prevent key events from being handled
|
||||
if v.isLoading.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
switch event.Key() {
|
||||
case tcell.KeyLeft:
|
||||
v.moveToPrevPage()
|
||||
case tcell.KeyRight:
|
||||
v.moveToNextPage()
|
||||
case tcell.KeyDown:
|
||||
if v.selectedRecordIndex+1 == v.lastRecordIndex {
|
||||
v.moveToNextPage()
|
||||
// The page's been switched
|
||||
if v.selectedRecordIndex+1 != v.lastRecordIndex {
|
||||
v.selectedRecordIndex++
|
||||
}
|
||||
} else {
|
||||
v.selectedRecordIndex++
|
||||
}
|
||||
case tcell.KeyUp:
|
||||
v.selectedRecordIndex = max(v.selectedRecordIndex-1, 0)
|
||||
if v.selectedRecordIndex < v.firstRecordIndex {
|
||||
v.moveToPrevPage()
|
||||
v.selectLastRecord()
|
||||
}
|
||||
case tcell.KeyTab:
|
||||
record := v.getSelectedItem()
|
||||
if record == nil {
|
||||
return
|
||||
}
|
||||
err := v.app.nav.Push(context.Background(), func(ctx context.Context) (tview.Primitive, error) {
|
||||
return v.app.newDetailedView(ctx, record)
|
||||
})
|
||||
v.app.stopOnErr(err)
|
||||
case tcell.KeyBacktab:
|
||||
v.app.nav.Pop()
|
||||
default:
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (v *RecordsView) getSelectedItem() *Record {
|
||||
if v.selectedRecordIndex < len(v.records) {
|
||||
return v.records[v.selectedRecordIndex]
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -24,15 +24,21 @@ func NewHandler(
|
|||
next Handler,
|
||||
) Handler {
|
||||
return func(key, value []byte) (repr *KV, _ Handler, err error) {
|
||||
// ? check rest bytes
|
||||
keyRepr, _, err := keyParser(key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("can't parse key: %w", err)
|
||||
var keyRepr parse.Result = parse.NewRawResult("")
|
||||
var valueRepr parse.Result = parse.NewRawResult("")
|
||||
|
||||
if keyParser != nil {
|
||||
keyRepr, _, err = keyParser(key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("can't parse key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
valueRepr, _, err := valParser(value)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("can't parse value: %w", err)
|
||||
if valParser != nil {
|
||||
valueRepr, _, err = valParser(value)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("can't parse value: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &KV{keyRepr, valueRepr}, next, nil
|
||||
|
@ -123,13 +129,11 @@ func GetHandler(filters ...Filter) Handler {
|
|||
GetECInfoBucketHandler(cfg),
|
||||
)
|
||||
|
||||
handler = FilterFailHandler(handler)
|
||||
|
||||
// if len(filters) > 0 {
|
||||
// handler = FilterFailHandler(handler)
|
||||
// } else {
|
||||
// handler = WithFallback(handler, RawHandler)
|
||||
// }
|
||||
if len(filters) > 0 {
|
||||
handler = FilterFailHandler(handler)
|
||||
} else {
|
||||
handler = WithFallback(handler, RawHandler)
|
||||
}
|
||||
|
||||
return handler
|
||||
}
|
||||
|
@ -254,13 +258,15 @@ func GetShardInfoHandler(_ *HandlerConfig) Handler {
|
|||
parse.Collect(parse.Chain(
|
||||
parse.Map(
|
||||
parse.Any(
|
||||
parse.ExactString("version"),
|
||||
parse.ExactString("id"),
|
||||
parse.ExactString("version"),
|
||||
parse.ExactString("logic_counter"),
|
||||
parse.ExactString("phy_counter"),
|
||||
parse.ExactString("user_counter"),
|
||||
),
|
||||
func(x string) parse.Result { return parse.NewRawResult(x) },
|
||||
func(x string) parse.Result {
|
||||
return parse.NewRawResult(x, func(s string) string { return fmt.Sprintf("%13s", s) })
|
||||
},
|
||||
),
|
||||
parse.Finalize(parse.EOF),
|
||||
)),
|
||||
|
|
|
@ -34,16 +34,16 @@ func Any[T any](parsers ...Parser[T]) Parser[T] {
|
|||
|
||||
func Chain[T any](parsers ...Parser[T]) Parser[[]T] {
|
||||
return func(data []byte) (_ []T, rest []byte, err error) {
|
||||
var results []T
|
||||
var result T
|
||||
results := make([]T, len(parsers))
|
||||
|
||||
rest = data
|
||||
for _, parser := range parsers {
|
||||
for index, parser := range parsers {
|
||||
result, rest, err = parser(rest)
|
||||
if err != nil {
|
||||
return nil, data, err
|
||||
}
|
||||
results = append(results, result)
|
||||
results[index] = result
|
||||
}
|
||||
return results, rest, nil
|
||||
}
|
||||
|
@ -98,7 +98,7 @@ func Collect(parser Parser[[]Result]) Parser[Result] {
|
|||
if err != nil {
|
||||
return nil, data, err
|
||||
}
|
||||
return NewResultArray(results...), rest, nil
|
||||
return &ResultArray{results: results}, rest, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -45,11 +45,12 @@ func NewResultArray(results ...Result) *ResultArray {
|
|||
}
|
||||
|
||||
func (r *ResultArray) String() string {
|
||||
var acc []string
|
||||
b := strings.Builder{}
|
||||
for _, result := range r.results {
|
||||
acc = append(acc, result.String())
|
||||
_, _ = b.WriteString(result.String())
|
||||
_, _ = b.WriteString(" ")
|
||||
}
|
||||
return strings.TrimRight(strings.Join(acc, " "), " ")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (r *ResultArray) DetailedString() string {
|
||||
|
@ -61,17 +62,26 @@ func (r *ResultArray) DetailedString() string {
|
|||
}
|
||||
|
||||
type RawResult struct {
|
||||
s string
|
||||
s string
|
||||
formatters []func(string) string
|
||||
}
|
||||
|
||||
func NewRawResult(s string) *RawResult {
|
||||
return &RawResult{s}
|
||||
func NewRawResult(s string, formatters ...func(string) string) *RawResult {
|
||||
return &RawResult{s: s, formatters: formatters}
|
||||
}
|
||||
|
||||
func (r *RawResult) String() string {
|
||||
return r.s
|
||||
s := r.s
|
||||
for _, f := range r.formatters {
|
||||
s = f(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (r *RawResult) DetailedString() string {
|
||||
return r.s + "\n"
|
||||
s := r.s
|
||||
for _, f := range r.formatters {
|
||||
s = f(s)
|
||||
}
|
||||
return s + "\n"
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue