From 92494e2690c6de72658e005ffed5fd00bdc98fc6 Mon Sep 17 00:00:00 2001 From: acidvegas Date: Fri, 29 Nov 2024 00:33:22 -0500 Subject: [PATCH] Initial commit --- .gitignore | 26 ++ .screens/preview.png | Bin 0 -> 44120 bytes LICENSE | 15 + README.md | 111 +++++ elastop.go | 1028 ++++++++++++++++++++++++++++++++++++++++++ go.mod | 18 + go.sum | 50 ++ 7 files changed, 1248 insertions(+) create mode 100644 .gitignore create mode 100644 .screens/preview.png create mode 100644 LICENSE create mode 100644 README.md create mode 100644 elastop.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b620af6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +elastop + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +vendor/ + +# Go workspace file +go.work + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store \ No newline at end of file diff --git a/.screens/preview.png b/.screens/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..da35a591bc8209e2070df17923e6ae13ad9b95c9 GIT binary patch literal 44120 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 + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..be2d3a6 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# Elastop - Elasticsearch Terminal Dashboard + +Elastop is a terminal-based dashboard for monitoring Elasticsearch clusters in real-time. It provides a comprehensive view of cluster health, node status, indices, and various performance metrics in an easy-to-read terminal interface. This tool was designed to look visually similar HTOP. + +![](./.screens/preview.png) + +## Features + +- Real-time cluster monitoring +- Node status and resource usage +- Index statistics and write rates +- Search and indexing performance metrics +- Memory usage and garbage collection stats +- Network and disk I/O monitoring +- Color-coded health status indicators +- Role-based node classification +- Version compatibility checking + +## Installation + +```bash +# Clone the repository +git git clone https://github.com/yourusername/elastop.git +cd elastop +go build +``` + +## Usage + +```bash +./elastop [flags] +``` + +### Command Line Flags +| Flag | Description | Default | +| ----------- | ---------------------- | ------------- | +| `-host` | Elasticsearch host | `localhost` | +| `-port` | Elasticsearch port | `9200` | +| `-user` | Elasticsearch username | `elastic` | +| `-password` | Elasticsearch password | `ES_PASSWORD` | + +## Dashboard Layout + +### Header Section +- Displays cluster name and health status +- Shows total number of nodes (successful/failed) +- Indicates version compatibility with latest Elasticsearch release + +### Nodes Panel +- Lists all nodes with their roles and status +- Shows real-time resource usage: + - CPU utilization + - Memory usage + - Heap usage + - Disk space + - Load average +- Displays node version and OS information + +### Indices Panel +- Lists all indices with health status +- Shows document counts and storage size +- Displays primary shards and replica configuration +- Real-time ingestion monitoring with: + - Document count changes + - Ingestion rates (docs/second) + - Active write indicators + +### Metrics Panel +- Search performance: + - Query counts and rates + - Average query latency +- Indexing metrics: + - Operation counts + - Indexing rates + - Average indexing latency +- Memory statistics: + - System memory usage + - JVM heap utilization +- GC metrics: + - Collection counts + - GC timing statistics +- I/O metrics: + - Network traffic (TX/RX) + - Disk operations + - Open file descriptors + +### Role Legend +Shows all possible node roles with their corresponding colors: +- M: Master +- D: Data +- C: Content +- H: Hot +- W: Warm +- K: Cold +- F: Frozen +- I: Ingest +- L: Machine Learning +- R: Remote Cluster Client +- T: Transform +- V: Voting Only +- O: Coordinating Only + +## Controls + +- Press `q` or `ESC` to quit +- Mouse scrolling supported in all panels +- Auto-refreshes every 5 seconds + +--- + +###### Mirrors: [acid.vegas](https://git.acid.vegas/elastop) • [SuperNETs](https://git.supernets.org/acidvegas/elastop) • [GitHub](https://github.com/acidvegas/elastop) • [GitLab](https://gitlab.com/acidvegas/elastop) • [Codeberg](https://codeberg.org/acidvegas/elastop) diff --git a/elastop.go b/elastop.go new file mode 100644 index 0000000..e9f6a82 --- /dev/null +++ b/elastop.go @@ -0,0 +1,1028 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type ClusterStats struct { + ClusterName string `json:"cluster_name"` + Status string `json:"status"` + Indices struct { + Count int `json:"count"` + Shards struct { + Total int `json:"total"` + } `json:"shards"` + Docs struct { + Count int `json:"count"` + } `json:"docs"` + Store struct { + SizeInBytes int64 `json:"size_in_bytes"` + TotalSizeInBytes int64 `json:"total_size_in_bytes"` + } `json:"store"` + } `json:"indices"` + Nodes struct { + Total int `json:"total"` + Successful int `json:"successful"` + Failed int `json:"failed"` + } `json:"_nodes"` +} + +type NodesInfo struct { + Nodes map[string]struct { + Name string `json:"name"` + TransportAddress string `json:"transport_address"` + Version string `json:"version"` + Roles []string `json:"roles"` + OS struct { + AvailableProcessors int `json:"available_processors"` + Name string `json:"name"` + Arch string `json:"arch"` + Version string `json:"version"` + PrettyName string `json:"pretty_name"` + } `json:"os"` + Process struct { + ID int `json:"id"` + Mlockall bool `json:"mlockall"` + } `json:"process"` + Settings struct { + Node struct { + Attr struct { + ML struct { + MachineMem string `json:"machine_memory"` + } `json:"ml"` + } `json:"attr"` + } `json:"node"` + } `json:"settings"` + } `json:"nodes"` +} + +type IndexStats []struct { + Index string `json:"index"` + Health string `json:"health"` + DocsCount string `json:"docs.count"` + StoreSize string `json:"store.size"` + PriShards string `json:"pri"` + Replicas string `json:"rep"` +} + +type IndexActivity struct { + LastDocsCount int + IsActive bool + InitialDocsCount int + StartTime time.Time +} + +type IndexWriteStats struct { + Indices map[string]struct { + Total struct { + Indexing struct { + IndexTotal int64 `json:"index_total"` + } `json:"indexing"` + } `json:"total"` + } `json:"indices"` +} + +type ClusterHealth struct { + ActiveShards int `json:"active_shards"` + ActivePrimaryShards int `json:"active_primary_shards"` + RelocatingShards int `json:"relocating_shards"` + InitializingShards int `json:"initializing_shards"` + UnassignedShards int `json:"unassigned_shards"` + DelayedUnassignedShards int `json:"delayed_unassigned_shards"` + NumberOfPendingTasks int `json:"number_of_pending_tasks"` + TaskMaxWaitingTime string `json:"task_max_waiting_time"` + ActiveShardsPercentAsNumber float64 `json:"active_shards_percent_as_number"` +} + +type NodesStats struct { + Nodes map[string]struct { + Indices struct { + Store struct { + SizeInBytes int64 `json:"size_in_bytes"` + } `json:"store"` + Search struct { + QueryTotal int64 `json:"query_total"` + QueryTimeInMillis int64 `json:"query_time_in_millis"` + } `json:"search"` + Indexing struct { + IndexTotal int64 `json:"index_total"` + IndexTimeInMillis int64 `json:"index_time_in_millis"` + } `json:"indexing"` + Segments struct { + Count int64 `json:"count"` + } `json:"segments"` + } `json:"indices"` + OS struct { + CPU struct { + Percent int `json:"percent"` + } `json:"cpu"` + Memory struct { + UsedInBytes int64 `json:"used_in_bytes"` + FreeInBytes int64 `json:"free_in_bytes"` + TotalInBytes int64 `json:"total_in_bytes"` + } `json:"mem"` + LoadAverage map[string]float64 `json:"load_average"` + } `json:"os"` + JVM struct { + Memory struct { + HeapUsedInBytes int64 `json:"heap_used_in_bytes"` + HeapMaxInBytes int64 `json:"heap_max_in_bytes"` + } `json:"mem"` + GC struct { + Collectors struct { + Young struct { + CollectionCount int64 `json:"collection_count"` + CollectionTimeInMillis int64 `json:"collection_time_in_millis"` + } `json:"young"` + Old struct { + CollectionCount int64 `json:"collection_count"` + CollectionTimeInMillis int64 `json:"collection_time_in_millis"` + } `json:"old"` + } `json:"collectors"` + } `json:"gc"` + } `json:"jvm"` + Transport struct { + RxSizeInBytes int64 `json:"rx_size_in_bytes"` + TxSizeInBytes int64 `json:"tx_size_in_bytes"` + RxCount int64 `json:"rx_count"` + TxCount int64 `json:"tx_count"` + } `json:"transport"` + HTTP struct { + CurrentOpen int64 `json:"current_open"` + } `json:"http"` + Process struct { + OpenFileDescriptors int64 `json:"open_file_descriptors"` + } `json:"process"` + FS struct { + DiskReads int64 `json:"disk_reads"` + DiskWrites int64 `json:"disk_writes"` + Total struct { + TotalInBytes int64 `json:"total_in_bytes"` + FreeInBytes int64 `json:"free_in_bytes"` + AvailableInBytes int64 `json:"available_in_bytes"` + } `json:"total"` + Data []struct { + Path string `json:"path"` + TotalInBytes int64 `json:"total_in_bytes"` + FreeInBytes int64 `json:"free_in_bytes"` + AvailableInBytes int64 `json:"available_in_bytes"` + } `json:"data"` + } `json:"fs"` + } `json:"nodes"` +} + +type GitHubRelease struct { + TagName string `json:"tag_name"` +} + +var ( + latestVersion string + versionCache time.Time +) + +var indexActivities = make(map[string]*IndexActivity) + +type IngestionEvent struct { + Index string + DocCount int + Timestamp time.Time +} + +type CatNodesStats struct { + Load1m string `json:"load_1m"` + Name string `json:"name"` +} + +func bytesToHuman(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + + units := []string{"B", "K", "M", "G", "T", "P", "E", "Z"} + exp := 0 + val := float64(bytes) + + for val >= unit && exp < len(units)-1 { + val /= unit + exp++ + } + + return fmt.Sprintf("%.1f%s", val, units[exp]) +} + +// In the indices panel section, update the formatting part: + +// First, let's create a helper function at package level for number formatting +func formatNumber(n int) string { + // Convert number to string + str := fmt.Sprintf("%d", n) + + // Add commas + var result []rune + for i, r := range str { + if i > 0 && (len(str)-i)%3 == 0 { + result = append(result, ',') + } + result = append(result, r) + } + return string(result) +} + +// Update the convertSizeFormat function to remove decimal points +func convertSizeFormat(sizeStr string) string { + var size float64 + var unit string + fmt.Sscanf(sizeStr, "%f%s", &size, &unit) + + // Convert units like "gb" to "G" + unit = strings.ToUpper(strings.TrimSuffix(unit, "b")) + + // Return without decimal points + return fmt.Sprintf("%d%s", int(size), unit) +} + +// Update formatResourceSize to return just the number and unit +func formatResourceSize(bytes int64, targetUnit string) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%3d%s", bytes, targetUnit) + } + + units := []string{"B", "K", "M", "G", "T", "P"} + exp := 0 + val := float64(bytes) + + for val >= unit && exp < len(units)-1 { + val /= unit + exp++ + } + + return fmt.Sprintf("%3d%s", int(val), targetUnit) +} + +// Add this helper function at package level +func getPercentageColor(percent float64) string { + switch { + case percent < 30: + return "green" + case percent < 70: + return "#00ffff" // cyan + case percent < 85: + return "#ffff00" // yellow + default: + return "#ff5555" // light red + } +} + +func getLatestVersion() string { + // Only fetch every hour + if time.Since(versionCache) < time.Hour && latestVersion != "" { + return latestVersion + } + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("https://api.github.com/repos/elastic/elasticsearch/releases/latest") + if err != nil { + return "" + } + defer resp.Body.Close() + + var release GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "" + } + + // Clean up version string (remove 'v' prefix if present) + latestVersion = strings.TrimPrefix(release.TagName, "v") + versionCache = time.Now() + return latestVersion +} + +func compareVersions(current, latest string) bool { + if latest == "" { + return true // If we can't get latest version, assume current is ok + } + + // Clean up version strings + current = strings.TrimPrefix(current, "v") + latest = strings.TrimPrefix(latest, "v") + + // Split versions into parts + currentParts := strings.Split(current, ".") + latestParts := strings.Split(latest, ".") + + // Compare each part + for i := 0; i < len(currentParts) && i < len(latestParts); i++ { + curr, _ := strconv.Atoi(currentParts[i]) + lat, _ := strconv.Atoi(latestParts[i]) + if curr != lat { + return curr >= lat + } + } + return len(currentParts) >= len(latestParts) +} + +// Update roleColors map with lighter colors for I and R +var roleColors = map[string]string{ + "master": "#ff5555", // red + "data": "#50fa7b", // green + "data_content": "#8be9fd", // cyan + "data_hot": "#ffb86c", // orange + "data_warm": "#bd93f9", // purple + "data_cold": "#f1fa8c", // yellow + "data_frozen": "#ff79c6", // pink + "ingest": "#87cefa", // light sky blue (was gray) + "ml": "#6272a4", // blue gray + "remote_cluster_client": "#dda0dd", // plum (was burgundy) + "transform": "#689d6a", // forest green + "voting_only": "#458588", // teal + "coordinating_only": "#d65d0e", // burnt orange +} + +// Add this map alongside the roleColors map at package level +var legendLabels = map[string]string{ + "master": "Master", + "data": "Data", + "data_content": "Data Content", + "data_hot": "Data Hot", + "data_warm": "Data Warm", + "data_cold": "Data Cold", + "data_frozen": "Data Frozen", + "ingest": "Ingest", + "ml": "Machine Learning", + "remote_cluster_client": "Remote Cluster Client", + "transform": "Transform", + "voting_only": "Voting Only", + "coordinating_only": "Coordinating Only", +} + +// Update the formatNodeRoles function to use full width for all possible roles +func formatNodeRoles(roles []string) string { + roleMap := map[string]string{ + "master": "M", + "data": "D", + "data_content": "C", + "data_hot": "H", + "data_warm": "W", + "data_cold": "K", + "data_frozen": "F", + "ingest": "I", + "ml": "L", + "remote_cluster_client": "R", + "transform": "T", + "voting_only": "V", + "coordinating_only": "O", + } + + // Get the role letters and sort them + var letters []string + for _, role := range roles { + if letter, exists := roleMap[role]; exists { + letters = append(letters, letter) + } + } + sort.Strings(letters) + + // Create a fixed-width string of 13 spaces (one for each possible role) + formattedRoles := " " // 13 spaces + runeRoles := []rune(formattedRoles) + + // Fill in the sorted letters + for i, letter := range letters { + if i < 13 { // Now we can accommodate all possible roles + runeRoles[i] = []rune(letter)[0] + } + } + + // Build the final string with colors + var result string + for _, r := range runeRoles { + if r == ' ' { + result += " " + } else { + // Find the role that corresponds to this letter + for role, shortRole := range roleMap { + if string(r) == shortRole { + result += fmt.Sprintf("[%s]%s[white]", roleColors[role], string(r)) + break + } + } + } + } + + return result +} + +// Add a helper function to get health color +func getHealthColor(health string) string { + switch health { + case "green": + return "green" + case "yellow": + return "#ffff00" // yellow + case "red": + return "#ff5555" // light red + default: + return "white" + } +} + +// Update the indexInfo struct to include health +type indexInfo struct { + index string + health string + docs int + storeSize string + priShards string + replicas string + writeOps int64 + indexingRate float64 +} + +// Add startTime at package level +var startTime = time.Now() + +func main() { + host := flag.String("host", "localhost", "Elasticsearch host") + port := flag.Int("port", 9200, "Elasticsearch port") + user := flag.String("user", "elastic", "Elasticsearch username") + password := flag.String("password", os.Getenv("ES_PASSWORD"), "Elasticsearch password") + flag.Parse() + + app := tview.NewApplication() + + // Update the grid layout to use three columns for the bottom section + grid := tview.NewGrid(). + SetRows(3, 0, 0). // Three rows: header, nodes, bottom panels + SetColumns(-1, -2, -1). // Three columns for bottom row: roles (1), indices (2), metrics (1) + SetBorders(true) + + // Create the individual panels + header := tview.NewTextView(). + SetDynamicColors(true). + SetTextAlign(tview.AlignLeft) + + nodesPanel := tview.NewTextView(). + SetDynamicColors(true) + + rolesPanel := tview.NewTextView(). // New panel for roles + SetDynamicColors(true) + + indicesPanel := tview.NewTextView(). + SetDynamicColors(true) + + metricsPanel := tview.NewTextView(). + SetDynamicColors(true) + + // Add panels to grid + grid.AddItem(header, 0, 0, 1, 3, 0, 0, false). // Header spans all columns + AddItem(nodesPanel, 1, 0, 1, 3, 0, 0, false). // Nodes panel spans all columns + AddItem(rolesPanel, 2, 0, 1, 1, 0, 0, false). // Roles panel in left column + AddItem(indicesPanel, 2, 1, 1, 1, 0, 0, false). // Indices panel in middle column + AddItem(metricsPanel, 2, 2, 1, 1, 0, 0, false) // Metrics panel in right column + + // Update function + update := func() { + baseURL := fmt.Sprintf("http://%s:%d", *host, *port) + client := &http.Client{} + + // Helper function for ES requests + makeRequest := func(path string, target interface{}) error { + req, err := http.NewRequest("GET", baseURL+path, nil) + if err != nil { + return err + } + req.SetBasicAuth(*user, *password) + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(body, target) + } + + // Get cluster stats + var clusterStats ClusterStats + if err := makeRequest("/_cluster/stats", &clusterStats); err != nil { + header.SetText(fmt.Sprintf("[red]Error: %v", err)) + return + } + + // Get nodes info + var nodesInfo NodesInfo + if err := makeRequest("/_nodes", &nodesInfo); err != nil { + nodesPanel.SetText(fmt.Sprintf("[red]Error: %v", err)) + return + } + + // Get indices stats + var indicesStats IndexStats + if err := makeRequest("/_cat/indices?format=json", &indicesStats); err != nil { + indicesPanel.SetText(fmt.Sprintf("[red]Error: %v", err)) + return + } + + // Get cluster health + var clusterHealth ClusterHealth + if err := makeRequest("/_cluster/health", &clusterHealth); err != nil { + indicesPanel.SetText(fmt.Sprintf("[red]Error: %v", err)) + return + } + + // Get nodes stats + var nodesStats NodesStats + if err := makeRequest("/_nodes/stats", &nodesStats); err != nil { + indicesPanel.SetText(fmt.Sprintf("[red]Error: %v", err)) + return + } + + // Calculate aggregate metrics + var ( + totalQueries int64 + totalQueryTime float64 + totalIndexing int64 + totalIndexingTime float64 + totalCPUPercent int + totalMemoryUsed int64 + totalMemoryTotal int64 + totalHeapUsed int64 + totalHeapMax int64 + totalGCCollections int64 + totalGCTime float64 + nodeCount int + ) + + for _, node := range nodesStats.Nodes { + totalQueries += node.Indices.Search.QueryTotal + totalQueryTime += float64(node.Indices.Search.QueryTimeInMillis) / 1000 + totalIndexing += node.Indices.Indexing.IndexTotal + totalIndexingTime += float64(node.Indices.Indexing.IndexTimeInMillis) / 1000 + totalCPUPercent += node.OS.CPU.Percent + totalMemoryUsed += node.OS.Memory.UsedInBytes + totalMemoryTotal += node.OS.Memory.TotalInBytes + totalHeapUsed += node.JVM.Memory.HeapUsedInBytes + totalHeapMax += node.JVM.Memory.HeapMaxInBytes + totalGCCollections += node.JVM.GC.Collectors.Young.CollectionCount + node.JVM.GC.Collectors.Old.CollectionCount + totalGCTime += float64(node.JVM.GC.Collectors.Young.CollectionTimeInMillis+node.JVM.GC.Collectors.Old.CollectionTimeInMillis) / 1000 + nodeCount++ + } + + // Update header + statusColor := map[string]string{ + "green": "green", + "yellow": "yellow", + "red": "red", + }[clusterStats.Status] + + // Calculate maxNodeNameLen first + maxNodeNameLen := 20 // default minimum length + for _, nodeInfo := range nodesInfo.Nodes { + if len(nodeInfo.Name) > maxNodeNameLen { + maxNodeNameLen = len(nodeInfo.Name) + } + } + + // Then use it in header formatting + header.Clear() + latestVer := getLatestVersion() + fmt.Fprintf(header, "[#00ffff]Cluster:[white] %s [%s]%s[-]%s[#00ffff]Latest: [white]%s\n", + clusterStats.ClusterName, + statusColor, + strings.ToUpper(clusterStats.Status), + strings.Repeat(" ", maxNodeNameLen-len(clusterStats.ClusterName)), // Add padding + latestVer) + fmt.Fprintf(header, "[#00ffff]Nodes:[white] %d Total, [green]%d[white] Successful, [#ff5555]%d[white] Failed\n", + clusterStats.Nodes.Total, + clusterStats.Nodes.Successful, + clusterStats.Nodes.Failed) + + // Update nodes panel + nodesPanel.Clear() + fmt.Fprintf(nodesPanel, "[::b][#00ffff]Nodes Information[::-]\n\n") + fmt.Fprintf(nodesPanel, "[::b]%-*s [#444444]│[#00ffff] %-13s [#444444]│[#00ffff] %-20s [#444444]│[#00ffff] %-7s [#444444]│[#00ffff] %4s [#444444]│[#00ffff] %4s [#444444]│[#00ffff] %-16s [#444444]│[#00ffff] %-16s [#444444]│[#00ffff] %-16s [#444444]│[#00ffff] %-25s[white]\n", + maxNodeNameLen, + "Node Name", + "Roles", + "Transport Address", + "Version", + "CPU", + "Load", + "Memory", + "Heap", + "Disk ", + "OS") + + // Display nodes with resource usage + for id, nodeInfo := range nodesInfo.Nodes { + nodeStats, exists := nodesStats.Nodes[id] + if !exists { + continue + } + + // Calculate resource percentages and format memory values + cpuPercent := nodeStats.OS.CPU.Percent + memPercent := float64(nodeStats.OS.Memory.UsedInBytes) / float64(nodeStats.OS.Memory.TotalInBytes) * 100 + heapPercent := float64(nodeStats.JVM.Memory.HeapUsedInBytes) / float64(nodeStats.JVM.Memory.HeapMaxInBytes) * 100 + + // Calculate disk usage - use the data path stats + diskTotal := int64(0) + diskAvailable := int64(0) + if len(nodeStats.FS.Data) > 0 { + // Use the first data path's stats - this is the Elasticsearch data directory + diskTotal = nodeStats.FS.Data[0].TotalInBytes // e.g. 5.6TB for r320-1 + diskAvailable = nodeStats.FS.Data[0].AvailableInBytes // e.g. 5.0TB available + } else { + // Fallback to total stats if data path stats aren't available + diskTotal = nodeStats.FS.Total.TotalInBytes + diskAvailable = nodeStats.FS.Total.AvailableInBytes + } + diskUsed := diskTotal - diskAvailable + diskPercent := float64(diskUsed) / float64(diskTotal) * 100 + + versionColor := "yellow" + if compareVersions(nodeInfo.Version, latestVer) { + versionColor = "green" + } + + // Add this request before the nodes panel update + var catNodesStats []CatNodesStats + if err := makeRequest("/_cat/nodes?format=json&h=name,load_1m", &catNodesStats); err != nil { + nodesPanel.SetText(fmt.Sprintf("[red]Error getting cat nodes stats: %v", err)) + return + } + + // Create a map for quick lookup of load averages by node name + nodeLoads := make(map[string]string) + for _, node := range catNodesStats { + nodeLoads[node.Name] = node.Load1m + } + + fmt.Fprintf(nodesPanel, "[#5555ff]%-*s[white] [#444444]│[white] %s [#444444]│[white] [white]%-20s[white] [#444444]│[white] [%s]%-7s[white] [#444444]│[white] [%s]%3d%% [#444444](%d)[white] [#444444]│[white] %4s [#444444]│[white] %4s / %4s [%s]%3d%%[white] [#444444]│[white] %4s / %4s [%s]%3d%%[white] [#444444]│[white] %4s / %4s [%s]%3d%%[white] [#444444]│[white] %s [#bd93f9]%s[white] [#444444](%s)[white]\n", + maxNodeNameLen, + nodeInfo.Name, + formatNodeRoles(nodeInfo.Roles), + nodeInfo.TransportAddress, + versionColor, + nodeInfo.Version, + getPercentageColor(float64(cpuPercent)), + cpuPercent, + nodeInfo.OS.AvailableProcessors, + nodeLoads[nodeInfo.Name], + formatResourceSize(nodeStats.OS.Memory.UsedInBytes, "G"), + formatResourceSize(nodeStats.OS.Memory.TotalInBytes, "G"), + getPercentageColor(memPercent), + int(memPercent), + formatResourceSize(nodeStats.JVM.Memory.HeapUsedInBytes, "G"), + formatResourceSize(nodeStats.JVM.Memory.HeapMaxInBytes, "G"), + getPercentageColor(heapPercent), + int(heapPercent), + formatResourceSize(diskUsed, "G"), + formatResourceSize(diskTotal, "T"), + getPercentageColor(diskPercent), + int(diskPercent), + nodeInfo.OS.PrettyName, + nodeInfo.OS.Version, + nodeInfo.OS.Arch) + } + + // Update indices panel + indicesPanel.Clear() + fmt.Fprintf(indicesPanel, "[::b][#00ffff]Indices Information[::-]\n\n") + fmt.Fprintf(indicesPanel, " [::b]%-20s %15s %12s %8s %8s %-12s %-10s[white]\n", + "Index Name", + "Documents", + "Size", + "Shards", + "Replicas", + "Ingested", + "Rate") + + totalDocs := 0 + totalSize := int64(0) + for _, node := range nodesStats.Nodes { + totalSize += node.FS.Total.TotalInBytes - node.FS.Total.AvailableInBytes + } + + // Get detailed index stats for write operations + var indexWriteStats IndexWriteStats + if err := makeRequest("/_stats", &indexWriteStats); err != nil { + indicesPanel.SetText(fmt.Sprintf("[red]Error getting write stats: %v", err)) + return + } + + // Create a slice to hold indices for sorting + var indices []indexInfo + + // Collect index information + for _, index := range indicesStats { + // Skip hidden indices + if !strings.HasPrefix(index.Index, ".") && index.DocsCount != "0" { + docs := 0 + fmt.Sscanf(index.DocsCount, "%d", &docs) + totalDocs += docs + + // Track document changes + activity, exists := indexActivities[index.Index] + if !exists { + indexActivities[index.Index] = &IndexActivity{ + LastDocsCount: docs, + InitialDocsCount: docs, + StartTime: time.Now(), + } + } else { + activity.LastDocsCount = docs + } + + // Get write operations count and calculate rate + writeOps := int64(0) + indexingRate := float64(0) + if stats, exists := indexWriteStats.Indices[index.Index]; exists { + writeOps = stats.Total.Indexing.IndexTotal + if activity, ok := indexActivities[index.Index]; ok { + timeDiff := time.Since(activity.StartTime).Seconds() + if timeDiff > 0 { + indexingRate = float64(docs-activity.InitialDocsCount) / timeDiff + } + } + } + + indices = append(indices, indexInfo{ + index: index.Index, + health: index.Health, + docs: docs, + storeSize: index.StoreSize, + priShards: index.PriShards, + replicas: index.Replicas, + writeOps: writeOps, + indexingRate: indexingRate, + }) + } + } + + // Sort indices by document count (descending) + sort.Slice(indices, func(i, j int) bool { + return indices[i].docs > indices[j].docs + }) + + // Display sorted indices + for _, idx := range indices { + // Only show purple dot if there's actual indexing happening + writeIcon := "[#444444]⚪" + if idx.indexingRate > 0 { + writeIcon = "[#5555ff]⚫" + } + + // Calculate document changes + activity := indexActivities[idx.index] + ingestedStr := "" + if activity != nil && activity.InitialDocsCount < idx.docs { + docChange := idx.docs - activity.InitialDocsCount + ingestedStr = fmt.Sprintf("[green]+%-11s", formatNumber(docChange)) + } else { + ingestedStr = fmt.Sprintf("%-12s", "") // Empty space if no changes + } + + // Format indexing rate + rateStr := "" + if idx.indexingRate > 0 { + if idx.indexingRate >= 1000 { + rateStr = fmt.Sprintf("[#50fa7b]%.1fk/s", idx.indexingRate/1000) + } else { + rateStr = fmt.Sprintf("[#50fa7b]%.1f/s", idx.indexingRate) + } + } else { + rateStr = "[#444444]0/s" + } + + // Convert the size format before display + sizeStr := convertSizeFormat(idx.storeSize) + + fmt.Fprintf(indicesPanel, "%s [%s]%-20s[white] %15s %12s %8s %8s %s %-10s\n", + writeIcon, + getHealthColor(idx.health), + idx.index, + formatNumber(idx.docs), + sizeStr, + idx.priShards, + idx.replicas, + ingestedStr, + rateStr) + } + + // Calculate total indexing rate for the cluster + totalIndexingRate := float64(0) + for _, idx := range indices { + totalIndexingRate += idx.indexingRate + } + + // Format cluster indexing rate + clusterRateStr := "" + if totalIndexingRate > 0 { + if totalIndexingRate >= 1000000 { + clusterRateStr = fmt.Sprintf("[#50fa7b]%.1fM/s", totalIndexingRate/1000000) + } else if totalIndexingRate >= 1000 { + clusterRateStr = fmt.Sprintf("[#50fa7b]%.1fK/s", totalIndexingRate/1000) + } else { + clusterRateStr = fmt.Sprintf("[#50fa7b]%.1f/s", totalIndexingRate) + } + } else { + clusterRateStr = "[#444444]0/s" + } + + // Display the totals with indexing rate + fmt.Fprintf(indicesPanel, "\n[#00ffff]Total Documents:[white] %s, [#00ffff]Total Size:[white] %s, [#00ffff]Indexing Rate:[white] %s\n", + formatNumber(totalDocs), + bytesToHuman(totalSize), + clusterRateStr) + + // Move shard stats to bottom of indices panel + fmt.Fprintf(indicesPanel, "\n[#00ffff]Shard Status:[white] Active: %d (%.1f%%), Primary: %d, Relocating: %d, Initializing: %d, Unassigned: %d\n", + clusterHealth.ActiveShards, + clusterHealth.ActiveShardsPercentAsNumber, + clusterHealth.ActivePrimaryShards, + clusterHealth.RelocatingShards, + clusterHealth.InitializingShards, + clusterHealth.UnassignedShards) + + // Update metrics panel + metricsPanel.Clear() + fmt.Fprintf(metricsPanel, "[::b][#00ffff]Cluster Metrics[::-]\n\n") + + // Helper function to format metric lines with consistent alignment + formatMetric := func(name string, value string) string { + return fmt.Sprintf("[#00ffff]%-25s[white] %s\n", name+":", value) + } + + // Search metrics + fmt.Fprint(metricsPanel, formatMetric("Search Queries", formatNumber(int(totalQueries)))) + fmt.Fprint(metricsPanel, formatMetric("Query Rate", fmt.Sprintf("%s/s", formatNumber(int(float64(totalQueries)/time.Since(startTime).Seconds()))))) + fmt.Fprint(metricsPanel, formatMetric("Total Query Time", fmt.Sprintf("%.1fs", totalQueryTime))) + fmt.Fprint(metricsPanel, formatMetric("Avg Query Latency", fmt.Sprintf("%.2fms", totalQueryTime*1000/float64(totalQueries+1)))) + + // Indexing metrics + fmt.Fprint(metricsPanel, formatMetric("Index Operations", formatNumber(int(totalIndexing)))) + fmt.Fprint(metricsPanel, formatMetric("Indexing Rate", fmt.Sprintf("%s/s", formatNumber(int(float64(totalIndexing)/time.Since(startTime).Seconds()))))) + fmt.Fprint(metricsPanel, formatMetric("Total Index Time", fmt.Sprintf("%.1fs", totalIndexingTime))) + fmt.Fprint(metricsPanel, formatMetric("Avg Index Latency", fmt.Sprintf("%.2fms", totalIndexingTime*1000/float64(totalIndexing+1)))) + + // GC metrics + fmt.Fprint(metricsPanel, formatMetric("GC Collections", formatNumber(int(totalGCCollections)))) + fmt.Fprint(metricsPanel, formatMetric("Total GC Time", fmt.Sprintf("%.1fs", totalGCTime))) + fmt.Fprint(metricsPanel, formatMetric("Avg GC Time", fmt.Sprintf("%.2fms", totalGCTime*1000/float64(totalGCCollections+1)))) + + // Memory metrics + totalMemoryPercent := float64(totalMemoryUsed) / float64(totalMemoryTotal) * 100 + totalHeapPercent := float64(totalHeapUsed) / float64(totalHeapMax) * 100 + fmt.Fprint(metricsPanel, formatMetric("Memory Usage", fmt.Sprintf("%s / %s (%.1f%%)", bytesToHuman(totalMemoryUsed), bytesToHuman(totalMemoryTotal), totalMemoryPercent))) + fmt.Fprint(metricsPanel, formatMetric("Heap Usage", fmt.Sprintf("%s / %s (%.1f%%)", bytesToHuman(totalHeapUsed), bytesToHuman(totalHeapMax), totalHeapPercent))) + + // Segment metrics + fmt.Fprint(metricsPanel, formatMetric("Total Segments", formatNumber(int(getTotalSegments(nodesStats))))) + fmt.Fprint(metricsPanel, formatMetric("Open File Descriptors", formatNumber(int(getTotalOpenFiles(nodesStats))))) + + // Network metrics + fmt.Fprint(metricsPanel, formatMetric("Network TX", bytesToHuman(getTotalNetworkTX(nodesStats)))) + fmt.Fprint(metricsPanel, formatMetric("Network RX", bytesToHuman(getTotalNetworkRX(nodesStats)))) + + // Disk I/O metrics + totalDiskReads := int64(0) + totalDiskWrites := int64(0) + for _, node := range nodesStats.Nodes { + totalDiskReads += node.FS.DiskReads + totalDiskWrites += node.FS.DiskWrites + } + fmt.Fprint(metricsPanel, formatMetric("Disk Reads", formatNumber(int(totalDiskReads)))) + fmt.Fprint(metricsPanel, formatMetric("Disk Writes", formatNumber(int(totalDiskWrites)))) + + // HTTP connections + totalHTTPConnections := int64(0) + for _, node := range nodesStats.Nodes { + totalHTTPConnections += node.HTTP.CurrentOpen + } + fmt.Fprint(metricsPanel, formatMetric("HTTP Connections", formatNumber(int(totalHTTPConnections)))) + + // Average CPU usage across nodes + avgCPUPercent := float64(totalCPUPercent) / float64(nodeCount) + fmt.Fprint(metricsPanel, formatMetric("Average CPU Usage", fmt.Sprintf("%.1f%%", avgCPUPercent))) + + // Pending tasks + fmt.Fprint(metricsPanel, formatMetric("Pending Tasks", formatNumber(clusterHealth.NumberOfPendingTasks))) + if clusterHealth.TaskMaxWaitingTime != "" && clusterHealth.TaskMaxWaitingTime != "0s" { + fmt.Fprint(metricsPanel, formatMetric("Max Task Wait Time", clusterHealth.TaskMaxWaitingTime)) + } + + // Update roles panel + rolesPanel.Clear() + fmt.Fprintf(rolesPanel, "[::b][#00ffff]Node Roles[::-]\n\n") + + // Create a map of used roles + usedRoles := make(map[string]bool) + for _, nodeInfo := range nodesInfo.Nodes { + for _, role := range nodeInfo.Roles { + usedRoles[role] = true + } + } + + // Display roles in the roles panel + roleLegend := [][2]string{ + {"C", "data_content"}, + {"D", "data"}, + {"F", "data_frozen"}, + {"H", "data_hot"}, + {"I", "ingest"}, + {"K", "data_cold"}, + {"L", "ml"}, + {"M", "master"}, + {"O", "coordinating_only"}, + {"R", "remote_cluster_client"}, + {"T", "transform"}, + {"V", "voting_only"}, + {"W", "data_warm"}, + } + + for _, role := range roleLegend { + if usedRoles[role[1]] { + fmt.Fprintf(rolesPanel, "[%s]%s[white] %s\n", + roleColors[role[1]], + role[0], + legendLabels[role[1]]) + } else { + fmt.Fprintf(rolesPanel, "[#444444]%s %s\n", + role[0], + legendLabels[role[1]]) + } + } + } + + // Set up periodic updates + go func() { + for { + app.QueueUpdateDraw(func() { + update() + }) + time.Sleep(5 * time.Second) + } + }() + + // Handle quit + app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEsc || event.Rune() == 'q' { + app.Stop() + } + return event + }) + + if err := app.SetRoot(grid, true).EnableMouse(true).Run(); err != nil { + panic(err) + } +} + +// Add these helper functions at package level +func getTotalSegments(stats NodesStats) int64 { + var total int64 + for _, node := range stats.Nodes { + total += node.Indices.Segments.Count + } + return total +} + +func getTotalOpenFiles(stats NodesStats) int64 { + var total int64 + for _, node := range stats.Nodes { + total += node.Process.OpenFileDescriptors + } + return total +} + +func getTotalNetworkTX(stats NodesStats) int64 { + var total int64 + for _, node := range stats.Nodes { + total += node.Transport.TxSizeInBytes + } + return total +} + +func getTotalNetworkRX(stats NodesStats) int64 { + var total int64 + for _, node := range stats.Nodes { + total += node.Transport.RxSizeInBytes + } + return total +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9041b25 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module elastop + +go 1.23.2 + +require ( + github.com/gdamore/tcell/v2 v2.7.4 + github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592 +) + +require ( + github.com/gdamore/encoding v1.0.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/term v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..74a393e --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU= +github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592 h1:YIJ+B1hePP6AgynC5TcqpO0H9k3SSoZa2BGyL6vDUzM= +github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +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/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=