diff --git a/README.md b/README.md index 1384a95..86f66fb 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Here are some references to get you started: ### 🎯 Real-Time Analysis - **Live streaming** - Process logs as they arrive from stdin, files, or network +- **Kubernetes native** - Direct integration with Kubernetes clusters for pod log streaming - **OTLP native** - First-class support for OpenTelemetry log format - **OTLP receiver** - Built-in gRPC server to receive logs via OpenTelemetry protocol - **Format detection** - Automatically detects JSON, logfmt, and plain text @@ -68,6 +69,7 @@ Here are some references to get you started: - **Regex support** - Filter logs with regular expressions - **Attribute search** - Find logs by specific attribute values - **Severity filtering** - Interactive modal to select specific log levels (Ctrl+f) +- **Kubernetes filtering** - Filter by namespace and pod with interactive selection (Ctrl+k) - **Multi-level selection** - Enable/disable multiple severity levels at once - **Interactive selection** - Click or keyboard navigate to explore logs @@ -144,7 +146,12 @@ gonzo -f "/var/log/*.log" --follow # Analyze logs from stdin (traditional way) cat application.log | gonzo -# Stream logs from kubectl +# Stream logs directly from Kubernetes clusters +gonzo --k8s-enabled=true --k8s-namespace=default +gonzo --k8s-enabled=true --k8s-namespace=production --k8s-namespace=staging +gonzo --k8s-enabled=true --k8s-selector="app=my-app" + +# Stream logs from kubectl (traditional way) kubectl logs -f deployment/my-app | gonzo # Follow system logs @@ -311,8 +318,9 @@ cat logs.json | gonzo --ai-model="gpt-4" | `/` | Enter filter mode (regex supported) | | `s` | Search and highlight text in logs | | `Ctrl+f` | Open severity filter modal | +| `Ctrl+k` | Open Kubernetes filter modal (k8s mode) | | `f` | Open fullscreen log viewer modal | -| `c` | Toggle Host/Service columns in log view | +| `c` | Toggle Namespace/Pod or Host/Service cols | | `r` | Reset all data (manual reset) | | `u` / `U` | Cycle update intervals (forward/backward) | | `i` | AI analysis (in detail view) | @@ -415,6 +423,16 @@ Flags: --ai-model string AI model for analysis (auto-selects best available if not specified) -s, --skin string Color scheme/skin to use (default, or name of a skin file) --stop-words strings Additional stop words to filter out from analysis (adds to built-in list) + +Kubernetes Flags: + --k8s-enabled=true Enable Kubernetes log streaming mode + --k8s-namespace stringArray Kubernetes namespace(s) to watch (can specify multiple, default: all) + --k8s-selector string Kubernetes label selector for filtering pods + --k8s-tail int Number of previous log lines to retrieve (default: 10) + --k8s-since int Only return logs newer than relative duration in seconds + --k8s-kubeconfig string Path to kubeconfig file (default: $HOME/.kube/config) + --k8s-context string Kubernetes context to use + -t, --test-mode Run without TTY for testing -v, --version Print version information --config string Config file (default: $HOME/.config/gonzo/config.yml) @@ -669,7 +687,7 @@ internal/ ### Prerequisites -- Go 1.21 or higher +- Go 1.25 or higher - Make (optional, for convenience) ### Building @@ -768,6 +786,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - [Docs](https://docs.controltheory.com/) - Complete user guide, integration examples, advanced features (AI, OTel, custom log formats) - [Usage Guide](USAGE_GUIDE.md) - Detailed usage instructions +- [Kubernetes Integration Guide](guides/KUBERNETES_USAGE.md) - Direct Kubernetes cluster integration, filtering, and usage examples - [AWS CloudWatch Logs Usage Guide](guides/CLOUDWATCH_USAGE_GUIDE.md) - Usage instructions for AWS CLI log tail and live tail with Gonzo - [Stern Usage Guide](guides/STERN_USAGE_GUIDE.md) - Usage and examples for using Stern with Gonzo - [Victoria Logs Integration](guides/VICTORIA_LOGS_USAGE.md) - Using Gonzo with Victoria Logs API diff --git a/cmd/gonzo/app.go b/cmd/gonzo/app.go index c2c4c26..e2a09b4 100644 --- a/cmd/gonzo/app.go +++ b/cmd/gonzo/app.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "fmt" + "io" "log" "os" "strings" @@ -12,6 +13,7 @@ import ( "github.com/control-theory/gonzo/internal/analyzer" "github.com/control-theory/gonzo/internal/filereader" "github.com/control-theory/gonzo/internal/formats" + "github.com/control-theory/gonzo/internal/k8s" "github.com/control-theory/gonzo/internal/memory" "github.com/control-theory/gonzo/internal/otlplog" "github.com/control-theory/gonzo/internal/otlpreceiver" @@ -21,6 +23,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" + "k8s.io/klog/v2" ) // runApp initializes and runs the application @@ -31,6 +34,15 @@ func runApp(cmd *cobra.Command, args []string) error { return nil } + // Redirect log output to discard to avoid messing up the TUI + // All log.Printf calls will be silently discarded + log.SetOutput(io.Discard) + + // Suppress klog output from Kubernetes client-go + // This prevents errors like "request.go:752" from appearing in the TUI + klog.SetOutput(io.Discard) + klog.LogToStderr(false) + // Start version checking in background (if not disabled) var versionChecker *versioncheck.Checker if !cfg.DisableVersionCheck { @@ -181,6 +193,10 @@ type simpleTuiModel struct { vmlogsReceiver *vmlogs.Receiver // Victoria Logs receiver for streaming logs hasVmlogsInput bool // Whether we're receiving Victoria Logs data + // Kubernetes receiver support + k8sReceiver *k8s.KubernetesLogSource // Kubernetes log source for streaming pod logs + hasK8sInput bool // Whether we're receiving Kubernetes logs + // JSON accumulation for multi-line OTLP support jsonBuffer strings.Builder // Buffer for accumulating multi-line JSON jsonDepth int // Track JSON object/array nesting depth @@ -195,8 +211,45 @@ func (m *simpleTuiModel) Init() tea.Cmd { // Initialize frequency reset timer m.lastFreqReset = time.Now() - // Check if Victoria Logs receiver is enabled - if cfg.VmlogsURL != "" { + // Check if Kubernetes receiver is enabled + if cfg.K8sEnabled { + // Kubernetes input mode + m.hasK8sInput = true + m.inputChan = make(chan string, 100) + + // Create kubernetes config + k8sConfig := &k8s.Config{ + Kubeconfig: cfg.K8sKubeconfig, + Context: cfg.K8sContext, + Namespaces: cfg.K8sNamespaces, + Selector: cfg.K8sSelector, + Since: cfg.K8sSince, + TailLines: cfg.K8sTailLines, + } + + // Create and start Kubernetes log source + k8sSource, err := k8s.NewKubernetesLogSource(k8sConfig) + if err != nil { + log.Printf("Error creating Kubernetes log source: %v", err) + // Fall back to other input methods if Kubernetes fails + m.hasK8sInput = false + } else { + m.k8sReceiver = k8sSource + if err := m.k8sReceiver.Start(); err != nil { + log.Printf("Error starting Kubernetes log source: %v", err) + // Fall back to other input methods if Kubernetes fails + m.hasK8sInput = false + } else { + // Wire K8s source to the dashboard for namespace/pod listing + m.dashboard.SetK8sSource(k8sSource) + // Start reading from Kubernetes receiver in the background + go m.readK8sAsync() + } + } + } + + // Check if Victoria Logs receiver is enabled (only if Kubernetes is not enabled) + if !m.hasK8sInput && cfg.VmlogsURL != "" { // Victoria Logs input mode m.hasVmlogsInput = true m.inputChan = make(chan string, 100) @@ -215,8 +268,8 @@ func (m *simpleTuiModel) Init() tea.Cmd { } } - // Check if OTLP receiver is enabled (only if Victoria Logs is not enabled) - if !m.hasVmlogsInput && cfg.OTLPEnabled { + // Check if OTLP receiver is enabled (only if Kubernetes and Victoria Logs are not enabled) + if !m.hasK8sInput && !m.hasVmlogsInput && cfg.OTLPEnabled { // OTLP input mode m.hasOTLPInput = true m.inputChan = make(chan string, 100) @@ -233,8 +286,8 @@ func (m *simpleTuiModel) Init() tea.Cmd { } } - // Check if we have file inputs specified (only if Victoria Logs and OTLP are not enabled) - if !m.hasVmlogsInput && !m.hasOTLPInput && len(cfg.Files) > 0 { + // Check if we have file inputs specified (only if Kubernetes, Victoria Logs and OTLP are not enabled) + if !m.hasK8sInput && !m.hasVmlogsInput && !m.hasOTLPInput && len(cfg.Files) > 0 { // File input mode m.hasFileInput = true m.inputChan = make(chan string, 100) @@ -252,8 +305,8 @@ func (m *simpleTuiModel) Init() tea.Cmd { } } - // If no Victoria Logs, no OTLP, no file input or file input failed, check stdin - if !m.hasVmlogsInput && !m.hasOTLPInput && !m.hasFileInput { + // If no Kubernetes, no Victoria Logs, no OTLP, no file input or file input failed, check stdin + if !m.hasK8sInput && !m.hasVmlogsInput && !m.hasOTLPInput && !m.hasFileInput { // Check if stdin has data available (not a terminal) stat, _ := os.Stdin.Stat() if (stat.Mode() & os.ModeCharDevice) == 0 { @@ -274,13 +327,46 @@ func (m *simpleTuiModel) Init() tea.Cmd { cmds = append(cmds, m.periodicUpdate()) // Start checking for input data if we have any input source - if m.hasStdinData || m.hasFileInput || m.hasOTLPInput || m.hasVmlogsInput { + if m.hasStdinData || m.hasFileInput || m.hasOTLPInput || m.hasVmlogsInput || m.hasK8sInput { cmds = append(cmds, m.checkInputChannel()) } return tea.Batch(cmds...) } +// readK8sAsync reads from the Kubernetes log source +func (m *simpleTuiModel) readK8sAsync() { + defer close(m.inputChan) + + if m.k8sReceiver == nil { + return + } + + // Get the channel from Kubernetes receiver + k8sLineChan := m.k8sReceiver.GetLineChan() + + // Forward lines from Kubernetes receiver to input channel + for { + select { + case <-m.ctx.Done(): + m.k8sReceiver.Stop() + return + case line, ok := <-k8sLineChan: + if !ok { + // Kubernetes receiver finished + return + } + if line != "" { + select { + case m.inputChan <- line: + case <-m.ctx.Done(): + return + } + } + } + } +} + // readVmlogsAsync reads from the Victoria Logs receiver func (m *simpleTuiModel) readVmlogsAsync() { defer close(m.inputChan) @@ -495,7 +581,7 @@ func (m *simpleTuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.processLogLine(string(msg)) // Continue checking for more data if we have input sources - if (m.hasStdinData || m.hasFileInput || m.hasOTLPInput || m.hasVmlogsInput) && !m.finished { + if (m.hasStdinData || m.hasFileInput || m.hasOTLPInput || m.hasVmlogsInput || m.hasK8sInput) && !m.finished { cmds = append(cmds, m.checkInputChannel()) } diff --git a/cmd/gonzo/main.go b/cmd/gonzo/main.go index e542f47..a50ed8f 100644 --- a/cmd/gonzo/main.go +++ b/cmd/gonzo/main.go @@ -41,6 +41,13 @@ type Config struct { VmlogsUser string `mapstructure:"vmlogs-user"` VmlogsPassword string `mapstructure:"vmlogs-password"` VmlogsQuery string `mapstructure:"vmlogs-query"` + K8sEnabled bool `mapstructure:"k8s-enabled"` + K8sKubeconfig string `mapstructure:"k8s-kubeconfig"` + K8sContext string `mapstructure:"k8s-context"` + K8sNamespaces []string `mapstructure:"k8s-namespaces"` + K8sSelector string `mapstructure:"k8s-selector"` + K8sSince int64 `mapstructure:"k8s-since"` + K8sTailLines int64 `mapstructure:"k8s-tail-lines"` Skin string `mapstructure:"skin"` StopWords []string `mapstructure:"stop-words"` Format string `mapstructure:"format"` @@ -71,9 +78,18 @@ Supports OTLP (OpenTelemetry) format natively, with automatic detection of JSON, # Use glob patterns to read multiple files gonzo -f "/var/log/*.log" --follow - # Stream logs from kubectl + # Stream logs from kubectl kubectl logs -f deployment/my-app | gonzo - + + # Stream logs directly from Kubernetes + gonzo --k8s-enabled + + # Stream logs from specific namespaces + gonzo --k8s-enabled --k8s-namespaces=default,production + + # Stream logs with label selector + gonzo --k8s-enabled --k8s-selector="app=myapp,env=prod" + # With custom settings gonzo -f logs.json --update-interval=2s --log-buffer=2000 @@ -153,6 +169,13 @@ func init() { rootCmd.Flags().String("vmlogs-user", "", "Victoria Logs basic auth username (can also use GONZO_VMLOGS_USER env var)") rootCmd.Flags().String("vmlogs-password", "", "Victoria Logs basic auth password (can also use GONZO_VMLOGS_PASSWORD env var)") rootCmd.Flags().String("vmlogs-query", "*", "Victoria Logs query (LogsQL) to use for streaming (default: '*' for all logs)") + rootCmd.Flags().Bool("k8s-enabled", false, "Enable Kubernetes log streaming from pods") + rootCmd.Flags().String("k8s-kubeconfig", "", "Path to kubeconfig file (default: $KUBECONFIG or ~/.kube/config)") + rootCmd.Flags().String("k8s-context", "", "Kubernetes context to use (default: current context)") + rootCmd.Flags().StringSlice("k8s-namespaces", []string{}, "Kubernetes namespaces to watch (default: all namespaces)") + rootCmd.Flags().String("k8s-selector", "", "Label selector to filter pods (e.g., 'app=myapp,env=prod')") + rootCmd.Flags().Int64("k8s-since", 0, "Only show logs newer than this many seconds (default: 0 = all)") + rootCmd.Flags().Int64("k8s-tail-lines", 10, "Lines of recent logs to show initially per pod (default: 10, use -1 for all)") rootCmd.Flags().StringP("skin", "s", "default", "Color scheme/skin to use (default, or name of a skin file in ~/.config/gonzo/skins/)") rootCmd.Flags().StringSlice("stop-words", []string{}, "Additional stop words to filter out from analysis (adds to built-in list)") rootCmd.Flags().String("format", "", "Log format to use (auto-detect if not specified). Can be: otlp, json, text, or a custom format name from ~/.config/gonzo/formats/") @@ -174,6 +197,13 @@ func init() { viper.BindPFlag("vmlogs-user", rootCmd.Flags().Lookup("vmlogs-user")) viper.BindPFlag("vmlogs-password", rootCmd.Flags().Lookup("vmlogs-password")) viper.BindPFlag("vmlogs-query", rootCmd.Flags().Lookup("vmlogs-query")) + viper.BindPFlag("k8s-enabled", rootCmd.Flags().Lookup("k8s-enabled")) + viper.BindPFlag("k8s-kubeconfig", rootCmd.Flags().Lookup("k8s-kubeconfig")) + viper.BindPFlag("k8s-context", rootCmd.Flags().Lookup("k8s-context")) + viper.BindPFlag("k8s-namespaces", rootCmd.Flags().Lookup("k8s-namespaces")) + viper.BindPFlag("k8s-selector", rootCmd.Flags().Lookup("k8s-selector")) + viper.BindPFlag("k8s-since", rootCmd.Flags().Lookup("k8s-since")) + viper.BindPFlag("k8s-tail-lines", rootCmd.Flags().Lookup("k8s-tail-lines")) viper.BindPFlag("skin", rootCmd.Flags().Lookup("skin")) viper.BindPFlag("stop-words", rootCmd.Flags().Lookup("stop-words")) viper.BindPFlag("format", rootCmd.Flags().Lookup("format")) diff --git a/examples/k8s_config.yml b/examples/k8s_config.yml new file mode 100644 index 0000000..cdd1c78 --- /dev/null +++ b/examples/k8s_config.yml @@ -0,0 +1,329 @@ +# Gonzo Kubernetes Configuration Example +# +# This file demonstrates all available Kubernetes integration options. +# Save to ~/.config/gonzo/config.yml to use as default configuration. +# +# You can override any setting with command line flags: +# gonzo --k8s-enabled=true --k8s-namespace=production + +# ========================================== +# Kubernetes Configuration +# ========================================== + +k8s: + # Enable Kubernetes log streaming mode + # When true, Gonzo will connect to your Kubernetes cluster and stream pod logs + # Default: false + enabled: true + + # Kubernetes namespaces to watch + # - Empty list or omit to watch ALL namespaces (requires cluster-wide permissions) + # - Specify one or more namespaces to limit scope + # - Can also use --k8s-namespace flag (can be specified multiple times) + # Examples: + # [] # Watch all namespaces + # ["default"] # Watch only default namespace + # ["production", "staging"] # Watch multiple namespaces + namespaces: + - production + - staging + + # Kubernetes label selector for filtering pods + # Uses standard Kubernetes label selector syntax + # - Empty string watches all pods (in selected namespaces) + # - Can also use --k8s-selector flag + # Examples: + # "" # All pods + # "app=nginx" # Single label + # "app=nginx,tier=frontend" # Multiple labels (AND) + # "environment in (production,staging)" # Set-based selector + # "tier notin (test,dev)" # Exclusion + # "critical" # Label exists + # "!experimental" # Label does not exist + selector: "app=nginx,tier=frontend" + + # Number of previous log lines to retrieve per pod + # When Gonzo starts, it will fetch this many historical log lines from each pod + # - Set to 0 to only show new logs (no history) + # - Set to -1 to fetch all available logs (can be slow for long-running pods) + # - Can also use --k8s-tail flag + # Default: 10 + tail: 50 + + # Only show logs newer than a relative duration (in seconds) + # Filters logs based on Kubernetes log timestamps + # - Set to 0 to show all logs (respects tail setting) + # - Useful for focusing on recent activity + # - Can also use --k8s-since flag + # Examples: + # 300 # Last 5 minutes + # 3600 # Last hour + # 86400 # Last 24 hours + # Default: 0 (no time filtering) + since: 3600 + + # Path to kubeconfig file + # - Defaults to $HOME/.kube/config if not specified + # - Can use KUBECONFIG environment variable + # - Supports standard kubeconfig merge semantics + # - Can also use --k8s-kubeconfig flag + # Examples: + # ~/.kube/config # Standard location + # /path/to/custom/kubeconfig # Custom location + # "" # Use default or KUBECONFIG env var + kubeconfig: ~/.kube/config + + # Kubernetes context to use from kubeconfig + # - Empty string uses current context from kubeconfig + # - Allows switching between clusters without changing kubeconfig + # - Can also use --k8s-context flag + # Examples: + # "" # Use current context + # "production-cluster" # Specific cluster + # "minikube" # Local development + context: production-cluster + +# ========================================== +# General Gonzo Configuration +# ========================================== + +# Dashboard update interval +# How often the TUI refreshes its display +# Default: 1s +update-interval: 1s + +# Maximum log entries to keep in buffer +# Older logs are pruned when buffer is full +# Higher values use more memory but keep more history +# Default: 1000 +log-buffer: 2000 + +# Maximum frequency analysis entries +# Controls memory used for word/attribute frequency tracking +# Default: 10000 +memory-size: 15000 + +# ========================================== +# UI Customization +# ========================================== + +# Color scheme/theme +# Available themes: default, dracula, nord, monokai, github-light, solarized-light, etc. +# Custom themes can be placed in ~/.config/gonzo/skins/ +# Default: default +skin: dracula + +# Reverse scroll wheel direction +# When true, scroll wheel up = move selection down (natural scrolling) +# Default: false +reverse-scroll-wheel: false + +# ========================================== +# AI Configuration +# ========================================== + +# AI model for log analysis +# - Requires OPENAI_API_KEY environment variable +# - Auto-selects best available if not specified +# - Supports OpenAI, LM Studio, Ollama, or any OpenAI-compatible API +# Examples: +# "" # Auto-select (recommended) +# "gpt-4" # OpenAI GPT-4 +# "gpt-3.5-turbo" # OpenAI GPT-3.5 +# "llama3" # Ollama +# "mistral" # Ollama Mistral +ai-model: "gpt-4" + +# ========================================== +# Advanced Configuration +# ========================================== + +# Additional stop words to filter from frequency analysis +# These words will be excluded from the word frequency chart +# Adds to built-in stop word list (common words like "the", "a", "is", etc.) +stop-words: + - log + - message + - debug + - info + - error + - warn + - timestamp + +# Format specification for log parsing +# - "auto" detects format automatically (JSON, OTLP, logfmt, plain text) +# - Can specify custom format name from ~/.config/gonzo/formats/ +# - Kubernetes logs are typically auto-detected as JSON or plain text +# Default: auto +format: auto + +# Test mode (disable TTY requirements) +# Useful for CI/CD or non-interactive environments +# Default: false +test-mode: false + +# ========================================== +# Example Use Cases +# ========================================== + +# Example 1: Production Monitoring +# k8s: +# enabled: true +# namespaces: ["production"] +# selector: "tier=backend" +# tail: 100 +# since: 3600 # Last hour only +# context: production-cluster +# log-buffer: 5000 +# ai-model: "gpt-4" + +# Example 2: Multi-Environment Development +# k8s: +# enabled: true +# namespaces: ["dev", "staging"] +# selector: "app=myapp" +# tail: 20 +# context: dev-cluster +# log-buffer: 1000 + +# Example 3: Troubleshooting Specific Pod +# k8s: +# enabled: true +# namespaces: ["production"] +# selector: "app=api,version=v2.0.0,critical=true" +# tail: 500 +# since: 300 # Last 5 minutes +# context: production-cluster +# log-buffer: 3000 + +# Example 4: Cluster-Wide Monitoring (Requires Broad Permissions) +# k8s: +# enabled: true +# namespaces: [] # All namespaces +# selector: "critical=true" # Only critical pods across all namespaces +# tail: 50 +# context: production-cluster +# log-buffer: 10000 + +# ========================================== +# Command Line Override Examples +# ========================================== + +# All settings can be overridden via command line flags: +# +# Watch specific namespace: +# gonzo --k8s --k8s-namespace=production +# +# Multiple namespaces: +# gonzo --k8s --k8s-namespace=prod --k8s-namespace=staging +# +# With label selector: +# gonzo --k8s --k8s-selector="app=nginx,tier=frontend" +# +# Recent logs only: +# gonzo --k8s --k8s-tail=100 --k8s-since=3600 +# +# Different cluster: +# gonzo --k8s --k8s-context=staging-cluster +# +# Custom kubeconfig: +# gonzo --k8s --k8s-kubeconfig=/path/to/kubeconfig +# +# All options combined: +# gonzo --k8s \ +# --k8s-namespace=production \ +# --k8s-selector="app=api" \ +# --k8s-tail=100 \ +# --k8s-since=3600 \ +# --k8s-context=prod-cluster \ +# --log-buffer=5000 \ +# --ai-model="gpt-4" + +# ========================================== +# Environment Variables +# ========================================== + +# Kubernetes-specific: +# KUBECONFIG - Path to kubeconfig file +# KUBE_CONTEXT - Default Kubernetes context +# +# Gonzo general: +# OPENAI_API_KEY - API key for AI analysis +# OPENAI_API_BASE - Custom API endpoint (for LM Studio, Ollama, etc.) +# GONZO_UPDATE_INTERVAL - Override update interval +# GONZO_LOG_BUFFER - Override log buffer size +# GONZO_AI_MODEL - Override AI model +# +# Example usage: +# export KUBECONFIG=/path/to/kubeconfig +# export KUBE_CONTEXT=production-cluster +# export OPENAI_API_KEY=sk-your-key-here +# gonzo --k8s + +# ========================================== +# Kubernetes RBAC Requirements +# ========================================== + +# Your Kubernetes user/service account needs these permissions: +# +# apiVersion: rbac.authorization.k8s.io/v1 +# kind: ClusterRole +# metadata: +# name: gonzo-log-reader +# rules: +# - apiGroups: [""] +# resources: ["pods", "pods/log"] +# verbs: ["get", "list", "watch"] +# - apiGroups: [""] +# resources: ["namespaces"] +# verbs: ["get", "list"] +# +# For namespace-specific access: +# +# apiVersion: rbac.authorization.k8s.io/v1 +# kind: Role +# metadata: +# name: gonzo-log-reader +# namespace: production +# rules: +# - apiGroups: [""] +# resources: ["pods", "pods/log"] +# verbs: ["get", "list", "watch"] + +# ========================================== +# Interactive Features +# ========================================== + +# While running, use these keyboard shortcuts: +# +# Ctrl+k - Open Kubernetes filter modal +# * Tab to switch between Namespaces/Pods +# * Arrow keys to navigate +# * Space to toggle selection +# * Enter to apply +# * ESC to cancel +# +# Ctrl+f - Open severity filter modal +# / - Filter by message/attributes (regex) +# s - Search and highlight +# c - Toggle Namespace/Pod columns +# f - Fullscreen log viewer +# ? - Show help + +# ========================================== +# More Information +# ========================================== + +# Documentation: +# - Main README: ../README.md +# - Kubernetes Guide: ../guides/KUBERNETES_USAGE.md +# - Usage Guide: ../USAGE_GUIDE.md +# +# Examples: +# - OTLP Config: ./otlp_example.yml +# - Basic Config: ./config.yml +# +# Support: +# - GitHub: https://github.com/control-theory/gonzo +# - Slack: https://ctrltheorycommunity.slack.com +# - Docs: https://docs.controltheory.com/ diff --git a/flake.lock b/flake.lock index 5e87a9e..f5313a8 100644 --- a/flake.lock +++ b/flake.lock @@ -39,14 +39,16 @@ "gomod2nix": { "inputs": { "flake-utils": "flake-utils_2", - "nixpkgs": "nixpkgs" + "nixpkgs": [ + "nixpkgs" + ] }, "locked": { - "lastModified": 1759991118, - "narHash": "sha256-pDyrtUQyeP1lVTMIYqJtftzDtsXEZaJjYy9ZQ/SGhL8=", + "lastModified": 1763982521, + "narHash": "sha256-ur4QIAHwgFc0vXiaxn5No/FuZicxBr2p0gmT54xZkUQ=", "owner": "nix-community", "repo": "gomod2nix", - "rev": "7f8d7438f5870eb167abaf2c39eea3d2302019d1", + "rev": "02e63a239d6eabd595db56852535992c898eba72", "type": "github" }, "original": { @@ -57,27 +59,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1755864489, - "narHash": "sha256-ojoEEg2c8gTyV+9sZzPWiMgtsXoqTkY/8k7tEqn9TQo=", + "lastModified": 1764517877, + "narHash": "sha256-pp3uT4hHijIC8JUK5MEqeAWmParJrgBVzHLNfJDZxg4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "f24c0439ea1296e295445e0d8117d282bdacaa9a", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "master", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_2": { - "locked": { - "lastModified": 1762111121, - "narHash": "sha256-4vhDuZ7OZaZmKKrnDpxLZZpGIJvAeMtK6FKLJYUtAdw=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "b3d51a0365f6695e7dd5cdf3e180604530ed33b4", + "rev": "2d293cbfa5a793b4c50d17c05ef9e385b90edf6c", "type": "github" }, "original": { @@ -91,7 +77,7 @@ "inputs": { "flake-utils": "flake-utils", "gomod2nix": "gomod2nix", - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs" } }, "systems": { diff --git a/flake.nix b/flake.nix index cd188de..73cc30e 100644 --- a/flake.nix +++ b/flake.nix @@ -4,6 +4,7 @@ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; inputs.flake-utils.url = "github:numtide/flake-utils"; inputs.gomod2nix.url = "github:nix-community/gomod2nix"; + inputs.gomod2nix.inputs.nixpkgs.follows = "nixpkgs"; outputs = { self, diff --git a/go.mod b/go.mod index 3cc3af7..bc1371d 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/control-theory/gonzo -go 1.23.0 - -toolchain go1.23.5 +go 1.25.0 require ( github.com/NimbleMarkets/ntcharts v0.3.1 @@ -16,6 +14,11 @@ require ( go.opentelemetry.io/proto/otlp v1.7.0 google.golang.org/grpc v1.74.2 google.golang.org/protobuf v1.36.7 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.34.2 + k8s.io/apimachinery v0.34.2 + k8s.io/client-go v0.34.2 + k8s.io/klog/v2 v2.130.1 ) require ( @@ -25,37 +28,68 @@ require ( github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.43.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect golang.org/x/text v0.28.0 // indirect + golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index c5747ed..0fd5a7b 100644 --- a/go.sum +++ b/go.sum @@ -25,24 +25,47 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= @@ -53,28 +76,53 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jaeyo/go-drain3 v0.1.2 h1:fY21wgbwhzzaoRNSQ+6HVbpYw4KkAYjCFCoERYozIJ8= github.com/jaeyo/go-drain3 v0.1.2/go.mod h1:6xr/0Dmq3BglAIZ5tDKiQiZvXevU1rE+qpfYZic9h9Y= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e h1:OLwZ8xVaeVrru0xyeuOX+fne0gQTFEGlzfNjipCbxlU= github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e/go.mod h1:NQ34EGeu8FAYGBMDzwhfNJL8YQYoWZP5xYJPRDAwN3E= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -97,12 +145,25 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= @@ -117,20 +178,59 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ= google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw= google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= @@ -142,5 +242,30 @@ google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= +k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= +k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= +k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/gomod2nix.toml b/gomod2nix.toml index 2f443af..88dcf54 100644 --- a/gomod2nix.toml +++ b/gomod2nix.toml @@ -31,15 +31,48 @@ schema = 3 [mod."github.com/charmbracelet/x/term"] version = "v0.2.1" hash = "sha256-VBkCZLI90PhMasftGw3403IqoV7d3E5WEGAIVrN5xQM=" + [mod."github.com/davecgh/go-spew"] + version = "v1.1.1" + hash = "sha256-nhzSUrE1fCkN0+RL04N4h8jWmRFPPPWbCuDc7Ss0akI=" + [mod."github.com/emicklei/go-restful/v3"] + version = "v3.12.2" + hash = "sha256-eQ0qtVH7c5jgqB7F9B17GhZujYelBA2g9KwpPuSS0sE=" [mod."github.com/erikgeiser/coninput"] version = "v0.0.0-20211004153227-1c3628e74d0f" hash = "sha256-OWSqN1+IoL73rWXWdbbcahZu8n2al90Y3eT5Z0vgHvU=" [mod."github.com/fsnotify/fsnotify"] version = "v1.9.0" hash = "sha256-WtpE1N6dpHwEvIub7Xp/CrWm0fd6PX7MKA4PV44rp2g=" + [mod."github.com/fxamacker/cbor/v2"] + version = "v2.9.0" + hash = "sha256-/IZK76MRCrz9XCiilieH5tKaLnIWyPJhwxDoVKB8dFc=" + [mod."github.com/go-logr/logr"] + version = "v1.4.3" + hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" + [mod."github.com/go-openapi/jsonpointer"] + version = "v0.21.0" + hash = "sha256-bB8XTzo4hzXemi8Ey3tIXia3mfn38bvwIzKYLJYC650=" + [mod."github.com/go-openapi/jsonreference"] + version = "v0.20.2" + hash = "sha256-klWZKK7LZqSg3HMIrSkjh/NwaZTr+8kTW2ok2+JlioE=" + [mod."github.com/go-openapi/swag"] + version = "v0.23.0" + hash = "sha256-D5CzsSQ3SYJLwXT6BDahnG66LI8du59Dy1mY4KutA7A=" [mod."github.com/go-viper/mapstructure/v2"] version = "v2.4.0" hash = "sha256-lLfcV9z4n94hDhgyXJlde4bFB0hfzlbh+polqcJCwGE=" + [mod."github.com/gogo/protobuf"] + version = "v1.3.2" + hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" + [mod."github.com/google/gnostic-models"] + version = "v0.7.0" + hash = "sha256-sxShRxqOUVlz9IkAz0C/NP/7CmLBW3ffwoZTdh+rIOc=" + [mod."github.com/google/go-cmp"] + version = "v0.7.0" + hash = "sha256-JbxZFBFGCh/Rj5XZ1vG94V2x7c18L8XKB0N9ZD5F2rM=" + [mod."github.com/google/uuid"] + version = "v1.6.0" + hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=" [mod."github.com/grpc-ecosystem/grpc-gateway/v2"] version = "v2.26.3" hash = "sha256-j/nyE8OgiwZ1YP0h3gmmxY1Fx4GDMCohi9g9kgNz4U8=" @@ -52,12 +85,21 @@ schema = 3 [mod."github.com/jaeyo/go-drain3"] version = "v0.1.2" hash = "sha256-mBQensNJV8/yKb8cqJTk7QG9B8PwN3NoZcWmvSv3vPo=" + [mod."github.com/josharian/intern"] + version = "v1.0.0" + hash = "sha256-LJR0QE2vOQ2/2shBbO5Yl8cVPq+NFrE3ers0vu9FRP0=" + [mod."github.com/json-iterator/go"] + version = "v1.1.12" + hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM=" [mod."github.com/lrstanley/bubblezone"] version = "v0.0.0-20240914071701-b48c55a5e78e" hash = "sha256-FoYUk7NlM5r1yYuhVPVSj72dWVHX1gb6v8Doqw0jx3I=" [mod."github.com/lucasb-eyer/go-colorful"] version = "v1.2.0" hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" + [mod."github.com/mailru/easyjson"] + version = "v0.7.7" + hash = "sha256-NVCz8MURpxgOjHXqxOZExqV4bnpHggpeAOyZDArjcy4=" [mod."github.com/mattn/go-isatty"] version = "v0.0.20" hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" @@ -67,6 +109,12 @@ schema = 3 [mod."github.com/mattn/go-runewidth"] version = "v0.0.16" hash = "sha256-NC+ntvwIpqDNmXb7aixcg09il80ygq6JAnW0Gb5b/DQ=" + [mod."github.com/modern-go/concurrent"] + version = "v0.0.0-20180306012644-bacd9c7ef1dd" + hash = "sha256-OTySieAgPWR4oJnlohaFTeK1tRaVp/b0d1rYY8xKMzo=" + [mod."github.com/modern-go/reflect2"] + version = "v1.0.3-0.20250322232337-35a7c28c31ee" + hash = "sha256-0pkWWZRB3lGFyzmlxxrm0KWVQo9HNXNafaUu3k+rE1g=" [mod."github.com/muesli/ansi"] version = "v0.0.0-20230316100256-276c6243b2f6" hash = "sha256-qRKn0Bh2yvP0QxeEMeZe11Vz0BPFIkVcleKsPeybKMs=" @@ -76,15 +124,21 @@ schema = 3 [mod."github.com/muesli/termenv"] version = "v0.16.0" hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI=" + [mod."github.com/munnerz/goautoneg"] + version = "v0.0.0-20191010083416-a7dc8b61c822" + hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q=" [mod."github.com/pelletier/go-toml/v2"] version = "v2.2.3" hash = "sha256-fE++SVgnCGdnFZoROHWuYjIR7ENl7k9KKxQrRTquv/o=" + [mod."github.com/pkg/errors"] + version = "v0.9.1" + hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw=" + [mod."github.com/pmezard/go-difflib"] + version = "v1.0.0" + hash = "sha256-/FtmHnaGjdvEIKAJtrUfEhV7EVo5A/eYrtdnUkuxLDA=" [mod."github.com/rivo/uniseg"] version = "v0.4.7" hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=" - [mod."github.com/rogpeppe/go-internal"] - version = "v1.13.1" - hash = "sha256-fD4n3XVDNHL7hfUXK9qi31LpBVzWnRK/7LNc3BmPtnU=" [mod."github.com/sagikazarmark/locafero"] version = "v0.7.0" hash = "sha256-ZmaGOKHDw18jJqdkwQwSpUT11F9toR6KPs3241TONeY=" @@ -109,6 +163,9 @@ schema = 3 [mod."github.com/subosito/gotenv"] version = "v1.6.0" hash = "sha256-LspbjTniiq2xAICSXmgqP7carwlNaLqnCTQfw2pa80A=" + [mod."github.com/x448/float16"] + version = "v0.8.4" + hash = "sha256-VKzMTMS9pIB/cwe17xPftCSK9Mf4Y6EuBEJlB4by5mE=" [mod."github.com/xo/terminfo"] version = "v0.0.0-20220910002029-abceb7e1c41e" hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" @@ -124,18 +181,33 @@ schema = 3 [mod."go.uber.org/multierr"] version = "v1.11.0" hash = "sha256-Lb6rHHfR62Ozg2j2JZy3MKOMKdsfzd1IYTR57r3Mhp0=" + [mod."go.yaml.in/yaml/v2"] + version = "v2.4.2" + hash = "sha256-oC8RWdf1zbMYCtmR0ATy/kCkhIwPR9UqFZSMOKLVF/A=" + [mod."go.yaml.in/yaml/v3"] + version = "v3.0.4" + hash = "sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4=" [mod."golang.org/x/net"] version = "v0.43.0" hash = "sha256-bf3iQFrsC8BoarVaS0uSspEFAcr1zHp1uziTtBpwV34=" + [mod."golang.org/x/oauth2"] + version = "v0.30.0" + hash = "sha256-btD7BUtQpOswusZY5qIU90uDo38buVrQ0tmmQ8qNHDg=" [mod."golang.org/x/sync"] version = "v0.16.0" hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" [mod."golang.org/x/sys"] version = "v0.35.0" hash = "sha256-ZKM8pesQE6NAFZeKQ84oPn5JMhGr8g4TSwLYAsHMGSI=" + [mod."golang.org/x/term"] + version = "v0.34.0" + hash = "sha256-faLolF6EUSSaC0ZwRiKH5JF/TmtcMQ+m+RWWl6Pk1PU=" [mod."golang.org/x/text"] version = "v0.28.0" hash = "sha256-8UlJniGK+km4Hmrw6XMxELnExgrih7+z8tU26Cntmto=" + [mod."golang.org/x/time"] + version = "v0.9.0" + hash = "sha256-ipaWVIk1+DZg0rfCzBSkz/Y6DEnB7xkX2RRYycHkhC0=" [mod."google.golang.org/genproto/googleapis/api"] version = "v0.0.0-20250528174236-200df99c418a" hash = "sha256-VO7Rko8b/zO2sm6vML7hhxi9laPilt6JEab8xl4qIN8=" @@ -148,6 +220,42 @@ schema = 3 [mod."google.golang.org/protobuf"] version = "v1.36.7" hash = "sha256-6xCU+t2AVPcscMKenVs4etGqutYGPDXCQ3DCD3PpTq4=" + [mod."gopkg.in/evanphx/json-patch.v4"] + version = "v4.12.0" + hash = "sha256-rUOokb3XW30ftpHp0fsF2WiJln1S0FSt2El7fTHq3CM=" + [mod."gopkg.in/inf.v0"] + version = "v0.9.1" + hash = "sha256-z84XlyeWLcoYOvWLxPkPFgLkpjyb2Y4pdeGMyySOZQI=" [mod."gopkg.in/yaml.v3"] version = "v3.0.1" hash = "sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU=" + [mod."k8s.io/api"] + version = "v0.34.2" + hash = "sha256-8TpPB07Dh6REOxYzT78OGLCV1iTZlMVzEQCzmpA4Q3U=" + [mod."k8s.io/apimachinery"] + version = "v0.34.2" + hash = "sha256-y6GVugdaX81lHwVWgDgFoIi/v6K9A3YC2kKlGUritVU=" + [mod."k8s.io/client-go"] + version = "v0.34.2" + hash = "sha256-ELhaXsMrieNn0ms84n5kL9QlNDXQ2u8xpbny+bTvdZg=" + [mod."k8s.io/klog/v2"] + version = "v2.130.1" + hash = "sha256-n5vls1o1a0V0KYv+3SULq4q3R2Is15K8iDHhFlsSH4o=" + [mod."k8s.io/kube-openapi"] + version = "v0.0.0-20250710124328-f3f2b991d03b" + hash = "sha256-0v0MN67pora3UCA8vXSJDgkosx/1RaB2HkjUnwdVLbY=" + [mod."k8s.io/utils"] + version = "v0.0.0-20250604170112-4c0f3b243397" + hash = "sha256-USPKRYYKfbhQWU0CgT/8V1hdBirWjmhTZvJCuuUlNFo=" + [mod."sigs.k8s.io/json"] + version = "v0.0.0-20241014173422-cfa47c3a1cc8" + hash = "sha256-dkegDkyjp/niYirIdhbQrBYt/uttCZQAfsBzKSzOMh0=" + [mod."sigs.k8s.io/randfill"] + version = "v1.0.0" + hash = "sha256-xldQxDwW84hmlihdSOFfjXyauhxEWV9KmIDLZMTcYNo=" + [mod."sigs.k8s.io/structured-merge-diff/v6"] + version = "v6.3.0" + hash = "sha256-2EqUZSaHUhwTrdjoZuv+Z99tZYrX1E6rxf2ejeKd2BM=" + [mod."sigs.k8s.io/yaml"] + version = "v1.6.0" + hash = "sha256-49hg7IVPzwxeovp+HTMiWa/10NMMTSTjAdCmIv6p9dw=" diff --git a/guides/KUBERNETES_USAGE.md b/guides/KUBERNETES_USAGE.md new file mode 100644 index 0000000..b47d69d --- /dev/null +++ b/guides/KUBERNETES_USAGE.md @@ -0,0 +1,431 @@ +# Kubernetes Integration Guide + +Gonzo provides native Kubernetes integration for streaming logs directly from your clusters. This guide covers installation, configuration, and usage patterns for Kubernetes log analysis. + +## Table of Contents + +- [Overview](#overview) +- [Prerequisites](#prerequisites) +- [Quick Start](#quick-start) +- [Configuration Options](#configuration-options) +- [Interactive Filtering](#interactive-filtering) +- [Display Modes](#display-modes) +- [Common Use Cases](#common-use-cases) +- [Troubleshooting](#troubleshooting) + +## Overview + +Gonzo's Kubernetes integration provides: + +- **Direct cluster access** - No need to pipe kubectl output +- **Multi-namespace support** - Watch multiple namespaces simultaneously +- **Label selectors** - Filter pods by Kubernetes labels +- **Interactive filtering** - Dynamic namespace and pod filtering with `Ctrl+k` +- **Auto-detection** - Automatically displays namespace and pod columns for k8s logs +- **Real-time streaming** - Live tail of pod logs with automatic reconnection + +## Prerequisites + +Before using Gonzo with Kubernetes, ensure you have: + +1. **Kubernetes cluster access** - Valid kubeconfig file +2. **Gonzo installed** - See main [README](../README.md) for installation + +### Required Kubernetes Permissions + +Your Kubernetes user/service account needs these permissions: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: gonzo-log-reader +rules: +- apiGroups: [""] + resources: ["pods", "pods/log"] + verbs: ["get", "list", "watch"] +- apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list"] +``` + +## Quick Start + +### Watch All Pods in All Namespaces + +```bash +# Stream logs from all pods in all namespaces +gonzo --k8s-enabled=true + +# Show last 50 lines from each pod +gonzo --k8s-enabled=true --k8s-tail=50 +``` + +### Watch Specific Namespaces + +```bash +# Single namespace +gonzo --k8s-enabled=true --k8s-namespace=production + +# Multiple namespaces +gonzo --k8s-enabled=true --k8s-namespace=production --k8s-namespace=staging +``` + +### Filter by Labels + +```bash +# Watch pods with specific label +gonzo --k8s-enabled=true --k8s-selector="app=nginx" + +# Complex label selector +gonzo --k8s-enabled=true --k8s-selector="app=nginx,tier=frontend" +gonzo --k8s-enabled=true --k8s-selector="environment in (production,staging)" +``` + +### Combine Filters + +```bash +# Specific namespace with label selector +gonzo --k8s-enabled=true \ + --k8s-namespace=production \ + --k8s-selector="app=api" + +# Multiple namespaces with label selector +gonzo --k8s-enabled=true \ + --k8s-namespace=production \ + --k8s-namespace=staging \ + --k8s-selector="tier=backend" +``` + +## Configuration Options + +### Command Line Flags + +```bash +--k8s-enabled=true # Enable Kubernetes mode +--k8s-namespace NAMESPACE # Target namespace (can specify multiple times) +--k8s-selector SELECTOR # Kubernetes label selector +--k8s-tail N # Number of previous log lines per pod (default: 10) +--k8s-since SECONDS # Only logs newer than N seconds +--k8s-kubeconfig PATH # Path to kubeconfig (default: ~/.kube/config) +--k8s-context CONTEXT # Kubernetes context to use +``` + +### Configuration File + +Add to `~/.config/gonzo/config.yml`: + +```yaml +# Enable Kubernetes mode +k8s: + enabled: true + + # Target namespaces (empty = all namespaces) + namespaces: + - production + - staging + + # Label selector for filtering pods + selector: "app=nginx,tier=frontend" + + # Number of historical log lines per pod + tail: 50 + + # Only logs newer than N seconds + since: 3600 # Last hour + + # Path to kubeconfig file + kubeconfig: ~/.kube/config + + # Kubernetes context to use + context: my-cluster +``` + +See [examples/k8s_config.yml](../examples/k8s_config.yml) for a complete example. + +### Environment Variables + +```bash +# Use specific kubeconfig +export KUBECONFIG=/path/to/custom/kubeconfig + +# Set default Kubernetes context +export KUBE_CONTEXT=production-cluster +``` + +## Interactive Filtering + +Gonzo provides an interactive filtering modal for Kubernetes logs accessible with `Ctrl+k`. + +### Features + +- **Namespace tab** - Select which namespaces to monitor +- **Pod tab** - Select specific pods to watch +- **Live updates** - Applies filters in real-time +- **Select all/none** - Quick bulk operations +- **Persistent** - Selections persist across modal opens + +### Usage + +1. Press `Ctrl+k` to open the Kubernetes filter modal +2. Use `Tab` to switch between Namespaces and Pods views +3. Navigate with arrow keys (`↑`/`↓` or `j`/`k`) +4. Press `Space` to toggle selection +5. Press `Enter` to apply filters +6. Press `ESC` to cancel changes + +### Keyboard Shortcuts + +| Key | Action | +| ------------------ | ------------------------------ | +| `Ctrl+k` | Open Kubernetes filter modal | +| `Tab` | Switch between tabs | +| `↑`/`↓` or `j`/`k` | Navigate items | +| `Space` | Toggle selection | +| `Enter` | Apply filter and close | +| `ESC` | Cancel and close | + +## Display Modes + +### K8s Mode (Auto-Detected) + +When Gonzo detects Kubernetes attributes (`k8s.namespace`, `k8s.pod`), it automatically switches to K8s display mode: + +``` +Time Level Namespace Pod Message +15:04:05 INFO production nginx-7d9c-xkr2p Request handled successfully +15:04:06 ERROR production api-server-5c4f-m89x Failed to connect to database +15:04:07 WARN staging worker-2b3a-qz8l High memory usage detected +``` + +**Column Layout:** +- **Time** - Log timestamp (8 chars) +- **Level** - Severity level (5 chars) +- **Namespace** - K8s namespace (20 chars, truncated with "...") +- **Pod** - Pod name (20 chars, truncated with "...") +- **Message** - Log message (remaining width) + +### Toggle Columns + +Press `c` to toggle column display on/off: + +``` +# With columns (default) +15:04:05 INFO production nginx-7d9c-xkr2p Request handled + +# Without columns +15:04:05 INFO Request handled successfully +``` + +### Standard Mode + +For non-Kubernetes logs, Gonzo displays host and service columns: + +``` +Time Level Host Service Message +15:04:05 INFO server01 api-gateway Request handled +``` + +## Common Use Cases + +### Development Workflow + +```bash +# Watch your development namespace +gonzo --k8s-enabled=true --k8s-namespace=dev --k8s-selector="app=myapp" + +# Quick check of specific pod +gonzo --k8s-enabled=true --k8s-namespace=dev --k8s-selector="app=myapp,version=v1.2.3" +``` + +### Production Monitoring + +```bash +# Monitor production with error focus (using severity filter) +gonzo --k8s-enabled=true --k8s-namespace=production + +# Then press Ctrl+f and select only ERROR and FATAL levels +``` + +### Multi-Environment Monitoring + +```bash +# Watch both production and staging +gonzo --k8s-enabled=true \ + --k8s-namespace=production \ + --k8s-namespace=staging \ + --k8s-selector="tier=backend" +``` + +### Troubleshooting Deployments + +```bash +# Check recent deployment logs +gonzo --k8s-enabled=true \ + --k8s-namespace=production \ + --k8s-selector="app=nginx,version=v2.0.0" \ + --k8s-since=300 # Last 5 minutes +``` + +### CI/CD Pipeline Integration + +```bash +# Monitor deployment in CI/CD +#!/bin/bash +NAMESPACE="production" +APP="myapp" +VERSION="v1.2.3" + +# Start monitoring +gonzo --k8s-enabled=true \ + --k8s-namespace=$NAMESPACE \ + --k8s-selector="app=$APP,version=$VERSION" \ + --k8s-tail=100 & + +GONZO_PID=$! + +# Run deployment +kubectl apply -f deployment.yaml + +# Wait for rollout +kubectl rollout status deployment/$APP -n $NAMESPACE + +# Stop monitoring +kill $GONZO_PID +``` + +### Label Selector Examples + +```bash +# Single label +gonzo --k8s-enabled=true --k8s-selector="app=nginx" + +# Multiple labels (AND) +gonzo --k8s-enabled=true --k8s-selector="app=nginx,tier=frontend" + +# Set-based requirements +gonzo --k8s-enabled=true --k8s-selector="environment in (production,staging)" +gonzo --k8s-enabled=true --k8s-selector="tier notin (test,dev)" + +# Existence check +gonzo --k8s-enabled=true --k8s-selector="critical" +gonzo --k8s-enabled=true --k8s-selector="!experimental" + +# Complex combinations +gonzo --k8s-enabled=true --k8s-selector="app=nginx,environment in (prod,stage),!experimental" +``` + +## Troubleshooting + +### No Logs Appearing + +**Check cluster access:** +```bash +# Verify kubectl works +kubectl get pods --all-namespaces + +# Check specific namespace +kubectl get pods -n production +``` + +**Check permissions:** +```bash +# Verify you can read logs +kubectl logs -n +``` + +**Check pod status:** +```bash +# Ensure pods are running +kubectl get pods -n --selector= +``` + +### Connection Issues + +**Verify kubeconfig:** +```bash +# Check current context +kubectl config current-context + +# List available contexts +kubectl config get-contexts + +# Use specific context +gonzo --k8s-enabled=true --k8s-context=my-cluster +``` + +**Check network connectivity:** +```bash +# Test cluster API access +kubectl cluster-info + +# Check pod network +kubectl get pods -A +``` + +### Filter Not Working + +**Verify label selector syntax:** +```bash +# Test selector with kubectl first +kubectl get pods --selector="app=nginx" -A + +# Then use same selector with Gonzo +gonzo --k8s-enabled=true --k8s-selector="app=nginx" +``` + +**Check namespace exists:** +```bash +# List all namespaces +kubectl get namespaces + +# Verify specific namespace +kubectl get namespace production +``` + +### Performance Issues + +**Reduce log volume:** +```bash +# Use more specific selectors +gonzo --k8s-enabled=true \ + --k8s-namespace=production \ + --k8s-selector="app=api,critical=true" + +# Limit to recent logs +gonzo --k8s-enabled=true --k8s-tail=10 --k8s-since=300 +``` + +**Adjust buffer size:** +```bash +# Increase buffer for high-volume logs +gonzo --k8s-enabled=true --log-buffer=5000 +``` + +### Debug Mode + +```bash +# Run with verbose output +GONZO_DEBUG=1 gonzo --k8s-enabled=true --k8s-namespace=default +``` + +## Best Practices + +1. **Start specific** - Use namespace and selector filters to reduce noise +2. **Use interactive filters** - Press `Ctrl+k` to dynamically adjust filters +3. **Leverage severity filtering** - Press `Ctrl+f` to focus on errors +4. **Monitor resources** - Watch `--log-buffer` usage for high-volume clusters +5. **Use contexts** - Switch between clusters with `--k8s-context` +6. **Save configs** - Store common configurations in `~/.config/gonzo/config.yml` + +## Next Steps + +- [Main README](../README.md) - General Gonzo usage +- [USAGE_GUIDE.md](../USAGE_GUIDE.md) - Detailed feature guide +- [Examples](../examples/k8s_config.yml) - Sample configurations + +## Getting Help + +- [GitHub Issues](https://github.com/control-theory/gonzo/issues) +- [Slack Community](https://ctrltheorycommunity.slack.com) +- [Documentation](https://docs.controltheory.com/) diff --git a/internal/drain3/impl.go b/internal/drain3/impl.go index fc69e38..9efda8c 100644 --- a/internal/drain3/impl.go +++ b/internal/drain3/impl.go @@ -134,7 +134,7 @@ func (d *Drain) Reset() error { // Process the log line through drain3 err := d.AddLogMessage(line) if err != nil { - fmt.Fprintf(os.Stderr, "Error processing log line: %v\n", err) + // Silently skip lines that fail to process to avoid messing up TUI continue } } diff --git a/internal/filereader/filereader.go b/internal/filereader/filereader.go index 93b0e17..364bbc4 100644 --- a/internal/filereader/filereader.go +++ b/internal/filereader/filereader.go @@ -122,9 +122,7 @@ func (fr *FileReader) Start() <-chan string { // startReadMode reads files once from beginning to end func (fr *FileReader) startReadMode() { - fr.wg.Add(1) - go func() { - defer fr.wg.Done() + fr.wg.Go(func() { defer close(fr.lineChan) for _, filePath := range fr.filePaths { @@ -133,14 +131,12 @@ func (fr *FileReader) startReadMode() { continue } } - }() + }) } // startFollowMode reads files and then watches for new content func (fr *FileReader) startFollowMode() { - fr.wg.Add(1) - go func() { - defer fr.wg.Done() + fr.wg.Go(func() { defer close(fr.lineChan) defer fr.closeAllWatchers() @@ -161,7 +157,7 @@ func (fr *FileReader) startFollowMode() { // Keep running until context is cancelled <-fr.ctx.Done() - }() + }) } // readFile reads a file from beginning to end @@ -241,15 +237,15 @@ func (fr *FileReader) setupFileWatcher(filePath string) error { } // Start watching for changes - fr.wg.Add(1) - go fr.watchFile(filePath, watcher) + fr.wg.Go(func() { + fr.watchFile(filePath, watcher) + }) return nil } // watchFile watches a single file for changes func (fr *FileReader) watchFile(filePath string, watcher *fsnotify.Watcher) { - defer fr.wg.Done() for { select { diff --git a/internal/k8s/config.go b/internal/k8s/config.go new file mode 100644 index 0000000..18c150a --- /dev/null +++ b/internal/k8s/config.go @@ -0,0 +1,78 @@ +package k8s + +import ( + "fmt" + "os" + "path/filepath" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +// Config holds kubernetes configuration +type Config struct { + Kubeconfig string + Context string + Namespaces []string + Selector string + Since int64 // Duration in seconds + TailLines int64 +} + +// NewDefaultConfig returns a default kubernetes configuration +func NewDefaultConfig() *Config { + tailLines := int64(10) // Default to last 10 lines to avoid overwhelming UI + return &Config{ + Kubeconfig: getDefaultKubeconfig(), + Namespaces: []string{""}, // Empty string means all namespaces + TailLines: tailLines, // Show only recent logs by default + } +} + +// getDefaultKubeconfig returns the default kubeconfig path +func getDefaultKubeconfig() string { + if kubeconfig := os.Getenv("KUBECONFIG"); kubeconfig != "" { + return kubeconfig + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".kube", "config") +} + +// BuildClientset creates a kubernetes clientset from the configuration +func (c *Config) BuildClientset() (*kubernetes.Clientset, error) { + // Try in-cluster config first + config, err := rest.InClusterConfig() + if err != nil { + // Fall back to kubeconfig + if c.Kubeconfig == "" { + c.Kubeconfig = getDefaultKubeconfig() + } + + // Load kubeconfig + loadingRules := &clientcmd.ClientConfigLoadingRules{ExplicitPath: c.Kubeconfig} + configOverrides := &clientcmd.ConfigOverrides{} + + // Override context if specified + if c.Context != "" { + configOverrides.CurrentContext = c.Context + } + + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + config, err = kubeConfig.ClientConfig() + if err != nil { + return nil, fmt.Errorf("failed to load kubeconfig: %w", err) + } + } + + // Create clientset + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create kubernetes clientset: %w", err) + } + + return clientset, nil +} diff --git a/internal/k8s/source.go b/internal/k8s/source.go new file mode 100644 index 0000000..09f2806 --- /dev/null +++ b/internal/k8s/source.go @@ -0,0 +1,276 @@ +package k8s + +import ( + "context" + "fmt" + "log" + "sync" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// KubernetesLogSource is the main entry point for streaming kubernetes logs +type KubernetesLogSource struct { + config *Config + watcher *PodWatcher + lineChan chan string + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup +} + +// NewKubernetesLogSource creates a new kubernetes log source +func NewKubernetesLogSource(config *Config) (*KubernetesLogSource, error) { + if config == nil { + config = NewDefaultConfig() + } + + ctx, cancel := context.WithCancel(context.Background()) + + return &KubernetesLogSource{ + config: config, + lineChan: make(chan string, 1000), + ctx: ctx, + cancel: cancel, + }, nil +} + +// Start starts streaming logs from kubernetes +func (s *KubernetesLogSource) Start() error { + // Build kubernetes clientset + clientset, err := s.config.BuildClientset() + if err != nil { + return fmt.Errorf("failed to build kubernetes client: %w", err) + } + + // Create tail lines pointer if specified + var tailLines *int64 + if s.config.TailLines >= 0 { + tailLines = &s.config.TailLines + } + + // Create since pointer if specified + var since *int64 + if s.config.Since > 0 { + since = &s.config.Since + } + + // Create pod watcher (initially no pod name filter) + watcher, err := NewPodWatcher( + clientset, + s.config.Namespaces, + s.config.Selector, + nil, // No pod name filter initially + s.lineChan, + tailLines, + since, + ) + if err != nil { + return fmt.Errorf("failed to create pod watcher: %w", err) + } + + s.watcher = watcher + + // Start watching pods + if err := watcher.Start(); err != nil { + return fmt.Errorf("failed to start pod watcher: %w", err) + } + + log.Printf("Started kubernetes log streaming") + if len(s.config.Namespaces) > 0 && s.config.Namespaces[0] != "" { + log.Printf(" Namespaces: %v", s.config.Namespaces) + } else { + log.Printf(" Namespaces: all") + } + if s.config.Selector != "" { + log.Printf(" Label selector: %s", s.config.Selector) + } + + return nil +} + +// Stop stops the kubernetes log source +func (s *KubernetesLogSource) Stop() { + if s.cancel != nil { + s.cancel() + } + + if s.watcher != nil { + s.watcher.Stop() + } + + s.wg.Wait() + close(s.lineChan) +} + +// GetLineChan returns the channel for receiving log lines +func (s *KubernetesLogSource) GetLineChan() <-chan string { + return s.lineChan +} + +// GetActiveStreams returns the number of active pod log streams +func (s *KubernetesLogSource) GetActiveStreams() int { + if s.watcher != nil { + return s.watcher.GetActiveStreams() + } + return 0 +} + +// UpdateFilter updates the namespace, label selector, and pod name filter +// This can be used to dynamically change what pods are being watched +func (s *KubernetesLogSource) UpdateFilter(namespaces []string, selector string, podNames []string) error { + // Stop current watcher + if s.watcher != nil { + s.watcher.Stop() + } + + // Update config + s.config.Namespaces = namespaces + s.config.Selector = selector + + // Build kubernetes clientset + clientset, err := s.config.BuildClientset() + if err != nil { + return fmt.Errorf("failed to build kubernetes client: %w", err) + } + + // Create tail lines pointer if specified + var tailLines *int64 + if s.config.TailLines >= 0 { + tailLines = &s.config.TailLines + } + + // Create since pointer if specified + var since *int64 + if s.config.Since > 0 { + since = &s.config.Since + } + + // Create new watcher with updated filter + watcher, err := NewPodWatcher( + clientset, + s.config.Namespaces, + s.config.Selector, + podNames, + s.lineChan, + tailLines, + since, + ) + if err != nil { + return fmt.Errorf("failed to create pod watcher: %w", err) + } + + s.watcher = watcher + + // Start watching pods + if err := watcher.Start(); err != nil { + return fmt.Errorf("failed to start pod watcher: %w", err) + } + + log.Printf("Updated kubernetes filter - Namespaces: %v, Selector: %s, Pods: %d selected", namespaces, selector, len(podNames)) + + return nil +} + +// ListNamespaces returns the list of available namespaces from the cluster +// If initial config had specific namespaces, those are marked as selected +func (s *KubernetesLogSource) ListNamespaces() (map[string]bool, error) { + // Build kubernetes clientset + clientset, err := s.config.BuildClientset() + if err != nil { + return nil, fmt.Errorf("failed to build kubernetes client: %w", err) + } + + // List all namespaces + nsList, err := clientset.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list namespaces: %w", err) + } + + // Build map of namespace -> selected status + result := make(map[string]bool) + + // Check which namespaces were initially configured + configuredNs := make(map[string]bool) + for _, ns := range s.config.Namespaces { + if ns != "" { // Empty string means "all namespaces" + configuredNs[ns] = true + } + } + + // If no specific namespaces configured (or empty string for all), select all + selectAll := len(configuredNs) == 0 + + for _, ns := range nsList.Items { + // Select if it was in the initial config, or if we're selecting all + result[ns.Name] = selectAll || configuredNs[ns.Name] + } + + return result, nil +} + +// ListPods returns the list of available pods from selected namespaces +// If initial config had specific namespaces/selector, relevant pods are marked as selected +func (s *KubernetesLogSource) ListPods(selectedNamespaces map[string]bool) (map[string]bool, error) { + // Build kubernetes clientset + clientset, err := s.config.BuildClientset() + if err != nil { + return nil, fmt.Errorf("failed to build kubernetes client: %w", err) + } + + result := make(map[string]bool) + + // Build list options with label selector if configured + listOptions := metav1.ListOptions{} + if s.config.Selector != "" { + listOptions.LabelSelector = s.config.Selector + } + + // Determine which namespaces to query + var namespacesToQuery []string + if len(selectedNamespaces) == 0 { + // No namespaces selected, query all + namespacesToQuery = []string{""} // Empty string means all namespaces + } else { + // Query only selected namespaces + for ns, selected := range selectedNamespaces { + if selected { + namespacesToQuery = append(namespacesToQuery, ns) + } + } + } + + // If still empty, query all + if len(namespacesToQuery) == 0 { + namespacesToQuery = []string{""} + } + + // List pods from each namespace + for _, ns := range namespacesToQuery { + var podList *corev1.PodList + var err error + + if ns == "" { + // List from all namespaces + podList, err = clientset.CoreV1().Pods("").List(context.Background(), listOptions) + } else { + // List from specific namespace + podList, err = clientset.CoreV1().Pods(ns).List(context.Background(), listOptions) + } + + if err != nil { + log.Printf("Warning: failed to list pods in namespace %q: %v", ns, err) + continue + } + + // Add pods to result - select all by default + for _, pod := range podList.Items { + // Use namespace/pod format for clarity + podKey := fmt.Sprintf("%s/%s", pod.Namespace, pod.Name) + result[podKey] = true + } + } + + return result, nil +} diff --git a/internal/k8s/streamer.go b/internal/k8s/streamer.go new file mode 100644 index 0000000..83c7864 --- /dev/null +++ b/internal/k8s/streamer.go @@ -0,0 +1,218 @@ +package k8s + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" +) + +// PodLogStreamer streams logs from a single container in a pod +type PodLogStreamer struct { + clientset *kubernetes.Clientset + pod *corev1.Pod + container string + output chan<- string + ctx context.Context + cancel context.CancelFunc + tailLines *int64 + since *int64 +} + +// NewPodLogStreamer creates a new pod log streamer +func NewPodLogStreamer( + clientset *kubernetes.Clientset, + pod *corev1.Pod, + container string, + output chan<- string, + parentCtx context.Context, + tailLines *int64, + since *int64, +) *PodLogStreamer { + ctx, cancel := context.WithCancel(parentCtx) + return &PodLogStreamer{ + clientset: clientset, + pod: pod, + container: container, + output: output, + ctx: ctx, + cancel: cancel, + tailLines: tailLines, + since: since, + } +} + +// Start starts streaming logs from the pod +func (s *PodLogStreamer) Start() { + go s.streamLogs() +} + +// Stop stops the log streaming +func (s *PodLogStreamer) Stop() { + if s.cancel != nil { + s.cancel() + } +} + +// streamLogs streams logs from the pod container +func (s *PodLogStreamer) streamLogs() { + // Build pod log options + opts := &corev1.PodLogOptions{ + Container: s.container, + Follow: true, + Timestamps: true, + } + + // Set tail lines if specified + if s.tailLines != nil && *s.tailLines >= 0 { + opts.TailLines = s.tailLines + } + + // Set since seconds if specified + if s.since != nil && *s.since > 0 { + opts.SinceSeconds = s.since + } + + // Get log stream request + req := s.clientset.CoreV1().Pods(s.pod.Namespace).GetLogs(s.pod.Name, opts) + + // Open stream + stream, err := req.Stream(s.ctx) + if err != nil { + log.Printf("Error opening log stream for pod %s/%s container %s: %v", + s.pod.Namespace, s.pod.Name, s.container, err) + return + } + defer stream.Close() + + // Read logs line by line + scanner := bufio.NewScanner(stream) + // Set larger buffer for long log lines + const maxScanTokenSize = 1024 * 1024 // 1MB + buf := make([]byte, maxScanTokenSize) + scanner.Buffer(buf, maxScanTokenSize) + + for scanner.Scan() { + select { + case <-s.ctx.Done(): + return + default: + line := scanner.Text() + if line != "" { + // Format log line with kubernetes metadata + enrichedLine := s.enrichLogLine(line) + select { + case s.output <- enrichedLine: + case <-s.ctx.Done(): + return + } + } + } + } + + // Check for scanner errors + if err := scanner.Err(); err != nil && err != io.EOF { + log.Printf("Error reading logs from pod %s/%s container %s: %v", + s.pod.Namespace, s.pod.Name, s.container, err) + } +} + +// enrichLogLine adds kubernetes metadata to the log line as JSON attributes +// K8s logs come with an optional RFC3339Nano timestamp prefix, followed by the raw log message. +// The log message itself can be plain text, JSON, or any format - we don't parse it here. +func (s *PodLogStreamer) enrichLogLine(line string) string { + // K8s API returns logs with RFC3339Nano timestamp prefix when Timestamps: true + // Format: "2024-01-15T10:30:45.123456789Z actual log message here" + // We need to strip the timestamp and pass the raw message through + + // Strip timestamp prefix if present (RFC3339Nano format) + actualMessage := line + if len(line) > 0 { + // Look for timestamp pattern: YYYY-MM-DDTHH:MM:SS.nnnnnnnnnZ followed by space + // Simple check: if first char is digit and we have a 'T' and 'Z' in the right places + if len(line) > 31 && line[4] == '-' && line[7] == '-' && line[10] == 'T' { + // Find the 'Z ' pattern (end of RFC3339Nano timestamp + space) + for i := 20; i < min(35, len(line)-1); i++ { + if line[i] == 'Z' && i+1 < len(line) && line[i+1] == ' ' { + // Found timestamp, strip it + actualMessage = line[i+2:] // Skip "Z " + break + } + } + } + } + + // Build K8s metadata attributes in OTLP format + k8sAttrs := []map[string]interface{}{ + { + "key": "k8s.namespace", + "value": map[string]interface{}{ + "stringValue": s.pod.Namespace, + }, + }, + { + "key": "k8s.pod", + "value": map[string]interface{}{ + "stringValue": s.pod.Name, + }, + }, + { + "key": "k8s.container", + "value": map[string]interface{}{ + "stringValue": s.container, + }, + }, + { + "key": "k8s.node", + "value": map[string]interface{}{ + "stringValue": s.pod.Spec.NodeName, + }, + }, + } + + // Add pod labels as attributes + if s.pod.Labels != nil { + for key, value := range s.pod.Labels { + k8sAttrs = append(k8sAttrs, map[string]interface{}{ + "key": fmt.Sprintf("k8s.label.%s", key), + "value": map[string]interface{}{ + "stringValue": value, + }, + }) + } + } + + // Build OTLP-like structure with the raw message as body + // The message will be parsed by gonzo's existing format detection/parsing logic + result := map[string]interface{}{ + "body": map[string]interface{}{ + "stringValue": actualMessage, + }, + "attributes": k8sAttrs, + } + + // Marshal to JSON + jsonBytes, err := json.Marshal(result) + if err != nil { + // Fallback to simple format if marshaling fails + log.Printf("Error marshaling enriched log: %v", err) + return fmt.Sprintf(`{"body":{"stringValue":%q},"attributes":%s}`, + actualMessage, mustMarshalJSON(k8sAttrs)) + } + + return string(jsonBytes) +} + +// mustMarshalJSON marshals to JSON or returns empty array string on error +func mustMarshalJSON(v interface{}) string { + bytes, err := json.Marshal(v) + if err != nil { + return "[]" + } + return string(bytes) +} diff --git a/internal/k8s/watcher.go b/internal/k8s/watcher.go new file mode 100644 index 0000000..292e25b --- /dev/null +++ b/internal/k8s/watcher.go @@ -0,0 +1,324 @@ +package k8s + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" +) + +// PodWatcher watches for pod lifecycle events and manages log streams +type PodWatcher struct { + clientset *kubernetes.Clientset + namespaces []string + selector labels.Selector + podNames map[string]bool // Pod names to filter (namespace/podname format), empty = all pods + output chan string + streamers map[string]*PodLogStreamer // key: namespace/podName/containerName + mu sync.RWMutex + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + tailLines *int64 + since *int64 +} + +// NewPodWatcher creates a new pod watcher +func NewPodWatcher( + clientset *kubernetes.Clientset, + namespaces []string, + selector string, + podNames []string, + output chan string, + tailLines *int64, + since *int64, +) (*PodWatcher, error) { + ctx, cancel := context.WithCancel(context.Background()) + + // Parse label selector + var labelSelector labels.Selector + var err error + if selector != "" { + labelSelector, err = labels.Parse(selector) + if err != nil { + cancel() + return nil, fmt.Errorf("invalid label selector: %w", err) + } + } else { + labelSelector = labels.Everything() + } + + // If no namespaces specified, watch all namespaces + if len(namespaces) == 0 { + namespaces = []string{""} // Empty string means all namespaces + } + + // Convert pod names slice to map for fast lookup + podNamesMap := make(map[string]bool) + for _, podName := range podNames { + podNamesMap[podName] = true + } + + return &PodWatcher{ + clientset: clientset, + namespaces: namespaces, + selector: labelSelector, + podNames: podNamesMap, + output: output, + streamers: make(map[string]*PodLogStreamer), + ctx: ctx, + cancel: cancel, + tailLines: tailLines, + since: since, + }, nil +} + +// Start starts watching for pods and streaming their logs +func (w *PodWatcher) Start() error { + // Create informers for each namespace + for _, namespace := range w.namespaces { + if err := w.watchNamespace(namespace); err != nil { + log.Printf("Error watching namespace %q: %v", namespace, err) + // Continue with other namespaces even if one fails + } + } + + return nil +} + +// watchNamespace creates an informer for a specific namespace +func (w *PodWatcher) watchNamespace(namespace string) error { + // Create informer factory + var factory informers.SharedInformerFactory + if namespace == "" { + // Watch all namespaces + factory = informers.NewSharedInformerFactory(w.clientset, time.Minute) + } else { + // Watch specific namespace + factory = informers.NewSharedInformerFactoryWithOptions( + w.clientset, + time.Minute, + informers.WithNamespace(namespace), + ) + } + + // Create pod informer with field selector to only watch running/pending pods + podInformer := factory.Core().V1().Pods().Informer() + + // Add event handlers + _, err := podInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + pod := obj.(*corev1.Pod) + if w.shouldWatchPod(pod) { + w.startPodStreams(pod) + } + }, + UpdateFunc: func(oldObj, newObj interface{}) { + pod := newObj.(*corev1.Pod) + if w.shouldWatchPod(pod) { + w.startPodStreams(pod) + } else { + // Pod no longer matches criteria, stop streams + w.stopPodStreams(pod) + } + }, + DeleteFunc: func(obj interface{}) { + pod := obj.(*corev1.Pod) + w.stopPodStreams(pod) + }, + }) + if err != nil { + return fmt.Errorf("failed to add event handler: %w", err) + } + + // Start informer (use context's Done channel as stop signal) + factory.Start(w.ctx.Done()) + + // Wait for cache sync + w.wg.Go(func() { + if !cache.WaitForCacheSync(w.ctx.Done(), podInformer.HasSynced) { + log.Printf("Failed to sync cache for namespace %q", namespace) + } + }) + + return nil +} + +// shouldWatchPod determines if a pod should be watched based on selector, name filter, and phase +func (w *PodWatcher) shouldWatchPod(pod *corev1.Pod) bool { + // Check if pod matches label selector + if !w.selector.Matches(labels.Set(pod.Labels)) { + return false + } + + // Check pod name filter (if specified) + if len(w.podNames) > 0 { + // Build pod key in namespace/podname format + podKey := fmt.Sprintf("%s/%s", pod.Namespace, pod.Name) + // If pod is not in the filter list, skip it + if !w.podNames[podKey] { + return false + } + } + + // Only watch running or succeeded pods (succeeded for job logs) + // Skip pending pods as they don't have logs yet + phase := pod.Status.Phase + if phase != corev1.PodRunning && phase != corev1.PodSucceeded { + return false + } + + return true +} + +// startPodStreams starts log streams for all containers in a pod +func (w *PodWatcher) startPodStreams(pod *corev1.Pod) { + // Start stream for each container + for _, container := range pod.Spec.Containers { + key := w.getStreamKey(pod, container.Name) + + w.mu.Lock() + // Check if stream already exists + if _, exists := w.streamers[key]; exists { + w.mu.Unlock() + continue + } + + // Create and start new streamer (pass parent context for cancellation cascade) + streamer := NewPodLogStreamer( + w.clientset, + pod, + container.Name, + w.output, + w.ctx, + w.tailLines, + w.since, + ) + w.streamers[key] = streamer + w.mu.Unlock() + + // Start streaming + streamer.Start() + log.Printf("Started streaming logs from %s/%s container %s", + pod.Namespace, pod.Name, container.Name) + } + + // Also handle init containers if they're still running + for _, container := range pod.Spec.InitContainers { + // Check if init container is currently running + isRunning := false + for _, status := range pod.Status.InitContainerStatuses { + if status.Name == container.Name && status.State.Running != nil { + isRunning = true + break + } + } + + if !isRunning { + continue + } + + key := w.getStreamKey(pod, container.Name) + + w.mu.Lock() + if _, exists := w.streamers[key]; exists { + w.mu.Unlock() + continue + } + + streamer := NewPodLogStreamer( + w.clientset, + pod, + container.Name, + w.output, + w.ctx, + w.tailLines, + w.since, + ) + w.streamers[key] = streamer + w.mu.Unlock() + + streamer.Start() + log.Printf("Started streaming logs from %s/%s init container %s", + pod.Namespace, pod.Name, container.Name) + } +} + +// stopPodStreams stops all log streams for a pod +func (w *PodWatcher) stopPodStreams(pod *corev1.Pod) { + w.mu.Lock() + defer w.mu.Unlock() + + // Stop streams for all containers (cancellation happens via context cascade) + for _, container := range pod.Spec.Containers { + key := w.getStreamKey(pod, container.Name) + if streamer, exists := w.streamers[key]; exists { + streamer.Stop() // This cancels the streamer's child context + delete(w.streamers, key) + log.Printf("Stopped streaming logs from %s/%s container %s", + pod.Namespace, pod.Name, container.Name) + } + } + + // Stop streams for init containers + for _, container := range pod.Spec.InitContainers { + key := w.getStreamKey(pod, container.Name) + if streamer, exists := w.streamers[key]; exists { + streamer.Stop() // This cancels the streamer's child context + delete(w.streamers, key) + log.Printf("Stopped streaming logs from %s/%s init container %s", + pod.Namespace, pod.Name, container.Name) + } + } +} + +// getStreamKey generates a unique key for a pod container stream +func (w *PodWatcher) getStreamKey(pod *corev1.Pod, containerName string) string { + return fmt.Sprintf("%s/%s/%s", pod.Namespace, pod.Name, containerName) +} + +// Stop stops the pod watcher and all active streams +func (w *PodWatcher) Stop() { + // Cancel context - this cascades to all streamers and stops all informers + if w.cancel != nil { + w.cancel() + } + + // Wait for all goroutines to finish + // The context cancellation will cause all streamers and informers to stop naturally + w.wg.Wait() + + // Clean up streamer map (they're already stopped via context cancellation) + w.mu.Lock() + w.streamers = make(map[string]*PodLogStreamer) + w.mu.Unlock() +} + +// GetActiveStreams returns the number of active streams +func (w *PodWatcher) GetActiveStreams() int { + w.mu.RLock() + defer w.mu.RUnlock() + return len(w.streamers) +} + +// ListPods returns a list of currently watched pods +func (w *PodWatcher) ListPods(ctx context.Context, namespace string) (*corev1.PodList, error) { + listOptions := metav1.ListOptions{} + if w.selector != labels.Everything() { + listOptions.LabelSelector = w.selector.String() + } + + // List pods with running phase + listOptions.FieldSelector = fields.OneTermEqualSelector("status.phase", string(corev1.PodRunning)).String() + + return w.clientset.CoreV1().Pods(namespace).List(ctx, listOptions) +} diff --git a/internal/otlplog/detector.go b/internal/otlplog/detector.go index 41f41fd..561e409 100644 --- a/internal/otlplog/detector.go +++ b/internal/otlplog/detector.go @@ -101,11 +101,26 @@ func (fd *FormatDetector) GetCustomFormatName() string { // containsOTLPFields checks if the JSON contains OTLP-specific fields func (fd *FormatDetector) containsOTLPFields(data map[string]interface{}) bool { + // Check for standard OTLP keywords for _, keyword := range fd.otlpKeywords { if fd.hasKeyRecursive(data, keyword) { return true } } + + // Check for single OTLP record format (used by K8s logs) + // Must have both "body" and "attributes", and body must have "stringValue" + if body, hasBody := data["body"].(map[string]interface{}); hasBody { + if _, hasStringValue := body["stringValue"]; hasStringValue { + // Also check if attributes exists and is an array + if attrs, hasAttrs := data["attributes"]; hasAttrs { + if _, isArray := attrs.([]interface{}); isArray { + return true + } + } + } + } + return false } diff --git a/internal/otlpreceiver/receiver.go b/internal/otlpreceiver/receiver.go index 8904c81..b245f97 100644 --- a/internal/otlpreceiver/receiver.go +++ b/internal/otlpreceiver/receiver.go @@ -78,14 +78,12 @@ func (r *Receiver) Start() error { otlpgrpc.RegisterLogsServiceServer(r.grpcServer, r) // Start serving in a goroutine - r.wg.Add(1) - go func() { - defer r.wg.Done() + r.wg.Go(func() { log.Printf("OTLP gRPC receiver listening on port %d", r.grpcPort) if err := r.grpcServer.Serve(grpcListener); err != nil && err != grpc.ErrServerStopped { log.Printf("OTLP gRPC receiver serve error: %v", err) } - }() + }) } // Start HTTP server @@ -105,14 +103,12 @@ func (r *Receiver) Start() error { } // Start serving in a goroutine - r.wg.Add(1) - go func() { - defer r.wg.Done() + r.wg.Go(func() { log.Printf("OTLP HTTP receiver listening on port %d", r.httpPort) if err := r.httpServer.Serve(httpListener); err != nil && err != http.ErrServerClosed { log.Printf("OTLP HTTP receiver serve error: %v", err) } - }() + }) } return nil diff --git a/internal/tui/components.go b/internal/tui/components.go index a50b988..1cdc6d5 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -278,12 +278,20 @@ func (m *DashboardModel) renderLogScrollContent(height int, logWidth int) []stri if m.showColumns { timestampHeader := lipgloss.NewStyle().Foreground(ColorWhite).Render("Time ") severityHeader := lipgloss.NewStyle().Foreground(ColorWhite).Render("Level") - hostHeader := lipgloss.NewStyle().Foreground(ColorWhite).Render("Host ") - serviceHeader := lipgloss.NewStyle().Foreground(ColorWhite).Render("Service ") + + // Use k8s headers if in k8s mode, otherwise use host/service headers + var col1Header, col2Header string + if m.isK8sMode() { + col1Header = lipgloss.NewStyle().Foreground(ColorWhite).Render("Namespace ") + col2Header = lipgloss.NewStyle().Foreground(ColorWhite).Render("Pod ") + } else { + col1Header = lipgloss.NewStyle().Foreground(ColorWhite).Render("Host ") + col2Header = lipgloss.NewStyle().Foreground(ColorWhite).Render("Service ") + } messageHeader := lipgloss.NewStyle().Foreground(ColorWhite).Render("Message") headerLine := fmt.Sprintf("%s %s %s %s %s", - timestampHeader, severityHeader, hostHeader, serviceHeader, messageHeader) + timestampHeader, severityHeader, col1Header, col2Header, messageHeader) logLines = append(logLines, headerLine) height-- // Reduce available height for logs } diff --git a/internal/tui/formatting.go b/internal/tui/formatting.go index e88b664..10118a5 100644 --- a/internal/tui/formatting.go +++ b/internal/tui/formatting.go @@ -20,25 +20,48 @@ func (m *DashboardModel) formatLogEntry(entry LogEntry, availableWidth int, isSe var logLine string if m.showColumns { - // Extract host.name and service.name from OTLP attributes - host := entry.Attributes["host.name"] - service := entry.Attributes["service.name"] - - // Truncate to fit column width - if len(host) > 12 { - host = host[:9] + "..." + // Check if this is a k8s log (has k8s.namespace or k8s.pod attributes) + namespace := entry.Attributes["k8s.namespace"] + pod := entry.Attributes["k8s.pod"] + isK8s := namespace != "" || pod != "" + + var col1Str, col2Str string + var columnsWidth int + + if isK8s { + // K8s mode: show namespace and pod (both truncated to 20 chars) + if len(namespace) > 20 { + namespace = namespace[:17] + "..." + } + if len(pod) > 20 { + pod = pod[:17] + "..." + } + + // Format fixed-width columns + col1Str = fmt.Sprintf("%-20s", namespace) + col2Str = fmt.Sprintf("%-20s", pod) + columnsWidth = 42 // 20 + 20 + 2 spaces + } else { + // Normal mode: show host.name and service.name from OTLP attributes + host := entry.Attributes["host.name"] + service := entry.Attributes["service.name"] + + // Truncate to fit column width + if len(host) > 12 { + host = host[:9] + "..." + } + if len(service) > 16 { + service = service[:13] + "..." + } + + // Format fixed-width columns + col1Str = fmt.Sprintf("%-12s", host) + col2Str = fmt.Sprintf("%-16s", service) + columnsWidth = 30 // 12 + 16 + 2 spaces } - if len(service) > 16 { - service = service[:13] + "..." - } - - // Format fixed-width columns - hostCol := fmt.Sprintf("%-12s", host) - serviceCol := fmt.Sprintf("%-16s", service) // Calculate remaining space for message // Use same calculation as non-selected: availableWidth - 18 - columnsWidth - columnsWidth := 30 // 12 + 16 + 2 spaces maxMessageLen := availableWidth - 18 - columnsWidth if maxMessageLen < 10 { maxMessageLen = 10 @@ -49,7 +72,7 @@ func (m *DashboardModel) formatLogEntry(entry LogEntry, availableWidth int, isSe message = message[:maxMessageLen-3] + "..." } - logLine = fmt.Sprintf("%s %-5s %s %s %s", timestamp, severity, hostCol, serviceCol, message) + logLine = fmt.Sprintf("%s %-5s %s %s %s", timestamp, severity, col1Str, col2Str, message) } else { // Calculate space for message - use same as non-selected: availableWidth - 18 maxMessageLen := availableWidth - 18 @@ -84,32 +107,58 @@ func (m *DashboardModel) formatLogEntry(entry LogEntry, availableWidth int, isSe Foreground(ColorGray). Render(timestamp) - // Extract Host and Service columns if enabled - var hostCol, serviceCol string + // Extract columns if enabled (K8s or Host/Service) + var col1, col2 string columnsWidth := 0 if m.showColumns { - // Extract host.name and service.name from OTLP attributes - host := entry.Attributes["host.name"] - service := entry.Attributes["service.name"] + // Check if this is a k8s log (has k8s.namespace or k8s.pod attributes) + namespace := entry.Attributes["k8s.namespace"] + pod := entry.Attributes["k8s.pod"] + isK8s := namespace != "" || pod != "" + + if isK8s { + // K8s mode: show namespace and pod (both truncated to 20 chars) + if len(namespace) > 20 { + namespace = namespace[:17] + "..." + } + if len(pod) > 20 { + pod = pod[:17] + "..." + } - // Truncate to fit column width (12 chars / 16 chars) - if len(host) > 12 { - host = host[:9] + "..." - } - if len(service) > 16 { - service = service[:13] + "..." - } + // Style the k8s columns + col1 = lipgloss.NewStyle(). + Foreground(ColorGreen). + Render(fmt.Sprintf("%-20s", namespace)) - // Style the columns - hostCol = lipgloss.NewStyle(). - Foreground(ColorGreen). - Render(fmt.Sprintf("%-12s", host)) + col2 = lipgloss.NewStyle(). + Foreground(ColorBlue). + Render(fmt.Sprintf("%-20s", pod)) - serviceCol = lipgloss.NewStyle(). - Foreground(ColorBlue). - Render(fmt.Sprintf("%-16s", service)) + columnsWidth = 42 // 20 + 20 + 2 spaces + } else { + // Normal mode: show host.name and service.name from OTLP attributes + host := entry.Attributes["host.name"] + service := entry.Attributes["service.name"] - columnsWidth = 30 // 12 + 16 + 2 spaces + // Truncate to fit column width (12 chars / 16 chars) + if len(host) > 12 { + host = host[:9] + "..." + } + if len(service) > 16 { + service = service[:13] + "..." + } + + // Style the columns + col1 = lipgloss.NewStyle(). + Foreground(ColorGreen). + Render(fmt.Sprintf("%-12s", host)) + + col2 = lipgloss.NewStyle(). + Foreground(ColorBlue). + Render(fmt.Sprintf("%-16s", service)) + + columnsWidth = 30 // 12 + 16 + 2 spaces + } } // Truncate message if too long @@ -131,7 +180,7 @@ func (m *DashboardModel) formatLogEntry(entry LogEntry, availableWidth int, isSe // Create the complete log line var logLine string if m.showColumns { - logLine = fmt.Sprintf("%s %s %s %s %s", styledTimestamp, styledSeverity, hostCol, serviceCol, message) + logLine = fmt.Sprintf("%s %s %s %s %s", styledTimestamp, styledSeverity, col1, col2, message) } else { logLine = fmt.Sprintf("%s %s %s", styledTimestamp, styledSeverity, message) } diff --git a/internal/tui/modal_k8s_filter.go b/internal/tui/modal_k8s_filter.go new file mode 100644 index 0000000..34b98f4 --- /dev/null +++ b/internal/tui/modal_k8s_filter.go @@ -0,0 +1,424 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// renderK8sFilterModal renders the Kubernetes namespace/pod filter modal +func (m *DashboardModel) renderK8sFilterModal() string { + // Calculate dimensions - wider modal to accommodate long pod names + modalWidth := min(m.width-10, 120) + modalHeight := min(m.height-8, 25) + + // Account for borders and headers + contentWidth := modalWidth - 4 + contentHeight := modalHeight - 5 // Header (1) + tab instructions (1) + status bar (1) + outer border (2) + + // Maximum item width (for truncation) = contentWidth - prefix - status - margin + maxItemWidth := contentWidth - 6 // "► " (2) + " ✓" (2) + margin (2) + + // Build all filter lines (ONLY the scrollable list, no extra headers) + var allLines []string + + // Header showing current view + viewTitle := "Kubernetes Filter - Namespaces" + if m.k8sActiveView == "pods" { + viewTitle = "Kubernetes Filter - Pods" + } + + // Build the list (no extra content - just the items) + if m.k8sActiveView == "namespaces" { + // Show namespaces + allLines = append(allLines, m.renderNamespaceList(maxItemWidth)...) + } else { + // Show pods + allLines = append(allLines, m.renderPodList(maxItemWidth)...) + } + + // Calculate scroll window (matching model_selection_modal pattern) + // Reserve space for: borders (2) + scroll indicators (2) + totalLines := len(allLines) + maxVisibleLines := contentHeight - 4 + visibleCount := maxVisibleLines + if visibleCount > totalLines { + visibleCount = totalLines + } + + // Ensure selected item is visible by adjusting scroll offset + if m.k8sFilterSelected < m.k8sScrollOffset { + m.k8sScrollOffset = m.k8sFilterSelected + } else if m.k8sFilterSelected >= m.k8sScrollOffset+visibleCount { + m.k8sScrollOffset = m.k8sFilterSelected - visibleCount + 1 + } + + // Clamp scroll offset + maxScroll := totalLines - visibleCount + if maxScroll < 0 { + maxScroll = 0 + } + if m.k8sScrollOffset > maxScroll { + m.k8sScrollOffset = maxScroll + } + if m.k8sScrollOffset < 0 { + m.k8sScrollOffset = 0 + } + + // Extract visible portion (limited by actual visible space minus border) + visibleLines := allLines[m.k8sScrollOffset:] + if len(visibleLines) > maxVisibleLines { + visibleLines = visibleLines[:maxVisibleLines] + } + + // Add scroll indicators + scrollInfo := "" + if totalLines > maxVisibleLines { + scrollInfo = fmt.Sprintf(" [%d/%d]", m.k8sScrollOffset+1, totalLines) + } + + // Create content pane with visible lines + // Don't set Height - let it naturally size to the content to avoid extra padding + contentText := strings.Join(visibleLines, "\n") + contentPane := lipgloss.NewStyle(). + Width(contentWidth). + Border(lipgloss.NormalBorder()). + BorderForeground(ColorBlue). + Render(contentText) + + // Header + activeNamespaces := 0 + for _, enabled := range m.k8sNamespaces { + if enabled { + activeNamespaces++ + } + } + activePods := 0 + for _, enabled := range m.k8sPods { + if enabled { + activePods++ + } + } + headerText := fmt.Sprintf("%s (%d namespaces, %d pods selected)%s", + viewTitle, activeNamespaces, activePods, scrollInfo) + header := lipgloss.NewStyle(). + Width(contentWidth). + Foreground(ColorBlue). + Bold(true). + Render(headerText) + + // Tab instructions (rendered separately, not in scrollable area) + tabInstructions := lipgloss.NewStyle(). + Foreground(ColorBlue). + Render("Tab: Switch between Namespaces / Pods") + + // Status bar + statusBar := lipgloss.NewStyle(). + Foreground(ColorGray). + Render("↑↓: Navigate • Space: Toggle • Tab: Switch View • Enter: Apply • ESC: Cancel") + + // Combine all parts (header, tab instructions, content, status) + modal := lipgloss.JoinVertical(lipgloss.Left, header, tabInstructions, contentPane, statusBar) + + // Add outer border and center + // Don't set Height - let it naturally size to avoid extra padding at bottom + finalModal := lipgloss.NewStyle(). + Width(modalWidth). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorBlue). + Render(modal) + + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, finalModal) +} + +// renderNamespaceList renders the list of namespaces +func (m *DashboardModel) renderNamespaceList(maxItemWidth int) []string { + var lines []string + + // Add "All Namespaces" option at the top + allNamespacesPrefix := " " + if m.k8sFilterSelected == 0 { + allNamespacesPrefix = "► " + } + allSelected := true + for _, enabled := range m.k8sNamespaces { + if !enabled { + allSelected = false + break + } + } + selectAllStatus := "" + if allSelected { + selectAllStatus = " ✓" + } + selectAllLine := allNamespacesPrefix + "All Namespaces" + selectAllStatus + + // Style the select all line + if m.k8sFilterSelected == 0 { + selectedStyle := lipgloss.NewStyle(). + Foreground(ColorBlue). + Bold(true) + selectAllLine = selectedStyle.Render(selectAllLine) + } + lines = append(lines, selectAllLine) + + // Add separator + lines = append(lines, "") + + // Get sorted namespace names (use helper for consistency) + namespaces := m.getSortedNamespaces() + + // Add individual namespaces (starting from index 2 after "All" and separator) + for i, ns := range namespaces { + listIndex := i + 2 + prefix := " " + if m.k8sFilterSelected == listIndex { + prefix = "► " + } + + // Show selection status + status := "" + if m.k8sNamespaces[ns] { + status = " ✓" + } + + // Truncate namespace name if too long + displayName := ns + if len(displayName) > maxItemWidth { + displayName = displayName[:maxItemWidth-3] + "..." + } + + line := prefix + displayName + status + + // Apply selection styling + if m.k8sFilterSelected == listIndex { + selectedStyle := lipgloss.NewStyle(). + Foreground(ColorBlue). + Bold(true) + line = selectedStyle.Render(line) + } + + lines = append(lines, line) + } + + return lines +} + +// renderPodList renders the list of pods +func (m *DashboardModel) renderPodList(maxItemWidth int) []string { + var lines []string + + // Add "All Pods" option at the top + allPodsPrefix := " " + if m.k8sFilterSelected == 0 { + allPodsPrefix = "► " + } + allSelected := true + for _, enabled := range m.k8sPods { + if !enabled { + allSelected = false + break + } + } + selectAllStatus := "" + if allSelected { + selectAllStatus = " ✓" + } + selectAllLine := allPodsPrefix + "All Pods" + selectAllStatus + + // Style the select all line + if m.k8sFilterSelected == 0 { + selectedStyle := lipgloss.NewStyle(). + Foreground(ColorBlue). + Bold(true) + selectAllLine = selectedStyle.Render(selectAllLine) + } + lines = append(lines, selectAllLine) + + // Add separator + lines = append(lines, "") + + // Get sorted pod names (use helper for consistency) + pods := m.getSortedPods() + + // Add individual pods (starting from index 2 after "All" and separator) + for i, pod := range pods { + listIndex := i + 2 + prefix := " " + if m.k8sFilterSelected == listIndex { + prefix = "► " + } + + // Show selection status + status := "" + if m.k8sPods[pod] { + status = " ✓" + } + + // Truncate pod name if too long + displayName := pod + if len(displayName) > maxItemWidth { + displayName = displayName[:maxItemWidth-3] + "..." + } + + line := prefix + displayName + status + + // Apply selection styling + if m.k8sFilterSelected == listIndex { + selectedStyle := lipgloss.NewStyle(). + Foreground(ColorBlue). + Bold(true) + line = selectedStyle.Render(line) + } + + lines = append(lines, line) + } + + if len(pods) == 0 { + lines = append(lines, lipgloss.NewStyle(). + Foreground(ColorGray). + Italic(true). + Render(" No pods available")) + } + + return lines +} + +// updateK8sNamespacesFromLogs scans log entries for k8s.namespace attributes +func (m *DashboardModel) updateK8sNamespacesFromLogs() { + if m.k8sNamespaces == nil { + m.k8sNamespaces = make(map[string]bool) + } + + // Scan all logs for k8s.namespace attribute + for _, entry := range m.allLogEntries { + if ns, ok := entry.Attributes["k8s.namespace"]; ok && ns != "" { + if _, exists := m.k8sNamespaces[ns]; !exists { + // New namespace found, enable it by default + m.k8sNamespaces[ns] = true + } + } + } +} + +// updateK8sPodsFromLogs scans log entries for k8s.pod attributes +func (m *DashboardModel) updateK8sPodsFromLogs() { + if m.k8sPods == nil { + m.k8sPods = make(map[string]bool) + } + + // Scan all logs for k8s.pod attribute (filtered by selected namespaces) + for _, entry := range m.allLogEntries { + ns, hasNs := entry.Attributes["k8s.namespace"] + pod, hasPod := entry.Attributes["k8s.pod"] + + // Only include pods from selected namespaces + if hasPod && pod != "" { + if !hasNs || m.k8sNamespaces[ns] { + // Format: namespace/pod for clarity + podKey := pod + if hasNs { + podKey = ns + "/" + pod + } + if _, exists := m.k8sPods[podKey]; !exists { + // New pod found, enable it by default + m.k8sPods[podKey] = true + } + } + } + } +} + +// updateK8sNamespacesFromAPI queries Kubernetes API for available namespaces +func (m *DashboardModel) updateK8sNamespacesFromAPI() { + // If no K8s source available, fall back to scanning logs + if m.k8sSource == nil { + m.updateK8sNamespacesFromLogs() + return + } + + // Query Kubernetes API for namespaces + namespaces, err := m.k8sSource.ListNamespaces() + if err != nil { + // Fallback to scanning logs if API query fails + m.updateK8sNamespacesFromLogs() + return + } + + // Preserve existing selections if we already have namespaces + if len(m.k8sNamespaces) > 0 { + // Preserve selections: keep existing state for namespaces that still exist + for ns := range namespaces { + if existingSelected, exists := m.k8sNamespaces[ns]; exists { + // Namespace already in our list - keep user's selection + namespaces[ns] = existingSelected + } + // New namespaces will use the default from API (selected by default) + } + } + + // Update namespaces map + m.k8sNamespaces = namespaces +} + +// updateK8sPodsFromAPI queries Kubernetes API for available pods +func (m *DashboardModel) updateK8sPodsFromAPI() { + // If no K8s source available, fall back to scanning logs + if m.k8sSource == nil { + m.updateK8sPodsFromLogs() + return + } + + // Build map of only selected namespaces to pass to API + selectedNamespaces := make(map[string]bool) + for ns, selected := range m.k8sNamespaces { + if selected { + selectedNamespaces[ns] = true + } + } + + // Query Kubernetes API for pods (only from selected namespaces) + pods, err := m.k8sSource.ListPods(selectedNamespaces) + if err != nil { + // Fallback to scanning logs if API query fails + m.updateK8sPodsFromLogs() + return + } + + // Preserve existing pod selections if we already have pods + if len(m.k8sPods) > 0 { + // Preserve selections: keep existing state for pods that still exist + for pod := range pods { + if existingSelected, exists := m.k8sPods[pod]; exists { + // Pod already in our list - keep user's selection + pods[pod] = existingSelected + } + // New pods will use the default from API (selected by default) + } + } + + // Update pods map + m.k8sPods = pods +} + +// getSortedNamespaces returns a sorted list of namespace names +func (m *DashboardModel) getSortedNamespaces() []string { + namespaces := make([]string, 0, len(m.k8sNamespaces)) + for ns := range m.k8sNamespaces { + namespaces = append(namespaces, ns) + } + sort.Strings(namespaces) + return namespaces +} + +// getSortedPods returns a sorted list of pod names +func (m *DashboardModel) getSortedPods() []string { + pods := make([]string, 0, len(m.k8sPods)) + for pod := range m.k8sPods { + pods = append(pods, pod) + } + sort.Strings(pods) + return pods +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 3cfcc2f..70d13ef 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -15,6 +15,14 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +// K8sSourceInterface defines the interface for Kubernetes log source +// This allows the TUI to query available namespaces and pods +type K8sSourceInterface interface { + ListNamespaces() (map[string]bool, error) + ListPods(selectedNamespaces map[string]bool) (map[string]bool, error) + UpdateFilter(namespaces []string, selector string, podNames []string) error +} + // Section represents different dashboard sections type Section int @@ -102,6 +110,18 @@ type DashboardModel struct { severityFilterActive bool // Whether severity filtering is active (any severity disabled) severityFilterOriginal map[string]bool // Original state when modal opened (for ESC cancellation) + // Kubernetes Filter (requires integration with k8s log source) + showK8sFilterModal bool // Whether to show K8s filter modal + k8sNamespaces map[string]bool // Available namespaces and their selection state + k8sPods map[string]bool // Available pods and their selection state + k8sFilterSelected int // Selected index in K8s filter modal + k8sScrollOffset int // Scroll offset for K8s filter modal + k8sFilterOriginal map[string]bool // Original namespace state (for ESC cancellation) + k8sPodsOriginal map[string]bool // Original pod state (for ESC cancellation) + k8sActiveView string // "namespaces" or "pods" + k8sFilterActive bool // Whether K8s filtering is currently active + k8sSource K8sSourceInterface // Reference to K8s source for listing namespaces/pods + // Charts data for rendering chartsInitialized bool @@ -420,6 +440,28 @@ func (m *DashboardModel) SetVersionChecker(checker *versioncheck.Checker) { m.versionChecker = checker } +// SetK8sSource sets the Kubernetes log source for the dashboard +func (m *DashboardModel) SetK8sSource(source K8sSourceInterface) { + m.k8sSource = source +} + +// isK8sMode returns true if any logs have k8s attributes (namespace or pod) +func (m *DashboardModel) isK8sMode() bool { + // Check the most recent log entries for k8s attributes + // We only need to check a few entries to determine mode + checkCount := min(10, len(m.logEntries)) + for i := len(m.logEntries) - checkCount; i < len(m.logEntries); i++ { + if i < 0 { + continue + } + entry := m.logEntries[i] + if entry.Attributes["k8s.namespace"] != "" || entry.Attributes["k8s.pod"] != "" { + return true + } + } + return false +} + // Init initializes the model func (m *DashboardModel) Init() tea.Cmd { var cmds []tea.Cmd diff --git a/internal/tui/navigation.go b/internal/tui/navigation.go index bfd535b..06116c4 100644 --- a/internal/tui/navigation.go +++ b/internal/tui/navigation.go @@ -217,6 +217,17 @@ func (m *DashboardModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.showCountsModal = false return m, nil } + if m.showK8sFilterModal { + // Restore original state (cancel changes) + for k, v := range m.k8sFilterOriginal { + m.k8sNamespaces[k] = v + } + for k, v := range m.k8sPodsOriginal { + m.k8sPods[k] = v + } + m.showK8sFilterModal = false + return m, nil + } if m.showSeverityFilterModal { // Restore original state (cancel changes) for k, v := range m.severityFilterOriginal { @@ -394,7 +405,7 @@ func (m *DashboardModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "ctrl+f": // Severity filter modal - if !m.showModal && !m.filterActive && !m.searchActive && !m.showHelp && !m.showPatternsModal && !m.showModelSelectionModal && !m.showStatsModal && !m.showCountsModal { + if !m.showModal && !m.filterActive && !m.searchActive && !m.showHelp && !m.showPatternsModal && !m.showModelSelectionModal && !m.showStatsModal && !m.showCountsModal && !m.showK8sFilterModal { // Store original state for ESC cancellation m.severityFilterOriginal = make(map[string]bool) for k, v := range m.severityFilter { @@ -405,9 +416,33 @@ func (m *DashboardModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + case "ctrl+k": + // Kubernetes filter modal + if !m.showModal && !m.filterActive && !m.searchActive && !m.showHelp && !m.showPatternsModal && !m.showModelSelectionModal && !m.showStatsModal && !m.showCountsModal && !m.showSeverityFilterModal { + // Update namespaces and pods from Kubernetes API + m.updateK8sNamespacesFromAPI() + m.updateK8sPodsFromAPI() + + // Store original state for ESC cancellation + m.k8sFilterOriginal = make(map[string]bool) + for k, v := range m.k8sNamespaces { + m.k8sFilterOriginal[k] = v + } + m.k8sPodsOriginal = make(map[string]bool) + for k, v := range m.k8sPods { + m.k8sPodsOriginal[k] = v + } + + m.showK8sFilterModal = true + m.k8sFilterSelected = 0 // Start at the top + m.k8sScrollOffset = 0 // Reset scroll + m.k8sActiveView = "namespaces" // Start with namespaces view + return m, nil + } + case " ": // Spacebar: Global pause/unpause toggle for entire UI - if !m.showModal && !m.filterActive && !m.searchActive && !m.showSeverityFilterModal { + if !m.showModal && !m.filterActive && !m.searchActive && !m.showSeverityFilterModal && !m.showK8sFilterModal { wasPaused := m.viewPaused m.viewPaused = !m.viewPaused @@ -678,6 +713,154 @@ func (m *DashboardModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + // Kubernetes filter modal shortcuts + if m.showK8sFilterModal { + var totalItems int + if m.k8sActiveView == "namespaces" { + totalItems = len(m.k8sNamespaces) + 2 // +2 for "All Namespaces" and separator + } else { + totalItems = len(m.k8sPods) + 2 // +2 for "All Pods" and separator + } + + switch msg.String() { + case "tab": + // Switch between namespaces and pods view + if m.k8sActiveView == "namespaces" { + m.k8sActiveView = "pods" + // Update pods based on selected namespaces + m.updateK8sPodsFromAPI() + } else { + m.k8sActiveView = "namespaces" + } + m.k8sFilterSelected = 0 // Reset selection to top + m.k8sScrollOffset = 0 // Reset scroll + return m, nil + + case "up", "k": + if m.k8sFilterSelected > 0 { + m.k8sFilterSelected-- + // Skip separator at index 1 + if m.k8sFilterSelected == 1 { + m.k8sFilterSelected = 0 + } + } + return m, nil + + case "down", "j": + if m.k8sFilterSelected < totalItems-1 { + m.k8sFilterSelected++ + // Skip separator at index 1 + if m.k8sFilterSelected == 1 { + m.k8sFilterSelected = 2 + } + } + return m, nil + + case " ": + // Spacebar: Toggle selection + if m.k8sActiveView == "namespaces" { + if m.k8sFilterSelected == 0 { + // Toggle All Namespaces - if all are selected, deselect all; otherwise select all + allSelected := true + for _, enabled := range m.k8sNamespaces { + if !enabled { + allSelected = false + break + } + } + // Toggle: if all selected, deselect all; otherwise select all + newState := !allSelected + for ns := range m.k8sNamespaces { + m.k8sNamespaces[ns] = newState + } + // Update pods when namespaces change + m.updateK8sPodsFromAPI() + } else if m.k8sFilterSelected >= 2 { + // Individual namespace - use helper to get sorted list + sortedNS := m.getSortedNamespaces() + nsIndex := m.k8sFilterSelected - 2 + if nsIndex >= 0 && nsIndex < len(sortedNS) { + ns := sortedNS[nsIndex] + m.k8sNamespaces[ns] = !m.k8sNamespaces[ns] + // Update pods when namespaces change + m.updateK8sPodsFromAPI() + } + } + } else { + // Pods view + if m.k8sFilterSelected == 0 { + // Toggle All Pods - if all are selected, deselect all; otherwise select all + allSelected := true + for _, enabled := range m.k8sPods { + if !enabled { + allSelected = false + break + } + } + // Toggle: if all selected, deselect all; otherwise select all + newState := !allSelected + for pod := range m.k8sPods { + m.k8sPods[pod] = newState + } + } else if m.k8sFilterSelected >= 2 { + // Individual pod - use helper to get sorted list + sortedPods := m.getSortedPods() + podIndex := m.k8sFilterSelected - 2 + if podIndex >= 0 && podIndex < len(sortedPods) { + pod := sortedPods[podIndex] + m.k8sPods[pod] = !m.k8sPods[pod] + } + } + } + m.k8sFilterActive = true + return m, nil + + case "enter": + // Apply filter and close modal + m.showK8sFilterModal = false + m.k8sFilterActive = true + + // Update the actual K8s source to stream only from selected namespaces and pods + if m.k8sSource != nil { + // Build list of selected namespaces + var selectedNamespaces []string + for ns, selected := range m.k8sNamespaces { + if selected { + selectedNamespaces = append(selectedNamespaces, ns) + } + } + + // If no namespaces selected, use empty string to mean "all" + if len(selectedNamespaces) == 0 { + selectedNamespaces = []string{""} + } + + // Build list of selected pods (format: namespace/podname or just podname) + var selectedPods []string + for pod, selected := range m.k8sPods { + if selected { + selectedPods = append(selectedPods, pod) + } + } + + // Update K8s source filter (both namespace and pod filtering at source) + if err := m.k8sSource.UpdateFilter(selectedNamespaces, "", selectedPods); err != nil { + // Log error but don't block + // Note: In production, you might want to show this error to the user + } + } + + // Refresh filtered view + m.updateFilteredView() + return m, nil + + case "escape", "esc": + // Cancel handled in escape section above + return m, nil + } + return m, nil + } + // Severity filter modal shortcuts if m.showSeverityFilterModal { severityLevels := []string{"FATAL", "CRITICAL", "ERROR", "WARN", "INFO", "DEBUG", "TRACE", "UNKNOWN"} diff --git a/internal/tui/update.go b/internal/tui/update.go index 074888a..9a25526 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -660,8 +660,40 @@ func (m *DashboardModel) updateFilteredView() { normalizedSeverity := normalizeSeverityLevel(entry.Severity) passesSeverityFilter := !m.severityFilterActive || m.severityFilter[normalizedSeverity] - // Include entry only if it passes both filters - if passesRegexFilter && passesSeverityFilter { + // Check K8s filter (if active) + // Note: When using K8s source, filtering is applied at the source level, + // so logs from unselected pods won't arrive here at all. + // This display-side filter is kept as a fallback for edge cases. + passesK8sFilter := true + if m.k8sFilterActive && m.k8sSource == nil { + // Only apply display-side filtering if we don't have a K8s source + // (e.g., when reading k8s logs from stdin/file) + ns, hasNs := entry.Attributes["k8s.namespace"] + pod, hasPod := entry.Attributes["k8s.pod"] + + // If entry has K8s attributes, apply filtering + if hasNs || hasPod { + // Default to not passing if we have K8s attributes + passesK8sFilter = false + + // Check namespace filter - must be selected + if hasNs && m.k8sNamespaces[ns] { + passesK8sFilter = true + + // Also check pod filter if pod attribute exists + if hasPod { + // Build namespace/pod key for lookup + podKey := ns + "/" + pod + // Check if this specific pod is selected + passesK8sFilter = m.k8sPods[podKey] + } + } + } + // If no K8s attributes, let it pass (non-K8s logs) + } + + // Include entry only if it passes all filters + if passesRegexFilter && passesSeverityFilter && passesK8sFilter { m.logEntries = append(m.logEntries, entry) } } diff --git a/internal/tui/view.go b/internal/tui/view.go index 8d3b027..d1c8390 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -37,6 +37,11 @@ func (m *DashboardModel) View() string { return m.renderCountsModal() } + // Show Kubernetes filter modal (check before log viewer so it can overlay) + if m.showK8sFilterModal { + return m.renderK8sFilterModal() + } + // Show severity filter modal (check before log viewer so it can overlay) if m.showSeverityFilterModal { return m.renderSeverityFilterModal()