elastop- Unnamed repository; edit this file 'description' to name the repository. |
git clone git://git.acid.vegas/-c.git |
Log | Files | Refs | Archive | README | LICENSE |
elastop.go (42315B)
1 package main 2 3 import ( 4 "crypto/tls" 5 "crypto/x509" 6 "encoding/json" 7 "flag" 8 "fmt" 9 "io" 10 "net/http" 11 "os" 12 "sort" 13 "strconv" 14 "strings" 15 "time" 16 17 "github.com/gdamore/tcell/v2" 18 "github.com/rivo/tview" 19 ) 20 21 type ClusterStats struct { 22 ClusterName string `json:"cluster_name"` 23 Status string `json:"status"` 24 Indices struct { 25 Count int `json:"count"` 26 Shards struct { 27 Total int `json:"total"` 28 } `json:"shards"` 29 Docs struct { 30 Count int `json:"count"` 31 } `json:"docs"` 32 Store struct { 33 SizeInBytes int64 `json:"size_in_bytes"` 34 TotalSizeInBytes int64 `json:"total_size_in_bytes"` 35 } `json:"store"` 36 } `json:"indices"` 37 Nodes struct { 38 Total int `json:"total"` 39 Successful int `json:"successful"` 40 Failed int `json:"failed"` 41 } `json:"_nodes"` 42 Process struct { 43 CPU struct { 44 Percent int `json:"percent"` 45 } `json:"cpu"` 46 OpenFileDescriptors struct { 47 Min int `json:"min"` 48 Max int `json:"max"` 49 Avg int `json:"avg"` 50 } `json:"open_file_descriptors"` 51 } `json:"process"` 52 Snapshots struct { 53 Count int `json:"count"` 54 } `json:"snapshots"` 55 } 56 57 type NodesInfo struct { 58 Nodes map[string]struct { 59 Name string `json:"name"` 60 TransportAddress string `json:"transport_address"` 61 Version string `json:"version"` 62 Roles []string `json:"roles"` 63 OS struct { 64 AvailableProcessors int `json:"available_processors"` 65 Name string `json:"name"` 66 Arch string `json:"arch"` 67 Version string `json:"version"` 68 PrettyName string `json:"pretty_name"` 69 } `json:"os"` 70 Process struct { 71 ID int `json:"id"` 72 } `json:"process"` 73 } `json:"nodes"` 74 } 75 76 type IndexStats []struct { 77 Index string `json:"index"` 78 Health string `json:"health"` 79 DocsCount string `json:"docs.count"` 80 StoreSize string `json:"store.size"` 81 PriShards string `json:"pri"` 82 Replicas string `json:"rep"` 83 } 84 85 type IndexActivity struct { 86 LastDocsCount int 87 InitialDocsCount int 88 StartTime time.Time 89 } 90 91 type IndexWriteStats struct { 92 Indices map[string]struct { 93 Total struct { 94 Indexing struct { 95 IndexTotal int64 `json:"index_total"` 96 } `json:"indexing"` 97 } `json:"total"` 98 } `json:"indices"` 99 } 100 101 type ClusterHealth struct { 102 ActiveShards int `json:"active_shards"` 103 ActivePrimaryShards int `json:"active_primary_shards"` 104 RelocatingShards int `json:"relocating_shards"` 105 InitializingShards int `json:"initializing_shards"` 106 UnassignedShards int `json:"unassigned_shards"` 107 DelayedUnassignedShards int `json:"delayed_unassigned_shards"` 108 NumberOfPendingTasks int `json:"number_of_pending_tasks"` 109 TaskMaxWaitingTime string `json:"task_max_waiting_time"` 110 ActiveShardsPercentAsNumber float64 `json:"active_shards_percent_as_number"` 111 } 112 113 type NodesStats struct { 114 Nodes map[string]struct { 115 Indices struct { 116 Store struct { 117 SizeInBytes int64 `json:"size_in_bytes"` 118 } `json:"store"` 119 Search struct { 120 QueryTotal int64 `json:"query_total"` 121 QueryTimeInMillis int64 `json:"query_time_in_millis"` 122 } `json:"search"` 123 Indexing struct { 124 IndexTotal int64 `json:"index_total"` 125 IndexTimeInMillis int64 `json:"index_time_in_millis"` 126 } `json:"indexing"` 127 Segments struct { 128 Count int64 `json:"count"` 129 } `json:"segments"` 130 } `json:"indices"` 131 OS struct { 132 CPU struct { 133 Percent int `json:"percent"` 134 } `json:"cpu"` 135 Memory struct { 136 UsedInBytes int64 `json:"used_in_bytes"` 137 FreeInBytes int64 `json:"free_in_bytes"` 138 TotalInBytes int64 `json:"total_in_bytes"` 139 } `json:"mem"` 140 LoadAverage map[string]float64 `json:"load_average"` 141 } `json:"os"` 142 JVM struct { 143 Memory struct { 144 HeapUsedInBytes int64 `json:"heap_used_in_bytes"` 145 HeapMaxInBytes int64 `json:"heap_max_in_bytes"` 146 } `json:"mem"` 147 GC struct { 148 Collectors struct { 149 Young struct { 150 CollectionCount int64 `json:"collection_count"` 151 CollectionTimeInMillis int64 `json:"collection_time_in_millis"` 152 } `json:"young"` 153 Old struct { 154 CollectionCount int64 `json:"collection_count"` 155 CollectionTimeInMillis int64 `json:"collection_time_in_millis"` 156 } `json:"old"` 157 } `json:"collectors"` 158 } `json:"gc"` 159 UptimeInMillis int64 `json:"uptime_in_millis"` 160 } `json:"jvm"` 161 Transport struct { 162 RxSizeInBytes int64 `json:"rx_size_in_bytes"` 163 TxSizeInBytes int64 `json:"tx_size_in_bytes"` 164 RxCount int64 `json:"rx_count"` 165 TxCount int64 `json:"tx_count"` 166 } `json:"transport"` 167 HTTP struct { 168 CurrentOpen int64 `json:"current_open"` 169 } `json:"http"` 170 Process struct { 171 OpenFileDescriptors int64 `json:"open_file_descriptors"` 172 } `json:"process"` 173 FS struct { 174 DiskReads int64 `json:"disk_reads"` 175 DiskWrites int64 `json:"disk_writes"` 176 Total struct { 177 TotalInBytes int64 `json:"total_in_bytes"` 178 FreeInBytes int64 `json:"free_in_bytes"` 179 AvailableInBytes int64 `json:"available_in_bytes"` 180 } `json:"total"` 181 Data []struct { 182 Path string `json:"path"` 183 TotalInBytes int64 `json:"total_in_bytes"` 184 FreeInBytes int64 `json:"free_in_bytes"` 185 AvailableInBytes int64 `json:"available_in_bytes"` 186 } `json:"data"` 187 } `json:"fs"` 188 } `json:"nodes"` 189 } 190 191 type GitHubRelease struct { 192 TagName string `json:"tag_name"` 193 } 194 195 var ( 196 latestVersion string 197 versionCache time.Time 198 ) 199 200 var indexActivities = make(map[string]*IndexActivity) 201 202 var ( 203 showNodes = true 204 showRoles = true 205 showIndices = true 206 showMetrics = true 207 showHiddenIndices = false 208 ) 209 210 var ( 211 header *tview.TextView 212 nodesPanel *tview.TextView 213 rolesPanel *tview.TextView 214 indicesPanel *tview.TextView 215 metricsPanel *tview.TextView 216 ) 217 218 type DataStreamResponse struct { 219 DataStreams []DataStream `json:"data_streams"` 220 } 221 222 type DataStream struct { 223 Name string `json:"name"` 224 Timestamp string `json:"timestamp"` 225 Status string `json:"status"` 226 Template string `json:"template"` 227 } 228 229 var ( 230 apiKey string 231 ) 232 233 type CatNodesStats struct { 234 Load1m string `json:"load_1m"` 235 Name string `json:"name"` 236 } 237 238 func bytesToHuman(bytes int64) string { 239 const unit = 1024 240 if bytes < unit { 241 return fmt.Sprintf("%d B", bytes) 242 } 243 244 units := []string{"B", "K", "M", "G", "T", "P", "E", "Z"} 245 exp := 0 246 val := float64(bytes) 247 248 for val >= unit && exp < len(units)-1 { 249 val /= unit 250 exp++ 251 } 252 253 return fmt.Sprintf("%.1f%s", val, units[exp]) 254 } 255 256 func formatNumber(n int) string { 257 str := fmt.Sprintf("%d", n) 258 259 var result []rune 260 for i, r := range str { 261 if i > 0 && (len(str)-i)%3 == 0 { 262 result = append(result, ',') 263 } 264 result = append(result, r) 265 } 266 return string(result) 267 } 268 269 func convertSizeFormat(sizeStr string) string { 270 var size float64 271 var unit string 272 fmt.Sscanf(sizeStr, "%f%s", &size, &unit) 273 274 unit = strings.ToUpper(strings.TrimSuffix(unit, "b")) 275 276 return fmt.Sprintf("%d%s", int(size), unit) 277 } 278 279 func getPercentageColor(percent float64) string { 280 switch { 281 case percent < 30: 282 return "green" 283 case percent < 70: 284 return "#00ffff" // cyan 285 case percent < 85: 286 return "#ffff00" // yellow 287 default: 288 return "#ff5555" // light red 289 } 290 } 291 292 func getLatestVersion() string { 293 // Only fetch every hour 294 if time.Since(versionCache) < time.Hour && latestVersion != "" { 295 return latestVersion 296 } 297 298 client := &http.Client{Timeout: 5 * time.Second} 299 resp, err := client.Get("https://api.github.com/repos/elastic/elasticsearch/releases/latest") 300 if err != nil { 301 return "" 302 } 303 defer resp.Body.Close() 304 305 var release GitHubRelease 306 if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { 307 return "" 308 } 309 310 latestVersion = strings.TrimPrefix(release.TagName, "v") 311 versionCache = time.Now() 312 return latestVersion 313 } 314 315 func compareVersions(current, latest string) bool { 316 if latest == "" { 317 return true 318 } 319 320 // Clean up version strings 321 current = strings.TrimPrefix(current, "v") 322 latest = strings.TrimPrefix(latest, "v") 323 324 // Split versions into parts 325 currentParts := strings.Split(current, ".") 326 latestParts := strings.Split(latest, ".") 327 328 // Compare each part 329 for i := 0; i < len(currentParts) && i < len(latestParts); i++ { 330 curr, _ := strconv.Atoi(currentParts[i]) 331 lat, _ := strconv.Atoi(latestParts[i]) 332 if curr != lat { 333 return curr >= lat 334 } 335 } 336 return len(currentParts) >= len(latestParts) 337 } 338 339 var roleColors = map[string]string{ 340 "master": "#ff5555", // red 341 "data": "#50fa7b", // green 342 "data_content": "#8be9fd", // cyan 343 "data_hot": "#ffb86c", // orange 344 "data_warm": "#bd93f9", // purple 345 "data_cold": "#f1fa8c", // yellow 346 "data_frozen": "#ff79c6", // pink 347 "ingest": "#87cefa", // light sky blue 348 "ml": "#6272a4", // blue gray 349 "remote_cluster_client": "#dda0dd", // plum 350 "transform": "#689d6a", // forest green 351 "voting_only": "#458588", // teal 352 "coordinating_only": "#d65d0e", // burnt orange 353 } 354 355 var legendLabels = map[string]string{ 356 "master": "Master", 357 "data": "Data", 358 "data_content": "Data Content", 359 "data_hot": "Data Hot", 360 "data_warm": "Data Warm", 361 "data_cold": "Data Cold", 362 "data_frozen": "Data Frozen", 363 "ingest": "Ingest", 364 "ml": "Machine Learning", 365 "remote_cluster_client": "Remote Cluster Client", 366 "transform": "Transform", 367 "voting_only": "Voting Only", 368 "coordinating_only": "Coordinating Only", 369 } 370 371 func formatNodeRoles(roles []string) string { 372 // Define all possible roles and their letters in the desired order 373 roleMap := map[string]string{ 374 "master": "M", 375 "data": "D", 376 "data_content": "C", 377 "data_hot": "H", 378 "data_warm": "W", 379 "data_cold": "K", 380 "data_frozen": "F", 381 "ingest": "I", 382 "ml": "L", 383 "remote_cluster_client": "R", 384 "transform": "T", 385 "voting_only": "V", 386 "coordinating_only": "O", 387 } 388 389 // Create a map of the node's roles for quick lookup 390 nodeRoles := make(map[string]bool) 391 for _, role := range roles { 392 nodeRoles[role] = true 393 } 394 395 // Create ordered list of role keys based on their letters 396 orderedRoles := []string{ 397 "data_content", // C 398 "data", // D 399 "data_frozen", // F 400 "data_hot", // H 401 "ingest", // I 402 "data_cold", // K 403 "ml", // L 404 "master", // M 405 "coordinating_only", // O 406 "remote_cluster_client", // R 407 "transform", // T 408 "voting_only", // V 409 "data_warm", // W 410 } 411 412 result := "" 413 for _, role := range orderedRoles { 414 letter := roleMap[role] 415 if nodeRoles[role] { 416 // Node has this role - use the role's color 417 result += fmt.Sprintf("[%s]%s[white]", roleColors[role], letter) 418 } else { 419 // Node doesn't have this role - use dark grey 420 result += fmt.Sprintf("[#444444]%s[white]", letter) 421 } 422 } 423 424 return result 425 } 426 427 func getHealthColor(health string) string { 428 switch health { 429 case "green": 430 return "green" 431 case "yellow": 432 return "#ffff00" // yellow 433 case "red": 434 return "#ff5555" // light red 435 default: 436 return "white" 437 } 438 } 439 440 type indexInfo struct { 441 index string 442 health string 443 docs int 444 storeSize string 445 priShards string 446 replicas string 447 writeOps int64 448 indexingRate float64 449 } 450 451 func updateGridLayout(grid *tview.Grid, showRoles, showIndices, showMetrics bool) { 452 // Start with clean grid 453 grid.Clear() 454 455 visiblePanels := 0 456 if showRoles { 457 visiblePanels++ 458 } 459 if showIndices { 460 visiblePanels++ 461 } 462 if showMetrics { 463 visiblePanels++ 464 } 465 466 // When only nodes panel is visible, use a single column layout 467 if showNodes && visiblePanels == 0 { 468 grid.SetRows(3, 0) // Header and nodes only 469 grid.SetColumns(0) // Single full-width column 470 471 // Add header and nodes panel 472 grid.AddItem(header, 0, 0, 1, 1, 0, 0, false) 473 grid.AddItem(nodesPanel, 1, 0, 1, 1, 0, 0, false) 474 return 475 } 476 477 // Rest of the layout logic for when bottom panels are visible 478 if showNodes { 479 grid.SetRows(3, 0, 0) // Header, nodes, bottom panels 480 } else { 481 grid.SetRows(3, 0) // Just header and bottom panels 482 } 483 484 // Configure columns based on visible panels 485 switch { 486 case visiblePanels == 3: 487 if showRoles { 488 grid.SetColumns(30, -2, -1) 489 } 490 case visiblePanels == 2: 491 if showRoles { 492 grid.SetColumns(30, 0) 493 } else { 494 grid.SetColumns(-1, -1) 495 } 496 case visiblePanels == 1: 497 grid.SetColumns(0) 498 } 499 500 // Always show header at top spanning all columns 501 grid.AddItem(header, 0, 0, 1, visiblePanels, 0, 0, false) 502 503 // Add nodes panel if visible, spanning all columns 504 if showNodes { 505 grid.AddItem(nodesPanel, 1, 0, 1, visiblePanels, 0, 0, false) 506 } 507 508 // Add bottom panels in their respective positions 509 col := 0 510 if showRoles { 511 row := 1 512 if showNodes { 513 row = 2 514 } 515 grid.AddItem(rolesPanel, row, col, 1, 1, 0, 0, false) 516 col++ 517 } 518 if showIndices { 519 row := 1 520 if showNodes { 521 row = 2 522 } 523 grid.AddItem(indicesPanel, row, col, 1, 1, 0, 0, false) 524 col++ 525 } 526 if showMetrics { 527 row := 1 528 if showNodes { 529 row = 2 530 } 531 grid.AddItem(metricsPanel, row, col, 1, 1, 0, 0, false) 532 } 533 } 534 535 func main() { 536 host := flag.String("host", "http://localhost", "Elasticsearch host URL (e.g., http://localhost or https://example.com)") 537 port := flag.Int("port", 9200, "Elasticsearch port") 538 user := flag.String("user", os.Getenv("ES_USER"), "Elasticsearch username") 539 password := flag.String("password", os.Getenv("ES_PASSWORD"), "Elasticsearch password") 540 flag.StringVar(&apiKey, "apikey", os.Getenv("ES_API_KEY"), "Elasticsearch API key") 541 542 // Add new certificate-related flags 543 certFile := flag.String("cert", "", "Path to client certificate file") 544 keyFile := flag.String("key", "", "Path to client private key file") 545 caFile := flag.String("ca", "", "Path to CA certificate file") 546 skipVerify := flag.Bool("insecure", false, "Skip TLS certificate verification") 547 548 flag.Parse() 549 550 // Validate and process the host URL 551 if !strings.HasPrefix(*host, "http://") && !strings.HasPrefix(*host, "https://") { 552 fmt.Fprintf(os.Stderr, "Error: host must start with http:// or https://\n") 553 os.Exit(1) 554 } 555 556 // Validate authentication methods - only one should be used 557 authMethods := 0 558 if apiKey != "" { 559 authMethods++ 560 } 561 if *user != "" || *password != "" { 562 authMethods++ 563 } 564 if *certFile != "" || *keyFile != "" { 565 authMethods++ 566 } 567 if authMethods > 1 { 568 fmt.Fprintf(os.Stderr, "Error: Cannot use multiple authentication methods simultaneously (API key, username/password, or certificates)\n") 569 os.Exit(1) 570 } 571 572 // Validate certificate files if specified 573 if (*certFile != "" && *keyFile == "") || (*certFile == "" && *keyFile != "") { 574 fmt.Fprintf(os.Stderr, "Error: Both certificate and key files must be specified together\n") 575 os.Exit(1) 576 } 577 578 // Strip any trailing slash from the host 579 *host = strings.TrimRight(*host, "/") 580 581 // Create TLS config 582 tlsConfig := &tls.Config{ 583 InsecureSkipVerify: *skipVerify, 584 } 585 586 // Load client certificates if specified 587 if *certFile != "" && *keyFile != "" { 588 cert, err := tls.LoadX509KeyPair(*certFile, *keyFile) 589 if err != nil { 590 fmt.Fprintf(os.Stderr, "Error loading client certificates: %v\n", err) 591 os.Exit(1) 592 } 593 tlsConfig.Certificates = []tls.Certificate{cert} 594 } 595 596 // Load CA certificate if specified 597 if *caFile != "" { 598 caCert, err := os.ReadFile(*caFile) 599 if err != nil { 600 fmt.Fprintf(os.Stderr, "Error reading CA certificate: %v\n", err) 601 os.Exit(1) 602 } 603 604 caCertPool := x509.NewCertPool() 605 if !caCertPool.AppendCertsFromPEM(caCert) { 606 fmt.Fprintf(os.Stderr, "Error parsing CA certificate\n") 607 os.Exit(1) 608 } 609 tlsConfig.RootCAs = caCertPool 610 } 611 612 // Create custom HTTP client with SSL configuration 613 tr := &http.Transport{ 614 TLSClientConfig: tlsConfig, 615 } 616 client := &http.Client{ 617 Transport: tr, 618 Timeout: time.Second * 10, 619 } 620 621 app := tview.NewApplication() 622 623 // Update the grid layout to use proportional columns 624 grid := tview.NewGrid(). 625 SetRows(3, 0, 0). // Three rows: header, nodes, bottom panels 626 SetColumns(-1, -2, -1). // Three columns for bottom row: roles (1), indices (2), metrics (1) 627 SetBorders(true) 628 629 // Initialize the panels (move initialization to package level) 630 header = tview.NewTextView(). 631 SetDynamicColors(true). 632 SetTextAlign(tview.AlignLeft) 633 634 nodesPanel = tview.NewTextView(). 635 SetDynamicColors(true) 636 637 rolesPanel = tview.NewTextView(). // New panel for roles 638 SetDynamicColors(true) 639 640 indicesPanel = tview.NewTextView(). 641 SetDynamicColors(true) 642 643 metricsPanel = tview.NewTextView(). 644 SetDynamicColors(true) 645 646 // Initial layout 647 updateGridLayout(grid, showRoles, showIndices, showMetrics) 648 649 // Add panels to grid 650 grid.AddItem(header, 0, 0, 1, 3, 0, 0, false). // Header spans all columns 651 AddItem(nodesPanel, 1, 0, 1, 3, 0, 0, false). // Nodes panel spans all columns 652 AddItem(rolesPanel, 2, 0, 1, 1, 0, 0, false). // Roles panel in left column 653 AddItem(indicesPanel, 2, 1, 1, 1, 0, 0, false). // Indices panel in middle column 654 AddItem(metricsPanel, 2, 2, 1, 1, 0, 0, false) // Metrics panel in right column 655 656 // Update function 657 update := func() { 658 baseURL := fmt.Sprintf("%s:%d", *host, *port) 659 660 // Helper function for ES requests 661 makeRequest := func(path string, target interface{}) error { 662 req, err := http.NewRequest("GET", baseURL+path, nil) 663 if err != nil { 664 return err 665 } 666 667 // Set authentication 668 if apiKey != "" { 669 req.Header.Set("Authorization", fmt.Sprintf("ApiKey %s", apiKey)) 670 } else if *user != "" && *password != "" { 671 req.SetBasicAuth(*user, *password) 672 } 673 674 resp, err := client.Do(req) 675 if err != nil { 676 return err 677 } 678 defer resp.Body.Close() 679 680 if resp.StatusCode != http.StatusOK { 681 body, _ := io.ReadAll(resp.Body) 682 return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) 683 } 684 685 body, err := io.ReadAll(resp.Body) 686 if err != nil { 687 return err 688 } 689 return json.Unmarshal(body, target) 690 } 691 692 // Get cluster stats 693 var clusterStats ClusterStats 694 if err := makeRequest("/_cluster/stats", &clusterStats); err != nil { 695 header.SetText(fmt.Sprintf("[red]Error: %v", err)) 696 return 697 } 698 699 // Get nodes info 700 var nodesInfo NodesInfo 701 if err := makeRequest("/_nodes", &nodesInfo); err != nil { 702 nodesPanel.SetText(fmt.Sprintf("[red]Error: %v", err)) 703 return 704 } 705 706 // Get indices stats 707 var indicesStats IndexStats 708 if err := makeRequest("/_cat/indices?format=json", &indicesStats); err != nil { 709 indicesPanel.SetText(fmt.Sprintf("[red]Error: %v", err)) 710 return 711 } 712 713 // Get cluster health 714 var clusterHealth ClusterHealth 715 if err := makeRequest("/_cluster/health", &clusterHealth); err != nil { 716 indicesPanel.SetText(fmt.Sprintf("[red]Error: %v", err)) 717 return 718 } 719 720 // Get nodes stats 721 var nodesStats NodesStats 722 if err := makeRequest("/_nodes/stats", &nodesStats); err != nil { 723 indicesPanel.SetText(fmt.Sprintf("[red]Error: %v", err)) 724 return 725 } 726 727 // Get index write stats 728 var indexWriteStats IndexWriteStats 729 if err := makeRequest("/_stats", &indexWriteStats); err != nil { 730 indicesPanel.SetText(fmt.Sprintf("[red]Error getting write stats: %v", err)) 731 return 732 } 733 734 // Query and indexing metrics 735 var ( 736 totalQueries int64 737 totalQueryTime int64 738 totalIndexing int64 739 totalIndexTime int64 740 totalSegments int64 741 ) 742 743 for _, node := range nodesStats.Nodes { 744 totalQueries += node.Indices.Search.QueryTotal 745 totalQueryTime += node.Indices.Search.QueryTimeInMillis 746 totalIndexing += node.Indices.Indexing.IndexTotal 747 totalIndexTime += node.Indices.Indexing.IndexTimeInMillis 748 totalSegments += node.Indices.Segments.Count 749 } 750 751 queryRate := float64(totalQueries) / float64(totalQueryTime) * 1000 // queries per second 752 indexRate := float64(totalIndexing) / float64(totalIndexTime) * 1000 // docs per second 753 754 // GC metrics 755 var ( 756 totalGCCollections int64 757 totalGCTime int64 758 ) 759 760 for _, node := range nodesStats.Nodes { 761 totalGCCollections += node.JVM.GC.Collectors.Young.CollectionCount + node.JVM.GC.Collectors.Old.CollectionCount 762 totalGCTime += node.JVM.GC.Collectors.Young.CollectionTimeInMillis + node.JVM.GC.Collectors.Old.CollectionTimeInMillis 763 } 764 765 // Update header 766 statusColor := map[string]string{ 767 "green": "green", 768 "yellow": "yellow", 769 "red": "red", 770 }[clusterStats.Status] 771 772 // Get max lengths after fetching node and index info 773 maxNodeNameLen, maxIndexNameLen, maxTransportLen, maxIngestedLen := getMaxLengths(nodesInfo, indicesStats) 774 775 // Update header with dynamic padding 776 header.Clear() 777 latestVer := getLatestVersion() 778 padding := 0 779 if maxNodeNameLen > len(clusterStats.ClusterName) { 780 padding = maxNodeNameLen - len(clusterStats.ClusterName) 781 } 782 fmt.Fprintf(header, "[#00ffff]Cluster :[white] %s [#666666]([%s]%s[-]%s[#666666]) [#00ffff]Latest: [white]%s\n", 783 clusterStats.ClusterName, 784 statusColor, 785 strings.ToUpper(clusterStats.Status), 786 strings.Repeat(" ", padding), 787 latestVer) 788 fmt.Fprintf(header, "[#00ffff]Nodes :[white] %d Total, [green]%d[white] Successful, [#ff5555]%d[white] Failed\n", 789 clusterStats.Nodes.Total, 790 clusterStats.Nodes.Successful, 791 clusterStats.Nodes.Failed) 792 fmt.Fprintf(header, "[#666666]Press 2-5 to toggle panels, 'h' to toggle hidden indices, 'q' to quit[white]\n") 793 794 // Update nodes panel with dynamic width 795 nodesPanel.Clear() 796 fmt.Fprintf(nodesPanel, "[::b][#00ffff][[#ff5555]2[#00ffff]] Nodes Information[::-]\n\n") 797 fmt.Fprint(nodesPanel, getNodesPanelHeader(maxNodeNameLen, maxTransportLen)) 798 799 // Create a sorted slice of node IDs based on node names 800 var nodeIDs []string 801 for id := range nodesInfo.Nodes { 802 nodeIDs = append(nodeIDs, id) 803 } 804 sort.Slice(nodeIDs, func(i, j int) bool { 805 return nodesInfo.Nodes[nodeIDs[i]].Name < nodesInfo.Nodes[nodeIDs[j]].Name 806 }) 807 808 // Update node entries with dynamic width 809 for _, id := range nodeIDs { 810 nodeInfo := nodesInfo.Nodes[id] 811 nodeStats, exists := nodesStats.Nodes[id] 812 if !exists { 813 continue 814 } 815 816 // Calculate resource percentages and format memory values 817 cpuPercent := nodeStats.OS.CPU.Percent 818 memPercent := float64(nodeStats.OS.Memory.UsedInBytes) / float64(nodeStats.OS.Memory.TotalInBytes) * 100 819 heapPercent := float64(nodeStats.JVM.Memory.HeapUsedInBytes) / float64(nodeStats.JVM.Memory.HeapMaxInBytes) * 100 820 821 // Calculate disk usage - use the data path stats 822 diskTotal := int64(0) 823 diskAvailable := int64(0) 824 if len(nodeStats.FS.Data) > 0 { 825 // Use the first data path's stats - this is the Elasticsearch data directory 826 diskTotal = nodeStats.FS.Data[0].TotalInBytes 827 diskAvailable = nodeStats.FS.Data[0].AvailableInBytes 828 } else { 829 // Fallback to total stats if data path stats aren't available 830 diskTotal = nodeStats.FS.Total.TotalInBytes 831 diskAvailable = nodeStats.FS.Total.AvailableInBytes 832 } 833 diskUsed := diskTotal - diskAvailable 834 diskPercent := float64(diskUsed) / float64(diskTotal) * 100 835 836 versionColor := "yellow" 837 if compareVersions(nodeInfo.Version, latestVer) { 838 versionColor = "green" 839 } 840 841 // Add this request before the nodes panel update 842 var catNodesStats []CatNodesStats 843 if err := makeRequest("/_cat/nodes?format=json&h=name,load_1m", &catNodesStats); err != nil { 844 nodesPanel.SetText(fmt.Sprintf("[red]Error getting cat nodes stats: %v", err)) 845 return 846 } 847 848 // Create a map for quick lookup of load averages by node name 849 nodeLoads := make(map[string]string) 850 for _, node := range catNodesStats { 851 nodeLoads[node.Name] = node.Load1m 852 } 853 854 fmt.Fprintf(nodesPanel, "[#5555ff]%-*s [white] [#444444]│[white] %s [#444444]│[white] [white]%*s[white] [#444444]│[white] [%s]%-7s[white] [#444444]│[white] [%s]%3d%% [#444444](%d)[white] [#444444]│[white] %4s / %4s [%s]%3d%%[white] [#444444]│[white] %4s / %4s [%s]%3d%%[white] [#444444]│[white] %4s / %4s [%s]%3d%%[white] [#444444]│[white] %-8s[white] [#444444]│[white] %s [#bd93f9]%s[white] [#444444](%s)[white]\n", 855 maxNodeNameLen, 856 nodeInfo.Name, 857 formatNodeRoles(nodeInfo.Roles), 858 maxTransportLen, 859 nodeInfo.TransportAddress, 860 versionColor, 861 nodeInfo.Version, 862 getPercentageColor(float64(cpuPercent)), 863 cpuPercent, 864 nodeInfo.OS.AvailableProcessors, 865 formatResourceSize(nodeStats.OS.Memory.UsedInBytes), 866 formatResourceSize(nodeStats.OS.Memory.TotalInBytes), 867 getPercentageColor(memPercent), 868 int(memPercent), 869 formatResourceSize(nodeStats.JVM.Memory.HeapUsedInBytes), 870 formatResourceSize(nodeStats.JVM.Memory.HeapMaxInBytes), 871 getPercentageColor(heapPercent), 872 int(heapPercent), 873 formatResourceSize(diskUsed), 874 formatResourceSize(diskTotal), 875 getPercentageColor(diskPercent), 876 int(diskPercent), 877 formatUptime(nodeStats.JVM.UptimeInMillis), 878 nodeInfo.OS.PrettyName, 879 nodeInfo.OS.Version, 880 nodeInfo.OS.Arch) 881 } 882 883 // Get data streams info 884 var dataStreamResp DataStreamResponse 885 if err := makeRequest("/_data_stream", &dataStreamResp); err != nil { 886 indicesPanel.SetText(fmt.Sprintf("[red]Error getting data streams: %v", err)) 887 return 888 } 889 890 // Update indices panel with dynamic width 891 indicesPanel.Clear() 892 fmt.Fprintf(indicesPanel, "[::b][#00ffff][[#ff5555]4[#00ffff]] Indices Information[::-]\n\n") 893 fmt.Fprint(indicesPanel, getIndicesPanelHeader(maxIndexNameLen, maxIngestedLen)) 894 895 // Update index entries with dynamic width 896 var indices []indexInfo 897 var totalDocs int 898 var totalSize int64 899 900 // Collect index information 901 for _, index := range indicesStats { 902 // Skip hidden indices unless showHiddenIndices is true 903 if (!showHiddenIndices && strings.HasPrefix(index.Index, ".")) || index.DocsCount == "0" { 904 continue 905 } 906 docs := 0 907 fmt.Sscanf(index.DocsCount, "%d", &docs) 908 totalDocs += docs 909 910 // Track document changes 911 activity, exists := indexActivities[index.Index] 912 if !exists { 913 indexActivities[index.Index] = &IndexActivity{ 914 LastDocsCount: docs, 915 InitialDocsCount: docs, 916 StartTime: time.Now(), 917 } 918 } else { 919 activity.LastDocsCount = docs 920 } 921 922 // Get write operations count and calculate rate 923 writeOps := int64(0) 924 indexingRate := float64(0) 925 if stats, exists := indexWriteStats.Indices[index.Index]; exists { 926 writeOps = stats.Total.Indexing.IndexTotal 927 if activity, ok := indexActivities[index.Index]; ok { 928 timeDiff := time.Since(activity.StartTime).Seconds() 929 if timeDiff > 0 { 930 indexingRate = float64(docs-activity.InitialDocsCount) / timeDiff 931 } 932 } 933 } 934 935 indices = append(indices, indexInfo{ 936 index: index.Index, 937 health: index.Health, 938 docs: docs, 939 storeSize: index.StoreSize, 940 priShards: index.PriShards, 941 replicas: index.Replicas, 942 writeOps: writeOps, 943 indexingRate: indexingRate, 944 }) 945 } 946 947 // Calculate total size 948 for _, node := range nodesStats.Nodes { 949 totalSize += node.FS.Total.TotalInBytes - node.FS.Total.AvailableInBytes 950 } 951 952 // Sort indices - active ones first, then alphabetically within each group 953 sort.Slice(indices, func(i, j int) bool { 954 // If one is active and the other isn't, active goes first 955 if (indices[i].indexingRate > 0) != (indices[j].indexingRate > 0) { 956 return indices[i].indexingRate > 0 957 } 958 // Within the same group (both active or both inactive), sort alphabetically 959 return indices[i].index < indices[j].index 960 }) 961 962 // Update index entries with dynamic width 963 for _, idx := range indices { 964 writeIcon := "[#444444]⚪" 965 if idx.indexingRate > 0 { 966 writeIcon = "[#5555ff]⚫" 967 } 968 969 // Add data stream indicator 970 streamIndicator := " " 971 if isDataStream(idx.index, dataStreamResp) { 972 streamIndicator = "[#bd93f9]⚫[white]" 973 } 974 975 // Calculate document changes with dynamic padding 976 activity := indexActivities[idx.index] 977 ingestedStr := "" 978 if activity != nil && activity.InitialDocsCount < idx.docs { 979 docChange := idx.docs - activity.InitialDocsCount 980 ingestedStr = fmt.Sprintf("[green]%-*s", maxIngestedLen, fmt.Sprintf("+%s", formatNumber(docChange))) 981 } else { 982 ingestedStr = fmt.Sprintf("%-*s", maxIngestedLen, "") 983 } 984 985 // Format indexing rate 986 rateStr := "" 987 if idx.indexingRate > 0 { 988 if idx.indexingRate >= 1000 { 989 rateStr = fmt.Sprintf("[#50fa7b]%.1fk/s", idx.indexingRate/1000) 990 } else { 991 rateStr = fmt.Sprintf("[#50fa7b]%.1f/s", idx.indexingRate) 992 } 993 } else { 994 rateStr = "[#444444]0/s" 995 } 996 997 // Convert the size format before display 998 sizeStr := convertSizeFormat(idx.storeSize) 999 1000 fmt.Fprintf(indicesPanel, "%s %s[%s]%-*s[white] [#444444]│[white] %13s [#444444]│[white] %5s [#444444]│[white] %6s [#444444]│[white] %8s [#444444]│[white] %-*s [#444444]│[white] %-8s\n", 1001 writeIcon, 1002 streamIndicator, 1003 getHealthColor(idx.health), 1004 maxIndexNameLen, 1005 idx.index, 1006 formatNumber(idx.docs), 1007 sizeStr, 1008 idx.priShards, 1009 idx.replicas, 1010 maxIngestedLen, 1011 ingestedStr, 1012 rateStr) 1013 } 1014 1015 // Calculate total indexing rate for the cluster 1016 totalIndexingRate := float64(0) 1017 for _, idx := range indices { 1018 totalIndexingRate += idx.indexingRate 1019 } 1020 1021 // Format cluster indexing rate 1022 clusterRateStr := "" 1023 if totalIndexingRate > 0 { 1024 if totalIndexingRate >= 1000000 { 1025 clusterRateStr = fmt.Sprintf("[#50fa7b]%.1fM/s", totalIndexingRate/1000000) 1026 } else if totalIndexingRate >= 1000 { 1027 clusterRateStr = fmt.Sprintf("[#50fa7b]%.1fK/s", totalIndexingRate/1000) 1028 } else { 1029 clusterRateStr = fmt.Sprintf("[#50fa7b]%.1f/s", totalIndexingRate) 1030 } 1031 } else { 1032 clusterRateStr = "[#444444]0/s" 1033 } 1034 1035 // Display the totals with indexing rate 1036 fmt.Fprintf(indicesPanel, "\n[#00ffff]Total Documents:[white] %s, [#00ffff]Total Size:[white] %s, [#00ffff]Indexing Rate:[white] %s\n", 1037 formatNumber(totalDocs), 1038 bytesToHuman(totalSize), 1039 clusterRateStr) 1040 1041 // Move shard stats to bottom of indices panel 1042 fmt.Fprintf(indicesPanel, "\n[#00ffff]Shard Status:[white] Active: %d (%.1f%%), Primary: %d, Relocating: %d, Initializing: %d, Unassigned: %d\n", 1043 clusterHealth.ActiveShards, 1044 clusterHealth.ActiveShardsPercentAsNumber, 1045 clusterHealth.ActivePrimaryShards, 1046 clusterHealth.RelocatingShards, 1047 clusterHealth.InitializingShards, 1048 clusterHealth.UnassignedShards) 1049 1050 // Update metrics panel 1051 metricsPanel.Clear() 1052 fmt.Fprintf(metricsPanel, "[::b][#00ffff][[#ff5555]5[#00ffff]] Cluster Metrics[::-]\n\n") 1053 1054 // Define metrics keys with proper grouping 1055 metricKeys := []string{ 1056 // System metrics 1057 "CPU", 1058 "Memory", 1059 "Heap", 1060 "Disk", 1061 1062 // Network metrics 1063 "Network TX", 1064 "Network RX", 1065 "HTTP Connections", 1066 1067 // Performance metrics 1068 "Query Rate", 1069 "Index Rate", 1070 1071 // Miscellaneous 1072 "Snapshots", 1073 } 1074 1075 // Find the longest key for proper alignment 1076 maxKeyLength := 0 1077 for _, key := range metricKeys { 1078 if len(key) > maxKeyLength { 1079 maxKeyLength = len(key) 1080 } 1081 } 1082 1083 // Add padding for better visual separation 1084 maxKeyLength += 2 1085 1086 // Helper function for metric lines with proper alignment 1087 formatMetric := func(name string, value string) string { 1088 return fmt.Sprintf("[#00ffff]%-*s[white] %s\n", maxKeyLength, name+":", value) 1089 } 1090 1091 // CPU metrics 1092 totalProcessors := 0 1093 for _, node := range nodesInfo.Nodes { 1094 totalProcessors += node.OS.AvailableProcessors 1095 } 1096 cpuPercent := float64(clusterStats.Process.CPU.Percent) 1097 fmt.Fprint(metricsPanel, formatMetric("CPU", fmt.Sprintf("%7.1f%% [#444444](%d processors)[white]", cpuPercent, totalProcessors))) 1098 1099 // Disk metrics 1100 diskUsed := getTotalSize(nodesStats) 1101 diskTotal := getTotalDiskSpace(nodesStats) 1102 diskPercent := float64(diskUsed) / float64(diskTotal) * 100 1103 fmt.Fprint(metricsPanel, formatMetric("Disk", fmt.Sprintf("%8s / %8s [%s]%5.1f%%[white]", 1104 bytesToHuman(diskUsed), 1105 bytesToHuman(diskTotal), 1106 getPercentageColor(diskPercent), 1107 diskPercent))) 1108 1109 // Calculate heap and memory totals 1110 var ( 1111 totalHeapUsed int64 1112 totalHeapMax int64 1113 totalMemoryUsed int64 1114 totalMemoryTotal int64 1115 ) 1116 1117 for _, node := range nodesStats.Nodes { 1118 totalHeapUsed += node.JVM.Memory.HeapUsedInBytes 1119 totalHeapMax += node.JVM.Memory.HeapMaxInBytes 1120 totalMemoryUsed += node.OS.Memory.UsedInBytes 1121 totalMemoryTotal += node.OS.Memory.TotalInBytes 1122 } 1123 1124 // Heap metrics 1125 heapPercent := float64(totalHeapUsed) / float64(totalHeapMax) * 100 1126 fmt.Fprint(metricsPanel, formatMetric("Heap", fmt.Sprintf("%8s / %8s [%s]%5.1f%%[white]", 1127 bytesToHuman(totalHeapUsed), 1128 bytesToHuman(totalHeapMax), 1129 getPercentageColor(heapPercent), 1130 heapPercent))) 1131 1132 // Memory metrics 1133 memoryPercent := float64(totalMemoryUsed) / float64(totalMemoryTotal) * 100 1134 fmt.Fprint(metricsPanel, formatMetric("Memory", fmt.Sprintf("%8s / %8s [%s]%5.1f%%[white]", 1135 bytesToHuman(totalMemoryUsed), 1136 bytesToHuman(totalMemoryTotal), 1137 getPercentageColor(memoryPercent), 1138 memoryPercent))) 1139 1140 // Network metrics 1141 fmt.Fprint(metricsPanel, formatMetric("Network TX", fmt.Sprintf(" %7s", bytesToHuman(getTotalNetworkTX(nodesStats))))) 1142 fmt.Fprint(metricsPanel, formatMetric("Network RX", fmt.Sprintf(" %7s", bytesToHuman(getTotalNetworkRX(nodesStats))))) 1143 1144 // HTTP Connections and Shard metrics - right aligned to match Network RX 'G' 1145 fmt.Fprint(metricsPanel, formatMetric("HTTP Connections", fmt.Sprintf("%8s", formatNumber(int(getTotalHTTPConnections(nodesStats)))))) 1146 fmt.Fprint(metricsPanel, formatMetric("Query Rate", fmt.Sprintf("%6s/s", formatNumber(int(queryRate))))) 1147 fmt.Fprint(metricsPanel, formatMetric("Index Rate", fmt.Sprintf("%6s/s", formatNumber(int(indexRate))))) 1148 1149 // Snapshots 1150 fmt.Fprint(metricsPanel, formatMetric("Snapshots", fmt.Sprintf("%8s", formatNumber(clusterStats.Snapshots.Count)))) 1151 1152 if showRoles { 1153 updateRolesPanel(rolesPanel, nodesInfo) 1154 } 1155 } 1156 1157 // Set up periodic updates 1158 go func() { 1159 for { 1160 app.QueueUpdateDraw(func() { 1161 update() 1162 }) 1163 time.Sleep(5 * time.Second) 1164 } 1165 }() 1166 1167 // Handle quit 1168 app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 1169 switch event.Key() { 1170 case tcell.KeyEsc: 1171 app.Stop() 1172 case tcell.KeyRune: 1173 switch event.Rune() { 1174 case 'q': 1175 app.Stop() 1176 case '2': 1177 showNodes = !showNodes 1178 updateGridLayout(grid, showRoles, showIndices, showMetrics) 1179 case '3': 1180 showRoles = !showRoles 1181 updateGridLayout(grid, showRoles, showIndices, showMetrics) 1182 case '4': 1183 showIndices = !showIndices 1184 updateGridLayout(grid, showRoles, showIndices, showMetrics) 1185 case '5': 1186 showMetrics = !showMetrics 1187 updateGridLayout(grid, showRoles, showIndices, showMetrics) 1188 case 'h': 1189 showHiddenIndices = !showHiddenIndices 1190 // Let the regular update cycle handle it 1191 } 1192 } 1193 return event 1194 }) 1195 1196 if err := app.SetRoot(grid, true).EnableMouse(true).Run(); err != nil { 1197 panic(err) 1198 } 1199 } 1200 1201 func getTotalNetworkTX(stats NodesStats) int64 { 1202 var total int64 1203 for _, node := range stats.Nodes { 1204 total += node.Transport.TxSizeInBytes 1205 } 1206 return total 1207 } 1208 1209 func getTotalNetworkRX(stats NodesStats) int64 { 1210 var total int64 1211 for _, node := range stats.Nodes { 1212 total += node.Transport.RxSizeInBytes 1213 } 1214 return total 1215 } 1216 1217 func getMaxLengths(nodesInfo NodesInfo, indicesStats IndexStats) (int, int, int, int) { 1218 maxNodeNameLen := 0 1219 maxIndexNameLen := 0 1220 maxTransportLen := 0 1221 maxIngestedLen := 8 // Start with "Ingested" header length 1222 1223 // Get max node name and transport address length 1224 for _, nodeInfo := range nodesInfo.Nodes { 1225 if len(nodeInfo.Name) > maxNodeNameLen { 1226 maxNodeNameLen = len(nodeInfo.Name) 1227 } 1228 if len(nodeInfo.TransportAddress) > maxTransportLen { 1229 maxTransportLen = len(nodeInfo.TransportAddress) 1230 } 1231 } 1232 1233 // Get max index name length and calculate max ingested length 1234 for _, index := range indicesStats { 1235 if (showHiddenIndices || !strings.HasPrefix(index.Index, ".")) && index.DocsCount != "0" { 1236 if len(index.Index) > maxIndexNameLen { 1237 maxIndexNameLen = len(index.Index) 1238 } 1239 1240 docs := 0 1241 fmt.Sscanf(index.DocsCount, "%d", &docs) 1242 if activity := indexActivities[index.Index]; activity != nil { 1243 if activity.InitialDocsCount < docs { 1244 docChange := docs - activity.InitialDocsCount 1245 ingestedStr := fmt.Sprintf("+%s", formatNumber(docChange)) 1246 if len(ingestedStr) > maxIngestedLen { 1247 maxIngestedLen = len(ingestedStr) 1248 } 1249 } 1250 } 1251 } 1252 } 1253 1254 // Add padding 1255 maxNodeNameLen += 2 1256 maxIndexNameLen += 1 // Changed from 2 to 1 for minimal padding 1257 maxTransportLen += 2 1258 maxIngestedLen += 1 // Minimal padding for ingested column 1259 1260 return maxNodeNameLen, maxIndexNameLen, maxTransportLen, maxIngestedLen 1261 } 1262 1263 func getNodesPanelHeader(maxNodeNameLen, maxTransportLen int) string { 1264 return fmt.Sprintf("[::b]%-*s [#444444]│[#00ffff] %-13s [#444444]│[#00ffff] %*s [#444444]│[#00ffff] %-7s [#444444]│[#00ffff] %-9s [#444444]│[#00ffff] %-16s [#444444]│[#00ffff] %-16s [#444444]│[#00ffff] %-16s [#444444]│[#00ffff] %-6s [#444444]│[#00ffff] %-25s[white]\n", 1265 maxNodeNameLen, 1266 "Node Name", 1267 "Roles", 1268 maxTransportLen, 1269 "Transport Address", 1270 "Version", 1271 "CPU", 1272 "Memory", 1273 "Heap", 1274 "Disk", 1275 "Uptime", 1276 "OS") 1277 } 1278 1279 func getIndicesPanelHeader(maxIndexNameLen, maxIngestedLen int) string { 1280 return fmt.Sprintf(" [::b] %-*s [#444444]│[#00ffff] %13s [#444444]│[#00ffff] %5s [#444444]│[#00ffff] %6s [#444444]│[#00ffff] %8s [#444444]│[#00ffff] %-*s [#444444][#00ffff] %-8s[white]\n", 1281 maxIndexNameLen, 1282 "Index Name", 1283 "Documents", 1284 "Size", 1285 "Shards", 1286 "Replicas", 1287 maxIngestedLen, 1288 "Ingested", 1289 "Rate") 1290 } 1291 1292 func isDataStream(name string, dataStreams DataStreamResponse) bool { 1293 for _, ds := range dataStreams.DataStreams { 1294 if ds.Name == name { 1295 return true 1296 } 1297 } 1298 return false 1299 } 1300 1301 func getTotalSize(stats NodesStats) int64 { 1302 var total int64 1303 for _, node := range stats.Nodes { 1304 if len(node.FS.Data) > 0 { 1305 total += node.FS.Data[0].TotalInBytes - node.FS.Data[0].AvailableInBytes 1306 } 1307 } 1308 return total 1309 } 1310 1311 func getTotalDiskSpace(stats NodesStats) int64 { 1312 var total int64 1313 for _, node := range stats.Nodes { 1314 if len(node.FS.Data) > 0 { 1315 total += node.FS.Data[0].TotalInBytes 1316 } 1317 } 1318 return total 1319 } 1320 1321 func formatUptime(uptimeMillis int64) string { 1322 uptime := time.Duration(uptimeMillis) * time.Millisecond 1323 days := int(uptime.Hours() / 24) 1324 hours := int(uptime.Hours()) % 24 1325 minutes := int(uptime.Minutes()) % 60 1326 1327 var result string 1328 if days > 0 { 1329 result = fmt.Sprintf("%d[#ff99cc]d[white]%d[#ff99cc]h[white]", days, hours) 1330 } else if hours > 0 { 1331 result = fmt.Sprintf("%d[#ff99cc]h[white]%d[#ff99cc]m[white]", hours, minutes) 1332 } else { 1333 result = fmt.Sprintf("%d[#ff99cc]m[white]", minutes) 1334 } 1335 1336 // Calculate the actual display length by removing all color codes in one pass 1337 displayLen := len(strings.NewReplacer( 1338 "[#ff99cc]", "", 1339 "[white]", "", 1340 ).Replace(result)) 1341 1342 // Add padding to make all uptime strings align (6 chars for display) 1343 padding := 6 - displayLen 1344 if padding > 0 { 1345 result = strings.TrimRight(result, " ") + strings.Repeat(" ", padding) 1346 } 1347 1348 return result 1349 } 1350 1351 func getTotalHTTPConnections(stats NodesStats) int64 { 1352 var total int64 1353 for _, node := range stats.Nodes { 1354 total += node.HTTP.CurrentOpen 1355 } 1356 return total 1357 } 1358 1359 func updateRolesPanel(rolesPanel *tview.TextView, nodesInfo NodesInfo) { 1360 rolesPanel.Clear() 1361 fmt.Fprintf(rolesPanel, "[::b][#00ffff][[#ff5555]3[#00ffff]] Legend[::-]\n\n") 1362 1363 // Add Node Roles title in cyan 1364 fmt.Fprintf(rolesPanel, "[::b][#00ffff]Node Roles[::-]\n") 1365 1366 // Define role letters (same as in formatNodeRoles) 1367 roleMap := map[string]string{ 1368 "master": "M", 1369 "data": "D", 1370 "data_content": "C", 1371 "data_hot": "H", 1372 "data_warm": "W", 1373 "data_cold": "K", 1374 "data_frozen": "F", 1375 "ingest": "I", 1376 "ml": "L", 1377 "remote_cluster_client": "R", 1378 "transform": "T", 1379 "voting_only": "V", 1380 "coordinating_only": "O", 1381 } 1382 1383 // Create a map of active roles in the cluster 1384 activeRoles := make(map[string]bool) 1385 for _, node := range nodesInfo.Nodes { 1386 for _, role := range node.Roles { 1387 activeRoles[role] = true 1388 } 1389 } 1390 1391 // Sort roles alphabetically by their letters 1392 var roles []string 1393 for role := range legendLabels { 1394 roles = append(roles, role) 1395 } 1396 sort.Slice(roles, func(i, j int) bool { 1397 return roleMap[roles[i]] < roleMap[roles[j]] 1398 }) 1399 1400 // Display each role with its color and description 1401 for _, role := range roles { 1402 color := roleColors[role] 1403 label := legendLabels[role] 1404 letter := roleMap[role] 1405 1406 // If role is not active in cluster, use grey color for the label 1407 labelColor := "[white]" 1408 if !activeRoles[role] { 1409 labelColor = "[#444444]" 1410 } 1411 1412 fmt.Fprintf(rolesPanel, "[%s]%s[white] %s%s\n", color, letter, labelColor, label) 1413 } 1414 1415 // Add version status information 1416 fmt.Fprintf(rolesPanel, "\n[::b][#00ffff]Version Status[::-]\n") 1417 fmt.Fprintf(rolesPanel, "[green]⚫[white] Up to date\n") 1418 fmt.Fprintf(rolesPanel, "[yellow]⚫[white] Outdated\n") 1419 1420 // Add index health status information 1421 fmt.Fprintf(rolesPanel, "\n[::b][#00ffff]Index Health[::-]\n") 1422 fmt.Fprintf(rolesPanel, "[green]⚫[white] All shards allocated\n") 1423 fmt.Fprintf(rolesPanel, "[#ffff00]⚫[white] Replica shards unallocated\n") 1424 fmt.Fprintf(rolesPanel, "[#ff5555]⚫[white] Primary shards unallocated\n") 1425 1426 // Add index status indicators 1427 fmt.Fprintf(rolesPanel, "\n[::b][#00ffff]Index Status[::-]\n") 1428 fmt.Fprintf(rolesPanel, "[#5555ff]⚫[white] Active indexing\n") 1429 fmt.Fprintf(rolesPanel, "[#444444]⚪[white] No indexing\n") 1430 fmt.Fprintf(rolesPanel, "[#bd93f9]⚫[white] Data stream\n") 1431 } 1432 1433 func formatResourceSize(bytes int64) string { 1434 const unit = 1024 1435 if bytes < unit { 1436 return fmt.Sprintf("%4d B", bytes) 1437 } 1438 1439 units := []string{"B", "K", "M", "G", "T", "P"} 1440 exp := 0 1441 val := float64(bytes) 1442 1443 for val >= unit && exp < len(units)-1 { 1444 val /= unit 1445 exp++ 1446 } 1447 1448 return fmt.Sprintf("%3d%s", int(val), units[exp]) 1449 }