package main import ( "context" "flag" "fmt" "io" "log" "path/filepath" "strconv" "strings" "time" "github.com/dustin/go-humanize" ui "github.com/gizak/termui/v3" "github.com/gizak/termui/v3/widgets" corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/util/homedir" metricsv "k8s.io/metrics/pkg/client/clientset/versioned" ) // Helper function to generate a resource usage graph func generateResourceGraph(value float64, maxValue float64, width int) string { if maxValue <= 0 { maxValue = 1.0 // Avoid division by zero } // Calculate number of segments to fill usedSegments := int(value / maxValue * float64(width)) if usedSegments < 0 { usedSegments = 0 } if usedSegments > width { usedSegments = width } // Build the graph string graph := "" for i := 0; i < usedSegments; i++ { graph += "▉" } for i := usedSegments; i < width; i++ { graph += "░" } return graph } // Helper function to parse CPU and Memory usage for bar graphs func parseResourceUsage(usage string) float64 { if usage == "" { return 0 } // Parse CPU usage (e.g., "125m" to 0.125) if strings.HasSuffix(usage, "m") { value, err := strconv.ParseFloat(strings.TrimSuffix(usage, "m"), 64) if err == nil { return value / 1000 } return 0 } // Parse memory usage (convert to MB for easier display) if strings.HasSuffix(usage, "Mi") { value, err := strconv.ParseFloat(strings.TrimSuffix(usage, "Mi"), 64) if err == nil { return value } } else if strings.HasSuffix(usage, "Gi") { value, err := strconv.ParseFloat(strings.TrimSuffix(usage, "Gi"), 64) if err == nil { return value * 1024 } } else if strings.HasSuffix(usage, "Ki") { value, err := strconv.ParseFloat(strings.TrimSuffix(usage, "Ki"), 64) if err == nil { return value / 1024 } } // Try to parse as a plain number value, err := strconv.ParseFloat(usage, 64) if err == nil { return value } return 0 } // getDefaultKubeconfig returns the default kubeconfig path func getDefaultKubeconfig() string { if home := homedir.HomeDir(); home != "" { return filepath.Join(home, ".kube", "config") } return "" } func main() { // Command line arguments kubeconfig := flag.String("kubeconfig", getDefaultKubeconfig(), "Path to the kubeconfig file") namespaceFlag := flag.String("namespaces", "dev,home,cart", "Comma-separated list of namespaces to monitor") refreshInterval := flag.Int("refresh", 5, "Refresh interval in seconds") flag.Parse() // Parse namespaces into a slice namespaceList := strings.Split(*namespaceFlag, ",") // Show startup message with configurations log.Printf("Starting k8s-btop with settings:") log.Printf("- Kubeconfig: %s", *kubeconfig) log.Printf("- Monitoring namespaces: %s", *namespaceFlag) log.Printf("- Refresh interval: %d seconds", *refreshInterval) config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig) if err != nil { log.Fatalf("Error building kubeconfig: %v", err) } clientset, err := kubernetes.NewForConfig(config) if err != nil { log.Fatalf("Error creating Kubernetes clientset: %v", err) } metricsClient, err := metricsv.NewForConfig(config) if err != nil { log.Printf("Warning: metrics API not available: %v", err) } if err := ui.Init(); err != nil { log.Fatalf("Failed to initialize termui: %v", err) } defer ui.Close() podTable := widgets.NewTable() podTable.Title = "Pod List" podTable.BorderStyle.Fg = ui.ColorGreen podTable.TitleStyle.Fg = ui.ColorWhite podTable.Rows = [][]string{ {"Namespace", "Name", "Status", "Ready", "Restarts", "CPU", "CPU Graph", "Memory", "Memory Graph", "Age", "Node"}, } podTable.RowSeparator = false podTable.ColumnWidths = []int{15, 20, 10, 10, 8, 8, 15, 8, 15, 10, 15} // Create CPU usage bar chart with custom styling cpuChart := widgets.NewBarChart() cpuChart.Title = "CPU Usage (cores)" cpuChart.TitleStyle.Fg = ui.ColorBlue cpuChart.BorderStyle.Fg = ui.ColorBlue cpuChart.BarWidth = 5 cpuChart.BarGap = 1 cpuChart.BarColors = []ui.Color{ui.ColorGreen, ui.ColorYellow, ui.ColorRed} cpuChart.LabelStyles = []ui.Style{ui.NewStyle(ui.ColorBlue)} cpuChart.NumFormatter = func(f float64) string { return fmt.Sprintf("%.2f", f) } // Create memory usage bar chart with custom styling memChart := widgets.NewBarChart() memChart.Title = "Memory Usage (MB)" memChart.TitleStyle.Fg = ui.ColorMagenta memChart.BorderStyle.Fg = ui.ColorMagenta memChart.BarWidth = 5 memChart.BarGap = 1 memChart.BarColors = []ui.Color{ui.ColorGreen, ui.ColorYellow, ui.ColorRed} memChart.LabelStyles = []ui.Style{ui.NewStyle(ui.ColorMagenta)} memChart.NumFormatter = func(f float64) string { return fmt.Sprintf("%.0f", f) } // Create a single grid for the UI grid := ui.NewGrid() termWidth, termHeight := ui.TerminalDimensions() grid.SetRect(0, 0, termWidth, termHeight) updateUI := func() { // Fetch data from Kubernetes pods, err := refreshData(clientset, metricsClient, namespaceList) if err != nil { log.Printf("Error refreshing data: %v", err) } // Initialize table rows with header rows := [][]string{ {"Namespace", "Name", "Status", "Ready", "Restarts", "CPU", "CPU Graph", "Memory", "Memory Graph", "Age", "Node"}, } // Keep track of starting pods for log display var startingPods []PodInfo type PodWithMetrics struct { Pod PodInfo CPUVal float64 MemVal float64 } podMetrics := []PodWithMetrics{} // Process pods if data was fetched successfully if pods != nil { for _, pod := range pods { cpuVal := parseResourceUsage(pod.CPUUsage) memVal := parseResourceUsage(pod.MemoryUsage) // Check if pod is starting or restarting if pod.IsStarting { startingPods = append(startingPods, pod) } // Create CPU usage graph // Find the max value to scale maxCPUVal := 1.0 // Default to 1 core for _, p := range pods { val := parseResourceUsage(p.CPUUsage) if val > maxCPUVal { maxCPUVal = val } } // Generate the CPU usage graph cpuGraph := generateResourceGraph(cpuVal, maxCPUVal, 15) // Create memory usage graph // Find max value to scale maxMemVal := 1024.0 // Default to 1 GB for _, p := range pods { val := parseResourceUsage(p.MemoryUsage) if val > maxMemVal { maxMemVal = val } } // Generate the memory usage graph memGraph := generateResourceGraph(memVal, maxMemVal, 15) rows = append(rows, []string{ pod.Namespace, pod.Name, pod.Status, fmt.Sprintf("%d/%d", pod.ReadyContainers, pod.ContainerCount), fmt.Sprintf("%d", pod.RestartCount), pod.CPUUsage, cpuGraph, pod.MemoryUsage, memGraph, humanize.Time(time.Now().Add(-pod.Age)), pod.Node, }) // We already parsed the metrics above, no need to do it again podMetrics = append(podMetrics, PodWithMetrics{ Pod: pod, CPUVal: cpuVal, MemVal: memVal, }) } } // Update the table and charts podTable.Rows = rows podTable.RowStyles = make(map[int]ui.Style) // Set row styles based on pod status for i, row := range rows { if i == 0 { // Header row - make it bold podTable.RowStyles[i] = ui.NewStyle(ui.ColorWhite, ui.ColorClear, ui.ModifierBold) continue } status := row[2] switch status { case "Running": podTable.RowStyles[i] = ui.NewStyle(ui.ColorGreen) case "Pending": podTable.RowStyles[i] = ui.NewStyle(ui.ColorYellow) case "Failed": podTable.RowStyles[i] = ui.NewStyle(ui.ColorRed) case "Succeeded": podTable.RowStyles[i] = ui.NewStyle(ui.ColorBlue) default: podTable.RowStyles[i] = ui.NewStyle(ui.ColorWhite) } } starting := len(startingPods) logs := make([]interface{}, 0) logSize := 1.0 if starting > 0 { logSize = 0.6 } logs = append(logs, ui.NewCol(logSize, podTable)) for _, pod := range startingPods { logsViewer := widgets.NewParagraph() logsViewer.Title = pod.Name logsViewer.BorderStyle.Fg = ui.ColorYellow logsViewer.TitleStyle.Fg = ui.ColorYellow logsViewer.Text = "No logs to display. Select a starting pod." logsViewer.WrapText = true podLogs := getPodLogs(clientset, startingPods[0], 20) // Show last 20 lines logsViewer.Title = fmt.Sprintf("Logs: %s/%s", startingPods[0].Namespace, startingPods[0].Name) logsViewer.Text = podLogs logs = append(logs, ui.NewCol(0.4/float64(starting), logsViewer)) } grid.Set( ui.NewRow(1.0, logs...), ) } // Initial UI update updateUI() ui.Render(grid) // Set up ticker with configured refresh interval ticker := time.NewTicker(time.Duration(*refreshInterval) * time.Second) defer ticker.Stop() // Set up event handling uiEvents := ui.PollEvents() // Create help text widget but don't display it initially helpText := widgets.NewParagraph() helpText.Title = "Keyboard Shortcuts" helpText.BorderStyle.Fg = ui.ColorCyan helpText.TitleStyle.Fg = ui.ColorCyan helpText.Text = ` q, Ctrl+C : Quit r : Manual refresh ` for { select { case e := <-uiEvents: switch e.ID { case "q", "": return case "r": updateUI() ui.Render(grid) case "": payload := e.Payload.(ui.Resize) grid.SetRect(0, 0, payload.Width, payload.Height) ui.Clear() ui.Render(grid) } case <-ticker.C: updateUI() ui.Render(grid) } } } // Helper function for string slicing func min(a, b int) int { if a < b { return a } return b } // Helper function to get pod logs func getPodLogs(clientset *kubernetes.Clientset, pod PodInfo, lines int64) string { podLogOptions := corev1.PodLogOptions{ TailLines: &lines, Follow: false, // We don't want to block } req := clientset.CoreV1().Pods(pod.Namespace).GetLogs(pod.Name, &podLogOptions) podLogs, err := req.Stream(context.Background()) if err != nil { return fmt.Sprintf("Error getting logs: %v", err) } defer podLogs.Close() buf := new(strings.Builder) _, err = io.Copy(buf, podLogs) if err != nil { return fmt.Sprintf("Error copying logs: %v", err) } return buf.String() }