From 92dedfc95f27e024a5e49c0b320b83d1d4d3bb67 Mon Sep 17 00:00:00 2001 From: Jussi Maki Date: Fri, 19 Dec 2025 14:40:30 +0100 Subject: [PATCH] tui: Initial commit Signed-off-by: Jussi Maki --- cmd/tui/main.go | 45 +++ go.mod | 17 +- go.sum | 57 ++- reconciler/example/main.go | 3 + reconciler/example/types.go | 6 +- tui/tui.go | 739 ++++++++++++++++++++++++++++++++++++ tui/tui_test.go | 48 +++ 7 files changed, 899 insertions(+), 16 deletions(-) create mode 100644 cmd/tui/main.go create mode 100644 tui/tui.go create mode 100644 tui/tui_test.go diff --git a/cmd/tui/main.go b/cmd/tui/main.go new file mode 100644 index 0000000..f460991 --- /dev/null +++ b/cmd/tui/main.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Cilium + +package main + +import ( + "context" + "flag" + "log" + "log/slog" + "os" + "os/exec" + "os/signal" + "strings" + "syscall" + + "github.com/cilium/statedb/tui" +) + +func main() { + execCmd := flag.String("exec", "", "Command to run for each db/db/show invocation (required)") + flag.Parse() + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + if *execCmd == "" { + log.Fatalf("--exec is required") + } + + baseCmd := *execCmd + baseArgs := flag.Args() + run := func(cmd string) (string, error) { + args := append(append([]string{}, baseArgs...), strings.Fields(cmd)...) + c := exec.CommandContext(ctx, baseCmd, args...) + out, err := c.CombinedOutput() + return string(out), err + } + + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + if err := tui.Run(ctx, run, logger); err != nil { + log.Fatalf("tui: %v", err) + } +} diff --git a/go.mod b/go.mod index 2d26e7f..60afb25 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,13 @@ module github.com/cilium/statedb -go 1.24 +go 1.24.0 require ( - github.com/cilium/hive v0.0.0-20250731144630-28e7a35ed227 + github.com/cilium/hive v0.0.0-20251219070844-89ccf807d9fb github.com/cilium/stream v0.0.0-20240209152734-a0792b51812d + github.com/gdamore/tcell/v2 v2.13.4 github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de + github.com/rivo/tview v0.42.0 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.4 @@ -17,12 +19,15 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gdamore/encoding v1.0.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -33,10 +38,10 @@ require ( go.uber.org/dig v1.17.1 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect - golang.org/x/sys v0.17.0 // indirect - golang.org/x/term v0.16.0 // indirect - golang.org/x/text v0.14.0 // indirect - golang.org/x/tools v0.17.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 6d38554..e3cf396 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/cilium/hive v0.0.0-20250731144630-28e7a35ed227 h1:eeiPlekde25dlwsqv87HF9/BOEbf9uujC2dB1ucwywo= github.com/cilium/hive v0.0.0-20250731144630-28e7a35ed227/go.mod h1:6qtm9+eQD8D1SsqGFgNE63lNeys9PZswouh37X5ZhWU= +github.com/cilium/hive v0.0.0-20251219070844-89ccf807d9fb h1:4liozOPul/ER4VH2q8KJXTot1rfYxZbYyAyZzBcvP/M= +github.com/cilium/hive v0.0.0-20251219070844-89ccf807d9fb/go.mod h1:6qtm9+eQD8D1SsqGFgNE63lNeys9PZswouh37X5ZhWU= github.com/cilium/stream v0.0.0-20240209152734-a0792b51812d h1:p6MgATaKEB9o7iAsk9rlzXNDMNCeKPAkx4Y8f+Zq8X8= github.com/cilium/stream v0.0.0-20240209152734-a0792b51812d/go.mod h1:3VLiLgs8wfjirkuYqos4t0IBPQ+sXtf3tFkChLm6ARM= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -11,6 +13,10 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.13.4 h1:k4fdtdHGvLsLr2RttPnWEGTZEkEuTaL+rL6AOVFyRWU= +github.com/gdamore/tcell/v2 v2.13.4/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -26,6 +32,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -35,6 +43,10 @@ github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdU github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= +github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -63,6 +75,7 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc= go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -71,18 +84,48 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= -golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/reconciler/example/main.go b/reconciler/example/main.go index 070301d..d91ca66 100644 --- a/reconciler/example/main.go +++ b/reconciler/example/main.go @@ -19,6 +19,7 @@ import ( "github.com/cilium/hive" "github.com/cilium/hive/cell" "github.com/cilium/hive/job" + "github.com/cilium/hive/shell" "github.com/cilium/statedb" "github.com/cilium/statedb/reconciler" ) @@ -107,6 +108,8 @@ var Hive = hive.NewWithOptions( }, }, + shell.ServerCell("/tmp/example.sock"), + statedb.Cell, job.Cell, diff --git a/reconciler/example/types.go b/reconciler/example/types.go index b4de5db..46e135c 100644 --- a/reconciler/example/types.go +++ b/reconciler/example/types.go @@ -30,16 +30,16 @@ type Memo struct { } // TableHeader implements statedb.TableWritable. -func (memo Memo) TableHeader() []string { +func (memo *Memo) TableHeader() []string { return []string{"Name", "Content", "Status"} } // TableRow implements statedb.TableWritable. -func (memo Memo) TableRow() []string { +func (memo *Memo) TableRow() []string { return []string{memo.Name, memo.Content, memo.Status.String()} } -var _ statedb.TableWritable = Memo{} +var _ statedb.TableWritable = &Memo{} // GetStatus returns the reconciliation status. Used to provide the // reconciler access to it. diff --git a/tui/tui.go b/tui/tui.go new file mode 100644 index 0000000..4764526 --- /dev/null +++ b/tui/tui.go @@ -0,0 +1,739 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Cilium + +// Package tui provides a small tview-based UI on top of the Hive script shell. +// It drives the existing "db" and "db/show" commands instead of talking to the +// database directly so that the output matches the regular hive shell. +package tui + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "regexp" + "slices" + "sort" + "strings" + "sync" + "syscall" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +const hiveShellEndMarker = "<>" + +// Run launches a terminal UI backed by a command runner. +// The runner function is called with commands like "db" or "db/show " +// and should return the stdout (optionally with the hive/shell "<>" marker). +func Run(ctx context.Context, run func(string) (string, error), log *slog.Logger) error { + if log == nil { + log = slog.New(slog.NewTextHandler(os.Stdout, nil)) + } + + client := newShellClient(run, log) + + app := newView(client, log) + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Stop the UI when the context is cancelled or on SIGINT/SIGTERM. + go func() { + select { + case <-ctx.Done(): + case <-interruptChan(): + } + app.Stop() + }() + + return app.Run() +} + +type shellClient struct { + runFunc func(string) (string, error) + mu sync.Mutex + log *slog.Logger +} + +func newShellClient(run func(string) (string, error), log *slog.Logger) *shellClient { + return &shellClient{ + runFunc: run, + log: log, + } +} + +// run executes a single hive shell command and returns stdout until the next prompt. +func (c *shellClient) run(cmd string) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + out, err := c.runFunc(cmd) + if err != nil { + return out, err + } + return strings.TrimSpace(stripEndMarker(out)), nil +} + +type view struct { + app *tview.Application + client *shellClient + log *slog.Logger + filterInput *tview.InputField + statusInfo *tview.TextView + statusMsg *tview.TextView + viewFlex *tview.Flex + focusOrder []tview.Primitive + tableNames []string + filterText string + views []*tableView + filterFocus bool +} + +type tableView struct { + root *tview.Flex + tableDrop *tview.DropDown + sortDrop *tview.DropDown + filterDrop *tview.DropDown + dataTable *tview.Table + table string + header []string + rows [][]string + filterCol int + sortCol int + sortAsc bool + lastRow int +} + +func newView(client *shellClient, log *slog.Logger) *view { + v := &view{ + app: tview.NewApplication(), + client: client, + log: log, + } + v.app.EnableMouse(true) + + v.viewFlex = tview.NewFlex().SetDirection(tview.FlexRow) + + v.filterInput = tview.NewInputField(). + SetLabel("Search: "). + SetPlaceholder(""). + SetChangedFunc(func(text string) { + v.filterText = text + v.applyFilterAndSortAll() + }) + v.filterInput.SetFieldWidth(40) + v.filterInput.SetFocusFunc(func() { v.filterFocus = true }) + v.filterInput.SetBlurFunc(func() { v.filterFocus = false }) + + v.statusInfo = tview.NewTextView(). + SetDynamicColors(true). + SetText("[yellow]Tab: focus N: new pane n/p: next/prev table r: reload R: refresh schema q: close pane Ctrl+Q: quit") + v.statusMsg = tview.NewTextView(). + SetDynamicColors(true). + SetTextAlign(tview.AlignRight) + + v.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyTAB: + v.focusNext(false) + return nil + case tcell.KeyBacktab: + v.focusNext(true) + return nil + case tcell.KeyCtrlQ: + v.app.Stop() + return nil + } + + if v.filterFocus { + return event + } + + switch event.Rune() { + case 'q': + v.closeFocusedView() + return nil + case 'r': + v.reloadAll() + return nil + case 'R': + v.refreshTables() + return nil + case 'N': + v.addTableView("") + return nil + case 'n': + v.selectNextTable(+1) + return nil + case 'p': + v.selectNextTable(-1) + return nil + } + return event + }) + + controlBar := tview.NewFlex(). + AddItem(v.filterInput, 0, 1, true) + + root := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(v.viewFlex, 0, 1, true). + AddItem(controlBar, 1, 0, false). + AddItem(tview.NewFlex(). + AddItem(v.statusInfo, 0, 3, false). + AddItem(v.statusMsg, 0, 2, false), 1, 0, false) + + v.app.SetRoot(root, true) + + return v +} + +func (v *view) Run() error { + if err := v.refreshTables(); err != nil { + return err + } + if len(v.views) == 0 { + v.addTableView("") + } + return v.app.Run() +} + +func (v *view) Stop() { + v.app.Stop() +} + +func (v *view) refreshTables() error { + out, err := v.client.run("db") + if err != nil { + v.setStatus(fmt.Sprintf("[red]db: %v", err)) + return err + } + + table, err := parseTable(out) + if err != nil { + v.setStatus(fmt.Sprintf("[red]parse db: %v", err)) + return err + } + + nameIdx := findColumn(table.Header, "Name") + v.tableNames = nil + for _, row := range table.Rows { + name := valueAt(row, nameIdx) + if name != "" { + v.tableNames = append(v.tableNames, name) + } + } + if len(v.views) == 0 && len(v.tableNames) > 0 { + v.addTableView(v.tableNames[0]) + } else { + for _, tv := range v.views { + v.updateTableOptions(tv) + if tv.table == "" && len(v.tableNames) > 0 { + tv.table = v.tableNames[0] + tv.tableDrop.SetCurrentOption(0) + v.loadTable(tv, tv.table) + } + } + } + + v.setStatus("[green]Schema refreshed") + return nil +} + +func (v *view) applyFilterAndSortAll() { + for _, tv := range v.views { + tv.applyFilter(v.filterText) + } +} + +func (v *view) setStatus(msg string) { + v.statusMsg.SetText(msg) +} + +func (v *view) focusNext(reverse bool) { + if len(v.focusOrder) == 0 { + return + } + current := v.app.GetFocus() + idx := -1 + for i, p := range v.focusOrder { + if p == current { + idx = i + break + } + } + if idx == -1 { + idx = 0 + } + if reverse { + idx = (idx - 1 + len(v.focusOrder)) % len(v.focusOrder) + } else { + idx = (idx + 1) % len(v.focusOrder) + } + v.app.SetFocus(v.focusOrder[idx]) +} + +func (v *view) closeFocusedView() { + focus := v.app.GetFocus() + for i, tv := range v.views { + if focus == tv.dataTable || focus == tv.tableDrop || focus == tv.sortDrop || focus == tv.filterDrop { + v.viewFlex.RemoveItem(tv.root) + v.views = append(v.views[:i], v.views[i+1:]...) + v.rebuildFocusOrder() + if len(v.views) == 0 && len(v.tableNames) > 0 { + v.addTableView(v.tableNames[0]) + } + return + } + } +} + +func (v *view) selectNextTable(delta int) { + if len(v.tableNames) == 0 || len(v.views) == 0 { + return + } + // Use focused view if possible, else first. + var tv *tableView + focus := v.app.GetFocus() + for _, t := range v.views { + if focus == t.dataTable || focus == t.tableDrop || focus == t.sortDrop || focus == t.filterDrop { + tv = t + break + } + } + if tv == nil { + tv = v.views[0] + } + // Find current index in tableNames. + curIdx := 0 + for i, name := range v.tableNames { + if name == tv.table { + curIdx = i + break + } + } + nextIdx := (curIdx + delta + len(v.tableNames)) % len(v.tableNames) + tv.tableDrop.SetCurrentOption(nextIdx) + tv.table = v.tableNames[nextIdx] + tv.dataTable.Clear() + v.loadTable(tv, tv.table) +} + +func (v *view) addTableView(initial string) { + if len(v.tableNames) == 0 { + v.setStatus("[red]No tables available") + return + } + if initial == "" { + initial = v.tableNames[0] + } + tv := &tableView{ + table: initial, + sortAsc: true, + sortCol: 0, + filterCol: 0, + lastRow: 1, + } + + tv.tableDrop = tview.NewDropDown() + tv.tableDrop.SetLabel("Table: ") + tv.tableDrop.SetFieldWidth(32) + tv.tableDrop.SetBackgroundColor(tcell.ColorGray) + tv.sortDrop = tview.NewDropDown() + tv.sortDrop.SetLabel("Sort by: ") + tv.sortDrop.SetFieldWidth(32) + tv.sortDrop.SetBackgroundColor(tcell.ColorGray) + tv.filterDrop = tview.NewDropDown() + tv.filterDrop.SetLabel("Search by: ") + tv.filterDrop.SetFieldWidth(32) + tv.filterDrop.SetBorder(false) + tv.filterDrop.SetBackgroundColor(tcell.ColorGray) + tv.dataTable = tview.NewTable(). + SetBorders(false). + SetFixed(1, 1). + SetSelectable(true, true). + SetSeparator(tview.Borders.Vertical) + + tv.dataTable.SetSelectionChangedFunc(func(row, column int) { + if row != 0 || column < 0 || column >= len(tv.header) { + return + } + if column == tv.sortCol { + tv.sortAsc = !tv.sortAsc + } else { + tv.sortCol = column + tv.sortAsc = true + } + tv.sortDrop.SetCurrentOption(tv.sortCol) + tv.applyFilter(v.filterText) + // Move selection back to data row to avoid keeping header selected. + targetRow := tv.lastRow + if targetRow == 0 && len(tv.rows) > 0 { + targetRow = 1 + } + tv.dataTable.Select(targetRow, column) + }) + + // Wire callbacks + tv.sortDrop.SetSelectedFunc(func(_ string, idx int) { + if idx == tv.sortCol { + tv.sortAsc = !tv.sortAsc + } else { + tv.sortCol = idx + tv.sortAsc = true + } + v.applyFilterAndSortAll() + }) + tv.filterDrop.SetSelectedFunc(func(_ string, idx int) { + tv.filterCol = idx + v.applyFilterAndSortAll() + }) + + spacer := tview.NewBox().SetBackgroundColor(tcell.ColorGray) + control := tview.NewFlex(). + AddItem(tv.tableDrop, 32, 0, false). // fixed width to keep controls left + AddItem(tv.sortDrop, 32, 0, false). + AddItem(tv.filterDrop, 32, 0, false). + AddItem(spacer, 0, 1, false) // spacer consumes remaining width + + tv.root = tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(control, 1, 0, false). + AddItem(tv.dataTable, 0, 1, true) + tv.root.SetBorder(true) + + v.updateTableOptions(tv) + v.views = append(v.views, tv) + v.viewFlex.AddItem(tv.root, 0, 1, false) + v.rebuildFocusOrder() + + v.loadTable(tv, initial) +} + +type parsedTable struct { + Header []string + Rows [][]string +} + +func parseTable(out string) (parsedTable, error) { + out = strings.ReplaceAll(out, "\r", "") + lines := slices.DeleteFunc(strings.Split(out, "\n"), func(s string) bool { + return strings.TrimSpace(s) == "" + }) + if len(lines) == 0 { + return parsedTable{}, fmt.Errorf("empty table output") + } + + // Pick the first non-empty line as header. + headerLine := "" + var rest []string + for i, line := range lines { + line = strings.TrimSpace(line) + if line == "[stdout]" || line == "" { + continue + } + headerLine = stripMagic(line) + rest = lines[i+1:] + break + } + if headerLine == "" { + return parsedTable{}, fmt.Errorf("no header line detected") + } + + header, positions := splitHeaderLine(headerLine) + splitByTabs := strings.ContainsRune(headerLine, '\t') + usePositions := len(header) > 0 + if !usePositions { + header = strings.Fields(headerLine) + } + if len(header) == 0 { + return parsedTable{}, fmt.Errorf("no header columns detected") + } + + rows := make([][]string, 0, len(rest)) + for _, line := range rest { + line = stripMagic(line) + if strings.TrimSpace(line) == "" { + continue + } + switch { + case usePositions: + rows = append(rows, splitByPositions(line, positions, splitByTabs)) + case splitByTabs: + rows = append(rows, strings.Split(line, "\t")) + default: + rows = append(rows, strings.Fields(line)) + } + } + return parsedTable{Header: header, Rows: rows}, nil +} + +func stripMagic(line string) string { + return strings.Map(func(r rune) rune { + if r == 0xfe { + return -1 + } + return r + }, line) +} + +func stripEndMarker(out string) string { + out = strings.ReplaceAll(out, "\r", "") + if idx := strings.LastIndex(out, hiveShellEndMarker); idx >= 0 { + out = out[:idx] + } + return out +} + +func findColumn(header []string, name string) int { + for i, col := range header { + if strings.EqualFold(col, name) { + return i + } + } + return -1 +} + +func valueAt(row []string, idx int) string { + if idx >= 0 && idx < len(row) { + return row[idx] + } + return "" +} + +func tableHeaderCell(text string) *tview.TableCell { + return tview.NewTableCell(text). + SetAlign(tview.AlignLeft). + SetAttributes(tcell.AttrBold | tcell.AttrUnderline). + SetSelectable(false) + +} + +// splitHeaderLine mirrors the helper used by the script commands to extract +// column names and their start offsets from a formatted header line. +func splitHeaderLine(line string) (names []string, pos []int) { + start := 0 + for i := 0; i < len(line); { + if line[i] == ' ' || line[i] == '\t' { + runStart := i + hasTab := false + for i < len(line) && (line[i] == ' ' || line[i] == '\t') { + if line[i] == '\t' { + hasTab = true + } + i++ + } + if hasTab || i-runStart >= 2 { + if start < runStart { + names = append(names, strings.TrimSpace(line[start:runStart])) + pos = append(pos, start) + } + start = i + } + continue + } + i++ + } + if start < len(line) { + names = append(names, strings.TrimSpace(line[start:])) + pos = append(pos, start) + } + return +} + +// splitByPositions mirrors the script helper used to parse rows formatted by a +// tabwriter. The start positions are derived from splitHeaderLine. +func splitByPositions(line string, positions []int, splitByTabs bool) []string { + out := make([]string, 0, len(positions)) + start := 0 + for i, pos := range positions[1:] { + if start >= len(line) { + out = append(out, "") + start = len(line) + continue + } + if splitByTabs { + s := strings.Split(line[start:min(pos, len(line))], "\t")[i] + out = append(out, s) + start += len(s) + i + } else { + out = append(out, strings.TrimRight(line[start:min(pos, len(line))], " \t")) + start = pos + } + } + if splitByTabs { + out = append(out, strings.Split(line[min(start, len(line)):], "\t")...) + } else { + out = append(out, strings.TrimRight(line[min(start, len(line)):], " \t")) + } + return out +} + +func interruptChan() <-chan os.Signal { + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Interrupt, syscall.SIGTERM) + return ch +} +func (v *view) updateTableOptions(tv *tableView) { + tv.tableDrop.SetOptions(v.tableNames, func(name string, _ int) { + tv.table = name + tv.dataTable.Clear() + v.loadTable(tv, name) + }) + for i, name := range v.tableNames { + if name == tv.table { + tv.tableDrop.SetCurrentOption(i) + break + } + } +} + +func (v *view) loadTable(tv *tableView, name string) { + if name == "" { + return + } + target := name + v.setStatus(fmt.Sprintf("[yellow]Loading %s...", name)) + + go func() { + out, err := v.client.run("db/show " + name) + if err != nil { + v.app.QueueUpdateDraw(func() { + v.setStatus(fmt.Sprintf("[red]db/show %s: %v", name, err)) + }) + return + } + + table, err := parseTable(out) + if err != nil { + v.app.QueueUpdateDraw(func() { + v.setStatus(fmt.Sprintf("[red]parse %s: %v", name, err)) + }) + return + } + + v.app.QueueUpdateDraw(func() { + if tv.table != target { + return // stale response after table changed + } + tv.table = target + tv.header = table.Header + tv.rows = table.Rows + tv.installColumns(v.filterText, v.applyFilterAndSortAll) + if len(tv.rows) == 0 { + tv.lastRow = 0 + } else { + tv.lastRow = 1 + } + tv.applyFilter(v.filterText) + v.setStatus(fmt.Sprintf("[green]%s loaded (%d rows)", name, len(table.Rows))) + }) + }() +} + +func (v *view) reloadAll() { + for _, tv := range v.views { + v.loadTable(tv, tv.table) + } +} + +func (v *view) rebuildFocusOrder() { + var order []tview.Primitive + order = append(order, v.filterInput) + for _, tv := range v.views { + order = append(order, tv.tableDrop, tv.sortDrop, tv.filterDrop, tv.dataTable) + } + v.focusOrder = order + if v.app.GetFocus() == nil && len(order) > 0 { + v.app.SetFocus(order[0]) + } +} + +func (tv *tableView) installColumns(filterText string, apply func()) { + cols := tv.header + tv.sortDrop.SetOptions(cols, func(_ string, idx int) { + if idx == tv.sortCol { + tv.sortAsc = !tv.sortAsc + } else { + tv.sortCol = idx + tv.sortAsc = true + } + apply() + }) + tv.filterDrop.SetOptions(cols, func(_ string, idx int) { + tv.filterCol = idx + apply() + }) + if tv.sortCol >= len(cols) { + tv.sortCol = 0 + } + if tv.filterCol >= len(cols) { + tv.filterCol = 0 + } + tv.sortDrop.SetCurrentOption(tv.sortCol) + tv.filterDrop.SetCurrentOption(tv.filterCol) + apply() +} + +func (tv *tableView) applyFilter(filterText string) { + if len(tv.header) == 0 { + return + } + rows := tv.rows + + if filterText != "" && tv.filterCol < len(tv.header) { + re, err := regexp.Compile(filterText) + if err != nil { + return + } + filtered := make([][]string, 0, len(rows)) + for _, row := range rows { + if tv.filterCol < len(row) && re.MatchString(row[tv.filterCol]) { + filtered = append(filtered, row) + } + } + rows = filtered + } + + if tv.sortCol < len(tv.header) { + sort.SliceStable(rows, func(i, j int) bool { + a := valueAt(rows[i], tv.sortCol) + b := valueAt(rows[j], tv.sortCol) + if tv.sortAsc { + return a < b + } + return a > b + }) + } + + tv.renderRows(rows) +} + +func (tv *tableView) renderRows(rows [][]string) { + tv.dataTable.Clear() + tv.dataTable.SetFixed(1, 0).SetOffset(0, 0) + for i, col := range tv.header { + icon := "" + if i == tv.sortCol { + if tv.sortAsc { + icon += "▲" + } else { + icon += "▼" + } + } + if i == tv.filterCol { + icon += "🔍" + } + label := col + if icon != "" { + label = fmt.Sprintf("%s %s", icon, col) + } + tv.dataTable.SetCell(0, i, tableHeaderCell(label).SetExpansion(1).SetMaxWidth(0).SetText(fmt.Sprintf(" %s ", label))) + } + for r, row := range rows { + for c, val := range row { + tv.dataTable.SetCell(r+1, c, tview.NewTableCell(fmt.Sprintf(" %s ", val)).SetExpansion(1).SetMaxWidth(0)) + } + } +} diff --git a/tui/tui_test.go b/tui/tui_test.go new file mode 100644 index 0000000..d3da145 --- /dev/null +++ b/tui/tui_test.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Cilium + +package tui + +import ( + "strings" + "testing" + + "github.com/liggitt/tabwriter" +) + +func TestParseTable(t *testing.T) { + var b strings.Builder + tw := tabwriter.NewWriter(&b, 5, 4, 3, ' ', tabwriter.RememberWidths) + _, _ = tw.Write([]byte("Name\tObject count\tZombie objects\n")) + _, _ = tw.Write([]byte("foo\t1\t0\n")) + _, _ = tw.Write([]byte("bar\t2\t0\n")) + tw.Flush() + + table, err := parseTable(b.String()) + if err != nil { + t.Fatalf("parseTable: %v", err) + } + t.Logf("header: %#v", table.Header) + + if got, want := table.Header, []string{"Name", "Object count", "Zombie objects"}; !slicesEqual(got, want) { + t.Fatalf("header mismatch: got %v want %v", got, want) + } + if len(table.Rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(table.Rows)) + } + if table.Rows[0][0] != "foo" || table.Rows[1][0] != "bar" { + t.Fatalf("unexpected rows: %v", table.Rows) + } +} + +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +}