From e3b96b3e3bef570242dd59f17923c6a7cf1f2457 Mon Sep 17 00:00:00 2001 From: "T. von Dein" Date: Wed, 1 Jul 2026 13:13:16 +0200 Subject: [PATCH] add node show + node clients (#61) --- README.md | 1 + cmd/completion.go | 3 + cmd/node.go | 45 +++++++++++++- pkg/cfg/cluster.go | 1 - pkg/es/api.go | 12 +++- pkg/es/node.go | 145 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 202 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 367969f..aee6bec 100644 --- a/README.md +++ b/README.md @@ -549,6 +549,7 @@ index - manage indicies node - manage nodes list - list nodes show - show details about a node + clients - show node http clients role - manage roles list - list roles show - show details about a role diff --git a/cmd/completion.go b/cmd/completion.go index c4b2148..c91e4ff 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -32,6 +32,7 @@ const ( Ccluster Capi Cilm + Cnode ) func complete(cmd *cli.Command, what int) { @@ -64,6 +65,8 @@ func complete(cmd *cli.Command, what int) { list = es.ApiPathNames() case Cilm: list, err = es.IlmNames(conf) + case Cnode: + list, err = es.NodeNames(conf) } if err != nil { diff --git a/cmd/node.go b/cmd/node.go index c403e2f..46d62f1 100644 --- a/cmd/node.go +++ b/cmd/node.go @@ -18,6 +18,7 @@ package cmd import ( "context" + "errors" "codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/es" @@ -34,6 +35,7 @@ func Node(conf *cfg.Config) *cli.Command { Commands: []*cli.Command{ NodeList(conf), NodeShow(conf), + NodeClients(conf), }, } } @@ -57,10 +59,47 @@ func NodeShow(conf *cfg.Config) *cli.Command { Usage: "show details about a node", UsageText: "show [options] ", + ShellComplete: func(ctx context.Context, cmd *cli.Command) { + complete(cmd, Cnode) + }, + Action: func(ctx context.Context, cmd *cli.Command) error { - // FIXME: implement es.NodeShow() - // return es.NodeShow(conf, cmd.Args().Get(0)) - return nil + node := cmd.Args().Get(0) + if node == "" { + return errors.New("no node specified") + } + + return es.NodeShow(conf, node) + }, + } +} + +func NodeClients(conf *cfg.Config) *cli.Command { + return &cli.Command{ + Name: "clients", + Usage: "show node http clients", + UsageText: "clients [options] ", + + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "query", + Usage: "include query parameters", + Destination: &conf.All, + Aliases: []string{"q"}, + }, + }, + + ShellComplete: func(ctx context.Context, cmd *cli.Command) { + complete(cmd, Cnode) + }, + + Action: func(ctx context.Context, cmd *cli.Command) error { + node := cmd.Args().Get(0) + if node == "" { + return errors.New("no node specified") + } + + return es.NodeClients(conf, node) }, } } diff --git a/pkg/cfg/cluster.go b/pkg/cfg/cluster.go index 49ef88c..addf92b 100644 --- a/pkg/cfg/cluster.go +++ b/pkg/cfg/cluster.go @@ -104,7 +104,6 @@ func (cluster *Cluster) ES() *elasticsearch.TypedClient { // add authentication to es client, if not yet done func (cluster *Cluster) CheckAuth() error { - if cluster.Pass == "" && cluster.User != "" && cluster.Token == "" && cluster.Default { // no token - user is set, but no password. diff --git a/pkg/es/api.go b/pkg/es/api.go index a296f9a..bf6bc4e 100644 --- a/pkg/es/api.go +++ b/pkg/es/api.go @@ -172,7 +172,17 @@ func CallAPI(conf *cfg.Config, verb, path, data string) ([]byte, error) { req.Header.Add("Content-Type", "application/json") req.Header.Add("accept", "application/json") - req.Header.Add("Authorization", "Basic "+encodeAuth(conf.DefaultCluster.User, conf.DefaultCluster.Pass)) + + // make sure we have got all we need + if err := conf.DefaultCluster.CheckAuth(); err != nil { + return nil, err + } + + if conf.DefaultCluster.Token != "" { + req.Header.Add("Authorization", "APIKey "+conf.DefaultCluster.Token) + } else { + req.Header.Add("Authorization", "Basic "+encodeAuth(conf.DefaultCluster.User, conf.DefaultCluster.Pass)) + } // actually execute the request resp, err := client.Do(req) diff --git a/pkg/es/node.go b/pkg/es/node.go index a5b91a8..d9874b8 100644 --- a/pkg/es/node.go +++ b/pkg/es/node.go @@ -20,9 +20,12 @@ import ( "context" "fmt" "log/slog" + "strings" + "time" "codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/printer" + "github.com/dustin/go-humanize" ) func NodeList(conf *cfg.Config) error { @@ -56,3 +59,145 @@ func NodeList(conf *cfg.Config) error { return nil } + +func NodeNames(conf *cfg.Config) ([]string, error) { + nodes, err := conf.DefaultCluster.ES().Cat.Nodes(). + Do(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get nodes: %s", esErrorString(err)) + } + + slog.Debug("ES result", "nodes", nodes) + + nodelist := make([]string, len(nodes)) + + for idx, node := range nodes { + nodelist[idx] = *node.Name + } + + return nodelist, err +} + +// FIXME: adding the settings metric leads to json unmarshall error: +// https://github.com/elastic/go-elasticsearch/issues/1524 +func NodeShow(conf *cfg.Config, nodename string) error { + res, err := conf.DefaultCluster.ES().Nodes.Info(). + NodeId(nodename). + Metric("os, jvm, thread_pool, remote_cluster_server"). + Do(context.Background()) + if err != nil { + return fmt.Errorf("failed to get node info: %s", esErrorString(err)) + } + + slog.Debug("ES result", "node", res) + + stats, err := conf.DefaultCluster.ES().Nodes.Stats(). + NodeId(nodename). + Do(context.Background()) + if err != nil { + return fmt.Errorf("failed to get node stats: %s", esErrorString(err)) + } + + slog.Debug("ES result", "stat", stats) + + for id, info := range res.Nodes { + stat := stats.Nodes[id] + + table := printer.NewTable(conf, 2, 0) + table.Addheaders(nodename+" property", "value") + + roles := make([]string, len(info.Roles)) + for idx, role := range info.Roles { + roles[idx] = role.Name + } + + k8snode := info.Attributes["k8s_node_name"] + + table.Entries = [][]string{ + {"Id", id}, + {"Name", nodename}, + {"Kubernetes node", k8snode}, + {"Ip address", info.Ip}, + {"Node rank", *stat.AdaptiveSelection[id].Rank}, + {"JVM", info.Jvm.VmName + " " + info.Jvm.Version}, + {"JVM Started", time.UnixMilli(info.Jvm.StartTimeInMillis).String()}, + {"OS", info.Os.PrettyName + " " + info.Os.Version}, + {"Node roles", strings.Join(roles, ",")}, + {"Node version", info.Version}, + {"HTTP clients", fmt.Sprintf("%d", *stat.Http.CurrentOpen)}, + {"CPUs", fmt.Sprintf("%d", *info.Os.AllocatedProcessors)}, + {"Load 15m/5m/1m", fmt.Sprintf("%.2f/%.2f/%.2f", + stat.Os.Cpu.LoadAverage["15m"], + stat.Os.Cpu.LoadAverage["5m"], + stat.Os.Cpu.LoadAverage["1m"], + )}, + {"Open FD's", fmt.Sprintf("%d", *stat.Process.OpenFileDescriptors)}, + {"Response time avg", fmt.Sprintf("%dns", *stat.AdaptiveSelection[id].AvgResponseTimeNs)}, + {"Memory usage (used/avail)", + humanize.Bytes(uint64(*stat.Os.Mem.UsedInBytes)) + " / " + humanize.Bytes(uint64(*stat.Os.Mem.TotalInBytes))}, + } + + if len(stat.Fs.Data) > 0 { + fs := stat.Fs.Data[0] + table.Entries = append(table.Entries, [][]string{ + {"Storage usage (used/avail)", + humanize.Bytes(uint64(*fs.AvailableInBytes)) + " / " + humanize.Bytes(uint64(*fs.TotalInBytes))}, + {"Storage mount", *fs.Mount}, + }...) + } + + if err := table.Print(); err != nil { + return err + } + } + + return nil +} + +func NodeClients(conf *cfg.Config, nodename string) error { + stats, err := conf.DefaultCluster.ES().Nodes.Stats(). + NodeId(nodename). + Do(context.Background()) + if err != nil { + return fmt.Errorf("failed to get node stats: %s", esErrorString(err)) + } + + slog.Debug("ES result", "stat", stats) + + table := printer.NewTable(conf, 5, 0) + table.Addheaders("agent", "id", "when", "from host", "url") + + for _, stat := range stats.Nodes { + for _, client := range stat.Http.Clients { + if client.ClosedTimeMillis == nil { + agent := "" + if client.Agent != nil { + agent = *client.Agent + } + + uri := "" + if client.LastUri != nil { + uri = *client.LastUri + + if !conf.All { + parts := strings.Split(uri, "?") + uri = parts[0] + } + } + + table.AddRow( + agent, + fmt.Sprintf("%d", *client.Id), + time.UnixMilli(*client.LastRequestTimeMillis).String(), + *client.RemoteAddress, + uri, + ) + } + } + + break + } + + table.Sort() + return table.Print() +}