diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c06bb3b1..acd1f839 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -36,15 +36,15 @@ jobs: shell: bash --noprofile --norc -x -eo pipefail {0} run: | PATH=$PATH:$GOPATH/bin - GO_LIST=$(go list ./...) + GO_LIST=$(go list ./... | grep -F -e asciigraph -v) $(exit $(go fmt $GO_LIST | wc -l)) go vet -composites=false $GO_LIST - find . -type f -name "*.go" | xargs misspell -error -locale US + find . -type f -name "*.go" | grep -F -e asciigraph -v | xargs misspell -error -locale US staticcheck -f stylish $GO_LIST - name: Run tests shell: bash --noprofile --norc -x -eo pipefail {0} run: | set -e - go test -v --failfast -p=1 ./... + go list ./... | grep -F -e asciigraph -v | xargs go test -v --failfast -p=1 set +e diff --git a/ABTaskFile b/ABTaskFile index 3c5e0c08..92903f7e 100644 --- a/ABTaskFile +++ b/ABTaskFile @@ -17,7 +17,7 @@ commands: script: | set -e - go test ./... + go list ./... | grep -F -e asciigraph -v |xargs go test - name: lint type: exec @@ -60,17 +60,17 @@ commands: {{ if .Flags.spell }} ab_say Checking spelling - find . -type f -name "*.go" | xargs misspell -error -locale US -i flavour + find . -type f -name "*.go" | grep -F -e asciigraph -v | xargs misspell -error -locale US -i flavour {{ end }} {{ if .Flags.vet }} ab_say Performing go vet - go vet ./... + go list ./... | grep -F -e asciigraph -v |xargs go vet {{ end }} {{ if .Flags.staticcheck }} ab_say Running staticcheck - staticcheck ./... + go list ./... | grep -F -e asciigraph -v |xargs staticcheck {{ end }} - name: dependencies diff --git a/cli/account_command.go b/cli/account_command.go index 9aa3c77b..7ce52112 100644 --- a/cli/account_command.go +++ b/cli/account_command.go @@ -235,7 +235,7 @@ func (c *actCmd) reportServerStats(_ *fisk.ParseContext) error { } table := newTableWriter("Server Statistics") - table.AddHeaders("Server", "Cluster", "Version", "Tags", "Connections", "Leafnodes", "Sent Bytes", "Sent Messages", "Received Bytes", "Received Messages", "Slow Consumers") + table.AddHeaders("Server", "Cluster", "Version", "Tags", "Connections", "Subscriptions", "Leafnodes", "Sent Bytes", "Sent Messages", "Received Bytes", "Received Messages", "Slow Consumers") var ( conn, ln int @@ -267,6 +267,7 @@ func (c *actCmd) reportServerStats(_ *fisk.ParseContext) error { sz.ServerInfo.Version, f(sz.ServerInfo.Tags), f(stats.Conns), + f(stats.NumSubs), f(stats.LeafNodes), humanize.IBytes(uint64(stats.Sent.Bytes)), f(stats.Sent.Msgs), diff --git a/cli/audit_gather_command.go b/cli/audit_gather_command.go index 8754c58a..72d894f9 100644 --- a/cli/audit_gather_command.go +++ b/cli/audit_gather_command.go @@ -268,8 +268,9 @@ func (c *auditGatherCmd) gather(_ *fisk.ParseContext) error { // Discover servers by broadcasting a PING and then collecting responses func (c *auditGatherCmd) discoverServers(nc *nats.Conn) (map[string]*server.ServerInfo, error) { var serverInfoMap = make(map[string]*server.ServerInfo) + c.logProgress("Broadcasting PING to discover servers... (this may take a few seconds)") - err := doReqAsync(nil, "$SYS.REQ.SERVER.PING", 0, nc, func(b []byte) { + err := doReqAsync(nil, "$SYS.REQ.SERVER.PING", doReqAsyncWaitFullTimeoutInterval, nc, func(b []byte) { var apiResponse server.ServerAPIResponse if err := json.Unmarshal(b, &apiResponse); err != nil { c.logWarning("Failed to deserialize PING response: %s", err) diff --git a/cli/bench_command.go b/cli/bench_command.go index e79c39e7..8d019fde 100644 --- a/cli/bench_command.go +++ b/cli/bench_command.go @@ -62,6 +62,7 @@ type benchCmd struct { fetchTimeout bool multiSubject bool multiSubjectMax int + multisubjectFormat string deDuplication bool deDuplicationWindow time.Duration ack bool @@ -453,7 +454,7 @@ func (c *benchCmd) getPublishSubject(number int) string { if c.multiSubjectMax == 0 { return c.subject + "." + strconv.Itoa(number) } else { - return c.subject + "." + strconv.Itoa(number%c.multiSubjectMax) + return c.subject + "." + fmt.Sprintf(c.multisubjectFormat, number%c.multiSubjectMax) } } else { return c.subject @@ -1546,6 +1547,8 @@ func (c *benchCmd) coreNATSPublisher(nc *nats.Conn, progress *uiprogress.Bar, ms }) } + c.multisubjectFormat = fmt.Sprintf("%%0%dd", len(strconv.Itoa(c.multiSubjectMax))) + for i := 0; i < numMsg; i++ { if progress != nil { progress.Incr() @@ -1575,6 +1578,8 @@ func (c *benchCmd) coreNATSRequester(nc *nats.Conn, progress *uiprogress.Bar, ms }) } + c.multisubjectFormat = fmt.Sprintf("%%0%dd", len(strconv.Itoa(c.multiSubjectMax))) + for i := 0; i < numMsg; i++ { if progress != nil { progress.Incr() @@ -1610,6 +1615,8 @@ func (c *benchCmd) jsPublisher(nc *nats.Conn, progress *uiprogress.Bar, msg []by }) } + c.multisubjectFormat = fmt.Sprintf("%%0%dd", len(strconv.Itoa(c.multiSubjectMax))) + if c.batchSize != 1 { for i := 0; i < numMsg; { state = "Publishing" diff --git a/cli/columns.go b/cli/columns.go index 6be6369c..67d0dbf7 100644 --- a/cli/columns.go +++ b/cli/columns.go @@ -33,3 +33,11 @@ func fiBytes(v uint64) string { func f(v any) string { return columns.F(v) } + +func fFloat2Int(v any) string { + return columns.F(uint64(v.(float64))) +} + +func fiBytesFloat2Int(v any) string { + return fiBytes(uint64(v.(float64))) +} diff --git a/cli/consumer_command.go b/cli/consumer_command.go index e1dcd859..adb87fee 100644 --- a/cli/consumer_command.go +++ b/cli/consumer_command.go @@ -22,7 +22,6 @@ import ( "math" "math/rand" "os" - "os/exec" "os/signal" "regexp" "sort" @@ -30,8 +29,8 @@ import ( "strings" "time" - "github.com/guptarohit/asciigraph" "github.com/nats-io/natscli/columns" + "github.com/nats-io/natscli/internal/asciigraph" iu "github.com/nats-io/natscli/internal/util" terminal "golang.org/x/term" "gopkg.in/yaml.v3" @@ -102,23 +101,28 @@ type consumerCmd struct { metadata map[string]string pauseUntil string - dryRun bool - mgr *jsm.Manager - nc *nats.Conn - nak bool - fPull bool - fPush bool - fBound bool - fWaiting int - fAckPending int - fPending uint64 - fIdle time.Duration - fCreated time.Duration - fReplicas uint - fInvert bool - fExpression string - fLeader string - interactive bool + dryRun bool + mgr *jsm.Manager + nc *nats.Conn + nak bool + fPull bool + fPush bool + fBound bool + fWaiting int + fAckPending int + fPending uint64 + fIdle time.Duration + fCreated time.Duration + fReplicas uint + fInvert bool + fExpression string + fLeader string + interactive bool + pinnedGroups []string + pinnedTTL time.Duration + overflowGroups []string + groupName string + fPinned bool } func configureConsumerCommand(app commandHost) { @@ -172,6 +176,9 @@ func configureConsumerCommand(app commandHost) { f.Flag("metadata", "Adds metadata to the consumer").PlaceHolder("META").IsSetByUser(&c.metadataIsSet).StringMapVar(&c.metadata) if !edit { f.Flag("pause", fmt.Sprintf("Pause the consumer for a duration after start or until a specific timestamp (eg %s)", time.Now().Format(time.DateTime))).StringVar(&c.pauseUntil) + f.Flag("pinned-groups", "Create a Pinned Client consumer based on these groups").StringsVar(&c.pinnedGroups) + f.Flag("pinned-ttl", "The time to allow for a client to pull before losing the pinned status").DurationVar(&c.pinnedTTL) + f.Flag("overflow-groups", "Create a Overflow consumer based on these groups").StringsVar(&c.overflowGroups) } } @@ -215,6 +222,7 @@ func configureConsumerCommand(app commandHost) { consFind.Flag("created", "Display consumers created longer ago than duration").PlaceHolder("DURATION").DurationVar(&c.fCreated) consFind.Flag("replicas", "Display consumers with fewer or equal replicas than the value").PlaceHolder("REPLICAS").UintVar(&c.fReplicas) consFind.Flag("leader", "Display only clustered streams with a specific leader").PlaceHolder("SERVER").StringVar(&c.fLeader) + consFind.Flag("pinned", "Finds Pinned Client priority group consumers that are fully pinned").UnNegatableBoolVar(&c.fPinned) consFind.Flag("invert", "Invert the check - before becomes after, with becomes without").BoolVar(&c.fInvert) consFind.Flag("expression", "Match consumers using an expression language").StringVar(&c.fExpression) @@ -268,6 +276,12 @@ func configureConsumerCommand(app commandHost) { conPause.Arg("until", fmt.Sprintf("Pause until a specific time (eg %s)", time.Now().UTC().Format(time.DateTime))).PlaceHolder("TIME").StringVar(&c.pauseUntil) conPause.Flag("force", "Force pause without prompting").Short('f').UnNegatableBoolVar(&c.force) + conUnpin := cons.Command("unpin", "Unpin the current Pinned Client from a Priority Group").Action(c.unpinAction) + conUnpin.Arg("stream", "Stream name").StringVar(&c.stream) + conUnpin.Arg("consumer", "Consumer name").StringVar(&c.consumer) + conUnpin.Arg("group", "The group to unpin").StringVar(&c.groupName) + conUnpin.Flag("force", "Force unpin without prompting").Short('f').UnNegatableBoolVar(&c.force) + conResume := cons.Command("resume", "Resume a paused consumer").Action(c.resumeAction) conResume.Arg("stream", "Stream name").StringVar(&c.stream) conResume.Arg("consumer", "Consumer name").StringVar(&c.consumer) @@ -289,6 +303,61 @@ func init() { registerCommand("consumer", 4, configureConsumerCommand) } +func (c *consumerCmd) unpinAction(_ *fisk.ParseContext) error { + c.connectAndSetup(true, true) + + if !c.selectedConsumer.IsPinnedClientPriority() { + return fmt.Errorf("consumer is not a pinned priority consumer") + } + + nfo, err := c.selectedConsumer.State() + if err != nil { + return err + } + + matched := map[string]api.PriorityGroupState{} + var groups []string + for _, v := range nfo.PriorityGroups { + if v.PinnedClientID != "" { + matched[v.Group] = v + groups = append(groups, v.Group) + } + } + + if len(matched) == 0 { + return fmt.Errorf("no priority groups have pinned clients") + } + + if c.groupName == "" { + err = iu.AskOne(&survey.Select{ + Message: "Select a Group", + Options: groups, + PageSize: iu.SelectPageSize(len(groups)), + }, &c.groupName, survey.WithValidator(survey.Required)) + if err != nil { + return err + } + } + + if !c.force { + ok, err := askConfirmation(fmt.Sprintf("Really unpin client from group %s > %s > %s", c.stream, c.consumer, c.groupName), false) + fisk.FatalIfError(err, "could not obtain confirmation") + + if !ok { + return nil + } + } + + err = c.selectedConsumer.Unpin(c.groupName) + if err != nil { + return err + } + + fmt.Printf("Unpinned client %s from Priority Group %s > %s > %s\n", matched[c.groupName].PinnedClientID, c.stream, c.consumer, c.groupName) + + return nil +} + func (c *consumerCmd) findAction(_ *fisk.ParseContext) error { var err error var stream *jsm.Stream @@ -344,6 +413,9 @@ func (c *consumerCmd) findAction(_ *fisk.ParseContext) error { if c.fLeader != "" { opts = append(opts, jsm.ConsumerQueryLeaderServer(c.fLeader)) } + if c.fPinned { + opts = append(opts, jsm.ConsumerQueryIsPinned()) + } found, err := stream.QueryConsumers(opts...) if err != nil { @@ -424,13 +496,17 @@ func (c *consumerCmd) graphAction(_ *fisk.ParseContext) error { height -= 5 } + if width < 20 || height < 20 { + return fmt.Errorf("please increase terminal dimensions") + } + nfo, err := consumer.State() if err != nil { continue } - deliveredRates = append(deliveredRates, float64(nfo.Delivered.Stream-lastDeliveredSeq)/time.Since(lastStateTs).Seconds()) - ackedRates = append(ackedRates, float64(nfo.AckFloor.Stream-lastAckedSeq)/time.Since(lastStateTs).Seconds()) + deliveredRates = append(deliveredRates, calculateRate(float64(nfo.Delivered.Stream), float64(lastDeliveredSeq), time.Since(lastStateTs))) + ackedRates = append(ackedRates, calculateRate(float64(nfo.AckFloor.Stream), float64(lastAckedSeq), time.Since(lastStateTs))) unprocessedMessages = append(unprocessedMessages, float64(nfo.NumPending)) outstandingMessages = append(outstandingMessages, float64(nfo.NumAckPending)) lastDeliveredSeq = nfo.Delivered.Stream @@ -448,6 +524,7 @@ func (c *consumerCmd) graphAction(_ *fisk.ParseContext) error { asciigraph.Height(height/4-2), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(f), ) ackedPlot := asciigraph.Plot(ackedRates, @@ -456,6 +533,7 @@ func (c *consumerCmd) graphAction(_ *fisk.ParseContext) error { asciigraph.Height(height/4-2), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(f), ) unprocessedPlot := asciigraph.Plot(unprocessedMessages, @@ -464,6 +542,7 @@ func (c *consumerCmd) graphAction(_ *fisk.ParseContext) error { asciigraph.Height(height/4-2), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(fFloat2Int), ) outstandingPlot := asciigraph.Plot(outstandingMessages, @@ -472,6 +551,7 @@ func (c *consumerCmd) graphAction(_ *fisk.ParseContext) error { asciigraph.Height(height/4-2), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(fFloat2Int), ) iu.ClearScreen() @@ -561,11 +641,6 @@ func (c *consumerCmd) leaderStandDownAction(_ *fisk.ParseContext) error { } func (c *consumerCmd) interactiveEdit(cfg api.ConsumerConfig) (*api.ConsumerConfig, error) { - editor := os.Getenv("EDITOR") - if editor == "" { - return &api.ConsumerConfig{}, fmt.Errorf("set EDITOR environment variable to your chosen editor") - } - cj, err := decoratedYamlMarshal(cfg) if err != nil { return &api.ConsumerConfig{}, fmt.Errorf("could not create temporary file: %s", err) @@ -584,14 +659,9 @@ func (c *consumerCmd) interactiveEdit(cfg api.ConsumerConfig) (*api.ConsumerConf tfile.Close() - cmd := exec.Command(editor, tfile.Name()) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - err = cmd.Run() + err = iu.EditFile(tfile.Name()) if err != nil { - return &api.ConsumerConfig{}, fmt.Errorf("could not create temporary file: %s", err) + return &api.ConsumerConfig{}, err } nb, err := os.ReadFile(tfile.Name()) @@ -766,6 +836,11 @@ func (c *consumerCmd) editAction(pc *fisk.ParseContext) error { } } + err = c.checkConfigLevel(ncfg) + if err != nil { + return err + } + cons, err := c.mgr.NewConsumerFromDefault(c.stream, *ncfg) if err != nil { return err @@ -1007,6 +1082,11 @@ func (c *consumerCmd) showInfo(config api.ConsumerConfig, state api.ConsumerInfo } else { cols.AddRowIf("Paused Until Deadline", fmt.Sprintf("%s (passed)", f(config.PauseUntil)), !config.PauseUntil.IsZero()) } + if config.PriorityPolicy != api.PriorityNone { + cols.AddRow("Priority Policy", config.PriorityPolicy) + cols.AddRow("Priority Groups", config.PriorityGroups) + cols.AddRowIf("Pinned TTL", config.PinnedTTL, config.PriorityPolicy == api.PriorityPinnedClient) + } meta := iu.RemoveReservedMetadata(config.Metadata) if len(meta) > 0 { @@ -1079,6 +1159,19 @@ func (c *consumerCmd) showInfo(config api.ConsumerConfig, state api.ConsumerInfo cols.AddRowf("Paused Until", "%s (%s remaining)", f(state.TimeStamp.Add(state.PauseRemaining)), state.PauseRemaining.Round(time.Second)) } + if len(state.PriorityGroups) > 0 && config.PriorityPolicy == api.PriorityPinnedClient { + groups := map[string]string{} + for _, v := range state.PriorityGroups { + msg := "No client" + if v.PinnedClientID != "" { + msg = fmt.Sprintf("pinned %s at %s", v.PinnedClientID, f(v.PinnedTS)) + } + + groups[v.Group] = msg + } + cols.AddMapStringsAsValue("Priority Groups", groups) + } + cols.Frender(os.Stdout) } @@ -1662,6 +1755,18 @@ func (c *consumerCmd) prepareConfig() (cfg *api.ConsumerConfig, err error) { } } + switch { + case len(c.pinnedGroups) > 0 && len(c.overflowGroups) > 0: + return nil, fmt.Errorf("setting both overflow and pinned groups are not supported") + case len(c.pinnedGroups) > 0: + cfg.PriorityPolicy = api.PriorityPinnedClient + cfg.PriorityGroups = c.pinnedGroups + cfg.PinnedTTL = c.pinnedTTL + case len(c.overflowGroups) > 0: + cfg.PriorityPolicy = api.PriorityOverflow + cfg.PriorityGroups = c.pinnedGroups + } + cfg.Metadata = iu.RemoveReservedMetadata(cfg.Metadata) return cfg, nil @@ -1690,7 +1795,7 @@ func (c *consumerCmd) parsePauseUntil(until string) (time.Time, error) { func (c *consumerCmd) resumeAction(_ *fisk.ParseContext) error { c.connectAndSetup(true, true) - err := iu.RequireAPILevel(c.mgr, 1, "resuming consumers requires NATS Server 2.11") + err := iu.RequireAPILevel(c.mgr, 1, "resuming Consumers requires NATS Server 2.11") if err != nil { return err } @@ -1724,7 +1829,7 @@ func (c *consumerCmd) resumeAction(_ *fisk.ParseContext) error { func (c *consumerCmd) pauseAction(_ *fisk.ParseContext) error { c.connectAndSetup(true, true) - err := iu.RequireAPILevel(c.mgr, 1, "pausing consumers requires NATS Server 2.11") + err := iu.RequireAPILevel(c.mgr, 1, "pausing Consumers requires NATS Server 2.11") if err != nil { return err } @@ -1877,11 +1982,9 @@ func (c *consumerCmd) createAction(pc *fisk.ParseContext) (err error) { c.connectAndSetup(true, false) - if !cfg.PauseUntil.IsZero() { - err := iu.RequireAPILevel(c.mgr, 1, "pausing consumers requires NATS Server 2.11") - if err != nil { - return err - } + err = c.checkConfigLevel(cfg) + if err != nil { + return err } created, err := c.mgr.NewConsumerFromDefault(c.stream, *cfg) @@ -1894,6 +1997,24 @@ func (c *consumerCmd) createAction(pc *fisk.ParseContext) (err error) { return nil } +func (c *consumerCmd) checkConfigLevel(cfg *api.ConsumerConfig) error { + if !cfg.PauseUntil.IsZero() { + err := iu.RequireAPILevel(c.mgr, 1, "pausing consumers requires NATS Server 2.11") + if err != nil { + return err + } + } + + if len(cfg.PriorityGroups) > 0 || cfg.PriorityPolicy != api.PriorityNone { + err := iu.RequireAPILevel(c.mgr, 1, "Consumer Groups requires NATS Server 2.11") + if err != nil { + return err + } + } + + return nil +} + func (c *consumerCmd) getNextMsgDirect(stream string, consumer string) error { req := &api.JSApiConsumerGetNextRequest{Batch: 1, Expires: opts().Timeout} diff --git a/cli/context_command.go b/cli/context_command.go index adc1f70a..355d810c 100644 --- a/cli/context_command.go +++ b/cli/context_command.go @@ -17,13 +17,14 @@ import ( "bytes" "encoding/json" "fmt" - iu "github.com/nats-io/natscli/internal/util" "os" - "os/exec" + "os/user" "sort" "strings" "text/template" + iu "github.com/nats-io/natscli/internal/util" + "github.com/AlecAivazis/survey/v2" "github.com/choria-io/fisk" "github.com/fatih/color" @@ -232,11 +233,6 @@ socks_proxy: {{ .SocksProxy | t }} ` func (c *ctxCommand) editCommand(pc *fisk.ParseContext) error { - editor := os.Getenv("EDITOR") - if editor == "" { - return fmt.Errorf("set EDITOR environment variable to your chosen editor") - } - if !natscontext.IsKnown(c.name) { return fmt.Errorf("unknown context %q", c.name) } @@ -245,7 +241,7 @@ func (c *ctxCommand) editCommand(pc *fisk.ParseContext) error { if err != nil { return err } - editFile := path + editFp := path var ctx *natscontext.Context @@ -284,21 +280,16 @@ func (c *ctxCommand) editCommand(pc *fisk.ParseContext) error { } f.Close() - editFile = f.Name() + editFp = f.Name() } - cmd := exec.Command(editor, editFile) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - err = cmd.Run() + err = iu.EditFile(editFp) if err != nil { return err } - if path != editFile { - yctx, err := os.ReadFile(editFile) + if path != editFp { + yctx, err := os.ReadFile(editFp) if err != nil { return fmt.Errorf("could not read temporary copy: %w", err) } @@ -330,7 +321,7 @@ func (c *ctxCommand) editCommand(pc *fisk.ParseContext) error { err = c.showCommand(pc) if err != nil { // but not if the file was already corrupt and we are editing the json directly - if path == editFile { + if path == editFp { return err } @@ -450,6 +441,16 @@ func (c *ctxCommand) showCommand(_ *fisk.ParseContext) error { if file == "" { return "" } + if strings.HasPrefix(file, "op://") { + return color.CyanString("1Password") + } + if file[0] == '~' { + usr, err := user.Current() + if err != nil { + return color.YellowString("failed to expand '~'. $HOME or $USER possibly not set") + } + file = strings.Replace(file, "~", usr.HomeDir, 1) + } ok, err := fileAccessible(file) if !ok || err != nil { diff --git a/cli/errors_command.go b/cli/errors_command.go index a8c05d44..dbc4314f 100644 --- a/cli/errors_command.go +++ b/cli/errors_command.go @@ -18,7 +18,6 @@ import ( "errors" "fmt" "os" - "os/exec" "regexp" "sort" "strconv" @@ -28,6 +27,7 @@ import ( "github.com/fatih/color" "github.com/nats-io/jsm.go/schemas" "github.com/nats-io/nats-server/v2/server" + iu "github.com/nats-io/natscli/internal/util" ) type errCmd struct { @@ -122,10 +122,6 @@ func (c *errCmd) listAction(_ *fisk.ParseContext) error { } func (c *errCmd) editAction(pc *fisk.ParseContext) error { - if os.Getenv("EDITOR") == "" { - return fmt.Errorf("EDITOR variable is not set") - } - errs, err := c.loadErrors(nil) if err != nil { return err @@ -162,15 +158,12 @@ func (c *errCmd) editAction(pc *fisk.ParseContext) error { tfile.Write(fj) tfile.Close() - for { - cmd := exec.Command(os.Getenv("EDITOR"), tfile.Name()) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + fp := tfile.Name() - err = cmd.Run() + for { + err = iu.EditFile(fp) if err != nil { - return fmt.Errorf("could not edit error: %s", err) + return err } eb, err := os.ReadFile(tfile.Name()) diff --git a/cli/kv_command.go b/cli/kv_command.go index 51f236b6..c9104013 100644 --- a/cli/kv_command.go +++ b/cli/kv_command.go @@ -14,6 +14,7 @@ package cli import ( + "context" "errors" "fmt" "io" @@ -23,6 +24,7 @@ import ( "strings" "time" + "github.com/nats-io/nats.go/jetstream" "github.com/nats-io/natscli/internal/util" "github.com/AlecAivazis/survey/v2" @@ -118,7 +120,7 @@ for an indefinite period or a per-bucket configured TTL. update.Arg("bucket", "The bucket to act on").Required().StringVar(&c.bucket) update.Arg("key", "The key to act on").Required().StringVar(&c.key) update.Arg("value", "The value to store").Required().StringVar(&c.val) - update.Arg("revision", "The revision of the previous value in the bucket").Uint64Var(&c.revision) + update.Arg("revision", "The revision of the previous value in the bucket").Required().Uint64Var(&c.revision) del := kv.Command("del", "Deletes a key or the entire bucket").Alias("rm").Action(c.deleteAction) del.Arg("bucket", "The bucket to act on").Required().StringVar(&c.bucket) @@ -183,13 +185,13 @@ func (c *kvCommand) parseLimitStrings(_ *fisk.ParseContext) (err error) { return nil } -func (c *kvCommand) strForOp(op nats.KeyValueOp) string { +func (c *kvCommand) strForOp(op jetstream.KeyValueOp) string { switch op { - case nats.KeyValuePut: + case jetstream.KeyValuePut: return "PUT" - case nats.KeyValuePurge: + case jetstream.KeyValuePurge: return "PURGE" - case nats.KeyValueDelete: + case jetstream.KeyValueDelete: return "DELETE" default: return "UNKNOWN" @@ -210,12 +212,15 @@ func (c *kvCommand) lsBucketKeys() error { return fmt.Errorf("unable to prepare js helper: %s", err) } - kv, err := js.KeyValue(c.bucket) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + kv, err := js.KeyValue(ctx, c.bucket) if err != nil { return fmt.Errorf("unable to load bucket: %s", err) } - lister, err := kv.ListKeys() + lister, err := kv.ListKeys(ctx) if err != nil { return err } @@ -240,7 +245,7 @@ func (c *kvCommand) lsBucketKeys() error { return nil } -func (c *kvCommand) displayKeyInfo(kv nats.KeyValue, keys nats.KeyLister) (bool, error) { +func (c *kvCommand) displayKeyInfo(kv jetstream.KeyValue, keys jetstream.KeyLister) (bool, error) { var found bool if kv == nil { @@ -255,9 +260,12 @@ func (c *kvCommand) displayKeyInfo(kv nats.KeyValue, keys nats.KeyLister) (bool, table.AddHeaders("Key", "Created", "Delta", "Revision") } + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + for keyName := range keys.Keys() { found = true - kve, err := kv.Get(keyName) + kve, err := kv.Get(ctx, keyName) if err != nil { return found, fmt.Errorf("unable to fetch key %s: %s", keyName, err) } @@ -336,7 +344,19 @@ func (c *kvCommand) revertAction(pc *fisk.ParseContext) error { return err } - rev, err := store.GetRevision(c.key, c.revision) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + history, err := store.History(ctx, c.key) + if err != nil { + return err + } + + if len(history) <= 1 { + return errors.New("cannot revert key in a bucket where history=1") + } + + rev, err := store.GetRevision(ctx, c.key, c.revision) if err != nil { return err } @@ -355,7 +375,10 @@ func (c *kvCommand) revertAction(pc *fisk.ParseContext) error { } } - _, err = store.Put(c.key, rev.Value()) + // We get the latest revision number so that we can revert with update() over put() + latestRevision := history[len(history)-1].Revision() + + _, err = store.Update(ctx, c.key, rev.Value(), latestRevision) if err != nil { return err } @@ -369,7 +392,10 @@ func (c *kvCommand) historyAction(_ *fisk.ParseContext) error { return err } - history, err := store.History(c.key) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + history, err := store.History(ctx, c.key) if err != nil { return err } @@ -408,7 +434,10 @@ func (c *kvCommand) compactAction(_ *fisk.ParseContext) error { } } - return store.PurgeDeletes() + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + return store.PurgeDeletes(ctx) } func (c *kvCommand) deleteAction(pc *fisk.ParseContext) error { @@ -433,7 +462,10 @@ func (c *kvCommand) deleteAction(pc *fisk.ParseContext) error { } } - return store.Delete(c.key) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + return store.Delete(ctx, c.key) } func (c *kvCommand) addAction(_ *fisk.ParseContext) error { @@ -442,20 +474,20 @@ func (c *kvCommand) addAction(_ *fisk.ParseContext) error { return err } - storage := nats.FileStorage + storage := jetstream.FileStorage if strings.HasPrefix(c.storage, "m") { - storage = nats.MemoryStorage + storage = jetstream.MemoryStorage } - var placement *nats.Placement + var placement *jetstream.Placement if c.placementCluster != "" || len(c.placementTags) > 0 { - placement = &nats.Placement{Cluster: c.placementCluster} + placement = &jetstream.Placement{Cluster: c.placementCluster} if len(c.placementTags) > 0 { placement.Tags = c.placementTags } } - cfg := &nats.KeyValueConfig{ + cfg := jetstream.KeyValueConfig{ Bucket: c.bucket, Description: c.description, MaxValueSize: int32(c.maxValueSize), @@ -469,7 +501,7 @@ func (c *kvCommand) addAction(_ *fisk.ParseContext) error { } if c.repubDest != "" { - cfg.RePublish = &nats.RePublish{ + cfg.RePublish = &jetstream.RePublish{ Source: c.repubSource, Destination: c.repubDest, HeadersOnly: c.repubHeadersOnly, @@ -477,19 +509,22 @@ func (c *kvCommand) addAction(_ *fisk.ParseContext) error { } if c.mirror != "" { - cfg.Mirror = &nats.StreamSource{ + cfg.Mirror = &jetstream.StreamSource{ Name: c.mirror, Domain: c.mirrorDomain, } } for _, source := range c.sources { - cfg.Sources = append(cfg.Sources, &nats.StreamSource{ + cfg.Sources = append(cfg.Sources, &jetstream.StreamSource{ Name: source, }) } - store, err := js.CreateKeyValue(cfg) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + store, err := js.CreateKeyValue(ctx, cfg) if err != nil { return err } @@ -503,11 +538,14 @@ func (c *kvCommand) getAction(_ *fisk.ParseContext) error { return err } - var res nats.KeyValueEntry + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + var res jetstream.KeyValueEntry if c.revision > 0 { - res, err = store.GetRevision(c.key, c.revision) + res, err = store.GetRevision(ctx, c.key, c.revision) } else { - res, err = store.Get(c.key) + res, err = store.Get(ctx, c.key) } if err != nil { return err @@ -545,7 +583,10 @@ func (c *kvCommand) putAction(_ *fisk.ParseContext) error { return err } - _, err = store.Put(c.key, val) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + _, err = store.Put(ctx, c.key, val) if err != nil { return err } @@ -566,7 +607,10 @@ func (c *kvCommand) createAction(_ *fisk.ParseContext) error { return err } - _, err = store.Create(c.key, val) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + _, err = store.Create(ctx, c.key, val) if err != nil { return err } @@ -587,7 +631,10 @@ func (c *kvCommand) updateAction(_ *fisk.ParseContext) error { return err } - _, err = store.Update(c.key, val, c.revision) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + _, err = store.Update(ctx, c.key, val, c.revision) if err != nil { return err } @@ -605,7 +652,7 @@ func (c *kvCommand) valOrReadVal() ([]byte, error) { return io.ReadAll(os.Stdin) } -func (c *kvCommand) loadBucket() (*nats.Conn, nats.JetStreamContext, nats.KeyValue, error) { +func (c *kvCommand) loadBucket() (*nats.Conn, jetstream.JetStream, jetstream.KeyValue, error) { nc, js, err := prepareJSHelper() if err != nil { return nil, nil, nil, err @@ -631,7 +678,10 @@ func (c *kvCommand) loadBucket() (*nats.Conn, nats.JetStreamContext, nats.KeyVal } } - store, err := js.KeyValue(c.bucket) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + store, err := js.KeyValue(ctx, c.bucket) if err != nil { return nil, nil, nil, err } @@ -675,7 +725,9 @@ func (c *kvCommand) watchAction(_ *fisk.ParseContext) error { return err } - watch, err := store.Watch(c.key) + ctx := context.Background() + + watch, err := store.Watch(ctx, c.key) if err != nil { return err } @@ -687,9 +739,9 @@ func (c *kvCommand) watchAction(_ *fisk.ParseContext) error { } switch res.Operation() { - case nats.KeyValueDelete, nats.KeyValuePurge: + case jetstream.KeyValueDelete, jetstream.KeyValuePurge: fmt.Printf("[%s] %s %s > %s\n", f(res.Created()), color.RedString(c.strForOp(res.Operation())), res.Bucket(), res.Key()) - case nats.KeyValuePut: + case jetstream.KeyValuePut: fmt.Printf("[%s] %s %s > %s: %s\n", f(res.Created()), color.GreenString(c.strForOp(res.Operation())), res.Bucket(), res.Key(), res.Value()) } } @@ -715,7 +767,10 @@ func (c *kvCommand) purgeAction(_ *fisk.ParseContext) error { } } - return store.Purge(c.key) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + return store.Purge(ctx, c.key) } func (c *kvCommand) rmBucketAction(_ *fisk.ParseContext) error { @@ -736,18 +791,24 @@ func (c *kvCommand) rmBucketAction(_ *fisk.ParseContext) error { return err } - return js.DeleteKeyValue(c.bucket) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + return js.DeleteKeyValue(ctx, c.bucket) } -func (c *kvCommand) showStatus(store nats.KeyValue) error { - status, err := store.Status() +func (c *kvCommand) showStatus(store jetstream.KeyValue) error { + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + status, err := store.Status(ctx) if err != nil { return err } - var nfo *nats.StreamInfo + var nfo *jetstream.StreamInfo if status.BackingStore() == "JetStream" { - nfo = status.(*nats.KeyValueBucketStatus).StreamInfo() + nfo = status.(*jetstream.KeyValueBucketStatus).StreamInfo() } cols := newColumns("") @@ -797,26 +858,31 @@ func (c *kvCommand) showStatus(store nats.KeyValue) error { } if nfo.Mirror != nil { - s := nfo.Mirror + s := nfo.Config.Mirror cols.AddSectionTitle("Mirror Information") cols.AddRow("Origin Bucket", strings.TrimPrefix(s.Name, "KV_")) if s.External != nil { cols.AddRow("External API", s.External.APIPrefix) } - if s.Active > 0 && s.Active < math.MaxInt64 { - cols.AddRow("Last Seen", s.Active) + + if nfo.Mirror.Active > 0 && nfo.Mirror.Active < math.MaxInt64 { + cols.AddRow("Last Seen", nfo.Mirror.Active) } else { cols.AddRowf("Last Seen", "never") } - cols.AddRow("Lag", s.Lag) + cols.AddRow("Lag", nfo.Mirror.Lag) } if len(nfo.Sources) > 0 { cols.AddSectionTitle("Sources Information") for _, source := range nfo.Sources { - cols.AddRow("Source Bucket", strings.TrimPrefix(source.Name, "KV_")) - if source.External != nil { - cols.AddRow("External API", source.External.APIPrefix) + for _, s := range nfo.Config.Sources { + if s.Name == source.Name { + cols.AddRow("Source Bucket", strings.TrimPrefix(source.Name, "KV_")) + if s.External != nil { + cols.AddRow("External API", s.External.APIPrefix) + } + } } if source.Active > 0 && source.Active < math.MaxInt64 { cols.AddRow("Last Seen", source.Active) @@ -836,7 +902,7 @@ func (c *kvCommand) showStatus(store nats.KeyValue) error { return nil } -func renderNatsGoClusterInfo(cols *columns.Writer, info *nats.StreamInfo) { +func renderNatsGoClusterInfo(cols *columns.Writer, info *jetstream.StreamInfo) { cols.AddRow("Name", info.Cluster.Name) cols.AddRow("Leader", info.Cluster.Leader) for _, r := range info.Cluster.Replicas { diff --git a/cli/object_command.go b/cli/object_command.go index ba32dc26..9feb5a5b 100644 --- a/cli/object_command.go +++ b/cli/object_command.go @@ -14,6 +14,7 @@ package cli import ( + "context" "encoding/base64" "fmt" "io" @@ -23,6 +24,7 @@ import ( "strings" "time" + "github.com/nats-io/nats.go/jetstream" "github.com/nats-io/natscli/internal/util" "github.com/AlecAivazis/survey/v2" @@ -143,7 +145,10 @@ func (c *objCommand) watchAction(_ *fisk.ParseContext) error { return err } - w, err := obj.Watch(nats.IncludeHistory()) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + w, err := obj.Watch(ctx, jetstream.IncludeHistory()) if err != nil { return err } @@ -179,7 +184,10 @@ func (c *objCommand) sealAction(_ *fisk.ParseContext) error { return err } - err = obj.Seal() + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + err = obj.Seal(ctx) if err != nil { return err } @@ -195,9 +203,13 @@ func (c *objCommand) delAction(_ *fisk.ParseContext) error { return err } + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + if c.file != "" { if !c.force { - nfo, err := obj.GetInfo(c.file) + + nfo, err := obj.GetInfo(ctx, c.file) if err != nil { return err } @@ -212,7 +224,7 @@ func (c *objCommand) delAction(_ *fisk.ParseContext) error { return nil } } - err = obj.Delete(c.file) + err = obj.Delete(ctx, c.file) if err != nil { return err } @@ -239,7 +251,10 @@ func (c *objCommand) delAction(_ *fisk.ParseContext) error { return err } - return js.DeleteObjectStore(c.bucket) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + return js.DeleteObjectStore(ctx, c.bucket) } } @@ -253,7 +268,10 @@ func (c *objCommand) infoAction(_ *fisk.ParseContext) error { return c.showBucketInfo(obj) } - nfo, err := obj.GetInfo(c.file) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + nfo, err := obj.GetInfo(ctx, c.file) if err != nil { return err } @@ -263,15 +281,18 @@ func (c *objCommand) infoAction(_ *fisk.ParseContext) error { return nil } -func (c *objCommand) showBucketInfo(store nats.ObjectStore) error { - status, err := store.Status() +func (c *objCommand) showBucketInfo(store jetstream.ObjectStore) error { + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + status, err := store.Status(ctx) if err != nil { return err } - var nfo *nats.StreamInfo + var nfo *jetstream.StreamInfo if status.BackingStore() == "JetStream" { - nfo = status.(*nats.ObjectBucketStatus).StreamInfo() + nfo = status.(*jetstream.ObjectBucketStatus).StreamInfo() } cols := newColumns("") @@ -319,7 +340,7 @@ func (c *objCommand) showBucketInfo(store nats.ObjectStore) error { return nil } -func (c *objCommand) showObjectInfo(nfo *nats.ObjectInfo) { +func (c *objCommand) showObjectInfo(nfo *jetstream.ObjectInfo) { digest := strings.SplitN(nfo.Digest, "=", 2) digestBytes, _ := base64.URLEncoding.DecodeString(digest[1]) @@ -403,7 +424,10 @@ func (c *objCommand) lsAction(_ *fisk.ParseContext) error { return err } - contents, err := obj.List() + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + contents, err := obj.List(ctx) if err != nil { return err } @@ -447,7 +471,10 @@ func (c *objCommand) putAction(_ *fisk.ParseContext) error { return fmt.Errorf("--name is required when reading from stdin") } - nfo, err := obj.GetInfo(name) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + nfo, err := obj.GetInfo(ctx, name) if err == nil && !nfo.Deleted && !c.force { c.showObjectInfo(nfo) fmt.Println() @@ -486,7 +513,7 @@ func (c *objCommand) putAction(_ *fisk.ParseContext) error { pr = f } - meta := &nats.ObjectMeta{ + meta := jetstream.ObjectMeta{ Name: filepath.Clean(name), Description: c.description, Headers: hdr, @@ -508,7 +535,7 @@ func (c *objCommand) putAction(_ *fisk.ParseContext) error { pr = &progressRW{p: progress, r: pr} } - nfo, err = obj.Put(meta, pr) + nfo, err = obj.Put(ctx, meta, pr) stop() if err != nil { return err @@ -525,7 +552,10 @@ func (c *objCommand) getAction(_ *fisk.ParseContext) error { return err } - res, err := obj.Get(c.file) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + res, err := obj.Get(ctx, c.file) if err != nil { return err } @@ -616,17 +646,20 @@ func (c *objCommand) addAction(_ *fisk.ParseContext) error { return err } - st := nats.FileStorage + st := jetstream.FileStorage if c.storage == "memory" || c.storage == "m" { - st = nats.MemoryStorage + st = jetstream.MemoryStorage } - placement := &nats.Placement{Cluster: c.placementCluster} + placement := &jetstream.Placement{Cluster: c.placementCluster} if len(c.placementTags) > 0 { placement.Tags = c.placementTags } - obj, err := js.CreateObjectStore(&nats.ObjectStoreConfig{ + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + obj, err := js.CreateObjectStore(ctx, jetstream.ObjectStoreConfig{ Bucket: c.bucket, Description: c.description, TTL: c.ttl, @@ -644,7 +677,7 @@ func (c *objCommand) addAction(_ *fisk.ParseContext) error { return c.showBucketInfo(obj) } -func (c *objCommand) loadBucket() (*nats.Conn, nats.JetStreamContext, nats.ObjectStore, error) { +func (c *objCommand) loadBucket() (*nats.Conn, jetstream.JetStream, jetstream.ObjectStore, error) { nc, js, err := prepareJSHelper() if err != nil { return nil, nil, nil, err @@ -670,7 +703,10 @@ func (c *objCommand) loadBucket() (*nats.Conn, nats.JetStreamContext, nats.Objec } } - store, err := js.ObjectStore(c.bucket) + ctx, cancel := context.WithTimeout(ctx, opts().Timeout) + defer cancel() + + store, err := js.ObjectStore(ctx, c.bucket) if err != nil { return nil, nil, nil, err } diff --git a/cli/pub_command.go b/cli/pub_command.go index c52bee35..dde7be85 100644 --- a/cli/pub_command.go +++ b/cli/pub_command.go @@ -266,7 +266,14 @@ func (c *pubCmd) doJetstream(nc *nats.Conn, progress *uiprogress.Bar) error { progress.Incr() } - fmt.Printf(">>> Stream: %v Sequence: %s Duplicate: %t Domain: %q\n", ack.Stream, f(ack.Sequence), ack.Duplicate, ack.Domain) + fmt.Printf(">>> Stream: %s Sequence: %s", ack.Stream, f(ack.Sequence)) + if ack.Domain != "" { + fmt.Printf(" Domain: %q", ack.Domain) + } + if ack.Duplicate { + fmt.Printf(" Duplicate: true") + } + fmt.Println() // If applicable, account for the wait duration in a publish sleep. if c.cnt > 1 && c.sleep > 0 { diff --git a/cli/server_check_command.go b/cli/server_check_command.go index a5abc2ea..9723f967 100644 --- a/cli/server_check_command.go +++ b/cli/server_check_command.go @@ -62,6 +62,7 @@ type SrvCheckCmd struct { consumerLastAckCriticalIsSet bool consumerRedeliveryCritical int consumerRedeliveryCriticalIsSet bool + consumerPinned bool raftExpect int raftExpectIsSet bool @@ -168,13 +169,14 @@ When set these settings will be used, but can be overridden using --waiting-crit consumer.Flag("last-delivery-critical", "Time to allow since the last delivery").Default("0s").IsSetByUser(&c.consumerLastDeliveryCriticalIsSet).DurationVar(&c.consumerLastDeliveryCritical) consumer.Flag("last-ack-critical", "Time to allow since the last ack").Default("0s").IsSetByUser(&c.consumerLastAckCriticalIsSet).DurationVar(&c.consumerLastAckCritical) consumer.Flag("redelivery-critical", "Maximum number of redeliveries to allow").Default("-1").IsSetByUser(&c.consumerRedeliveryCriticalIsSet).IntVar(&c.consumerRedeliveryCritical) + consumer.Flag("pinned", "Requires Pinned Client priority with all groups having a pinned client").UnNegatableBoolVar(&c.consumerPinned) msg := check.Command("message", "Checks properties of a message stored in a stream").Action(c.checkMsg) msg.Flag("stream", "The streams to check").Required().StringVar(&c.sourcesStream) msg.Flag("subject", "The subject to fetch a message from").Default(">").StringVar(&c.msgSubject) msg.Flag("age-warn", "Warning threshold for message age as a duration").PlaceHolder("DURATION").DurationVar(&c.msgAgeWarn) msg.Flag("age-critical", "Critical threshold for message age as a duration").PlaceHolder("DURATION").DurationVar(&c.msgAgeCrit) - msg.Flag("content", "Regular expression to check the content against").PlaceHolder("REGEX").RegexpVar(&c.msgRegexp) + msg.Flag("content", "Regular expression to check the content against").PlaceHolder("REGEX").Default(".").RegexpVar(&c.msgRegexp) msg.Flag("body-timestamp", "Use message body as a unix timestamp instead of message metadata").UnNegatableBoolVar(&c.msgBodyAsTs) meta := check.Command("meta", "Check JetStream cluster state").Alias("raft").Action(c.checkRaft) @@ -253,7 +255,12 @@ func (c *SrvCheckCmd) checkConsumer(_ *fisk.ParseContext) error { check := &monitor.Result{Name: fmt.Sprintf("%s_%s", c.sourcesStream, c.consumerName), Check: "consumer", OutFile: checkRenderOutFile, NameSpace: opts().PrometheusNamespace, RenderFormat: checkRenderFormat} defer check.GenericExit() - checkOpts := &monitor.ConsumerHealthCheckOptions{} + checkOpts := &monitor.ConsumerHealthCheckOptions{ + StreamName: c.sourcesStream, + ConsumerName: c.consumerName, + Pinned: c.consumerPinned, + } + if c.consumerAckOutstandingCriticalIsSet { checkOpts.AckOutstandingCritical = c.consumerAckOutstandingCritical } diff --git a/cli/server_command.go b/cli/server_command.go index 2e4cc8a1..02b7cbbd 100644 --- a/cli/server_command.go +++ b/cli/server_command.go @@ -22,6 +22,7 @@ func configureServerCommand(app commandHost) { configureServerClusterCommand(srv) configureServerConfigCommand(srv) configureServerGenerateCommand(srv) + configureServerGraphCommand(srv) configureServerInfoCommand(srv) configureServerListCommand(srv) configureServerMappingCommand(srv) @@ -31,6 +32,8 @@ func configureServerCommand(app commandHost) { configureServerRequestCommand(srv) configureServerRunCommand(srv) configureServerWatchCommand(srv) + configureStreamCheckCommand(srv) + configureConsumerCheckCommand(srv) } func init() { diff --git a/cli/server_consumer_check.go b/cli/server_consumer_check.go new file mode 100644 index 00000000..c9d43039 --- /dev/null +++ b/cli/server_consumer_check.go @@ -0,0 +1,334 @@ +// Copyright 2024 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/choria-io/fisk" + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/natscli/internal/sysclient" +) + +type ( + ConsumerDetail struct { + ServerID string + StreamName string + ConsumerName string + Account string + AccountID string + RaftGroup string + State server.StreamState + Cluster *server.ClusterInfo + StreamCluster *server.ClusterInfo + DeliveredStreamSeq uint64 + DeliveredConsumerSeq uint64 + AckFloorStreamSeq uint64 + AckFloorConsumerSeq uint64 + NumAckPending int + NumRedelivered int + NumWaiting int + NumPending uint64 + HealthStatus string + } + + ConsumerCheckCmd struct { + raftGroup string + streamName string + consumerName string + unsyncedFilter bool + health bool + expected int + stdin bool + readTimeout int + } +) + +func configureConsumerCheckCommand(app commandHost) { + cc := &ConsumerCheckCmd{} + consumerCheck := app.Command("consumer-check", "Check and display consumer information").Action(cc.consumerCheck).Hidden() + consumerCheck.Flag("stream", "Filter results by stream").StringVar(&cc.streamName) + consumerCheck.Flag("consumer", "Filter results by consumer").StringVar(&cc.consumerName) + consumerCheck.Flag("raft-group", "Filter results by raft group").StringVar(&cc.raftGroup) + consumerCheck.Flag("health", "Check health from consumers").UnNegatableBoolVar(&cc.health) + consumerCheck.Flag("expected", "Expected number of servers").IntVar(&cc.expected) + consumerCheck.Flag("unsynced", "Filter results by streams that are out of sync").UnNegatableBoolVar(&cc.unsyncedFilter) + consumerCheck.Flag("stdin", "Process the contents from STDIN").UnNegatableBoolVar(&cc.stdin) + consumerCheck.Flag("read-timeout", "Read timeout in seconds").Default("5").IntVar(&cc.readTimeout) +} + +func (c *ConsumerCheckCmd) consumerCheck(_ *fisk.ParseContext) error { + var err error + start := time.Now() + + nc, _, err := prepareHelper(opts().Servers, natsOpts()...) + if err != nil { + return err + } + fmt.Printf("Connected in %.3fs\n", time.Since(start).Seconds()) + + sys := sysclient.New(nc) + + if c.expected == 0 { + c.expected, err = currentActiveServers(nc) + if err != nil { + return fmt.Errorf("failed to get current active servers: %s", err) + } + } + + start = time.Now() + servers, err := sys.FindServers(c.stdin, c.expected, opts().Timeout, time.Duration(c.readTimeout), true) + if err != nil { + return fmt.Errorf("failed to find servers: %s", err) + } + fmt.Printf("Response took %.3fs\n", time.Since(start).Seconds()) + fmt.Printf("Servers: %d\n", len(servers)) + + streams := make(map[string]map[string]*streamDetail) + consumers := make(map[string]map[string]*ConsumerDetail) + // Collect all info from servers. + for _, resp := range servers { + server := resp.Server + jsz := resp.JSInfo + for _, acc := range jsz.AccountDetails { + for _, stream := range acc.Streams { + var mok bool + var ms map[string]*streamDetail + mkey := fmt.Sprintf("%s|%s", acc.Name, stream.RaftGroup) + if ms, mok = streams[mkey]; !mok { + ms = make(map[string]*streamDetail) + streams[mkey] = ms + } + ms[server.Name] = &streamDetail{ + ServerID: server.ID, + StreamName: stream.Name, + Account: acc.Name, + AccountID: acc.Id, + RaftGroup: stream.RaftGroup, + State: stream.State, + Cluster: stream.Cluster, + } + + for _, consumer := range stream.Consumer { + var raftGroup string + for _, cr := range stream.ConsumerRaftGroups { + if cr.Name == consumer.Name { + raftGroup = cr.RaftGroup + break + } + } + + var ok bool + var m map[string]*ConsumerDetail + key := fmt.Sprintf("%s|%s", acc.Name, raftGroup) + if m, ok = consumers[key]; !ok { + m = make(map[string]*ConsumerDetail) + consumers[key] = m + } + + m[server.Name] = &ConsumerDetail{ + ServerID: server.ID, + StreamName: consumer.Stream, + ConsumerName: consumer.Name, + Account: acc.Name, + AccountID: acc.Id, + RaftGroup: raftGroup, + State: stream.State, + DeliveredStreamSeq: consumer.Delivered.Stream, + DeliveredConsumerSeq: consumer.Delivered.Consumer, + AckFloorStreamSeq: consumer.AckFloor.Stream, + AckFloorConsumerSeq: consumer.AckFloor.Consumer, + Cluster: consumer.Cluster, + StreamCluster: stream.Cluster, + NumAckPending: consumer.NumAckPending, + NumRedelivered: consumer.NumRedelivered, + NumWaiting: consumer.NumWaiting, + NumPending: consumer.NumPending, + } + } + } + } + } + + keys := make([]string, 0) + for k := range consumers { + for kk := range consumers[k] { + key := fmt.Sprintf("%s/%s", k, kk) + keys = append(keys, key) + } + } + sort.Strings(keys) + fmt.Printf("Consumers: %d\n", len(keys)) + + table := newTableWriter("Consumers") + + if c.health { + table.AddHeaders("Consumer", "Stream", "Raft", "Account", "Account ID", "Node", "Delivered (S,C)", "ACK Floor (S,C)", "Counters", "Status", "Leader", "Stream Cluster Leader", "Peers", "Health") + } else { + table.AddHeaders("Consumer", "Stream", "Raft", "Account", "Account ID", "Node", "Delivered (S,C)", "ACK Floor (S,C)", "Counters", "Status", "Leader", "Stream Cluster Leader", "Peers") + } + + for _, k := range keys { + var unsynced bool + av := strings.Split(k, "|") + accName := av[0] + v := strings.Split(av[1], "/") + raftGroup, serverName := v[0], v[1] + + if c.raftGroup != "" && raftGroup == c.raftGroup { + continue + } + + key := fmt.Sprintf("%s|%s", accName, raftGroup) + consumer := consumers[key] + replica := consumer[serverName] + var status string + statuses := make(map[string]bool) + + if c.consumerName != "" && replica.ConsumerName != c.consumerName { + continue + } + + if c.streamName != "" && replica.StreamName != c.streamName { + continue + } + + if replica.State.LastSeq < replica.DeliveredStreamSeq { + statuses["UNSYNCED:DELIVERED_AHEAD_OF_STREAM_SEQ"] = true + unsynced = true + } + + if replica.State.LastSeq < replica.AckFloorStreamSeq { + statuses["UNSYNCED:ACKFLOOR_AHEAD_OF_STREAM_SEQ"] = true + unsynced = true + } + + // Make comparisons against other peers. + for _, peer := range consumer { + if peer.DeliveredStreamSeq != replica.DeliveredStreamSeq || + peer.DeliveredConsumerSeq != replica.DeliveredConsumerSeq { + statuses["UNSYNCED:DELIVERED"] = true + unsynced = true + } + if peer.AckFloorStreamSeq != replica.AckFloorStreamSeq || + peer.AckFloorConsumerSeq != replica.AckFloorConsumerSeq { + statuses["UNSYNCED:ACK_FLOOR"] = true + unsynced = true + } + if peer.Cluster == nil { + statuses["NO_CLUSTER"] = true + unsynced = true + } else { + if replica.Cluster == nil { + statuses["NO_CLUSTER_R"] = true + unsynced = true + } + if peer.Cluster.Leader != replica.Cluster.Leader { + statuses["MULTILEADER"] = true + unsynced = true + } + } + } + if replica.AckFloorStreamSeq == 0 || replica.AckFloorConsumerSeq == 0 || + replica.DeliveredConsumerSeq == 0 || replica.DeliveredStreamSeq == 0 { + statuses["EMPTY"] = true + } + if len(statuses) > 0 { + for k := range statuses { + status = fmt.Sprintf("%s%s,", status, k) + } + } else { + status = "IN SYNC" + } + + if replica.Cluster != nil { + if serverName == replica.Cluster.Leader && replica.Cluster.Leader == replica.StreamCluster.Leader { + status += " / INTERSECT" + } + } + + if c.unsyncedFilter && !unsynced { + continue + } + var alen int + if len(replica.Account) > 10 { + alen = 10 + } else { + alen = len(replica.Account) + } + + accountname := strings.Replace(replica.Account[:alen], " ", "_", -1) + + // Mark it in case it is a leader. + var suffix string + if replica.Cluster == nil { + status = "NO_CLUSTER" + unsynced = true + } else if serverName == replica.Cluster.Leader { + suffix = "*" + } else if replica.Cluster.Leader == "" { + status = "LEADERLESS" + unsynced = true + } + node := fmt.Sprintf("%s%s", serverName, suffix) + + progress := "0%" + if replica.State.LastSeq > 0 { + result := (float64(replica.DeliveredStreamSeq) / float64(replica.State.LastSeq)) * 100 + progress = fmt.Sprintf("%-3.0f%%", result) + } + + delivered := fmt.Sprintf("%d [%d, %d] %-3s | %d", + replica.DeliveredStreamSeq, replica.State.FirstSeq, replica.State.LastSeq, progress, replica.DeliveredConsumerSeq) + ackfloor := fmt.Sprintf("%d | %d", replica.AckFloorStreamSeq, replica.AckFloorConsumerSeq) + counters := fmt.Sprintf("(ap:%d, nr:%d, nw:%d, np:%d)", replica.NumAckPending, replica.NumRedelivered, replica.NumWaiting, replica.NumPending) + + var replicasInfo string + if replica.Cluster != nil { + for _, r := range replica.Cluster.Replicas { + info := fmt.Sprintf("%s(current=%-5v,offline=%v)", r.Name, r.Current, r.Offline) + replicasInfo = fmt.Sprintf("%-40s %s", info, replicasInfo) + } + } + + // Include Healthz if option added. + var healthStatus string + if c.health { + hstatus, err := sys.Healthz(replica.ServerID, server.HealthzOptions{ + Account: replica.Account, + Stream: replica.StreamName, + Consumer: replica.ConsumerName, + }) + if err != nil { + healthStatus = err.Error() + } else { + healthStatus = fmt.Sprintf(":%s:%s", hstatus.Healthz.Status, hstatus.Healthz.Error) + } + } + + clusterLeader := "" + + if replica.Cluster != nil { + clusterLeader = replica.Cluster.Leader + } + + table.AddRow(replica.ConsumerName, replica.StreamName, replica.RaftGroup, accountname, replica.AccountID, node, delivered, ackfloor, counters, status, clusterLeader, replica.StreamCluster.Leader, replicasInfo, healthStatus) + } + + fmt.Println(table.Render()) + return nil +} diff --git a/cli/server_graph_command.go b/cli/server_graph_command.go new file mode 100644 index 00000000..fd55622d --- /dev/null +++ b/cli/server_graph_command.go @@ -0,0 +1,359 @@ +// Copyright 2024 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "encoding/json" + "fmt" + "os" + "os/signal" + "strings" + "time" + + "github.com/choria-io/fisk" + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/nats-io/natscli/internal/asciigraph" + iu "github.com/nats-io/natscli/internal/util" + terminal "golang.org/x/term" +) + +type SrvGraphCmd struct { + id string + js bool +} + +func configureServerGraphCommand(srv *fisk.CmdClause) { + c := &SrvGraphCmd{} + + graph := srv.Command("graph", "Show graphs for a single server").Action(c.graph) + graph.Arg("server", "Server ID or Name to inspect").StringVar(&c.id) + graph.Flag("jetstream", "Draw JetStream statistics").Short('j').UnNegatableBoolVar(&c.js) +} + +func (c *SrvGraphCmd) graph(_ *fisk.ParseContext) error { + if !c.js { + return c.graphServer() + } + + return c.graphJetStream() +} + +func (c *SrvGraphCmd) graphWrapper(graphs int, h func(width int, height int, vz *server.Varz) ([]string, error)) error { + if !iu.IsTerminal() { + return fmt.Errorf("can only graph data on an interactive terminal") + } + + width, height, err := terminal.GetSize(int(os.Stdout.Fd())) + if err != nil { + return fmt.Errorf("failed to get terminal dimensions: %w", err) + } + + minHeight := graphs*5 + 2 // 3 graph lines, the ruler, the heading and overall heading plus newline + + if width < 20 || height < minHeight { + return fmt.Errorf("please increase terminal dimensions") + } + + nc, _, err := prepareHelper("", natsOpts()...) + if err != nil { + return err + } + + subj := fmt.Sprintf("$SYS.REQ.SERVER.%s.VARZ", c.id) + body := []byte("{}") + + if len(c.id) != 56 || strings.ToUpper(c.id) != c.id { + subj = "$SYS.REQ.SERVER.PING.VARZ" + opts := server.VarzEventOptions{EventFilterOptions: server.EventFilterOptions{Name: c.id}} + body, err = json.Marshal(opts) + if err != nil { + return err + } + } + + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) + defer cancel() + + vz, err := c.getVz(nc, subj, body) + if err != nil { + return err + } + + _, err = h(width, height, vz) + if err != nil { + return err + } + + ticker := time.NewTicker(time.Second) + for { + select { + case <-ticker.C: + width, height, err = terminal.GetSize(int(os.Stdout.Fd())) + if err != nil { + height = 40 + width = 80 + } + if width > 15 { + width -= 11 + } + if height > 10 { + height -= graphs + 1 // make space for the main heading and gaps in the graphs etc + } + + if width < 20 || height < minHeight { + return fmt.Errorf("please increase terminal dimensions") + } + + vz, err = c.getVz(nc, subj, body) + if err != nil { + return err + } + + iu.ClearScreen() + + plots, err := h(width, height, vz) + if err != nil { + return err + } + + for _, plot := range plots { + fmt.Println(plot) + fmt.Println() + } + + case <-ctx.Done(): + iu.ClearScreen() + return nil + } + } +} + +func (c *SrvGraphCmd) graphJetStream() error { + var memUsed, cpuUsed, fileUsed, haAssets []float64 + var apiRates, pending []float64 + var lastApi float64 + lastStateTs := time.Now() + first := true + + return c.graphWrapper(6, func(width int, height int, vz *server.Varz) ([]string, error) { + fmt.Printf("JetStream Statistics for %s\n", c.id) + fmt.Println() + + if first { + if vz.JetStream.Stats != nil { + lastApi = float64(vz.JetStream.Stats.API.Total) + } + memUsed = make([]float64, width) + cpuUsed = make([]float64, width) + fileUsed = make([]float64, width) + haAssets = make([]float64, width) + apiRates = make([]float64, width) + pending = make([]float64, width) + first = false + return nil, nil + } + + if vz.JetStream.Stats != nil { + memUsed = c.resizeData(memUsed, width, float64(vz.JetStream.Stats.Memory)/1024/1024/1024) + cpuUsed = c.resizeData(cpuUsed, width, vz.CPU/float64(vz.Cores)) + fileUsed = c.resizeData(fileUsed, width, float64(vz.JetStream.Stats.Store)/1024/1024/1024) + haAssets = c.resizeData(haAssets, width, float64(vz.JetStream.Stats.HAAssets)) + + apiRate := (float64(vz.JetStream.Stats.API.Total) - lastApi) / time.Since(lastStateTs).Seconds() + if apiRate < 0 { + apiRate = 0 + } + apiRates = c.resizeData(apiRates, width, apiRate) + + lastApi = float64(vz.JetStream.Stats.API.Total) + } + + if vz.JetStream.Meta != nil { + pending = c.resizeData(pending, width, float64(vz.JetStream.Meta.Pending)) + } + + lastStateTs = time.Now() + + cpuPlot := asciigraph.Plot(cpuUsed, + asciigraph.Caption(fmt.Sprintf("CPU %% Used (normalized for %d cores)", vz.Cores)), + asciigraph.Height(height/6-2), + asciigraph.Width(width), + asciigraph.Precision(0), + asciigraph.ValueFormatter(f)) + + memPlot := asciigraph.Plot(memUsed, + asciigraph.Caption("Memory Storage in GB"), + asciigraph.Height(height/6-2), + asciigraph.Width(width), + asciigraph.ValueFormatter(fiBytesFloat2Int)) + + filePlot := asciigraph.Plot(fileUsed, + asciigraph.Caption("File Storage in GB"), + asciigraph.Height(height/6-2), + asciigraph.Width(width), + asciigraph.ValueFormatter(fiBytesFloat2Int)) + + assetsPlot := asciigraph.Plot(haAssets, + asciigraph.Caption("HA Assets"), + asciigraph.Height(height/6-2), + asciigraph.Width(width), + asciigraph.Precision(0), + asciigraph.ValueFormatter(fFloat2Int)) + + apiRatesPlot := asciigraph.Plot(apiRates, + asciigraph.Caption("API Requests / second"), + asciigraph.Height(height/6-2), + asciigraph.Width(width), + asciigraph.Precision(0), + asciigraph.ValueFormatter(f)) + + pendingPlot := asciigraph.Plot(pending, + asciigraph.Caption("Pending API Requests"), + asciigraph.Height(height/6-2), + asciigraph.Width(width), + asciigraph.Precision(0), + asciigraph.ValueFormatter(fFloat2Int)) + + return []string{cpuPlot, assetsPlot, apiRatesPlot, pendingPlot, filePlot, memPlot}, nil + }) +} + +func (c *SrvGraphCmd) graphServer() error { + var cpuUsed, memUsed, connections, subscriptions []float64 + var messagesRate, bytesRate []float64 + var lastMessages, lastByes float64 + lastStateTs := time.Now() + first := true + + return c.graphWrapper(6, func(width int, height int, vz *server.Varz) ([]string, error) { + fmt.Printf("JetStream Statistics for %s\n", c.id) + fmt.Println() + + if first { + lastMessages = float64(vz.InMsgs + vz.OutMsgs) + lastByes = float64(vz.InBytes + vz.OutBytes) + cpuUsed = make([]float64, width) + memUsed = make([]float64, width) + connections = make([]float64, width) + subscriptions = make([]float64, width) + messagesRate = make([]float64, width) + bytesRate = make([]float64, width) + first = false + return nil, nil + } + + cpuUsed = c.resizeData(cpuUsed, width, vz.CPU/float64(vz.Cores)) + memUsed = c.resizeData(memUsed, width, float64(vz.Mem)/1024/1024) + connections = c.resizeData(connections, width, float64(vz.Connections)) + subscriptions = c.resizeData(subscriptions, width, float64(vz.Subscriptions)) + + messagesRate = c.resizeData(messagesRate, width, calculateRate(float64(vz.InMsgs+vz.OutMsgs), lastMessages, time.Since(lastStateTs))) + bytesRate = c.resizeData(bytesRate, width, calculateRate(float64(vz.InBytes+vz.OutBytes), lastByes, time.Since(lastStateTs))) + + lastMessages = float64(vz.InMsgs + vz.OutMsgs) + lastByes = float64(vz.InBytes + vz.OutBytes) + lastStateTs = time.Now() + + cpuPlot := asciigraph.Plot(cpuUsed, + asciigraph.Caption(fmt.Sprintf("CPU %% Used (normalized for %d cores)", vz.Cores)), + asciigraph.Height(height/6-2), + asciigraph.Width(width), + asciigraph.Precision(0), + asciigraph.ValueFormatter(f)) + + memPlot := asciigraph.Plot(memUsed, + asciigraph.Caption("Memory Used in MB"), + asciigraph.Height(height/6-2), + asciigraph.Width(width), + asciigraph.ValueFormatter(fiBytesFloat2Int)) + + connectionsPlot := asciigraph.Plot(connections, + asciigraph.Caption("Connections"), + asciigraph.Height(height/6-2), + asciigraph.Width(width), + asciigraph.Precision(0), + asciigraph.ValueFormatter(fFloat2Int)) + + subscriptionsPlot := asciigraph.Plot(subscriptions, + asciigraph.Caption("Subscriptions"), + asciigraph.Height(height/6-2), + asciigraph.Width(width), + asciigraph.Precision(0), + asciigraph.ValueFormatter(fFloat2Int)) + + messagesPlot := asciigraph.Plot(messagesRate, + asciigraph.Caption("Messages In+Out / second"), + asciigraph.Height(height/6-2), + asciigraph.Width(width), + asciigraph.Precision(0), + asciigraph.ValueFormatter(f)) + + bytesPlot := asciigraph.Plot(bytesRate, + asciigraph.Caption("Bytes In+Out / second"), + asciigraph.Height(height/6-2), + asciigraph.Width(width), + asciigraph.Precision(0), + asciigraph.ValueFormatter(fiBytesFloat2Int)) + + return []string{cpuPlot, memPlot, connectionsPlot, subscriptionsPlot, messagesPlot, bytesPlot}, nil + }) +} + +func (c *SrvGraphCmd) getVz(nc *nats.Conn, subj string, body []byte) (*server.Varz, error) { + resp, err := nc.Request(subj, body, opts().Timeout) + if err != nil { + return nil, fmt.Errorf("no results received, ensure the account used has system privileges and appropriate permissions") + } + + reqresp := map[string]json.RawMessage{} + err = json.Unmarshal(resp.Data, &reqresp) + if err != nil { + return nil, err + } + + errresp, ok := reqresp["error"] + if ok { + return nil, fmt.Errorf("invalid response received: %#v", errresp) + } + + data, ok := reqresp["data"] + if !ok { + return nil, fmt.Errorf("no data received in response: %#v", reqresp) + } + + varz := &server.Varz{} + err = json.Unmarshal(data, varz) + if err != nil { + return nil, err + } + + return varz, nil +} + +func (c *SrvGraphCmd) resizeData(data []float64, width int, val float64) []float64 { + data = append(data, val) + + if width <= 0 { + return data + } + + length := len(data) + + if length > width { + return data[length-width:] + } + + return data +} diff --git a/cli/server_info_command.go b/cli/server_info_command.go index 21b61c6e..d10b872a 100644 --- a/cli/server_info_command.go +++ b/cli/server_info_command.go @@ -150,6 +150,8 @@ func (c *SrvInfoCmd) info(_ *fisk.ParseContext) error { } cols.AddRow("Maximum Memory Storage", humanize.IBytes(uint64(js.Config.MaxMemory))) cols.AddRow("Maximum File Storage", humanize.IBytes(uint64(js.Config.MaxStore))) + cols.AddRowIfNotEmpty("Unique Tag", js.Config.UniqueTag) + cols.AddRow("Cluster Message Compression", js.Config.CompressOK) if js.Limits != nil { cols.AddRowUnlimited("Maximum HA Assets", int64(js.Limits.MaxHAAssets), 0) cols.AddRowUnlimited("Maximum Ack Pending", int64(js.Limits.MaxAckPending), 0) @@ -160,6 +162,7 @@ func (c *SrvInfoCmd) info(_ *fisk.ParseContext) error { cols.AddRow("Maximum Duplicate Window", js.Limits.Duplicates) } } + cols.AddRow("Strict API Parsing", js.Config.Strict) } cols.AddSectionTitle("Limits") diff --git a/cli/server_list_command.go b/cli/server_list_command.go index 827b5a99..e6cebcd8 100644 --- a/cli/server_list_command.go +++ b/cli/server_list_command.go @@ -243,6 +243,33 @@ func (c *SrvLsCmd) list(_ *fisk.ParseContext) error { gwaysOk = "X" } + var slow []string + if ssm.Stats.SlowConsumersStats != nil { + sstat := ssm.Stats.SlowConsumersStats + if sstat.Clients > 0 { + slow = append(slow, fmt.Sprintf("c: %s", f(sstat.Clients))) + } + if sstat.Routes > 0 { + slow = append(slow, fmt.Sprintf("r: %s", f(sstat.Routes))) + } + if sstat.Gateways > 0 { + slow = append(slow, fmt.Sprintf("g: %s", f(sstat.Gateways))) + } + if sstat.Leafs > 0 { + slow = append(slow, fmt.Sprintf("l: %s", f(sstat.Leafs))) + } + + // only print details if non clients also had slow consumers + if len(slow) == 1 && sstat.Clients > 0 { + slow = []string{} + } + } + + sc := f(ssm.Stats.SlowConsumers) + if len(slow) > 0 { + sc = fmt.Sprintf("%s (%s)", sc, strings.Join(slow, " ")) + } + table.AddRow( cNames[i], cluster, @@ -256,7 +283,7 @@ func (c *SrvLsCmd) list(_ *fisk.ParseContext) error { humanize.IBytes(uint64(ssm.Stats.Mem)), fmt.Sprintf("%.0f", ssm.Stats.CPU), ssm.Stats.Cores, - ssm.Stats.SlowConsumers, + sc, f(ssm.Server.Time.Sub(ssm.Stats.Start)), f(ssm.rtt.Round(time.Millisecond))) } diff --git a/cli/server_ping_command.go b/cli/server_ping_command.go index af7e9244..2cef4d65 100644 --- a/cli/server_ping_command.go +++ b/cli/server_ping_command.go @@ -25,9 +25,9 @@ import ( "time" "github.com/choria-io/fisk" - "github.com/guptarohit/asciigraph" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" + "github.com/nats-io/natscli/internal/asciigraph" ) type SrvPingCmd struct { diff --git a/cli/server_report_command.go b/cli/server_report_command.go index cff43fd6..876b48d5 100644 --- a/cli/server_report_command.go +++ b/cli/server_report_command.go @@ -192,18 +192,21 @@ func (c *SrvReportCmd) reportJetStream(_ *fisk.ParseContext) error { var ( names []string jszResponses []*jszr - apiErr uint64 + apiErrTotal uint64 apiTotal uint64 - memory uint64 - store uint64 - consumers int - streams int - bytes uint64 - msgs uint64 + pendingTotal int + memoryTotal uint64 + storeTotal uint64 + consumersTotal int + streamsTotal int + bytesTotal uint64 + msgsTotal uint64 cluster *server.MetaClusterInfo expectedClusterSize int ) + // TODO: remove after 2.12 is out + renderPending := iu.ServerMinVersion(nc, 2, 10, 21) renderDomain := false for _, r := range res { response := jszr{} @@ -278,11 +281,15 @@ func (c *SrvReportCmd) reportJetStream(_ *fisk.ParseContext) error { table = newTableWriter("JetStream Summary") } + hdrs := []any{"Server", "Cluster"} if renderDomain { - table.AddHeaders("Server", "Cluster", "Domain", "Streams", "Consumers", "Messages", "Bytes", "Memory", "File", "API Req", "API Err") - } else { - table.AddHeaders("Server", "Cluster", "Streams", "Consumers", "Messages", "Bytes", "Memory", "File", "API Req", "API Err") + hdrs = append(hdrs, "Domain") + } + hdrs = append(hdrs, "Streams", "Consumers", "Messages", "Bytes", "Memory", "File", "API Req", "API Err") + if renderPending { + hdrs = append(hdrs, "Pending") } + table.AddHeaders(hdrs...) for i, js := range jszResponses { jss := js.Data.JetStreamStats @@ -295,11 +302,12 @@ func (c *SrvReportCmd) reportJetStream(_ *fisk.ParseContext) error { doAccountStats = true } - apiErr += jss.API.Errors + apiErrTotal += jss.API.Errors apiTotal += jss.API.Total - memory += jss.Memory - store += jss.Store + memoryTotal += jss.Memory + storeTotal += jss.Store + rPending := 0 rStreams := 0 rConsumers := 0 rMessages := uint64(0) @@ -307,24 +315,24 @@ func (c *SrvReportCmd) reportJetStream(_ *fisk.ParseContext) error { if doAccountStats { rBytes = acc.Memory + acc.Store - bytes += rBytes + bytesTotal += rBytes rStreams = len(acc.Streams) - streams += rStreams + streamsTotal += rStreams for _, sd := range acc.Streams { - consumers += sd.State.Consumers + consumersTotal += sd.State.Consumers rConsumers += sd.State.Consumers - msgs += sd.State.Msgs + msgsTotal += sd.State.Msgs rMessages += sd.State.Msgs } } else { - consumers += js.Data.Consumers + consumersTotal += js.Data.Consumers rConsumers = js.Data.Consumers - streams += js.Data.Streams + streamsTotal += js.Data.Streams rStreams = js.Data.Streams - bytes += js.Data.Bytes + bytesTotal += js.Data.Bytes rBytes = js.Data.Bytes - msgs += js.Data.Messages + msgsTotal += js.Data.Messages rMessages = js.Data.Messages } @@ -337,6 +345,8 @@ func (c *SrvReportCmd) reportJetStream(_ *fisk.ParseContext) error { if expectedClusterSize < js.Data.Meta.Size { expectedClusterSize = js.Data.Meta.Size } + rPending = js.Data.Meta.Pending + pendingTotal += rPending } row := []any{cNames[i] + leader, js.Server.Cluster} @@ -358,6 +368,9 @@ func (c *SrvReportCmd) reportJetStream(_ *fisk.ParseContext) error { f(jss.API.Total), errCol, ) + if renderPending { + row = append(row, rPending) + } table.AddRow(row...) } @@ -366,7 +379,10 @@ func (c *SrvReportCmd) reportJetStream(_ *fisk.ParseContext) error { if renderDomain { row = append(row, "") } - row = append(row, f(streams), f(consumers), f(msgs), humanize.IBytes(bytes), humanize.IBytes(memory), humanize.IBytes(store), f(apiTotal), f(apiErr)) + row = append(row, f(streamsTotal), f(consumersTotal), f(msgsTotal), humanize.IBytes(bytesTotal), humanize.IBytes(memoryTotal), humanize.IBytes(storeTotal), f(apiTotal), f(apiErrTotal)) + if renderPending { + row = append(row, pendingTotal) + } table.AddFooter(row...) fmt.Print(table.Render()) diff --git a/cli/server_request_command.go b/cli/server_request_command.go index cc5da3c3..8e4f2ffe 100644 --- a/cli/server_request_command.go +++ b/cli/server_request_command.go @@ -139,7 +139,7 @@ func configureServerRequestCommand(srv *fisk.CmdClause) { healthz.Flag("details", "Include extended details about all failures").Default("true").BoolVar(&c.includeDetails) profilez := req.Command("profile", "Run a profile").Action(c.profilez) - profilez.Arg("profile", "Specify the name of the profile to run (allocs, heap, goroutine, mutex, threadcreate, block, cpu)").StringVar(&c.profileName) + profilez.Arg("profile", "Specify the name of the profile to run (allocs, heap, goroutine, mutex, threadcreate, block, cpu)").Required().EnumVar(&c.profileName, "allocs", "heap", "goroutine", "mutex", "threadcreate", "block", "cpu") profilez.Arg("dir", "Set the output directory for profile files").Default(".").ExistingDirVar(&c.profileDir) profilez.Flag("level", "Set the debug level of the profile").IntVar(&c.profileDebug) diff --git a/cli/server_stream_check.go b/cli/server_stream_check.go new file mode 100644 index 00000000..2ebcf382 --- /dev/null +++ b/cli/server_stream_check.go @@ -0,0 +1,231 @@ +// Copyright 2024 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/choria-io/fisk" + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/natscli/internal/sysclient" +) + +type ( + streamDetail struct { + StreamName string + Account string + AccountID string + RaftGroup string + State server.StreamState + Cluster *server.ClusterInfo + HealthStatus string + ServerID string + } + + StreamCheckCmd struct { + raftGroup string + streamName string + unsyncedFilter bool + health bool + expected int + stdin bool + readTimeout int + } +) + +func configureStreamCheckCommand(app commandHost) { + sc := &StreamCheckCmd{} + streamCheck := app.Command("stream-check", "Check and display stream information").Action(sc.streamCheck).Hidden() + streamCheck.Flag("stream", "Filter results by stream").StringVar(&sc.streamName) + streamCheck.Flag("raft-group", "Filter results by raft group").StringVar(&sc.raftGroup) + streamCheck.Flag("health", "Check health from streams").UnNegatableBoolVar(&sc.health) + streamCheck.Flag("expected", "Expected number of servers").IntVar(&sc.expected) + streamCheck.Flag("unsynced", "Filter results by streams that are out of sync").UnNegatableBoolVar(&sc.unsyncedFilter) + streamCheck.Flag("stdin", "Process the contents from STDIN").UnNegatableBoolVar(&sc.stdin) + streamCheck.Flag("read-timeout", "Read timeout in seconds").Default("5").IntVar(&sc.readTimeout) +} + +func (c *StreamCheckCmd) streamCheck(_ *fisk.ParseContext) error { + var err error + start := time.Now() + + nc, _, err := prepareHelper(opts().Servers, natsOpts()...) + if err != nil { + return err + } + fmt.Printf("Connected in %.3fs\n", time.Since(start).Seconds()) + + sys := sysclient.New(nc) + + if c.expected == 0 { + c.expected, err = currentActiveServers(nc) + if err != nil { + return fmt.Errorf("failed to get current active servers: %s", err) + } + } + + start = time.Now() + servers, err := sys.FindServers(c.stdin, c.expected, opts().Timeout, time.Duration(c.readTimeout), false) + if err != nil { + return fmt.Errorf("failed to find servers: %s", err) + } + fmt.Printf("Response took %.3fs\n", time.Since(start).Seconds()) + fmt.Printf("Servers: %d\n", len(servers)) + + // Collect all info from servers. + streams := make(map[string]map[string]*streamDetail) + for _, resp := range servers { + server := resp.Server + jsz := resp.JSInfo + for _, acc := range jsz.AccountDetails { + for _, stream := range acc.Streams { + var ok bool + var m map[string]*streamDetail + key := fmt.Sprintf("%s|%s", acc.Name, stream.RaftGroup) + if m, ok = streams[key]; !ok { + m = make(map[string]*streamDetail) + streams[key] = m + } + m[server.Name] = &streamDetail{ + ServerID: server.ID, + StreamName: stream.Name, + Account: acc.Name, + AccountID: acc.Id, + RaftGroup: stream.RaftGroup, + State: stream.State, + Cluster: stream.Cluster, + } + } + } + } + keys := make([]string, 0) + for k := range streams { + for kk := range streams[k] { + keys = append(keys, fmt.Sprintf("%s/%s", k, kk)) + } + } + sort.Strings(keys) + + fmt.Printf("Streams: %d\n", len(keys)) + + table := newTableWriter("Streams") + if c.health { + table.AddHeaders("Stream Replica", "Raft", "Account", "Account ID", "Node", "Messages", "Bytes", "Subjects", "Deleted", "Consumers", "First", "Last", "Status", "Leader", "Peers", "Health") + } else { + table.AddHeaders("Stream Replica", "Raft", "Account", "Account ID", "Node", "Messages", "Bytes", "Subjects", "Deleted", "Consumers", "First", "Last", "Status", "Leader", "Peers") + } + + for _, k := range keys { + var unsynced bool + av := strings.Split(k, "|") + accName := av[0] + v := strings.Split(av[1], "/") + raftName, serverName := v[0], v[1] + if c.raftGroup != "" && raftName != c.raftGroup { + continue + } + + key := fmt.Sprintf("%s|%s", accName, raftName) + stream := streams[key] + replica := stream[serverName] + status := "IN SYNC" + + if c.streamName != "" && replica.StreamName != c.streamName { + continue + } + + // Make comparisons against other peers. + for _, peer := range stream { + if peer.State.Msgs != replica.State.Msgs && peer.State.Bytes != replica.State.Bytes { + status = "UNSYNCED" + unsynced = true + } + if peer.State.FirstSeq != replica.State.FirstSeq { + status = "UNSYNCED" + unsynced = true + } + if peer.State.LastSeq != replica.State.LastSeq { + status = "UNSYNCED" + unsynced = true + } + // Cannot trust results unless coming from the stream leader. + // Need Stream INFO and collect multiple responses instead. + if peer.Cluster.Leader != replica.Cluster.Leader { + status = "MULTILEADER" + unsynced = true + } + } + if c.unsyncedFilter && !unsynced { + continue + } + + if replica == nil { + status = "?" + unsynced = true + continue + } + var alen int + if len(replica.Account) > 10 { + alen = 10 + } else { + alen = len(replica.Account) + } + + account := strings.Replace(replica.Account[:alen], " ", "_", -1) + + // Mark it in case it is a leader. + var suffix string + var isStreamLeader bool + if serverName == replica.Cluster.Leader { + isStreamLeader = true + suffix = "*" + } else if replica.Cluster.Leader == "" { + status = "LEADERLESS" + unsynced = true + } + + var replicasInfo string // PEER + for _, r := range replica.Cluster.Replicas { + if isStreamLeader && r.Name == replica.Cluster.Leader { + status = "LEADER_IS_FOLLOWER" + unsynced = true + } + info := fmt.Sprintf("%s(current=%-5v,offline=%v)", r.Name, r.Current, r.Offline) + replicasInfo = fmt.Sprintf("%-40s %s", info, replicasInfo) + } + + // Include Healthz if option added. + var healthStatus string + if c.health { + hstatus, err := sys.Healthz(replica.ServerID, server.HealthzOptions{ + Account: replica.Account, + Stream: replica.StreamName, + }) + if err != nil { + healthStatus = err.Error() + } else { + healthStatus = fmt.Sprintf(":%s:%s", hstatus.Healthz.Status, hstatus.Healthz.Error) + } + } + + table.AddRow(replica.StreamName, replica.RaftGroup, account, replica.AccountID, fmt.Sprintf("%s%s", serverName, suffix), replica.State.Msgs, replica.State.Bytes, replica.State.NumSubjects, replica.State.NumDeleted, replica.State.Consumers, replica.State.FirstSeq, + replica.State.LastSeq, status, replica.Cluster.Leader, replicasInfo, healthStatus) + } + + fmt.Println(table.Render()) + return nil +} diff --git a/cli/server_watch_js_command.go b/cli/server_watch_js_command.go index 0207da09..7cc99784 100644 --- a/cli/server_watch_js_command.go +++ b/cli/server_watch_js_command.go @@ -90,14 +90,28 @@ func (c *SrvWatchJSCmd) updateSizes() error { return nil } +func (c *SrvWatchJSCmd) prePing(nc *nats.Conn, h nats.MsgHandler) { + sub, err := nc.Subscribe(nc.NewRespInbox(), h) + if err != nil { + return + } + + time.AfterFunc(2*time.Second, func() { sub.Unsubscribe() }) + + msg := nats.NewMsg("$SYS.REQ.SERVER.PING") + msg.Reply = sub.Subject + nc.PublishMsg(msg) +} + func (c *SrvWatchJSCmd) jetstreamAction(_ *fisk.ParseContext) error { nc, _, err := prepareHelper("", natsOpts()...) if err != nil { return err } - _, err = nc.Subscribe("$SYS.SERVER.*.STATSZ", c.handle) + c.prePing(nc, c.handle) + _, err = nc.Subscribe("$SYS.SERVER.*.STATSZ", c.handle) if err != nil { return err } @@ -106,10 +120,13 @@ func (c *SrvWatchJSCmd) jetstreamAction(_ *fisk.ParseContext) error { ctx, cancel := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT) defer cancel() + // TODO: remove after 2.12 is out + drawPending := iu.ServerMinVersion(nc, 2, 10, 21) + for { select { case <-tick.C: - err = c.redraw() + err = c.redraw(drawPending) if err != nil { return err } @@ -136,7 +153,7 @@ func (c *SrvWatchJSCmd) handle(msg *nats.Msg) { c.mu.Unlock() } -func (c *SrvWatchJSCmd) redraw() error { +func (c *SrvWatchJSCmd) redraw(drawPending bool) error { c.mu.Lock() defer c.mu.Unlock() @@ -146,12 +163,13 @@ func (c *SrvWatchJSCmd) redraw() error { } var ( - servers []*server.ServerStatsMsg - assets int - mem uint64 - store uint64 - api uint64 - apiError uint64 + servers []*server.ServerStatsMsg + assets int + mem uint64 + store uint64 + api uint64 + apiError uint64 + apiPending int ) for _, srv := range c.servers { @@ -166,6 +184,9 @@ func (c *SrvWatchJSCmd) redraw() error { store += srv.Stats.JetStream.Stats.Store api += srv.Stats.JetStream.Stats.API.Total apiError += srv.Stats.JetStream.Stats.API.Errors + if srv.Stats.JetStream.Meta != nil { + apiPending += srv.Stats.JetStream.Meta.Pending + } } sort.Slice(servers, func(i, j int) bool { @@ -192,7 +213,12 @@ func (c *SrvWatchJSCmd) redraw() error { } table := newTableWriter(fmt.Sprintf("Top %s Server activity by %s at %s", tc, c.sortNames[c.sort], c.lastMsg.Format(time.DateTime))) - table.AddHeaders("Server", "HA Assets", "Memory", "File", "API", "API Errors") + + if drawPending { + table.AddHeaders("Server", "HA Assets", "Memory", "File", "API", "API Errors", "API Pending") + } else { + table.AddHeaders("Server", "HA Assets", "Memory", "File", "API", "API Errors") + } var matched []*server.ServerStatsMsg if len(servers) < c.topCount { @@ -203,16 +229,35 @@ func (c *SrvWatchJSCmd) redraw() error { for _, srv := range matched { js := srv.Stats.JetStream.Stats - table.AddRow( - srv.Server.Name, + name := srv.Server.Name + + pending := 0 + if srv.Stats.JetStream.Meta != nil { + pending = srv.Stats.JetStream.Meta.Pending + if srv.Stats.JetStream.Meta.Leader == name { + name = name + "*" + } + } + + row := []any{ + name, f(js.HAAssets), fiBytes(js.Memory), fiBytes(js.Store), f(js.API.Total), f(js.API.Errors), - ) + } + if drawPending { + row = append(row, f(pending)) + } + + table.AddRow(row...) + } + row := []any{fmt.Sprintf("Totals (%d Servers)", len(matched)), f(assets), fiBytes(mem), fiBytes(store), f(api), f(apiError)} + if drawPending { + row = append(row, f(apiPending)) } - table.AddFooter("Totals (All Servers)", f(assets), fiBytes(mem), fiBytes(store), f(api), f(apiError)) + table.AddFooter(row...) iu.ClearScreen() fmt.Print(table.Render()) diff --git a/cli/server_watch_srv_command.go b/cli/server_watch_srv_command.go index add91721..357a61ee 100644 --- a/cli/server_watch_srv_command.go +++ b/cli/server_watch_srv_command.go @@ -95,12 +95,28 @@ func (c *SrvWatchServerCmd) updateSizes() error { return nil } + +func (c *SrvWatchServerCmd) prePing(nc *nats.Conn, h nats.MsgHandler) { + sub, err := nc.Subscribe(nc.NewRespInbox(), h) + if err != nil { + return + } + + time.AfterFunc(2*time.Second, func() { sub.Unsubscribe() }) + + msg := nats.NewMsg("$SYS.REQ.SERVER.PING") + msg.Reply = sub.Subject + nc.PublishMsg(msg) +} + func (c *SrvWatchServerCmd) serversAction(_ *fisk.ParseContext) error { nc, _, err := prepareHelper("", natsOpts()...) if err != nil { return err } + c.prePing(nc, c.handle) + _, err = nc.Subscribe("$SYS.SERVER.*.STATSZ", c.handle) if err != nil { diff --git a/cli/stream_command.go b/cli/stream_command.go index d4eda13f..80d661fe 100644 --- a/cli/stream_command.go +++ b/cli/stream_command.go @@ -21,7 +21,6 @@ import ( "io" "math" "os" - "os/exec" "os/signal" "path/filepath" "sort" @@ -31,7 +30,7 @@ import ( "syscall" "time" - "github.com/guptarohit/asciigraph" + "github.com/nats-io/natscli/internal/asciigraph" iu "github.com/nats-io/natscli/internal/util" terminal "golang.org/x/term" @@ -475,20 +474,24 @@ func (c *streamCmd) graphAction(_ *fisk.ParseContext) error { width = 80 } if width > 15 { - width -= 10 + width -= 11 } if height > 10 { height -= 6 } + if width < 20 || height < 20 { + return fmt.Errorf("please increase terminal dimensions") + } + nfo, err := stream.State() if err != nil { continue } messagesStored = append(messagesStored, float64(nfo.Msgs)) - messageRates = append(messageRates, float64(nfo.LastSeq-lastLastSeq)/time.Since(lastStateTs).Seconds()) - limitedRates = append(limitedRates, float64(nfo.FirstSeq-lastFirstSeq)/time.Since(lastStateTs).Seconds()) + messageRates = append(messageRates, calculateRate(float64(nfo.LastSeq), float64(lastLastSeq), time.Since(lastStateTs))) + limitedRates = append(limitedRates, calculateRate(float64(nfo.FirstSeq), float64(lastFirstSeq), time.Since(lastStateTs))) lastStateTs = time.Now() lastLastSeq = nfo.LastSeq @@ -504,6 +507,7 @@ func (c *streamCmd) graphAction(_ *fisk.ParseContext) error { asciigraph.Height(height/3-2), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(fFloat2Int), ) limitedRatePlot := asciigraph.Plot(limitedRates, @@ -512,6 +516,7 @@ func (c *streamCmd) graphAction(_ *fisk.ParseContext) error { asciigraph.Height(height/3-2), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(f), ) msgRatePlot := asciigraph.Plot(messageRates, @@ -520,6 +525,7 @@ func (c *streamCmd) graphAction(_ *fisk.ParseContext) error { asciigraph.Height(height/3-2), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(f), ) iu.ClearScreen() @@ -1793,11 +1799,6 @@ func (c *streamCmd) copyAndEditStream(cfg api.StreamConfig, pc *fisk.ParseContex } func (c *streamCmd) interactiveEdit(cfg api.StreamConfig) (api.StreamConfig, error) { - editor := os.Getenv("EDITOR") - if editor == "" { - return api.StreamConfig{}, fmt.Errorf("set EDITOR environment variable to your chosen editor") - } - cj, err := decoratedYamlMarshal(cfg) if err != nil { return api.StreamConfig{}, fmt.Errorf("could not create temporary file: %s", err) @@ -1816,14 +1817,9 @@ func (c *streamCmd) interactiveEdit(cfg api.StreamConfig) (api.StreamConfig, err tfile.Close() - cmd := exec.Command(editor, tfile.Name()) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - err = cmd.Run() + err = iu.EditFile(tfile.Name()) if err != nil { - return api.StreamConfig{}, fmt.Errorf("could not create temporary file: %s", err) + return api.StreamConfig{}, err } nb, err := os.ReadFile(tfile.Name()) @@ -3039,7 +3035,7 @@ func (c *streamCmd) purgeAction(_ *fisk.ParseContext) (err error) { var req *api.JSApiStreamPurgeRequest if c.purgeKeep > 0 || c.purgeSubject != "" || c.purgeSequence > 0 { if c.purgeSequence > 0 && c.purgeKeep > 0 { - return fmt.Errorf("sequence and keep cannot be combined when purghing") + return fmt.Errorf("sequence and keep cannot be combined when purging") } req = &api.JSApiStreamPurgeRequest{ diff --git a/cli/sub_command.go b/cli/sub_command.go index 64b9a61f..cd070581 100644 --- a/cli/sub_command.go +++ b/cli/sub_command.go @@ -28,10 +28,10 @@ import ( "github.com/choria-io/fisk" "github.com/dustin/go-humanize" - "github.com/guptarohit/asciigraph" "github.com/nats-io/jsm.go" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" + "github.com/nats-io/natscli/internal/asciigraph" iu "github.com/nats-io/natscli/internal/util" terminal "golang.org/x/term" ) @@ -191,6 +191,7 @@ func (c *subCmd) startGraph(ctx context.Context, mu *sync.Mutex) { asciigraph.Height((c.height/(len(c.subjects)+1))-1), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(f), ) fmt.Println(msgRatePlot) fmt.Println() diff --git a/cli/util.go b/cli/util.go index 9845af50..0817c852 100644 --- a/cli/util.go +++ b/cli/util.go @@ -36,6 +36,7 @@ import ( "time" "unicode" + "github.com/nats-io/nats.go/jetstream" "github.com/nats-io/natscli/options" iu "github.com/nats-io/natscli/internal/util" @@ -376,7 +377,7 @@ func newNatsConn(servers string, copts ...nats.Option) (*nats.Conn, error) { return newNatsConnUnlocked(servers, copts...) } -func prepareJSHelper() (*nats.Conn, nats.JetStreamContext, error) { +func prepareJSHelper() (*nats.Conn, jetstream.JetStream, error) { mu.Lock() defer mu.Unlock() @@ -394,12 +395,13 @@ func prepareJSHelper() (*nats.Conn, nats.JetStreamContext, error) { return opts.Conn, opts.JSc, nil } - opts.JSc, err = opts.Conn.JetStream(jsOpts()...) + opts.JSc, err = jetstream.New(opts.Conn) if err != nil { return nil, nil, err } return opts.Conn, opts.JSc, nil + } func prepareHelper(servers string, copts ...nats.Option) (*nats.Conn, *jsm.Manager, error) { @@ -782,6 +784,17 @@ func renderCluster(cluster *api.ClusterInfo) string { return f(compact) } +// doReqAsyncWaitFullTimeoutInterval special value to be passed as `waitFor` argument of doReqAsync to turn off +// "adaptive" timeout and wait for the full interval +const doReqAsyncWaitFullTimeoutInterval = -1 + +// doReqAsync serializes and sends a request to the given subject and handles multiple responses. +// This function uses the value from `Timeout` CLI flag as upper limit for responses gathering. +// The value of the `waitFor` may shorten the interval during which responses are gathered: +// +// waitFor < 0 : listen for responses for the full timeout interval +// waitFor == 0 : (adaptive timeout), after each response, wait a short amount of time for more, then stop +// waitFor > 0 : stops listening before the timeout if the given number of responses are received func doReqAsync(req any, subj string, waitFor int, nc *nats.Conn, cb func([]byte)) error { jreq := []byte("{}") var err error @@ -808,10 +821,13 @@ func doReqAsync(req any, subj string, waitFor int, nc *nats.Conn, cb func([]byte finisher *time.Timer ) + // Set deadline, max amount of time this function waits for responses ctx, cancel := context.WithTimeout(ctx, opts().Timeout) defer cancel() + // Activate "adaptive timeout". Finisher may trigger early termination if waitFor == 0 { + // First response can take up to Timeout to arrive finisher = time.NewTimer(opts().Timeout) go func() { select { @@ -852,7 +868,9 @@ func doReqAsync(req any, subj string, waitFor int, nc *nats.Conn, cb func([]byte } } + // If adaptive timeout is active, set deadline for next response if finisher != nil { + // Stop listening and return if no further responses arrive within this interval finisher.Reset(300 * time.Millisecond) } @@ -864,6 +882,7 @@ func doReqAsync(req any, subj string, waitFor int, nc *nats.Conn, cb func([]byte cb(data) ctr++ + // Stop listening if the requested number of responses have been received if waitFor > 0 && ctr == waitFor { cancel() } @@ -1210,3 +1229,12 @@ func currentActiveServers(nc *nats.Conn) (int, error) { return expect, err } + +func calculateRate(new, last float64, since time.Duration) float64 { + // If new == 0 we have missed a data point from nats. + // Return the previous calculation so that it doesn't break graphs + if new == 0 { + return last + } + return (new - last) / since.Seconds() +} diff --git a/go.mod b/go.mod index 0a64cdeb..cee9cbfd 100644 --- a/go.mod +++ b/go.mod @@ -5,48 +5,47 @@ go 1.22.0 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/HdrHistogram/hdrhistogram-go v1.1.2 - github.com/choria-io/fisk v0.6.2 - github.com/choria-io/scaffold v0.0.2-0.20240516112801-fc127c79a1df + github.com/choria-io/fisk v0.6.4 + github.com/choria-io/scaffold v0.0.2 github.com/dustin/go-humanize v1.0.1 github.com/emicklei/dot v1.6.2 github.com/expr-lang/expr v1.16.9 - github.com/fatih/color v1.17.0 + github.com/fatih/color v1.18.0 github.com/ghodss/yaml v1.0.0 github.com/google/go-cmp v0.6.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gosuri/uiprogress v0.0.1 - github.com/guptarohit/asciigraph v0.7.2 - github.com/jedib0t/go-pretty/v6 v6.5.9 + github.com/jedib0t/go-pretty/v6 v6.6.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/klauspost/compress v1.17.9 + github.com/klauspost/compress v1.17.11 github.com/mattn/go-isatty v0.0.20 - github.com/nats-io/jsm.go v0.1.1-0.20240910110459-a94b3842a419 - github.com/nats-io/jwt/v2 v2.5.8 - github.com/nats-io/nats-server/v2 v2.11.0-dev.0.20240909173510-a07bde9fa7d4 + github.com/nats-io/jsm.go v0.1.1-0.20241119145046-e1d638961b90 + github.com/nats-io/jwt/v2 v2.7.2 + github.com/nats-io/nats-server/v2 v2.11.0-dev.0.20241119025009-2dadd723643c github.com/nats-io/nats.go v1.37.0 github.com/nats-io/nkeys v0.4.7 github.com/nats-io/nuid v1.0.1 - github.com/prometheus/client_golang v1.20.3 + github.com/prometheus/client_golang v1.20.5 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 - github.com/synadia-io/jwt-auth-builder.go v0.0.0-20240628155003-21e8d1e9d490 + github.com/synadia-io/jwt-auth-builder.go v0.0.0-20240829124321-43722a8ce3ce github.com/tylertreat/hdrhistogram-writer v0.0.0-20210816161836-2e440612a39f - golang.org/x/crypto v0.27.0 - golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa - golang.org/x/term v0.24.0 + golang.org/x/crypto v0.29.0 + golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c + golang.org/x/term v0.26.0 gopkg.in/gizak/termui.v1 v1.0.0-20151021151108-e62b5929642a gopkg.in/yaml.v3 v3.0.1 ) require ( - dario.cat/mergo v1.0.0 // indirect + dario.cat/mergo v1.0.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver/v3 v3.2.1 // indirect + github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/google/go-tpm v0.9.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gosuri/uilive v0.0.4 // indirect - github.com/huandu/xstrings v1.4.0 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect @@ -55,18 +54,18 @@ require ( github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nats-io/nsc/v2 v2.8.6-0.20231220104935-3f89317df670 // indirect + github.com/nats-io/nsc/v2 v2.8.6 // indirect github.com/nsf/termbox-go v1.1.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.59.1 // indirect + github.com/prometheus/common v0.60.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/spf13/cast v1.6.0 // indirect - golang.org/x/net v0.29.0 // indirect - golang.org/x/sys v0.25.0 // indirect - golang.org/x/text v0.18.0 // indirect - golang.org/x/time v0.6.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect + github.com/spf13/cast v1.7.0 // indirect + golang.org/x/net v0.31.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.20.0 // indirect + golang.org/x/time v0.8.0 // indirect + google.golang.org/protobuf v1.35.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index be290fa3..0e7c9c3a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= @@ -9,8 +9,8 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= -github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= @@ -18,10 +18,10 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/choria-io/fisk v0.6.2 h1:Vfvpcv8SD53FHW5cT4u7LStpz/wThwRPQHU7mzv1kMI= -github.com/choria-io/fisk v0.6.2/go.mod h1:PajiUZTAotE5zO18eU6UexuPLLv565WOma4dB0ObxRM= -github.com/choria-io/scaffold v0.0.2-0.20240516112801-fc127c79a1df h1:AjeW8+RG33eMFF/nGCyO3GmAYJpUpR6ml33XiPD76sE= -github.com/choria-io/scaffold v0.0.2-0.20240516112801-fc127c79a1df/go.mod h1:BHv4yglwBIDPf9+ZDKXa+R8kidRQZKf6xxYd0a6V+/s= +github.com/choria-io/fisk v0.6.4 h1:0y79FnS0yYL3WxqzFVLLqVg8FaBu/jf2eSfzCLmO3IY= +github.com/choria-io/fisk v0.6.4/go.mod h1:wZpYdeUibttuIFRz7ggD3zZpQWlS5utcksf5Q43Qnww= +github.com/choria-io/scaffold v0.0.2 h1:pyg0U6wah+T08SMDfQDEfsAqNLS7em/EMM1SH578Z+k= +github.com/choria-io/scaffold v0.0.2/go.mod h1:cxFEkQeddcoklXsRVYgwEbd0v+6lTAkwZRxx0WLardo= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= @@ -34,16 +34,16 @@ github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/expr-lang/expr v1.16.9 h1:WUAzmR0JNI9JCiF0/ewwHB1gmcGw5wW7nWt8gc6PpCI= github.com/expr-lang/expr v1.16.9/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= -github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= -github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= @@ -52,8 +52,8 @@ 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/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM= github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/google/pprof v0.0.0-20240509144519-723abb6459b7 h1:velgFPYr1X9TDwLIfkV7fWqsFlf7TeP11M/7kPd/dVI= -github.com/google/pprof v0.0.0-20240509144519-723abb6459b7/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k= +github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -62,19 +62,17 @@ github.com/gosuri/uilive v0.0.4 h1:hUEBpQDj8D8jXgtCdBu7sWsy5sbW/5GhuO8KBwJ2jyY= github.com/gosuri/uilive v0.0.4/go.mod h1:V/epo5LjjlDE5RJUcqx8dbw+zc93y5Ya3yg8tfZ74VI= github.com/gosuri/uiprogress v0.0.1 h1:0kpv/XY/qTmFWl/SkaJykZXrBBzwwadmW8fRb7RJSxw= github.com/gosuri/uiprogress v0.0.1/go.mod h1:C1RTYn4Sc7iEyf6j8ft5dyoZ4212h8G1ol9QQluh5+0= -github.com/guptarohit/asciigraph v0.7.2 h1:pBBJYbMl4j7zS4AwmrfAs6tA0VQOEQC933aG72dlrFA= -github.com/guptarohit/asciigraph v0.7.2/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= -github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= -github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/jedib0t/go-pretty/v6 v6.5.9 h1:ACteMBRrrmm1gMsXe9PSTOClQ63IXDUt03H5U+UV8OU= -github.com/jedib0t/go-pretty/v6 v6.5.9/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/jedib0t/go-pretty/v6 v6.6.1 h1:iJ65Xjb680rHcikRj6DSIbzCex2huitmc7bDtxYVWyc= +github.com/jedib0t/go-pretty/v6 v6.6.1/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -106,35 +104,35 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nats-io/jsm.go v0.1.1-0.20240910110459-a94b3842a419 h1:ahH+acQcvfgk8sqFzVlZcVPdK2jHMxqVnZSsLYBshOU= -github.com/nats-io/jsm.go v0.1.1-0.20240910110459-a94b3842a419/go.mod h1:qarKt1X8221zgCOg+JcjkH1/i7+p3HQFRWNYv1lk3dI= -github.com/nats-io/jwt/v2 v2.5.8 h1:uvdSzwWiEGWGXf+0Q+70qv6AQdvcvxrv9hPM0RiPamE= -github.com/nats-io/jwt/v2 v2.5.8/go.mod h1:ZdWS1nZa6WMZfFwwgpEaqBV8EPGVgOTDHN/wTbz0Y5A= -github.com/nats-io/nats-server/v2 v2.11.0-dev.0.20240909173510-a07bde9fa7d4 h1:4ZtCJK+tZMXPcyzFGNWNkJSY1/twBs15DHwMFp7BHWw= -github.com/nats-io/nats-server/v2 v2.11.0-dev.0.20240909173510-a07bde9fa7d4/go.mod h1:o07K/z9ovs02pREUUNWwGGuIYqL110VKnjbjHp+/TjE= +github.com/nats-io/jsm.go v0.1.1-0.20241119145046-e1d638961b90 h1:TWc4Yc/ZpS+LK9LTU1c24xRRrUyte6WSSynJZdqplfE= +github.com/nats-io/jsm.go v0.1.1-0.20241119145046-e1d638961b90/go.mod h1:XYx1eATSGIODQY8jhQQBPnB1w1DcqYO7gxb/kLC5mqk= +github.com/nats-io/jwt/v2 v2.7.2 h1:SCRjfDLJ2q8naXp8YlGJJS5/yj3wGSODFYVi4nnwVMw= +github.com/nats-io/jwt/v2 v2.7.2/go.mod h1:kB6QUmqHG6Wdrzj0KP2L+OX4xiTPBeV+NHVstFaATXU= +github.com/nats-io/nats-server/v2 v2.11.0-dev.0.20241119025009-2dadd723643c h1:lcnWooJpdDKowjjijNW5LCTGRjInR4rpAecFVXxoOZQ= +github.com/nats-io/nats-server/v2 v2.11.0-dev.0.20241119025009-2dadd723643c/go.mod h1:UMry3yQXAiKBN3yh82BS4HxJWF9ht4sbRQyZ1qXODqc= github.com/nats-io/nats.go v1.37.0 h1:07rauXbVnnJvv1gfIyghFEo6lUcYRY0WXc3x7x0vUxE= github.com/nats-io/nats.go v1.37.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8= github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI= github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc= -github.com/nats-io/nsc/v2 v2.8.6-0.20231220104935-3f89317df670 h1:NQzs7g/+Z4kC4XsYsKCQlwRcM4Hk0VyKuz7F4zUgjvQ= -github.com/nats-io/nsc/v2 v2.8.6-0.20231220104935-3f89317df670/go.mod h1:Z2+aDD1PzpXk8kF1ro17cfGBzmBoWPbtvGW8hBssAdA= +github.com/nats-io/nsc/v2 v2.8.6 h1:ytf5F2mb+BXx8DjXImPyqOZrFFozt9umQGuQ+6m9eXs= +github.com/nats-io/nsc/v2 v2.8.6/go.mod h1:jHj6s7VspjVwl0NRoWjVN+gqVvhA+NdkTpAk/WZg5yk= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= -github.com/onsi/ginkgo/v2 v2.17.3 h1:oJcvKpIb7/8uLpDDtnQuf18xVnwKp8DTD7DQ6gTd/MU= -github.com/onsi/ginkgo/v2 v2.17.3/go.mod h1:nP2DPOQoNsQmsVyv5rDA8JkXQoCs6goXIvr/PRJ1eCc= -github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= -github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= +github.com/onsi/ginkgo/v2 v2.20.0 h1:PE84V2mHqoT1sglvHc8ZdQtPcwmvvt29WLEEO3xmdZw= +github.com/onsi/ginkgo/v2 v2.20.0/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.20.3 h1:oPksm4K8B+Vt35tUhw6GbSNSgVlVSBH0qELP/7u83l4= -github.com/prometheus/client_golang v1.20.3/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.59.1 h1:LXb1quJHWm1P6wq/U824uxYi4Sg0oGvNeUm1z5dJoX0= -github.com/prometheus/common v0.59.1/go.mod h1:GpWM7dewqmVYcd7SmRaiWVe9SSqjf0UrwnYnpEZNuT0= +github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc= +github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -146,30 +144,30 @@ github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6Ng github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/synadia-io/jwt-auth-builder.go v0.0.0-20240628155003-21e8d1e9d490 h1:kQ0l2H+bm1rbCanV4w2wCEOy1ofgpU2kI+Pykg+j9BU= -github.com/synadia-io/jwt-auth-builder.go v0.0.0-20240628155003-21e8d1e9d490/go.mod h1:z+ZENSUrwJFMZstPcEfPAXXVKRibe0iqx0ACG5ikYBg= +github.com/synadia-io/jwt-auth-builder.go v0.0.0-20240829124321-43722a8ce3ce h1:/BAyu+r73DXnBe/+dHuwV6HAZ7cg/ifsjqQaBw0gF6Q= +github.com/synadia-io/jwt-auth-builder.go v0.0.0-20240829124321-43722a8ce3ce/go.mod h1:z+ZENSUrwJFMZstPcEfPAXXVKRibe0iqx0ACG5ikYBg= github.com/tylertreat/hdrhistogram-writer v0.0.0-20210816161836-2e440612a39f h1:SGznmvCovewbaSgBsHgdThtWsLj5aCLX/3ZXMLd1UD0= github.com/tylertreat/hdrhistogram-writer v0.0.0-20210816161836-2e440612a39f/go.mod h1:IY84XkhrEJTdHYLNy/zObs8mXuUAp9I65VyarbPSCCY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= -golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= +golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= +golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -180,8 +178,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn 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.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= +golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= 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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -195,28 +193,28 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= -golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= +golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= 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.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= -golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 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.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= -golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -225,8 +223,8 @@ gonum.org/v1/gonum v0.8.2 h1:CCXrcPKiGGotvnN6jfUsKk4rRqm7q09/YbKb5xCEvtM= gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/asciigraph/LICENSE b/internal/asciigraph/LICENSE new file mode 100644 index 00000000..7918b971 --- /dev/null +++ b/internal/asciigraph/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2018, Rohit Gupta +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/internal/asciigraph/asciigraph.go b/internal/asciigraph/asciigraph.go new file mode 100644 index 00000000..2bd58115 --- /dev/null +++ b/internal/asciigraph/asciigraph.go @@ -0,0 +1,316 @@ +package asciigraph + +import ( + "bytes" + "fmt" + "math" + "strings" +) + +// Plot returns ascii graph for a series. +func Plot(series []float64, options ...Option) string { + return PlotMany([][]float64{series}, options...) +} + +// PlotMany returns ascii graph for multiple series. +func PlotMany(data [][]float64, options ...Option) string { + var logMaximum float64 + config := configure(config{ + Offset: 3, + Precision: 2, + AlwaysY: true, + }, options) + + // Create a deep copy of the input data + dataCopy := make([][]float64, len(data)) + for i, series := range data { + dataCopy[i] = make([]float64, len(series)) + copy(dataCopy[i], series) + } + data = dataCopy + + lenMax := 0 + for i := range data { + if l := len(data[i]); l > lenMax { + lenMax = l + } + } + + if config.Width > 0 { + for i := range data { + for j := len(data[i]); j < lenMax; j++ { + data[i] = append(data[i], math.NaN()) + } + data[i] = interpolateArray(data[i], config.Width) + } + + lenMax = config.Width + } + + minimum, maximum := math.Inf(1), math.Inf(-1) + for i := range data { + minVal, maxVal := minMaxFloat64Slice(data[i]) + if minVal < minimum { + minimum = minVal + } + if maxVal > maximum { + maximum = maxVal + } + } + + if config.LowerBound != nil && *config.LowerBound < minimum { + minimum = *config.LowerBound + } + if config.UpperBound != nil && *config.UpperBound > maximum { + maximum = *config.UpperBound + } + interval := math.Abs(maximum - minimum) + + if config.Height <= 0 { + config.Height = calculateHeight(interval) + } + + if config.Offset <= 0 { + config.Offset = 3 + } + + var ratio float64 + if interval != 0 { + ratio = float64(config.Height) / interval + } else { + ratio = 1 + } + min2 := round(minimum * ratio) + max2 := round(maximum * ratio) + + intmin2 := int(min2) + intmax2 := int(max2) + + rows := int(math.Abs(float64(intmax2 - intmin2))) + width := lenMax + config.Offset + + type cell struct { + Text string + Color AnsiColor + } + + if rows == 0 && config.AlwaysY { + rows = config.Height + intmax2 = config.Height + } + + // This guards against the extreme case where the window is very small + if rows < 0 { + rows = 0 + } + + plot := make([][]cell, rows+1) + + // initialise empty 2D grid + for i := 0; i < rows+1; i++ { + line := make([]cell, width) + for j := 0; j < width; j++ { + line[j].Text = " " + line[j].Color = Default + } + plot[i] = line + } + + precision := config.Precision + logMaximum = math.Log10(math.Max(math.Abs(maximum), math.Abs(minimum))) //to find number of zeros after decimal + if minimum == float64(0) && maximum == float64(0) { + logMaximum = float64(-1) + } + + if logMaximum < 0 { + // negative log + if math.Mod(logMaximum, 1) != 0 { + // non-zero digits after decimal + precision += uint(math.Abs(logMaximum)) + } else { + precision += uint(math.Abs(logMaximum) - 1.0) + } + } else if logMaximum > 2 { + precision = 0 + } + + maxNumLength, minNumLength := 0, math.MaxInt64 + var magnitudes []float64 + + if config.ValueFormatter == nil { + maxNumLength = len(fmt.Sprintf("%0.*f", precision, maximum)) + minNumLength = len(fmt.Sprintf("%0.*f", precision, minimum)) + } + + // calculate label magnitudes and the length when formatted using the ValueFormatter + for y := intmin2; y < intmax2+1; y++ { + var magnitude float64 + if rows > 0 { + magnitude = maximum - (float64(y-intmin2) * interval / float64(rows)) + } else { + magnitude = float64(y) + } + magnitudes = append(magnitudes, magnitude) + + if config.ValueFormatter != nil { + l := len(config.ValueFormatter(magnitude)) + if l > maxNumLength { + maxNumLength = l + } + if l < minNumLength { + minNumLength = l + } + } + } + maxWidth := int(math.Max(float64(maxNumLength), float64(minNumLength))) + + // Protect us from infinity... + if maxWidth < 0 { + maxWidth = 0 + } + + maxLabelLength := 0 + // axis and labels reusing the previously calculated magnitudes + for w, magnitude := range magnitudes { + var label string + if config.ValueFormatter == nil { + label = fmt.Sprintf("%*.*f", maxWidth+1, precision, magnitude) + } else { + val := config.ValueFormatter(magnitude) + label = strings.Repeat(" ", maxWidth+1-len(val)) + val + } + + h := int(math.Max(float64(config.Offset)-float64(len(label)), 0)) + + labelLength := len(label) + if labelLength > maxLabelLength { + maxLabelLength = labelLength + } + + plot[w][h].Text = label + plot[w][h].Color = config.LabelColor + plot[w][config.Offset-1].Text = "┤" + plot[w][config.Offset-1].Color = config.AxisColor + } + + width -= maxLabelLength + + for i := range data { + series := data[i] + + color := Default + if i < len(config.SeriesColors) { + color = config.SeriesColors[i] + } + + var y0, y1 int + + if !math.IsNaN(series[0]) { + y0 = int(round(series[0]*ratio) - min2) + plot[rows-y0][config.Offset-1].Text = "┼" // first value + plot[rows-y0][config.Offset-1].Color = config.AxisColor + } + + for x := 0; x < len(series)-1; x++ { // plot the line + d0 := series[x] + d1 := series[x+1] + + if math.IsNaN(d0) && math.IsNaN(d1) { + continue + } + + if math.IsNaN(d1) && !math.IsNaN(d0) { + y0 = int(round(d0*ratio) - float64(intmin2)) + plot[rows-y0][x+config.Offset].Text = "╴" + plot[rows-y0][x+config.Offset].Color = color + continue + } + + if math.IsNaN(d0) && !math.IsNaN(d1) { + y1 = int(round(d1*ratio) - float64(intmin2)) + plot[rows-y1][x+config.Offset].Text = "╶" + plot[rows-y1][x+config.Offset].Color = color + continue + } + + y0 = int(round(d0*ratio) - float64(intmin2)) + y1 = int(round(d1*ratio) - float64(intmin2)) + + if y0 == y1 { + plot[rows-y0][x+config.Offset].Text = "─" + } else { + if y0 > y1 { + plot[rows-y1][x+config.Offset].Text = "╰" + plot[rows-y0][x+config.Offset].Text = "╮" + } else { + plot[rows-y1][x+config.Offset].Text = "╭" + plot[rows-y0][x+config.Offset].Text = "╯" + } + + start := int(math.Min(float64(y0), float64(y1))) + 1 + end := int(math.Max(float64(y0), float64(y1))) + for y := start; y < end; y++ { + plot[rows-y][x+config.Offset].Text = "│" + } + } + + start := int(math.Min(float64(y0), float64(y1))) + end := int(math.Max(float64(y0), float64(y1))) + for y := start; y <= end; y++ { + plot[rows-y][x+config.Offset].Color = color + } + } + } + + // join columns + var lines bytes.Buffer + for h, horizontal := range plot { + if h != 0 { + lines.WriteRune('\n') + } + + // remove trailing spaces + lastCharIndex := 0 + for i := width - 1; i >= 0; i-- { + if horizontal[i].Text != " " { + lastCharIndex = i + break + } + } + + c := Default + for _, v := range horizontal[:lastCharIndex+1] { + if v.Color != c { + c = v.Color + lines.WriteString(c.String()) + } + + lines.WriteString(v.Text) + } + if c != Default { + lines.WriteString(Default.String()) + } + } + + // add caption if not empty + if config.Caption != "" { + lines.WriteRune('\n') + lines.WriteString(strings.Repeat(" ", config.Offset+maxWidth)) + if len(config.Caption) < lenMax { + lines.WriteString(strings.Repeat(" ", (lenMax-len(config.Caption)-maxLabelLength)/2)) + } + if config.CaptionColor != Default { + lines.WriteString(config.CaptionColor.String()) + } + lines.WriteString(config.Caption) + if config.CaptionColor != Default { + lines.WriteString(Default.String()) + } + } + + if len(config.SeriesLegends) > 0 { + addLegends(&lines, config, lenMax, config.Offset+maxWidth) + } + + return lines.String() +} diff --git a/internal/asciigraph/asciigraph_test.go b/internal/asciigraph/asciigraph_test.go new file mode 100644 index 00000000..db0a0203 --- /dev/null +++ b/internal/asciigraph/asciigraph_test.go @@ -0,0 +1,451 @@ +package asciigraph + +import ( + "fmt" + "math" + "strings" + "testing" +) + +func TestPlot(t *testing.T) { + cases := []struct { + data []float64 + opts []Option + expected string + }{ + + { + []float64{1, 1, 1, 1, 1}, + []Option{AlwaysY(false)}, + ` 1.00 ┼────`}, + { + []float64{0, 0, 0, 0, 0}, + []Option{AlwaysY(false)}, + ` 0.00 ┼────`}, + { + []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 1}, + []Option{AlwaysY(false)}, + ` + 11.00 ┤ ╭╮ + 10.00 ┤ ││ + 9.00 ┤ ││ + 8.00 ┤ ││ + 7.00 ┤ ╭╯│╭╮ + 6.00 ┤ │ │││ + 5.00 ┤ ╭╯ │││ + 4.00 ┤ │ │││ + 3.00 ┤ │ ╰╯│ + 2.00 ┼╮ ╭╮│ │ + 1.00 ┤╰─╯││ ╰ + 0.00 ┤ ││ + -1.00 ┤ ││ + -2.00 ┤ ╰╯`}, + { + []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 4, 5, 6, 9, 4, 0, 6, 1, 5, 3, 6, 2}, + []Option{Caption("Plot using asciigraph."), AlwaysY(false)}, + ` + 11.00 ┤ ╭╮ + 10.00 ┤ ││ + 9.00 ┤ ││ ╭╮ + 8.00 ┤ ││ ││ + 7.00 ┤ ╭╯│╭╮ ││ + 6.00 ┤ │ │││ ╭╯│ ╭╮ ╭╮ + 5.00 ┤ ╭╯ │││╭╯ │ ││╭╮││ + 4.00 ┤ │ ││╰╯ ╰╮││││││ + 3.00 ┤ │ ╰╯ ││││╰╯│ + 2.00 ┼╮ ╭╮│ ││││ ╰ + 1.00 ┤╰─╯││ ││╰╯ + 0.00 ┤ ││ ╰╯ + -1.00 ┤ ││ + -2.00 ┤ ╰╯ + Plot using asciigraph.`}, + { + []float64{.2, .1, .2, 2, -.9, .7, .91, .3, .7, .4, .5}, + []Option{Caption("Plot using asciigraph."), AlwaysY(false)}, + ` + 2.00 ┤ ╭╮ ╭╮ + 0.55 ┼──╯│╭╯╰─── + -0.90 ┤ ╰╯ + Plot using asciigraph.`}, + { + []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 1}, + []Option{Height(4), Offset(3), AlwaysY(false)}, + ` + 11.00 ┤ ╭╮ + 7.75 ┤ ╭─╯│╭╮ + 4.50 ┼╮ ╭╮│ ╰╯│ + 1.25 ┤╰─╯││ ╰ + -2.00 ┤ ╰╯`}, + { + []float64{.453, .141, .951, .251, .223, .581, .771, .191, .393, .617, .478}, + []Option{AlwaysY(false)}, + ` + 0.95 ┤ ╭╮ + 0.85 ┤ ││ ╭╮ + 0.75 ┤ ││ ││ + 0.65 ┤ ││ ╭╯│ ╭╮ + 0.55 ┤ ││ │ │ │╰ + 0.44 ┼╮││ │ │╭╯ + 0.34 ┤│││ │ ││ + 0.24 ┤││╰─╯ ╰╯ + 0.14 ┤╰╯`}, + + { + []float64{.01, .004, .003, .0042, .0083, .0033, 0.0079}, + []Option{AlwaysY(false)}, + ` + 0.010 ┼╮ + 0.009 ┤│ + 0.008 ┤│ ╭╮╭ + 0.007 ┤│ │││ + 0.006 ┤│ │││ + 0.005 ┤│ │││ + 0.004 ┤╰╮╭╯││ + 0.003 ┤ ╰╯ ╰╯`}, + + { + []float64{192, 431, 112, 449, -122, 375, 782, 123, 911, 1711, 172}, + []Option{Height(10), AlwaysY(false)}, + ` + 1711 ┤ ╭╮ + 1528 ┤ ││ + 1344 ┤ ││ + 1161 ┤ ││ + 978 ┤ ╭╯│ + 794 ┤ ╭╮│ │ + 611 ┤ │││ │ + 428 ┤╭╮╭╮╭╯││ │ + 245 ┼╯╰╯││ ╰╯ ╰ + 61 ┤ ││ + -122 ┤ ╰╯`}, + { + []float64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + []Option{Height(10), AlwaysY(true)}, + ` + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┼──────────`}, + { + []float64{0.3189989805, 0.149949026, 0.30142492354, 0.195129182935, 0.3142492354, 0.1674974513, 0.3142492354, 0.1474974513, 0.3047974513}, + []Option{Width(30), Height(5), Caption("Plot with custom height & width."), AlwaysY(false)}, + ` + 0.32 ┼╮ ╭─╮ ╭╮ ╭ + 0.29 ┤╰╮ ╭─╮ ╭╯ │ ╭╯│ │ + 0.26 ┤ │ ╭╯ ╰╮ ╭╯ ╰╮ ╭╯ ╰╮ ╭╯ + 0.23 ┤ ╰╮ ╭╯ ╰╮│ ╰╮╭╯ ╰╮ ╭╯ + 0.20 ┤ ╰╮│ ╰╯ ╰╯ │╭╯ + 0.16 ┤ ╰╯ ╰╯ + Plot with custom height & width.`}, + { + []float64{ + 0, 0, 0, 0, 1.5, 0, 0, -0.5, 9, -3, 0, 0, 1, 2, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 1.5, 0, 0, -0.5, 8, -3, 0, 0, 1, 2, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 1.5, 0, 0, -0.5, 10, -3, 0, 0, 1, 2, 1, 0, 0, 0, 0, + }, + []Option{Offset(10), Height(10), Caption("I'm a doctor, not an engineer."), AlwaysY(false)}, + ` + 10.00 ┤ ╭╮ + 8.70 ┤ ╭╮ ││ + 7.40 ┤ ││ ╭╮ ││ + 6.10 ┤ ││ ││ ││ + 4.80 ┤ ││ ││ ││ + 3.50 ┤ ││ ││ ││ + 2.20 ┤ ││ ╭╮ ││ ╭╮ ││ ╭╮ + 0.90 ┤ ╭╮ ││ ╭╯╰╮ ╭╮ ││ ╭╯╰╮ ╭╮ ││ ╭╯╰╮ + -0.40 ┼───╯╰──╯│╭─╯ ╰───────╯╰──╯│╭─╯ ╰───────╯╰──╯│╭─╯ ╰─── + -1.70 ┤ ││ ││ ││ + -3.00 ┤ ╰╯ ╰╯ ╰╯ + I'm a doctor, not an engineer.`}, + { + []float64{-5, -2, -3, -4, 0, -5, -6, -7, -8, 0, -9, -3, -5, -2, -9, -3, -1}, + []Option{AlwaysY(false)}, + ` + 0.00 ┤ ╭╮ ╭╮ + -1.00 ┤ ││ ││ ╭ + -2.00 ┤╭╮ ││ ││ ╭╮ │ + -3.00 ┤│╰╮││ ││╭╮││╭╯ + -4.00 ┤│ ╰╯│ │││││││ + -5.00 ┼╯ ╰╮ │││╰╯││ + -6.00 ┤ ╰╮ │││ ││ + -7.00 ┤ ╰╮│││ ││ + -8.00 ┤ ╰╯││ ││ + -9.00 ┤ ╰╯ ╰╯`}, + { + []float64{-0.000018527, -0.021, -.00123, .00000021312, -.0434321234, -.032413241234, .0000234234}, + []Option{Height(5), Width(45), AlwaysY(false)}, + ` + 0.000 ┼─╮ ╭────────╮ ╭ + -0.008 ┤ ╰──╮ ╭──╯ ╰─╮ ╭─╯ + -0.017 ┤ ╰─────╯ ╰╮ ╭─╯ + -0.025 ┤ ╰─╮ ╭─╯ + -0.034 ┤ ╰╮ ╭────╯ + -0.042 ┤ ╰───╯`}, + { + []float64{57.76, 54.04, 56.31, 57.02, 59.5, 52.63, 52.97, 56.44, 56.75, 52.96, 55.54, 55.09, 58.22, 56.85, 60.61, 59.62, 59.73, 59.93, 56.3, 54.69, 55.32, 54.03, 50.98, 50.48, 54.55, 47.49, 55.3, 46.74, 46, 45.8, 49.6, 48.83, 47.64, 46.61, 54.72, 42.77, 50.3, 42.79, 41.84, 44.19, 43.36, 45.62, 45.09, 44.95, 50.36, 47.21, 47.77, 52.04, 47.46, 44.19, 47.22, 45.55, 40.65, 39.64, 37.26, 40.71, 42.15, 36.45, 39.14, 36.62}, + []Option{Width(-10), Height(-10), Offset(-1), AlwaysY(false)}, + ` + 60.61 ┤ ╭╮ ╭╮ + 59.60 ┤ ╭╮ │╰─╯│ + 58.60 ┤ ││ ╭╮│ │ + 57.59 ┼╮ ╭╯│ │││ │ + 56.58 ┤│╭╯ │ ╭─╮ │╰╯ ╰╮ + 55.58 ┤││ │ │ │╭─╯ │╭╮ ╭╮ + 54.57 ┤╰╯ │ │ ││ ╰╯╰╮ ╭╮││ ╭╮ + 53.56 ┤ │╭╯ ╰╯ │ ││││ ││ + 52.56 ┤ ╰╯ │ ││││ ││ ╭╮ + 51.55 ┤ ╰╮││││ ││ ││ + 50.54 ┤ ╰╯│││ ││╭╮ ╭╮ ││ + 49.54 ┤ │││ ╭─╮ ││││ ││ ││ + 48.53 ┤ │││ │ │ ││││ ││ ││ + 47.52 ┤ ╰╯│ │ ╰╮││││ │╰─╯╰╮╭╮ + 46.52 ┤ ╰─╮│ ╰╯│││ │ │││ + 45.51 ┤ ╰╯ │││ ╭──╯ ││╰╮ + 44.50 ┤ │││ ╭╮│ ╰╯ │ + 43.50 ┤ ││╰╮│╰╯ │ + 42.49 ┤ ╰╯ ╰╯ │ ╭╮ + 41.48 ┤ │ ││ + 40.48 ┤ ╰╮ ╭╯│ + 39.47 ┤ ╰╮│ │╭╮ + 38.46 ┤ ││ │││ + 37.46 ┤ ╰╯ │││ + 36.45 ┤ ╰╯╰`}, + { + []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 4, 5, 6, 9, 4, 0, 6, 1, 5, 3, 6, 2}, + []Option{LowerBound(-3), UpperBound(13), AlwaysY(false)}, + ` 13.00 ┤ + 12.00 ┤ + 11.00 ┤ ╭╮ + 10.00 ┤ ││ + 9.00 ┤ ││ ╭╮ + 8.00 ┤ ││ ││ + 7.00 ┤ ╭╯│╭╮ ││ + 6.00 ┤ │ │││ ╭╯│ ╭╮ ╭╮ + 5.00 ┤ ╭╯ │││╭╯ │ ││╭╮││ + 4.00 ┤ │ ││╰╯ ╰╮││││││ + 3.00 ┤ │ ╰╯ ││││╰╯│ + 2.00 ┼╮ ╭╮│ ││││ ╰ + 1.00 ┤╰─╯││ ││╰╯ + 0.00 ┤ ││ ╰╯ + -1.00 ┤ ││ + -2.00 ┤ ╰╯ + -3.00 ┤`}, + { + []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 4, 5, 6, 9, 4, 0, 6, 1, 5, 3, 6, 2}, + []Option{LowerBound(0), UpperBound(3), AlwaysY(false)}, + ` 11.00 ┤ ╭╮ + 10.00 ┤ ││ + 9.00 ┤ ││ ╭╮ + 8.00 ┤ ││ ││ + 7.00 ┤ ╭╯│╭╮ ││ + 6.00 ┤ │ │││ ╭╯│ ╭╮ ╭╮ + 5.00 ┤ ╭╯ │││╭╯ │ ││╭╮││ + 4.00 ┤ │ ││╰╯ ╰╮││││││ + 3.00 ┤ │ ╰╯ ││││╰╯│ + 2.00 ┼╮ ╭╮│ ││││ ╰ + 1.00 ┤╰─╯││ ││╰╯ + 0.00 ┤ ││ ╰╯ + -1.00 ┤ ││ + -2.00 ┤ ╰╯`}, + + { + []float64{1, 1, math.NaN(), 1, 1}, + []Option{AlwaysY(false)}, + ` 1.00 ┼─╴╶─`}, + { + []float64{math.NaN(), 1}, + []Option{AlwaysY(false)}, + ` 1.00 ┤╶`}, + { + []float64{0, 0, 1, 1, math.NaN(), math.NaN(), 3, 3, 4}, + []Option{AlwaysY(false)}, + ` + 4.00 ┤ ╭ + 3.00 ┤ ╶─╯ + 2.00 ┤ + 1.00 ┤ ╭─╴ + 0.00 ┼─╯`}, + { + []float64{.1, .2, .3, math.NaN(), .5, .6, .7, math.NaN(), math.NaN(), .9, 1}, + []Option{AlwaysY(false)}, + ` + 1.00 ┤ ╭ + 0.90 ┤ ╶╯ + 0.80 ┤ + 0.70 ┤ ╭╴ + 0.60 ┤ ╭╯ + 0.50 ┤ ╶╯ + 0.40 ┤ + 0.30 ┤ ╭╴ + 0.20 ┤╭╯ + 0.10 ┼╯`}, + { + []float64{-0.000018527, -0.021, -.00123, .00000021312, -.0434321234, -.032413241234, .0000234234}, + []Option{Height(5), Width(45), Precision(5), AlwaysY(false)}, + ` + 0.000023 ┼─╮ ╭────────╮ ╭ + -0.008467 ┤ ╰──╮ ╭──╯ ╰─╮ ╭─╯ + -0.016958 ┤ ╰─────╯ ╰╮ ╭─╯ + -0.025449 ┤ ╰─╮ ╭─╯ + -0.033940 ┤ ╰╮ ╭────╯ + -0.042430 ┤ ╰───╯`}, + + { + []float64{math.NaN(), 1}, + []Option{Caption("color test"), CaptionColor(Red), AxisColor(Green), LabelColor(Blue), AlwaysY(false)}, + ` +\x1b[94m 1.00\x1b[0m \x1b[32m┤\x1b[0m╶ + \x1b[91mcolor test\x1b[0m`}, + { + []float64{.02, .03, .02}, + []Option{AlwaysY(false)}, + ` + 0.030 ┤╭╮ + 0.020 ┼╯╰`}, + { + []float64{.2, .3, .1, .3}, + []Option{AlwaysY(false)}, + ` + 0.30 ┤╭╮╭ + 0.20 ┼╯││ + 0.10 ┤ ╰╯`}, + { + []float64{70 * 1024 * 1024 * 1024, 90 * 1024 * 1024 * 1024, 80 * 1024 * 1024 * 1024, 2 * 1024 * 1024 * 1024}, + []Option{Height(5), Width(45), ValueFormatter(func(v any) string { + return fmt.Sprintf("%.2f Foo", v.(float64)/1024/1024/1024) + }), AlwaysY(false)}, + ` 89.77 Foo ┤ ╭──────────────────────╮ + 72.22 Foo ┼──────╯ ╰──╮ + 54.66 Foo ┤ ╰───╮ + 37.11 Foo ┤ ╰──╮ + 19.55 Foo ┤ ╰──╮ + 2.00 Foo ┤ ╰─`, + }, + } + + for i := range cases { + name := fmt.Sprintf("%d", i) + t.Run(name, func(t *testing.T) { + c := cases[i] + expected := strings.Replace(strings.TrimPrefix(c.expected, "\n"), `\x1b`, "\x1b", -1) + actual := Plot(c.data, c.opts...) + if actual != expected { + conf := configure(config{AlwaysY: false}, c.opts) + t.Errorf("Plot(%f, %#v)", c.data, conf) + t.Logf("expected:\n%s\n", expected) + } + t.Logf("actual:\n%s\n", actual) + }) + } +} + +func TestPlotMany(t *testing.T) { + cases := []struct { + data [][]float64 + opts []Option + expected string + }{ + { + [][]float64{{0}, {1}, {2}}, + nil, + ` + 2.00 ┼ + 1.00 ┼ + 0.00 ┼`}, + { + [][]float64{{0, 0, 2, 2, math.NaN()}, {1, 1, 1, 1, 1, 1, 1}, {math.NaN(), math.NaN(), math.NaN(), 0, 0, 2, 2}}, + nil, + ` + 2.00 ┤ ╭─╴╭─ + 1.00 ┼────│─ + 0.00 ┼─╯╶─╯`}, + { + [][]float64{{0, 0, 0}, {math.NaN(), 0, 0}, {math.NaN(), math.NaN(), 0}}, + nil, + ` 0.00 ┼╶╶`}, + { + [][]float64{{0, 1, 0}, {2, 3, 4, 3, 2}, {4, 5, 6, 7, 6, 5, 4}}, + []Option{Width(21), Caption("interpolation test"), AlwaysY(false)}, + ` + 7.00 ┤ ╭──╮ + 6.00 ┤ ╭───╯ ╰───╮ + 5.00 ┤ ╭──╯ ╰──╮ + 4.00 ┼─╯ ╭───╮ ╰─ + 3.00 ┤ ╭──╯ ╰──╮ + 2.00 ┼─╯ ╰─╴ + 1.00 ┤ ╭───╮ + 0.00 ┼─╯ ╰╴ + interpolation test`}, + + { + [][]float64{{0, 0}, {math.NaN(), 0}}, + []Option{SeriesColors(Red), AlwaysY(false)}, + " 0.00 ┼╶"}, + { + [][]float64{{0, 0}, {math.NaN(), 0}}, + []Option{SeriesColors(Default, Red), AlwaysY(false)}, + " 0.00 ┼\x1b[91m╶\x1b[0m"}, + { + [][]float64{{math.NaN(), 0, 2}, {0, 2}}, + []Option{SeriesColors(Red, Red), AlwaysY(false)}, + ` + 2.00 ┤\x1b[91m╭╭\x1b[0m + 1.00 ┤\x1b[91m││\x1b[0m + 0.00 ┼\x1b[91m╯╯\x1b[0m`}, + { + [][]float64{{0, 1, 0}, {2, 3, 4, 3, 2}}, + []Option{SeriesColors(Red, Blue), SeriesLegends("Red", "Blue"), + Caption("legends with caption test"), AlwaysY(false)}, + ` + 4.00 ┤ ╭╮ + 3.00 ┤╭╯╰╮ + 2.00 ┼╯ ╰ + 1.00 ┤╭╮ + 0.00 ┼╯╰ + legends with caption test + + ■ Red ■ Blue`}, + } + + for i := range cases { + name := fmt.Sprintf("%d", i) + t.Run(name, func(t *testing.T) { + c := cases[i] + expected := strings.Replace(strings.TrimPrefix(c.expected, "\n"), `\x1b`, "\x1b", -1) + actual := PlotMany(c.data, c.opts...) + if actual != expected { + conf := configure(config{}, c.opts) + t.Errorf("Plot(%f, %#v)", c.data, conf) + t.Logf("expected:\n%s\n", expected) + } + t.Logf("actual:\n%s\n", actual) + }) + } +} + +func BenchmarkPlot(b *testing.B) { + data := []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 1} + opts := []Option{Height(4), Offset(3)} + + for i := 0; i < b.N; i++ { + Plot(data, opts...) + } +} + +func BenchmarkPlotMany(b *testing.B) { + data1 := []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 1} + data2 := []float64{5, 3, 2, 7, 1, -2, 9, 4, 3, 2, 1} + opts := []Option{Height(4), Offset(3)} + datasets := [][]float64{data1, data2} + + for i := 0; i < b.N; i++ { + PlotMany(datasets, opts...) + } +} diff --git a/internal/asciigraph/color.go b/internal/asciigraph/color.go new file mode 100644 index 00000000..1c828e52 --- /dev/null +++ b/internal/asciigraph/color.go @@ -0,0 +1,312 @@ +package asciigraph + +import "fmt" + +type AnsiColor byte + +var ( + Default AnsiColor = 0 + AliceBlue AnsiColor = 255 + AntiqueWhite AnsiColor = 255 + Aqua AnsiColor = 14 + Aquamarine AnsiColor = 122 + Azure AnsiColor = 15 + Beige AnsiColor = 230 + Bisque AnsiColor = 224 + Black AnsiColor = 188 // dummy value + BlanchedAlmond AnsiColor = 230 + Blue AnsiColor = 12 + BlueViolet AnsiColor = 92 + Brown AnsiColor = 88 + BurlyWood AnsiColor = 180 + CadetBlue AnsiColor = 73 + Chartreuse AnsiColor = 118 + Chocolate AnsiColor = 166 + Coral AnsiColor = 209 + CornflowerBlue AnsiColor = 68 + Cornsilk AnsiColor = 230 + Crimson AnsiColor = 161 + Cyan AnsiColor = 14 + DarkBlue AnsiColor = 18 + DarkCyan AnsiColor = 30 + DarkGoldenrod AnsiColor = 136 + DarkGray AnsiColor = 248 + DarkGreen AnsiColor = 22 + DarkKhaki AnsiColor = 143 + DarkMagenta AnsiColor = 90 + DarkOliveGreen AnsiColor = 59 + DarkOrange AnsiColor = 208 + DarkOrchid AnsiColor = 134 + DarkRed AnsiColor = 88 + DarkSalmon AnsiColor = 173 + DarkSeaGreen AnsiColor = 108 + DarkSlateBlue AnsiColor = 60 + DarkSlateGray AnsiColor = 238 + DarkTurquoise AnsiColor = 44 + DarkViolet AnsiColor = 92 + DeepPink AnsiColor = 198 + DeepSkyBlue AnsiColor = 39 + DimGray AnsiColor = 242 + DodgerBlue AnsiColor = 33 + Firebrick AnsiColor = 124 + FloralWhite AnsiColor = 15 + ForestGreen AnsiColor = 28 + Fuchsia AnsiColor = 13 + Gainsboro AnsiColor = 253 + GhostWhite AnsiColor = 15 + Gold AnsiColor = 220 + Goldenrod AnsiColor = 178 + Gray AnsiColor = 8 + Green AnsiColor = 2 + GreenYellow AnsiColor = 155 + Honeydew AnsiColor = 15 + HotPink AnsiColor = 205 + IndianRed AnsiColor = 167 + Indigo AnsiColor = 54 + Ivory AnsiColor = 15 + Khaki AnsiColor = 222 + Lavender AnsiColor = 254 + LavenderBlush AnsiColor = 255 + LawnGreen AnsiColor = 118 + LemonChiffon AnsiColor = 230 + LightBlue AnsiColor = 152 + LightCoral AnsiColor = 210 + LightCyan AnsiColor = 195 + LightGoldenrodYellow AnsiColor = 230 + LightGray AnsiColor = 252 + LightGreen AnsiColor = 120 + LightPink AnsiColor = 217 + LightSalmon AnsiColor = 216 + LightSeaGreen AnsiColor = 37 + LightSkyBlue AnsiColor = 117 + LightSlateGray AnsiColor = 103 + LightSteelBlue AnsiColor = 152 + LightYellow AnsiColor = 230 + Lime AnsiColor = 10 + LimeGreen AnsiColor = 77 + Linen AnsiColor = 255 + Magenta AnsiColor = 13 + Maroon AnsiColor = 1 + MediumAquamarine AnsiColor = 79 + MediumBlue AnsiColor = 20 + MediumOrchid AnsiColor = 134 + MediumPurple AnsiColor = 98 + MediumSeaGreen AnsiColor = 72 + MediumSlateBlue AnsiColor = 99 + MediumSpringGreen AnsiColor = 48 + MediumTurquoise AnsiColor = 80 + MediumVioletRed AnsiColor = 162 + MidnightBlue AnsiColor = 17 + MintCream AnsiColor = 15 + MistyRose AnsiColor = 224 + Moccasin AnsiColor = 223 + NavajoWhite AnsiColor = 223 + Navy AnsiColor = 4 + OldLace AnsiColor = 230 + Olive AnsiColor = 3 + OliveDrab AnsiColor = 64 + Orange AnsiColor = 214 + OrangeRed AnsiColor = 202 + Orchid AnsiColor = 170 + PaleGoldenrod AnsiColor = 223 + PaleGreen AnsiColor = 120 + PaleTurquoise AnsiColor = 159 + PaleVioletRed AnsiColor = 168 + PapayaWhip AnsiColor = 230 + PeachPuff AnsiColor = 223 + Peru AnsiColor = 173 + Pink AnsiColor = 218 + Plum AnsiColor = 182 + PowderBlue AnsiColor = 152 + Purple AnsiColor = 5 + Red AnsiColor = 9 + RosyBrown AnsiColor = 138 + RoyalBlue AnsiColor = 63 + SaddleBrown AnsiColor = 94 + Salmon AnsiColor = 210 + SandyBrown AnsiColor = 215 + SeaGreen AnsiColor = 29 + SeaShell AnsiColor = 15 + Sienna AnsiColor = 131 + Silver AnsiColor = 7 + SkyBlue AnsiColor = 117 + SlateBlue AnsiColor = 62 + SlateGray AnsiColor = 66 + Snow AnsiColor = 15 + SpringGreen AnsiColor = 48 + SteelBlue AnsiColor = 67 + Tan AnsiColor = 180 + Teal AnsiColor = 6 + Thistle AnsiColor = 182 + Tomato AnsiColor = 203 + Turquoise AnsiColor = 80 + Violet AnsiColor = 213 + Wheat AnsiColor = 223 + White AnsiColor = 15 + WhiteSmoke AnsiColor = 255 + Yellow AnsiColor = 11 + YellowGreen AnsiColor = 149 +) + +var ColorNames = map[string]AnsiColor{ + "default": Default, + "aliceblue": AliceBlue, + "antiquewhite": AntiqueWhite, + "aqua": Aqua, + "aquamarine": Aquamarine, + "azure": Azure, + "beige": Beige, + "bisque": Bisque, + "black": Black, + "blanchedalmond": BlanchedAlmond, + "blue": Blue, + "blueviolet": BlueViolet, + "brown": Brown, + "burlywood": BurlyWood, + "cadetblue": CadetBlue, + "chartreuse": Chartreuse, + "chocolate": Chocolate, + "coral": Coral, + "cornflowerblue": CornflowerBlue, + "cornsilk": Cornsilk, + "crimson": Crimson, + "cyan": Cyan, + "darkblue": DarkBlue, + "darkcyan": DarkCyan, + "darkgoldenrod": DarkGoldenrod, + "darkgray": DarkGray, + "darkgreen": DarkGreen, + "darkkhaki": DarkKhaki, + "darkmagenta": DarkMagenta, + "darkolivegreen": DarkOliveGreen, + "darkorange": DarkOrange, + "darkorchid": DarkOrchid, + "darkred": DarkRed, + "darksalmon": DarkSalmon, + "darkseagreen": DarkSeaGreen, + "darkslateblue": DarkSlateBlue, + "darkslategray": DarkSlateGray, + "darkturquoise": DarkTurquoise, + "darkviolet": DarkViolet, + "deeppink": DeepPink, + "deepskyblue": DeepSkyBlue, + "dimgray": DimGray, + "dodgerblue": DodgerBlue, + "firebrick": Firebrick, + "floralwhite": FloralWhite, + "forestgreen": ForestGreen, + "fuchsia": Fuchsia, + "gainsboro": Gainsboro, + "ghostwhite": GhostWhite, + "gold": Gold, + "goldenrod": Goldenrod, + "gray": Gray, + "green": Green, + "greenyellow": GreenYellow, + "honeydew": Honeydew, + "hotpink": HotPink, + "indianred": IndianRed, + "indigo": Indigo, + "ivory": Ivory, + "khaki": Khaki, + "lavender": Lavender, + "lavenderblush": LavenderBlush, + "lawngreen": LawnGreen, + "lemonchiffon": LemonChiffon, + "lightblue": LightBlue, + "lightcoral": LightCoral, + "lightcyan": LightCyan, + "lightgoldenrodyellow": LightGoldenrodYellow, + "lightgray": LightGray, + "lightgreen": LightGreen, + "lightpink": LightPink, + "lightsalmon": LightSalmon, + "lightseagreen": LightSeaGreen, + "lightskyblue": LightSkyBlue, + "lightslategray": LightSlateGray, + "lightsteelblue": LightSteelBlue, + "lightyellow": LightYellow, + "lime": Lime, + "limegreen": LimeGreen, + "linen": Linen, + "magenta": Magenta, + "maroon": Maroon, + "mediumaquamarine": MediumAquamarine, + "mediumblue": MediumBlue, + "mediumorchid": MediumOrchid, + "mediumpurple": MediumPurple, + "mediumseagreen": MediumSeaGreen, + "mediumslateblue": MediumSlateBlue, + "mediumspringgreen": MediumSpringGreen, + "mediumturquoise": MediumTurquoise, + "mediumvioletred": MediumVioletRed, + "midnightblue": MidnightBlue, + "mintcream": MintCream, + "mistyrose": MistyRose, + "moccasin": Moccasin, + "navajowhite": NavajoWhite, + "navy": Navy, + "oldlace": OldLace, + "olive": Olive, + "olivedrab": OliveDrab, + "orange": Orange, + "orangered": OrangeRed, + "orchid": Orchid, + "palegoldenrod": PaleGoldenrod, + "palegreen": PaleGreen, + "paleturquoise": PaleTurquoise, + "palevioletred": PaleVioletRed, + "papayawhip": PapayaWhip, + "peachpuff": PeachPuff, + "peru": Peru, + "pink": Pink, + "plum": Plum, + "powderblue": PowderBlue, + "purple": Purple, + "red": Red, + "rosybrown": RosyBrown, + "royalblue": RoyalBlue, + "saddlebrown": SaddleBrown, + "salmon": Salmon, + "sandybrown": SandyBrown, + "seagreen": SeaGreen, + "seashell": SeaShell, + "sienna": Sienna, + "silver": Silver, + "skyblue": SkyBlue, + "slateblue": SlateBlue, + "slategray": SlateGray, + "snow": Snow, + "springgreen": SpringGreen, + "steelblue": SteelBlue, + "tan": Tan, + "teal": Teal, + "thistle": Thistle, + "tomato": Tomato, + "turquoise": Turquoise, + "violet": Violet, + "wheat": Wheat, + "white": White, + "whitesmoke": WhiteSmoke, + "yellow": Yellow, + "yellowgreen": YellowGreen, +} + +func (c AnsiColor) String() string { + if c == Default { + return "\x1b[0m" + } + if c == Black { + c = 0 + } + if c <= Silver { + // 3-bit color + return fmt.Sprintf("\x1b[%dm", 30+byte(c)) + } + if c <= White { + // 4-bit color + return fmt.Sprintf("\x1b[%dm", 82+byte(c)) + } + // 8-bit color + return fmt.Sprintf("\x1b[38;5;%dm", byte(c)) +} diff --git a/internal/asciigraph/legend.go b/internal/asciigraph/legend.go new file mode 100644 index 00000000..42d98982 --- /dev/null +++ b/internal/asciigraph/legend.go @@ -0,0 +1,45 @@ +package asciigraph + +import ( + "bytes" + "fmt" + "strings" + "unicode/utf8" +) + +// Create legend item as a colored box and text +func createLegendItem(text string, color AnsiColor) (string, int) { + return fmt.Sprintf( + "%s■%s %s", + color.String(), + Default.String(), + text, + ), + // Can't use len() because of AnsiColor, add 2 for box and space + utf8.RuneCountInString(text) + 2 +} + +// Add legend for each series added to the graph +func addLegends(lines *bytes.Buffer, config *config, lenMax int, leftPad int) { + lines.WriteString("\n\n") + lines.WriteString(strings.Repeat(" ", leftPad)) + + var legendsText string + var legendsTextLen int + rightPad := 3 + for i, text := range config.SeriesLegends { + item, itemLen := createLegendItem(text, config.SeriesColors[i]) + legendsText += item + legendsTextLen += itemLen + + if i < len(config.SeriesLegends)-1 { + legendsText += strings.Repeat(" ", rightPad) + legendsTextLen += rightPad + } + } + + if legendsTextLen < lenMax { + lines.WriteString(strings.Repeat(" ", (lenMax-legendsTextLen)/2)) + } + lines.WriteString(legendsText) +} diff --git a/internal/asciigraph/options.go b/internal/asciigraph/options.go new file mode 100644 index 00000000..18d723f4 --- /dev/null +++ b/internal/asciigraph/options.go @@ -0,0 +1,144 @@ +package asciigraph + +import ( + "strings" +) + +// Option represents a configuration setting. +type Option interface { + apply(c *config) +} + +// config holds various graph options +type config struct { + Width, Height int + LowerBound, UpperBound *float64 + Offset int + Caption string + Precision uint + CaptionColor AnsiColor + AxisColor AnsiColor + LabelColor AnsiColor + SeriesColors []AnsiColor + SeriesLegends []string + ValueFormatter NumberFormatter + AlwaysY bool +} + +type NumberFormatter func(any) string + +// An optionFunc applies an option. +type optionFunc func(*config) + +// apply implements the Option interface. +func (of optionFunc) apply(c *config) { of(c) } + +func configure(defaults config, options []Option) *config { + for _, o := range options { + o.apply(&defaults) + } + return &defaults +} + +// Width sets the graphs width. By default, the width of the graph is +// determined by the number of data points. If the value given is a +// positive number, the data points are interpolated on the x axis. +// Values <= 0 reset the width to the default value. +func Width(w int) Option { + return optionFunc(func(c *config) { + if w > 0 { + c.Width = w + } else { + c.Width = 0 + } + }) +} + +// Height sets the graphs height. +func Height(h int) Option { + return optionFunc(func(c *config) { + if h > 0 { + c.Height = h + } else { + c.Height = 0 + } + }) +} + +// LowerBound sets the graph's minimum value for the vertical axis. It will be ignored +// if the series contains a lower value. +func LowerBound(min float64) Option { + return optionFunc(func(c *config) { c.LowerBound = &min }) +} + +// UpperBound sets the graph's maximum value for the vertical axis. It will be ignored +// if the series contains a bigger value. +func UpperBound(max float64) Option { + return optionFunc(func(c *config) { c.UpperBound = &max }) +} + +// Offset sets the graphs offset. +func Offset(o int) Option { + return optionFunc(func(c *config) { c.Offset = o }) +} + +// Precision sets the graphs precision. +func Precision(p uint) Option { + return optionFunc(func(c *config) { c.Precision = p }) +} + +// Caption sets the graphs caption. +func Caption(caption string) Option { + return optionFunc(func(c *config) { + c.Caption = strings.TrimSpace(caption) + }) +} + +// CaptionColor sets the caption color. +func CaptionColor(ac AnsiColor) Option { + return optionFunc(func(c *config) { + c.CaptionColor = ac + }) +} + +// AxisColor sets the axis color. +func AxisColor(ac AnsiColor) Option { + return optionFunc(func(c *config) { + c.AxisColor = ac + }) +} + +// LabelColor sets the axis label color. +func LabelColor(ac AnsiColor) Option { + return optionFunc(func(c *config) { + c.LabelColor = ac + }) +} + +// SeriesColors sets the series colors. +func SeriesColors(ac ...AnsiColor) Option { + return optionFunc(func(c *config) { + c.SeriesColors = ac + }) +} + +// SeriesLegends sets the legend text for the corresponding series. +func SeriesLegends(text ...string) Option { + return optionFunc(func(c *config) { + c.SeriesLegends = text + }) +} + +// ValueFormatter formats values printed to the side of graphs +func ValueFormatter(f NumberFormatter) Option { + return optionFunc(func(c *config) { + c.ValueFormatter = f + }) +} + +// AxisColor sets the axis color. +func AlwaysY(ay bool) Option { + return optionFunc(func(c *config) { + c.AlwaysY = ay + }) +} diff --git a/internal/asciigraph/utils.go b/internal/asciigraph/utils.go new file mode 100644 index 00000000..fdade455 --- /dev/null +++ b/internal/asciigraph/utils.go @@ -0,0 +1,104 @@ +package asciigraph + +import ( + "fmt" + "log" + "math" + "os" + "os/exec" + "runtime" +) + +func minMaxFloat64Slice(v []float64) (min, max float64) { + min = math.Inf(1) + max = math.Inf(-1) + + if len(v) == 0 { + panic("Empty slice") + } + + for _, e := range v { + if e < min { + min = e + } + if e > max { + max = e + } + } + return +} + +func round(input float64) float64 { + if math.IsNaN(input) { + return math.NaN() + } + sign := 1.0 + if input < 0 { + sign = -1 + input *= -1 + } + _, decimal := math.Modf(input) + var rounded float64 + if decimal >= 0.5 { + rounded = math.Ceil(input) + } else { + rounded = math.Floor(input) + } + return rounded * sign +} + +func linearInterpolate(before, after, atPoint float64) float64 { + return before + (after-before)*atPoint +} + +func interpolateArray(data []float64, fitCount int) []float64 { + var interpolatedData []float64 + + springFactor := float64(len(data)-1) / float64(fitCount-1) + interpolatedData = append(interpolatedData, data[0]) + + for i := 1; i < fitCount-1; i++ { + spring := float64(i) * springFactor + before := math.Floor(spring) + after := math.Ceil(spring) + atPoint := spring - before + interpolatedData = append(interpolatedData, linearInterpolate(data[int(before)], data[int(after)], atPoint)) + } + interpolatedData = append(interpolatedData, data[len(data)-1]) + return interpolatedData +} + +// clear terminal screen +var Clear func() + +func init() { + platform := runtime.GOOS + + if platform == "windows" { + Clear = func() { + cmd := exec.Command("cmd", "/c", "cls") + cmd.Stdout = os.Stdout + if err := cmd.Run(); err != nil { + log.Fatal(err) + } + } + } else { + Clear = func() { + fmt.Print("\033[2J\033[H") + } + } +} + +func calculateHeight(interval float64) int { + if interval >= 1 { + return int(interval) + } + + scaleFactor := math.Pow(10, math.Floor(math.Log10(interval))) + scaledDelta := interval / scaleFactor + + if scaledDelta < 2 { + return int(math.Ceil(scaledDelta)) + } + return int(math.Floor(scaledDelta)) +} diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index 70fd11bb..7e9547ae 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -69,7 +69,8 @@ func FromFile(file string) (*Bundle, error) { // FromUrl reads a bundle from a http(s) URL func FromUrl(url *url.URL) (*Bundle, error) { if url.Scheme == "fs" { - return FromFs(Store, filepath.Join("store", url.Path)) + // fs requires unix paths even on windows + return FromFs(Store, strings.ReplaceAll(filepath.Join("store", url.Path), `\`, `/`)) } res, err := http.Get(url.String()) diff --git a/internal/sysclient/healthstatus.go b/internal/sysclient/healthstatus.go new file mode 100644 index 00000000..6a2f5d35 --- /dev/null +++ b/internal/sysclient/healthstatus.go @@ -0,0 +1,83 @@ +// Copyright 2024 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sysclient + +import ( + "encoding/json" + "fmt" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/natscli/internal/util" +) + +const ( + StatusOK HealthStatus = iota + StatusUnavailable + StatusError +) + +type ( + HealthzResp struct { + Server server.ServerInfo `json:"server"` + Healthz Healthz `json:"data"` + } + + Healthz struct { + Status HealthStatus `json:"status"` + Error string `json:"error,omitempty"` + } + + HealthStatus int +) + +func (hs *HealthStatus) UnmarshalJSON(data []byte) error { + switch string(data) { + case util.JSONString("ok"): + *hs = StatusOK + case util.JSONString("na"), util.JSONString("unavailable"): + *hs = StatusUnavailable + case util.JSONString("error"): + *hs = StatusError + default: + return fmt.Errorf("cannot unmarshal %q", data) + } + + return nil +} + +func (hs HealthStatus) MarshalJSON() ([]byte, error) { + switch hs { + case StatusOK: + return json.Marshal("ok") + case StatusUnavailable: + return json.Marshal("na") + case StatusError: + return json.Marshal("error") + default: + return nil, fmt.Errorf("unknown health status: %v", hs) + } +} + +func (hs HealthStatus) String() string { + switch hs { + case StatusOK: + return "ok" + case StatusUnavailable: + return "na" + case StatusError: + return "error" + default: + return "unknown health status" + } +} diff --git a/internal/sysclient/sysclient.go b/internal/sysclient/sysclient.go new file mode 100644 index 00000000..a235cf05 --- /dev/null +++ b/internal/sysclient/sysclient.go @@ -0,0 +1,232 @@ +// Copyright 2024 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sysclient + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "time" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" +) + +const ( + srvJszSubj = "$SYS.REQ.SERVER.%s.JSZ" + // Healthz checks server health status. + srvHealthzSubj = "$SYS.REQ.SERVER.%s.HEALTHZ" + DefaultRequestTimeout = 60 * time.Second +) + +var ( + ErrValidation = errors.New("validation error") + ErrInvalidServerID = errors.New("server with given ID does not exist") +) + +type ( + // SysClient can be used to request monitoring data from the server. + // This is used by the stream-check and consumer-check commands + SysClient struct { + nc *nats.Conn + } + + FetchOpts struct { + Timeout time.Duration + ReadTimeout time.Duration + Expected int + } + + FetchOpt func(*FetchOpts) error + + JSZResp struct { + Server server.ServerInfo `json:"server"` + JSInfo server.JSInfo `json:"data"` + } +) + +func New(nc *nats.Conn) SysClient { + return SysClient{ + nc: nc, + } +} + +func (s *SysClient) JszPing(opts server.JszEventOptions, fopts ...FetchOpt) ([]JSZResp, error) { + subj := fmt.Sprintf(srvJszSubj, "PING") + payload, err := json.Marshal(opts) + if err != nil { + return nil, err + } + resp, err := s.Fetch(subj, payload, fopts...) + if err != nil { + return nil, err + } + srvJsz := make([]JSZResp, 0, len(resp)) + for _, msg := range resp { + var jszResp JSZResp + if err := json.Unmarshal(msg.Data, &jszResp); err != nil { + return nil, err + } + srvJsz = append(srvJsz, jszResp) + } + return srvJsz, nil +} + +func (s *SysClient) Fetch(subject string, data []byte, opts ...FetchOpt) ([]*nats.Msg, error) { + if subject == "" { + return nil, fmt.Errorf("%w: expected subject 0", ErrValidation) + } + + conn := s.nc + reqOpts := &FetchOpts{} + for _, opt := range opts { + if err := opt(reqOpts); err != nil { + return nil, err + } + } + + inbox := nats.NewInbox() + res := make([]*nats.Msg, 0) + msgsChan := make(chan *nats.Msg, 100) + + readTimer := time.NewTimer(reqOpts.ReadTimeout) + sub, err := conn.Subscribe(inbox, func(msg *nats.Msg) { + readTimer.Reset(reqOpts.ReadTimeout) + msgsChan <- msg + }) + defer sub.Unsubscribe() + + if err := conn.PublishRequest(subject, inbox, data); err != nil { + return nil, err + } + + for { + select { + case msg := <-msgsChan: + if msg.Header.Get("Status") == "503" { + return nil, fmt.Errorf("server request on subject %q failed: %w", subject, err) + } + res = append(res, msg) + if reqOpts.Expected != -1 && len(res) == reqOpts.Expected { + return res, nil + } + case <-readTimer.C: + return res, nil + case <-time.After(reqOpts.Timeout): + return res, nil + } + } +} + +func (s *SysClient) Healthz(id string, opts server.HealthzOptions) (*HealthzResp, error) { + if id == "" { + return nil, fmt.Errorf("%w: server id cannot be empty", ErrValidation) + } + subj := fmt.Sprintf(srvHealthzSubj, id) + payload, err := json.Marshal(opts) + if err != nil { + return nil, err + } + resp, err := s.nc.Request(subj, payload, DefaultRequestTimeout) + if err != nil { + if errors.Is(err, nats.ErrNoResponders) { + return nil, fmt.Errorf("%w: %s", ErrInvalidServerID, id) + } + return nil, err + } + var healthzResp HealthzResp + if err := json.Unmarshal(resp.Data, &healthzResp); err != nil { + return nil, err + } + + return &healthzResp, nil +} + +func (s *SysClient) FindServers(stdin bool, expected int, timeout time.Duration, readTimeout time.Duration, getConsumer bool) ([]JSZResp, error) { + var err error + servers := []JSZResp{} + + if stdin { + reader := bufio.NewReader(os.Stdin) + + for i := 0; i < expected; i++ { + data, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return servers, err + } + if len(data) > 0 { + var jszResp JSZResp + if err := json.Unmarshal([]byte(data), &jszResp); err != nil { + return servers, err + } + servers = append(servers, jszResp) + } + } + } else { + jszOptions := server.JSzOptions{ + Streams: true, + RaftGroups: true, + } + + if getConsumer { + jszOptions.Consumer = true + } + + fetchTimeout := fetchTimeout(timeout * time.Second) + fetchExpected := fetchExpected(expected) + fetchReadTimeout := fetchReadTimeout(readTimeout * time.Second) + servers, err = s.JszPing(server.JszEventOptions{ + JSzOptions: jszOptions, + }, fetchTimeout, fetchReadTimeout, fetchExpected) + if err != nil { + log.Fatal(err) + } + } + + return servers, nil +} + +func fetchTimeout(timeout time.Duration) FetchOpt { + return func(opts *FetchOpts) error { + if timeout <= 0 { + return fmt.Errorf("%w: timeout has to be greater than 0", ErrValidation) + } + opts.Timeout = timeout + return nil + } +} + +func fetchReadTimeout(timeout time.Duration) FetchOpt { + return func(opts *FetchOpts) error { + if timeout <= 0 { + return fmt.Errorf("%w: read timeout has to be greater than 0", ErrValidation) + } + opts.ReadTimeout = timeout + return nil + } +} + +func fetchExpected(expected int) FetchOpt { + return func(opts *FetchOpts) error { + if expected <= 0 { + return fmt.Errorf("%w: expected request count has to be greater than 0", ErrValidation) + } + opts.Expected = expected + return nil + } +} diff --git a/internal/util/util.go b/internal/util/util.go index 10aa5a35..603b561d 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -21,6 +21,7 @@ import ( "io" "math" "os" + "os/exec" "reflect" "regexp" "sort" @@ -29,9 +30,10 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/dustin/go-humanize" - "github.com/guptarohit/asciigraph" + "github.com/google/shlex" "github.com/nats-io/jsm.go" "github.com/nats-io/nats.go" + "github.com/nats-io/natscli/internal/asciigraph" "github.com/nats-io/natscli/options" "golang.org/x/exp/constraints" @@ -425,3 +427,46 @@ func ProgressWidth() int { return w - 30 } } + +// JSONString returns a quoted string to be used as a JSON object +func JSONString(s string) string { + return "\"" + s + "\"" +} + +// Split the string into a command and its arguments. +func SplitCommand(s string) (string, []string, error) { + cmdAndArgs, err := shlex.Split(s) + if err != nil { + return "", nil, err + } + + cmd := cmdAndArgs[0] + args := cmdAndArgs[1:] + return cmd, args, nil +} + +// Edit the file at filepath f using the environment variable EDITOR command. +func EditFile(f string) error { + rawEditor := os.Getenv("EDITOR") + if rawEditor == "" { + return fmt.Errorf("set EDITOR environment variable to your chosen editor") + } + + editor, args, err := SplitCommand(rawEditor) + if err != nil { + return fmt.Errorf("could not parse EDITOR: %v", rawEditor) + } + + args = append(args, f) + cmd := exec.Command(editor, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err = cmd.Run() + if err != nil { + return fmt.Errorf("could not edit file %v: %s", f, err) + } + + return nil +} diff --git a/internal/util/util_test.go b/internal/util/util_test.go index 24c02db1..402d2169 100644 --- a/internal/util/util_test.go +++ b/internal/util/util_test.go @@ -14,6 +14,10 @@ package util import ( + "io" + "os" + "slices" + "strings" "testing" ) @@ -34,3 +38,83 @@ func TestMultipleSort(t *testing.T) { t.Fatalf("expected true") } } + +func TestSplitCommand(t *testing.T) { + cmd, args, err := SplitCommand("vim") + if err != nil { + t.Fatalf("Expected err to be nil, got %v", err) + } + if cmd != "vim" && len(args) != 0 { + t.Fatalf("Expected vim and [], got %v and %v", cmd, args) + } + + cmd, args, err = SplitCommand("code --wait") + if err != nil { + t.Fatalf("Expected err to be nil, got %v", err) + } + if cmd != "code" && !slices.Equal(args, []string{"--wait"}) { + t.Fatalf("Expected code and [\"--wait\"], got %v and %v", cmd, args) + } + + cmd, args, err = SplitCommand("code --wait --new-window") + if err != nil { + t.Fatalf("Expected err to be nil, got %v", err) + } + if cmd != "code" && !slices.Equal(args, []string{"--wait", "--new-window"}) { + t.Fatalf("Expected code and [\"--wait\", \"--new-window\"], got %v and %v", cmd, args) + } + + // EOF found when expecting closing quote + _, _, err = SplitCommand("foo --bar 'hello") + if err == nil { + t.Fatal("Expected err to not be nil, got nil") + } +} + +func TestEditFile(t *testing.T) { + r, w, _ := os.Pipe() + os.Stdout = w + defer r.Close() + defer w.Close() + + f, err := os.CreateTemp("", "test_edit_file") + if err != nil { + t.Fatalf("Expected err to be nil, got %v", err) + } + defer f.Close() + defer os.Remove(f.Name()) + + t.Run("EDITOR unset", func(t *testing.T) { + os.Unsetenv("EDITOR") + err := EditFile("") + if err == nil { + t.Fatal("Expected err to not be nil, got nil") + } + }) + + t.Run("EDITOR set", func(t *testing.T) { + os.Setenv("EDITOR", "echo") + err := EditFile(f.Name()) + if err != nil { + t.Fatalf("Expected err to be nil, got %v", err) + } + + w.Close() + stdout, err := io.ReadAll(r) + if err != nil { + t.Fatalf("Expected err to be nil, got %v", err) + } + r.Close() + + actual := string(stdout) + lines := strings.Split(actual, "\n") + + if len(lines) != 2 || lines[1] != "" { + t.Fatalf("Expected one line of output, got %v", actual) + } + + if !strings.Contains(lines[0], "test_edit_file") { + t.Fatalf("Expected echo output, got %v", actual) + } + }) +} diff --git a/options/options.go b/options/options.go index 867306c6..9a027f98 100644 --- a/options/options.go +++ b/options/options.go @@ -14,10 +14,12 @@ package options import ( + "time" + "github.com/nats-io/jsm.go" "github.com/nats-io/jsm.go/natscontext" "github.com/nats-io/nats.go" - "time" + "github.com/nats-io/nats.go/jetstream" ) var DefaultOptions *Options @@ -63,7 +65,7 @@ type Options struct { // Mgr sets a prepared jsm Manager to use for JetStream access Mgr *jsm.Manager // JSc is a prepared NATS JetStream context to use for KV and Object access - JSc nats.JetStreamContext + JSc jetstream.JetStream // Disables registering of CLI cheats NoCheats bool // PrometheusNamespace is the namespace to use for prometheus format output in server check