From 3a4bdbde940c1df6446a4e2b59b67eff88114db2 Mon Sep 17 00:00:00 2001 From: mappu Date: Sun, 23 Jun 2024 13:07:33 +1200 Subject: [PATCH] redis: improve dialog style, open connection, enumerate databases --- db_redis.go | 184 ++++++++++++++++++++++++++++++++++++++- main.go | 21 +++-- redisConnectionDialog.go | 85 +++++++++++++----- util_types.go | 11 +++ util_vcl.go | 27 ++++++ 5 files changed, 296 insertions(+), 32 deletions(-) create mode 100644 util_types.go create mode 100644 util_vcl.go diff --git a/db_redis.go b/db_redis.go index 2799b60..e7b4591 100644 --- a/db_redis.go +++ b/db_redis.go @@ -1,5 +1,187 @@ package main import ( - _ "github.com/redis/go-redis/v9" + "context" + "errors" + "fmt" + "strconv" + "strings" + "unsafe" + + "github.com/redis/go-redis/v9" + "github.com/ying32/govcl/vcl" + // "github.com/ying32/govcl/vcl/types" ) + +type redisLoadedDatabase struct { + displayName string + db *redis.Client + nav *vcl.TTreeNode + + currentDb int + maxDb int + serverVersion string // populated at connection-time from INFO command + + arena []*navData // keepalive +} + +func (ld *redisLoadedDatabase) DisplayName() string { + return ld.displayName +} + +func (ld *redisLoadedDatabase) DriverName() string { + return "Redis " + ld.serverVersion +} + +func (ld *redisLoadedDatabase) RootElement() *vcl.TTreeNode { + return ld.nav +} + +func (ld *redisLoadedDatabase) Keepalive(ndata *navData) { + ld.arena = append(ld.arena, ndata) +} + +func (ld *redisLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) { + + /* + // Load properties + + bucketDisplayName := strings.Join(ndata.bucketPath, `/`) + content := fmt.Sprintf("Selected database: %#v\n\n\nSelected bucket: %q\n", ld.db.Stats(), bucketDisplayName) + f.propertiesBox.SetText(content) + + // Load data + + f.contentBox.SetEnabled(false) + f.contentBox.Clear() + + // Bolt always uses Key + Value as the columns + + f.contentBox.Columns().Clear() + colKey := f.contentBox.Columns().Add() + colKey.SetCaption("Key") + colKey.SetWidth(MY_WIDTH) + colKey.SetAlignment(types.TaLeftJustify) + colVal := f.contentBox.Columns().Add() + colVal.SetCaption("Value") + + err := ld.db.View(func(tx *bbolt.Tx) error { + b := boltTargetBucket(tx, ndata.bucketPath) + if b == nil { + // no such bucket + return nil + } + + // Valid + f.contentBox.Clear() + + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + dataEntry := f.contentBox.Items().Add() + dataEntry.SetCaption(formatUtf8(k)) + dataEntry.SubItems().Add(formatUtf8(v)) + } + return nil + }) + if err != nil { + vcl.ShowMessage(fmt.Sprintf("Failed to load data for bucket %q: %s", bucketDisplayName, err.Error())) + return + } + + // Valid + f.contentBox.SetEnabled(true)*/ +} + +func (ld *redisLoadedDatabase) NavChildren(ndata *navData) ([]string, error) { + // In the bolt implementation, the nav is a recursive tree of child buckets + return nil, errors.New("TODO") + //return boltChildBucketNames(ld.db, ndata.bucketPath) +} + +func (ld *redisLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) { + ctx := context.Background() + + // Need to parse the query into separate string+args fields for the protocol + // TODO This needs to better handle quotes, escaping, ... + fields := strings.Fields(query) + + fields_boxed := box_interface(fields) + + ret, err := ld.db.Do(ctx, fields_boxed...).Result() + if err != nil { + vcl.ShowMessage(fmt.Sprintf("The redis query returned an error: %v", err)) + return + } + + // Put ret into the data field somehow + fmt.Printf("result\n%#v\n", ret) +} + +var _ loadedDatabase = &redisLoadedDatabase{} // interface assertion + +// + +func (f *TMainForm) redisConnect(opts *redis.Options) { + // TODO load in background thread to stop blocking the UI + ctx := context.Background() + + ld := &redisLoadedDatabase{ + displayName: opts.Addr, + currentDb: 0, + maxDb: 1, + serverVersion: "", + } + + // Patch in a hook to remember current DB after keepalive reconnection + opts.DB = 0 // Default + opts.OnConnect = func(ctx context.Context, cn *redis.Conn) error { + return cn.Select(ctx, ld.currentDb).Err() + } + + // NewClient doesn't necessarily connect, so it can't throw an err + ld.db = redis.NewClient(opts) + + // Make an INFO request (mandatory) + info, err := ld.db.InfoMap(ctx).Result() + if err != nil { + vcl.ShowMessage(fmt.Sprintf("Failed to connect to Redis server: During INFO: %v", err)) + return + } + + if serverInfo, ok := info["Server"]; ok { + if v, ok := serverInfo["redis_version"]; ok { + ld.serverVersion = v + } + } + + // List available databases (usually 1..16) with "0" as default + // If this fails, probably the target redis does not support multiple databases + // (e.g. Redis Cluster). Assume max=0. + if maxDatabases, err := ld.db.ConfigGet(ctx, "databases").Result(); err == nil { + // Got a result. Must parse it + m, err := strconv.ParseInt(maxDatabases["databases"], 10, 64) + if err != nil { + vcl.ShowMessage(fmt.Sprintf("Failed to connect to Redis server: During CONFIG GET databases: %v", err)) + return + } + + ld.maxDb = int(m) + } + + // Done with setup + + ld.nav = f.Buckets.Items().Add(nil, ld.displayName) + ld.nav.SetHasChildren(true) // dynamically populate in OnNavExpanding + ld.nav.SetImageIndex(imgDatabase) + ld.nav.SetSelectedIndex(imgDatabase) + navData := &navData{ + ld: ld, + childrenLoaded: false, // will be loaded dynamically + bucketPath: []string{}, // empty = root + } + ld.nav.SetData(unsafe.Pointer(navData)) + + f.dbs = append(f.dbs, ld) + + ld.Keepalive(navData) +} diff --git a/main.go b/main.go index a324761..dabd252 100644 --- a/main.go +++ b/main.go @@ -9,12 +9,6 @@ import ( "github.com/ying32/govcl/vcl/types" ) -const ( - MY_SPACING = 6 - MY_HEIGHT = 90 - MY_WIDTH = 180 -) - type TMainForm struct { *vcl.TForm @@ -241,10 +235,21 @@ func (f *TMainForm) OnMnuFileSqliteMemoryClick(sender vcl.IObject) { } func (f *TMainForm) OnMnuFileRedisConnectClick(sender vcl.IObject) { + var child *TRedisConnectionDialog vcl.Application.CreateForm(&child) - child.ShowModal() - child.Free() + defer child.Free() + + var ret TRedisConnectionDialogResult + child.CustomReturn = &ret + + mr := child.ShowModal() // Fake blocking + if mr != types.MrOk || ret == nil { + return // Cancelled + } + + // Connect + f.redisConnect(ret) } func (f *TMainForm) OnMnuFileExitClick(sender vcl.IObject) { diff --git a/redisConnectionDialog.go b/redisConnectionDialog.go index 9d4fe2a..3a9a6da 100644 --- a/redisConnectionDialog.go +++ b/redisConnectionDialog.go @@ -1,10 +1,15 @@ package main import ( + "fmt" + + "github.com/redis/go-redis/v9" "github.com/ying32/govcl/vcl" "github.com/ying32/govcl/vcl/types" ) +type TRedisConnectionDialogResult = *redis.Options + type TRedisConnectionDialog struct { *vcl.TForm @@ -12,72 +17,76 @@ type TRedisConnectionDialog struct { Port *vcl.TSpinEdit Password *vcl.TEdit IsResp3Protocol *vcl.TCheckBox + ConnectBtn *vcl.TButton + + // CustomReturn must be set by the caller to a *redis.Options variable. + CustomReturn *TRedisConnectionDialogResult // nil = no CustomReturn/cancelled dialog } func (f *TRedisConnectionDialog) OnFormCreate(sender vcl.IObject) { f.SetCaption("Connect to Redis...") f.ScreenCenter() + f.SetWidth(320) + f.SetHeight(160) // row 1 - row1 := vcl.NewPanel(f) - row1.SetParent(f) - row1.SetBorderStyle(types.BsNone) - row1.SetAlign(types.AlTop) - row1.SetTop(1) - row1.SetAutoSize(true) + row1 := vcl_row(f, 1) - lblAddress := vcl.NewStaticText(row1) + lblAddress := vcl.NewLabel(row1) lblAddress.SetParent(row1) lblAddress.SetCaption("Address:") lblAddress.SetAlign(types.AlLeft) + lblAddress.SetLayout(types.TlCenter) lblAddress.SetLeft(1) - //lblAddress.SetAutoSize(true) + lblAddress.BorderSpacing().SetAround(MY_SPACING) f.Address = vcl.NewEdit(row1) f.Address.SetParent(row1) - f.Address.SetAlign(types.AlLeft) + f.Address.SetAlign(types.AlClient) // grow AlLeft) f.Address.SetWidth(MY_WIDTH) f.Address.SetLeft(2) + f.Address.SetText("127.0.0.1") + f.Address.BorderSpacing().SetAround(MY_SPACING) - lblPort := vcl.NewStaticText(row1) + lblPort := vcl.NewLabel(row1) lblPort.SetParent(row1) lblPort.SetCaption(":") - lblPort.SetAlign(types.AlLeft) - lblPort.SetLeft(3) + lblPort.SetAlign(types.AlRight) + lblPort.SetLayout(types.TlCenter) + lblPort.SetLeft(0) lblPort.SetAutoSize(true) + lblPort.BorderSpacing().SetAround(MY_SPACING) f.Port = vcl.NewSpinEdit(row1) f.Port.SetParent(row1) f.Port.SetMinValue(1) f.Port.SetMaxValue(65535) f.Port.SetValue(6379) // Redis default port - f.Port.SetAlign(types.AlLeft) - f.Port.SetLeft(4) + f.Port.SetAlign(types.AlRight) + f.Port.SetLeft(1000) + f.Port.BorderSpacing().SetAround(MY_SPACING) // row 2 - row2 := vcl.NewPanel(f) - row2.SetParent(f) - row2.SetBorderStyle(types.BsNone) - row2.SetAlign(types.AlTop) - row2.SetTop(2) - row2.SetAutoSize(true) + row2 := vcl_row(f, 2) - lblPassword := vcl.NewStaticText(row2) + lblPassword := vcl.NewLabel(row2) lblPassword.SetParent(row2) lblPassword.SetCaption("Password:") lblPassword.SetAlign(types.AlLeft) + lblPassword.SetLayout(types.TlCenter) lblPassword.SetLeft(1) - // lblPassword.SetAutoSize(true) + lblPassword.BorderSpacing().SetAround(MY_SPACING) f.Password = vcl.NewEdit(row2) f.Password.SetParent(row2) - f.Password.SetAlign(types.AlLeft) + f.Password.SetAlign(types.AlClient) // grow f.Password.SetLeft(2) f.Password.SetWidth(MY_WIDTH) f.Password.SetPasswordChar(uint16('*')) + f.Password.BorderSpacing().SetAround(MY_SPACING) // row 3 @@ -87,5 +96,35 @@ func (f *TRedisConnectionDialog) OnFormCreate(sender vcl.IObject) { f.IsResp3Protocol.SetChecked(true) f.IsResp3Protocol.SetAlign(types.AlTop) f.IsResp3Protocol.SetTop(3) + f.IsResp3Protocol.BorderSpacing().SetAround(MY_SPACING) + // buttons + + row4 := vcl_row(f, 4) + row4.SetAlign(types.AlBottom) + + f.ConnectBtn = vcl.NewButton(row4) + f.ConnectBtn.SetParent(row4) + f.ConnectBtn.SetAlign(types.AlRight) + f.ConnectBtn.SetCaption("Connect") + f.ConnectBtn.SetOnClick(f.OnConnectBtnClick) +} + +func (f *TRedisConnectionDialog) OnConnectBtnClick(sender vcl.IObject) { + + ret := &redis.Options{ + Addr: fmt.Sprintf("%s:%d", f.Address.Text(), f.Port.Value()), + Password: f.Password.Text(), + DB: 0, // Start off with default DB, but, we will browse them ourselves + Protocol: 2, // RESP2 + } + + if f.IsResp3Protocol.Checked() { + ret.Protocol = 3 // RESP3 + } + + // Success. + // Fill in target pointer and close dialog with "MrOk" sentinel + *f.CustomReturn = ret + f.SetModalResult(types.MrOk) } diff --git a/util_types.go b/util_types.go new file mode 100644 index 0000000..19379c2 --- /dev/null +++ b/util_types.go @@ -0,0 +1,11 @@ +package main + +// box_interface creates a slice where all elements of the input slice are boxed +// in an interface{} type. +func box_interface[T any](input []T) []interface{} { + ret := make([]interface{}, 0, len(input)) + for _, v := range input { + ret = append(ret, v) + } + return ret +} \ No newline at end of file diff --git a/util_vcl.go b/util_vcl.go new file mode 100644 index 0000000..37a89bf --- /dev/null +++ b/util_vcl.go @@ -0,0 +1,27 @@ +package main + +import ( + "github.com/ying32/govcl/vcl" + "github.com/ying32/govcl/vcl/types" +) + +const ( + MY_SPACING = 6 + MY_HEIGHT = 90 + MY_WIDTH = 180 +) + +// vcl_row makes a TPanel row inside the target component. +func vcl_row(owner vcl.IWinControl, top int32) *vcl.TPanel { + + r := vcl.NewPanel(owner) + r.SetParent(owner) + r.SetBorderStyle(types.BsNone) + r.SetBevelInner(types.BvNone) + r.SetBevelOuter(types.BvNone) + r.SetAlign(types.AlTop) + r.SetTop(top) + r.SetAutoSize(true) + + return r +}