diff --git a/.gitignore b/.gitignore index 415ccd9..a5e7ade 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,10 @@ tmp/ # Testing artifacts testdata/output/ +# Web dashboard +web/node_modules/ +web/dist/ + # Claude Code settings .claude/settings.local.json result diff --git a/Makefile b/Makefile index d908edc..b21b72c 100644 --- a/Makefile +++ b/Makefile @@ -32,9 +32,13 @@ PURPLE := \033[0;35m CYAN := \033[0;36m NC := \033[0m # No Color -.PHONY: all build clean clean-all test test-race test-integration \ +# Code signing (macOS) — set via environment or here +CODESIGN_IDENTITY ?= - + +.PHONY: all build build-tui clean clean-all test test-race test-integration \ fmt vet lint install uninstall run demo \ - deps deps-update deps-tidy cross-build release help dev ci info + deps deps-update deps-tidy cross-build release help dev ci info \ + web-deps web-build web-dev web-clean build-signed # Default target all: clean build @@ -53,16 +57,54 @@ help: ## Show this help message @echo " make cross-build # Build for all platforms" @echo " make install # Install to GOPATH/bin" +# Web dashboard targets +web-deps: ## Install web dashboard dependencies + @echo "$(BLUE)Installing web dependencies...$(NC)" + @cd web && rm -rf node_modules && npm ci + @echo "$(GREEN)✓ Web dependencies installed$(NC)" + +web-build: web-deps ## Build web dashboard + @echo "$(BLUE)Building web dashboard...$(NC)" + @cd web && npm run build + @echo "$(GREEN)✓ Web dashboard built$(NC)" + +web-dev: web-deps ## Start web dev server with API proxy + @echo "$(BLUE)Starting web dev server...$(NC)" + @cd web && npm run dev + +web-clean: ## Clean web build artifacts + @echo "$(BLUE)Cleaning web artifacts...$(NC)" + @rm -rf web/dist web/node_modules + @echo "$(GREEN)✓ Web artifacts cleaned$(NC)" + +# Ensure web/dist exists for go:embed (stub if not built) +ensure-web-dist: + @mkdir -p web/dist + @test -f web/dist/index.html || echo '
' > web/dist/index.html + # Build targets -build: deps ## Build the TUI binary - @echo "$(BLUE)Building TUI version...$(NC)" +build: deps web-build ## Build binary with embedded web dashboard + @echo "$(BLUE)Building Gonzo with web dashboard...$(NC)" @mkdir -p $(BUILD_DIR) CGO_ENABLED=$(CGO_ENABLED) GOOS=$(GOOS) GOARCH=$(GOARCH) \ $(GO) build $(BUILD_FLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(CMD_DIR) @echo "$(GREEN)✓ Built $(BUILD_DIR)/$(BINARY_NAME)$(NC)" +build-signed: build ## Build and codesign for macOS (set CODESIGN_IDENTITY for distribution) + @echo "$(BLUE)Signing $(BUILD_DIR)/$(BINARY_NAME)...$(NC)" + @codesign --force --options runtime --sign "$(CODESIGN_IDENTITY)" --timestamp $(BUILD_DIR)/$(BINARY_NAME) + @echo "$(GREEN)✓ Signed $(BUILD_DIR)/$(BINARY_NAME)$(NC)" + @codesign -dvv $(BUILD_DIR)/$(BINARY_NAME) 2>&1 | head -5 + +build-tui: deps ensure-web-dist ## Build TUI-only binary (placeholder web dashboard) + @echo "$(BLUE)Building TUI-only version...$(NC)" + @mkdir -p $(BUILD_DIR) + CGO_ENABLED=$(CGO_ENABLED) GOOS=$(GOOS) GOARCH=$(GOARCH) \ + $(GO) build $(BUILD_FLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(CMD_DIR) + @echo "$(GREEN)✓ Built $(BUILD_DIR)/$(BINARY_NAME) (TUI only)$(NC)" + # Cross-platform builds -cross-build: clean deps ## Build for multiple platforms +cross-build: clean deps ensure-web-dist ## Build for multiple platforms @echo "$(BLUE)Building for multiple platforms...$(NC)" @mkdir -p $(DIST_DIR) @@ -108,11 +150,11 @@ deps-tidy: ## Clean up dependencies @$(GO) mod tidy # Testing -test: deps ## Run tests +test: deps ensure-web-dist ## Run tests @echo "$(BLUE)Running tests...$(NC)" @$(GO) test -v ./... -test-race: deps ## Run tests with race detection +test-race: deps ensure-web-dist ## Run tests with race detection @echo "$(BLUE)Running tests with race detection...$(NC)" @$(GO) test -race -v ./... @@ -142,7 +184,7 @@ fmt: ## Format code @echo "$(BLUE)Formatting code...$(NC)" @$(GO) fmt ./... -vet: ## Run go vet +vet: ensure-web-dist ## Run go vet @echo "$(BLUE)Running go vet...$(NC)" @$(GO) vet ./... @@ -211,6 +253,7 @@ clean: ## Clean build artifacts @echo "$(BLUE)Cleaning build artifacts...$(NC)" @rm -rf $(BUILD_DIR) @rm -rf $(DIST_DIR) + @rm -rf web/dist @rm -f $(BINARY_NAME) @echo "$(GREEN)✓ Cleaned$(NC)" diff --git a/README.md b/README.md index afd118f..a1de077 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,17 @@ Here are some references to get you started: - **Log Counts analysis** - Detailed modal with heatmap visualization, pattern analysis by severity, and service distribution - **AI analysis** - Get intelligent insights about log patterns and anomalies with configurable models +### 🌐 Web Dashboard (Dstl8 Lite) + +- **Embedded React UI** - Full web dashboard served directly from the Gonzo binary (no external dependencies) +- **Real-time updates** - WebSocket-powered live streaming with 1-second refresh +- **Severity distribution** - Interactive time-series severity charts with stream-level filtering +- **Sentiment heatmap** - Color-coded heatmap visualization grouped by pod, namespace, service, host, or deployment +- **Pattern analysis** - Drain3-powered log pattern detection and classification +- **Log viewer** - Searchable, auto-scrolling log viewer with click-to-expand details +- **Source browser** - Explore log sources with dimension breakdowns +- **Light/dark mode** - Automatic theme support + ### 🔍 Advanced Filtering - **Regex support** - Filter logs with regular expressions @@ -306,6 +317,30 @@ export OPENAI_API_KEY=sk-your-key-here cat logs.json | gonzo --ai-model="gpt-4" ``` +### Web Dashboard (Dstl8 Lite) + +Gonzo includes an embedded web dashboard that runs alongside the TUI. It starts automatically on port 5718. + +```bash +# Gonzo starts the web dashboard automatically +gonzo -f application.log --follow +# Open http://localhost:5718 in your browser + +# Use a custom port +gonzo -f application.log --web-port=3000 + +# Disable the web dashboard +gonzo -f application.log --web-disabled +``` + +The dashboard includes: +- **Workspaces** - Overview of all active log streams with sparkline previews +- **Stream Details** - Severity distribution, top attributes, pattern analysis, and live log viewer per stream +- **Sentiment Heatmap** - Real-time heatmap grouped by pod, namespace, service, host, or deployment with auto-detection of available dimensions +- **Sources** - Browse log sources with dimension breakdowns + +All data updates in real-time via WebSocket, matching what you see in the TUI. + ### Keyboard Shortcuts #### Navigation @@ -452,6 +487,10 @@ Flags: -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) +Web Dashboard Flags: + --web-port int Port for the Dstl8 Lite web dashboard (default: 5718) + --web-disabled Disable the web dashboard + Kubernetes Flags: --k8s-enabled=true Enable Kubernetes log streaming mode --k8s-namespaces stringArray Kubernetes namespace(s) to watch (can specify multiple, default: all) @@ -501,6 +540,10 @@ test-mode: false # AI configuration ai-provider: "openai" # Options: "openai" (default), "claude-code" ai-model: "gpt-4" + +# Web dashboard (Dstl8 Lite) +web-port: 5718 # Port for the web dashboard +web-disabled: false # Set to true to disable ``` See [examples/config.yml](examples/config.yml) for a complete configuration example with detailed comments. @@ -689,6 +732,8 @@ When you don't specify the `--ai-model` flag, Gonzo automatically selects the be | `GONZO_LOG_BUFFER` | Override log buffer size | | `GONZO_MEMORY_SIZE` | Override memory size | | `GONZO_AI_MODEL` | Override default AI model | +| `GONZO_WEB_PORT` | Override web dashboard port (default: 5718) | +| `GONZO_WEB_DISABLED` | Disable web dashboard (true/false) | | `GONZO_TEST_MODE` | Enable test mode | | `NO_COLOR` | Disable colored output | @@ -760,16 +805,19 @@ Gonzo is built with: - **OpenTelemetry** - Native OTLP support - **Large amounts of** ☕️ -The architecture follows a clean separation: +The architecture follows a clean separation with a shared analysis engine: ``` cmd/gonzo/ # Main application entry internal/ -├── tui/ # Terminal UI implementation -├── analyzer/ # Log analysis engine +├── engine/ # Shared analysis engine (feeds TUI and web) +├── tui/ # Terminal UI implementation +├── web/ # Dstl8 Lite web dashboard (embedded React) +├── analyzer/ # Log analysis engine ├── memory/ # Frequency tracking ├── otlplog/ # OTLP format handling └── ai/ # AI integration +web/ # React frontend source (Vite + TypeScript) ``` ## 🧪 Development @@ -782,9 +830,13 @@ internal/ ### Building ```bash -# Quick build +# Quick build (includes web dashboard) make build +# Build web dashboard only +make web-deps # Install npm dependencies +make web-build # Build React app + # Run tests make test @@ -879,6 +931,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - [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 +- [Web Dashboard](guides/WEB_DASHBOARD_USAGE.md) - Dstl8 Lite web UI setup - [Contributing Guide](CONTRIBUTING.md) - How to contribute - [Changelog](CHANGELOG.md) - Version history diff --git a/USAGE_GUIDE.md b/USAGE_GUIDE.md index d4c45ac..33fb55d 100644 --- a/USAGE_GUIDE.md +++ b/USAGE_GUIDE.md @@ -143,8 +143,12 @@ cat test.log | ./build/gonzo --reverse-scroll-wheel # Reverse scroll wheel direction (natural scrolling) --config string # Config file (default: ~/.gonzo.yaml) +# Web dashboard (Dstl8 Lite) + --web-port=5718 # Port for the web dashboard + --web-disabled # Disable the web dashboard + # Version and help --v, --version # Show version information +-v, --version # Show version information -h, --help # Show help message # Commands @@ -273,6 +277,44 @@ This affects all scrollable areas: - **Consistency**: Keep same scroll direction across all applications - **Personal preference**: Use whichever feels more intuitive to you +## Web Dashboard (Dstl8 Lite) + +Gonzo includes an embedded web dashboard that starts automatically alongside the TUI. + +### Accessing the Dashboard + +```bash +# Start Gonzo — web dashboard is available at http://localhost:5718 +./build/gonzo -f test.log --follow + +# Custom port +./build/gonzo -f test.log --web-port=3000 + +# Disable the web dashboard +./build/gonzo -f test.log --web-disabled +``` + +Open `http://localhost:5718` in your browser to see: +- **Workspaces** - Overview of active log streams with live sparklines +- **Stream Details** - Severity distribution, top attributes, pattern analysis, and live log viewer +- **Sentiment Heatmap** - Real-time heatmap grouped by pod, namespace, service, host, or deployment +- **Sources** - Browse log sources and their dimensions + +The dashboard updates in real-time via WebSocket — the same data that powers the TUI. + +### Building the Web Dashboard + +The web dashboard is built with Vite + React + TypeScript and embedded into the Go binary. + +```bash +# Install dependencies and build (included in make build) +make web-deps +make web-build + +# Full build including web dashboard +make build +``` + ### Supported Integrations - [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 7939c69..eeaa0e1 100644 --- a/cmd/gonzo/app.go +++ b/cmd/gonzo/app.go @@ -12,7 +12,12 @@ import ( "strings" "time" + "io/fs" + "github.com/control-theory/gonzo/internal/analyzer" + "github.com/control-theory/gonzo/internal/engine" + "github.com/control-theory/gonzo/internal/releases" + "github.com/control-theory/gonzo/internal/state" "github.com/control-theory/gonzo/internal/filereader" "github.com/control-theory/gonzo/internal/formats" "github.com/control-theory/gonzo/internal/k8s" @@ -22,6 +27,8 @@ import ( "github.com/control-theory/gonzo/internal/tui" versioncheck "github.com/control-theory/gonzo/internal/version" "github.com/control-theory/gonzo/internal/vmlogs" + gonzoweb "github.com/control-theory/gonzo/internal/web" + webembed "github.com/control-theory/gonzo/web" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" @@ -108,6 +115,23 @@ func runApp(cmd *cobra.Command, args []string) error { if versionChecker != nil { dashboard.SetVersionChecker(versionChecker) } + if !cfg.WebDisabled { + dashboard.SetWebPort(cfg.WebPort) + } + + // What's New: fetch GitHub releases in background and wire up auto-show + currentVersion, _ := GetVersionInfo() + relFetcher := releases.NewFetcher() + if currentVersion != "dev" && currentVersion != "" && !cfg.DisableVersionCheck { + relFetcher.FetchInBackground() + } + appState := state.Load() + dashboard.SetReleasesFetcher(relFetcher) + dashboard.SetCurrentVersion(currentVersion) + dashboard.SetLastSeenVersion(appState.LastSeenVersion) + + // Create shared analysis engine (used by web dashboard) + eng := engine.NewEngine(cfg.LogBuffer, textAnalyzer.GetStopWords(), nil, cfg.UseLogTime) tuiModel := &simpleTuiModel{ formatDetector: formatDetector, @@ -117,6 +141,7 @@ func runApp(cmd *cobra.Command, args []string) error { otlpAnalyzer: otlpAnalyzer, freqMemory: freqMemory, dashboard: dashboard, + engine: eng, updateInterval: cfg.UpdateInterval, testMode: cfg.TestMode, versionChecker: versionChecker, @@ -138,6 +163,19 @@ func runApp(cmd *cobra.Command, args []string) error { tuiModel.ctx = ctx tuiModel.cancelFunc = cancel + // Start web dashboard in background (if not disabled) + if !cfg.WebDisabled { + // Get embedded web dashboard assets + var staticFS fs.FS + if distFS, err := fs.Sub(webembed.DistFS, "dist"); err == nil { + staticFS = distFS + } + webSrv := gonzoweb.NewServer(eng, staticFS, currentVersion, relFetcher) + if err := webSrv.Start(ctx, cfg.WebPort); err != nil { + log.Printf("Warning: web dashboard failed to start: %v", err) + } + } + if _, err := p.Run(); err != nil { if strings.Contains(err.Error(), "TTY") || strings.Contains(err.Error(), "/dev/tty") { return fmt.Errorf("TUI requires a real terminal. Try --test-mode for non-interactive testing") @@ -168,6 +206,7 @@ type simpleTuiModel struct { otlpAnalyzer *analyzer.OTLPAnalyzer freqMemory *memory.FrequencyMemory dashboard *tui.DashboardModel + engine *engine.Engine // Shared analysis state for web dashboard updateInterval time.Duration testMode bool ctx context.Context @@ -633,6 +672,11 @@ func (m *simpleTuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Send periodic snapshot with current count (even if 0) snapshot := m.freqMemory.GetSnapshot() + // Feed snapshot to the shared engine + if m.engine != nil { + m.engine.UpdateFrequencySnapshot(snapshot) + } + // No automatic reset - only manual reset via 'r' key now shouldReset := false @@ -644,6 +688,11 @@ func (m *simpleTuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ResetDrain3: shouldReset, // Reset drain3 when frequency memory resets } + // Feed interval counts to the shared engine + if m.engine != nil && m.severityCounts != nil { + m.engine.IngestSeverityCounts(*m.severityCounts) + } + // Reset severity counts for next interval m.severityCounts = &tui.SeverityCounts{} m.logCount = 0 @@ -783,6 +832,7 @@ func systemLogPath() string { case "darwin": return "/var/log/system.log" case "linux": + // Try syslog first, then messages for _, p := range []string{"/var/log/syslog", "/var/log/messages"} { if _, err := os.Stat(p); err == nil { return p diff --git a/cmd/gonzo/main.go b/cmd/gonzo/main.go index ae05aee..c924626 100644 --- a/cmd/gonzo/main.go +++ b/cmd/gonzo/main.go @@ -55,6 +55,10 @@ type Config struct { DisableVersionCheck bool `mapstructure:"disable-version-check"` ReverseScrollWheel bool `mapstructure:"reverse-scroll-wheel"` UseLogTime bool `mapstructure:"use-log-time"` + + // Web dashboard (Dstl8 Lite) + WebPort int `mapstructure:"web-port"` + WebDisabled bool `mapstructure:"web-disabled"` } var ( @@ -186,6 +190,10 @@ func init() { rootCmd.Flags().Bool("reverse-scroll-wheel", false, "Reverse scroll wheel direction (natural scrolling)") rootCmd.Flags().Bool("use-log-time", false, "Use original log timestamps instead of receive time for heatmap and display (falls back to receive time if log has no timestamp)") + // Dstl8 Lite web dashboard flags + rootCmd.Flags().Int("web-port", 5718, "Port for the Dstl8 Lite web dashboard") + rootCmd.Flags().Bool("web-disabled", false, "Disable the web dashboard") + // Bind flags to viper viper.BindPFlag("memory-size", rootCmd.Flags().Lookup("memory-size")) viper.BindPFlag("update-interval", rootCmd.Flags().Lookup("update-interval")) @@ -215,7 +223,8 @@ func init() { viper.BindPFlag("disable-version-check", rootCmd.Flags().Lookup("disable-version-check")) viper.BindPFlag("reverse-scroll-wheel", rootCmd.Flags().Lookup("reverse-scroll-wheel")) viper.BindPFlag("use-log-time", rootCmd.Flags().Lookup("use-log-time")) - + viper.BindPFlag("web-port", rootCmd.Flags().Lookup("web-port")) + viper.BindPFlag("web-disabled", rootCmd.Flags().Lookup("web-disabled")) // Add version command rootCmd.AddCommand(versionCmd) } diff --git a/cmd/gonzo/processing.go b/cmd/gonzo/processing.go index ee8ecea..0d11de2 100644 --- a/cmd/gonzo/processing.go +++ b/cmd/gonzo/processing.go @@ -151,6 +151,11 @@ func (m *simpleTuiModel) processSingleLogEntry(result *analyzer.AnalysisResult, // Count severity for this interval m.severityCounts.AddCount(logEntry.Severity) + // Feed the shared analysis engine (for web dashboard) + if m.engine != nil { + m.engine.Ingest(*logEntry) + } + updateMsg := tui.UpdateMsg{NewLogEntry: logEntry} m.dashboard.Update(updateMsg) } diff --git a/flake.nix b/flake.nix index 73cc30e..7422e9a 100644 --- a/flake.nix +++ b/flake.nix @@ -22,7 +22,13 @@ src = ./.; modules = ./gomod2nix.toml; - # If you split binaries later, enable: subPackages = [ "cmd/gonzo" ]; + # Create stub web/dist so //go:embed all:dist compiles + # Full web dashboard is built separately via `make build` + preBuild = '' + mkdir -p web/dist + echo '' > web/dist/index.html + ''; + ldflags = ["-s" "-w"]; meta = with pkgs.lib; { diff --git a/go.mod b/go.mod index 36039bf..e591ca8 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/NimbleMarkets/ntcharts v0.3.1 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 + github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/fsnotify/fsnotify v1.9.0 github.com/jaeyo/go-drain3 v0.1.2 @@ -19,16 +20,21 @@ require ( k8s.io/apimachinery v0.34.2 k8s.io/client-go v0.34.2 k8s.io/klog/v2 v2.130.1 + nhooyr.io/websocket v1.8.17 ) require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/ansi v0.10.2 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dlclark/regexp2 v1.11.5 // 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 @@ -41,21 +47,24 @@ require ( 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/gorilla/css v1.0.1 // 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/lucasb-eyer/go-colorful v1.3.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/mattn/go-runewidth v0.0.17 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // 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/reflow v0.3.0 // 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 @@ -71,6 +80,8 @@ require ( 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 + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // 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 diff --git a/go.sum b/go.sum index 669debe..122fc03 100644 --- a/go.sum +++ b/go.sum @@ -2,12 +2,20 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/NimbleMarkets/ntcharts v0.3.1 h1:EH4O80RMy5rqDmZM7aWjTbCSuRDDJ5fXOv/qAzdwOjk= github.com/NimbleMarkets/ntcharts v0.3.1/go.mod h1:zVeRqYkh2n59YPe1bflaSL4O2aD2ZemNmrbdEqZ70hk= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= @@ -16,14 +24,18 @@ github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGs github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= 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= @@ -31,6 +43,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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= @@ -70,10 +84,14 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY 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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jaeyo/go-drain3 v0.1.2 h1:fY21wgbwhzzaoRNSQ+6HVbpYw4KkAYjCFCoERYozIJ8= @@ -93,16 +111,19 @@ 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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.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/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= +github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 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= @@ -113,6 +134,8 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D 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/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 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= @@ -127,6 +150,7 @@ 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.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -166,6 +190,10 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM 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= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= @@ -265,6 +293,8 @@ k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOP 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= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= 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= diff --git a/gomod2nix.toml b/gomod2nix.toml index 88dcf54..0006913 100644 --- a/gomod2nix.toml +++ b/gomod2nix.toml @@ -1,261 +1,386 @@ schema = 3 [mod] - [mod."github.com/NimbleMarkets/ntcharts"] - version = "v0.3.1" - hash = "sha256-ZybI8VrsxgK890OYFJazgFnNR6bGi4suowVHd1+XJlM=" - [mod."github.com/atotto/clipboard"] - version = "v0.1.4" - hash = "sha256-ZZ7U5X0gWOu8zcjZcWbcpzGOGdycwq0TjTFh/eZHjXk=" - [mod."github.com/aymanbagabas/go-osc52/v2"] - version = "v2.0.1" - hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=" - [mod."github.com/charmbracelet/bubbles"] - version = "v0.21.0" - hash = "sha256-cfjUHgy9eq5SretTHuYuRaeeT6QmJYQBB9dsI8QSnW0=" - [mod."github.com/charmbracelet/bubbletea"] - version = "v1.3.6" - hash = "sha256-79MpQk+92RDXnNc2hSct8EDf0AhV6MZpvFMmmrVC/bs=" - [mod."github.com/charmbracelet/colorprofile"] - version = "v0.2.3-0.20250311203215-f60798e515dc" - hash = "sha256-D9E/bMOyLXAUVOHA1/6o3i+vVmLfwIMOWib6sU7A6+Q=" - [mod."github.com/charmbracelet/lipgloss"] - version = "v1.1.1-0.20250404203927-76690c660834" - hash = "sha256-CwS1AyoRsPOFA4SrC0CTtwLnbg57FmmSW/mvVQUFYcc=" - [mod."github.com/charmbracelet/x/ansi"] - version = "v0.9.3" - hash = "sha256-Bzum17p7UQZeNxL155Pho/+GXj1DElB9Bp3O194CYf8=" - [mod."github.com/charmbracelet/x/cellbuf"] - version = "v0.0.13" - hash = "sha256-ubgBd82jcS5L4i6rCFLOB1BXGrDEu6wWlciJA0gsH2g=" - [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=" - [mod."github.com/hashicorp/golang-lru/v2"] - version = "v2.0.7" - hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g=" - [mod."github.com/inconshreveable/mousetrap"] - version = "v1.1.0" - hash = "sha256-XWlYH0c8IcxAwQTnIi6WYqq44nOKUylSWxWO/vi+8pE=" - [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=" - [mod."github.com/mattn/go-localereader"] - version = "v0.0.1" - hash = "sha256-JlWckeGaWG+bXK8l8WEdZqmSiTwCA8b1qbmBKa/Fj3E=" - [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=" - [mod."github.com/muesli/cancelreader"] - version = "v0.2.2" - hash = "sha256-uEPpzwRJBJsQWBw6M71FDfgJuR7n55d/7IV8MO+rpwQ=" - [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/sagikazarmark/locafero"] - version = "v0.7.0" - hash = "sha256-ZmaGOKHDw18jJqdkwQwSpUT11F9toR6KPs3241TONeY=" - [mod."github.com/sourcegraph/conc"] - version = "v0.3.0" - hash = "sha256-mIdMs9MLAOBKf3/0quf1iI3v8uNWydy7ae5MFa+F2Ko=" - [mod."github.com/spf13/afero"] - version = "v1.12.0" - hash = "sha256-TX3DcyAdrXqf+TxmEz4TilWQo2Y4hcBXOeRY6BjDp+s=" - [mod."github.com/spf13/cast"] - version = "v1.7.1" - hash = "sha256-BjX0aY/PC37gIdMc7JhMgvhWFsksGdAcp2FgzpuvkPo=" - [mod."github.com/spf13/cobra"] - version = "v1.9.1" - hash = "sha256-dzEqquABE3UqZmJuj99244QjvfojS8cFlsPr/MXQGj0=" - [mod."github.com/spf13/pflag"] - version = "v1.0.6" - hash = "sha256-NjrK0FZPIfO/p2xtL1J7fOBQNTZAPZOC6Cb4aMMvhxI=" - [mod."github.com/spf13/viper"] - version = "v1.20.1" - hash = "sha256-gbCM0k7RAlvn7jpSuWB2LX5Nip9vgwsPNGbDXTI7JvM=" - [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=" - [mod."go.opentelemetry.io/otel"] - version = "v1.37.0" - hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo=" - [mod."go.opentelemetry.io/otel/sdk/metric"] - version = "v1.37.0" - hash = "sha256-dm6Aa5UDFgQCVexayiWu85A1ir1lpNn5rIN9tj9KVDs=" - [mod."go.opentelemetry.io/proto/otlp"] - version = "v1.7.0" - hash = "sha256-wqmUA6XGjODfuTCRQs32tYFkvO717ZGyZYZFcbNHvLw=" - [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=" - [mod."google.golang.org/genproto/googleapis/rpc"] - version = "v0.0.0-20250811230008-5f3141c8851a" - hash = "sha256-hbGMdlN/vwPIOJhYv6CAEnpQqTXbQ1GlXabiQUOv3sc=" - [mod."google.golang.org/grpc"] - version = "v1.74.2" - hash = "sha256-tvYMdfu/ZQZRPZNmnQI4CZpg46CM8+mD49hw0gFheGs=" - [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=" + [mod.'github.com/NimbleMarkets/ntcharts'] + version = 'v0.3.1' + hash = 'sha256-ZybI8VrsxgK890OYFJazgFnNR6bGi4suowVHd1+XJlM=' + + [mod.'github.com/alecthomas/chroma/v2'] + version = 'v2.20.0' + hash = 'sha256-rL1mJS8fCltcii9w/efyyE4iyzogyjP+N80lE3CuzVE=' + + [mod.'github.com/atotto/clipboard'] + version = 'v0.1.4' + hash = 'sha256-ZZ7U5X0gWOu8zcjZcWbcpzGOGdycwq0TjTFh/eZHjXk=' + + [mod.'github.com/aymanbagabas/go-osc52/v2'] + version = 'v2.0.1' + hash = 'sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=' + + [mod.'github.com/aymerick/douceur'] + version = 'v0.2.0' + hash = 'sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE=' + + [mod.'github.com/charmbracelet/bubbles'] + version = 'v0.21.0' + hash = 'sha256-cfjUHgy9eq5SretTHuYuRaeeT6QmJYQBB9dsI8QSnW0=' + + [mod.'github.com/charmbracelet/bubbletea'] + version = 'v1.3.6' + hash = 'sha256-79MpQk+92RDXnNc2hSct8EDf0AhV6MZpvFMmmrVC/bs=' + + [mod.'github.com/charmbracelet/colorprofile'] + version = 'v0.2.3-0.20250311203215-f60798e515dc' + hash = 'sha256-D9E/bMOyLXAUVOHA1/6o3i+vVmLfwIMOWib6sU7A6+Q=' + + [mod.'github.com/charmbracelet/glamour'] + version = 'v1.0.0' + hash = 'sha256-1sGgVA3QXJ2P5c/DNMb7R0HFG1X5vz9nmDhLrzKdgMw=' + + [mod.'github.com/charmbracelet/lipgloss'] + version = 'v1.1.1-0.20250404203927-76690c660834' + hash = 'sha256-CwS1AyoRsPOFA4SrC0CTtwLnbg57FmmSW/mvVQUFYcc=' + + [mod.'github.com/charmbracelet/x/ansi'] + version = 'v0.10.2' + hash = 'sha256-+JMWFbgv/5Wl2UERKxY4p5oayCRgJEDZpt2ACu3oG1o=' + + [mod.'github.com/charmbracelet/x/cellbuf'] + version = 'v0.0.13' + hash = 'sha256-ubgBd82jcS5L4i6rCFLOB1BXGrDEu6wWlciJA0gsH2g=' + + [mod.'github.com/charmbracelet/x/exp/slice'] + version = 'v0.0.0-20250327172914-2fdc97757edf' + hash = 'sha256-C1tksnevc/RdytJRQg5LQ0+VVSWlTwbNGic649m6E1Q=' + + [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/dlclark/regexp2'] + version = 'v1.11.5' + hash = 'sha256-jN5+2ED+YbIoPIuyJ4Ou5pqJb2w1uNKzp5yTjKY6rEQ=' + + [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/gorilla/css'] + version = 'v1.0.1' + hash = 'sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A=' + + [mod.'github.com/grpc-ecosystem/grpc-gateway/v2'] + version = 'v2.26.3' + hash = 'sha256-j/nyE8OgiwZ1YP0h3gmmxY1Fx4GDMCohi9g9kgNz4U8=' + + [mod.'github.com/hashicorp/golang-lru/v2'] + version = 'v2.0.7' + hash = 'sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g=' + + [mod.'github.com/inconshreveable/mousetrap'] + version = 'v1.1.0' + hash = 'sha256-XWlYH0c8IcxAwQTnIi6WYqq44nOKUylSWxWO/vi+8pE=' + + [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.3.0' + hash = 'sha256-6BKrJsfmxie+YFAWzTYVPQfrwjQEXRo+J8LY+50C1BU=' + + [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=' + + [mod.'github.com/mattn/go-localereader'] + version = 'v0.0.1' + hash = 'sha256-JlWckeGaWG+bXK8l8WEdZqmSiTwCA8b1qbmBKa/Fj3E=' + + [mod.'github.com/mattn/go-runewidth'] + version = 'v0.0.17' + hash = 'sha256-Xeb3F+WZhyhEWNKuWRm/umc2FolXYHtbi3QoPfDPtaA=' + + [mod.'github.com/microcosm-cc/bluemonday'] + version = 'v1.0.27' + hash = 'sha256-EZSya9FLPQ83CL7N2cZy21fdS35hViTkiMK5f3op8Es=' + + [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=' + + [mod.'github.com/muesli/cancelreader'] + version = 'v0.2.2' + hash = 'sha256-uEPpzwRJBJsQWBw6M71FDfgJuR7n55d/7IV8MO+rpwQ=' + + [mod.'github.com/muesli/reflow'] + version = 'v0.3.0' + hash = 'sha256-Pou2ybE9SFSZG6YfZLVV1Eyfm+X4FuVpDPLxhpn47Cc=' + + [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/sagikazarmark/locafero'] + version = 'v0.7.0' + hash = 'sha256-ZmaGOKHDw18jJqdkwQwSpUT11F9toR6KPs3241TONeY=' + + [mod.'github.com/sourcegraph/conc'] + version = 'v0.3.0' + hash = 'sha256-mIdMs9MLAOBKf3/0quf1iI3v8uNWydy7ae5MFa+F2Ko=' + + [mod.'github.com/spf13/afero'] + version = 'v1.12.0' + hash = 'sha256-TX3DcyAdrXqf+TxmEz4TilWQo2Y4hcBXOeRY6BjDp+s=' + + [mod.'github.com/spf13/cast'] + version = 'v1.7.1' + hash = 'sha256-BjX0aY/PC37gIdMc7JhMgvhWFsksGdAcp2FgzpuvkPo=' + + [mod.'github.com/spf13/cobra'] + version = 'v1.9.1' + hash = 'sha256-dzEqquABE3UqZmJuj99244QjvfojS8cFlsPr/MXQGj0=' + + [mod.'github.com/spf13/pflag'] + version = 'v1.0.6' + hash = 'sha256-NjrK0FZPIfO/p2xtL1J7fOBQNTZAPZOC6Cb4aMMvhxI=' + + [mod.'github.com/spf13/viper'] + version = 'v1.20.1' + hash = 'sha256-gbCM0k7RAlvn7jpSuWB2LX5Nip9vgwsPNGbDXTI7JvM=' + + [mod.'github.com/stretchr/testify'] + version = 'v1.11.1' + hash = 'sha256-sWfjkuKJyDllDEtnM8sb/pdLzPQmUYWYtmeWz/5suUc=' + + [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=' + + [mod.'github.com/yuin/goldmark'] + version = 'v1.7.13' + hash = 'sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=' + + [mod.'github.com/yuin/goldmark-emoji'] + version = 'v1.0.6' + hash = 'sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY=' + + [mod.'go.opentelemetry.io/proto/otlp'] + version = 'v1.7.0' + hash = 'sha256-wqmUA6XGjODfuTCRQs32tYFkvO717ZGyZYZFcbNHvLw=' + + [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.48.0' + hash = 'sha256-oZpddsiJwWCH3Aipa+XXpy7G/xHY5fEagUSok7T0bXE=' + + [mod.'golang.org/x/oauth2'] + version = 'v0.34.0' + hash = 'sha256-5eqpGGxJ7FJsPmfRek6roeGmkWHBMJaWYXyz8gXJsS4=' + + [mod.'golang.org/x/sync'] + version = 'v0.19.0' + hash = 'sha256-RbRZ+sKZUurOczGhhzOoY/sojTlta3H9XjL4PXX/cno=' + + [mod.'golang.org/x/sys'] + version = 'v0.39.0' + hash = 'sha256-dxTBu/JAWUkPbjFIXXRFdhQWyn+YyEpIC+tWqGo0Y6U=' + + [mod.'golang.org/x/term'] + version = 'v0.38.0' + hash = 'sha256-46mX91XGJqii6SUESiU5mLVJDBOfKYCp+408m91hrMw=' + + [mod.'golang.org/x/text'] + version = 'v0.32.0' + hash = 'sha256-9PXtWBKKY9rG4AgjSP4N+I1DhepXhy8SF/vWSIDIoWs=' + + [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-20251202230838-ff82c1b0f217' + hash = 'sha256-z0OqsPMjhZ8fNmUbcDfi5Q49hBcutKX8lyzW1mLBTec=' + + [mod.'google.golang.org/genproto/googleapis/rpc'] + version = 'v0.0.0-20251202230838-ff82c1b0f217' + hash = 'sha256-I3ZNpNjKKvTq4DVNw3wLKrCuORabZ0oYj0KKhOMI/MA=' + + [mod.'google.golang.org/grpc'] + version = 'v1.79.3' + hash = 'sha256-mO9ZI8ONBMEBTlrwIPeQMfb3C9ru5zPb26P+uG/pIzY=' + + [mod.'google.golang.org/protobuf'] + version = 'v1.36.10' + hash = 'sha256-gUrj1qSpjcpRKCBnrYlKMm+P0OSh7B/8EBREstwhD1w=' + + [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.'nhooyr.io/websocket'] + version = 'v1.8.17' + hash = 'sha256-YIYdIz3KR3j3ykT/TSOoftwkCoZdX1/bUtlXm3RjfoQ=' + + [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/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..505f53e --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,1380 @@ +package engine + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/control-theory/gonzo/internal/ai" + "github.com/control-theory/gonzo/internal/memory" + "github.com/control-theory/gonzo/internal/tui" +) + +// Engine is the shared, concurrency-safe analysis state that the TUI +// and web dashboard read from. The processing pipeline +// writes via Ingest(); all Query*/Get* methods acquire a read lock. +type Engine struct { + mu sync.RWMutex + + // Log buffer (ring buffer) + allLogEntries []tui.LogEntry + maxLogBuffer int + + // Severity tracking + lifetimeSeverityCounts map[string]int64 + countsHistory []tui.SeverityCounts + severityTimeSeries []SeverityTimePoint // timestamped 1-second severity history + + // Heatmap data (minute-by-minute severity counts) + heatmapData []tui.HeatmapMinute + + // Pattern clustering per severity + drain3BySeverity map[string]*tui.Drain3Manager + // Combined drain3 for all logs + drain3All *tui.Drain3Manager + + // Service tracking per severity + servicesBySeverity map[string][]tui.ServiceCount + + // Dimension tracking for QueryInsightsParams + lifetimeHostCounts map[string]int64 + lifetimeServiceCounts map[string]int64 + lifetimeAttrKeyCounts map[string]map[string]int64 + + // Frequency snapshot (latest) + snapshot *memory.FrequencySnapshot + + // Word/attribute tracking for classes + lifetimeWordCounts map[string]int64 + lifetimeAttrCounts map[string]int64 + stopWords map[string]bool + + // Stream tracking + streams map[string]*StreamInfo + + // Statistics + statsStartTime time.Time + totalLogsEver int + totalBytes int64 + + // AI client for summary queries + aiClient ai.Client + + // Config + useLogTime bool +} + +// NewEngine creates a new Engine with the given configuration. +func NewEngine(maxLogBuffer int, stopWords map[string]bool, aiClient ai.Client, useLogTime bool) *Engine { + return &Engine{ + allLogEntries: make([]tui.LogEntry, 0, maxLogBuffer), + maxLogBuffer: maxLogBuffer, + lifetimeSeverityCounts: make(map[string]int64), + countsHistory: make([]tui.SeverityCounts, 0), + severityTimeSeries: make([]SeverityTimePoint, 0, 120), + heatmapData: make([]tui.HeatmapMinute, 0), + drain3BySeverity: tui.InitializeDrain3BySeverity(), + drain3All: tui.NewDrain3Manager(), + servicesBySeverity: make(map[string][]tui.ServiceCount), + lifetimeHostCounts: make(map[string]int64), + lifetimeServiceCounts: make(map[string]int64), + lifetimeAttrKeyCounts: make(map[string]map[string]int64), + lifetimeWordCounts: make(map[string]int64), + lifetimeAttrCounts: make(map[string]int64), + stopWords: stopWords, + streams: make(map[string]*StreamInfo), + statsStartTime: time.Now(), + aiClient: aiClient, + useLogTime: useLogTime, + } +} + +// Ingest adds a new log entry to the engine. Called from the processing pipeline. +// This is the only write path — it acquires a write lock. +func (e *Engine) Ingest(entry tui.LogEntry) { + e.mu.Lock() + defer e.mu.Unlock() + + // Add to buffer + e.allLogEntries = append(e.allLogEntries, entry) + if len(e.allLogEntries) > e.maxLogBuffer { + e.allLogEntries = e.allLogEntries[1:] + } + + // Update statistics + e.totalLogsEver++ + e.totalBytes += int64(len(entry.RawLine)) + + // Update lifetime severity counts + e.lifetimeSeverityCounts[entry.Severity]++ + + // Update host counts + if host := entry.Attributes["host"]; host != "" { + e.lifetimeHostCounts[host]++ + } + + // Update service counts + serviceName := getServiceName(entry) + if serviceName != "" && serviceName != "unknown" { + e.lifetimeServiceCounts[serviceName]++ + } + + // Update per-attribute-key value counts + for key, value := range entry.Attributes { + attrKey := fmt.Sprintf("%s=%s", key, value) + if len(attrKey) < 200 { + e.lifetimeAttrCounts[attrKey]++ + } + if e.lifetimeAttrKeyCounts[key] == nil { + e.lifetimeAttrKeyCounts[key] = make(map[string]int64) + } + e.lifetimeAttrKeyCounts[key][value]++ + } + + // Update word counts + words := strings.Fields(strings.ToLower(entry.Message)) + for _, word := range words { + if len(word) >= 2 && len(word) <= 50 { + word = strings.Trim(word, ".,!?;:()[]{}\"'") + if len(word) >= 3 && !e.stopWords[word] { + e.lifetimeWordCounts[word]++ + } + } + } + + // Update heatmap + e.updateHeatmapData(entry) + + // Update services by severity + e.updateServicesBySeverity(entry) + + // Update drain3 patterns + if drain3Instance, exists := e.drain3BySeverity[entry.Severity]; exists && drain3Instance != nil { + drain3Instance.AddLogMessage(entry.Message) + } + if e.drain3All != nil { + e.drain3All.AddLogMessage(entry.Message) + } + + // Update stream tracking + e.updateStreamTracking(entry) +} + +// IngestSeverityCounts adds interval-level severity counts to history. +func (e *Engine) IngestSeverityCounts(counts tui.SeverityCounts) { + e.mu.Lock() + defer e.mu.Unlock() + + e.countsHistory = append(e.countsHistory, counts) + if len(e.countsHistory) > 50 { + e.countsHistory = e.countsHistory[1:] + } + + // Also store in timestamped time series for the web severity chart + point := SeverityTimePoint{ + Timestamp: time.Now().Unix(), + Counts: map[string]int{ + "FATAL": counts.Fatal, + "ERROR": counts.Error, + "WARN": counts.Warn, + "INFO": counts.Info, + "DEBUG": counts.Debug, + "TRACE": counts.Trace, + }, + Total: counts.Total, + } + e.severityTimeSeries = append(e.severityTimeSeries, point) + // Keep up to 120 seconds (2 minutes) + if len(e.severityTimeSeries) > 120 { + e.severityTimeSeries = e.severityTimeSeries[len(e.severityTimeSeries)-120:] + } +} + +// UpdateFrequencySnapshot updates the latest frequency snapshot. +func (e *Engine) UpdateFrequencySnapshot(snapshot *memory.FrequencySnapshot) { + e.mu.Lock() + defer e.mu.Unlock() + e.snapshot = snapshot +} + +// Reset clears all engine state. +func (e *Engine) Reset() { + e.mu.Lock() + defer e.mu.Unlock() + + e.allLogEntries = e.allLogEntries[:0] + e.lifetimeSeverityCounts = make(map[string]int64) + e.countsHistory = e.countsHistory[:0] + e.severityTimeSeries = e.severityTimeSeries[:0] + e.heatmapData = e.heatmapData[:0] + e.drain3BySeverity = tui.InitializeDrain3BySeverity() + e.drain3All = tui.NewDrain3Manager() + e.servicesBySeverity = make(map[string][]tui.ServiceCount) + e.lifetimeHostCounts = make(map[string]int64) + e.lifetimeServiceCounts = make(map[string]int64) + e.lifetimeAttrKeyCounts = make(map[string]map[string]int64) + e.lifetimeWordCounts = make(map[string]int64) + e.lifetimeAttrCounts = make(map[string]int64) + e.streams = make(map[string]*StreamInfo) + e.snapshot = nil + e.totalLogsEver = 0 + e.totalBytes = 0 + e.statsStartTime = time.Now() +} + +// --- Read-path: State Accessors (for TUI backward compat) --- + +// GetAllLogEntries returns a copy of all log entries. +func (e *Engine) GetAllLogEntries() []tui.LogEntry { + e.mu.RLock() + defer e.mu.RUnlock() + result := make([]tui.LogEntry, len(e.allLogEntries)) + copy(result, e.allLogEntries) + return result +} + +// GetLogEntryCount returns the number of log entries in the buffer. +func (e *Engine) GetLogEntryCount() int { + e.mu.RLock() + defer e.mu.RUnlock() + return len(e.allLogEntries) +} + +// GetHeatmapData returns a copy of heatmap data. +func (e *Engine) GetHeatmapData() []tui.HeatmapMinute { + e.mu.RLock() + defer e.mu.RUnlock() + result := make([]tui.HeatmapMinute, len(e.heatmapData)) + copy(result, e.heatmapData) + return result +} + +// GetSeverityTimeSeries returns timestamped per-second severity counts. +// When no filters are provided, returns the pre-aggregated global series. +// When filters are present (e.g. search), builds the series from filtered log entries. +func (e *Engine) GetSeverityTimeSeries() []SeverityTimePoint { + e.mu.RLock() + defer e.mu.RUnlock() + result := make([]SeverityTimePoint, len(e.severityTimeSeries)) + copy(result, e.severityTimeSeries) + return result +} + +// QuerySeverityTimeSeries returns per-second severity counts filtered by the given filters. +// This recomputes the time series from raw log entries to support search/stream filtering. +func (e *Engine) QuerySeverityTimeSeries(_ context.Context, filters InsightsFilters) []SeverityTimePoint { + e.mu.RLock() + defer e.mu.RUnlock() + + // If no meaningful filters, return the pre-aggregated series + if filters.Search == nil && filters.Severity == nil && filters.Start == nil && filters.End == nil { + result := make([]SeverityTimePoint, len(e.severityTimeSeries)) + copy(result, e.severityTimeSeries) + return result + } + + filtered := e.filterEntries(filters) + if len(filtered) == 0 { + return nil + } + + // Bucket entries by second + buckets := make(map[int64]map[string]int) + for _, entry := range filtered { + ts := entry.Timestamp.Unix() + if e.useLogTime && !entry.OrigTimestamp.IsZero() { + ts = entry.OrigTimestamp.Unix() + } + if buckets[ts] == nil { + buckets[ts] = make(map[string]int) + } + sev := strings.ToUpper(entry.Severity) + if sev == "" { + sev = "INFO" + } + buckets[ts][sev]++ + } + + // Sort timestamps + timestamps := make([]int64, 0, len(buckets)) + for ts := range buckets { + timestamps = append(timestamps, ts) + } + sort.Slice(timestamps, func(i, j int) bool { return timestamps[i] < timestamps[j] }) + + // Build result + result := make([]SeverityTimePoint, 0, len(timestamps)) + for _, ts := range timestamps { + counts := buckets[ts] + total := 0 + for _, c := range counts { + total += c + } + result = append(result, SeverityTimePoint{ + Timestamp: ts, + Counts: counts, + Total: total, + }) + } + return result +} + +// GetDrain3BySeverity returns the drain3 map (not a copy — callers should not modify). +func (e *Engine) GetDrain3BySeverity() map[string]*tui.Drain3Manager { + e.mu.RLock() + defer e.mu.RUnlock() + return e.drain3BySeverity +} + +// GetServicesBySeverity returns the services map. +func (e *Engine) GetServicesBySeverity() map[string][]tui.ServiceCount { + e.mu.RLock() + defer e.mu.RUnlock() + // Return a shallow copy + result := make(map[string][]tui.ServiceCount, len(e.servicesBySeverity)) + for k, v := range e.servicesBySeverity { + copied := make([]tui.ServiceCount, len(v)) + copy(copied, v) + result[k] = copied + } + return result +} + +// GetCountsHistory returns a copy of the counts history. +func (e *Engine) GetCountsHistory() []tui.SeverityCounts { + e.mu.RLock() + defer e.mu.RUnlock() + result := make([]tui.SeverityCounts, len(e.countsHistory)) + copy(result, e.countsHistory) + return result +} + +// GetFrequencySnapshot returns the latest frequency snapshot. +func (e *Engine) GetFrequencySnapshot() *memory.FrequencySnapshot { + e.mu.RLock() + defer e.mu.RUnlock() + return e.snapshot +} + +// GetLifetimeSeverityCounts returns a copy of lifetime severity counts. +func (e *Engine) GetLifetimeSeverityCounts() map[string]int64 { + e.mu.RLock() + defer e.mu.RUnlock() + result := make(map[string]int64, len(e.lifetimeSeverityCounts)) + for k, v := range e.lifetimeSeverityCounts { + result[k] = v + } + return result +} + +// GetLifetimeHostCounts returns a copy of lifetime host counts. +func (e *Engine) GetLifetimeHostCounts() map[string]int64 { + e.mu.RLock() + defer e.mu.RUnlock() + result := make(map[string]int64, len(e.lifetimeHostCounts)) + for k, v := range e.lifetimeHostCounts { + result[k] = v + } + return result +} + +// GetLifetimeServiceCounts returns a copy of lifetime service counts. +func (e *Engine) GetLifetimeServiceCounts() map[string]int64 { + e.mu.RLock() + defer e.mu.RUnlock() + result := make(map[string]int64, len(e.lifetimeServiceCounts)) + for k, v := range e.lifetimeServiceCounts { + result[k] = v + } + return result +} + +// GetLifetimeAttrCounts returns a copy of lifetime attribute counts. +func (e *Engine) GetLifetimeAttrCounts() map[string]int64 { + e.mu.RLock() + defer e.mu.RUnlock() + result := make(map[string]int64, len(e.lifetimeAttrCounts)) + for k, v := range e.lifetimeAttrCounts { + result[k] = v + } + return result +} + +// GetLifetimeWordCounts returns a copy of lifetime word counts. +func (e *Engine) GetLifetimeWordCounts() map[string]int64 { + e.mu.RLock() + defer e.mu.RUnlock() + result := make(map[string]int64, len(e.lifetimeWordCounts)) + for k, v := range e.lifetimeWordCounts { + result[k] = v + } + return result +} + +// GetLifetimeAttrKeyCounts returns a copy of per-key attribute value counts. +func (e *Engine) GetLifetimeAttrKeyCounts() map[string]map[string]int64 { + e.mu.RLock() + defer e.mu.RUnlock() + result := make(map[string]map[string]int64, len(e.lifetimeAttrKeyCounts)) + for k, v := range e.lifetimeAttrKeyCounts { + inner := make(map[string]int64, len(v)) + for ik, iv := range v { + inner[ik] = iv + } + result[k] = inner + } + return result +} + +// GetStats returns engine statistics. +func (e *Engine) GetStats() EngineStats { + e.mu.RLock() + defer e.mu.RUnlock() + return EngineStats{ + StartTime: e.statsStartTime, + TotalLogsEver: e.totalLogsEver, + TotalBytes: e.totalBytes, + BufferSize: e.maxLogBuffer, + BufferUsed: len(e.allLogEntries), + } +} + +// GetStreams returns all tracked streams. +func (e *Engine) GetStreams() []StreamInfo { + e.mu.RLock() + defer e.mu.RUnlock() + result := make([]StreamInfo, 0, len(e.streams)) + for _, s := range e.streams { + result = append(result, *s) + } + return result +} + +// --- Read-path: Query Methods (for Web API) --- + +// QueryLogSamples returns log entries matching the given filters. +func (e *Engine) QueryLogSamples(_ context.Context, filters InsightsFilters) ([]LogSample, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + filtered := e.filterEntries(filters) + + // Apply limit + limit := len(filtered) + if filters.Limit != nil && *filters.Limit > 0 && *filters.Limit < limit { + limit = *filters.Limit + } + + // Take the most recent entries (from the end) + start := len(filtered) - limit + if start < 0 { + start = 0 + } + + samples := make([]LogSample, 0, limit) + for _, entry := range filtered[start:] { + ts := entry.Timestamp.Unix() + if e.useLogTime && !entry.OrigTimestamp.IsZero() { + ts = entry.OrigTimestamp.Unix() + } + samples = append(samples, LogSample{ + Timestamp: ts, + Severity: entry.Severity, + Message: entry.Message, + Attributes: entry.Attributes, + RawLine: entry.RawLine, + }) + } + + return samples, nil +} + +// QuerySeverityData returns severity distribution grouped by the specified dimension. +func (e *Engine) QuerySeverityData(_ context.Context, filters InsightsFilters) ([]SeverityGroup, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + filtered := e.filterEntries(filters) + groupBy := filters.GroupBy + if groupBy == "" { + groupBy = "service" + } + + groups := make(map[string]map[string]int) + for _, entry := range filtered { + groupVal := e.getGroupValue(entry, groupBy) + if groups[groupVal] == nil { + groups[groupVal] = make(map[string]int) + } + groups[groupVal][entry.Severity]++ + } + + result := make([]SeverityGroup, 0, len(groups)) + for groupVal, counts := range groups { + total := 0 + for _, c := range counts { + total += c + } + result = append(result, SeverityGroup{ + GroupValue: groupVal, + Counts: counts, + Total: total, + }) + } + + // Sort by total descending + sort.Slice(result, func(i, j int) bool { + return result[i].Total > result[j].Total + }) + + return result, nil +} + +// QuerySentimentData returns sentiment distribution derived from severity. +func (e *Engine) QuerySentimentData(_ context.Context, filters InsightsFilters) (*SentimentData, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + filtered := e.filterEntries(filters) + groupBy := filters.GroupBy + if groupBy == "" { + groupBy = "service" + } + + // Group entries by time bucket (1-second) and group_by dimension + type bucketKey struct { + second int64 + groupVal string + } + buckets := make(map[bucketKey][]float64) + groupValueSet := make(map[string]bool) + + for _, entry := range filtered { + groupVal := e.getGroupValue(entry, groupBy) + groupValueSet[groupVal] = true + + ts := entry.Timestamp + if e.useLogTime && !entry.OrigTimestamp.IsZero() { + ts = entry.OrigTimestamp + } + sec := ts.Unix() + + key := bucketKey{second: sec, groupVal: groupVal} + sentiment := severityToSentiment(entry.Severity) + buckets[key] = append(buckets[key], sentiment) + } + + // Build response + groupValues := make([]string, 0, len(groupValueSet)) + for gv := range groupValueSet { + groupValues = append(groupValues, gv) + } + sort.Strings(groupValues) + + sentimentBuckets := make([]SentimentBucket, 0, len(buckets)) + for key, sentiments := range buckets { + avg := 0.0 + for _, s := range sentiments { + avg += s + } + avg /= float64(len(sentiments)) + + sentimentBuckets = append(sentimentBuckets, SentimentBucket{ + Timestamp: key.second, + GroupValue: key.groupVal, + Sentiment: avg, + LogCount: len(sentiments), + }) + } + + return &SentimentData{ + GroupValues: groupValues, + Buckets: sentimentBuckets, + }, nil +} + +// QueryPatterns returns drain3 patterns grouped by the specified dimension. +func (e *Engine) QueryPatterns(_ context.Context, filters InsightsFilters) ([]PatternGroup, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + groupBy := filters.GroupBy + if groupBy == "" { + groupBy = "service" + } + + limit := 20 + if filters.Limit != nil && *filters.Limit > 0 { + limit = *filters.Limit + } + + // For severity grouping, use the pre-built drain3 instances + if groupBy == "severity" { + result := make([]PatternGroup, 0) + for severity, dm := range e.drain3BySeverity { + if dm == nil { + continue + } + patterns := dm.GetTopPatterns(limit) + if len(patterns) == 0 { + continue + } + items := make([]PatternItem, len(patterns)) + for i, p := range patterns { + items[i] = PatternItem{ + Pattern: p.Template, + Count: p.Count, + Percentage: p.Percentage, + } + } + result = append(result, PatternGroup{ + GroupValue: severity, + Patterns: items, + }) + } + return result, nil + } + + // For other groupings, use the combined drain3 and return as single group + patterns := e.drain3All.GetTopPatterns(limit) + items := make([]PatternItem, len(patterns)) + for i, p := range patterns { + items[i] = PatternItem{ + Pattern: p.Template, + Count: p.Count, + Percentage: p.Percentage, + } + } + + return []PatternGroup{{ + GroupValue: "all", + Patterns: items, + }}, nil +} + +// QueryClasses returns log classification distribution. +func (e *Engine) QueryClasses(_ context.Context, filters InsightsFilters) ([]ClassItem, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + filtered := e.filterEntries(filters) + + // Classify logs by severity as a simple categorization + classCounts := make(map[string]int) + for _, entry := range filtered { + classCounts[entry.Severity]++ + } + + total := len(filtered) + result := make([]ClassItem, 0, len(classCounts)) + for name, count := range classCounts { + pct := 0.0 + if total > 0 { + pct = float64(count) * 100.0 / float64(total) + } + result = append(result, ClassItem{ + Name: name, + Count: count, + Percentage: pct, + }) + } + + sort.Slice(result, func(i, j int) bool { + return result[i].Count > result[j].Count + }) + + if filters.Limit != nil && *filters.Limit > 0 && len(result) > *filters.Limit { + result = result[:*filters.Limit] + } + + return result, nil +} + +// QuerySummary returns an AI-generated summary of the logs. +func (e *Engine) QuerySummary(_ context.Context, filters InsightsFilters) (string, error) { + e.mu.RLock() + entries := e.filterEntries(filters) + severityCounts := make(map[string]int) + for _, entry := range entries { + severityCounts[entry.Severity]++ + } + totalLogs := len(entries) + aiClient := e.aiClient + e.mu.RUnlock() + + if aiClient == nil { + // Return a statistical summary when AI is not configured + return e.generateStatisticalSummary(totalLogs, severityCounts, entries), nil + } + + // Build a prompt for the AI + var sb strings.Builder + sb.WriteString("Analyze these log entries and provide a brief summary of key issues, patterns, and recommendations:\n\n") + sb.WriteString(fmt.Sprintf("Total logs: %d\n", totalLogs)) + sb.WriteString("Severity distribution:\n") + for sev, count := range severityCounts { + sb.WriteString(fmt.Sprintf(" %s: %d\n", sev, count)) + } + sb.WriteString("\nRecent log samples:\n") + + // Include up to 50 recent entries + sampleCount := min(50, len(entries)) + start := len(entries) - sampleCount + for _, entry := range entries[start:] { + sb.WriteString(fmt.Sprintf("[%s] %s\n", entry.Severity, entry.Message)) + } + + result, err := aiClient.AnalyzeLog(sb.String(), "SUMMARY", time.Now().Format(time.RFC3339), nil) + if err != nil { + return "", fmt.Errorf("AI analysis failed: %w", err) + } + + return result, nil +} + +// GetSentimentHeatmap returns an ASCII heatmap of sentiment over time. +func (e *Engine) GetSentimentHeatmap(_ context.Context, filters InsightsFilters) (string, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + filtered := e.filterEntries(filters) + groupBy := filters.GroupBy + if groupBy == "" { + groupBy = "service" + } + + // Build minute-by-group sentiment map + type cellKey struct { + minute time.Time + groupVal string + } + cells := make(map[cellKey][]float64) + groupValueSet := make(map[string]bool) + var minTime, maxTime time.Time + + for _, entry := range filtered { + groupVal := e.getGroupValue(entry, groupBy) + groupValueSet[groupVal] = true + + ts := entry.Timestamp + if e.useLogTime && !entry.OrigTimestamp.IsZero() { + ts = entry.OrigTimestamp + } + minute := ts.Truncate(time.Minute) + + if minTime.IsZero() || minute.Before(minTime) { + minTime = minute + } + if maxTime.IsZero() || minute.After(maxTime) { + maxTime = minute + } + + key := cellKey{minute: minute, groupVal: groupVal} + cells[key] = append(cells[key], severityToSentiment(entry.Severity)) + } + + if len(cells) == 0 { + return "No data available for heatmap.", nil + } + + // Build ASCII heatmap + groupValues := make([]string, 0, len(groupValueSet)) + for gv := range groupValueSet { + groupValues = append(groupValues, gv) + } + sort.Strings(groupValues) + + // Generate time columns (every 5 minutes) + var sb strings.Builder + sb.WriteString("Sentiment Heatmap (+ positive, . neutral, - negative)\n\n") + + // Determine label width + maxLabelLen := 0 + for _, gv := range groupValues { + if len(gv) > maxLabelLen { + maxLabelLen = len(gv) + } + } + if maxLabelLen > 20 { + maxLabelLen = 20 + } + + for _, gv := range groupValues { + label := gv + if len(label) > maxLabelLen { + label = label[:maxLabelLen] + } + sb.WriteString(fmt.Sprintf("%-*s │", maxLabelLen, label)) + + // Walk through time buckets + t := minTime + for !t.After(maxTime) { + key := cellKey{minute: t, groupVal: gv} + if sentiments, ok := cells[key]; ok { + avg := 0.0 + for _, s := range sentiments { + avg += s + } + avg /= float64(len(sentiments)) + sb.WriteByte(sentimentToChar(avg)) + } else { + sb.WriteByte(' ') + } + t = t.Add(time.Minute) + } + sb.WriteByte('\n') + } + + return sb.String(), nil +} + +// GetAnomalies detects anomalies in the log data. +func (e *Engine) GetAnomalies(_ context.Context, filters InsightsFilters) (string, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + filtered := e.filterEntries(filters) + if len(filtered) == 0 { + return "No log data available for anomaly detection.", nil + } + + var sb strings.Builder + sb.WriteString("# Anomaly Detection Report\n\n") + + // Check for fatal/critical logs + fatalCount := 0 + errorCount := 0 + for _, entry := range filtered { + switch entry.Severity { + case "FATAL", "CRITICAL": + fatalCount++ + case "ERROR": + errorCount++ + } + } + + if fatalCount > 0 { + sb.WriteString(fmt.Sprintf("## Fatal/Critical Logs Detected\n- **%d** fatal/critical log entries found\n\n", fatalCount)) + } + + // Check error rate + totalCount := len(filtered) + if totalCount > 0 { + errorRate := float64(errorCount) * 100.0 / float64(totalCount) + if errorRate > 10 { + sb.WriteString(fmt.Sprintf("## High Error Rate\n- Error rate: **%.1f%%** (%d/%d logs)\n\n", errorRate, errorCount, totalCount)) + } + } + + // Check for error spikes (compare last 5 min vs overall rate) + now := time.Now() + recentCutoff := now.Add(-5 * time.Minute) + recentErrors := 0 + recentTotal := 0 + for _, entry := range filtered { + ts := entry.Timestamp + if e.useLogTime && !entry.OrigTimestamp.IsZero() { + ts = entry.OrigTimestamp + } + if ts.After(recentCutoff) { + recentTotal++ + if entry.Severity == "ERROR" || entry.Severity == "FATAL" || entry.Severity == "CRITICAL" { + recentErrors++ + } + } + } + + if recentTotal > 0 { + recentErrorRate := float64(recentErrors) * 100.0 / float64(recentTotal) + overallErrorRate := float64(errorCount+fatalCount) * 100.0 / float64(totalCount) + if recentErrorRate > overallErrorRate*2 && recentErrors > 5 { + sb.WriteString(fmt.Sprintf("## Error Spike Detected\n- Last 5 minutes: **%.1f%%** error rate (%d errors in %d logs)\n- Overall: **%.1f%%** error rate\n\n", + recentErrorRate, recentErrors, recentTotal, overallErrorRate)) + } + } + + // Check sentiment variance by service + type serviceStats struct { + sentiments []float64 + } + serviceMap := make(map[string]*serviceStats) + for _, entry := range filtered { + svc := getServiceName(entry) + if svc == "" || svc == "unknown" { + continue + } + if serviceMap[svc] == nil { + serviceMap[svc] = &serviceStats{} + } + serviceMap[svc].sentiments = append(serviceMap[svc].sentiments, severityToSentiment(entry.Severity)) + } + + for svc, stats := range serviceMap { + if len(stats.sentiments) < 10 { + continue + } + mean := 0.0 + for _, s := range stats.sentiments { + mean += s + } + mean /= float64(len(stats.sentiments)) + + variance := 0.0 + for _, s := range stats.sentiments { + diff := s - mean + variance += diff * diff + } + variance /= float64(len(stats.sentiments)) + + if mean < -0.3 { + sb.WriteString(fmt.Sprintf("## Negative Sentiment: %s\n- Mean sentiment: **%.2f** (%.0f logs)\n- Variance: %.2f\n\n", + svc, mean, float64(len(stats.sentiments)), variance)) + } + } + + if sb.Len() == len("# Anomaly Detection Report\n\n") { + sb.WriteString("No anomalies detected in the current data.\n") + } + + return sb.String(), nil +} + +// QueryInsightsParams returns available filter dimension values. +func (e *Engine) QueryInsightsParams(_ context.Context, _ InsightsFilters) (*InsightsParams, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + params := &InsightsParams{ + TotalLogs: int64(e.totalLogsEver), + } + + // Collect unique values from lifetime counts + for host := range e.lifetimeHostCounts { + params.Hosts = append(params.Hosts, host) + } + for svc := range e.lifetimeServiceCounts { + params.Services = append(params.Services, svc) + } + for sev := range e.lifetimeSeverityCounts { + params.Severities = append(params.Severities, sev) + } + + // Collect namespace, pod, deployment, environment, cluster from attributes + dimKeys := map[string]*[]string{ + "k8s.namespace": ¶ms.Namespaces, + "k8s.pod": ¶ms.Pods, + "k8s.deployment": ¶ms.Deployments, + "environment": ¶ms.Environments, + "env": ¶ms.Environments, + "cluster": ¶ms.Clusters, + } + for key, target := range dimKeys { + if vals, ok := e.lifetimeAttrKeyCounts[key]; ok { + for val := range vals { + *target = append(*target, val) + } + } + } + + // Time range + if len(e.allLogEntries) > 0 { + first := e.allLogEntries[0].Timestamp.Unix() + last := e.allLogEntries[len(e.allLogEntries)-1].Timestamp.Unix() + params.OldestLog = &first + params.NewestLog = &last + } + + // Sort all slices + sort.Strings(params.Hosts) + sort.Strings(params.Services) + sort.Strings(params.Severities) + sort.Strings(params.Namespaces) + sort.Strings(params.Pods) + sort.Strings(params.Deployments) + sort.Strings(params.Environments) + sort.Strings(params.Clusters) + + return params, nil +} + +// --- Internal helpers --- + +// filterEntries applies InsightsFilters to the in-memory log buffer. +// Must be called with at least a read lock held. +func (e *Engine) filterEntries(filters InsightsFilters) []tui.LogEntry { + result := make([]tui.LogEntry, 0, len(e.allLogEntries)) + + for _, entry := range e.allLogEntries { + ts := entry.Timestamp + if e.useLogTime && !entry.OrigTimestamp.IsZero() { + ts = entry.OrigTimestamp + } + + // Time range filter + if filters.Start != nil && ts.Unix() < *filters.Start { + continue + } + if filters.End != nil && ts.Unix() > *filters.End { + continue + } + + // Severity filter + if filters.Severity != nil && *filters.Severity != "" { + if !strings.EqualFold(entry.Severity, *filters.Severity) { + continue + } + } + + // Search filter + if filters.Search != nil && *filters.Search != "" { + search := strings.ToLower(*filters.Search) + if !strings.Contains(strings.ToLower(entry.Message), search) && + !strings.Contains(strings.ToLower(entry.RawLine), search) { + found := false + for _, v := range entry.Attributes { + if strings.Contains(strings.ToLower(v), search) { + found = true + break + } + } + if !found { + continue + } + } + } + + // Dimension filters + if !e.matchesDimensionFilter(entry, filters) { + continue + } + + result = append(result, entry) + } + + return result +} + +// matchesDimensionFilter checks if an entry matches the dimension filters. +func (e *Engine) matchesDimensionFilter(entry tui.LogEntry, filters InsightsFilters) bool { + if filters.Namespaces != nil && len(*filters.Namespaces) > 0 { + ns := entry.Attributes["k8s.namespace"] + if !containsString(*filters.Namespaces, ns) { + return false + } + } + if filters.Pods != nil && len(*filters.Pods) > 0 { + pod := entry.Attributes["k8s.pod"] + if !containsString(*filters.Pods, pod) { + return false + } + } + if filters.Hosts != nil && len(*filters.Hosts) > 0 { + host := entry.Attributes["host"] + if !containsString(*filters.Hosts, host) { + return false + } + } + if filters.Services != nil && len(*filters.Services) > 0 { + svc := getServiceName(entry) + if !containsString(*filters.Services, svc) { + return false + } + } + if filters.Environments != nil && len(*filters.Environments) > 0 { + env := entry.Attributes["environment"] + if env == "" { + env = entry.Attributes["env"] + } + if !containsString(*filters.Environments, env) { + return false + } + } + if filters.Clusters != nil && len(*filters.Clusters) > 0 { + cluster := entry.Attributes["cluster"] + if !containsString(*filters.Clusters, cluster) { + return false + } + } + if filters.Deployments != nil && len(*filters.Deployments) > 0 { + deploy := entry.Attributes["k8s.deployment"] + if !containsString(*filters.Deployments, deploy) { + return false + } + } + return true +} + +// getGroupValue extracts the group_by dimension value from a log entry. +func (e *Engine) getGroupValue(entry tui.LogEntry, groupBy string) string { + switch groupBy { + case "service": + return getServiceName(entry) + case "host": + if h := entry.Attributes["host"]; h != "" { + return h + } + return "unknown" + case "severity": + return entry.Severity + case "namespace": + if ns := entry.Attributes["k8s.namespace"]; ns != "" { + return ns + } + return "default" + case "pod": + if pod := entry.Attributes["k8s.pod"]; pod != "" { + return pod + } + return "unknown" + case "deployment": + if dep := entry.Attributes["k8s.deployment"]; dep != "" { + return dep + } + return "unknown" + case "env", "environment": + if env := entry.Attributes["environment"]; env != "" { + return env + } + if env := entry.Attributes["env"]; env != "" { + return env + } + return "default" + case "cluster": + if cl := entry.Attributes["cluster"]; cl != "" { + return cl + } + return "default" + case "category": + return entry.Severity // Use severity as category for local + default: + return getServiceName(entry) + } +} + +// updateHeatmapData updates minute-by-minute heatmap data. +func (e *Engine) updateHeatmapData(entry tui.LogEntry) { + ts := entry.Timestamp + if e.useLogTime && !entry.OrigTimestamp.IsZero() { + ts = entry.OrigTimestamp + } + entryTime := ts.Truncate(time.Minute) + + // Find or create the minute entry + var targetMinute *tui.HeatmapMinute + for i := range e.heatmapData { + if e.heatmapData[i].Timestamp.Equal(entryTime) { + targetMinute = &e.heatmapData[i] + break + } + } + + if targetMinute == nil { + e.heatmapData = append(e.heatmapData, tui.HeatmapMinute{ + Timestamp: entryTime, + Counts: tui.SeverityCounts{}, + }) + targetMinute = &e.heatmapData[len(e.heatmapData)-1] + } + + // Update severity count + switch entry.Severity { + case "TRACE": + targetMinute.Counts.Trace++ + case "DEBUG": + targetMinute.Counts.Debug++ + case "INFO": + targetMinute.Counts.Info++ + case "WARN", "WARNING": + targetMinute.Counts.Warn++ + case "ERROR": + targetMinute.Counts.Error++ + case "FATAL": + targetMinute.Counts.Fatal++ + case "CRITICAL": + targetMinute.Counts.Critical++ + default: + targetMinute.Counts.Unknown++ + } + targetMinute.Counts.Total++ + + // Prune old data (keep 6 hours) + cutoffTime := time.Now().Add(-6 * time.Hour) + filtered := e.heatmapData[:0] + for _, minute := range e.heatmapData { + if minute.Timestamp.After(cutoffTime) { + filtered = append(filtered, minute) + } + } + e.heatmapData = filtered +} + +// updateServicesBySeverity updates the services-by-severity tracking. +func (e *Engine) updateServicesBySeverity(entry tui.LogEntry) { + severity := entry.Severity + if severity == "" { + severity = "UNKNOWN" + } + + serviceName := getServiceName(entry) + if serviceName == "" || serviceName == "unknown" { + return + } + + if e.servicesBySeverity[severity] == nil { + e.servicesBySeverity[severity] = make([]tui.ServiceCount, 0) + } + + found := false + for i := range e.servicesBySeverity[severity] { + if e.servicesBySeverity[severity][i].Service == serviceName { + e.servicesBySeverity[severity][i].Count++ + found = true + break + } + } + + if !found { + e.servicesBySeverity[severity] = append(e.servicesBySeverity[severity], tui.ServiceCount{ + Service: serviceName, + Count: 1, + }) + } + + // Keep top 10 sorted + services := e.servicesBySeverity[severity] + sort.Slice(services, func(i, j int) bool { + return services[i].Count > services[j].Count + }) + if len(services) > 10 { + e.servicesBySeverity[severity] = services[:10] + } +} + +// updateStreamTracking updates stream info for a log entry. +func (e *Engine) updateStreamTracking(entry tui.LogEntry) { + // Determine source and stream from attributes + source := "stdin" + stream := "stdin" + + if ns := entry.Attributes["k8s.namespace"]; ns != "" { + source = "k8s" + pod := entry.Attributes["k8s.pod"] + if pod != "" { + stream = ns + "/" + pod + } else { + stream = ns + } + } else if svc := entry.Attributes["service.name"]; svc != "" { + source = "otlp" + stream = svc + } else if filename := entry.Attributes["source_file"]; filename != "" { + source = "file" + stream = filename + } + + key := source + ":" + stream + if s, ok := e.streams[key]; ok { + s.LogCount++ + s.LastSeen = time.Now() + } else { + e.streams[key] = &StreamInfo{ + Source: source, + Stream: stream, + LogCount: 1, + LastSeen: time.Now(), + Active: true, + } + } +} + +// generateStatisticalSummary creates a text summary when AI is not configured. +func (e *Engine) generateStatisticalSummary(totalLogs int, severityCounts map[string]int, entries []tui.LogEntry) string { + var sb strings.Builder + sb.WriteString("## Log Analysis Summary (Statistical)\n\n") + sb.WriteString(fmt.Sprintf("**Total logs analyzed:** %d\n\n", totalLogs)) + + sb.WriteString("### Severity Distribution\n") + for sev, count := range severityCounts { + pct := float64(count) * 100.0 / float64(max(totalLogs, 1)) + sb.WriteString(fmt.Sprintf("- %s: %d (%.1f%%)\n", sev, count, pct)) + } + + sb.WriteString("\n### Top Patterns\n") + patterns := e.drain3All.GetTopPatterns(5) + for i, p := range patterns { + sb.WriteString(fmt.Sprintf("%d. `%s` (%d occurrences, %.1f%%)\n", i+1, p.Template, p.Count, p.Percentage)) + } + + if len(entries) > 0 { + uptime := time.Since(e.statsStartTime).Round(time.Second) + sb.WriteString(fmt.Sprintf("\n**Uptime:** %s | **Buffer:** %d/%d\n", uptime, len(e.allLogEntries), e.maxLogBuffer)) + } + + sb.WriteString("\n*Note: AI analysis is not configured. Set up an AI provider for deeper insights.*\n") + return sb.String() +} + +// --- Utility functions --- + +func getServiceName(entry tui.LogEntry) string { + if svc := entry.Attributes["service"]; svc != "" { + return svc + } + if svc := entry.Attributes["service.name"]; svc != "" { + return svc + } + if svc := entry.Attributes["serviceName"]; svc != "" { + return svc + } + if svc := entry.Attributes["app"]; svc != "" { + return svc + } + if svc := entry.Attributes["application"]; svc != "" { + return svc + } + if host := entry.Attributes["host"]; host != "" { + return "host:" + host + } + return "unknown" +} + +func severityToSentiment(severity string) float64 { + switch strings.ToUpper(severity) { + case "FATAL", "CRITICAL": + return -1.0 + case "ERROR": + return -0.6 + case "WARN", "WARNING": + return 0.0 + case "INFO": + return 0.5 + case "DEBUG": + return 0.7 + case "TRACE": + return 0.9 + default: + return 0.0 + } +} + +func sentimentToChar(sentiment float64) byte { + if sentiment > 0.3 { + return '+' + } else if sentiment < -0.3 { + return '-' + } + return '.' +} + +func containsString(slice []string, s string) bool { + for _, item := range slice { + if strings.EqualFold(item, s) { + return true + } + } + return false +} + diff --git a/internal/engine/streams.go b/internal/engine/streams.go new file mode 100644 index 0000000..74d5384 --- /dev/null +++ b/internal/engine/streams.go @@ -0,0 +1,31 @@ +package engine + +import "time" + +// RegisterStream explicitly registers a stream (called at startup for known inputs). +func (e *Engine) RegisterStream(source, stream string) { + e.mu.Lock() + defer e.mu.Unlock() + + key := source + ":" + stream + if _, ok := e.streams[key]; !ok { + e.streams[key] = &StreamInfo{ + Source: source, + Stream: stream, + LogCount: 0, + LastSeen: time.Now(), + Active: true, + } + } +} + +// MarkStreamInactive marks a stream as inactive (e.g., file reader finished). +func (e *Engine) MarkStreamInactive(source, stream string) { + e.mu.Lock() + defer e.mu.Unlock() + + key := source + ":" + stream + if s, ok := e.streams[key]; ok { + s.Active = false + } +} diff --git a/internal/engine/types.go b/internal/engine/types.go new file mode 100644 index 0000000..8720c51 --- /dev/null +++ b/internal/engine/types.go @@ -0,0 +1,150 @@ +package engine + +import "time" + +// InsightsFilters defines filters for querying log analysis data. +// For local Gonzo, time range filters apply to the in-memory log buffer. +// Dimension filters (environments, namespaces, etc.) map to log entry attributes. +type InsightsFilters struct { + Start *int64 `json:"start,omitempty"` + End *int64 `json:"end,omitempty"` + GroupBy string `json:"group_by,omitempty"` + Environments *[]string `json:"environments,omitempty"` + Clusters *[]string `json:"clusters,omitempty"` + Namespaces *[]string `json:"namespaces,omitempty"` + Deployments *[]string `json:"deployments,omitempty"` + Pods *[]string `json:"pods,omitempty"` + Hosts *[]string `json:"hosts,omitempty"` + Services *[]string `json:"services,omitempty"` + Search *string `json:"search,omitempty"` + Severity *string `json:"severity,omitempty"` + Limit *int `json:"limit,omitempty"` +} + +// LogSample is a single log entry for API responses. +type LogSample struct { + Timestamp int64 `json:"timestamp"` + Severity string `json:"severity"` + Message string `json:"message"` + Attributes map[string]string `json:"attributes,omitempty"` + RawLine string `json:"raw_line,omitempty"` +} + +// SeverityGroup represents severity distribution for one group_by value. +type SeverityGroup struct { + GroupValue string `json:"group_value"` + Counts map[string]int `json:"counts"` + Total int `json:"total"` +} + +// SentimentBucket represents one time bucket of sentiment data for a group. +type SentimentBucket struct { + Timestamp int64 `json:"timestamp"` + GroupValue string `json:"group_value"` + Sentiment float64 `json:"sentiment"` + IsAnomaly bool `json:"is_anomaly,omitempty"` + LogCount int `json:"log_count"` +} + +// SentimentData is the response format for sentiment queries. +type SentimentData struct { + GroupValues []string `json:"group_values"` + Buckets []SentimentBucket `json:"buckets"` +} + +// PatternItem represents a single log pattern. +type PatternItem struct { + Pattern string `json:"pattern"` + Count int `json:"count"` + Percentage float64 `json:"percentage"` + Sample string `json:"sample,omitempty"` +} + +// PatternGroup represents patterns for one group_by value. +type PatternGroup struct { + GroupValue string `json:"group_value"` + Patterns []PatternItem `json:"patterns"` +} + +// ClassItem represents a log class/category. +type ClassItem struct { + Name string `json:"name"` + Count int `json:"count"` + Percentage float64 `json:"percentage"` +} + +// HeatmapMinuteData represents one minute of heatmap data. +type HeatmapMinuteData struct { + Timestamp int64 `json:"timestamp"` + Counts map[string]int `json:"counts"` +} + +// SeverityTimePoint represents severity counts at a point in time (1-second interval). +type SeverityTimePoint struct { + Timestamp int64 `json:"timestamp"` + Counts map[string]int `json:"counts"` + Total int `json:"total"` +} + +// AnomalyInfo represents a detected anomaly. +type AnomalyInfo struct { + Type string `json:"type"` + Severity string `json:"severity"` + Description string `json:"description"` + Timestamp int64 `json:"timestamp"` + Score float64 `json:"score"` +} + +// InsightsParams describes available filter dimension values. +type InsightsParams struct { + Environments []string `json:"environments,omitempty"` + Clusters []string `json:"clusters,omitempty"` + Namespaces []string `json:"namespaces,omitempty"` + Deployments []string `json:"deployments,omitempty"` + Pods []string `json:"pods,omitempty"` + Hosts []string `json:"hosts,omitempty"` + Services []string `json:"services,omitempty"` + Severities []string `json:"severities,omitempty"` + Categories []string `json:"categories,omitempty"` + TotalLogs int64 `json:"total_logs"` + OldestLog *int64 `json:"oldest_log,omitempty"` + NewestLog *int64 `json:"newest_log,omitempty"` +} + +// StreamInfo represents an active log input stream. +type StreamInfo struct { + Source string `json:"source"` + Stream string `json:"stream"` + LogCount int64 `json:"log_count"` + LastSeen time.Time `json:"last_seen"` + Active bool `json:"active"` +} + +// StatusInfo represents server status. +type StatusInfo struct { + Uptime string `json:"uptime"` + TotalLogs int64 `json:"total_logs"` + TotalBytes int64 `json:"total_bytes"` + LogRate float64 `json:"log_rate"` + Streams []StreamInfo `json:"streams"` + BufferSize int `json:"buffer_size"` + BufferUsed int `json:"buffer_used"` + AIConfigured bool `json:"ai_configured"` +} + +// AttributeEntry represents a single top attribute key+value with count. +type AttributeEntry struct { + Key string `json:"key"` + Value string `json:"value"` + Count int64 `json:"count"` + Percentage float64 `json:"percentage"` +} + +// EngineStats holds engine-level statistics. +type EngineStats struct { + StartTime time.Time `json:"start_time"` + TotalLogsEver int `json:"total_logs_ever"` + TotalBytes int64 `json:"total_bytes"` + BufferSize int `json:"buffer_size"` + BufferUsed int `json:"buffer_used"` +} diff --git a/internal/releases/releases.go b/internal/releases/releases.go new file mode 100644 index 0000000..2316059 --- /dev/null +++ b/internal/releases/releases.go @@ -0,0 +1,127 @@ +package releases + +import ( + "context" + "encoding/json" + "io" + "net/http" + "sync" + "time" +) + +// Release represents a GitHub release. +type Release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + PublishedAt string `json:"published_at"` + URL string `json:"url"` +} + +// githubRelease is the raw GitHub API response shape (we only extract what we need). +type githubRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + PublishedAt string `json:"published_at"` + HTMLURL string `json:"html_url"` +} + +// Fetcher fetches and caches GitHub releases. +type Fetcher struct { + releases []Release + done chan struct{} + once sync.Once +} + +// NewFetcher creates a new releases fetcher. +func NewFetcher() *Fetcher { + return &Fetcher{ + done: make(chan struct{}), + } +} + +// FetchInBackground starts a goroutine to fetch releases from the GitHub API. +// Non-blocking. Silently ignores errors. +func (f *Fetcher) FetchInBackground() { + go func() { + defer func() { + f.once.Do(func() { close(f.done) }) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", + "https://api.github.com/repos/control-theory/gonzo/releases", nil) + if err != nil { + return + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return + } + + var ghReleases []githubRelease + if err := json.Unmarshal(body, &ghReleases); err != nil { + return + } + + releases := make([]Release, 0, len(ghReleases)) + for _, gh := range ghReleases { + releases = append(releases, Release{ + TagName: gh.TagName, + Name: gh.Name, + Body: gh.Body, + PublishedAt: gh.PublishedAt, + URL: gh.HTMLURL, + }) + } + + f.releases = releases + }() +} + +// GetReleases returns fetched releases (newest first), or nil if not ready/failed. +// On the first call, waits up to 2 seconds for the fetch to complete. +func (f *Fetcher) GetReleases() []Release { + select { + case <-f.done: + return f.releases + case <-time.After(2 * time.Second): + return f.releases + } +} + +// GetReleasesNonBlocking returns whatever releases are available without waiting. +func (f *Fetcher) GetReleasesNonBlocking() []Release { + select { + case <-f.done: + return f.releases + default: + return nil + } +} + +// GetRelease returns the release matching the given tag (e.g. "v0.3.1"), or nil. +func (f *Fetcher) GetRelease(tag string) *Release { + rels := f.GetReleasesNonBlocking() + for i := range rels { + if rels[i].TagName == tag { + return &rels[i] + } + } + return nil +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..237e121 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,69 @@ +package state + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// AppState holds persistent application state (separate from user config). +type AppState struct { + LastSeenVersion string `json:"last_seen_version"` +} + +// stateDir returns ~/.config/gonzo (same base as config). +func stateDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "gonzo"), nil +} + +// statePath returns the full path to the state file. +func statePath() (string, error) { + dir, err := stateDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "state.json"), nil +} + +// Load reads the app state from disk. Returns zero value if file is missing or unreadable. +func Load() AppState { + p, err := statePath() + if err != nil { + return AppState{} + } + + data, err := os.ReadFile(p) + if err != nil { + return AppState{} + } + + var s AppState + if err := json.Unmarshal(data, &s); err != nil { + return AppState{} + } + return s +} + +// Save writes the app state to disk, creating the directory if needed. +func Save(s AppState) error { + p, err := statePath() + if err != nil { + return err + } + + dir := filepath.Dir(p) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + + return os.WriteFile(p, data, 0o644) +} diff --git a/internal/tui/components.go b/internal/tui/components.go index f082bdd..069cbe2 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -99,13 +99,13 @@ func (m *DashboardModel) renderStatusLine() string { } else { // Default status showing main actions if veryNarrow { - statusText = "Tab • Space • i • ? • q" + statusText = "Tab • Space • i • d • ? • q" } else if narrow { - statusText = "?: Help • Tab: Nav • Space: Pause • i: Stats • q: Quit" + statusText = "?: Help • d: Web UI • Tab: Nav • Space: Pause • i: Stats • q: Quit" } else if medium { - statusText = "Tab: Navigate • Space: Pause • i: Stats • Enter: Select • q: Quit" + statusText = "?: Help • d: Web UI • Tab: Navigate • Space: Pause • i: Stats • Enter: Select • q: Quit" } else { - statusText = "?: Help • Click sections • Wheel: scroll • Space: Pause • Tab: Navigate • i: Stats • Enter: Select • q: Quit" + statusText = "?: Help • d: Web UI • Click sections • Wheel: scroll • Space: Pause • Tab: Navigate • i: Stats • Enter: Select • q: Quit" } } diff --git a/internal/tui/modal_help.go b/internal/tui/modal_help.go index 19396ff..f7c29ae 100644 --- a/internal/tui/modal_help.go +++ b/internal/tui/modal_help.go @@ -85,6 +85,8 @@ ACTIONS: i - Show comprehensive statistics modal i - AI analysis (when viewing log details) m - Switch AI model (shows available models) + d - Open Dstl8 Lite web dashboard in browser + w - Show what's new (release notes) ? or h - Toggle this help q/Ctrl+C - Quit diff --git a/internal/tui/modal_whats_new.go b/internal/tui/modal_whats_new.go new file mode 100644 index 0000000..e2a7f64 --- /dev/null +++ b/internal/tui/modal_whats_new.go @@ -0,0 +1,194 @@ +package tui + +import ( + "fmt" + "regexp" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" + + "github.com/control-theory/gonzo/internal/state" +) + +// gitHashRe matches full 40-character git commit hashes. +var gitHashRe = regexp.MustCompile(`\b([0-9a-f]{40})\b`) + +// renderWhatsNewModal renders the "What's New" modal with GitHub release notes. +func (m *DashboardModel) renderWhatsNewModal() string { + modalWidth := m.width - 8 + modalHeight := m.height - 4 + + contentWidth := modalWidth - 4 + contentHeight := modalHeight - 4 + + m.infoViewport.Width = contentWidth + m.infoViewport.Height = contentHeight + + // Only re-render content if width changed or cache is empty + if m.whatsNewRenderedCache == "" || m.whatsNewCacheWidth != contentWidth { + m.whatsNewRenderedCache = m.buildWhatsNewContent(contentWidth) + m.whatsNewCacheWidth = contentWidth + m.infoViewport.SetContent(m.whatsNewRenderedCache) + } + + contentPane := lipgloss.NewStyle(). + Width(contentWidth). + Height(contentHeight). + Border(lipgloss.NormalBorder()). + BorderForeground(ColorGray). + Render(m.infoViewport.View()) + + header := lipgloss.NewStyle(). + Width(contentWidth). + Foreground(ColorGreen). + Bold(true). + Render("🚀 What's New in Gonzo") + + statusBar := lipgloss.NewStyle(). + Foreground(ColorGray). + Render("↑↓/Wheel: Scroll • ESC: Close") + + modal := lipgloss.JoinVertical(lipgloss.Left, header, contentPane, statusBar) + + finalModal := lipgloss.NewStyle(). + Width(modalWidth). + Height(modalHeight). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorGreen). + Render(modal) + + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, finalModal) +} + +// buildWhatsNewContent renders all release notes via glamour. Called once and cached. +func (m *DashboardModel) buildWhatsNewContent(width int) string { + if m.releasesFetcher == nil { + return "Loading release notes..." + } + + releases := m.releasesFetcher.GetReleasesNonBlocking() + if len(releases) == 0 { + return "Could not load release notes. Check https://github.com/control-theory/gonzo/releases" + } + + renderWidth := max(40, width-4) + renderer, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(renderWidth), + ) + + var sb strings.Builder + + for i, rel := range releases { + version := rel.TagName + name := rel.Name + if name == "" { + name = version + } + + dateStr := "" + if t, parseErr := time.Parse(time.RFC3339, rel.PublishedAt); parseErr == nil { + dateStr = t.Format("January 2, 2006") + } + + if i == 0 && m.currentVersion != "" && ("v"+m.currentVersion == version || m.currentVersion == version || baseVersion("v"+m.currentVersion) == version) { + badge := lipgloss.NewStyle().Bold(true).Foreground(ColorGreen).Render(fmt.Sprintf("★ %s (current)", name)) + if dateStr != "" { + badge += lipgloss.NewStyle().Foreground(ColorGray).Render(" " + dateStr) + } + sb.WriteString(badge) + } else { + badge := lipgloss.NewStyle().Bold(true).Foreground(ColorBlue).Render(name) + if dateStr != "" { + badge += lipgloss.NewStyle().Foreground(ColorGray).Render(" " + dateStr) + } + sb.WriteString(badge) + } + sb.WriteString("\n") + + body := gitHashRe.ReplaceAllStringFunc(strings.TrimSpace(rel.Body), func(hash string) string { + return hash[:7] + }) + if body != "" && err == nil { + rendered, renderErr := renderer.Render(body) + if renderErr == nil { + sb.WriteString(rendered) + } else { + sb.WriteString(body) + sb.WriteString("\n") + } + } else if body != "" { + sb.WriteString(body) + sb.WriteString("\n") + } else { + sb.WriteString(lipgloss.NewStyle().Foreground(ColorGray).Render(" No release notes.")) + sb.WriteString("\n") + } + + if i < len(releases)-1 { + sep := lipgloss.NewStyle().Foreground(ColorGray).Render(strings.Repeat("─", min(width-4, 60))) + sb.WriteString("\n" + sep + "\n\n") + } + } + + return sb.String() +} + +// saveWhatsNewState saves the current version as "last seen" so the modal won't auto-show again. +func (m *DashboardModel) saveWhatsNewState() tea.Cmd { + base := baseVersion(m.currentVersion) + return func() tea.Msg { + state.Save(state.AppState{LastSeenVersion: base}) + return nil + } +} + +// baseVersion strips git describe suffixes like "-13-g58cce74-dirty" from a version string. +func baseVersion(v string) string { + s := v + prefix := "" + if strings.HasPrefix(s, "v") { + prefix = "v" + s = s[1:] + } + parts := strings.SplitN(s, "-", 2) + return prefix + parts[0] +} + +// checkWhatsNewAutoShow checks if we should auto-show the what's-new modal. +func (m *DashboardModel) checkWhatsNewAutoShow() { + if m.whatsNewCheckDone || m.currentVersion == "" || m.currentVersion == "dev" { + m.whatsNewCheckDone = true + return + } + + if m.releasesFetcher == nil { + m.whatsNewCheckDone = true + return + } + + base := baseVersion(m.currentVersion) + if baseVersion(m.lastSeenVersion) == base { + m.whatsNewCheckDone = true + return + } + + rels := m.releasesFetcher.GetReleasesNonBlocking() + if rels == nil { + m.whatsNewRetries++ + if m.whatsNewRetries > 5 { + m.whatsNewCheckDone = true + } + return + } + m.whatsNewCheckDone = true + + if len(rels) > 0 { + m.showWhatsNewModal = true + m.whatsNewRenderedCache = "" // force re-render + m.infoViewport.GotoTop() + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 67d2ba5..6983247 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1,12 +1,15 @@ package tui import ( + "os/exec" "regexp" + "runtime" "sort" "time" "github.com/control-theory/gonzo/internal/ai" "github.com/control-theory/gonzo/internal/memory" + "github.com/control-theory/gonzo/internal/releases" versioncheck "github.com/control-theory/gonzo/internal/version" "github.com/charmbracelet/bubbles/textarea" @@ -225,6 +228,19 @@ type DashboardModel struct { // Version checking versionChecker *versioncheck.Checker // Version checker for update notifications + + // Web dashboard + webPort int // Port for the Dstl8 Lite web dashboard (for browser open shortcut) + + // What's New modal + showWhatsNewModal bool + releasesFetcher *releases.Fetcher + currentVersion string + lastSeenVersion string + whatsNewCheckDone bool // true after we've checked whether to auto-show + whatsNewRetries int // retry counter for waiting on background fetch + whatsNewRenderedCache string // pre-rendered content (glamour is expensive) + whatsNewCacheWidth int // width the cache was rendered at } // UpdateMsg contains data updates for the dashboard @@ -254,8 +270,8 @@ type AIAnalysisMsg struct { // ManualResetMsg represents a manual reset request triggered by user type ManualResetMsg struct{} -// initializeDrain3BySeverity creates separate drain3 instances for each severity level -func initializeDrain3BySeverity() map[string]*Drain3Manager { +// InitializeDrain3BySeverity creates separate drain3 instances for each severity level +func InitializeDrain3BySeverity() map[string]*Drain3Manager { severities := []string{"FATAL", "ERROR", "WARN", "INFO", "DEBUG", "TRACE", "UNKNOWN"} drain3Map := make(map[string]*Drain3Manager) @@ -317,7 +333,7 @@ func NewDashboardModel(maxLogBuffer int, updateInterval time.Duration, aiProvide allLogEntries: make([]LogEntry, 0, maxLogBuffer), countsHistory: make([]SeverityCounts, 0), heatmapData: make([]HeatmapMinute, 0), - drain3BySeverity: initializeDrain3BySeverity(), + drain3BySeverity: InitializeDrain3BySeverity(), servicesBySeverity: make(map[string][]ServiceCount), availableIntervals: availableIntervals, currentIntervalIdx: currentIdx, @@ -486,6 +502,40 @@ func (m *DashboardModel) SetVersionChecker(checker *versioncheck.Checker) { m.versionChecker = checker } +// SetWebPort sets the web dashboard port for the browser open shortcut +func (m *DashboardModel) SetWebPort(port int) { + m.webPort = port +} + +// SetReleasesFetcher sets the releases fetcher for the what's-new modal +func (m *DashboardModel) SetReleasesFetcher(f *releases.Fetcher) { + m.releasesFetcher = f +} + +// SetCurrentVersion sets the current app version for the what's-new modal +func (m *DashboardModel) SetCurrentVersion(v string) { + m.currentVersion = v +} + +// SetLastSeenVersion sets the last version the user has seen +func (m *DashboardModel) SetLastSeenVersion(v string) { + m.lastSeenVersion = v +} + +// openBrowser opens the given URL in the default browser +func (m *DashboardModel) openBrowser(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + cmd = exec.Command("xdg-open", url) + } + cmd.Start() +} + // SetK8sSource sets the Kubernetes log source for the dashboard func (m *DashboardModel) SetK8sSource(source K8sSourceInterface) { m.k8sSource = source diff --git a/internal/tui/navigation.go b/internal/tui/navigation.go index b63232e..a95d7bc 100644 --- a/internal/tui/navigation.go +++ b/internal/tui/navigation.go @@ -201,6 +201,10 @@ func (m *DashboardModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Quit case "escape", "esc": + if m.showWhatsNewModal { + m.showWhatsNewModal = false + return m, m.saveWhatsNewState() + } if m.showModelSelectionModal { m.showModelSelectionModal = false return m, nil @@ -455,6 +459,26 @@ func (m *DashboardModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + case "d": + // Open Dstl8 Lite web dashboard in browser + if !m.showModal && !m.filterActive && !m.searchActive && !m.showSeverityFilterModal && !m.showK8sFilterModal && !m.showColumnConfigModal { + if m.webPort > 0 { + m.openBrowser(fmt.Sprintf("http://localhost:%d", m.webPort)) + } + return m, nil + } + + case "w": + // Open What's New modal + if !m.showModal && !m.showWhatsNewModal && !m.filterActive && !m.searchActive && !m.showSeverityFilterModal && !m.showK8sFilterModal && !m.showColumnConfigModal && !m.showHelp && !m.showPatternsModal && !m.showStatsModal && !m.showCountsModal { + if m.releasesFetcher != nil { + m.showWhatsNewModal = true + m.whatsNewRenderedCache = "" // force fresh render + m.infoViewport.GotoTop() + } + return m, nil + } + case " ": // Spacebar: Global pause/unpause toggle for entire UI if !m.showModal && !m.filterActive && !m.searchActive && !m.showSeverityFilterModal && !m.showK8sFilterModal && !m.showColumnConfigModal { @@ -577,7 +601,6 @@ func (m *DashboardModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } - // Patterns modal shortcuts if m.showPatternsModal { switch msg.String() { case "up", "k": @@ -603,6 +626,35 @@ func (m *DashboardModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } + // What's New modal shortcuts (same pattern as stats/patterns modals) + if m.showWhatsNewModal { + switch msg.String() { + case "up", "k": + m.infoViewport.ScrollUp(1) + return m, nil + case "down", "j": + m.infoViewport.ScrollDown(1) + return m, nil + case "pgup": + m.infoViewport.HalfPageUp() + return m, nil + case "pgdown": + m.infoViewport.HalfPageDown() + return m, nil + case "w": + // Allow 'w' to toggle what's-new modal off + m.showWhatsNewModal = false + return m, m.saveWhatsNewState() + case "escape", "esc": + m.showWhatsNewModal = false + return m, m.saveWhatsNewState() + } + + var cmd tea.Cmd + m.infoViewport, cmd = m.infoViewport.Update(msg) + return m, cmd + } + // Statistics modal shortcuts if m.showStatsModal { switch msg.String() { diff --git a/internal/tui/update.go b/internal/tui/update.go index 3ca2be4..c49c560 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -34,6 +34,11 @@ func (m *DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return msg } case TickMsg: + // Check if we should auto-show the what's-new modal (once) + if !m.whatsNewCheckDone { + m.checkWhatsNewAutoShow() + } + // Update processing rate statistics on every tick m.updateProcessingRateStats() diff --git a/internal/tui/view.go b/internal/tui/view.go index 5fbb527..1616d0d 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -22,6 +22,11 @@ func (m *DashboardModel) View() string { return m.renderHelpModal() } + // Show what's new modal + if m.showWhatsNewModal { + return m.renderWhatsNewModal() + } + // Show patterns modal if m.showPatternsModal { return m.renderPatternsModal() diff --git a/internal/web/api.go b/internal/web/api.go new file mode 100644 index 0000000..362653c --- /dev/null +++ b/internal/web/api.go @@ -0,0 +1,233 @@ +package web + +import ( + "net/http" + "sort" + "strconv" + "time" + + "github.com/control-theory/gonzo/internal/engine" +) + +func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) { + stats := s.engine.GetStats() + streams := s.engine.GetStreams() + + uptime := time.Since(stats.StartTime).Round(time.Second).String() + logRate := 0.0 + elapsed := time.Since(stats.StartTime).Seconds() + if elapsed > 0 { + logRate = float64(stats.TotalLogsEver) / elapsed + } + + streamInfos := make([]engine.StreamInfo, len(streams)) + copy(streamInfos, streams) + + writeJSON(w, engine.StatusInfo{ + Uptime: uptime, + TotalLogs: int64(stats.TotalLogsEver), + TotalBytes: stats.TotalBytes, + LogRate: logRate, + Streams: streamInfos, + BufferSize: stats.BufferSize, + BufferUsed: stats.BufferUsed, + AIConfigured: false, + }) +} + +func (s *Server) handleSeverity(w http.ResponseWriter, r *http.Request) { + filters := parseFilters(r) + data, err := s.engine.QuerySeverityData(r.Context(), filters) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, data) +} + +func (s *Server) handleSentiment(w http.ResponseWriter, r *http.Request) { + filters := parseFilters(r) + data, err := s.engine.QuerySentimentData(r.Context(), filters) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, data) +} + +func (s *Server) handlePatterns(w http.ResponseWriter, r *http.Request) { + filters := parseFilters(r) + data, err := s.engine.QueryPatterns(r.Context(), filters) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, data) +} + +func (s *Server) handleClasses(w http.ResponseWriter, r *http.Request) { + filters := parseFilters(r) + data, err := s.engine.QueryClasses(r.Context(), filters) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, data) +} + +func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) { + filters := parseFilters(r) + data, err := s.engine.QueryLogSamples(r.Context(), filters) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, data) +} + +func (s *Server) handleHeatmap(w http.ResponseWriter, _ *http.Request) { + raw := s.engine.GetHeatmapData() + // Convert tui.HeatmapMinute (struct fields) to engine.HeatmapMinuteData (map) + data := make([]engine.HeatmapMinuteData, 0, len(raw)) + for _, m := range raw { + counts := map[string]int{ + "FATAL": m.Counts.Fatal, + "ERROR": m.Counts.Error, + "WARN": m.Counts.Warn, + "INFO": m.Counts.Info, + "DEBUG": m.Counts.Debug, + "TRACE": m.Counts.Trace, + } + data = append(data, engine.HeatmapMinuteData{ + Timestamp: m.Timestamp.Unix(), + Counts: counts, + }) + } + writeJSON(w, data) +} + +func (s *Server) handleAnomalies(w http.ResponseWriter, r *http.Request) { + filters := parseFilters(r) + report, err := s.engine.GetAnomalies(r.Context(), filters) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, map[string]string{"report": report}) +} + +func (s *Server) handleStreams(w http.ResponseWriter, _ *http.Request) { + streams := s.engine.GetStreams() + writeJSON(w, streams) +} + +func (s *Server) handleInsightsParams(w http.ResponseWriter, r *http.Request) { + filters := parseFilters(r) + params, err := s.engine.QueryInsightsParams(r.Context(), filters) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, params) +} + +func (s *Server) handleSeverityTimeSeries(w http.ResponseWriter, r *http.Request) { + filters := parseFilters(r) + writeJSON(w, s.engine.QuerySeverityTimeSeries(r.Context(), filters)) +} + +func (s *Server) handleTopAttributes(w http.ResponseWriter, r *http.Request) { + limitStr := r.URL.Query().Get("limit") + limit := 20 + if limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + } + + attrKeyCounts := s.engine.GetLifetimeAttrKeyCounts() + stats := s.engine.GetStats() + totalLogs := int64(stats.TotalLogsEver) + + // Flatten into entries and sort by count + var entries []engine.AttributeEntry + for key, vals := range attrKeyCounts { + for val, count := range vals { + pct := 0.0 + if totalLogs > 0 { + pct = float64(count) * 100.0 / float64(totalLogs) + } + entries = append(entries, engine.AttributeEntry{ + Key: key, + Value: val, + Count: count, + Percentage: pct, + }) + } + } + + // Sort descending by count + sort.Slice(entries, func(i, j int) bool { + return entries[i].Count > entries[j].Count + }) + + if len(entries) > limit { + entries = entries[:limit] + } + + writeJSON(w, entries) +} + +func (s *Server) handleSummary(w http.ResponseWriter, r *http.Request) { + filters := parseFilters(r) + summary, err := s.engine.QuerySummary(r.Context(), filters) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, map[string]string{"summary": summary}) +} + +// parseFilters extracts InsightsFilters from query parameters. +func parseFilters(r *http.Request) engine.InsightsFilters { + q := r.URL.Query() + var filters engine.InsightsFilters + + if v := q.Get("start"); v != "" { + if ts, err := strconv.ParseInt(v, 10, 64); err == nil { + filters.Start = &ts + } + } + if v := q.Get("end"); v != "" { + if ts, err := strconv.ParseInt(v, 10, 64); err == nil { + filters.End = &ts + } + } + if v := q.Get("group_by"); v != "" { + filters.GroupBy = v + } + if v := q.Get("search"); v != "" { + filters.Search = &v + } + if v := q.Get("severity"); v != "" { + filters.Severity = &v + } + if v := q.Get("limit"); v != "" { + if limit, err := strconv.Atoi(v); err == nil { + filters.Limit = &limit + } + } + + return filters +} + +func (s *Server) handleReleases(w http.ResponseWriter, _ *http.Request) { + var rels interface{} + if s.relFetcher != nil { + rels = s.relFetcher.GetReleases() + } + writeJSON(w, map[string]interface{}{ + "version": s.version, + "releases": rels, + }) +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..7b1046d --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,195 @@ +package web + +import ( + "context" + "encoding/json" + "fmt" + "io/fs" + "log" + "net/http" + "time" + + "github.com/control-theory/gonzo/internal/engine" + "github.com/control-theory/gonzo/internal/releases" +) + +// Server is the HTTP server for the Dstl8 Lite web dashboard. +type Server struct { + engine *engine.Engine + httpServer *http.Server + hub *Hub + staticFS fs.FS // embedded React build assets (nil if not embedded) + version string + relFetcher *releases.Fetcher +} + +// NewServer creates a new web dashboard server. +func NewServer(eng *engine.Engine, staticFS fs.FS, version string, relFetcher *releases.Fetcher) *Server { + return &Server{ + engine: eng, + hub: NewHub(), + staticFS: staticFS, + version: version, + relFetcher: relFetcher, + } +} + +// Start starts the web server on the given port. +func (s *Server) Start(ctx context.Context, port int) error { + mux := http.NewServeMux() + + // API endpoints + mux.HandleFunc("GET /api/status", s.handleStatus) + mux.HandleFunc("GET /api/severity", s.handleSeverity) + mux.HandleFunc("GET /api/sentiment", s.handleSentiment) + mux.HandleFunc("GET /api/patterns", s.handlePatterns) + mux.HandleFunc("GET /api/classes", s.handleClasses) + mux.HandleFunc("GET /api/logs", s.handleLogs) + mux.HandleFunc("GET /api/heatmap", s.handleHeatmap) + mux.HandleFunc("GET /api/anomalies", s.handleAnomalies) + mux.HandleFunc("GET /api/streams", s.handleStreams) + mux.HandleFunc("POST /api/summary", s.handleSummary) + mux.HandleFunc("GET /api/insights-params", s.handleInsightsParams) + mux.HandleFunc("GET /api/severity-history", s.handleSeverityTimeSeries) + mux.HandleFunc("GET /api/top-attributes", s.handleTopAttributes) + mux.HandleFunc("GET /api/releases", s.handleReleases) + + // WebSocket endpoint + mux.HandleFunc("/ws", s.handleWebSocket) + + // Static assets (React app) + if s.staticFS != nil { + mux.Handle("/", spaHandler(s.staticFS)) + } else { + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, ` +Web dashboard assets not embedded. Run make build to include them.
API available at /api/*
+ + {summary} +
+ +{error}
+
+ AI analysis requires a configured AI provider.
+ Configure --ai-provider or try{' '}
+
+ Mobius on Dstl8 Pro
+
+ {' '}for built-in AI analysis.
+
+ Generate an AI-powered summary of your current log data. +
+ ++ Want continuous AI analysis?{' '} + + Try Mobius on Dstl8 Pro + +
+| Group | +Pattern | +Count | +% | +
|---|---|---|---|
|
+ |
+ + {p.pattern} + | ++ {p.count.toLocaleString()} + | ++ {p.percentage.toFixed(1)}% + | +
Something went wrong
++ {this.state.error?.message} +
+ ++ Incident Management +
++ Create, track, and resolve incidents with AI-powered root cause analysis. +
+ + Available on Dstl8 Pro + ++ Continuous AI Analysis +
++ Mobius provides real-time AI insights, anomaly detection, and intelligent alerting + across all your log streams. +
+ + Try Mobius Free + ++ Analyzing last 60 minutes of log data +
++ Dstl8 Lite keeps a rolling 60-minute window. For unlimited retention and historical analysis, + upgrade to the full dashboard. +
++ Add always-on sources for AWS, Vercel, Supabase, OTel and more for continuous runtime feedback for your AI tooling. +
+{info.description}
+ + Upgrade to Dstl8 Pro + ++ Dstl8 Lite is powered by{' '} + + Gonzo + + {' '}— the full Dstl8 dashboard unlocks all features. +
+| Key | +Value | +Count | +% | +
|---|---|---|---|
| {attr.key} | +{attr.value} | ++ {attr.count.toLocaleString()} + | +
+
+
+
+
+
+
+ {attr.percentage.toFixed(1)}%
+
+ |
+
+ Get AI-powered summaries, correlated anomaly detection, and + root-cause analysis across all your sources. +
+ + Upgrade for Möbius AI Analysis + Root cause analysis, summaries, alerts and chat + ++ Create, track, and resolve alerts with automatic correlation, + timeline views, and team collaboration. +
+ + Upgrade for Alerts + ++ Dstl8 Lite supports a single local workspace powered by Gonzo. +
++ Upgrade to Dstl8 Pro for multiple workspaces, team collaboration, + cloud log sources (AWS, GCP, Azure, Supabase, Vercel), and more. +
+