From b573d63c54dce440cf3dfb4744d672b79f55f967 Mon Sep 17 00:00:00 2001 From: "T. von Dein" Date: Thu, 2 Jul 2026 12:16:59 +0200 Subject: [PATCH] enhance cluster status, add word wrap to wide table rows (#62) --- go.mod | 1 + go.sum | 2 + pkg/cfg/term.go | 38 ++++++++++++ pkg/es/api.go | 25 ++------ pkg/es/cluser_health_report.go | 59 ++++++++++++++++++ pkg/es/cluster.go | 110 +++++++++++++++++++-------------- pkg/es/debug.go | 48 ++++++++++++++ pkg/es/ilm_forecast.go | 6 +- pkg/es/parallel.go | 23 +++++-- pkg/es/search.go | 20 ------ pkg/printer/table.go | 49 +++++++++++++-- 11 files changed, 284 insertions(+), 97 deletions(-) create mode 100644 pkg/cfg/term.go create mode 100644 pkg/es/cluser_health_report.go create mode 100644 pkg/es/debug.go diff --git a/go.mod b/go.mod index c268fb9..e8116bb 100644 --- a/go.mod +++ b/go.mod @@ -85,6 +85,7 @@ require ( github.com/olekukonko/errors v1.2.0 // indirect github.com/olekukonko/ll v0.1.8 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/seeruk/go-wordwrap v0.0.0-20191208221741-14ec4aac9550 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/go.sum b/go.sum index ba50cb2..2918b3b 100644 --- a/go.sum +++ b/go.sum @@ -171,6 +171,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/seeruk/go-wordwrap v0.0.0-20191208221741-14ec4aac9550 h1:C3CfUXH/qmWuQFRqnPm3Sx8PFxa+pqACjhV5CaNO8pw= +github.com/seeruk/go-wordwrap v0.0.0-20191208221741-14ec4aac9550/go.mod h1:Sl541M2Em6rRG3V9WObycR7MYFZiERVkd/TJg0Gt0U4= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/pkg/cfg/term.go b/pkg/cfg/term.go new file mode 100644 index 0000000..c05069f --- /dev/null +++ b/pkg/cfg/term.go @@ -0,0 +1,38 @@ +/* +Copyright © 2026 Thomas von Dein + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +package cfg + +import ( + "os" + + "golang.org/x/term" +) + +const ( + DefaultMargin = 4 +) + +func GetTermWidth() int { + if term.IsTerminal(int(os.Stdout.Fd())) { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err == nil { + return width - DefaultMargin + } + } + + return 80 +} diff --git a/pkg/es/api.go b/pkg/es/api.go index bf6bc4e..ab06457 100644 --- a/pkg/es/api.go +++ b/pkg/es/api.go @@ -41,12 +41,10 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/chzyer/readline" "github.com/go-openapi/spec" - "golang.org/x/term" ) const ( - DefaultMargin = 4 - intro = `Input format: verb path [data]" + intro = `Input format: verb path [data]" Example: @@ -328,19 +326,19 @@ func ApiShow(conf *cfg.Config, showpath, verb string) error { cleanMarkup := regexp.MustCompile(`<[^<>]+>`) - width := getTermWidth() + width := cfg.GetTermWidth() params := getApiParameters(op, showpath, width) sample := getApiExample(op) - description := markdown.Render(cleanMarkup.ReplaceAllString(op.Op.Description, ""), width, DefaultMargin) + description := markdown.Render(cleanMarkup.ReplaceAllString(op.Op.Description, ""), width, cfg.DefaultMargin) var bold = lipgloss.NewStyle(). Bold(true) var paragraph = lipgloss.NewStyle(). MarginBottom(1). - MarginLeft(DefaultMargin) + MarginLeft(cfg.DefaultMargin) var boldparagraph = lipgloss.NewStyle(). MarginBottom(1). - MarginLeft(DefaultMargin). + MarginLeft(cfg.DefaultMargin). Bold(true) var indentparagraph = lipgloss.NewStyle(). MarginBottom(1). @@ -423,7 +421,7 @@ func getApiParameters(op *Op, path string, width int) Params { } } else { par := Param{ - Description: string(markdown.Render(param.Description, width, DefaultMargin)), + Description: string(markdown.Render(param.Description, width, cfg.DefaultMargin)), Param: param.Name, } @@ -527,14 +525,3 @@ func findOperation(item spec.PathItemProps) []*Op { return ops } - -func getTermWidth() int { - if term.IsTerminal(int(os.Stdout.Fd())) { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err == nil { - return width - DefaultMargin - } - } - - return 80 -} diff --git a/pkg/es/cluser_health_report.go b/pkg/es/cluser_health_report.go new file mode 100644 index 0000000..e751e51 --- /dev/null +++ b/pkg/es/cluser_health_report.go @@ -0,0 +1,59 @@ +/* +Copyright © 2026 Thomas von Dein + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +package es + +import ( + "encoding/json" + "fmt" + + "codeberg.org/scip/esctl/pkg/cfg" +) + +type HealthReport struct { + Indicators map[string]HealthReportIndicator +} + +type HealthReportIndicator struct { + Status, Symptom string + Diagnosis []HealthReportDiagnosis +} + +type HealthReportDiagnosis struct { + Id, Cause, Action string + AffectedResources map[string][]string `json:"affected_resources"` +} + +// we do not use the go-elasticsearch client API here but call the ES +// API directly, because the returned structure (a +// healthreport.Response) is not iterable, you'd have to explicitly +// call every indicator type and every cause etc which also have +// different types each. To check which is !green would result in a +// gigantic function. +func getHealthReport(conf *cfg.Config) (*HealthReport, error) { + raw, err := CallAPI(conf, "GET", "/_health_report", "") + if err != nil { + return nil, err + } + + report := HealthReport{} + + if err := json.Unmarshal(raw, &report); err != nil { + return nil, fmt.Errorf("failed to unmarshal healthreport response: %w", err) + } + + return &report, nil +} diff --git a/pkg/es/cluster.go b/pkg/es/cluster.go index 571e0f9..fe17035 100644 --- a/pkg/es/cluster.go +++ b/pkg/es/cluster.go @@ -25,12 +25,7 @@ import ( "codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/printer" "github.com/dustin/go-humanize" - "github.com/elastic/go-elasticsearch/v9/typedapi/cat/indices" - "github.com/elastic/go-elasticsearch/v9/typedapi/cat/tasks" - "github.com/elastic/go-elasticsearch/v9/typedapi/ccr/stats" - "github.com/elastic/go-elasticsearch/v9/typedapi/cluster/health" clusterstats "github.com/elastic/go-elasticsearch/v9/typedapi/cluster/stats" - "github.com/elastic/go-elasticsearch/v9/typedapi/core/info" "github.com/elastic/go-elasticsearch/v9/typedapi/types" ) @@ -78,8 +73,8 @@ func ClusterList(conf *cfg.Config) error { // We're using goroutines here to parallelize API requests, since we // have to do 3 of'em for each cluster. This speeds things up. -func ClusterStatus(conf *cfg.Config) error { - gocount := 5 +func getClusterStatus(conf *cfg.Config) (*apiResponse, error) { + gocount := 6 if conf.Verbose { gocount++ } @@ -90,65 +85,72 @@ func ClusterStatus(conf *cfg.Config) error { wg := &sync.WaitGroup{} wg.Add(gocount) - go getApiData(es, wg, responses, "health") - go getApiData(es, wg, responses, "info") - go getApiData(es, wg, responses, "ccrstats") - go getApiData(es, wg, responses, "indices") - go getApiData(es, wg, responses, "tasks") + go getApiData(conf, es, wg, responses, "health") + go getApiData(conf, es, wg, responses, "healthreport") + go getApiData(conf, es, wg, responses, "info") + go getApiData(conf, es, wg, responses, "ccr") + go getApiData(conf, es, wg, responses, "indices") + go getApiData(conf, es, wg, responses, "tasks") if conf.Verbose { - go getApiData(es, wg, responses, "stats") + go getApiData(conf, es, wg, responses, "stats") } wg.Wait() - var clusterhealth *health.Response - var info *info.Response - var ccrstats *stats.Response - var clusterstats *clusterstats.Response - var indexstats *indices.Response - var taskstatus *tasks.Response + all := apiResponse{} for i := 0; i < gocount; i++ { r := <-responses if r.error != nil { - return r.error + return nil, r.error } switch r.which { case ResponseHealth: - clusterhealth = r.health + all.health = r.health case ResponseCcr: - ccrstats = r.ccr + all.ccr = r.ccr case ResponseInfo: - info = r.info + all.info = r.info case ResponseStats: - clusterstats = r.stats + all.stats = r.stats case ResponseIndices: - indexstats = r.indices + all.indices = r.indices case ResponseTasks: - taskstatus = r.tasks + all.tasks = r.tasks + case ResponseHealthReport: + all.healthreport = r.healthreport } } - slog.Debug("ES result", "cluster health", clusterhealth) + return &all, nil +} - isleader := len(ccrstats.AutoFollowStats.AutoFollowedClusters) == 0 +func ClusterStatus(conf *cfg.Config) error { + res, err := getClusterStatus(conf) + if err != nil { + return err + } + + slog.Debug("ES result", "cluster health", res.health) + + isleader := len(res.ccr.AutoFollowStats.AutoFollowedClusters) == 0 ccrfollowing := "" - if len(ccrstats.AutoFollowStats.AutoFollowedClusters) > 0 { + if len(res.ccr.AutoFollowStats.AutoFollowedClusters) > 0 { // is following another cluster ccrfollowing = fmt.Sprintf("%s (%d/%d)", - ccrstats.AutoFollowStats.AutoFollowedClusters[0].ClusterName, - ccrstats.AutoFollowStats.NumberOfSuccessfulFollowIndices, - ccrstats.AutoFollowStats.NumberOfFailedFollowIndices, + res.ccr.AutoFollowStats.AutoFollowedClusters[0].ClusterName, + res.ccr.AutoFollowStats.NumberOfSuccessfulFollowIndices, + res.ccr.AutoFollowStats.NumberOfFailedFollowIndices, ) } // look for red indices, if any redindices := 0 - for _, index := range *indexstats { + for _, index := range *res.indices { if *index.Health == "red" { redindices++ } @@ -156,7 +158,7 @@ func ClusterStatus(conf *cfg.Config) error { // look for long running tasks longtasks := 0 - for _, task := range *taskstatus { + for _, task := range *res.tasks { if strings.Contains(*task.RunningTime, "d") { longtasks++ } @@ -166,16 +168,16 @@ func ClusterStatus(conf *cfg.Config) error { table.Addheaders(conf.DefaultCluster.Name, "status") table.Entries = [][]string{ - {"Cluster Name", clusterhealth.ClusterName}, - {"ES Status", printer.Colorize(conf, clusterhealth.Status.Name, clusterhealth.Status.Name)}, - {"ES Version", info.Version.Int}, + {"Cluster Name", res.health.ClusterName}, + {"ES Status", printer.Colorize(conf, res.health.Status.Name, res.health.Status.Name)}, + {"ES Version", res.info.Version.Int}, {"Is Leader", fmt.Sprintf("%t", isleader)}, - {"Active Shards", fmt.Sprintf("%d", clusterhealth.ActiveShards)}, - {"Active Primary Shards", fmt.Sprintf("%d", clusterhealth.ActivePrimaryShards)}, - {"Unassigned Shards", fmt.Sprintf("%d", clusterhealth.UnassignedShards)}, - {"Unassigned Primary Shards", fmt.Sprintf("%d", clusterhealth.UnassignedPrimaryShards)}, - {"Pending Tasks", fmt.Sprintf("%d", clusterhealth.NumberOfPendingTasks)}, - {"Nodes", fmt.Sprintf("%d", clusterhealth.NumberOfNodes)}, + {"Active Shards", fmt.Sprintf("%d", res.health.ActiveShards)}, + {"Active Primary Shards", fmt.Sprintf("%d", res.health.ActivePrimaryShards)}, + {"Unassigned Shards", fmt.Sprintf("%d", res.health.UnassignedShards)}, + {"Unassigned Primary Shards", fmt.Sprintf("%d", res.health.UnassignedPrimaryShards)}, + {"Pending Tasks", fmt.Sprintf("%d", res.health.NumberOfPendingTasks)}, + {"Nodes", fmt.Sprintf("%d", res.health.NumberOfNodes)}, {"Red Indices", fmt.Sprintf("%d", redindices)}, {"Long Running Tasks", fmt.Sprintf("%d", longtasks)}, } @@ -183,12 +185,30 @@ func ClusterStatus(conf *cfg.Config) error { if !isleader { table.Entries = append(table.Entries, [][]string{ {"AutoFollow (success/failed indices)", ccrfollowing}, - {"Followed Indices", fmt.Sprintf("%d", len(ccrstats.FollowStats.Indices))}, + {"Followed Indices", fmt.Sprintf("%d", len(res.ccr.FollowStats.Indices))}, }...) } if conf.Verbose { - table = gatherClusterStats(conf, clusterstats, table) + table = gatherClusterStats(conf, res.stats, table) + } + + if res.health.Status.Name != "green" { + for name, indicator := range res.healthreport.Indicators { + if indicator.Status != "green" { + table.Entries = append(table.Entries, []string{ + printer.Colorize(conf, indicator.Status, "Bad health "+name), indicator.Symptom, + }) + + for _, diag := range indicator.Diagnosis { + table.Entries = append(table.Entries, []string{" -> cause", diag.Cause}) + + for resource, items := range diag.AffectedResources { + table.Entries = append(table.Entries, []string{" -> affected " + resource, strings.Join(items, ",")}) + } + } + } + } } if err := table.Print(); err != nil { diff --git a/pkg/es/debug.go b/pkg/es/debug.go new file mode 100644 index 0000000..9045bba --- /dev/null +++ b/pkg/es/debug.go @@ -0,0 +1,48 @@ +/* +Copyright © 2026 Thomas von Dein + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +package es + +import ( + "codeberg.org/scip/esctl/pkg/cfg" + "github.com/alecthomas/repr" +) + +func Debug(conf *cfg.Config) error { + report, err := getHealthReport(conf) + if err != nil { + return err + } + + repr.Println(report) + /* + res, err := conf.DefaultCluster.ES().Search(). + Index(conf.Index). + Size(0). + Aggregations(map[string]types.Aggregations{ + "min_ts": *esdsl.NewMinAggregation().Field("@timestamp").AggregationsCaster(), + "max_ts": *esdsl.NewMaxAggregation().Field("@timestamp").AggregationsCaster(), + }). + Do(context.Background()) + + if err != nil { + return err + } + + repr.Println(res) + */ + return nil +} diff --git a/pkg/es/ilm_forecast.go b/pkg/es/ilm_forecast.go index c9db433..569bf2d 100644 --- a/pkg/es/ilm_forecast.go +++ b/pkg/es/ilm_forecast.go @@ -190,9 +190,9 @@ func getIlmPhaseData(conf *cfg.Config) ([]PhaseData, error) { wg := &sync.WaitGroup{} wg.Add(3) - go getApiData(conf.DefaultCluster.ES(), wg, responses, "indicesbytes") - go getApiData(conf.DefaultCluster.ES(), wg, responses, "explain") - go getApiData(conf.DefaultCluster.ES(), wg, responses, "policies") + go getApiData(conf, conf.DefaultCluster.ES(), wg, responses, "indicesbytes") + go getApiData(conf, conf.DefaultCluster.ES(), wg, responses, "explain") + go getApiData(conf, conf.DefaultCluster.ES(), wg, responses, "policies") wg.Wait() diff --git a/pkg/es/parallel.go b/pkg/es/parallel.go index 9097c18..25a47be 100644 --- a/pkg/es/parallel.go +++ b/pkg/es/parallel.go @@ -18,10 +18,10 @@ package es import ( "context" - "errors" "fmt" "sync" + "codeberg.org/scip/esctl/pkg/cfg" "github.com/elastic/go-elasticsearch/v9" "github.com/elastic/go-elasticsearch/v9/typedapi/cat/indices" "github.com/elastic/go-elasticsearch/v9/typedapi/cat/tasks" @@ -43,12 +43,14 @@ const ( ResponseTasks ResponseExplain ResponseLifecycle + ResponseHealthReport ) type apiResponse struct { error error info *info.Response health *health.Response + healthreport *HealthReport ccr *stats.Response stats *clusterstats.Response indices *indices.Response @@ -59,12 +61,16 @@ type apiResponse struct { which int } -func getApiData(es *elasticsearch.TypedClient, wg *sync.WaitGroup, - reschan chan apiResponse, which string) { +func getApiData( + conf *cfg.Config, + es *elasticsearch.TypedClient, + wg *sync.WaitGroup, + reschan chan apiResponse, + which string) { defer wg.Done() ar := apiResponse{} - arerr := errors.New("") + var arerr error switch which { case "health": @@ -75,6 +81,13 @@ func getApiData(es *elasticsearch.TypedClient, wg *sync.WaitGroup, ar.which = ResponseHealth arerr = err + case "healthreport": + report, err := getHealthReport(conf) + + ar.healthreport = report + ar.which = ResponseHealthReport + arerr = err + case "info": res, err := es.Info(). Do(context.Background()) @@ -83,7 +96,7 @@ func getApiData(es *elasticsearch.TypedClient, wg *sync.WaitGroup, ar.which = ResponseInfo arerr = err - case "ccrstats": + case "ccr": res, err := es.Ccr.Stats(). Do(context.Background()) diff --git a/pkg/es/search.go b/pkg/es/search.go index f93c5f1..19a2204 100644 --- a/pkg/es/search.go +++ b/pkg/es/search.go @@ -26,7 +26,6 @@ import ( "codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/printer" - "github.com/alecthomas/repr" "github.com/elastic/go-elasticsearch/v9/typedapi/core/search" "github.com/elastic/go-elasticsearch/v9/typedapi/esdsl" "github.com/elastic/go-elasticsearch/v9/typedapi/indices/validatequery" @@ -149,25 +148,6 @@ func validateSearch(conf *cfg.Config, queries []string) error { return nil } -func Debug(conf *cfg.Config) error { - res, err := conf.DefaultCluster.ES().Search(). - Index(conf.Index). - Size(0). - Aggregations(map[string]types.Aggregations{ - "min_ts": *esdsl.NewMinAggregation().Field("@timestamp").AggregationsCaster(), - "max_ts": *esdsl.NewMaxAggregation().Field("@timestamp").AggregationsCaster(), - }). - Do(context.Background()) - - if err != nil { - return err - } - - repr.Println(res) - - return nil -} - func searchOnce(conf *cfg.Config, search *search.Search) error { res, err := search. From(conf.From). diff --git a/pkg/printer/table.go b/pkg/printer/table.go index d0083da..236ffc8 100644 --- a/pkg/printer/table.go +++ b/pkg/printer/table.go @@ -28,6 +28,7 @@ import ( "github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter/renderer" "github.com/olekukonko/tablewriter/tw" + "github.com/seeruk/go-wordwrap" "gopkg.in/yaml.v3" ) @@ -38,10 +39,11 @@ type Table struct { lenHeaders []int alignInts bool + maxwidth int } func NewTable(conf *cfg.Config, columns, rows int) *Table { - table := Table{Mode: conf.Output} + table := Table{Mode: conf.Output, maxwidth: cfg.GetTermWidth()} table.Headers = make([]string, columns) table.Entries = make([][]string, rows) @@ -118,17 +120,31 @@ func (data *Table) PrintTSV() error { } for _, entries := range data.Entries { + currentWidth := 0 for idx, entry := range entries { length := visibleLen(entry) + if data.lenHeaders[idx] < length { - data.lenHeaders[idx] = length + if length > currentWidth+data.maxwidth { + data.lenHeaders[idx] = data.maxwidth - currentWidth + } else { + data.lenHeaders[idx] = length + } } + + currentWidth += data.lenHeaders[idx] } } - // output + // output headers for idx, header := range data.Headers { - fmt.Print(header, strings.Repeat(" ", data.lenHeaders[idx]-visibleLen(header))) + if idx+1 != len(data.Headers) { + fmt.Print(header, strings.Repeat(" ", data.lenHeaders[idx]-visibleLen(header))) + } else { + // no padding for last header + fmt.Print(header) + } + if idx < len(data.Headers)-1 { fmt.Print(" ") } @@ -136,14 +152,37 @@ func (data *Table) PrintTSV() error { fmt.Println() for _, entries := range data.Entries { + currentWidth := 0 + for idx, entry := range entries { length := visibleLen(entry) + if length+currentWidth > data.maxwidth { + // text is too wide to be put into one line, wrap it + wrapper := wordwrap.Wrapper(data.maxwidth-currentWidth, false) + wrapped := wrapper(entry) + + // and indent it + for idx, line := range strings.Split(wrapped, "\n") { + if idx == 0 { + entry = line + } else { + entry += "\n " + strings.Repeat(" ", currentWidth) + line + } + } + } + + currentWidth += data.lenHeaders[idx] + if isInt(entry) && data.alignInts { // align right fmt.Print(strings.Repeat(" ", data.lenHeaders[idx]-length), entry) - } else { + } else if length < data.lenHeaders[idx] && idx+1 != len(entries) { + // pad right, if required fmt.Print(entry, strings.Repeat(" ", data.lenHeaders[idx]-length)) + } else { + // no padding for last entry + fmt.Print(entry) } if idx < len(data.Headers)-1 {