/* 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 ( "fmt" "log/slog" "strings" "sync" "codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/printer" "github.com/dustin/go-humanize" clusterstats "github.com/elastic/go-elasticsearch/v9/typedapi/cluster/stats" "github.com/elastic/go-elasticsearch/v9/typedapi/types" ) type ClusterIndices map[string]map[string]*types.IndicesRecord type clusterReachable struct { reachable bool err error } func ClusterList(conf *cfg.Config) error { var mu sync.Mutex var wg sync.WaitGroup reachable := make(map[string]clusterReachable, len(conf.Clusters)) // check endpoints in parallel to speed things up for name, cluster := range conf.Clusters { wg.Add(1) go func() { defer wg.Done() online, err := cluster.IsReachable() mu.Lock() reachable[name] = clusterReachable{reachable: online, err: err} mu.Unlock() }() } wg.Wait() table := printer.NewTable(conf, 5, len(conf.Clusters)) table.Addheaders("cluster", "uri", "reachable", "current", "error") idx := 0 for name, cluster := range conf.Clusters { reachableStr := "no" current := "no" errmsg := "" if reachable[name].reachable { reachableStr = printer.Colorize(conf, "green", "reachable") } if cluster.Default { current = printer.Colorize(conf, "green", "yes") if !reachable[name].reachable { reachableStr = printer.Colorize(conf, "red", "no") errmsg = reachable[name].err.Error() } } table.Entries[idx] = []string{name, cluster.Uri, reachableStr, current, errmsg} idx++ } table.Sort() return table.Print() } // 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 getClusterStatus(conf *cfg.Config) (*apiResponse, error) { gocount := 6 if conf.Verbose { gocount++ } es := conf.DefaultCluster.ES() responses := make(chan apiResponse, gocount) wg := &sync.WaitGroup{} wg.Add(gocount) 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(conf, es, wg, responses, "stats") } wg.Wait() all := apiResponse{} for i := 0; i < gocount; i++ { r := <-responses if r.error != nil { return nil, r.error } switch r.which { case ResponseHealth: all.health = r.health case ResponseCcr: all.ccr = r.ccr case ResponseInfo: all.info = r.info case ResponseStats: all.stats = r.stats case ResponseIndices: all.indices = r.indices case ResponseTasks: all.tasks = r.tasks case ResponseHealthReport: all.healthreport = r.healthreport } } return &all, nil } 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(res.ccr.AutoFollowStats.AutoFollowedClusters) > 0 { // is following another cluster ccrfollowing = fmt.Sprintf("%s (%d/%d)", 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 *res.indices { if *index.Health == "red" { redindices++ } } // look for long running tasks longtasks := 0 for _, task := range *res.tasks { if strings.Contains(*task.RunningTime, "d") { longtasks++ } } table := printer.NewTable(conf, 2, 7) table.Addheaders(conf.DefaultCluster.Name, "status") table.Entries = [][]string{ {"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", 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)}, } if !isleader { table.Entries = append(table.Entries, [][]string{ {"AutoFollow (success/failed indices)", ccrfollowing}, {"Followed Indices", fmt.Sprintf("%d", len(res.ccr.FollowStats.Indices))}, }...) } if conf.Verbose { 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 { return err } return nil } func gatherClusterStats(conf *cfg.Config, clusterstats *clusterstats.Response, table *printer.Table) *printer.Table { var querycount int64 var vmversion string for _, count := range clusterstats.Indices.Search.Queries { querycount += count } if len(clusterstats.Nodes.Jvm.Versions) > 0 { vmversion = strings.Join([]string{ clusterstats.Nodes.Jvm.Versions[0].VmName, clusterstats.Nodes.Jvm.Versions[0].VmVersion}, " ") } table.Entries = append(table.Entries, [][]string{ {"Indicies", fmt.Sprintf("%d", clusterstats.Indices.Count)}, {"Docs", fmt.Sprintf("%d", clusterstats.Indices.Docs.Count)}, {"Total Size", humanize.Bytes(uint64(clusterstats.Indices.Docs.TotalSizeInBytes))}, {"Total Queries", fmt.Sprintf("%d", querycount)}, {"Shards Primaries", fmt.Sprintf("%d", clusterstats.Indices.Shards.Primaries)}, {"Shards Total", fmt.Sprintf("%d", clusterstats.Indices.Shards.Total)}, {"Storage", fmt.Sprintf( "%s/%s", humanize.Bytes(uint64(clusterstats.Indices.Store.SizeInBytes)), humanize.Bytes(uint64(*clusterstats.Indices.Store.TotalDataSetSizeInBytes)), )}, {"JVM Heap Memory", fmt.Sprintf( "%s/%s", humanize.Bytes(uint64(clusterstats.Nodes.Jvm.Mem.HeapUsedInBytes)), humanize.Bytes(uint64(clusterstats.Nodes.Jvm.Mem.HeapMaxInBytes)), )}, {"JVM Threads", fmt.Sprintf("%d", clusterstats.Nodes.Jvm.Threads)}, {"JVM Version", vmversion}, {"CPUs", fmt.Sprintf("%d", clusterstats.Nodes.Os.AllocatedProcessors)}, {"CPU Usage", fmt.Sprintf("%d%%", clusterstats.Nodes.Process.Cpu.Percent)}, {"Open FDs", fmt.Sprintf("%d", clusterstats.Nodes.Process.OpenFileDescriptors.Avg)}, }...) return table }