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 }