diff --git a/internal/cmd/branch/connections.go b/internal/cmd/branch/connections.go index 39f688f41..3607eef3e 100644 --- a/internal/cmd/branch/connections.go +++ b/internal/cmd/branch/connections.go @@ -10,8 +10,8 @@ import ( func ConnectionsCmd(ch *cmdutil.Helper) *cobra.Command { cmd := &cobra.Command{ Use: "connections ", - Short: "Show and kill MySQL or Postgres branch connections", - Long: `Show and kill MySQL or Postgres branch connections. Neki is not supported. + Short: "Show and kill branch connections", + Long: `Show and kill MySQL or Postgres branch connections, and show Neki branch connections. MySQL (Vitess): show/kill/top with --keyspace and --shard. @@ -20,6 +20,9 @@ Postgres: show/top with --instance or --role. kill-transaction terminates a transaction_id from show. +Neki: + show/top list client sessions across the branch's routers. Read-only. + Agent workflow: 1. Run: pscale branch connections show --format json 2. Inspect query_id, transaction_id, and connection_id from the selected row. diff --git a/internal/cmd/branch/connections/client.go b/internal/cmd/branch/connections/client.go index bf5a5bc34..a687f31d9 100644 --- a/internal/cmd/branch/connections/client.go +++ b/internal/cmd/branch/connections/client.go @@ -2,7 +2,9 @@ package connections import ( "context" + "errors" "fmt" + "sync" "github.com/planetscale/cli/internal/cmdutil" live "github.com/planetscale/cli/internal/connections" @@ -39,6 +41,65 @@ func databaseNotFoundError(ch *cmdutil.Helper, database string) error { printer.BoldBlue(database), printer.BoldBlue(ch.Config.Organization)) } +// notFoundExplainer replaces the connections endpoint's ambiguous 404 with +// whether the branch is missing or just lacks live connections. The answer is +// cached so a refreshing TUI does not repeat the lookups every interval. +type notFoundExplainer struct { + ch *cmdutil.Helper + database string + branch string + + once sync.Once + explained error +} + +func newNotFoundExplainer(ch *cmdutil.Helper, database, branch string) *notFoundExplainer { + return ¬FoundExplainer{ch: ch, database: database, branch: branch} +} + +func (e *notFoundExplainer) explain(ctx context.Context, err error) error { + var notFound *live.NotFoundError + if e == nil || !errors.As(err, ¬Found) { + return err + } + e.once.Do(func() { + e.explained = e.resolve(ctx, err) + }) + return e.explained +} + +func (e *notFoundExplainer) resolve(ctx context.Context, err error) error { + client, clientErr := e.ch.Client() + if clientErr != nil { + return err + } + org := e.ch.Config.Organization + db, dbErr := client.Databases.Get(ctx, &ps.GetDatabaseRequest{Organization: org, Database: e.database}) + if cmdutil.ErrCode(dbErr) == ps.ErrNotFound { + return databaseNotFoundError(e.ch, e.database) + } + if dbErr != nil || db == nil { + return err + } + + var branchErr error + if db.Kind == ps.DatabaseEngineMySQL { + _, branchErr = client.DatabaseBranches.Get(ctx, &ps.GetDatabaseBranchRequest{Organization: org, Database: e.database, Branch: e.branch}) + } else { + _, branchErr = client.PostgresBranches.Get(ctx, &ps.GetPostgresBranchRequest{Organization: org, Database: e.database, Branch: e.branch}) + } + switch { + case cmdutil.ErrCode(branchErr) == ps.ErrNotFound: + return fmt.Errorf("branch %s does not exist in database %s (organization: %s)", + printer.BoldBlue(e.branch), printer.BoldBlue(e.database), printer.BoldBlue(org)) + case branchErr != nil: + return err + default: + return fmt.Errorf("live connections are not enabled for branch %s in database %s (organization: %s)", + printer.BoldBlue(e.branch), printer.BoldBlue(e.database), printer.BoldBlue(org)) + } +} + func newConnectionsClient(ch *cmdutil.Helper, database, branch string, target ConnectionTarget) (*live.Client, error) { return live.NewClient(live.ClientConfig{ BaseURL: ch.Config.BaseURL, diff --git a/internal/cmd/branch/connections/client_test.go b/internal/cmd/branch/connections/client_test.go new file mode 100644 index 000000000..43d62c3d5 --- /dev/null +++ b/internal/cmd/branch/connections/client_test.go @@ -0,0 +1,56 @@ +package connections + +import ( + "context" + "errors" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/config" + live "github.com/planetscale/cli/internal/connections" + "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" +) + +func TestNotFoundExplainerLooksUpBranchOnce(t *testing.T) { + c := qt.New(t) + lookups := 0 + branches := &mock.DatabaseBranchesService{ + GetFn: func(context.Context, *ps.GetDatabaseBranchRequest) (*ps.DatabaseBranch, error) { + lookups++ + return &ps.DatabaseBranch{Name: "main"}, nil + }, + } + ch := &cmdutil.Helper{ + Config: &config.Config{Organization: "acme"}, + Client: func() (*ps.Client, error) { + return &ps.Client{ + Databases: &mock.DatabaseService{ + GetFn: func(_ context.Context, req *ps.GetDatabaseRequest) (*ps.Database, error) { + return &ps.Database{Name: req.Database, Kind: ps.DatabaseEngineMySQL}, nil + }, + }, + DatabaseBranches: branches, + }, nil + }, + } + explainer := newNotFoundExplainer(ch, "shop", "main") + notFound := &live.NotFoundError{Organization: "acme", Database: "shop", Branch: "main"} + + first := explainer.explain(context.Background(), notFound) + second := explainer.explain(context.Background(), notFound) + + c.Assert(first, qt.ErrorMatches, `live connections are not enabled for branch main in database shop \(organization: acme\)`) + c.Assert(second, qt.Equals, first) + c.Assert(lookups, qt.Equals, 1) +} + +func TestNotFoundExplainerPassesOtherErrorsThrough(t *testing.T) { + c := qt.New(t) + other := errors.New("list connections: rate limited, please retry in a moment") + + got := newNotFoundExplainer(&cmdutil.Helper{}, "shop", "main").explain(context.Background(), other) + + c.Assert(got, qt.Equals, other) +} diff --git a/internal/cmd/branch/connections/engine_flags.go b/internal/cmd/branch/connections/engine_flags.go index a9c7ac898..0c22badba 100644 --- a/internal/cmd/branch/connections/engine_flags.go +++ b/internal/cmd/branch/connections/engine_flags.go @@ -6,7 +6,11 @@ import ( ps "github.com/planetscale/cli/internal/planetscale" ) -var errNekiUnsupported = errors.New("connections is not supported for Neki databases") +var ( + errVitessTargetOnly = errors.New("--keyspace/--shard are only supported for Vitess databases") + errPostgresFilterOnly = errors.New("--instance/--role are only supported for Postgres databases") + errNekiReadOnly = errors.New("connections are read-only for Neki databases") +) // ValidateEngineFlags rejects flags that only apply to another database engine. func ValidateEngineFlags(engine ps.DatabaseEngine, filter ConnectionFilter, target ConnectionTarget) error { @@ -14,18 +18,31 @@ func ValidateEngineFlags(engine ps.DatabaseEngine, filter ConnectionFilter, targ } func validateEngineFlags(engine ps.DatabaseEngine, filter connectionFilter, target ConnectionTarget) error { - if engine == ps.DatabaseEngineNeki { - return errNekiUnsupported - } + vitessTarget := target.Keyspace != "" || target.Shard != "" switch engine { case ps.DatabaseEnginePostgres: - if target.Keyspace != "" || target.Shard != "" { - return errors.New("--keyspace/--shard are only supported for Vitess databases") + if vitessTarget { + return errVitessTargetOnly } case ps.DatabaseEngineMySQL: if filter.active() { - return errors.New("--instance/--role are only supported for Postgres databases") + return errPostgresFilterOnly + } + case ps.DatabaseEngineNeki: + if vitessTarget { + return errVitessTargetOnly + } + if filter.active() { + return errPostgresFilterOnly } } return nil } + +// ValidateActionEngine rejects kill commands for engines whose connections are read-only. +func ValidateActionEngine(engine ps.DatabaseEngine) error { + if engine == ps.DatabaseEngineNeki { + return errNekiReadOnly + } + return nil +} diff --git a/internal/cmd/branch/connections/engine_flags_test.go b/internal/cmd/branch/connections/engine_flags_test.go index 9fb1f2015..077068247 100644 --- a/internal/cmd/branch/connections/engine_flags_test.go +++ b/internal/cmd/branch/connections/engine_flags_test.go @@ -7,10 +7,38 @@ import ( ps "github.com/planetscale/cli/internal/planetscale" ) -func TestValidateEngineFlagsRejectsNeki(t *testing.T) { - c := qt.New(t) +func TestValidateEngineFlagsForNeki(t *testing.T) { + tests := []struct { + name string + filter ConnectionFilter + target ConnectionTarget + wantErr string + }{ + {name: "no flags"}, + {name: "vitess target", target: ConnectionTarget{Keyspace: "commerce"}, wantErr: "--keyspace/--shard are only supported for Vitess databases"}, + {name: "instance filter", filter: ConnectionFilter{Instance: "router-a"}, wantErr: "--instance/--role are only supported for Postgres databases"}, + {name: "role filter", filter: ConnectionFilter{Role: "primary"}, wantErr: "--instance/--role are only supported for Postgres databases"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := qt.New(t) + + err := ValidateEngineFlags(ps.DatabaseEngineNeki, tt.filter, tt.target) - err := ValidateEngineFlags(ps.DatabaseEngineNeki, ConnectionFilter{}, ConnectionTarget{}) + if tt.wantErr == "" { + c.Assert(err, qt.IsNil) + return + } + c.Assert(err, qt.ErrorMatches, tt.wantErr) + }) + } +} + +func TestValidateActionEngine(t *testing.T) { + c := qt.New(t) - c.Assert(err, qt.ErrorMatches, "connections is not supported for Neki databases") + c.Assert(ValidateActionEngine(ps.DatabaseEngineNeki), qt.ErrorMatches, "connections are read-only for Neki databases") + c.Assert(ValidateActionEngine(ps.DatabaseEnginePostgres), qt.IsNil) + c.Assert(ValidateActionEngine(ps.DatabaseEngineMySQL), qt.IsNil) } diff --git a/internal/cmd/branch/connections/replay_test.go b/internal/cmd/branch/connections/replay_test.go index 685c1cd3b..5d17a29a3 100644 --- a/internal/cmd/branch/connections/replay_test.go +++ b/internal/cmd/branch/connections/replay_test.go @@ -214,6 +214,46 @@ func TestTopCmdReplayUsesVitessViewForMySQLTrace(t *testing.T) { c.Assert(view, qt.Not(qt.Contains), "WAIT") } +func TestTopCmdReplayUsesNekiViewForNekiTrace(t *testing.T) { + c := qt.New(t) + restoreTTY := setPrinterTTY(t, true) + defer restoreTTY() + + var capturedModel tea.Model + restoreProgram := setRunTeaProgram(t, func(model tea.Model, options ...tea.ProgramOption) error { + capturedModel = model + return nil + }) + defer restoreProgram() + + path := filepath.Join(t.TempDir(), "neki.jsonl") + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600) + c.Assert(err, qt.IsNil) + list := live.NewConnectionList(time.Date(2026, 10, 16, 12, 0, 5, 0, time.UTC), []live.Connection{{ + PID: 7, + Instance: "router-a", + RouterName: "router-a", + State: "active", + QueryText: "SELECT 1", + }}, live.SortByTransactionStart) + list.DatabaseKind = live.DatabaseKindNeki + list.Warnings = []string{"router router-b unreachable"} + c.Assert(history.NewCaptureWriter(file).Write(history.NewCapture(list)), qt.IsNil) + c.Assert(file.Close(), qt.IsNil) + + cmd := testTopCmd(&cmdutil.Helper{Config: &config.Config{}}) + cmd.SetArgs([]string{"--replay", path}) + + c.Assert(cmd.Execute(), qt.IsNil) + sized, _ := capturedModel.(tui.Model).Update(tea.WindowSizeMsg{Width: 160, Height: 24}) + view := sized.(tui.Model).View() + + c.Assert(view, qt.Contains, "ROUTER") + c.Assert(view, qt.Contains, "router-a") + c.Assert(view, qt.Contains, "incomplete results: router router-b unreachable") + c.Assert(view, qt.Not(qt.Contains), "BLOCK") +} + func TestReplayHeaderShowsTraceTarget(t *testing.T) { c := qt.New(t) restoreTTY := setPrinterTTY(t, true) diff --git a/internal/cmd/branch/connections/show.go b/internal/cmd/branch/connections/show.go index 78f980fb7..5e5fe0c05 100644 --- a/internal/cmd/branch/connections/show.go +++ b/internal/cmd/branch/connections/show.go @@ -47,7 +47,7 @@ func runList(ctx context.Context, ch *cmdutil.Helper, database, branch string, f return err } - list, err := filteredLister{client: client, filter: filter}.List(ctx, live.SortByTransactionStart) + list, err := filteredLister{client: client, filter: filter, notFound: newNotFoundExplainer(ch, database, branch)}.List(ctx, live.SortByTransactionStart) if err != nil { return live.UserFacingError(err, "view") } @@ -76,6 +76,7 @@ type printableList struct { Topology *ListTopology `json:"topology,omitempty"` Instances []printableInstance `json:"instances"` Connections []printableConnection `json:"connections"` + Warnings []string `json:"warnings,omitempty"` } type printableInstance struct { @@ -101,6 +102,13 @@ type printableConnection struct { QueryID *string `csv:"query_id" header:"query_id" json:"query_id"` TransactionID *string `csv:"transaction_id" header:"transaction_id" json:"transaction_id"` ConnectionID *string `csv:"connection_id" header:"connection_id" json:"connection_id"` + + RouterName string `csv:"-" json:"router_name,omitempty"` + RouterCell string `csv:"-" json:"router_cell,omitempty"` + RouterUID string `csv:"-" json:"router_uid,omitempty"` + AvailabilityZone string `csv:"-" json:"availability_zone,omitempty"` + BackendStart *time.Time `csv:"-" json:"backend_start,omitempty"` + SidecarBackends []live.SidecarBackend `csv:"-" json:"sidecar_backends,omitempty"` } func toPrintableList(list live.ConnectionList, topology ListTopology) printableList { @@ -109,6 +117,7 @@ func toPrintableList(list live.ConnectionList, topology ListTopology) printableL CapturedAt: list.CapturedAt, Instances: toPrintableInstances(list.Instances), Connections: toPrintableConnections(list), + Warnings: list.Warnings, } if !topology.isEmpty() { out.Topology = &topology @@ -150,7 +159,7 @@ func (p printableList) hasDatabaseName() bool { func toPrintableConnections(list live.ConnectionList) []printableConnection { connections := make([]printableConnection, 0, len(list.Connections)) for _, conn := range list.Connections { - connections = append(connections, printableConnection{ + printable := printableConnection{ PID: conn.PID, Instance: conn.Instance, InstanceRole: conn.InstanceRole, @@ -167,7 +176,16 @@ func toPrintableConnections(list live.ConnectionList) []printableConnection { QueryID: conn.QueryID, TransactionID: conn.TransactionID, ConnectionID: conn.ConnectionID, - }) + } + if list.DatabaseKind == live.DatabaseKindNeki { + printable.RouterName = conn.RouterName + printable.RouterCell = conn.RouterCell + printable.RouterUID = conn.RouterUID + printable.AvailabilityZone = conn.AvailabilityZone + printable.BackendStart = conn.BackendStart + printable.SidecarBackends = conn.SidecarBackends + } + connections = append(connections, printable) } return connections } @@ -185,6 +203,7 @@ func printableBlockedBy(blockedBy []int) []int { func printHumanConnectionList(out io.Writer, list live.ConnectionList, topology ListTopology) { fmt.Fprintf(out, "captured_at: %s\n", list.CapturedAt.Format(time.RFC3339)) vitess := list.DatabaseKind == live.DatabaseKindMySQL || !topology.isEmpty() + neki := list.DatabaseKind == live.DatabaseKindNeki if !topology.isEmpty() { fmt.Fprintln(out, "topology:") fmt.Fprintf(out, " keyspace: %s\n", topology.Keyspace) @@ -194,6 +213,9 @@ func printHumanConnectionList(out io.Writer, list live.ConnectionList, topology if warning := unreachableInstanceWarning(list.Instances); warning != "" { fmt.Fprintln(out, warning) } + for _, warning := range list.Warnings { + fmt.Fprintln(out, "warning: incomplete results: "+live.SanitizeDisplayText(warning)) + } if len(list.Connections) == 0 { fmt.Fprintln(out, "No live connections found.") @@ -207,13 +229,16 @@ func printHumanConnectionList(out io.Writer, list live.ConnectionList, topology fmt.Fprintf(out, "*************************** %d. row ***************************\n", i+1) fields := conn.HumanFields() - if vitess { + switch { + case vitess: fields = vitessHumanFields(conn) + case neki: + fields = conn.NekiHumanFields() } for _, field := range fields { writeHumanField(out, field[0], field[1]) } - if conn.DatabaseName != "" { + if conn.DatabaseName != "" && !neki { writeHumanField(out, "database", conn.DatabaseName) } fmt.Fprintln(out, "query:") diff --git a/internal/cmd/branch/connections/show_metadata.go b/internal/cmd/branch/connections/show_metadata.go index b36ba3d5a..846703409 100644 --- a/internal/cmd/branch/connections/show_metadata.go +++ b/internal/cmd/branch/connections/show_metadata.go @@ -1,6 +1,10 @@ package connections -import live "github.com/planetscale/cli/internal/connections" +import ( + "time" + + live "github.com/planetscale/cli/internal/connections" +) // ListTopology describes the Vitess tablet selected for a live connection list. type ListTopology struct { @@ -41,6 +45,13 @@ type printableCSVConnectionWithDatabase struct { QueryID *string `csv:"query_id" header:"query_id" json:"query_id"` TransactionID *string `csv:"transaction_id" header:"transaction_id" json:"transaction_id"` ConnectionID *string `csv:"connection_id" header:"connection_id" json:"connection_id"` + + RouterName string `csv:"-" json:"router_name,omitempty"` + RouterCell string `csv:"-" json:"router_cell,omitempty"` + RouterUID string `csv:"-" json:"router_uid,omitempty"` + AvailabilityZone string `csv:"-" json:"availability_zone,omitempty"` + BackendStart *time.Time `csv:"-" json:"backend_start,omitempty"` + SidecarBackends []live.SidecarBackend `csv:"-" json:"sidecar_backends,omitempty"` } type printableConnectionWithTopology struct { diff --git a/internal/cmd/branch/connections/top.go b/internal/cmd/branch/connections/top.go index c1349788e..666441e50 100644 --- a/internal/cmd/branch/connections/top.go +++ b/internal/cmd/branch/connections/top.go @@ -36,7 +36,8 @@ previously captured trace in the TUI — actions are rejected in replay mode. For Postgres, connections top shows session activity across instances. For Vitess, pass --keyspace and --shard or run interactively to select them when -the server reports available targets. Neki is not supported.`, +the server reports available targets. For Neki, connections top shows read-only +client sessions across the branch's routers.`, Args: func(cmd *cobra.Command, args []string) error { if flags.replay != "" { return nil @@ -220,7 +221,7 @@ func validateTopFlagsForEngine(engine ps.DatabaseEngine, filter connectionFilter return err } switch engine { - case ps.DatabaseEnginePostgres, ps.DatabaseEngineMySQL: + case ps.DatabaseEnginePostgres, ps.DatabaseEngineMySQL, ps.DatabaseEngineNeki: return nil default: return fmt.Errorf("connections top is not supported for database kind %q", engine) @@ -275,8 +276,11 @@ func replayTarget(source *history.ReplaySource) tui.Target { func replayConnectionView(captures []history.Capture) tui.ConnectionViewProfile { for _, capture := range captures { - if capture.List.DatabaseKind == live.DatabaseKindMySQL { + switch capture.List.DatabaseKind { + case live.DatabaseKindMySQL: return tui.VitessConnectionView + case live.DatabaseKindNeki: + return tui.NekiConnectionView } } return tui.PostgresConnectionView @@ -341,11 +345,20 @@ func newTopSource(ctx context.Context, ch *cmdutil.Helper, request topRequest) ( return topSource{}, err } return topSource{ - Client: filteredLister{client: client, filter: request.Filter}, + Client: filteredLister{client: client, filter: request.Filter, notFound: newNotFoundExplainer(ch, request.Database, request.Branch)}, View: tui.PostgresConnectionView, }, nil case ps.DatabaseEngineMySQL: return newVitessTopSource(ctx, ch, request) + case ps.DatabaseEngineNeki: + client, err := newConnectionsClient(ch, request.Database, request.Branch, ConnectionTarget{}) + if err != nil { + return topSource{}, err + } + return topSource{ + Client: filteredLister{client: client, notFound: newNotFoundExplainer(ch, request.Database, request.Branch)}, + View: tui.NekiConnectionView, + }, nil default: return topSource{}, fmt.Errorf("connections top is not supported for database kind %q", request.Engine) } @@ -361,7 +374,7 @@ func newVitessTopSource(ctx context.Context, ch *cmdutil.Helper, request topRequ return topSource{}, err } return topSource{ - Client: filteredLister{client: client}, + Client: filteredLister{client: client, notFound: newNotFoundExplainer(ch, request.Database, request.Branch)}, View: tui.VitessConnectionView, Target: resolved, }, nil @@ -397,7 +410,7 @@ func probeVitessTopTarget(ctx context.Context, ch *cmdutil.Helper, database, bra if err != nil { return err } - _, err = client.List(ctx, live.SortByDuration) + _, err = filteredLister{client: client, notFound: newNotFoundExplainer(ch, database, branch)}.List(ctx, live.SortByDuration) return err } @@ -556,14 +569,15 @@ func filterConnectionList(list live.ConnectionList, f connectionFilter) live.Con } type filteredLister struct { - client *live.Client - filter connectionFilter + client *live.Client + filter connectionFilter + notFound *notFoundExplainer } func (f filteredLister) List(ctx context.Context, sort live.SortMode) (live.ConnectionList, error) { list, err := f.client.List(ctx, sort) if err != nil { - return list, err + return list, f.notFound.explain(ctx, err) } if err := validateInstanceFilter(list, f.filter); err != nil { return list, err diff --git a/internal/cmd/branch/connections/top_test.go b/internal/cmd/branch/connections/top_test.go index 120bedd38..dc2414cd1 100644 --- a/internal/cmd/branch/connections/top_test.go +++ b/internal/cmd/branch/connections/top_test.go @@ -163,10 +163,16 @@ func TestTopCmdValidation(t *testing.T) { wantErr: "--instance/--role are only supported for Postgres databases", }, { - name: "neki is not supported", + name: "vitess target flags on neki", engine: ps.DatabaseEngineNeki, - args: []string{"neki", "main"}, - wantErr: "connections is not supported for Neki databases", + args: []string{"neki", "main", "--shard", "-80"}, + wantErr: "--keyspace/--shard are only supported for Vitess databases", + }, + { + name: "postgres filters on neki", + engine: ps.DatabaseEngineNeki, + args: []string{"neki", "main", "--instance", "router-a"}, + wantErr: "--instance/--role are only supported for Postgres databases", }, { name: "unknown role", @@ -612,6 +618,76 @@ func TestTopCmdRunEHeadlessVitessWritesCapture(t *testing.T) { }) } +func TestTopCmdRunsReadOnlyNekiTop(t *testing.T) { + c := qt.New(t) + restoreTTY := setPrinterTTY(t, true) + defer restoreTTY() + + server := liveConnectionsServerForTop(t, func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/acme/databases/neki/branches/main/connections") + c.Assert(r.URL.RawQuery, qt.Equals, "") + _, _ = io.WriteString(w, sampleNekiTopResponse()) + }) + + var view, blockersView, cancelView string + var cancelConfirming bool + restoreProgram := setRunTeaProgram(t, func(model tea.Model, options ...tea.ProgramOption) error { + updated := fetchInitialTopModel(t, model) + view = updated.View() + withBlockers, _ := updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}) + blockersView = withBlockers.View() + withCancel, _ := updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}) + cancelView = withCancel.View() + cancelConfirming = strings.Contains(cancelView, "[y/N]") + return nil + }) + defer restoreProgram() + + cmd := topCmdForServerAndEngine(server.URL, ps.DatabaseEngineNeki, &bytes.Buffer{}) + cmd.SetArgs([]string{"neki", "main"}) + + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(view, qt.Contains, "ROUTER") + c.Assert(view, qt.Contains, "router-a") + c.Assert(view, qt.Contains, "incomplete results: router router-b unreachable") + c.Assert(view, qt.Not(qt.Contains), "BLOCK") + c.Assert(view, qt.Not(qt.Contains), "cancel query") + c.Assert(view, qt.Not(qt.Contains), "force terminate") + c.Assert(blockersView, qt.Not(qt.Contains), "[blockers]") + c.Assert(cancelConfirming, qt.IsFalse) +} + +func TestTopCmdRunEHeadlessNekiWritesCapture(t *testing.T) { + c := qt.New(t) + restoreTTY := setPrinterTTY(t, false) + defer restoreTTY() + + server := liveConnectionsServerForTop(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, sampleNekiTopResponse()) + }) + capture := filepath.Join(t.TempDir(), "trace.jsonl") + cmd := topCmdForServerAndEngine(server.URL, ps.DatabaseEngineNeki, &bytes.Buffer{}) + cmd.SetArgs([]string{"neki", "main", "--capture", capture, "--duration", "200ms", "--interval", "1s"}) + + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + records := readJSONLines(t, capture) + c.Assert(records, qt.HasLen, 2) + captureRecord := capturedConnectionList(c, records[1]) + c.Assert(captureRecord["database_kind"], qt.Equals, "neki") + c.Assert(captureRecord["warnings"], qt.DeepEquals, []any{"router router-b unreachable"}) + connections := captureRecord["connections"].([]any) + c.Assert(connections, qt.HasLen, 1) + conn := connections[0].(map[string]any) + c.Assert(conn["pid"], qt.Equals, float64(2147483647)) + c.Assert(conn["router_name"], qt.Equals, "router-a") + c.Assert(conn["backend_start"], qt.Equals, "2026-10-16T12:00:00.123456Z") +} + func TestTopCmdInteractiveCaptureStartsEnabled(t *testing.T) { c := qt.New(t) restoreTTY := setPrinterTTY(t, true) @@ -745,6 +821,10 @@ func sampleVitessTopResponse() string { return `{"type":"list","database_kind":"mysql","captured_at":"2026-06-04T12:30:00Z","instances":[],"topology":{"keyspace":"commerce","shard":"-80","tablet":"zone1-1001"},"data":[{"pid":101,"instance":"zone1-1001","duration_ms":42000,"state":"Query/executing","usename":"vt_app","datname":"checkout","client_addr":"10.0.0.1:1234","query_text":"SELECT 1","connection_id":"zone1-1001-101","query_id":"zone1-1001-101"}]}` } +func sampleNekiTopResponse() string { + return `{"type":"list","database_kind":"neki","captured_at":"2026-10-16T12:00:05Z","instances":[],"warnings":["router router-b unreachable"],"data":[{"pid":2147483647,"instance":"router-a","router_name":"router-a","router_cell":"us-east-1a","router_uid":"router-a-us-east-1a-1","availability_zone":"us-east-1a","state":"active","usename":"app","datname":"postgres","application_name":"worker","duration_ms":5000,"query_text":"SELECT 1","backend_start":"2026-10-16T12:00:00.123456Z","sidecar_backends":[{"shard_uid":"shard-1","shard_name":"-80","sidecar_uid":"sidecar-1","backend_pid":4242,"query_text":"SELECT 1"}]}]}` +} + func writeVitessMultipleKeyspacesResponse(w http.ResponseWriter) { w.WriteHeader(http.StatusBadRequest) _, _ = io.WriteString(w, `{"code":"bad_request","message":"This database has multiple keyspaces. Specify which keyspace to target.","available":{"keyspaces":["commerce","lookup"]}}`) diff --git a/internal/cmd/branch/connections_commands.go b/internal/cmd/branch/connections_commands.go index 8a5e5e1ad..00908bf9c 100644 --- a/internal/cmd/branch/connections_commands.go +++ b/internal/cmd/branch/connections_commands.go @@ -84,6 +84,9 @@ the current query.`, if err != nil { return err } + if err := connections.ValidateActionEngine(engine); err != nil { + return err + } target := connections.ConnectionTarget{Keyspace: flags.keyspace, Shard: flags.shard} if err := connections.ValidateEngineFlags(engine, connections.ConnectionFilter{}, target); err != nil { return err @@ -119,7 +122,7 @@ the matching Postgres connection.`, if err != nil { return err } - if err := connections.ValidateEngineFlags(engine, connections.ConnectionFilter{}, connections.ConnectionTarget{}); err != nil { + if err := connections.ValidateActionEngine(engine); err != nil { return err } if engine != ps.DatabaseEnginePostgres { diff --git a/internal/cmd/branch/connections_test.go b/internal/cmd/branch/connections_test.go index d3556a9ea..cb7e07673 100644 --- a/internal/cmd/branch/connections_test.go +++ b/internal/cmd/branch/connections_test.go @@ -42,7 +42,8 @@ func TestConnectionsHelpListsEngines(t *testing.T) { help := connectionsHelpForTest(c, "--help") c.Assert(help, qt.Contains, "MySQL (Vitess):") c.Assert(help, qt.Contains, "Postgres:") - c.Assert(help, qt.Contains, "Neki is not supported") + c.Assert(help, qt.Contains, "Neki:") + c.Assert(help, qt.Contains, "Read-only.") } func TestConnectionsShowHelpListsTargetAndFilterFlags(t *testing.T) { @@ -262,10 +263,16 @@ func TestConnectionsShowRejectsEngineSpecificFlags(t *testing.T) { wantErr: "--keyspace/--shard are only supported for Vitess databases", }, { - name: "neki is not supported", + name: "neki rejects vitess target", engine: ps.DatabaseEngineNeki, - args: []string{"show", "neki", "main"}, - wantErr: "connections is not supported for Neki databases", + args: []string{"show", "neki", "main", "--keyspace", "commerce"}, + wantErr: "--keyspace/--shard are only supported for Vitess databases", + }, + { + name: "neki rejects postgres role filter", + engine: ps.DatabaseEngineNeki, + args: []string{"show", "neki", "main", "--role", "primary"}, + wantErr: "--instance/--role are only supported for Postgres databases", }, } @@ -537,13 +544,13 @@ func TestConnectionsKillTransactionRejectsVitess(t *testing.T) { c.Assert(err, qt.ErrorMatches, "connections kill-transaction is only supported for Postgres databases") } -func TestConnectionsRejectsNekiBeforeAPI(t *testing.T) { +func TestConnectionsRejectsNekiActionsBeforeAPI(t *testing.T) { tests := []struct { name string args []string }{ - {name: "show", args: []string{"show", "neki", "main"}}, {name: "kill", args: []string{"kill", "neki", "main", "primary-123-c"}}, + {name: "kill query", args: []string{"kill", "neki", "main", "primary-123-q", "--query"}}, {name: "kill-transaction", args: []string{"kill-transaction", "neki", "main", "primary-123-t"}}, } @@ -558,11 +565,178 @@ func TestConnectionsRejectsNekiBeforeAPI(t *testing.T) { err := cmd.Execute() - c.Assert(err, qt.ErrorMatches, "connections is not supported for Neki databases") + c.Assert(err, qt.ErrorMatches, "connections are read-only for Neki databases") + }) + } +} + +func TestConnectionsShowExplainsNotFound(t *testing.T) { + tests := []struct { + name string + branchErr error + wantErr string + }{ + { + name: "branch exists without live connections", + wantErr: `live connections are not enabled for branch main in database neki \(organization: acme\)`, + }, + { + name: "branch does not exist", + branchErr: &ps.Error{Code: ps.ErrNotFound}, + wantErr: `branch main does not exist in database neki \(organization: acme\)`, + }, + { + name: "branch lookup fails", + branchErr: errors.New("boom"), + wantErr: `branch main does not exist in database neki \(organization: acme\), or live connections are not enabled for it`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := qt.New(t) + server := liveConnectionsBranchServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"code":"not_found","message":"Not Found"}`) + }) + branches := &mock.PostgresBranchesService{ + GetFn: func(_ context.Context, req *ps.GetPostgresBranchRequest) (*ps.PostgresBranch, error) { + c.Assert(req.Branch, qt.Equals, "main") + if tt.branchErr != nil { + return nil, tt.branchErr + } + return &ps.PostgresBranch{Name: req.Branch}, nil + }, + } + ch := connectionsTestHelper("acme", ps.DatabaseEngineNeki, nil, server.URL, printer.JSON, &bytes.Buffer{}) + ch.Client = func() (*ps.Client, error) { + return &ps.Client{Databases: databaseServiceForEngine("acme", ps.DatabaseEngineNeki), PostgresBranches: branches}, nil + } + cmd := connectionsCmdForTest(ch) + cmd.SetArgs([]string{"show", "neki", "main"}) + + err := cmd.Execute() + + c.Assert(err, qt.ErrorMatches, tt.wantErr) }) } } +func TestConnectionsShowNekiJSON(t *testing.T) { + c := qt.New(t) + + server := liveConnectionsBranchServer(t, func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/acme/databases/neki/branches/main/connections") + c.Assert(r.URL.RawQuery, qt.Equals, "") + _, _ = io.WriteString(w, sampleNekiConnectionsListResponse()) + }) + var out bytes.Buffer + cmd := connectionsCmdForTest(connectionsTestHelper("acme", ps.DatabaseEngineNeki, nil, server.URL, printer.JSON, &out)) + cmd.SetArgs([]string{"show", "neki", "main"}) + + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + var got struct { + DatabaseKind string `json:"database_kind"` + Warnings []string `json:"warnings"` + Connections []struct { + PID int `json:"pid"` + Instance string `json:"instance"` + RouterName string `json:"router_name"` + RouterCell string `json:"router_cell"` + RouterUID string `json:"router_uid"` + AvailabilityZone string `json:"availability_zone"` + BackendStart string `json:"backend_start"` + ConnectionID *string `json:"connection_id"` + SidecarBackends []struct { + ShardName string `json:"shard_name"` + BackendPID int `json:"backend_pid"` + } `json:"sidecar_backends"` + } `json:"connections"` + } + c.Assert(json.Unmarshal(out.Bytes(), &got), qt.IsNil) + c.Assert(got.DatabaseKind, qt.Equals, "neki") + c.Assert(got.Warnings, qt.DeepEquals, []string{"router router-b unreachable"}) + c.Assert(got.Connections, qt.HasLen, 2) + conn := got.Connections[0] + c.Assert(conn.PID, qt.Equals, 7) + c.Assert(conn.Instance, qt.Equals, "router-a") + c.Assert(conn.RouterName, qt.Equals, "router-a") + c.Assert(conn.RouterCell, qt.Equals, "us-east-1a") + c.Assert(conn.RouterUID, qt.Equals, "router-a-us-east-1a-1") + c.Assert(conn.AvailabilityZone, qt.Equals, "us-east-1a") + c.Assert(conn.BackendStart, qt.Equals, "2026-10-16T12:00:00.123456Z") + c.Assert(conn.ConnectionID, qt.IsNil) + c.Assert(conn.SidecarBackends, qt.HasLen, 1) + c.Assert(conn.SidecarBackends[0].ShardName, qt.Equals, "-80") + c.Assert(conn.SidecarBackends[0].BackendPID, qt.Equals, 4242) + c.Assert(got.Connections[1].Instance, qt.Equals, "router-b") + c.Assert(got.Connections[1].PID, qt.Equals, 7) +} + +func TestConnectionsShowNekiHuman(t *testing.T) { + c := qt.New(t) + + server := liveConnectionsBranchServer(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, sampleNekiConnectionsListResponse()) + }) + var out bytes.Buffer + cmd := connectionsCmdForTest(connectionsTestHelper("acme", ps.DatabaseEngineNeki, nil, server.URL, printer.Human, &out)) + cmd.SetArgs([]string{"show", "neki", "main"}) + + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + got := out.String() + c.Assert(got, qt.Contains, "warning: incomplete results: router router-b unreachable") + c.Assert(got, qt.Contains, "router: router-a") + c.Assert(got, qt.Contains, "zone: us-east-1a") + c.Assert(got, qt.Contains, "backend_start: 2026-10-16T12:00:00Z") + c.Assert(got, qt.Contains, "shard_backends: 1\nshard: -80\nshard_uid: shard-1\nsidecar: sidecar-1\nbackend_pid: 4242\n") + c.Assert(got, qt.Contains, "backend_query: SELECT 1\n") + c.Assert(got, qt.Contains, "shard_backends: 0\n") + c.Assert(got, qt.Not(qt.Contains), "connection_id:") + c.Assert(strings.Count(got, "database:"), qt.Equals, 2) +} + +func TestConnectionsShowNekiEmptyWithWarningsIsNotHealthy(t *testing.T) { + c := qt.New(t) + + server := liveConnectionsBranchServer(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"type":"list","database_kind":"neki","captured_at":"2026-10-16T12:00:05Z","instances":[],"warnings":["router router-a unreachable"],"data":[]}`) + }) + var out bytes.Buffer + cmd := connectionsCmdForTest(connectionsTestHelper("acme", ps.DatabaseEngineNeki, nil, server.URL, printer.Human, &out)) + cmd.SetArgs([]string{"show", "neki", "main"}) + + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(out.String(), qt.Contains, "warning: incomplete results: router router-a unreachable\nNo live connections found.") +} + +func TestConnectionsShowNekiCSVUsesRouterAsInstance(t *testing.T) { + c := qt.New(t) + + server := liveConnectionsBranchServer(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, sampleNekiConnectionsListResponse()) + }) + var out bytes.Buffer + cmd := connectionsCmdForTest(connectionsTestHelper("acme", ps.DatabaseEngineNeki, nil, server.URL, printer.CSV, &out)) + cmd.SetArgs([]string{"show", "neki", "main"}) + + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + rows := readCSVRows(c, out.String()) + c.Assert(rows, qt.HasLen, 3) + c.Assert(rows[0], qt.Contains, "instance") + c.Assert(rows[0], qt.Not(qt.Contains), "sidecar_backends") + c.Assert(rows[1], qt.Contains, "router-a") + c.Assert(rows[2], qt.Contains, "router-b") +} + func TestPostgresActionResultOmitsVitessTopologyColumns(t *testing.T) { c := qt.New(t) @@ -781,6 +955,13 @@ func sampleBranchConnectionsListResponse() string { return `{"type":"list","database_kind":"postgresql","captured_at":"2026-04-29T12:34:56Z","instances":[{"id":"primary","role":"primary","error":null}],"data":[{"pid":123,"instance":"primary","duration_ms":664000,"state":"active","usename":"alice","application_name":"psql","client_addr":"10.0.0.1","query_text":"SELECT pg_sleep(600)","xact_start":"2026-04-29T12:23:52Z","query_start":"2026-04-29T12:23:52Z","query_id":"primary-123-q","transaction_id":"primary-123-t","connection_id":"primary-123-c"}]}` } +func sampleNekiConnectionsListResponse() string { + return `{"type":"list","database_kind":"neki","captured_at":"2026-10-16T12:00:05Z","instances":[],"warnings":["router router-b unreachable"],"data":[` + + `{"pid":7,"instance":"router-a","router_name":"router-a","router_cell":"us-east-1a","router_uid":"router-a-us-east-1a-1","availability_zone":"us-east-1a","state":"active","usename":"app","datname":"postgres","application_name":"worker","client_addr":null,"duration_ms":5000,"query_text":"SELECT 1","xact_start":"2026-10-16T12:00:00Z","backend_start":"2026-10-16T12:00:00.123456Z","sidecar_backends":[{"shard_uid":"shard-1","shard_name":"-80","sidecar_uid":"sidecar-1","backend_pid":4242,"query_text":"SELECT\n 1"}]},` + + `{"pid":7,"instance":"router-b","router_name":"router-b","router_cell":"us-east-1b","router_uid":"router-b-us-east-1b-1","availability_zone":"us-east-1b","state":"idle","usename":"app","datname":"postgres","application_name":"","duration_ms":null,"query_text":"SELECT 2","xact_start":"2026-10-16T12:00:01Z","backend_start":"2026-10-16T11:59:00Z","sidecar_backends":[]}` + + `]}` +} + func commandNames(cmd *cobra.Command) []string { names := make([]string, 0, len(cmd.Commands())) for _, child := range cmd.Commands() { diff --git a/internal/cmd/branch/processlist_test.go b/internal/cmd/branch/processlist_test.go index fb8097f45..c984a1eed 100644 --- a/internal/cmd/branch/processlist_test.go +++ b/internal/cmd/branch/processlist_test.go @@ -2,6 +2,7 @@ package branch import ( "bytes" + "context" "io" "net/http" "net/http/httptest" @@ -11,6 +12,7 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" + "github.com/planetscale/cli/internal/mock" ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" ) @@ -147,15 +149,20 @@ func TestProcesslist_NotFound(t *testing.T) { var buf bytes.Buffer ch := processlistTestHelper(org, server.URL, printer.JSON, &buf) + ch.Client = func() (*ps.Client, error) { + return &ps.Client{ + Databases: databaseServiceForEngine(org, ps.DatabaseEngineMySQL), + DatabaseBranches: &mock.DatabaseBranchesService{ + GetFn: func(context.Context, *ps.GetDatabaseBranchRequest) (*ps.DatabaseBranch, error) { + return nil, &ps.Error{Code: ps.ErrNotFound} + }, + }, + }, nil + } cmd := ProcesslistCmd(ch) cmd.SetArgs([]string{"show", db, branch}) err := cmd.Execute() - c.Assert(err, qt.IsNotNil) - c.Assert(err.Error(), qt.Contains, "branch") - c.Assert(err.Error(), qt.Contains, branch) - c.Assert(err.Error(), qt.Contains, db) - c.Assert(err.Error(), qt.Contains, org) - c.Assert(err.Error(), qt.Not(qt.Contains), "Not Found") + c.Assert(err, qt.ErrorMatches, "branch missing-branch does not exist in database missing-db \\(organization: my-org\\)") } diff --git a/internal/connections/client.go b/internal/connections/client.go index a1704150f..21d342562 100644 --- a/internal/connections/client.go +++ b/internal/connections/client.go @@ -137,6 +137,7 @@ type listResponse struct { Instances []InstanceMeta `json:"instances"` Topology *Topology `json:"topology"` Data []listEntry `json:"data"` + Warnings []string `json:"warnings"` } type listEntry struct { @@ -158,10 +159,17 @@ type listEntry struct { DurationMS int64 `json:"duration_ms"` BlockedBy []int `json:"blocked_by"` QueryText string `json:"query_text"` + + BackendStart *time.Time `json:"backend_start"` + RouterName string `json:"router_name"` + RouterCell string `json:"router_cell"` + RouterUID string `json:"router_uid"` + AvailabilityZone string `json:"availability_zone"` + SidecarBackends []SidecarBackend `json:"sidecar_backends"` } -func (e listEntry) connection() Connection { - return Connection{ +func (e listEntry) connection(kind DatabaseKind) Connection { + conn := Connection{ PID: e.PID, Instance: e.Instance, DatabaseName: e.DatabaseName, @@ -180,7 +188,19 @@ func (e listEntry) connection() Connection { Duration: time.Duration(e.DurationMS) * time.Millisecond, BlockedBy: e.BlockedBy, QueryText: e.QueryText, + + RouterName: e.RouterName, + RouterCell: e.RouterCell, + RouterUID: e.RouterUID, + AvailabilityZone: e.AvailabilityZone, + SidecarBackends: e.SidecarBackends, } + // Postgres also reports backend_start, but older pscale versions reject + // unknown fields when replaying captures, so only Neki rows carry it. + if kind == DatabaseKindNeki { + conn.BackendStart = e.BackendStart + } + return conn } func (s *Client) List(ctx context.Context, sort SortMode) (ConnectionList, error) { @@ -298,17 +318,18 @@ func (s *Client) tryList(ctx context.Context, sort SortMode) (ConnectionList, ti } func (r listResponse) connectionList(sort SortMode) ConnectionList { - list := NewConnectionList(r.CapturedAt, connectionsFromEntries(r.Data), sort) + list := NewConnectionList(r.CapturedAt, connectionsFromEntries(r.Data, r.DatabaseKind), sort) list.DatabaseKind = r.DatabaseKind list.Instances = r.Instances list.Topology = r.Topology + list.Warnings = r.Warnings return list } -func connectionsFromEntries(entries []listEntry) []Connection { +func connectionsFromEntries(entries []listEntry, kind DatabaseKind) []Connection { connections := make([]Connection, 0, len(entries)) for _, entry := range entries { - connections = append(connections, entry.connection()) + connections = append(connections, entry.connection(kind)) } return connections } @@ -491,12 +512,24 @@ func actionIDName(op string) string { } } +// NotFoundError is the connections endpoint's 404, which the API returns both +// for a branch that does not exist and for one without live connections. +type NotFoundError struct { + Organization string + Database string + Branch string +} + +func (e *NotFoundError) Error() string { + return fmt.Sprintf( + "branch %s does not exist in database %s (organization: %s), or live connections are not enabled for it", + e.Branch, e.Database, e.Organization, + ) +} + func (s *Client) formatHTTPError(op string, status int, body []byte) error { if status == http.StatusNotFound { - return fmt.Errorf( - "branch %s does not exist in database %s (organization: %s), or live connections are not enabled for it", - s.cfg.Branch, s.cfg.Database, s.cfg.Organization, - ) + return &NotFoundError{Organization: s.cfg.Organization, Database: s.cfg.Database, Branch: s.cfg.Branch} } if status == http.StatusTooManyRequests { return fmt.Errorf("%s: rate limited, please retry in a moment", op) diff --git a/internal/connections/client_test.go b/internal/connections/client_test.go index ba126f986..9b1743056 100644 --- a/internal/connections/client_test.go +++ b/internal/connections/client_test.go @@ -326,6 +326,115 @@ func TestClient_ListDecodesVitessProcesslist(t *testing.T) { } } +func TestClient_ListDecodesNekiSessions(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "" { + t.Errorf("query = %q, want none", r.URL.RawQuery) + } + _, _ = io.WriteString(w, `{ + "type": "list", + "database_kind": "neki", + "captured_at": "2026-10-16T12:00:05Z", + "instances": [], + "warnings": ["router router-b unreachable"], + "data": [ + { + "pid": 2147483647, + "instance": "router-a", + "router_name": "router-a", + "router_cell": "us-east-1a", + "router_uid": "router-a-us-east-1a-1", + "availability_zone": "us-east-1a", + "datname": "postgres", + "usename": "app", + "application_name": "", + "client_addr": null, + "state": null, + "backend_type": null, + "query_text": "SELECT 1", + "query_start": null, + "xact_start": null, + "backend_start": "2026-10-16T12:00:00.123456Z", + "state_change": null, + "duration_ms": null, + "sidecar_backends": [ + {"shard_uid": "shard-1", "shard_name": "-80", "sidecar_uid": "sidecar-1", "backend_pid": 4242, "query_text": "SELECT 1", "xact_start": null, "query_start": "2026-10-16T12:00:04Z"} + ] + } + ] + }`) + })) + defer srv.Close() + + client, err := NewClient(ClientConfig{ + BaseURL: srv.URL, + Organization: "acme", + Database: "neki", + Branch: "main", + ServiceTokenID: "tid", + ServiceToken: "secret", + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + list, err := client.List(context.Background(), SortByTransactionStart) + if err != nil { + t.Fatalf("List: %v", err) + } + + if list.DatabaseKind != DatabaseKindNeki { + t.Errorf("DatabaseKind = %q, want %q", list.DatabaseKind, DatabaseKindNeki) + } + if !slices.Equal(list.Warnings, []string{"router router-b unreachable"}) { + t.Errorf("Warnings = %v", list.Warnings) + } + if len(list.Connections) != 1 { + t.Fatalf("len(Connections) = %d, want 1", len(list.Connections)) + } + conn := list.Connections[0] + if conn.PID != 2147483647 || conn.Instance != "router-a" { + t.Errorf("identity = %d/%q", conn.PID, conn.Instance) + } + if conn.RouterName != "router-a" || conn.RouterCell != "us-east-1a" || conn.RouterUID != "router-a-us-east-1a-1" || conn.AvailabilityZone != "us-east-1a" { + t.Errorf("router fields = %+v", conn) + } + wantBackendStart, _ := time.Parse(time.RFC3339Nano, "2026-10-16T12:00:00.123456Z") + if conn.BackendStart == nil || !conn.BackendStart.Equal(wantBackendStart) { + t.Errorf("BackendStart = %v, want %v", conn.BackendStart, wantBackendStart) + } + if conn.State != "" || conn.ClientAddr != "" || conn.Duration != 0 || conn.XactStart != nil || conn.QueryStart != nil { + t.Errorf("missing optional fields were not left empty: %+v", conn) + } + if len(conn.SidecarBackends) != 1 { + t.Fatalf("len(SidecarBackends) = %d, want 1", len(conn.SidecarBackends)) + } + backend := conn.SidecarBackends[0] + if backend.ShardName != "-80" || backend.SidecarUID != "sidecar-1" || backend.BackendPID != 4242 || backend.QueryStart == nil || backend.XactStart != nil { + t.Errorf("SidecarBackend = %+v", backend) + } +} + +func TestClient_ListKeepsBackendStartOutOfPostgresRows(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"type":"list","database_kind":"postgresql","captured_at":"2026-04-29T12:34:56Z","instances":[],"data":[{"pid":123,"instance":"primary","backend_start":"2026-04-29T12:00:00Z"}]}`) + })) + defer srv.Close() + client, err := NewClient(ClientConfig{BaseURL: srv.URL, Organization: "acme", Database: "pgload", Branch: "main", ServiceTokenID: "tid", ServiceToken: "secret"}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + list, err := client.List(context.Background(), SortByTransactionStart) + + if err != nil { + t.Fatalf("List: %v", err) + } + if list.Connections[0].BackendStart != nil { + t.Errorf("BackendStart = %v, want nil so Postgres captures keep their shape", list.Connections[0].BackendStart) + } +} + func TestClient_ListLeavesInstanceRoleEmptyForUnknownInstance(t *testing.T) { const body = `{ "type": "list", diff --git a/internal/connections/connection_list.go b/internal/connections/connection_list.go index dd9eddc87..3840949d1 100644 --- a/internal/connections/connection_list.go +++ b/internal/connections/connection_list.go @@ -20,6 +20,7 @@ type DatabaseKind string const ( DatabaseKindMySQL DatabaseKind = "mysql" DatabaseKindPostgreSQL DatabaseKind = "postgresql" + DatabaseKindNeki DatabaseKind = "neki" ) type InstanceMeta struct { @@ -41,6 +42,9 @@ type ConnectionList struct { Sort SortMode `json:"sort"` Instances []InstanceMeta `json:"instances,omitempty"` Topology *Topology `json:"topology,omitempty"` + // Warnings reports collection problems, such as unreachable Neki routers, + // that leave Connections incomplete without failing the request. + Warnings []string `json:"warnings,omitempty"` } // HasUnreachableInstance reports whether the server flagged any instance as @@ -75,6 +79,24 @@ type Connection struct { Duration time.Duration `json:"duration"` BlockedBy []int `json:"blocked_by,omitempty"` QueryText string `json:"query_text,omitempty"` + + BackendStart *time.Time `json:"backend_start,omitempty"` + RouterName string `json:"router_name,omitempty"` + RouterCell string `json:"router_cell,omitempty"` + RouterUID string `json:"router_uid,omitempty"` + AvailabilityZone string `json:"availability_zone,omitempty"` + SidecarBackends []SidecarBackend `json:"sidecar_backends,omitempty"` +} + +// SidecarBackend is a Neki shard backend serving a router session. +type SidecarBackend struct { + ShardUID string `json:"shard_uid"` + ShardName string `json:"shard_name,omitempty"` + SidecarUID string `json:"sidecar_uid"` + BackendPID int `json:"backend_pid"` + QueryText string `json:"query_text,omitempty"` + XactStart *time.Time `json:"xact_start,omitempty"` + QueryStart *time.Time `json:"query_start,omitempty"` } func NewConnectionList(capturedAt time.Time, connections []Connection, sort SortMode) ConnectionList { @@ -112,7 +134,14 @@ func compareDuration(a, b Connection) int { if cmp := compareBool(durationSortHasWork(a), durationSortHasWork(b)); cmp != 0 { return cmp } - return a.PID - b.PID + return compareIdentity(a, b) +} + +func compareIdentity(a, b Connection) int { + if a.PID != b.PID { + return a.PID - b.PID + } + return strings.Compare(a.Instance, b.Instance) } func durationSortHasWork(c Connection) bool { @@ -199,7 +228,7 @@ func compareTransactionStart(a, b Connection) int { if cmp := compareNullableTime(a.QueryStart, b.QueryStart); cmp != 0 { return cmp } - return a.PID - b.PID + return compareIdentity(a, b) } func compareNullableTime(a, b *time.Time) int { @@ -242,6 +271,42 @@ func (c Connection) HumanFields() [][2]string { } } +// NekiHumanFields is the HumanFields equivalent for Neki router sessions. +func (c Connection) NekiHumanFields() [][2]string { + fields := [][2]string{ + {"pid", strconv.Itoa(c.PID)}, + {"router", c.Instance}, + {"zone", c.AvailabilityZone}, + {"state", c.State}, + {"duration", c.Duration.String()}, + {"user", c.Username}, + {"database", c.DatabaseName}, + {"application", c.ApplicationName}, + {"client_addr", c.ClientAddr}, + {"backend_start", formatOptionalTime(c.BackendStart)}, + {"shard_backends", strconv.Itoa(len(c.SidecarBackends))}, + } + for _, backend := range c.SidecarBackends { + fields = append(fields, + [2]string{"shard", backend.ShardName}, + [2]string{"shard_uid", backend.ShardUID}, + [2]string{"sidecar", backend.SidecarUID}, + [2]string{"backend_pid", strconv.Itoa(backend.BackendPID)}, + [2]string{"xact_start", formatOptionalTime(backend.XactStart)}, + [2]string{"query_start", formatOptionalTime(backend.QueryStart)}, + [2]string{"backend_query", strings.Join(strings.Fields(backend.QueryText), " ")}, + ) + } + return fields +} + +func formatOptionalTime(t *time.Time) string { + if t == nil { + return "" + } + return t.Format(time.RFC3339) +} + // JoinWaitEvents renders the wait-event type/event pair as "type/event", // collapsing to whichever side is present when one is empty. func JoinWaitEvents(waitEventType, waitEvent string) string { diff --git a/internal/connections/connection_list_test.go b/internal/connections/connection_list_test.go index 32c524056..8e34ddf61 100644 --- a/internal/connections/connection_list_test.go +++ b/internal/connections/connection_list_test.go @@ -26,6 +26,22 @@ func TestNewConnectionListSortsByTransactionStartWithNullsLast(t *testing.T) { c.Assert(pids(list.Connections), qt.DeepEquals, []int{1, 2, 3, 4}) } +func TestSortConnectionsBreaksSharedPIDTiesByInstance(t *testing.T) { + for _, mode := range []SortMode{SortByTransactionStart, SortByDuration} { + t.Run(string(mode), func(t *testing.T) { + c := qt.New(t) + + list := NewConnectionList(time.Unix(100, 0), []Connection{ + {PID: 7, Instance: "router-b", State: "idle"}, + {PID: 7, Instance: "router-a", State: "idle"}, + }, mode) + + c.Assert(list.Connections[0].Instance, qt.Equals, "router-a") + c.Assert(list.Connections[1].Instance, qt.Equals, "router-b") + }) + } +} + func TestNewConnectionListSortsByTransactionStartWithQueryStartAndPIDTieBreakers(t *testing.T) { c := qt.New(t) now := time.Date(2026, 4, 28, 15, 12, 4, 0, time.UTC) diff --git a/internal/connections/history/capture_reader_test.go b/internal/connections/history/capture_reader_test.go index 0512b64d2..466bd05ed 100644 --- a/internal/connections/history/capture_reader_test.go +++ b/internal/connections/history/capture_reader_test.go @@ -152,6 +152,35 @@ func TestCaptureRoundTripIsByteIdentical(t *testing.T) { c.Assert(second.Bytes(), qt.DeepEquals, first.Bytes()) } +func TestCaptureRoundTripPreservesNekiSessions(t *testing.T) { + c := qt.New(t) + backendStart := time.Date(2026, 10, 16, 12, 0, 0, 123456000, time.UTC) + list := connections.NewConnectionList(time.Date(2026, 10, 16, 12, 0, 5, 0, time.UTC), []connections.Connection{{ + PID: 2147483647, + Instance: "router-a", + RouterName: "router-a", + RouterCell: "us-east-1a", + RouterUID: "router-a-us-east-1a-1", + AvailabilityZone: "us-east-1a", + BackendStart: &backendStart, + SidecarBackends: []connections.SidecarBackend{{ + ShardUID: "shard-1", + ShardName: "-80", + SidecarUID: "sidecar-1", + BackendPID: 4242, + }}, + }}, connections.SortByTransactionStart) + list.DatabaseKind = connections.DatabaseKindNeki + list.Warnings = []string{"router router-b unreachable"} + var buffer bytes.Buffer + c.Assert(NewCaptureWriter(&buffer).Write(NewCapture(list)), qt.IsNil) + + capture, err := NewCaptureReader(bytes.NewReader(buffer.Bytes())).Read() + + c.Assert(err, qt.IsNil) + c.Assert(capture.List, qt.DeepEquals, list) +} + // Anti-regression: a pre-amendment capture file with old-only fields must // surface a clear decode error rather than being accepted as a replayable // capture. diff --git a/internal/connections/tui/connection_capabilities.go b/internal/connections/tui/connection_capabilities.go index bc2278d1d..f32dadab2 100644 --- a/internal/connections/tui/connection_capabilities.go +++ b/internal/connections/tui/connection_capabilities.go @@ -77,6 +77,10 @@ func (s ConnectionCapabilities) supports(kind actionKind) bool { return requirement.field != actionTargetUnset } +func (s ConnectionCapabilities) hasActions() bool { + return s.supports(actionCancelQuery) || s.supports(actionTerminateTxn) || s.supports(actionTerminateConn) +} + func (s ConnectionCapabilities) requirement(kind actionKind) ActionRequirement { s = s.effective() switch kind { diff --git a/internal/connections/tui/detail.go b/internal/connections/tui/detail.go index 02d854cf2..80defaab4 100644 --- a/internal/connections/tui/detail.go +++ b/internal/connections/tui/detail.go @@ -203,10 +203,17 @@ func detailFieldValue(value string) string { } func connectionDetailFields(preset connectionDisplayPreset, conn live.Connection) [][2]string { - if preset != connectionDisplayProcesslist { + switch preset { + case connectionDisplayProcesslist: + return processlistDetailFields(conn) + case connectionDisplayNeki: + return conn.NekiHumanFields() + default: return conn.HumanFields() } +} +func processlistDetailFields(conn live.Connection) [][2]string { return [][2]string{ {"pid", fmt.Sprint(conn.PID)}, {"tablet", conn.Instance}, diff --git a/internal/connections/tui/help.go b/internal/connections/tui/help.go index d2129b6ef..ed02bce61 100644 --- a/internal/connections/tui/help.go +++ b/internal/connections/tui/help.go @@ -68,7 +68,14 @@ func helpModalLines(state helpState) []string { "", ) } - if !support.supports(actionTerminateTxn) { + switch { + case !support.hasActions(): + lines = append(lines, + headerStyle.Render("Actions"), + " Read-only: queries and connections cannot be cancelled or terminated from this view.", + "", + ) + case !support.supports(actionTerminateTxn): lines = append(lines, headerStyle.Render("Actions"), " c Kill the selected query (KILL QUERY) using the query_id from the selected process.", @@ -76,7 +83,7 @@ func helpModalLines(state helpState) []string { " c and K require confirmation. Replay mode blocks backend actions.", "", ) - } else { + default: lines = append(lines, headerStyle.Render("Actions"), " c Cancel the selected query (pg_cancel_backend) only if it is the same active query we observed.", diff --git a/internal/connections/tui/model.go b/internal/connections/tui/model.go index b37919bec..7f5c18f8c 100644 --- a/internal/connections/tui/model.go +++ b/internal/connections/tui/model.go @@ -56,6 +56,7 @@ type connectionDisplayPreset string const ( connectionDisplayDefault connectionDisplayPreset = "" connectionDisplayProcesslist connectionDisplayPreset = "processlist" + connectionDisplayNeki connectionDisplayPreset = "neki" ) type ConnectionViewProfile struct { @@ -85,6 +86,13 @@ var ( defaultSort: live.SortByDuration, sortModes: []live.SortMode{live.SortByDuration}, } + // NekiConnectionView presents read-only Neki router sessions. + NekiConnectionView = ConnectionViewProfile{ + displayPreset: connectionDisplayNeki, + capabilities: ConnectionCapabilities{configured: true}, + defaultSort: live.SortByTransactionStart, + sortModes: []live.SortMode{live.SortByTransactionStart, live.SortByDuration}, + } ) func postgresSortModes() []live.SortMode { @@ -182,12 +190,13 @@ type Model struct { helpOpen bool helpOffset int - detailOpen bool - detailInstance string - detailPID int - detailTab detailTab - blockerRow int - queryOffset int + detailOpen bool + detailInstance string + detailPID int + detailBackendStart *time.Time + detailTab detailTab + blockerRow int + queryOffset int } func NewModel(ctx context.Context, client ConnectionsClient, interval, duration time.Duration) Model { @@ -501,6 +510,7 @@ func (m Model) handleTableKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.detailOpen = true m.detailInstance = conn.Instance m.detailPID = conn.PID + m.detailBackendStart = conn.BackendStart m.blockerRow = 0 m.queryOffset = 0 if strings.EqualFold(msg.String(), "b") { @@ -604,6 +614,7 @@ func (m Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if ok { m.detailInstance = target.Instance m.detailPID = target.PID + m.detailBackendStart = target.BackendStart m.blockerRow = 0 m.queryOffset = 0 m.clampBlockerSelection() @@ -1116,7 +1127,16 @@ func (m *Model) reanchorProcesslistSelection(previous live.Connection, previousI } func sameConnection(a, b live.Connection) bool { - return a.PID == b.PID && a.Instance == b.Instance + return a.PID == b.PID && a.Instance == b.Instance && sameBackendStart(a.BackendStart, b.BackendStart) +} + +// sameBackendStart tells a reused PID apart from the session that held it +// before. Rows without a backend start fall back to PID and instance identity. +func sameBackendStart(a, b *time.Time) bool { + if a == nil || b == nil { + return true + } + return a.Equal(*b) } func nearestProcesslistWorkIndex(connections []live.Connection, index int) int { @@ -1145,7 +1165,7 @@ func (m Model) detailSubject() (live.Connection, bool) { return live.Connection{}, false } for _, conn := range m.lastSuccessfulList.Connections { - if conn.PID == m.detailPID && conn.Instance == m.detailInstance { + if conn.PID == m.detailPID && conn.Instance == m.detailInstance && sameBackendStart(conn.BackendStart, m.detailBackendStart) { return conn, true } } diff --git a/internal/connections/tui/model_test.go b/internal/connections/tui/model_test.go index 0983617b0..f1b959993 100644 --- a/internal/connections/tui/model_test.go +++ b/internal/connections/tui/model_test.go @@ -1936,3 +1936,128 @@ func TestModelPreListPausedFooterHidesRefresh(t *testing.T) { c.Assert(view, qt.Contains, "space resume") c.Assert(view, qt.Not(qt.Contains), "r refresh") } + +func TestModelNekiConnectionViewIsReadOnly(t *testing.T) { + c := qt.New(t) + client := &clientStub{} + model := nekiModel(client, nil, nekiConnection("router-a", 7, time.Unix(50, 0))) + + tableView := stripANSI(model.View()) + c.Assert(tableView, qt.Contains, "ROUTER") + c.Assert(tableView, qt.Contains, "router-a") + c.Assert(tableView, qt.Not(qt.Contains), "BLOCK") + c.Assert(tableView, qt.Not(qt.Contains), "cancel query") + c.Assert(tableView, qt.Not(qt.Contains), "kill transaction") + c.Assert(tableView, qt.Not(qt.Contains), "force terminate") + + for _, key := range []string{"c", "k", "K"} { + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) + c.Assert(updated.(Model).confirming, qt.IsFalse, qt.Commentf("key %s", key)) + } + c.Assert(client.cancelCalls+client.terminateTxn+client.terminateConn, qt.Equals, 0) + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}) + c.Assert(updated.(Model).detailOpen, qt.IsFalse) + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")}) + helpView := stripANSI(updated.(Model).View()) + c.Assert(helpView, qt.Contains, "Read-only") + c.Assert(helpView, qt.Not(qt.Contains), "pg_cancel_backend") + c.Assert(helpView, qt.Not(qt.Contains), "KILL QUERY") + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + detailView := stripANSI(updated.(Model).View()) + c.Assert(detailView, qt.Contains, "router:") + c.Assert(detailView, qt.Contains, "zone:") + c.Assert(detailView, qt.Contains, "shard_backends: 1") + c.Assert(detailView, qt.Contains, "backend_pid: 4242") + c.Assert(detailView, qt.Not(qt.Contains), "left/right tabs") + c.Assert(detailView, qt.Not(qt.Contains), "cancel query") +} + +func TestModelNekiConnectionViewSortsWithoutBlockers(t *testing.T) { + c := qt.New(t) + model := nekiModel(&clientStub{}, nil, nekiConnection("router-a", 7, time.Unix(50, 0))) + c.Assert(model.sort, qt.Equals, live.SortByTransactionStart) + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}) + c.Assert(updated.(Model).sort, qt.Equals, live.SortByDuration) + + updated, _ = updated.(Model).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}) + c.Assert(updated.(Model).sort, qt.Equals, live.SortByTransactionStart) +} + +func TestModelNekiDetailDropsSessionWhenRouterReusesPID(t *testing.T) { + c := qt.New(t) + model := nekiModel(&clientStub{}, nil, nekiConnection("router-a", 7, time.Unix(50, 0))) + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(Model) + + reconnected := live.NewConnectionList(time.Unix(101, 0), []live.Connection{nekiConnection("router-a", 7, time.Unix(90, 0))}, live.SortByTransactionStart) + updated, _ = model.Update(listMsg{list: reconnected}) + + c.Assert(stripANSI(updated.(Model).View()), qt.Contains, "no longer in the live snapshot") +} + +func TestModelNekiSelectionFollowsRouterSessionWithDuplicatePIDs(t *testing.T) { + c := qt.New(t) + routerA := nekiConnection("router-a", 7, time.Unix(50, 0)) + routerB := nekiConnection("router-b", 7, time.Unix(60, 0)) + model := nekiModel(&clientStub{}, nil, routerA, routerB) + model.moveSelection(1) + selected, _ := model.selectedConnection() + c.Assert(selected.Instance, qt.Equals, "router-b") + + earlier := time.Unix(40, 0) + routerB.XactStart = &earlier + updated, _ := model.Update(listMsg{list: live.NewConnectionList(time.Unix(101, 0), []live.Connection{routerA, routerB}, live.SortByTransactionStart)}) + got := updated.(Model) + + selected, _ = got.selectedConnection() + c.Assert(got.selected, qt.Equals, 0) + c.Assert(selected.Instance, qt.Equals, "router-b") +} + +func TestModelNekiRendersCollectionWarnings(t *testing.T) { + c := qt.New(t) + warnings := []string{"router router-b unreachable"} + + withRows := nekiModel(&clientStub{}, warnings, nekiConnection("router-a", 7, time.Unix(50, 0))) + empty := nekiModel(&clientStub{}, warnings) + + c.Assert(stripANSI(withRows.View()), qt.Contains, "incomplete results: router router-b unreachable") + emptyView := stripANSI(empty.View()) + c.Assert(emptyView, qt.Contains, "incomplete results: router router-b unreachable") + c.Assert(emptyView, qt.Contains, "no live connections") +} + +func nekiModel(client *clientStub, warnings []string, connections ...live.Connection) Model { + model := NewModel(context.Background(), client, time.Second, 0). + WithConnectionView(NekiConnectionView) + updated, _ := model.Update(tea.WindowSizeMsg{Width: 160, Height: 30}) + list := live.NewConnectionList(time.Unix(100, 0), connections, live.SortByTransactionStart) + list.DatabaseKind = live.DatabaseKindNeki + list.Warnings = warnings + updated, _ = updated.(Model).Update(listMsg{list: list}) + return updated.(Model) +} + +func nekiConnection(router string, pid int, backendStart time.Time) live.Connection { + return live.Connection{ + PID: pid, + Instance: router, + RouterName: router, + AvailabilityZone: "us-east-1a", + State: "active", + Username: "app", + QueryText: "SELECT 1", + XactStart: &backendStart, + BackendStart: &backendStart, + SidecarBackends: []live.SidecarBackend{{ + ShardUID: "shard-1", + ShardName: "-80", + SidecarUID: "sidecar-1", + BackendPID: 4242, + }}, + } +} diff --git a/internal/connections/tui/table.go b/internal/connections/tui/table.go index bea5d19e6..ca93ca145 100644 --- a/internal/connections/tui/table.go +++ b/internal/connections/tui/table.go @@ -204,7 +204,7 @@ func (b bindings) withPauseLabel(paused bool) bindings { func renderTable(state tableState) string { headerLines := []string{renderHeader(state), ""} var bannerLines []string - if banner := renderInstanceFailureBanner(state.List.Instances); banner != "" { + if banner := renderListBanner(state.List, tableWidth(state.Width)); banner != "" { bannerLines = []string{banner, ""} } footerLines := strings.Split(renderFooter(state), "\n") @@ -659,6 +659,20 @@ func sameLocalDay(a, b time.Time) bool { return ay == by && am == bm && ad == bd } +func renderListBanner(list live.ConnectionList, width int) string { + if banner := renderInstanceFailureBanner(list.Instances); banner != "" { + return banner + } + if len(list.Warnings) == 0 { + return "" + } + warnings := make([]string, 0, len(list.Warnings)) + for _, warning := range list.Warnings { + warnings = append(warnings, sanitizeFooterText(warning)) + } + return bannerStyle.Render(clipLine("incomplete results: "+strings.Join(warnings, "; "), width)) +} + func renderInstanceFailureBanner(instances []live.InstanceMeta) string { if len(instances) == 0 { return "" @@ -819,11 +833,18 @@ func connectionRowStyle(conn live.Connection, blockCount int, selected bool) lip } func connectionColumnStyleForDisplay(display connectionDisplayPreset, style lipgloss.Style, conn live.Connection, blockCount int, col int, headers []string) lipgloss.Style { - if display == connectionDisplayProcesslist { - if col >= 0 && col < len(headers) && headers[col] == "STATE" { + stateColumn := col >= 0 && col < len(headers) && headers[col] == "STATE" + switch display { + case connectionDisplayProcesslist: + if stateColumn { return processlistStateStyleFor(style, conn.State) } return style + case connectionDisplayNeki: + if stateColumn { + return stateStyleFor(style, conn.State) + } + return style } switch col { case connectionColState: @@ -859,8 +880,11 @@ func buildConnectionRows(connections []live.Connection, counts map[int]int, widt } func buildConnectionRowsForDisplay(display connectionDisplayPreset, connections []live.Connection, counts map[int]int, width int, selectedInSlice int) ([]string, [][]string) { - if display == connectionDisplayProcesslist { + switch display { + case connectionDisplayProcesslist: return buildProcesslistConnectionRows(connections, width, selectedInSlice) + case connectionDisplayNeki: + return buildNekiConnectionRows(connections, width, selectedInSlice) } includeStart := width >= startColumnMinWidth @@ -919,6 +943,33 @@ func buildProcesslistConnectionRows(connections []live.Connection, width int, se return headers, rows } +func buildNekiConnectionRows(connections []live.Connection, width int, selectedInSlice int) ([]string, [][]string) { + includeStart := width >= startColumnMinWidth + headers := []string{"", "PID", "ROUTER", "STATE", "DURATION", "USER", "APP"} + if includeStart { + headers = append(headers, "START") + } + headers = append(headers, "QUERY") + rows := make([][]string, 0, len(connections)) + for i, conn := range connections { + row := []string{ + processlistRowMarker(i == selectedInSlice), + fmt.Sprint(conn.PID), + emptyDash(conn.Instance), + stateCell(conn.State), + formatDuration(conn.Duration), + appText(conn.Username), + appTextForWidth(conn.ApplicationName, width), + } + if includeStart { + row = append(row, formatTime(startTime(conn))) + } + row = append(row, queryPreview(conn.QueryText)) + rows = append(rows, row) + } + return headers, rows +} + func rowMarker(role string, selected bool) string { cursor := " " if selected { @@ -1153,7 +1204,7 @@ func visibleRowCount(totalRows, bodyAvail int) int { func bodyHeight(state tableState) int { headerLines := 2 bannerLines := 0 - if renderInstanceFailureBanner(state.List.Instances) != "" { + if renderListBanner(state.List, tableWidth(state.Width)) != "" { bannerLines = 2 } footerLines := strings.Count(renderFooter(state), "\n") + 1