This commit is contained in:
matst80
2025-07-14 16:59:51 +02:00
commit b4abcfd4f8
7 changed files with 852 additions and 0 deletions

385
main.go Normal file
View File

@@ -0,0 +1,385 @@
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", "<C-c>":
return
case "r":
updateUI()
ui.Render(grid)
case "<Resize>":
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()
}