386 lines
9.9 KiB
Go
386 lines
9.9 KiB
Go
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()
|
|
}
|