code
This commit is contained in:
385
main.go
Normal file
385
main.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user