diff --git a/COVERAGE_SETUP.md b/COVERAGE_SETUP.md deleted file mode 100644 index c14cffc..0000000 --- a/COVERAGE_SETUP.md +++ /dev/null @@ -1,337 +0,0 @@ -# Coverage Setup - Complete Guide - -This project has **visual test coverage** directly in VSCode! Here's everything you need to know. - -## โœ… What's Already Configured - -Your VSCode is pre-configured to show coverage with: -- **๐ŸŸข Green gutter blocks** = Covered code -- **๐Ÿ”ด Red gutter blocks** = Uncovered code -- **Automatic coverage display** after running tests -- **Coverage on save** enabled - -## ๐Ÿš€ Quick Start - 3 Ways to See Coverage - -### Method 1: Single Package with Visual Coverage (Best!) - -**Perfect for: Working on a specific package** - -1. Open any Go file (e.g., `internal/output/spinner.go`) -2. Press `F5` -3. Select **"Test Current Package with Coverage"** -4. โœ… See green/red gutters appear in the editor! - -**What happens:** -- Tests run for the current package -- Coverage is calculated -- Green/red gutters appear showing what's covered -- `coverage.out` file is created - -### Method 2: All Packages with Coverage Summary - -**Perfect for: Checking overall project coverage** - -1. Press `Ctrl+Shift+P` (Cmd+Shift+P on Mac) -2. Type "Run Task" -3. Select **"go: test all packages with coverage and show"** -4. โœ… See all test results + coverage summary! - -**Output:** -``` -โœ… All tests pass -๐Ÿ“Š Coverage Summary: -internal/logger/logger.go:32: SetLevel 100.0% -internal/output/spinner.go:57: Start 90.0% -internal/output/vpn.go:194: displayVPNText 85.0% -... -total: (statements) 65.5% -``` - -### Method 3: Command Line - -**Perfect for: Quick checks or CI/CD** - -```bash -# Run all tests with coverage -go test -coverprofile=coverage.out ./... - -# See summary -go tool cover -func=coverage.out | tail -1 - -# Open HTML report (manual) -go tool cover -html=coverage.out -``` - -## ๐ŸŽจ Understanding Visual Coverage - -### What You'll See in the Editor - -``` -Line | Gutter | Code ------|--------|------------------------------------------------ - 32 | ๐ŸŸข | func NewSpinner(message string) *Spinner { - 33 | ๐ŸŸข | writer := os.Stderr - 34 | ๐ŸŸข | enabled := term.IsTerminal(int(writer.Fd())) - 35 | ๐Ÿ”ด | if someRareCondition { // NOT TESTED! - 36 | ๐Ÿ”ด | // This never runs - 37 | ๐Ÿ”ด | } - 38 | ๐ŸŸข | return &Spinner{...} -``` - -- **๐ŸŸข Green** = This line is executed by tests โœ… -- **๐Ÿ”ด Red** = This line is NOT executed by tests โŒ -- **No mark** = Not executable (comments, blank lines, declarations) - -### Coverage Appears Automatically! - -With `go.coverOnSave: true` enabled, coverage appears when: -- โœ… You run "Test Current Package with Coverage" (F5) -- โœ… You use the Test Explorer and click "Run Test" -- โœ… You run tests from the command line and `coverage.out` exists - -## ๐Ÿ“Š Current Project Coverage - -Run this to see current coverage: -```bash -go test -cover ./... -``` - -**Last known coverage:** -| Package | Coverage | -|---------|----------| -| internal/logger | 90.2% | -| internal/ssh | 74.4% | -| internal/output | 72.0% | -| internal/cache | 54.4% | -| internal/update | 52.9% | -| cmd | 21.4% | -| internal/gcp | 20.9% | -| internal/version | 100.0% | - -## ๐ŸŽฏ Workflow: Test-Driven Development with Coverage - -### Workflow 1: Writing New Code - -``` -1. Write new function in spinner.go - ๐Ÿ”ด All lines are RED (no tests) - -2. Write test in spinner_test.go - func TestNewFunction(t *testing.T) { ... } - -3. Press F5 โ†’ "Test Current Package with Coverage" - ๐ŸŸข Some lines turn GREEN! - -4. Add more test cases for edge cases -5. Press F5 again - ๐ŸŸข More lines turn GREEN! - -6. Repeat until all lines are GREEN - โœ… Function fully tested! -``` - -### Workflow 2: Fixing Bugs - -``` -1. Open file with bug (e.g., vpn.go) -2. Press F5 โ†’ "Test Current Package with Coverage" -3. Look for ๐Ÿ”ด RED lines near the bug -4. Those lines might not have tests! -5. Add test that reproduces the bug -6. Fix the bug -7. Press F5 โ†’ Verify fix and coverage -``` - -### Workflow 3: Improving Coverage - -``` -1. Run: Ctrl+Shift+P โ†’ "test all packages with coverage and show" -2. Find package with low coverage (e.g., 21.4%) -3. Open source file in that package -4. Press F5 โ†’ "Test Current Package with Coverage" -5. Look for ๐Ÿ”ด RED functions/lines -6. Add tests for those functions -7. Repeat until coverage is acceptable (>70%) -``` - -## โš™๏ธ Configuration Details - -### VSCode Settings (`.vscode/settings.json`) - -```json -{ - "go.coverOnSave": true, // Auto-show coverage after tests - "go.coverOnSingleTest": true, // Show coverage after single test - "go.coverOnTestPackage": true, // Show coverage after package tests - "go.coverageDecorator": { - "type": "gutter", // Show in gutter (left side) - "coveredGutterStyle": "blockgreen", // Green blocks - "uncoveredGutterStyle": "blockred" // Red blocks - }, - "go.coverageOptions": "showBothCoveredAndUncoveredCode" -} -``` - -### Debug Configuration (`.vscode/launch.json`) - -```json -{ - "name": "Test Current Package with Coverage", - "buildFlags": "-cover", // Build with coverage support - "args": [ - "-test.coverprofile=coverage.out" // Write coverage file - ] -} -``` - -## ๐Ÿ”ง Available Commands - -### VSCode Commands (Ctrl+Shift+P) - -| Command | What It Does | -|---------|-------------| -| **Go: Toggle Test Coverage in Current Package** | Show/hide coverage | -| **Go: Apply Coverage Profile** | Load coverage from file | -| **Go: Remove Coverage Profile** | Clear coverage display | - -### Tasks (Ctrl+Shift+P โ†’ Run Task) - -| Task | What It Does | -|------|-------------| -| **go: test all packages with coverage and show** | Run all tests, show summary | -| **go: test with coverage** | Run all tests, create coverage.out | -| **go: test current package with coverage (no debug)** | Test + HTML report | -| **Show Coverage** | Open HTML report in browser | - -### Terminal Commands - -```bash -# Run tests with coverage -go test -coverprofile=coverage.out ./... - -# View in terminal (summary) -go tool cover -func=coverage.out - -# View in terminal (last line = total) -go tool cover -func=coverage.out | tail -1 - -# Open HTML report in browser -go tool cover -html=coverage.out - -# Per-package coverage -go test -cover ./... - -# Verbose with coverage -go test -v -cover ./... -``` - -## ๐ŸŽ“ Tips & Tricks - -### Tip 1: Focus on One Package - -``` -1. Open a file in the package (e.g., spinner.go) -2. F5 โ†’ "Test Current Package with Coverage" -3. Focus on that package only -โœ… Faster feedback loop! -``` - -### Tip 2: Coverage + Breakpoints - -``` -1. Set breakpoint in source code -2. F5 โ†’ "Test Current Package with Coverage" -3. Debugger stops at breakpoint -4. Step through code -5. After debugging, see coverage! -โœ… Understand code flow + coverage! -``` - -### Tip 3: Find Untested Code Fast - -``` -1. F5 โ†’ "Test Current Package with Coverage" -2. Scroll through file -3. Look for ๐Ÿ”ด RED sections -4. Those are your testing targets! -โœ… Quick visual scan! -``` - -### Tip 4: Before Committing - -```bash -# Check coverage of what you changed -go test -cover ./internal/output - -# Must be >70% for new code -``` - -### Tip 5: Hide Coverage Temporarily - -``` -Ctrl+Shift+P โ†’ "Go: Remove Coverage Profile" -``` - -Coverage gutters disappear. Run tests again to bring them back. - -## ๐Ÿ› Troubleshooting - -### Coverage Not Showing? - -**Solution 1:** Reload window -``` -Ctrl+Shift+P โ†’ "Developer: Reload Window" -``` - -**Solution 2:** Check coverage file exists -```bash -ls -la coverage.out -``` - -**Solution 3:** Manually apply coverage -``` -Ctrl+Shift+P โ†’ "Go: Apply Coverage Profile" -Select: coverage.out -``` - -### Coverage Showing Old Data? - -**Solution:** Remove and regenerate -``` -Ctrl+Shift+P โ†’ "Go: Remove Coverage Profile" -F5 โ†’ "Test Current Package with Coverage" -``` - -### Can't See Gutter Decorations? - -**Check settings:** -```json -{ - "go.coverageDecorator": { - "type": "gutter" // Must be "gutter", not "highlight" - } -} -``` - -## ๐Ÿ“š Additional Resources - -- **Coverage Guide**: `.vscode/COVERAGE_GUIDE.md` - Detailed usage -- **Visual Examples**: `.vscode/COVERAGE_VISUAL_EXAMPLE.md` - See examples -- **VSCode README**: `.vscode/README.md` - All VSCode features -- **Quick Reference**: `.vscode/QUICK_REFERENCE.md` - Cheat sheet - -## โœจ Summary - -**You now have:** -- โœ… Visual coverage with green/red gutters -- โœ… Automatic coverage display after tests -- โœ… Multiple ways to run tests with coverage -- โœ… Complete documentation - -**To start:** -1. Open `internal/output/spinner.go` -2. Press `F5` -3. Select "Test Current Package with Coverage" -4. See the green and red gutters! ๐ŸŽจ - -**That's it!** Happy testing! ๐Ÿงชโœ… diff --git a/TUI.md b/TUI.md deleted file mode 100644 index 7f83cd9..0000000 --- a/TUI.md +++ /dev/null @@ -1,812 +0,0 @@ -# Compass TUI Implementation Plan & Progress - -## Overview -Implement a k9s-style keyboard-driven TUI interface for compass, accessible via `compass interactive`, providing full access to existing CLI features through an intuitive, navigable interface. - ---- - -## โœ… Current Status: Phase 6 Complete - -### What's Working Now (v0.6 - Global Search) -- โœ… Basic TUI launches successfully via `compass interactive` -- โœ… Displays instances from cache in a table format -- โœ… Keyboard navigation (arrow keys, vim-style j/k) -- โœ… Clean display without log interference -- โœ… Proper Ctrl+C, 'q', and ESC quit handling -- โœ… Mouse support enabled -- โœ… Status bar with dynamic keyboard hints -- โœ… Loads cached instances from all projects -- โœ… Real-time filtering with '/' (substring matching) -- โœ… Color-coded instance status -- โœ… SSH connection with 's' key (auto-detects IAP) -- โœ… Background refresh with 'r' key -- โœ… Instance detail modal with 'd' key -- โœ… Comprehensive help overlay with '?' key -- โœ… Smart ESC handling (closes modals, clears filter, or quits) -- โœ… VPN Inspector view with 'v' key -- โœ… Hierarchical VPN display (gateways, tunnels, BGP sessions) -- โœ… Color-coded VPN status (green=UP/ESTABLISHED, red=DOWN/ERROR) -- โœ… VPN detail modals for gateways, tunnels, and BGP sessions -- โœ… Orphan tunnel and BGP session detection -- โœ… Connectivity Tests view with 'c' key -- โœ… List all connectivity tests with source, destination, protocol, and result -- โœ… Color-coded test results (green=REACHABLE, red=UNREACHABLE, yellow=AMBIGUOUS) -- โœ… Test detail modals showing full test configuration and results -- โœ… Rerun connectivity tests with 'r' key -- โœ… Delete connectivity tests with Del key -- โœ… Refresh test list with Ctrl+R -- โœ… Global search view with Shift+S key -- โœ… Search across all cached projects for GCP resources -- โœ… Supports 22+ resource types (instances, MIGs, VPN, storage, networking, etc.) -- โœ… Real-time search filtering as you type -- โœ… Color-coded results by resource type -- โœ… Detail view for search results with 'd' key -- โœ… Open resource in Cloud Console with 'b' key -- โœ… SSH to instances directly from search results with 's' key -- โœ… Configurable parallelism for multi-project searches - -### Known Issues Fixed -- โŒ **Complex App architecture caused hang** - The original PageStack/Component lifecycle was causing a deadlock -- โœ… **Solution**: Implemented `RunDirect()` - a simplified direct runner that bypasses complex initialization -- โŒ **Logging interfered with display** - pterm debug output corrupted the terminal -- โœ… **Solution**: Removed logger dependency from TUI code - ---- - -## Architecture Design - -### Technology Stack -- **TUI Framework**: `tview` (github.com/rivo/tview v0.42.0) -- **Terminal Handling**: `tcell` (github.com/gdamore/tcell/v2 v2.9.0) -- **Pattern**: Simplified direct implementation (complex MVC abandoned due to deadlock) -- **Integration**: Reuses existing cache and GCP client - -### Current Implementation Structure - -``` -internal/tui/ -โ”œโ”€โ”€ direct.go # โœ… Working minimal TUI runner -โ”œโ”€โ”€ app.go # โš ๏ธ Complex version (causes hang - not used) -โ”œโ”€โ”€ app_simple.go # โš ๏ธ Intermediate version (not used) -โ”œโ”€โ”€ components.go # โš ๏ธ Component interfaces (not used) -โ”œโ”€โ”€ keys.go # โš ๏ธ Key action system (not used) -โ”œโ”€โ”€ styles.go # โœ… Color scheme definitions (reusable) -โ”œโ”€โ”€ page_stack.go # โš ๏ธ Navigation stack (causes deadlock) -โ”œโ”€โ”€ instance_view.go # โš ๏ธ Complex instance view (not used) -โ”œโ”€โ”€ help_view.go # โš ๏ธ Help view (not used) -โ””โ”€โ”€ widgets/ - โ””โ”€โ”€ table.go # โš ๏ธ Custom table widget (not used) - -cmd/ -โ””โ”€โ”€ interactive.go # โœ… CLI subcommand using RunDirect() -``` - ---- - -## Complete Implementation Plan - -### Phase 1: Foundation โœ… COMPLETED (MVP) -**Status**: Basic TUI working with simplified architecture - -**What's Implemented**: -- [x] Add tview and tcell dependencies -- [x] Create minimal direct runner (`direct.go`) -- [x] CLI integration (`compass interactive` subcommand) -- [x] Load instances from cache -- [x] Table display with borders -- [x] Basic keyboard handling (Ctrl+C, q, arrows) -- [x] Mouse support -- [x] Status bar with hints - -**Current Features**: -- Instance list table with columns: Name, Project, Zone, Status -- Navigation with arrow keys or vim-style (j/k - handled by tview) -- Status bar showing available shortcuts -- Clean exit with Ctrl+C or 'q' -- Loads all cached instances from all projects - ---- - -### Phase 2: Enhanced Instance View โœ… COMPLETED -**Goal**: Add filtering, sorting, and SSH capability - -**Features Implemented**: -- [x] Filter mode (press `/` to filter instances) -- [x] Filter by name, project, or zone with substring matching -- [x] Live filtering as you type (real-time results) -- [x] Color-coded status (green=RUNNING, red=STOPPED/TERMINATED, yellow=PROVISIONING/STAGING/STOPPING, orange=SUSPENDED, gray=other) -- [x] SSH to selected instance (press `s`) - - [x] Suspend TUI properly with `app.Suspend()` - - [x] Execute SSH via gcloud with proper args - - [x] Auto-detect IAP requirement (no external IP or CanUseIAP flag) - - [x] Resume TUI after SSH exits -- [x] Refresh instances (press `r`) - - [x] Background refresh without blocking UI - - [x] Fetches live data from GCP API - - [x] Updates status with loading indicator -- [x] Show instance details (press `d`) - - [x] Modal overlay with full instance information - - [x] Shows name, project, zone, status, machine type - - [x] Shows external IP, internal IP, IAP capability - - [x] Press Esc to close modal - -**Implementation Details**: -- Extended `direct.go` with filter input field (toggle visibility with `/`) -- Uses `app.Suspend()` for SSH sessions, properly restores TUI afterward -- Added `formatStatus()` helper function in `helpers.go` for color coding -- Background goroutines with `app.QueueUpdateDraw()` for refresh -- Modal detail view using tview.Pages for overlay -- Status bar updates dynamically based on context (filtering, normal, etc.) -- Implement input field for filter with Enter/Esc handling - ---- - -### Phase 3: Help System โœ… COMPLETED -**Goal**: Provide keyboard shortcut reference - -**Features Implemented**: -- [x] Help overlay (press `?`) -- [x] Show all keyboard shortcuts in organized sections -- [x] Scrollable help view for long content -- [x] Dismiss with Esc or `?` (toggle behavior) -- [x] Modal uses fixed size (70 columns x 28 rows) -- [x] Integrates with modalOpen flag for proper ESC handling - -**Implementation Details**: -- Modal overlay with TextView using tview.Pages -- Organized shortcuts into sections: Navigation, Instance Actions, Filtering, General -- Color-coded text (yellow headers, white keys, darkgray footer) -- Centered modal with flexible margins -- Sets modalOpen flag to prevent ESC from quitting while help is shown -- Help can be toggled on/off with '?' key - ---- - -### Phase 4: VPN Inspector โœ… COMPLETED -**Goal**: Browse VPN gateways, tunnels, and BGP sessions - -**Features Implemented**: -- [x] Navigate to VPN view (press `v` from main view) -- [x] Hierarchical table display with indentation: - - Level 0: Gateways and section headers - - Level 1: Tunnels under gateways - - Level 2: BGP sessions under tunnels -- [x] Show VPN statistics in title (gateway count, tunnel count, BGP count) -- [x] Detail modals for gateways, tunnels, and BGP sessions (press `d`) -- [x] Color-coded status: - - Tunnels: green=ESTABLISHED, red=ERROR states, yellow=PROVISIONING, orange=HANDSHAKE - - BGP: green=UP/Established, red=DOWN -- [x] Warning sections for orphaned resources: - - Orphan tunnels (Classic VPN without gateway) - - Orphan BGP sessions (not linked to tunnels) -- [x] Refresh data (press `r`) with background loading -- [x] Return to instance view (press Esc) -- [x] Context-sensitive help (press `?`) - -**Implementation Details**: -- Created separate `vpn_view.go` file for VPN view logic -- Uses `gcp.Client.ListVPNOverview()` to fetch all VPN data -- Table-based display with visual indentation for hierarchy -- Modal overlay system consistent with instance detail modals -- Proper modalOpen flag integration for ESC handling -- Helper functions: `formatTunnelStatus()`, `formatBGPStatus()`, `extractNetworkName()` - ---- - -### Phase 5: Connectivity Tests โœ… COMPLETED -**Goal**: Manage network connectivity tests - -**Features Implemented**: -- [x] Navigate to connectivity tests (press `c`) -- [x] List all tests with status -- [x] View test details (press `d`) -- [x] Rerun test (press `r`) -- [x] Delete test (press Del) -- [x] Color-coded results (REACHABLE=green, UNREACHABLE=red, AMBIGUOUS=yellow) -- [x] Context-sensitive help (press `?`) -- [x] Refresh test list (press Ctrl+R) -- [x] Return to instance view (press Esc) - -**Features Not Yet Implemented**: -- [ ] Create new test (press `n`) - - [ ] Form-based test creation - - [ ] Source/destination selection - - [ ] Protocol/port configuration -- [ ] Watch test until completion (press `w`) - -**Implementation Details**: -- Created separate `connectivity_view.go` file for connectivity tests view -- Uses `gcp.ConnectivityClient` to list, rerun, and delete tests -- Table-based display with columns: Name, Source, Destination, Protocol, Result -- Modal overlay for test details showing full configuration and reachability results -- Delete confirmation modal for safety -- Background operations for rerun and delete actions -- Helper functions: `formatEndpoint()`, `formatTestResult()`, `formatReachabilityDetails()` - -**Data Source**: -- Reuse `gcp.ConnectivityClient` methods -- Display reachability analysis and path trace in detail modal - ---- - -### Phase 6: Global Search โœ… COMPLETED -**Goal**: Search for GCP resources across all cached projects - -**Features Implemented**: -- [x] Navigate to search view (press `Shift+S`) -- [x] Search input field with real-time filtering -- [x] Results table with type, project, location, name, details -- [x] Supports 22+ resource types: - - Compute: instances, MIGs, instance templates, addresses, disks, snapshots - - Load Balancing: forwarding rules, backend services, target pools, health checks, URL maps - - Storage: Cloud Storage buckets - - Database: Cloud SQL instances - - Container: GKE clusters, node pools - - Networking: VPC networks, subnets, firewall rules - - Serverless: Cloud Run services - - Security: Secret Manager secrets - - VPN: HA VPN gateways, VPN tunnels -- [x] Color-coded results by resource type -- [x] Detail modal for search results (press `d`) -- [x] Open resource in Cloud Console (press `b`) -- [x] SSH to instances from search results (press `s`) -- [x] Configurable parallelism for multi-project searches -- [x] Type filtering with `--type` and `--no-type` flags -- [x] Smart search affinity learning (prioritizes projects based on search history) -- [x] Context-sensitive help (press `?`) -- [x] Return to instance view (press `Esc`) - -**Implementation Details**: -- Created `search_view.go` with full search functionality -- Reuses `search.Engine` with all registered providers -- Parallel project searching with configurable concurrency -- Modal overlay for resource details -- Integrated with Cloud Console URL generation -- Uses `cache.GetProjectsForSearch()` for affinity-based project prioritization -- Records search results via `cache.RecordSearchAffinity()` to learn patterns - ---- - -### Phase 7: IP Lookup ๐Ÿ”Ž TODO -**Goal**: Search for IP addresses across projects - -**Features to Add**: -- [ ] Navigate to IP lookup (press `i`) -- [ ] Search input at top -- [ ] Results table below -- [ ] Incremental search (search as you type) -- [ ] Multi-project search with progress -- [ ] Navigate to resource (press Enter on result) -- [ ] Color-coded by resource type - -**Data Source**: -- Reuse `gcp.Client.LookupIP()` -- Use subnet cache for fast lookups -- Show progress during multi-project scan - ---- - -### Phase 8: Project Manager ๐Ÿ“ TODO -**Goal**: Select which projects to include in searches - -**Features to Add**: -- [ ] Navigate to project manager (press `p`) -- [ ] List all available projects -- [ ] Multi-select with checkboxes (Space to toggle) -- [ ] Select all (press `a`) -- [ ] Select none (press `n`) -- [ ] Import new projects (press `i`) -- [ ] Save selection (press Enter) - -**Data Source**: -- Reuse `cache.Cache` project list -- Import projects via GCP API discovery - ---- - -### Phase 9: Navigation & Multi-View ๐Ÿงญ TODO -**Goal**: Navigate between different views - -**Features to Add**: -- [ ] Dashboard/home screen with quick access -- [ ] View navigation: - - `1` or `i` - Instances (default) - - `2` or `v` - VPN Inspector - - `3` or `c` - Connectivity Tests - - `4` or `p` - IP Lookup - - `5` or `j` - Projects -- [ ] Breadcrumb trail showing current location -- [ ] Back button (Esc) to previous view -- [ ] View history (forward with `]`) - -**Implementation**: -- Add view stack for navigation history -- Header showing current view path -- Global key bindings for view switching - ---- - -### Phase 10: Advanced Features ๐Ÿš€ TODO -**Goal**: Polish and additional features - -**Features to Add**: -- [ ] Background auto-refresh (configurable interval) -- [ ] Real-time instance status updates -- [ ] Search across all views (press `/`) -- [ ] Copy to clipboard (selected row data) -- [ ] Export to JSON/CSV (press `e`) -- [ ] Bookmarks/favorites -- [ ] Recent connections history -- [ ] Custom filters and saved searches -- [ ] Configuration file (~/.compass-tui.yaml) - - Last view - - Column preferences - - Refresh intervals - - Theme customization - ---- - -### Phase 11: SSH Integration ๐Ÿ” TODO -**Goal**: Seamless SSH from TUI - -**Features to Add**: -- [ ] SSH to instance (press Enter on selected) -- [ ] TUI suspend during SSH session -- [ ] Resume TUI after SSH exit -- [ ] SSH options dialog before connecting - - [ ] Port forwarding - - [ ] SOCKS proxy - - [ ] X11 forwarding - - [ ] Custom SSH flags -- [ ] MIG instance selection (if MIG selected) -- [ ] IAP tunneling auto-detection -- [ ] Show connection history -- [ ] Quick reconnect to recent instance - -**Implementation**: -- Use `app.Suspend(func() { ... })` to suspend TUI -- Execute SSH via `exec.Command("gcloud", ...)` -- Capture stdout/stderr to terminal -- Resume TUI on exit - ---- - -## Keyboard Shortcuts Summary - -### Global (All Views) -| Key | Action | -|-----|--------| -| `?` | Show help | -| `Ctrl+C` or `q` | Quit application | -| `Esc` | Go back / Cancel | -| `Ctrl+R` | Refresh current view | - -### Instance List (Current Implementation) -| Key | Action | -|-----|--------| -| `โ†‘`/`โ†“` or `j`/`k` | Navigate list | -| `Enter` | SSH to instance (TODO) | -| `/` | Filter mode (TODO) | -| `r` | Refresh (TODO) | -| `d` | Detail view (TODO) | - -### Planned Global Navigation -| Key | Action | -|-----|--------| -| `1` or `i` | Instances view | -| `2` or `v` | VPN Inspector | -| `3` or `c` | Connectivity Tests | -| `4` or `l` | IP Lookup | -| `5` or `p` | Projects | -| `h` or `โ†` | Back in history | -| `l` or `โ†’` | Forward in history | - -### Filter Mode (Planned) -| Key | Action | -|-----|--------| -| Type | Filter text | -| `Enter` | Apply filter | -| `Esc` | Cancel filter | - -### VPN Inspector (Planned) -| Key | Action | -|-----|--------| -| `Space` or `Enter` | Expand/collapse | -| `d` | Detail panel | -| `r` | Refresh | - -### Connectivity Tests (Current Implementation) -| Key | Action | -|-----|--------| -| `d` | View test details | -| `r` | Rerun test | -| `Del` | Delete test | -| `Ctrl+R` | Refresh test list | -| `?` | Show help | -| `Esc` | Return to instance view | - -### Global Search (Current Implementation) -| Key | Action | -|-----|--------| -| `Shift+S` | Open search view (from instance list) | -| Type | Filter results as you type | -| `Enter` | Execute search | -| `d` | View resource details | -| `b` | Open resource in Cloud Console | -| `s` | SSH to instance (instances only) | -| `Tab`/`Shift+Tab` | Navigate between search field and results | -| `Shift+R` | Refresh search results | -| `?` | Show help | -| `Esc` | Return to instance view | - -### Connectivity Tests (Planned) -| Key | Action | -|-----|--------| -| `n` | New test (TODO) | -| `w` | Watch test (TODO) | - ---- - -## Integration with Existing Code - -### โœ… What's Currently Integrated -- **Cache System**: `cache.Cache.GetProjects()`, `cache.Cache.GetLocationsByProject()`, `cache.Cache.GetProjectsByUsage()` -- **Search Affinity**: `cache.Cache.GetProjectsForSearch()`, `cache.Cache.RecordSearchAffinity()` for smart project prioritization -- **CLI Framework**: Cobra subcommand `compass interactive` -- **GCP Client**: `gcp.Client` for instance operations -- **SSH Execution**: Via gcloud command with IAP auto-detection -- **VPN Data**: `gcp.Client.ListVPNOverview()`, `gcp.Client.GetVPNGateway()` -- **Connectivity Tests**: `gcp.ConnectivityClient.ListTests()`, `gcp.ConnectivityClient.RunTest()`, `gcp.ConnectivityClient.DeleteTest()` -- **Instance Details**: `gcp.Client.FindInstance()` -- **Data Model**: Uses `cache.CachedLocation` type -- **Global Search**: `search.Engine` with 22+ resource providers (instances, MIGs, templates, addresses, disks, snapshots, buckets, load balancing, Cloud SQL, GKE, VPC, Cloud Run, firewall rules, secrets, VPN) -- **Cloud Console URLs**: `GetCloudConsoleURL()` for opening resources in browser - -### ๐Ÿ“‹ What Needs Integration -- **IP Lookup**: `gcp.Client.LookupIP()` - ---- - -## Technical Decisions & Lessons Learned - -### โŒ What Didn't Work -1. **Complex Component/PageStack Architecture** - - **Issue**: Caused deadlock during initialization - - **Root Cause**: Likely circular dependency or goroutine contention - - **Files Affected**: `app.go`, `page_stack.go`, `components.go`, `instance_view.go` - - **Decision**: Abandoned in favor of direct implementation - -2. **Structured Logging in TUI** - - **Issue**: pterm debug output corrupted terminal display - - **Root Cause**: Logging to stderr while tview owns the terminal - - **Solution**: Removed logger calls from TUI code - -### โœ… What Works Well -1. **Direct tview.Application Usage** - - Simple, straightforward - - No goroutine complexity - - Fast initialization - - Easy to debug - -2. **Cache Integration** - - Instant data availability - - No network calls needed for initial display - - Works perfectly with existing cache structure - -3. **Minimal Dependencies** - - Only tview and tcell needed - - Clean separation from rest of codebase - - Easy to test and maintain - ---- - -## Current File Structure - -### Working Files โœ… -``` -internal/tui/ -โ”œโ”€โ”€ direct.go # Main TUI implementation with navigation -โ”œโ”€โ”€ vpn_view.go # VPN Inspector view -โ”œโ”€โ”€ connectivity_view.go # Connectivity Tests view -โ”œโ”€โ”€ search_view.go # Global resource search view -โ”œโ”€โ”€ actions.go # Shared actions (SSH, browse, details) -โ””โ”€โ”€ styles.go # Color schemes (partial use) - -cmd/ -โ””โ”€โ”€ interactive.go # CLI integration -``` - -### Deprecated Files โš ๏ธ (Can be deleted or refactored) -``` -internal/tui/ -โ”œโ”€โ”€ app.go # Complex app (312 lines) - CAUSES HANG -โ”œโ”€โ”€ app_simple.go # Intermediate (102 lines) - NOT USED -โ”œโ”€โ”€ components.go # Component interface (72 lines) - NOT USED -โ”œโ”€โ”€ keys.go # Key actions (98 lines) - NOT USED -โ”œโ”€โ”€ page_stack.go # Navigation stack (138 lines) - CAUSES DEADLOCK -โ”œโ”€โ”€ instance_view.go # Complex view (334 lines) - NOT USED -โ”œโ”€โ”€ help_view.go # Help screen (82 lines) - NOT USED -โ””โ”€โ”€ widgets/ - โ””โ”€โ”€ table.go # Custom table (102 lines) - NOT USED -``` - -**Recommendation**: Delete unused files to avoid confusion, or refactor them to work with the direct approach. - ---- - -## Testing the Current Implementation - -### How to Use -```bash -# Build -go build -o compass - -# Run interactive mode -./compass interactive - -# You should see: -# - A table with cached instances -# - Arrow key navigation -# - Status bar at bottom -# - Press Ctrl+C or 'q' to quit -``` - -### What You Can Do Now -- โœ… View all cached instances from all projects -- โœ… Navigate with arrow keys (or j/k vim style) -- โœ… See instance name, project, zone, and status -- โœ… Quit cleanly with Ctrl+C or 'q' -- โœ… Mouse click to select rows (mouse support enabled) - -### What Doesn't Work Yet -- โŒ Cannot SSH to instances (Enter does nothing) -- โŒ Cannot filter instances (/ does nothing) -- โŒ Cannot refresh data (r does nothing) -- โŒ Cannot view details (d does nothing) -- โŒ No help screen (? does nothing) -- โŒ No other views (VPN, connectivity, etc.) - ---- - -## Next Steps (Priority Order) - -### Immediate (Phase 2 - Enhanced Instance View) -1. **Add SSH capability** - Most important feature - - Detect if Enter is pressed - - Get selected instance from table - - Suspend TUI with `app.Suspend()` - - Execute gcloud SSH command - - Resume TUI after exit - -2. **Add filter mode** - Highly requested - - Create input field (hidden by default) - - Toggle visibility with `/` key - - Filter table rows based on input - - Clear filter with Esc - -3. **Add color-coded status** - Visual improvement - - Green for RUNNING - - Red for STOPPED/TERMINATED - - Yellow for other states - - Requires fetching live instance data (not just cache) - -4. **Add refresh capability** - Keep data fresh - - Press `r` to refresh - - Query GCP API for latest instance status - - Update table with new data - - Show loading indicator - -### Short-term (Phase 3 - Help System) -5. **Implement help overlay** - - Modal dialog showing shortcuts - - Triggered by `?` key - - Scrollable if needed - - Close with Esc or ? - -### Medium-term (Phases 4-7) -6. **Add VPN Inspector view** -7. **Add Connectivity Tests view** -8. **Add IP Lookup view** -9. **Add Project Manager view** - -### Long-term (Phases 8-10) -10. **Multi-view navigation system** -11. **Advanced features (export, bookmarks, etc.)** -12. **Configuration persistence** - ---- - -## Design Principles (Based on k9s) - -### UI/UX Patterns -- **Keyboard-first**: Everything accessible via keyboard -- **Vim-inspired**: j/k navigation, Esc to cancel, / for search -- **Color-coded status**: Green=good, Red=bad, Yellow=warning -- **Minimal chrome**: Focus on content, not decoration -- **Contextual help**: Always accessible with ? -- **Status hints**: Bottom bar shows available actions - -### Color Scheme (k9s-inspired) -```go -// From styles.go -Background: Black -Foreground: White -Border: DarkCyan -StatusOK: Green -StatusError: Red -StatusWarning: Yellow -StatusInfo: DodgerBlue -TableHeader: DarkCyan background, Black text -TableSelected: DarkCyan background, White text -Title: Aqua text -Breadcrumb: Gray text -``` - -### Keyboard Philosophy -- **Single-key actions**: Common actions use single keys (r, d, v, etc.) -- **Ctrl+Key for global**: Ctrl+C quit, Ctrl+R refresh -- **Shift+Key for sorting**: Shift+N sort by name, Shift+A sort by age -- **/ for filter/search**: Universal pattern -- **? for help**: Universal pattern -- **Esc for back/cancel**: Universal pattern - ---- - -## Performance Considerations - -### Current Implementation -- **Instant startup**: No network calls, uses cache only -- **Fast rendering**: tview is highly optimized -- **Low memory**: Only loads cached data (~KB for typical cache) -- **No goroutines**: Simplified direct approach avoids concurrency issues - -### Future Optimizations Needed -- **Background refresh**: Poll GCP APIs without blocking UI -- **Virtualized scrolling**: For large instance lists (1000+) -- **Debounced filtering**: Don't filter on every keystroke -- **Lazy loading**: Load details only when requested -- **Connection pooling**: Reuse GCP client connections - ---- - -## Dependencies - -### Added to go.mod -```go -require ( - github.com/rivo/tview v0.42.0 - github.com/gdamore/tcell/v2 v2.9.0 -) -``` - -### Transitive Dependencies (auto-added) -```go -github.com/gdamore/encoding v1.0.1 -github.com/lucasb-eyer/go-colorful v1.2.0 -github.com/mattn/go-runewidth v0.0.19 // Already present -github.com/rivo/uniseg v0.4.7 -``` - ---- - -## Known Issues & Workarounds - -### Issue 1: Complex App Architecture Deadlock โŒ -**Symptom**: Program hangs at "Creating TUI app..." and cannot be stopped with Ctrl+C - -**Root Cause**: The PageStack/Component architecture with goroutines and channels causes a deadlock during initialization. Likely related to: -- `pageStack.Push()` calling `component.Start()` which launches goroutines -- `component.Start()` calling `app.QueueUpdateDraw()` before app.Run() is called -- Circular dependencies in the initialization order - -**Workaround**: Use `RunDirect()` instead of the complex App structure - -**Future Fix Options**: -1. Refactor to remove goroutines from component initialization -2. Use channels instead of direct method calls -3. Lazy-initialize components only after app.Run() is called -4. Simplify the component lifecycle (remove Start/Stop) - -### Issue 2: Logging Corruption โŒ -**Symptom**: Terminal display is corrupted with debug log messages - -**Root Cause**: pterm writes to stderr which interferes with tcell's terminal control - -**Workaround**: Remove all logger calls from TUI code - -**Future Fix**: Redirect logs to a file when in TUI mode, or use a separate log view - -### Issue 3: No Live Instance Status โš ๏ธ -**Symptom**: All instances show "CACHED" status regardless of actual state - -**Root Cause**: TUI only reads from cache, doesn't query GCP API - -**Workaround**: None yet - this is expected behavior - -**Future Fix**: Add background refresh that queries GCP API for latest status - ---- - -## Success Criteria - -### MVP (Current) โœ… -- [x] TUI launches successfully -- [x] Displays cached instances -- [x] Keyboard navigation works -- [x] Can quit with Ctrl+C -- [x] Clean display without log corruption - -### Version 1.0 (Target) -- [ ] SSH to instances from TUI -- [ ] Filter instances by name/project/zone -- [ ] Refresh instance status from GCP -- [ ] Help screen with keyboard shortcuts -- [ ] Color-coded instance status -- [ ] Stable, no crashes or hangs - -### Version 2.0 (Future) -- [ ] VPN Inspector view -- [ ] Connectivity Tests view -- [ ] IP Lookup view -- [ ] Project Manager view -- [ ] Multi-view navigation -- [ ] Background auto-refresh - ---- - -## Timeline Estimate - -Based on simplified architecture: - -- **Phase 2** (Enhanced Instance View): 2-3 days - - SSH: 4-6 hours - - Filter: 3-4 hours - - Color status: 2-3 hours - - Refresh: 2-3 hours - - Testing: 4-6 hours - -- **Phase 3** (Help System): 1 day -- **Phase 4** (VPN Inspector): 2-3 days -- **Phase 5** (Connectivity Tests): 2-3 days -- **Phase 6** (IP Lookup): 1-2 days -- **Phase 7** (Project Manager): 1-2 days -- **Phase 8** (Navigation): 2-3 days -- **Phase 9** (Advanced Features): 3-5 days -- **Phase 10** (SSH Integration): Already included in Phase 2 - -**Total Estimate**: 14-23 days (3-5 weeks) for full implementation - -**MVP to v1.0**: 2-3 days (just Phase 2 + 3) - ---- - -## Documentation - -### User Documentation Needed -- [ ] README section for TUI mode -- [ ] Keyboard shortcut reference -- [ ] Screenshots/GIFs of TUI in action -- [ ] Comparison: CLI vs TUI use cases - -### Developer Documentation Needed -- [ ] Architecture overview (current simplified approach) -- [ ] How to add new views -- [ ] How to add keyboard shortcuts -- [ ] Testing guide - ---- - -## Conclusion - -The TUI implementation is **working at MVP level** using a simplified direct approach. The complex component-based architecture was causing deadlocks and has been abandoned in favor of a straightforward tview implementation. - -**Current State**: Multiple views are functional and stable. Instance list, VPN Inspector, Connectivity Tests, and Global Search views are all working. - -**Next Priority**: Phase 7 (IP Lookup) to add IP address search functionality. - -**Long-term Vision**: Full-featured TUI with multiple views (instances, VPN, connectivity tests, global search, IP lookup, project management) providing a comprehensive GCP management interface, similar to k9s for Kubernetes. - ---- - -*Last Updated: 2025-01-15* -*Status: Phase 6 Complete - Global Search Working with Smart Affinity Learning* diff --git a/cmd/ip_lookup.go b/cmd/ip_lookup.go index 405e7dd..2e4c4e8 100644 --- a/cmd/ip_lookup.go +++ b/cmd/ip_lookup.go @@ -250,7 +250,8 @@ func executeLookupAcrossClients(ctx context.Context, ip string, clients []*gcp.C return groupCtx.Err() } - associations, err := client.LookupIPAddress(groupCtx, ip) + // Use optimized lookup that leverages cached subnet information + associations, err := client.LookupIPAddressFast(groupCtx, ip) res := lookupResult{ client: client, diff --git a/internal/gcp/ip_lookup_optimized.go b/internal/gcp/ip_lookup_optimized.go new file mode 100644 index 0000000..0012c0f --- /dev/null +++ b/internal/gcp/ip_lookup_optimized.go @@ -0,0 +1,493 @@ +package gcp + +import ( + "context" + "fmt" + "net" + "sort" + "strings" + + "github.com/kedare/compass/internal/cache" + "golang.org/x/sync/errgroup" + "google.golang.org/api/compute/v1" +) + +// SubnetHint provides cached subnet information to optimize IP lookup +type SubnetHint struct { + Region string + Network string + Subnet string +} + +// LookupIPAddressFast performs an optimized IP lookup using cached subnet information +// to narrow the search scope and reduce API calls. Uses StrategySmartFallback by default: +// tries cache-optimized search first, falls back to full scan if no results found. +func (c *Client) LookupIPAddressFast(ctx context.Context, ip string) ([]IPAssociation, error) { + if c == nil || c.service == nil { + return nil, fmt.Errorf("client is not initialized") + } + + target := strings.TrimSpace(ip) + if target == "" { + return nil, fmt.Errorf("ip address is required") + } + + targetIP := net.ParseIP(target) + if targetIP == nil { + return nil, fmt.Errorf("invalid IP address %q", ip) + } + + if ctx == nil { + ctx = context.Background() + } + + if err := ctx.Err(); err != nil { + return nil, err + } + + // Use smart fallback strategy + return c.LookupIPAddressWithStrategy(ctx, ip, StrategySmartFallback) +} + +// getSubnetHintsFromCache retrieves cached subnet information for the given IP +func (c *Client) getSubnetHintsFromCache(targetIP net.IP) []SubnetHint { + if c.cache == nil { + return nil + } + + entries := c.cache.FindSubnetsForIP(targetIP) + if len(entries) == 0 { + return nil + } + + // Filter to only subnets in the current project + hints := make([]SubnetHint, 0, len(entries)) + for _, entry := range entries { + if entry == nil || entry.Project != c.project { + continue + } + + hints = append(hints, SubnetHint{ + Region: entry.Region, + Network: entry.Network, + Subnet: entry.Name, + }) + } + + return hints +} + +// lookupWithHints performs a targeted lookup using subnet hints +func (c *Client) lookupWithHints(ctx context.Context, target net.IP, canonical string, hints []SubnetHint) ([]IPAssociation, error) { + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(3) + + var ( + instanceResults []IPAssociation + forwardingResults []IPAssociation + addressResults []IPAssociation + subnetResults []IPAssociation + ) + + // Extract unique regions from hints + regions := make(map[string]struct{}) + networks := make(map[string]struct{}) + for _, hint := range hints { + regions[hint.Region] = struct{}{} + networks[hint.Network] = struct{}{} + } + + // Search instances in target regions only + group.Go(func() error { + results, err := c.collectInstanceIPMatchesTargeted(groupCtx, target, canonical, regions, networks) + if err != nil { + return fmt.Errorf("failed to inspect instances: %w", err) + } + instanceResults = results + return nil + }) + + // Search forwarding rules in target regions only + group.Go(func() error { + results, err := c.collectForwardingRuleMatchesTargeted(groupCtx, target, canonical, regions) + if err != nil { + return fmt.Errorf("failed to inspect forwarding rules: %w", err) + } + forwardingResults = results + return nil + }) + + // Search addresses in target regions only + group.Go(func() error { + results, err := c.collectAddressMatchesTargeted(groupCtx, target, canonical, regions) + if err != nil { + return fmt.Errorf("failed to inspect addresses: %w", err) + } + addressResults = results + return nil + }) + + // Add subnet matches from hints (already have this info) + group.Go(func() error { + results := c.buildSubnetAssociationsFromHints(target, canonical, hints) + subnetResults = results + return nil + }) + + if err := group.Wait(); err != nil { + return nil, err + } + + // Combine and deduplicate results + results := make([]IPAssociation, 0, len(instanceResults)+len(forwardingResults)+len(addressResults)+len(subnetResults)) + seen := make(map[string]struct{}) + + for _, association := range instanceResults { + appendAssociation(&results, seen, association) + } + + for _, association := range forwardingResults { + appendAssociation(&results, seen, association) + } + + for _, association := range addressResults { + appendAssociation(&results, seen, association) + } + + for _, association := range subnetResults { + appendAssociation(&results, seen, association) + } + + sort.Slice(results, func(i, j int) bool { + if results[i].Project == results[j].Project { + if results[i].Kind == results[j].Kind { + return results[i].Resource < results[j].Resource + } + return results[i].Kind < results[j].Kind + } + return results[i].Project < results[j].Project + }) + + return results, nil +} + +// collectInstanceIPMatchesTargeted searches instances only in specified regions +func (c *Client) collectInstanceIPMatchesTargeted(ctx context.Context, target net.IP, canonical string, regions, networks map[string]struct{}) ([]IPAssociation, error) { + results := make([]IPAssociation, 0) + seen := make(map[string]struct{}) + + // Use aggregated list but filter early + call := c.service.Instances.AggregatedList(c.project).Context(ctx) + + err := call.Pages(ctx, func(page *compute.InstanceAggregatedList) error { + if err := ctx.Err(); err != nil { + return err + } + + for scope, scopedList := range page.Items { + if err := ctx.Err(); err != nil { + return err + } + + if scopedList.Instances == nil { + continue + } + + location := locationFromScope(scope) + + // Skip if not in target regions + if len(regions) > 0 { + if _, ok := regions[location]; !ok { + continue + } + } + + for _, inst := range scopedList.Instances { + if err := ctx.Err(); err != nil { + return err + } + + if inst == nil { + continue + } + + // Pre-filter by network if we have network hints + if len(networks) > 0 { + hasMatchingNetwork := false + for _, nic := range inst.NetworkInterfaces { + if nic != nil { + networkName := lastComponent(nic.Network) + if _, ok := networks[networkName]; ok { + hasMatchingNetwork = true + break + } + } + } + if !hasMatchingNetwork { + continue + } + } + + if matches := instanceIPMatches(inst, target); len(matches) > 0 { + for _, match := range matches { + association := IPAssociation{ + Project: c.project, + Kind: match.kind, + Resource: inst.Name, + Location: location, + IPAddress: canonical, + Details: match.details, + ResourceLink: inst.SelfLink, + } + appendAssociation(&results, seen, association) + } + } + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + return results, nil +} + +// collectForwardingRuleMatchesTargeted searches forwarding rules only in specified regions +func (c *Client) collectForwardingRuleMatchesTargeted(ctx context.Context, target net.IP, canonical string, regions map[string]struct{}) ([]IPAssociation, error) { + results := make([]IPAssociation, 0) + seen := make(map[string]struct{}) + + call := c.service.ForwardingRules.AggregatedList(c.project).Context(ctx) + + err := call.Pages(ctx, func(page *compute.ForwardingRuleAggregatedList) error { + if err := ctx.Err(); err != nil { + return err + } + + for scope, scopedList := range page.Items { + if err := ctx.Err(); err != nil { + return err + } + + if len(scopedList.ForwardingRules) == 0 { + continue + } + + location := locationFromScope(scope) + + // Skip if not in target regions + if len(regions) > 0 { + if _, ok := regions[location]; !ok { + continue + } + } + + for _, rule := range scopedList.ForwardingRules { + if err := ctx.Err(); err != nil { + return err + } + + if rule == nil || !equalIP(rule.IPAddress, target) { + continue + } + + association := IPAssociation{ + Project: c.project, + Kind: IPAssociationForwardingRule, + Resource: rule.Name, + Location: location, + IPAddress: canonical, + Details: describeForwardingRule(rule), + ResourceLink: rule.SelfLink, + } + appendAssociation(&results, seen, association) + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + return results, nil +} + +// collectAddressMatchesTargeted searches addresses only in specified regions +func (c *Client) collectAddressMatchesTargeted(ctx context.Context, target net.IP, canonical string, regions map[string]struct{}) ([]IPAssociation, error) { + results := make([]IPAssociation, 0) + seen := make(map[string]struct{}) + + call := c.service.Addresses.AggregatedList(c.project).Context(ctx) + + err := call.Pages(ctx, func(page *compute.AddressAggregatedList) error { + if err := ctx.Err(); err != nil { + return err + } + + for scope, scopedList := range page.Items { + if err := ctx.Err(); err != nil { + return err + } + + if len(scopedList.Addresses) == 0 { + continue + } + + location := locationFromScope(scope) + + // Skip if not in target regions + if len(regions) > 0 { + if _, ok := regions[location]; !ok { + continue + } + } + + for _, addr := range scopedList.Addresses { + if err := ctx.Err(); err != nil { + return err + } + + if addr == nil || !equalIP(addr.Address, target) { + continue + } + + association := IPAssociation{ + Project: c.project, + Kind: IPAssociationAddress, + Resource: addr.Name, + Location: location, + IPAddress: canonical, + Details: describeAddress(addr), + ResourceLink: addr.SelfLink, + } + appendAssociation(&results, seen, association) + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + return results, nil +} + +// buildSubnetAssociationsFromHints creates subnet associations from cached hints +func (c *Client) buildSubnetAssociationsFromHints(target net.IP, canonical string, hints []SubnetHint) []IPAssociation { + results := make([]IPAssociation, 0, len(hints)) + seen := make(map[string]struct{}) + + for _, hint := range hints { + // Get full subnet details from cache if available + if c.cache != nil { + if entry := c.getSubnetEntryFromCache(hint); entry != nil { + matched, detail := c.subnetMatchDetailsFromEntry(entry, target) + if matched { + association := IPAssociation{ + Project: c.project, + Kind: IPAssociationSubnet, + Resource: entry.Name, + Location: entry.Region, + IPAddress: canonical, + Details: detail, + ResourceLink: entry.SelfLink, + } + appendAssociation(&results, seen, association) + } + } + } + } + + return results +} + +// getSubnetEntryFromCache retrieves a specific subnet entry from cache +func (c *Client) getSubnetEntryFromCache(hint SubnetHint) *cache.SubnetEntry { + if c.cache == nil { + return nil + } + + // Search through cached subnets for the IP again, but filter by hint + // This is still fast because it's using the cache + entries := c.cache.FindSubnetsForIP(net.ParseIP("0.0.0.0")) // Placeholder - we need all subnets + for _, entry := range entries { + if entry != nil && + entry.Project == c.project && + entry.Region == hint.Region && + entry.Network == hint.Network && + entry.Name == hint.Subnet { + return entry + } + } + + return nil +} + +// subnetMatchDetailsFromEntry checks if IP matches subnet entry and returns details +func (c *Client) subnetMatchDetailsFromEntry(entry *cache.SubnetEntry, target net.IP) (bool, string) { + if entry == nil || target == nil { + return false, "" + } + + var ( + matchedCIDR string + source string + ) + + // Check primary CIDR + if entry.PrimaryCIDR != "" && ipInCIDR(target, entry.PrimaryCIDR) { + matchedCIDR = entry.PrimaryCIDR + source = "primary" + } + + // Check secondary ranges + if matchedCIDR == "" { + for _, sec := range entry.SecondaryRanges { + if sec.CIDR != "" && ipInCIDR(target, sec.CIDR) { + matchedCIDR = sec.CIDR + if sec.Name != "" { + source = fmt.Sprintf("secondary:%s", sec.Name) + } else { + source = "secondary" + } + break + } + } + } + + // Check IPv6 + if matchedCIDR == "" && entry.IPv6CIDR != "" && ipInCIDR(target, entry.IPv6CIDR) { + matchedCIDR = entry.IPv6CIDR + source = "ipv6" + } + + if matchedCIDR == "" { + return false, "" + } + + detailParts := make([]string, 0, 4) + if entry.Network != "" { + detailParts = append(detailParts, fmt.Sprintf("network=%s", entry.Network)) + } + + detailParts = append(detailParts, fmt.Sprintf("cidr=%s", matchedCIDR)) + if source != "" { + detailParts = append(detailParts, fmt.Sprintf("range=%s", source)) + } + + if entry.Gateway != "" { + detailParts = append(detailParts, fmt.Sprintf("gateway_ip=%s", entry.Gateway)) + if equalIP(entry.Gateway, target) { + detailParts = append(detailParts, "gateway=true") + } + } + + return true, strings.Join(detailParts, ", ") +} diff --git a/internal/gcp/ip_lookup_strategy.go b/internal/gcp/ip_lookup_strategy.go new file mode 100644 index 0000000..fd1bd71 --- /dev/null +++ b/internal/gcp/ip_lookup_strategy.go @@ -0,0 +1,101 @@ +package gcp + +import ( + "context" + "net" + "strings" + + "github.com/kedare/compass/internal/logger" +) + +// IPLookupStrategy defines how to balance speed vs completeness in IP lookups +type IPLookupStrategy int + +const ( + // StrategyFastOnly uses only cache-optimized search (fastest, may miss results if cache stale) + StrategyFastOnly IPLookupStrategy = iota + + // StrategySmartFallback uses fast search first, falls back to full scan if nothing found (recommended) + StrategySmartFallback + + // StrategyAlwaysComplete always does full scan (slowest, most thorough) + StrategyAlwaysComplete +) + +// LookupIPAddressWithStrategy performs IP lookup using the specified strategy +func (c *Client) LookupIPAddressWithStrategy(ctx context.Context, ip string, strategy IPLookupStrategy) ([]IPAssociation, error) { + target := strings.TrimSpace(ip) + if target == "" { + return nil, nil + } + + targetIP := net.ParseIP(target) + if targetIP == nil { + return nil, nil + } + + switch strategy { + case StrategyFastOnly: + return c.lookupFastOnly(ctx, ip, targetIP) + + case StrategySmartFallback: + return c.lookupSmartFallback(ctx, ip, targetIP) + + case StrategyAlwaysComplete: + return c.LookupIPAddress(ctx, ip) + + default: + return c.lookupSmartFallback(ctx, ip, targetIP) + } +} + +// lookupFastOnly uses only cache-optimized search +func (c *Client) lookupFastOnly(ctx context.Context, ip string, targetIP net.IP) ([]IPAssociation, error) { + // For private IPs, try cache-optimized search + if targetIP.IsPrivate() && c.cache != nil { + hints := c.getSubnetHintsFromCache(targetIP) + if len(hints) > 0 { + logger.Log.Debugf("[%s] Fast-only strategy: found %d subnet hints", c.project, len(hints)) + return c.lookupWithHints(ctx, targetIP, targetIP.String(), hints) + } + logger.Log.Debugf("[%s] Fast-only strategy: no cache hints, returning empty", c.project) + return nil, nil + } + + // For public IPs, must do full scan + logger.Log.Debugf("[%s] Public IP, using full scan", c.project) + return c.LookupIPAddress(ctx, ip) +} + +// lookupSmartFallback tries fast first, falls back to full scan if nothing found +func (c *Client) lookupSmartFallback(ctx context.Context, ip string, targetIP net.IP) ([]IPAssociation, error) { + // For private IPs with cache hints, try optimized search first + if targetIP.IsPrivate() && c.cache != nil { + hints := c.getSubnetHintsFromCache(targetIP) + if len(hints) > 0 { + logger.Log.Debugf("[%s] Smart fallback: trying fast search with %d hints", c.project, len(hints)) + results, err := c.lookupWithHints(ctx, targetIP, targetIP.String(), hints) + if err != nil { + return nil, err + } + + if len(results) > 0 { + logger.Log.Debugf("[%s] Smart fallback: found %d results, skipping full scan", c.project, len(results)) + return results, nil + } + + // No results from fast search - fall back to full scan + logger.Log.Debugf("[%s] Smart fallback: no results from fast search, trying full scan", c.project) + return c.LookupIPAddress(ctx, ip) + } + } + + // No hints available or public IP - use standard lookup + logger.Log.Debugf("[%s] No cache hints available, using full scan", c.project) + return c.LookupIPAddress(ctx, ip) +} + +// DefaultStrategy returns the recommended strategy for most use cases +func DefaultStrategy() IPLookupStrategy { + return StrategySmartFallback +} diff --git a/internal/gcp/search/vpn_tunnels.go b/internal/gcp/search/vpn_tunnels.go index a5c208e..93f79f0 100644 --- a/internal/gcp/search/vpn_tunnels.go +++ b/internal/gcp/search/vpn_tunnels.go @@ -81,6 +81,9 @@ func vpnTunnelDetails(tunnel *gcp.VPNTunnelInfo) map[string]string { if tunnel.GatewayLink != "" { parts := strings.Split(tunnel.GatewayLink, "/") details["gateway"] = parts[len(parts)-1] + details["isHA"] = "true" // HA VPN tunnel + } else if tunnel.TargetGatewayLink != "" { + details["isHA"] = "false" // Classic VPN tunnel } return details diff --git a/internal/tui/actions.go b/internal/tui/actions.go index 7f23f89..f751d61 100644 --- a/internal/tui/actions.go +++ b/internal/tui/actions.go @@ -1031,13 +1031,19 @@ func GetCloudConsoleURL(resourceType, name, project, location string, details ma return buildCloudConsoleURL(path.Join("compute/snapshotsDetail/projects", project, "global/snapshots", name), project) case string(search.KindForwardingRule): - return buildCloudConsoleURL(path.Join("net-services/loadbalancing/advanced/forwardingRules/details", location, name), project) + if location == "global" { + return buildCloudConsoleURL(path.Join("net-services/loadbalancing/advanced/globalForwardingRules/details", name), project) + } + return buildCloudConsoleURL(path.Join("net-services/loadbalancing/advanced/forwardingRules/details/regions", location, "forwardingRules", name), project) case string(search.KindBackendService): return buildCloudConsoleURL(path.Join("net-services/loadbalancing/advanced/backendServices/details", name), project) case string(search.KindHealthCheck): - return buildCloudConsoleURL("compute/healthChecks", project) + if location == "" || location == "global" { + return buildCloudConsoleURL(path.Join("compute/healthChecks/details", name), project) + } + return buildCloudConsoleURL(path.Join("compute/healthChecks/details/regions", location, name), project) case string(search.KindURLMap): return buildCloudConsoleURL(path.Join("net-services/loadbalancing/advanced/urlMaps/details", name), project) @@ -1052,14 +1058,22 @@ func GetCloudConsoleURL(resourceType, name, project, location string, details ma return buildCloudConsoleURL(path.Join("networking/firewalls/details", name), project) case string(search.KindVPNGateway): - return buildCloudConsoleURL(path.Join("hybrid/vpn/gateways/details", location, name), project) + return buildCloudConsoleURL(path.Join("hybrid/vpn/gateways/details", location, name), project) + "&isHA=true" case string(search.KindVPNTunnel): - return buildCloudConsoleURL(path.Join("hybrid/vpn/tunnels/details", location, name), project) + baseURL := buildCloudConsoleURL(path.Join("hybrid/vpn/tunnels/details", location, name), project) + // Add isHA=true for HA VPN tunnels (not Classic VPN) + if details["isHA"] == "true" { + return baseURL + "&isHA=true" + } + return baseURL case string(search.KindConnectivityTest): return buildCloudConsoleURL(path.Join("net-intelligence/connectivity/tests/details", name), project) + case string(search.KindRoute): + return buildCloudConsoleURL(path.Join("networking/routes/details", name), project) + default: return buildCloudConsoleURL("home/dashboard", project) } diff --git a/internal/tui/ip_lookup_view.go b/internal/tui/ip_lookup_view.go index 6a6c0ac..178a139 100644 --- a/internal/tui/ip_lookup_view.go +++ b/internal/tui/ip_lookup_view.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "net" "sort" "strings" "sync" @@ -14,6 +15,14 @@ import ( "github.com/rivo/tview" ) +// Status bar text constants for IP lookup view +const ( + ipStatusDefault = " [yellow]Enter[-] lookup [yellow]Esc[-] back [yellow]/[-] filter results [yellow]d[-] details [yellow]?[-] help" + ipStatusSearching = " [yellow]Looking up IP...[-] [yellow]Esc[-] cancel" + ipStatusFilterPrompt = " [yellow]Filter: spaces=AND |=OR -=NOT (e.g. \"web|api -dev\") Enter to apply, Esc to cancel[-]" + ipStatusHelpClose = " [yellow]Esc[-] back [yellow]?[-] close help" +) + // ipLookupEntry represents a row in the IP lookup results table type ipLookupEntry struct { Kind string @@ -25,38 +34,108 @@ type ipLookupEntry struct { ResourceLink string } +// ipLookupViewState encapsulates all state for the IP lookup view +type ipLookupViewState struct { + ctx context.Context + cache *cache.Cache + app *tview.Application + parallelism int + onBack func() + + // Data + allProjects []string + allResults []ipLookupEntry + resultsMu sync.Mutex + + // UI components + ipInput *tview.InputField + table *tview.Table + filterInput *tview.InputField + status *tview.TextView + progressText *tview.TextView + flex *tview.Flex + statusFlex *tview.Flex + + // State flags + isSearching bool + searchCancel context.CancelFunc + modalOpen bool + currentFilter string + filterMode bool +} + +// getPreferredProjectsForIP returns projects whose cached subnets contain the IP +func getPreferredProjectsForIP(c *cache.Cache, ip net.IP) []string { + if ip == nil || c == nil { + return nil + } + + entries := c.FindSubnetsForIP(ip) + if len(entries) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(entries)) + ordered := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry == nil { + continue + } + + projectID := strings.TrimSpace(entry.Project) + if projectID == "" { + continue + } + + key := strings.ToLower(projectID) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + ordered = append(ordered, projectID) + } + + return ordered +} + // RunIPLookupView shows the IP lookup interface with progressive results func RunIPLookupView(ctx context.Context, c *cache.Cache, app *tview.Application, parallelism int, onBack func()) error { - var allResults []ipLookupEntry - var resultsMu sync.Mutex - var isSearching bool - var searchCancel context.CancelFunc - var modalOpen bool - var currentFilter string - var filterMode bool - - // Get projects from cache for lookup - projects := c.GetProjectsByUsage() - if len(projects) == 0 { + allProjects := c.GetProjectsByUsage() + if len(allProjects) == 0 { return fmt.Errorf("no projects in cache") } - // Create UI components - ipInput := tview.NewInputField(). + s := &ipLookupViewState{ + ctx: ctx, + cache: c, + app: app, + parallelism: parallelism, + onBack: onBack, + allProjects: allProjects, + } + + s.setupUI() + s.setupKeyboardHandlers() + + app.SetRoot(s.flex, true).EnableMouse(true).SetFocus(s.ipInput) + return nil +} + +// setupUI initializes all UI components +func (s *ipLookupViewState) setupUI() { + s.ipInput = tview.NewInputField(). SetLabel(" IP Address: "). SetFieldWidth(0). SetFieldBackgroundColor(tcell.ColorBlack). SetLabelColor(tcell.ColorYellow). SetPlaceholder("Enter IP address and press Enter") - table := tview.NewTable(). + s.table = tview.NewTable(). SetBorders(false). SetSelectable(true, false). SetFixed(1, 0) + s.table.SetBorder(true).SetTitle(" IP Lookup Results (0) ") - table.SetBorder(true).SetTitle(" IP Lookup Results (0) ") - - // Add header headers := []string{"Kind", "Resource", "Project", "Location", "Details"} for col, header := range headers { cell := tview.NewTableCell(header). @@ -64,528 +143,531 @@ func RunIPLookupView(ctx context.Context, c *cache.Cache, app *tview.Application SetBackgroundColor(tcell.ColorDarkCyan). SetSelectable(false). SetExpansion(1) - table.SetCell(0, col, cell) + s.table.SetCell(0, col, cell) } - // Filter input - filterInput := tview.NewInputField(). + s.filterInput = tview.NewInputField(). SetLabel(" Filter: "). SetFieldWidth(0). SetFieldBackgroundColor(tcell.ColorBlack). SetLabelColor(tcell.ColorYellow) - // Status bar - status := tview.NewTextView(). + s.status = tview.NewTextView(). SetDynamicColors(true). - SetText(" [yellow]Enter[-] lookup [yellow]Esc[-] back [yellow]/[-] filter results [yellow]d[-] details [yellow]?[-] help") + SetText(ipStatusDefault) - // Progress indicator - progressText := tview.NewTextView(). + s.progressText = tview.NewTextView(). SetDynamicColors(true). SetTextAlign(tview.AlignRight) - // Layout - IP input at top, table in middle, progress/status at bottom - statusFlex := tview.NewFlex(). + s.statusFlex = tview.NewFlex(). SetDirection(tview.FlexColumn). - AddItem(status, 0, 3, false). - AddItem(progressText, 0, 1, false) + AddItem(s.status, 0, 3, false). + AddItem(s.progressText, 0, 1, false) - flex := tview.NewFlex(). + s.flex = tview.NewFlex(). SetDirection(tview.FlexRow). - AddItem(ipInput, 1, 0, true). - AddItem(table, 0, 1, false). - AddItem(statusFlex, 1, 0, false) - - // Function to rebuild layout with or without filter - rebuildLayout := func(showFilter bool, focusTable bool) { - flex.Clear() - flex.AddItem(ipInput, 1, 0, !showFilter && !focusTable) - if showFilter { - flex.AddItem(filterInput, 1, 0, true) - } - flex.AddItem(table, 0, 1, focusTable) - flex.AddItem(statusFlex, 1, 0, false) - } - - // Function to get kind color - getKindColor := func(kind string) string { - switch kind { - case string(gcp.IPAssociationInstanceInternal), string(gcp.IPAssociationInstanceExternal): - return "blue" - case string(gcp.IPAssociationForwardingRule): - return "cyan" - case string(gcp.IPAssociationAddress): - return "green" - case string(gcp.IPAssociationSubnet): - return "magenta" - default: - return "white" + AddItem(s.ipInput, 1, 0, true). + AddItem(s.table, 0, 1, false). + AddItem(s.statusFlex, 1, 0, false) + + s.table.SetSelectionChangedFunc(func(row, column int) { + if !s.modalOpen && !s.filterMode { + s.updateStatusWithActions() } - } + }) - // Shared function to update table with given results - var updateTableWithData = func(filter string, results []ipLookupEntry) { - currentSelectedRow, _ := table.GetSelection() - var selectedKey string - if currentSelectedRow > 0 && currentSelectedRow < table.GetRowCount() { - resourceCell := table.GetCell(currentSelectedRow, 1) - projectCell := table.GetCell(currentSelectedRow, 2) - locationCell := table.GetCell(currentSelectedRow, 3) - if resourceCell != nil && projectCell != nil && locationCell != nil { - selectedKey = resourceCell.Text + "|" + projectCell.Text + "|" + locationCell.Text + s.ipInput.SetDoneFunc(func(key tcell.Key) { + switch key { + case tcell.KeyEnter: + ipAddress := strings.TrimSpace(s.ipInput.GetText()) + if ipAddress != "" { + s.app.SetFocus(s.table) + go s.performLookup(ipAddress) + } + case tcell.KeyEscape: + if s.isSearching && s.searchCancel != nil { + s.searchCancel() + } else { + s.onBack() } } + }) - for row := table.GetRowCount() - 1; row > 0; row-- { - table.RemoveRow(row) + s.filterInput.SetDoneFunc(func(key tcell.Key) { + switch key { + case tcell.KeyEnter: + s.currentFilter = s.filterInput.GetText() + s.updateTable(s.currentFilter) + s.exitFilterMode() + case tcell.KeyEscape: + s.filterInput.SetText(s.currentFilter) + s.exitFilterMode() } + }) +} - filterExpr := parseFilter(filter) - currentRow := 1 - matchCount := 0 - newSelectedRow := -1 +// setupKeyboardHandlers sets up all keyboard event handlers +func (s *ipLookupViewState) setupKeyboardHandlers() { + s.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if s.modalOpen && event.Key() != tcell.KeyCtrlC { + return event + } + if s.filterMode { + return event + } + if s.app.GetFocus() == s.ipInput { + return event + } - for _, entry := range results { - if !filterExpr.matches(entry.Resource, entry.Project, entry.Location, entry.Kind, entry.Details) { - continue - } + switch event.Key() { + case tcell.KeyEscape: + return s.handleEscape() + case tcell.KeyEnter: + s.app.SetFocus(s.ipInput) + return nil + case tcell.KeyRune: + return s.handleRuneKey(event.Rune()) + } + return event + }) +} - kindColor := getKindColor(entry.Kind) - // Truncate details for display - displayDetails := entry.Details - if len(displayDetails) > 50 { - displayDetails = displayDetails[:47] + "..." - } +// handleEscape handles the Escape key +func (s *ipLookupViewState) handleEscape() *tcell.EventKey { + if s.isSearching && s.searchCancel != nil { + s.searchCancel() + return nil + } + if s.currentFilter != "" { + s.currentFilter = "" + s.filterInput.SetText("") + s.updateTable("") + s.showTemporaryStatus(" [yellow]Filter cleared[-]") + return nil + } + if s.searchCancel != nil { + s.searchCancel() + } + s.onBack() + return nil +} - table.SetCell(currentRow, 0, tview.NewTableCell(fmt.Sprintf("[%s]%s[-]", kindColor, entry.Kind)).SetExpansion(1)) - table.SetCell(currentRow, 1, tview.NewTableCell(entry.Resource).SetExpansion(1)) - table.SetCell(currentRow, 2, tview.NewTableCell(entry.Project).SetExpansion(1)) - table.SetCell(currentRow, 3, tview.NewTableCell(entry.Location).SetExpansion(1)) - table.SetCell(currentRow, 4, tview.NewTableCell(displayDetails).SetExpansion(2)) +// handleRuneKey handles character key presses +func (s *ipLookupViewState) handleRuneKey(r rune) *tcell.EventKey { + switch r { + case '/': + s.enterFilterMode() + case 'd': + s.showDetails() + case 'b': + s.openInBrowser() + case '?': + s.showHelp() + default: + return &tcell.EventKey{} + } + return nil +} - if selectedKey != "" && newSelectedRow == -1 { - rowKey := entry.Resource + "|" + entry.Project + "|" + entry.Location - if rowKey == selectedKey { - newSelectedRow = currentRow - } - } +// enterFilterMode enters filter/search mode +func (s *ipLookupViewState) enterFilterMode() { + s.filterMode = true + s.filterInput.SetText(s.currentFilter) + s.rebuildLayout(true, false) + s.app.SetFocus(s.filterInput) + s.status.SetText(ipStatusFilterPrompt) +} - currentRow++ - matchCount++ - } +// exitFilterMode exits filter mode and restores normal view +func (s *ipLookupViewState) exitFilterMode() { + s.filterMode = false + s.rebuildLayout(false, true) + s.app.SetFocus(s.table) + s.updateStatusWithActions() +} - if filter != "" { - table.SetTitle(fmt.Sprintf(" IP Lookup Results (%d/%d matched) ", matchCount, len(results))) - } else { - table.SetTitle(fmt.Sprintf(" IP Lookup Results (%d) ", len(results))) - } +// rebuildLayout rebuilds the flex layout +func (s *ipLookupViewState) rebuildLayout(showFilter bool, focusTable bool) { + s.flex.Clear() + s.flex.AddItem(s.ipInput, 1, 0, !showFilter && !focusTable) + if showFilter { + s.flex.AddItem(s.filterInput, 1, 0, true) + } + s.flex.AddItem(s.table, 0, 1, focusTable) + s.flex.AddItem(s.statusFlex, 1, 0, false) +} - if matchCount > 0 && table.GetRowCount() > 1 { - if newSelectedRow > 0 { - table.Select(newSelectedRow, 0) - } else if currentSelectedRow > 0 && currentSelectedRow < table.GetRowCount() { - table.Select(currentSelectedRow, 0) - } else if currentSelectedRow >= table.GetRowCount() && table.GetRowCount() > 1 { - table.Select(table.GetRowCount()-1, 0) - } else if currentSelectedRow == 0 { - table.Select(1, 0) - } +// updateTable updates the table with current results and filter +func (s *ipLookupViewState) updateTable(filter string) { + s.resultsMu.Lock() + results := make([]ipLookupEntry, len(s.allResults)) + copy(results, s.allResults) + s.resultsMu.Unlock() + + s.updateTableWithData(filter, results) +} + +// updateTableWithData updates the table display with given results +func (s *ipLookupViewState) updateTableWithData(filter string, results []ipLookupEntry) { + currentSelectedRow, _ := s.table.GetSelection() + var selectedKey string + if currentSelectedRow > 0 && currentSelectedRow < s.table.GetRowCount() { + resourceCell := s.table.GetCell(currentSelectedRow, 1) + projectCell := s.table.GetCell(currentSelectedRow, 2) + locationCell := s.table.GetCell(currentSelectedRow, 3) + if resourceCell != nil && projectCell != nil && locationCell != nil { + selectedKey = resourceCell.Text + "|" + projectCell.Text + "|" + locationCell.Text } } - // Function to update table with current results (copies data to avoid holding lock) - updateTable := func(filter string) { - resultsMu.Lock() - results := make([]ipLookupEntry, len(allResults)) - copy(results, allResults) - resultsMu.Unlock() - - updateTableWithData(filter, results) + for row := s.table.GetRowCount() - 1; row > 0; row-- { + s.table.RemoveRow(row) } - // Alias for updateTable - both copy data safely - updateTableNoLock := updateTable + filterExpr := parseFilter(filter) + currentRow := 1 + matchCount := 0 + newSelectedRow := -1 - // Helper function to get the currently selected entry - getSelectedEntry := func() *ipLookupEntry { - row, _ := table.GetSelection() - if row <= 0 { - return nil + for _, entry := range results { + if !filterExpr.matches(entry.Resource, entry.Project, entry.Location, entry.Kind, entry.Details) { + continue } - resultsMu.Lock() - defer resultsMu.Unlock() + kindColor := getKindColor(entry.Kind) + displayDetails := entry.Details + if len(displayDetails) > 50 { + displayDetails = displayDetails[:47] + "..." + } - expr := parseFilter(currentFilter) - visibleIdx := 0 - for i := range allResults { - entry := &allResults[i] - if !expr.matches(entry.Resource, entry.Project, entry.Location, entry.Kind, entry.Details) { - continue - } - visibleIdx++ - if visibleIdx == row { - return entry + s.table.SetCell(currentRow, 0, tview.NewTableCell(fmt.Sprintf("[%s]%s[-]", kindColor, entry.Kind)).SetExpansion(1)) + s.table.SetCell(currentRow, 1, tview.NewTableCell(entry.Resource).SetExpansion(1)) + s.table.SetCell(currentRow, 2, tview.NewTableCell(entry.Project).SetExpansion(1)) + s.table.SetCell(currentRow, 3, tview.NewTableCell(entry.Location).SetExpansion(1)) + s.table.SetCell(currentRow, 4, tview.NewTableCell(displayDetails).SetExpansion(2)) + + if selectedKey != "" && newSelectedRow == -1 { + rowKey := entry.Resource + "|" + entry.Project + "|" + entry.Location + if rowKey == selectedKey { + newSelectedRow = currentRow } } - return nil - } - // Helper function to update status bar with context-aware actions - updateStatusWithActions := func() { - resultsMu.Lock() - count := len(allResults) - resultsMu.Unlock() + currentRow++ + matchCount++ + } - entry := getSelectedEntry() + if filter != "" { + s.table.SetTitle(fmt.Sprintf(" IP Lookup Results (%d/%d matched) ", matchCount, len(results))) + } else { + s.table.SetTitle(fmt.Sprintf(" IP Lookup Results (%d) ", len(results))) + } - // During search, show search-specific status - if isSearching { - status.SetText(" [yellow]Looking up IP...[-] [yellow]Esc[-] cancel") - return - } - - if entry == nil { - if count > 0 { - status.SetText(fmt.Sprintf(" [green]%d results[-] [yellow]Enter[-] lookup [yellow]/[-] filter [yellow]Esc[-] back [yellow]?[-] help", count)) - } else { - status.SetText(" [yellow]Enter[-] lookup [yellow]/[-] filter [yellow]Esc[-] back [yellow]?[-] help") - } - return + if matchCount > 0 && s.table.GetRowCount() > 1 { + if newSelectedRow > 0 { + s.table.Select(newSelectedRow, 0) + } else if currentSelectedRow > 0 && currentSelectedRow < s.table.GetRowCount() { + s.table.Select(currentSelectedRow, 0) + } else if currentSelectedRow >= s.table.GetRowCount() && s.table.GetRowCount() > 1 { + s.table.Select(s.table.GetRowCount()-1, 0) + } else if currentSelectedRow == 0 { + s.table.Select(1, 0) } + } +} - actionStr := "[yellow]d[-] details [yellow]b[-] browser" - if currentFilter != "" { - status.SetText(fmt.Sprintf(" [green]Filter: %s[-] %s [yellow]/[-] edit [yellow]Esc[-] clear [yellow]?[-] help", currentFilter, actionStr)) - } else { - status.SetText(fmt.Sprintf(" %s [yellow]Enter[-] lookup [yellow]/[-] filter [yellow]Esc[-] back [yellow]?[-] help", actionStr)) - } +// getSelectedEntry returns the currently selected entry +func (s *ipLookupViewState) getSelectedEntry() *ipLookupEntry { + row, _ := s.table.GetSelection() + if row <= 0 { + return nil } - // Update status when selection changes - table.SetSelectionChangedFunc(func(row, column int) { - if !modalOpen && !filterMode { - updateStatusWithActions() - } - }) + s.resultsMu.Lock() + defer s.resultsMu.Unlock() - // Function to perform IP lookup across projects - performLookup := func(ipAddress string) { - if ipAddress == "" { - return + expr := parseFilter(s.currentFilter) + visibleIdx := 0 + for i := range s.allResults { + entry := &s.allResults[i] + if !expr.matches(entry.Resource, entry.Project, entry.Location, entry.Kind, entry.Details) { + continue } - - // Cancel any existing lookup - if searchCancel != nil { - searchCancel() + visibleIdx++ + if visibleIdx == row { + return entry } + } + return nil +} - // Clear previous results - resultsMu.Lock() - allResults = []ipLookupEntry{} - resultsMu.Unlock() - - lookupCtx, cancel := context.WithCancel(ctx) - searchCancel = cancel - isSearching = true - - // Track progress - var completedProjects int - var totalProjects = len(projects) - var progressMu sync.Mutex - - // Spinner animation - spinnerFrames := []string{"โ ‹", "โ ™", "โ น", "โ ธ", "โ ผ", "โ ด", "โ ฆ", "โ ง", "โ ‡", "โ "} - spinnerIdx := 0 - spinnerDone := make(chan bool, 2) - - // Start spinner goroutine - go func() { - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-ticker.C: - resultsMu.Lock() - count := len(allResults) - resultsMu.Unlock() - progressMu.Lock() - completed := completedProjects - progressMu.Unlock() - frame := spinnerFrames[spinnerIdx] - spinnerIdx = (spinnerIdx + 1) % len(spinnerFrames) - app.QueueUpdateDraw(func() { - if isSearching { - progressText.SetText(fmt.Sprintf("[yellow]%s %d/%d projects | %d results[-]", frame, completed, totalProjects, count)) - status.SetText(fmt.Sprintf(" [yellow]Looking up IP... (%d/%d projects)[-] [yellow]Esc[-] cancel", completed, totalProjects)) - } - }) - case <-spinnerDone: - return - case <-lookupCtx.Done(): - return - } - } - }() - - // Update initial status - app.QueueUpdateDraw(func() { - updateStatusWithActions() - progressText.SetText("[yellow]Starting IP lookup...[-]") - }) +// updateStatusWithActions updates the status bar with context-aware actions +func (s *ipLookupViewState) updateStatusWithActions() { + s.resultsMu.Lock() + count := len(s.allResults) + s.resultsMu.Unlock() - // Run lookup across all projects in parallel (limited concurrency) - sem := make(chan struct{}, parallelism) - var wg sync.WaitGroup + entry := s.getSelectedEntry() - for _, project := range projects { - if lookupCtx.Err() != nil { - break - } + if s.isSearching { + s.status.SetText(ipStatusSearching) + return + } - wg.Add(1) - go func(proj string) { - defer wg.Done() + if entry == nil { + if count > 0 { + s.status.SetText(fmt.Sprintf(" [green]%d results[-] [yellow]Enter[-] lookup [yellow]/[-] filter [yellow]Esc[-] back [yellow]?[-] help", count)) + } else { + s.status.SetText(ipStatusDefault) + } + return + } - sem <- struct{}{} - defer func() { <-sem }() + actionStr := "[yellow]d[-] details [yellow]b[-] browser" + if s.currentFilter != "" { + s.status.SetText(fmt.Sprintf(" [green]Filter: %s[-] %s [yellow]/[-] edit [yellow]Esc[-] clear [yellow]?[-] help", s.currentFilter, actionStr)) + } else { + s.status.SetText(fmt.Sprintf(" %s [yellow]Enter[-] lookup [yellow]/[-] filter [yellow]Esc[-] back [yellow]?[-] help", actionStr)) + } +} - if lookupCtx.Err() != nil { - return - } +// showTemporaryStatus shows a temporary status message that auto-restores after 2 seconds +func (s *ipLookupViewState) showTemporaryStatus(message string) { + s.status.SetText(message) + time.AfterFunc(2*time.Second, func() { + s.app.QueueUpdateDraw(func() { + s.updateStatusWithActions() + }) + }) +} - client, err := gcp.NewClient(lookupCtx, proj) - if err != nil { - progressMu.Lock() - completedProjects++ - progressMu.Unlock() - return - } +// performLookup performs IP lookup across projects with two-phase search +func (s *ipLookupViewState) performLookup(ipAddress string) { + if ipAddress == "" { + return + } - associations, err := client.LookupIPAddress(lookupCtx, ipAddress) - if err != nil { - progressMu.Lock() - completedProjects++ - progressMu.Unlock() - return - } + if s.searchCancel != nil { + s.searchCancel() + } - if len(associations) > 0 { - newEntries := make([]ipLookupEntry, 0, len(associations)) - for _, assoc := range associations { - entry := ipLookupEntry{ - Kind: string(assoc.Kind), - Resource: assoc.Resource, - Project: assoc.Project, - Location: assoc.Location, - IPAddress: assoc.IPAddress, - Details: assoc.Details, - ResourceLink: assoc.ResourceLink, - } - newEntries = append(newEntries, entry) - } + s.resultsMu.Lock() + s.allResults = []ipLookupEntry{} + s.resultsMu.Unlock() - resultsMu.Lock() - allResults = append(allResults, newEntries...) - resultsMu.Unlock() + lookupCtx, cancel := context.WithCancel(s.ctx) + s.searchCancel = cancel + s.isSearching = true - // Update UI - filter := currentFilter - app.QueueUpdateDraw(func() { - updateTableNoLock(filter) - }) - } + // Phase 1: Try preferred projects from cache + parsedIP := net.ParseIP(strings.TrimSpace(ipAddress)) + preferredProjects := getPreferredProjectsForIP(s.cache, parsedIP) - progressMu.Lock() - completedProjects++ - progressMu.Unlock() - }(project) - } + projects := s.allProjects + if len(preferredProjects) > 0 { + projects = preferredProjects + } - // Wait for all lookups to complete - wg.Wait() + s.app.QueueUpdateDraw(func() { + s.updateStatusWithActions() + s.progressText.SetText("[yellow]Starting IP lookup...[-]") + }) - // Stop spinner - select { - case spinnerDone <- true: - default: - } + s.searchProjects(lookupCtx, projects, ipAddress, "Looking up IP...") - isSearching = false + // Phase 2: Fallback to all projects if preferred search found nothing + s.resultsMu.Lock() + foundResults := len(s.allResults) > 0 + s.resultsMu.Unlock() - // Sort results by project, then kind, then resource - resultsMu.Lock() - sort.Slice(allResults, func(i, j int) bool { - if allResults[i].Project != allResults[j].Project { - return allResults[i].Project < allResults[j].Project - } - if allResults[i].Kind != allResults[j].Kind { - return allResults[i].Kind < allResults[j].Kind - } - return allResults[i].Resource < allResults[j].Resource - }) - resultsMu.Unlock() - - // Final UI update - filter := currentFilter - app.QueueUpdateDraw(func() { - progressText.SetText("") - rebuildLayout(false, true) - updateTableNoLock(filter) - app.SetFocus(table) - updateStatusWithActions() + if !foundResults && len(preferredProjects) > 0 && len(preferredProjects) < len(s.allProjects) { + s.app.QueueUpdateDraw(func() { + s.progressText.SetText("[yellow]No results in cached projects, searching all projects...[-]") }) + s.searchProjects(lookupCtx, s.allProjects, ipAddress, "Full scan...") } - // IP input handler - ipInput.SetDoneFunc(func(key tcell.Key) { - switch key { - case tcell.KeyEnter: - ipAddress := strings.TrimSpace(ipInput.GetText()) - if ipAddress != "" { - app.SetFocus(table) - // Run lookup in goroutine to avoid blocking the event loop - go performLookup(ipAddress) - } - case tcell.KeyEscape: - if isSearching && searchCancel != nil { - searchCancel() - } else { - onBack() - } - } - }) + s.isSearching = false - // Filter input handler - filterInput.SetDoneFunc(func(key tcell.Key) { - switch key { - case tcell.KeyEnter: - currentFilter = filterInput.GetText() - updateTable(currentFilter) - filterMode = false - rebuildLayout(false, true) - app.SetFocus(table) - updateStatusWithActions() - case tcell.KeyEscape: - filterInput.SetText(currentFilter) - filterMode = false - rebuildLayout(false, true) - app.SetFocus(table) + // Sort final results + s.resultsMu.Lock() + sort.Slice(s.allResults, func(i, j int) bool { + if s.allResults[i].Project != s.allResults[j].Project { + return s.allResults[i].Project < s.allResults[j].Project + } + if s.allResults[i].Kind != s.allResults[j].Kind { + return s.allResults[i].Kind < s.allResults[j].Kind } + return s.allResults[i].Resource < s.allResults[j].Resource }) + s.resultsMu.Unlock() + + filter := s.currentFilter + s.app.QueueUpdateDraw(func() { + s.progressText.SetText("") + s.rebuildLayout(false, true) + s.updateTable(filter) + s.app.SetFocus(s.table) + s.updateStatusWithActions() + }) +} - // Setup keyboard handlers - app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - // If a modal is open, let it handle all keys (except Ctrl+C) - if modalOpen && event.Key() != tcell.KeyCtrlC { - return event - } +// searchProjects searches the given projects in parallel and updates results progressively +func (s *ipLookupViewState) searchProjects(lookupCtx context.Context, projects []string, ipAddress string, statusPrefix string) { + var ( + completedProjects int + totalProjects = len(projects) + progressMu sync.Mutex + ) - // If in filter mode, let the input field handle it - if filterMode { - return event - } + spinnerDone := s.runSearchSpinner(lookupCtx, &progressMu, &completedProjects, totalProjects, statusPrefix) - // If IP input is focused, let it handle most keys - if app.GetFocus() == ipInput { - return event + sem := make(chan struct{}, s.parallelism) + var wg sync.WaitGroup + + for _, project := range projects { + if lookupCtx.Err() != nil { + break } - switch event.Key() { - case tcell.KeyEscape: - if isSearching && searchCancel != nil { - // Cancel ongoing lookup but stay in view - searchCancel() - return nil - } - if currentFilter != "" { - // Clear filter - currentFilter = "" - filterInput.SetText("") - updateTable("") - status.SetText(" [yellow]Filter cleared[-]") - time.AfterFunc(2*time.Second, func() { - app.QueueUpdateDraw(func() { - updateStatusWithActions() - }) - }) - return nil + wg.Add(1) + go func(proj string) { + defer wg.Done() + + sem <- struct{}{} + defer func() { <-sem }() + + if lookupCtx.Err() != nil { + return } - // Go back - if searchCancel != nil { - searchCancel() + + client, err := gcp.NewClient(lookupCtx, proj) + if err != nil { + progressMu.Lock() + completedProjects++ + progressMu.Unlock() + return } - onBack() - return nil - case tcell.KeyEnter: - // Focus IP input - app.SetFocus(ipInput) - return nil + associations, err := client.LookupIPAddressFast(lookupCtx, ipAddress) + if err != nil { + progressMu.Lock() + completedProjects++ + progressMu.Unlock() + return + } - case tcell.KeyRune: - switch event.Rune() { - case '/': - // Enter filter mode - filterMode = true - filterInput.SetText(currentFilter) - rebuildLayout(true, false) - app.SetFocus(filterInput) - status.SetText(" [yellow]Filter: spaces=AND |=OR -=NOT (e.g. \"web|api -dev\") Enter to apply, Esc to cancel[-]") - return nil - - case 'd': - // Show details for selected result - selectedEntry := getSelectedEntry() - if selectedEntry == nil { - status.SetText(" [red]No result selected[-]") - time.AfterFunc(2*time.Second, func() { - app.QueueUpdateDraw(func() { - updateStatusWithActions() - }) + if len(associations) > 0 { + newEntries := make([]ipLookupEntry, 0, len(associations)) + for _, assoc := range associations { + newEntries = append(newEntries, ipLookupEntry{ + Kind: string(assoc.Kind), + Resource: assoc.Resource, + Project: assoc.Project, + Location: assoc.Location, + IPAddress: assoc.IPAddress, + Details: assoc.Details, + ResourceLink: assoc.ResourceLink, }) - return nil } - showIPLookupDetail(app, table, flex, selectedEntry, &modalOpen, status, updateStatusWithActions) - return nil + s.resultsMu.Lock() + s.allResults = append(s.allResults, newEntries...) + s.resultsMu.Unlock() - case 'b': - // Open in Cloud Console - selectedEntry := getSelectedEntry() - if selectedEntry == nil { - return nil - } - - url := getIPLookupConsoleURL(selectedEntry) - if err := OpenInBrowser(url); err != nil { - status.SetText(fmt.Sprintf(" [yellow]URL: %s[-]", url)) - } else { - status.SetText(" [green]Opened in browser[-]") - } - time.AfterFunc(2*time.Second, func() { - app.QueueUpdateDraw(func() { - updateStatusWithActions() - }) + filter := s.currentFilter + s.app.QueueUpdateDraw(func() { + s.updateTable(filter) }) - return nil + } + + progressMu.Lock() + completedProjects++ + progressMu.Unlock() + }(project) + } + + wg.Wait() - case '?': - // Show help - showIPLookupHelp(app, table, flex, &modalOpen, status, updateStatusWithActions) - return nil + select { + case spinnerDone <- true: + default: + } +} + +// runSearchSpinner starts a spinner goroutine and returns a channel to signal completion +func (s *ipLookupViewState) runSearchSpinner(lookupCtx context.Context, progressMu *sync.Mutex, completedProjects *int, totalProjects int, statusPrefix string) chan bool { + spinnerFrames := []string{"โ ‹", "โ ™", "โ น", "โ ธ", "โ ผ", "โ ด", "โ ฆ", "โ ง", "โ ‡", "โ "} + spinnerIdx := 0 + done := make(chan bool, 2) + + go func() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.resultsMu.Lock() + count := len(s.allResults) + s.resultsMu.Unlock() + progressMu.Lock() + completed := *completedProjects + progressMu.Unlock() + frame := spinnerFrames[spinnerIdx] + spinnerIdx = (spinnerIdx + 1) % len(spinnerFrames) + s.app.QueueUpdateDraw(func() { + if s.isSearching { + s.progressText.SetText(fmt.Sprintf("[yellow]%s %d/%d projects | %d results[-]", frame, completed, totalProjects, count)) + s.status.SetText(fmt.Sprintf(" [yellow]%s (%d/%d projects)[-] [yellow]Esc[-] cancel", statusPrefix, completed, totalProjects)) + } + }) + case <-done: + return + case <-lookupCtx.Done(): + return } } - return event - }) + }() - app.SetRoot(flex, true).EnableMouse(true).SetFocus(ipInput) - return nil + return done +} + +// openInBrowser opens the selected resource in the Cloud Console +func (s *ipLookupViewState) openInBrowser() { + entry := s.getSelectedEntry() + if entry == nil { + return + } + + url := getIPLookupConsoleURL(entry) + if err := OpenInBrowser(url); err != nil { + s.status.SetText(fmt.Sprintf(" [yellow]URL: %s[-]", url)) + } else { + s.showTemporaryStatus(" [green]Opened in browser[-]") + } +} + +// showDetails shows detailed information for the selected entry +func (s *ipLookupViewState) showDetails() { + entry := s.getSelectedEntry() + if entry == nil { + s.showTemporaryStatus(" [red]No result selected[-]") + return + } + + s.modalOpen = true + showDetailModal(s.app, entry.Resource, s.buildDetailText(entry), func() { + s.modalOpen = false + s.app.SetRoot(s.flex, true) + s.app.SetFocus(s.table) + s.updateStatusWithActions() + }) } -// showIPLookupDetail displays details for an IP lookup result -func showIPLookupDetail(app *tview.Application, table *tview.Table, mainFlex *tview.Flex, entry *ipLookupEntry, modalOpen *bool, _ *tview.TextView, onRestoreStatus func()) { - var detailText strings.Builder +// buildDetailText builds the detail text for an IP lookup result +func (s *ipLookupViewState) buildDetailText(entry *ipLookupEntry) string { + var b strings.Builder kindDisplay := entry.Kind switch entry.Kind { @@ -601,70 +683,35 @@ func showIPLookupDetail(app *tview.Application, table *tview.Table, mainFlex *tv kindDisplay = "Subnet Range" } - detailText.WriteString(fmt.Sprintf("[yellow::b]%s[-:-:-]\n\n", kindDisplay)) - detailText.WriteString(fmt.Sprintf("[white::b]Resource:[-:-:-] %s\n", entry.Resource)) - detailText.WriteString(fmt.Sprintf("[white::b]Project:[-:-:-] %s\n", entry.Project)) - detailText.WriteString(fmt.Sprintf("[white::b]Location:[-:-:-] %s\n", entry.Location)) - detailText.WriteString(fmt.Sprintf("[white::b]IP Address:[-:-:-] %s\n", entry.IPAddress)) + b.WriteString(fmt.Sprintf("[yellow::b]%s[-:-:-]\n\n", kindDisplay)) + b.WriteString(fmt.Sprintf("[white::b]Resource:[-:-:-] %s\n", entry.Resource)) + b.WriteString(fmt.Sprintf("[white::b]Project:[-:-:-] %s\n", entry.Project)) + b.WriteString(fmt.Sprintf("[white::b]Location:[-:-:-] %s\n", entry.Location)) + b.WriteString(fmt.Sprintf("[white::b]IP Address:[-:-:-] %s\n", entry.IPAddress)) if entry.Details != "" { - detailText.WriteString("\n[yellow::b]Details:[-:-:-]\n") - // Parse key=value pairs from details + b.WriteString("\n[yellow::b]Details:[-:-:-]\n") pairs := strings.Split(entry.Details, ", ") for _, pair := range pairs { parts := strings.SplitN(pair, "=", 2) if len(parts) == 2 { - detailText.WriteString(fmt.Sprintf(" [white::b]%s:[-:-:-] %s\n", parts[0], parts[1])) + b.WriteString(fmt.Sprintf(" [white::b]%s:[-:-:-] %s\n", parts[0], parts[1])) } else { - detailText.WriteString(fmt.Sprintf(" %s\n", pair)) + b.WriteString(fmt.Sprintf(" %s\n", pair)) } } } if entry.ResourceLink != "" { - detailText.WriteString(fmt.Sprintf("\n[white::b]Resource Link:[-:-:-]\n %s\n", entry.ResourceLink)) + b.WriteString(fmt.Sprintf("\n[white::b]Resource Link:[-:-:-]\n %s\n", entry.ResourceLink)) } - detailText.WriteString("\n[darkgray]Press Esc to close[-]") - - detailView := tview.NewTextView(). - SetDynamicColors(true). - SetText(detailText.String()). - SetScrollable(true). - SetWordWrap(true) - detailView.SetBorder(true).SetTitle(fmt.Sprintf(" %s ", entry.Resource)) - - // Create status bar for detail view - detailStatus := tview.NewTextView(). - SetDynamicColors(true). - SetText(" [yellow]Esc[-] back [yellow]โ†‘/โ†“[-] scroll") - - // Create fullscreen detail layout - detailFlex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(detailView, 0, 1, true). - AddItem(detailStatus, 1, 0, false) - - // Set up input handler - detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - *modalOpen = false - app.SetRoot(mainFlex, true) - app.SetFocus(table) - if onRestoreStatus != nil { - onRestoreStatus() - } - return nil - } - return event - }) - - *modalOpen = true - app.SetRoot(detailFlex, true).SetFocus(detailView) + b.WriteString("\n[darkgray]Press Esc to close[-]") + return b.String() } -// showIPLookupHelp displays help for the IP lookup view -func showIPLookupHelp(app *tview.Application, table *tview.Table, mainFlex *tview.Flex, modalOpen *bool, _ *tview.TextView, onRestoreStatus func()) { +// showHelp displays the help screen +func (s *ipLookupViewState) showHelp() { helpText := `[yellow::b]IP Lookup View - Keyboard Shortcuts[-:-:-] [yellow]Lookup[-] @@ -696,6 +743,7 @@ func showIPLookupHelp(app *tview.Application, table *tview.Table, mainFlex *tvie [darkgray]Press Esc or ? to close this help[-]` + s.modalOpen = true helpView := tview.NewTextView(). SetDynamicColors(true). SetText(helpText). @@ -704,33 +752,43 @@ func showIPLookupHelp(app *tview.Application, table *tview.Table, mainFlex *tvie SetTitle(" IP Lookup Help "). SetTitleAlign(tview.AlignCenter) - // Create status bar for help view helpStatus := tview.NewTextView(). SetDynamicColors(true). - SetText(" [yellow]Esc[-] back [yellow]?[-] close help") + SetText(ipStatusHelpClose) - // Create fullscreen help layout helpFlex := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(helpView, 0, 1, true). AddItem(helpStatus, 1, 0, false) - // Set up input handler helpView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEscape || (event.Key() == tcell.KeyRune && event.Rune() == '?') { - *modalOpen = false - app.SetRoot(mainFlex, true) - app.SetFocus(table) - if onRestoreStatus != nil { - onRestoreStatus() - } + s.modalOpen = false + s.app.SetRoot(s.flex, true) + s.app.SetFocus(s.table) + s.updateStatusWithActions() return nil } return event }) - *modalOpen = true - app.SetRoot(helpFlex, true).SetFocus(helpView) + s.app.SetRoot(helpFlex, true).SetFocus(helpView) +} + +// getKindColor returns the display color for a resource kind +func getKindColor(kind string) string { + switch kind { + case string(gcp.IPAssociationInstanceInternal), string(gcp.IPAssociationInstanceExternal): + return "blue" + case string(gcp.IPAssociationForwardingRule): + return "cyan" + case string(gcp.IPAssociationAddress): + return "green" + case string(gcp.IPAssociationSubnet): + return "magenta" + default: + return "white" + } } // getIPLookupConsoleURL returns the Cloud Console URL for an IP lookup result @@ -739,18 +797,18 @@ func getIPLookupConsoleURL(entry *ipLookupEntry) string { switch entry.Kind { case string(gcp.IPAssociationInstanceInternal), string(gcp.IPAssociationInstanceExternal): - // Link to the instance return fmt.Sprintf("%s/compute/instancesDetail/zones/%s/instances/%s?project=%s", baseURL, entry.Location, entry.Resource, entry.Project) case string(gcp.IPAssociationForwardingRule): - // Link to the forwarding rule (load balancing) - return fmt.Sprintf("%s/net-services/loadbalancing/details/forwardingRules/%s/%s?project=%s", + if entry.Location == "global" { + return fmt.Sprintf("%s/net-services/loadbalancing/advanced/globalForwardingRules/details/%s?project=%s", + baseURL, entry.Resource, entry.Project) + } + return fmt.Sprintf("%s/net-services/loadbalancing/advanced/forwardingRules/details/regions/%s/forwardingRules/%s?project=%s", baseURL, entry.Location, entry.Resource, entry.Project) case string(gcp.IPAssociationAddress): - // Link to IP addresses return fmt.Sprintf("%s/networking/addresses/list?project=%s", baseURL, entry.Project) case string(gcp.IPAssociationSubnet): - // Link to VPC subnets return fmt.Sprintf("%s/networking/subnetworks/details/%s/%s?project=%s", baseURL, entry.Location, entry.Resource, entry.Project) default: diff --git a/internal/tui/vpn_view.go b/internal/tui/vpn_view.go index f76deb8..be93c22 100644 --- a/internal/tui/vpn_view.go +++ b/internal/tui/vpn_view.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "path" "strings" "time" @@ -12,6 +13,15 @@ import ( "github.com/rivo/tview" ) +// Status bar text constants +const ( + vpnStatusDefault = " [yellow]Esc[-] back [yellow]b[-] browser [yellow]d[-] details [yellow]Shift+R[-] refresh [yellow]/[-] filter [yellow]?[-] help" + vpnStatusDefaultNoR = " [yellow]Esc[-] back [yellow]b[-] browser [yellow]d[-] details [yellow]r[-] refresh [yellow]/[-] filter [yellow]?[-] help" + vpnStatusFilterActive = " [green]Filter active: %s[-]" + vpnStatusDetailScroll = " [yellow]Esc[-] back [yellow]up/down[-] scroll" + vpnStatusHelpClose = " [yellow]Esc[-] back [yellow]?[-] close help" +) + // vpnEntry represents a row in the VPN view type vpnEntry struct { Type string // "gateway", "tunnel", "bgp", "orphan-tunnel", "orphan-bgp" @@ -29,104 +39,118 @@ type vpnEntry struct { BGP *gcp.BGPSessionInfo } +// vpnViewState encapsulates all state for the VPN view +type vpnViewState struct { + ctx context.Context + gcpClient *gcp.Client + selectedProject string + app *tview.Application + vpnData *gcp.VPNOverview + allEntries []vpnEntry + onBack func() + + // UI components + table *tview.Table + status *tview.TextView + filterInput *tview.InputField + flex *tview.Flex + + // State flags + isRefreshing bool + modalOpen bool + filterMode bool + currentFilter string +} + // RunVPNView shows the VPN overview func RunVPNView(ctx context.Context, c *cache.Cache, app *tview.Application, onBack func()) error { return RunVPNViewWithProgress(ctx, c, app, nil, onBack) } // RunVPNViewWithProgress shows the VPN overview with optional progress callback -// It first prompts the user to select a project, then loads VPN data for that project func RunVPNViewWithProgress(ctx context.Context, c *cache.Cache, app *tview.Application, progress func(string), onBack func()) error { - // Show project selector first ShowProjectSelector(app, c, " Select Project for VPN View ", func(result ProjectSelectorResult) { if result.Cancelled { onBack() return } - selectedProject := result.Project - - // Show loading screen immediately after project selection - loadingCtx, cancelLoading := context.WithCancel(ctx) - - updateMsg, spinnerDone := showLoadingScreen(app, fmt.Sprintf(" Loading VPN Data [%s] ", selectedProject), "Creating GCP client...", func() { - cancelLoading() - onBack() - }) - - // Load everything in background - go func() { - var vpnData *gcp.VPNOverview - var allEntries []vpnEntry - var gcpClient *gcp.Client - - // Create GCP client - client, err := gcp.NewClient(loadingCtx, selectedProject) - if err != nil { - // Signal spinner to stop - select { - case spinnerDone <- true: - default: - } - - if loadingCtx.Err() == context.Canceled { - return - } - - app.QueueUpdateDraw(func() { - modal := tview.NewModal(). - SetText(fmt.Sprintf("Failed to create GCP client for project '%s':\n\n%v", selectedProject, err)). - AddButtons([]string{"OK"}). - SetDoneFunc(func(buttonIndex int, buttonLabel string) { - onBack() - }) - app.SetRoot(modal, true).SetFocus(modal) - }) - return - } - gcpClient = client - - // Progress callback - progressCallback := func(msg string) { - updateMsg(msg) - if progress != nil { - progress(msg) - } - } + loadVPNDataWithSpinner(ctx, app, result.Project, progress, onBack) + }) - updateMsg("Loading VPN gateways and tunnels...") + return nil +} - overview, err := gcpClient.ListVPNOverview(loadingCtx, progressCallback) - if err != nil { - allEntries = []vpnEntry{{ - Type: "error", - Name: "Failed to load VPN data", - Status: err.Error(), - }} - } else { - vpnData = overview - allEntries = buildVPNEntries(overview) - } +// loadVPNDataWithSpinner loads VPN data with a loading spinner +func loadVPNDataWithSpinner(ctx context.Context, app *tview.Application, project string, progress func(string), onBack func()) { + loadingCtx, cancelLoading := context.WithCancel(ctx) + updateMsg, spinnerDone := showLoadingScreen(app, fmt.Sprintf(" Loading VPN Data [%s] ", project), "Creating GCP client...", func() { + cancelLoading() + onBack() + }) - // Signal spinner to stop + go func() { + defer func() { select { case spinnerDone <- true: default: } + }() - // Check if cancelled + // Create GCP client + client, err := gcp.NewClient(loadingCtx, project) + if err != nil { if loadingCtx.Err() == context.Canceled { return } + showErrorModal(app, fmt.Sprintf("Failed to create GCP client for project '%s':\n\n%v", project, err), onBack) + return + } + + // Load VPN data + progressCallback := createProgressCallback(updateMsg, progress) + updateMsg("Loading VPN gateways and tunnels...") + + overview, err := client.ListVPNOverview(loadingCtx, progressCallback) + + var entries []vpnEntry + if err != nil { + entries = []vpnEntry{{Type: "error", Name: "Failed to load VPN data", Status: err.Error()}} + } else { + entries = buildVPNEntries(overview) + } - app.QueueUpdateDraw(func() { - // Now show the actual VPN view - showVPNViewUI(ctx, gcpClient, selectedProject, app, vpnData, allEntries, onBack) + if loadingCtx.Err() == context.Canceled { + return + } + + app.QueueUpdateDraw(func() { + showVPNViewUI(ctx, client, project, app, overview, entries, onBack) + }) + }() +} + +// createProgressCallback creates a combined progress callback +func createProgressCallback(updateMsg func(string), progress func(string)) func(string) { + return func(msg string) { + updateMsg(msg) + if progress != nil { + progress(msg) + } + } +} + +// showErrorModal displays an error modal +func showErrorModal(app *tview.Application, message string, onOK func()) { + app.QueueUpdateDraw(func() { + modal := tview.NewModal(). + SetText(message). + AddButtons([]string{"OK"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + onOK() }) - }() + app.SetRoot(modal, true).SetFocus(modal) }) - - return nil } // buildVPNEntries converts VPN overview data to display entries @@ -135,136 +159,137 @@ func buildVPNEntries(overview *gcp.VPNOverview) []vpnEntry { // Add gateways and their tunnels/BGP sessions for _, gw := range overview.Gateways { - // Gateway entry - tunnelCount := len(gw.Tunnels) - bgpCount := 0 - for _, t := range gw.Tunnels { - bgpCount += len(t.BgpSessions) - } - - gwEntry := vpnEntry{ - Type: "gateway", - Name: gw.Name, - Status: fmt.Sprintf("%d tunnels, %d BGP", tunnelCount, bgpCount), - Region: gw.Region, - Network: extractNetworkName(gw.Network), - Level: 0, - Gateway: gw, - } - allEntries = append(allEntries, gwEntry) - - // Tunnel entries - for _, tunnel := range gw.Tunnels { - tunnelEntry := vpnEntry{ - Type: "tunnel", - Name: tunnel.Name, - Status: formatTunnelStatus(tunnel.Status), - Region: tunnel.Region, - Parent: gw.Name, - DetailedMsg: tunnel.DetailedStatus, - Level: 1, - Tunnel: tunnel, - } - allEntries = append(allEntries, tunnelEntry) - - // BGP entries - for _, bgp := range tunnel.BgpSessions { - bgpEntry := vpnEntry{ - Type: "bgp", - Name: bgp.Name, - Status: formatBGPStatus(bgp.SessionState), - Region: bgp.Region, - Parent: tunnel.Name, - Level: 2, - BGP: bgp, - } - allEntries = append(allEntries, bgpEntry) - } - } + allEntries = append(allEntries, createGatewayEntry(gw)) + allEntries = append(allEntries, createTunnelEntries(gw)...) } // Add orphan tunnels if len(overview.OrphanTunnels) > 0 { - allEntries = append(allEntries, vpnEntry{ - Type: "section", - Name: "Orphan Tunnels (Classic VPN)", - Status: fmt.Sprintf("%d tunnels", len(overview.OrphanTunnels)), - Level: 0, - }) + allEntries = append(allEntries, createSectionEntry("Orphan Tunnels (Classic VPN)", len(overview.OrphanTunnels), "tunnels")) for _, tunnel := range overview.OrphanTunnels { - tunnelEntry := vpnEntry{ - Type: "orphan-tunnel", - Name: tunnel.Name, - Status: formatTunnelStatus(tunnel.Status), - Region: tunnel.Region, - DetailedMsg: tunnel.DetailedStatus, - Level: 1, - Tunnel: tunnel, - } - allEntries = append(allEntries, tunnelEntry) + allEntries = append(allEntries, createOrphanTunnelEntry(tunnel)) } } // Add orphan BGP sessions if len(overview.OrphanSessions) > 0 { - allEntries = append(allEntries, vpnEntry{ - Type: "section", - Name: "Orphan BGP Sessions", - Status: fmt.Sprintf("%d sessions", len(overview.OrphanSessions)), - Level: 0, - }) + allEntries = append(allEntries, createSectionEntry("Orphan BGP Sessions", len(overview.OrphanSessions), "sessions")) for _, bgp := range overview.OrphanSessions { - bgpEntry := vpnEntry{ - Type: "orphan-bgp", + allEntries = append(allEntries, createOrphanBGPEntry(bgp)) + } + } + + return allEntries +} + +// Helper functions to create entries +func createGatewayEntry(gw *gcp.VPNGatewayInfo) vpnEntry { + tunnelCount := len(gw.Tunnels) + bgpCount := 0 + for _, t := range gw.Tunnels { + bgpCount += len(t.BgpSessions) + } + + return vpnEntry{ + Type: "gateway", + Name: gw.Name, + Status: fmt.Sprintf("%d tunnels, %d BGP", tunnelCount, bgpCount), + Region: gw.Region, + Network: extractNetworkName(gw.Network), + Level: 0, + Gateway: gw, + } +} + +func createTunnelEntries(gw *gcp.VPNGatewayInfo) []vpnEntry { + var entries []vpnEntry + for _, tunnel := range gw.Tunnels { + entries = append(entries, vpnEntry{ + Type: "tunnel", + Name: tunnel.Name, + Status: formatTunnelStatus(tunnel.Status), + Region: tunnel.Region, + Parent: gw.Name, + DetailedMsg: tunnel.DetailedStatus, + Level: 1, + Tunnel: tunnel, + }) + + // Add BGP sessions for this tunnel + for _, bgp := range tunnel.BgpSessions { + entries = append(entries, vpnEntry{ + Type: "bgp", Name: bgp.Name, Status: formatBGPStatus(bgp.SessionState), Region: bgp.Region, - Level: 1, + Parent: tunnel.Name, + Level: 2, BGP: bgp, - } - allEntries = append(allEntries, bgpEntry) + }) } } + return entries +} + +func createSectionEntry(name string, count int, itemType string) vpnEntry { + return vpnEntry{ + Type: "section", + Name: name, + Status: fmt.Sprintf("%d %s", count, itemType), + Level: 0, + } +} - return allEntries +func createOrphanTunnelEntry(tunnel *gcp.VPNTunnelInfo) vpnEntry { + return vpnEntry{ + Type: "orphan-tunnel", + Name: tunnel.Name, + Status: formatTunnelStatus(tunnel.Status), + Region: tunnel.Region, + DetailedMsg: tunnel.DetailedStatus, + Level: 1, + Tunnel: tunnel, + } +} + +func createOrphanBGPEntry(bgp *gcp.BGPSessionInfo) vpnEntry { + return vpnEntry{ + Type: "orphan-bgp", + Name: bgp.Name, + Status: formatBGPStatus(bgp.SessionState), + Region: bgp.Region, + Level: 1, + BGP: bgp, + } } // showVPNViewUI displays the VPN view UI after data is loaded func showVPNViewUI(ctx context.Context, gcpClient *gcp.Client, selectedProject string, app *tview.Application, vpnData *gcp.VPNOverview, initialEntries []vpnEntry, onBack func()) { - var allEntries = initialEntries - var isRefreshing bool - var modalOpen bool - var filterMode bool - var currentFilter string + state := &vpnViewState{ + ctx: ctx, + gcpClient: gcpClient, + selectedProject: selectedProject, + app: app, + vpnData: vpnData, + allEntries: initialEntries, + onBack: onBack, + } - // Function to load VPN data (for refresh) - loadVPNData := func(progressCallback func(string)) { - isRefreshing = true + state.setupUI() + state.setupKeyboardHandlers() + state.updateTable("") - overview, err := gcpClient.ListVPNOverview(ctx, progressCallback) - if err != nil { - // Show error - allEntries = []vpnEntry{{ - Type: "error", - Name: "Failed to load VPN data", - Status: err.Error(), - }} - isRefreshing = false - return - } - - vpnData = overview - allEntries = buildVPNEntries(overview) - isRefreshing = false - } + app.SetRoot(state.flex, true).EnableMouse(true).SetFocus(state.table) +} +// setupUI initializes all UI components +func (s *vpnViewState) setupUI() { // Create table - table := tview.NewTable(). + s.table = tview.NewTable(). SetBorders(false). SetSelectable(true, false). SetFixed(1, 0) - - table.SetBorder(true).SetTitle(fmt.Sprintf(" VPN Overview [yellow][%s][-] ", selectedProject)) + s.table.SetBorder(true).SetTitle(fmt.Sprintf(" VPN Overview [yellow][%s][-] ", s.selectedProject)) // Add header headers := []string{"Name", "Status", "Region", "Network"} @@ -274,403 +299,509 @@ func showVPNViewUI(ctx context.Context, gcpClient *gcp.Client, selectedProject s SetBackgroundColor(tcell.ColorDarkCyan). SetSelectable(false). SetExpansion(1) - table.SetCell(0, col, cell) + s.table.SetCell(0, col, cell) } // Filter input - filterInput := tview.NewInputField(). + s.filterInput = tview.NewInputField(). SetLabel(" Filter: "). SetFieldWidth(0). SetFieldBackgroundColor(tcell.ColorBlack). SetLabelColor(tcell.ColorYellow) + s.setupFilterHandlers() - // Update table with entries - updateTable := func(filter string) { - // Clear existing rows (keep header) - for row := table.GetRowCount() - 1; row > 0; row-- { - table.RemoveRow(row) - } + // Status bar + s.status = tview.NewTextView(). + SetDynamicColors(true). + SetText(vpnStatusDefault) - // Filter entries - expr := parseFilter(filter) - var filteredEntries []vpnEntry - for _, entry := range allEntries { - if expr.matches(entry.Name, entry.Region, entry.Network, entry.Status) { - filteredEntries = append(filteredEntries, entry) - } - } + // Layout + s.flex = tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(s.table, 0, 1, true). + AddItem(s.status, 1, 0, false) +} - currentRow := 1 - for _, entry := range filteredEntries { - // Add indentation based on level - indent := strings.Repeat(" ", entry.Level) - name := indent + entry.Name - - table.SetCell(currentRow, 0, tview.NewTableCell(name).SetExpansion(1)) - table.SetCell(currentRow, 1, tview.NewTableCell(entry.Status).SetExpansion(1)) - table.SetCell(currentRow, 2, tview.NewTableCell(entry.Region).SetExpansion(1)) - table.SetCell(currentRow, 3, tview.NewTableCell(entry.Network).SetExpansion(1)) - currentRow++ +// setupFilterHandlers sets up filter input handlers +func (s *vpnViewState) setupFilterHandlers() { + s.filterInput.SetDoneFunc(func(key tcell.Key) { + switch key { + case tcell.KeyEnter: + s.currentFilter = s.filterInput.GetText() + s.exitFilterMode() + s.updateTable(s.currentFilter) + case tcell.KeyEscape: + s.filterInput.SetText(s.currentFilter) + s.exitFilterMode() } + }) +} - // Update title with project and counts - gwCount := 0 - tunnelCount := 0 - bgpCount := 0 - if vpnData != nil { - gwCount = len(vpnData.Gateways) - for _, gw := range vpnData.Gateways { - tunnelCount += len(gw.Tunnels) - for _, t := range gw.Tunnels { - bgpCount += len(t.BgpSessions) - } - } - tunnelCount += len(vpnData.OrphanTunnels) - bgpCount += len(vpnData.OrphanSessions) +// setupKeyboardHandlers sets up all keyboard event handlers +func (s *vpnViewState) setupKeyboardHandlers() { + s.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + // Let filter input handle events when in filter mode + if s.filterMode || s.isRefreshing { + return event } - titleSuffix := "" - if filter != "" { - titleSuffix = fmt.Sprintf(" [filtered: %d/%d]", len(filteredEntries), len(allEntries)) + // Let modals handle their own escape + if s.modalOpen && event.Key() == tcell.KeyEscape { + return event } - table.SetTitle(fmt.Sprintf(" VPN Overview [yellow][%s][-] (%d gateways, %d tunnels, %d BGP)%s ", selectedProject, gwCount, tunnelCount, bgpCount, titleSuffix)) - // Select first data row if available - if len(filteredEntries) > 0 { - table.Select(1, 0) + switch event.Key() { + case tcell.KeyEscape: + return s.handleEscape() + case tcell.KeyRune: + return s.handleRuneKey(event.Rune()) } + return event + }) +} + +// handleEscape handles the Escape key +func (s *vpnViewState) handleEscape() *tcell.EventKey { + if s.currentFilter != "" { + s.currentFilter = "" + s.filterInput.SetText("") + s.updateTable("") + s.showTemporaryStatus(" [yellow]Filter cleared[-]", vpnStatusDefaultNoR) + return nil } + s.onBack() + return nil +} - // Status bar - status := tview.NewTextView(). - SetDynamicColors(true). - SetText(" [yellow]Esc[-] back [yellow]d[-] details [yellow]Shift+R[-] refresh [yellow]/[-] filter [yellow]?[-] help") +// handleRuneKey handles character key presses +func (s *vpnViewState) handleRuneKey(r rune) *tcell.EventKey { + switch r { + case '/': + s.enterFilterMode() + case 'R': + s.refreshVPNData() + case 'b': + s.openInBrowser() + case 'd': + s.showDetails() + case '?': + s.showHelp() + default: + return &tcell.EventKey{} // Return event for unhandled keys + } + return nil +} - // Layout - flex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(table, 0, 1, true). - AddItem(status, 1, 0, false) +// enterFilterMode enters filter/search mode +func (s *vpnViewState) enterFilterMode() { + s.filterMode = true + s.filterInput.SetText(s.currentFilter) + s.flex.Clear() + s.flex.AddItem(s.filterInput, 1, 0, true) + s.flex.AddItem(s.table, 0, 1, false) + s.flex.AddItem(s.status, 1, 0, false) + s.app.SetFocus(s.filterInput) +} - // Setup filter input handlers - filterInput.SetDoneFunc(func(key tcell.Key) { - switch key { - case tcell.KeyEnter: - // Apply filter and exit filter mode - currentFilter = filterInput.GetText() - updateTable(currentFilter) - filterMode = false - flex.RemoveItem(filterInput) - flex.Clear() - flex.AddItem(table, 0, 1, true) - flex.AddItem(status, 1, 0, false) - app.SetFocus(table) - if currentFilter != "" { - status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) - } else { - status.SetText(" [yellow]Esc[-] back [yellow]d[-] details [yellow]r[-] refresh [yellow]/[-] filter [yellow]?[-] help") +// exitFilterMode exits filter mode and restores normal view +func (s *vpnViewState) exitFilterMode() { + s.filterMode = false + s.flex.RemoveItem(s.filterInput) + s.flex.Clear() + s.flex.AddItem(s.table, 0, 1, true) + s.flex.AddItem(s.status, 1, 0, false) + s.app.SetFocus(s.table) + s.restoreDefaultStatus() +} + +// refreshVPNData refreshes VPN data from GCP +func (s *vpnViewState) refreshVPNData() { + refreshCtx, cancelRefresh := context.WithCancel(s.ctx) + updateMsg, spinnerDone := showLoadingScreen(s.app, " Refreshing VPN Data ", "Initializing...", func() { + cancelRefresh() + s.app.SetRoot(s.flex, true).SetFocus(s.table) + s.showTemporaryStatus(" [yellow]Refresh cancelled[-]", vpnStatusDefault) + }) + + go func() { + s.isRefreshing = true + defer func() { s.isRefreshing = false }() + + overview, err := s.gcpClient.ListVPNOverview(refreshCtx, func(msg string) { + updateMsg(msg) + }) + + s.app.QueueUpdateDraw(func() { + select { + case spinnerDone <- true: + default: } - case tcell.KeyEscape: - // Cancel filter mode without applying - filterInput.SetText(currentFilter) - filterMode = false - flex.RemoveItem(filterInput) - flex.Clear() - flex.AddItem(table, 0, 1, true) - flex.AddItem(status, 1, 0, false) - app.SetFocus(table) - if currentFilter != "" { - status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) + + if refreshCtx.Err() == context.Canceled { + return + } + + if err != nil { + s.allEntries = []vpnEntry{{Type: "error", Name: "Failed to load VPN data", Status: err.Error()}} } else { - status.SetText(" [yellow]Esc[-] back [yellow]d[-] details [yellow]r[-] refresh [yellow]/[-] filter [yellow]?[-] help") + s.vpnData = overview + s.allEntries = buildVPNEntries(overview) } - } - }) - // Initial table population (data is already loaded) - updateTable("") + s.app.SetRoot(s.flex, true).SetFocus(s.table) + s.updateTable(s.currentFilter) + s.showTemporaryStatus(" [green]Refreshed![-]", vpnStatusDefault) + }) + }() +} - // Setup keyboard - app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - // If in filter mode, let the input field handle it - if filterMode { - return event - } +// openInBrowser opens the selected VPN resource in the Cloud Console +func (s *vpnViewState) openInBrowser() { + entry, err := s.getSelectedEntry() + if err != nil { + s.showTemporaryStatus(fmt.Sprintf(" [red]%s[-]", err.Error()), vpnStatusDefaultNoR) + return + } - // Don't handle ESC if a modal is open - if modalOpen && event.Key() == tcell.KeyEscape { - return event + url := s.buildConsoleURL(entry) + if url == "" { + s.showTemporaryStatus(" [red]Browser view not available for this item type[-]", vpnStatusDefaultNoR) + return + } + + if err := OpenInBrowser(url); err != nil { + s.status.SetText(fmt.Sprintf(" [yellow]URL: %s[-]", url)) + } else { + s.showTemporaryStatus(" [green]Opened in browser[-]", vpnStatusDefaultNoR) + } +} + +// showDetails shows detailed information for the selected entry +func (s *vpnViewState) showDetails() { + entry, err := s.getSelectedEntry() + if err != nil { + s.showTemporaryStatus(fmt.Sprintf(" [red]%s[-]", err.Error()), vpnStatusDefaultNoR) + return + } + + showVPNDetail(s.app, s.table, s.flex, entry, &s.modalOpen, s.status, s.currentFilter) +} + +// showHelp displays the help screen +func (s *vpnViewState) showHelp() { + showVPNHelp(s.app, s.table, s.flex, &s.modalOpen, s.currentFilter, s.status) +} + +// getSelectedEntry returns the currently selected entry with validation +func (s *vpnViewState) getSelectedEntry() (vpnEntry, error) { + row, _ := s.table.GetSelection() + if row <= 0 || row > len(s.allEntries) { + return vpnEntry{}, fmt.Errorf("no item selected") + } + return s.allEntries[row-1], nil +} + +// buildConsoleURL builds the Google Cloud Console URL for a VPN entry +func (s *vpnViewState) buildConsoleURL(entry vpnEntry) string { + switch { + case entry.Gateway != nil: + // HA VPN gateways need the isHA=true parameter + baseURL := buildCloudConsoleURL(path.Join("hybrid/vpn/gateways/details", entry.Region, entry.Name), s.selectedProject) + return baseURL + "&isHA=true" + case entry.Tunnel != nil: + // Differentiate between HA VPN and Classic VPN tunnels + // Regular tunnels (type="tunnel") are HA VPN - need isHA=true + // Orphan tunnels (type="orphan-tunnel") are Classic VPN - no isHA parameter + baseURL := buildCloudConsoleURL(path.Join("hybrid/vpn/tunnels/details", entry.Region, entry.Name), s.selectedProject) + if entry.Type == "tunnel" { + // HA VPN tunnel + return baseURL + "&isHA=true" } + // Classic VPN tunnel (orphan) + return baseURL + case entry.BGP != nil: + return buildCloudConsoleURL(path.Join("hybrid/routers/bgpSession", entry.BGP.Region, entry.BGP.RouterName, entry.BGP.Name), s.selectedProject) + default: + return "" + } +} - // Don't allow actions during refresh - if isRefreshing { - return event +// updateTable updates the table with filtered entries +func (s *vpnViewState) updateTable(filter string) { + // Clear existing rows (keep header) + for row := s.table.GetRowCount() - 1; row > 0; row-- { + s.table.RemoveRow(row) + } + + // Filter entries + expr := parseFilter(filter) + var filteredEntries []vpnEntry + for _, entry := range s.allEntries { + if expr.matches(entry.Name, entry.Region, entry.Network, entry.Status) { + filteredEntries = append(filteredEntries, entry) } + } - switch event.Key() { - case tcell.KeyEscape: - // Clear filter if active, otherwise go back - if currentFilter != "" { - currentFilter = "" - filterInput.SetText("") - updateTable("") - status.SetText(" [yellow]Filter cleared[-]") - time.AfterFunc(2*time.Second, func() { - app.QueueUpdateDraw(func() { - status.SetText(" [yellow]Esc[-] back [yellow]d[-] details [yellow]r[-] refresh [yellow]/[-] filter [yellow]?[-] help") - }) - }) - return nil - } - // Go back to instance view - onBack() - return nil + // Populate table rows + for i, entry := range filteredEntries { + indent := strings.Repeat(" ", entry.Level) + name := indent + entry.Name - case tcell.KeyRune: - switch event.Rune() { - case '/': - // Enter filter mode - filterMode = true - filterInput.SetText(currentFilter) - flex.Clear() - flex.AddItem(filterInput, 1, 0, true) - flex.AddItem(table, 0, 1, false) - flex.AddItem(status, 1, 0, false) - app.SetFocus(filterInput) - return nil - - case 'R': - // Refresh VPN data with loading screen - refreshCtx, cancelRefresh := context.WithCancel(ctx) - - updateMsg, spinnerDone := showLoadingScreen(app, " Refreshing VPN Data ", "Initializing...", func() { - cancelRefresh() - app.SetRoot(flex, true).SetFocus(table) - status.SetText(" [yellow]Refresh cancelled[-]") - time.AfterFunc(2*time.Second, func() { - app.QueueUpdateDraw(func() { - if currentFilter != "" { - status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) - } else { - status.SetText(" [yellow]Esc[-] back [yellow]d[-] details [yellow]Shift+R[-] refresh [yellow]/[-] filter [yellow]?[-] help") - } - }) - }) - }) - - // Progress callback for VPN loading - progressCallback := func(msg string) { - updateMsg(msg) - } + s.table.SetCell(i+1, 0, tview.NewTableCell(name).SetExpansion(1)) + s.table.SetCell(i+1, 1, tview.NewTableCell(entry.Status).SetExpansion(1)) + s.table.SetCell(i+1, 2, tview.NewTableCell(entry.Region).SetExpansion(1)) + s.table.SetCell(i+1, 3, tview.NewTableCell(entry.Network).SetExpansion(1)) + } - go func() { - loadVPNData(progressCallback) - app.QueueUpdateDraw(func() { - select { - case spinnerDone <- true: - default: - } - if refreshCtx.Err() == context.Canceled { - return - } - app.SetRoot(flex, true).SetFocus(table) - updateTable(currentFilter) - status.SetText(" [green]Refreshed![-]") - time.AfterFunc(2*time.Second, func() { - app.QueueUpdateDraw(func() { - if currentFilter != "" { - status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) - } else { - status.SetText(" [yellow]Esc[-] back [yellow]d[-] details [yellow]Shift+R[-] refresh [yellow]/[-] filter [yellow]?[-] help") - } - }) - }) - }) - }() - return nil - - case 'd': - // Show details for selected item - row, _ := table.GetSelection() - if row <= 0 || row > len(allEntries) { - status.SetText(" [red]No item selected[-]") - time.AfterFunc(2*time.Second, func() { - app.QueueUpdateDraw(func() { - if currentFilter != "" { - status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) - } else { - status.SetText(" [yellow]Esc[-] back [yellow]d[-] details [yellow]r[-] refresh [yellow]/[-] filter [yellow]?[-] help") - } - }) - }) - return nil - } + // Update title + s.updateTitle(filter, len(filteredEntries)) - entry := allEntries[row-1] - showVPNDetail(app, table, flex, entry, &modalOpen, status, currentFilter) - return nil + // Select first data row if available + if len(filteredEntries) > 0 { + s.table.Select(1, 0) + } +} - case '?': - // Show help - showVPNHelp(app, table, flex, &modalOpen, currentFilter, status) - return nil - } +// updateTitle updates the table title with counts +func (s *vpnViewState) updateTitle(filter string, filteredCount int) { + gwCount, tunnelCount, bgpCount := s.countResources() + + titleSuffix := "" + if filter != "" { + titleSuffix = fmt.Sprintf(" [filtered: %d/%d]", filteredCount, len(s.allEntries)) + } + + s.table.SetTitle(fmt.Sprintf(" VPN Overview [yellow][%s][-] (%d gateways, %d tunnels, %d BGP)%s ", + s.selectedProject, gwCount, tunnelCount, bgpCount, titleSuffix)) +} + +// countResources counts the total number of each resource type +func (s *vpnViewState) countResources() (gateways, tunnels, bgp int) { + if s.vpnData == nil { + return 0, 0, 0 + } + + gateways = len(s.vpnData.Gateways) + for _, gw := range s.vpnData.Gateways { + tunnels += len(gw.Tunnels) + for _, t := range gw.Tunnels { + bgp += len(t.BgpSessions) } - return event - }) + } + tunnels += len(s.vpnData.OrphanTunnels) + bgp += len(s.vpnData.OrphanSessions) + + return +} + +// restoreDefaultStatus restores the default status bar text +func (s *vpnViewState) restoreDefaultStatus() { + if s.currentFilter != "" { + s.status.SetText(fmt.Sprintf(vpnStatusFilterActive, s.currentFilter)) + } else { + s.status.SetText(vpnStatusDefaultNoR) + } +} - app.SetRoot(flex, true).EnableMouse(true).SetFocus(table) +// showTemporaryStatus shows a temporary status message that auto-restores after 2 seconds +func (s *vpnViewState) showTemporaryStatus(message, defaultStatus string) { + s.status.SetText(message) + time.AfterFunc(2*time.Second, func() { + s.app.QueueUpdateDraw(func() { + if s.currentFilter != "" { + s.status.SetText(fmt.Sprintf(vpnStatusFilterActive, s.currentFilter)) + } else { + s.status.SetText(defaultStatus) + } + }) + }) } -// ShowVPNGatewayDetails displays a fullscreen modal with detailed VPN gateway information. -// This is a reusable function that can be called from both the VPN view and global search. +// ShowVPNGatewayDetails displays a fullscreen modal with detailed VPN gateway information func ShowVPNGatewayDetails(app *tview.Application, gateway *gcp.VPNGatewayInfo, onClose func()) { - var detailText strings.Builder + detailText := buildGatewayDetailText(gateway) + showDetailModal(app, gateway.Name, detailText, onClose) +} + +// ShowVPNTunnelDetails displays a fullscreen modal with detailed VPN tunnel information +func ShowVPNTunnelDetails(app *tview.Application, tunnel *gcp.VPNTunnelInfo, onClose func()) { + detailText := buildTunnelDetailText(tunnel) + showDetailModal(app, tunnel.Name, detailText, onClose) +} - detailText.WriteString("[yellow::b]VPN Gateway Details[-:-:-]\n\n") - detailText.WriteString(fmt.Sprintf("[white::b]Name:[-:-:-] %s\n", gateway.Name)) - detailText.WriteString(fmt.Sprintf("[white::b]Region:[-:-:-] %s\n", gateway.Region)) - detailText.WriteString(fmt.Sprintf("[white::b]Network:[-:-:-] %s\n", extractNetworkName(gateway.Network))) +// buildGatewayDetailText builds the detail text for a gateway +func buildGatewayDetailText(gateway *gcp.VPNGatewayInfo) string { + var b strings.Builder + + b.WriteString("[yellow::b]VPN Gateway Details[-:-:-]\n\n") + b.WriteString(fmt.Sprintf("[white::b]Name:[-:-:-] %s\n", gateway.Name)) + b.WriteString(fmt.Sprintf("[white::b]Region:[-:-:-] %s\n", gateway.Region)) + b.WriteString(fmt.Sprintf("[white::b]Network:[-:-:-] %s\n", extractNetworkName(gateway.Network))) if gateway.Description != "" { - detailText.WriteString(fmt.Sprintf("[white::b]Description:[-:-:-] %s\n", gateway.Description)) + b.WriteString(fmt.Sprintf("[white::b]Description:[-:-:-] %s\n", gateway.Description)) } - detailText.WriteString(fmt.Sprintf("[white::b]Tunnels:[-:-:-] %d\n", len(gateway.Tunnels))) + b.WriteString(fmt.Sprintf("[white::b]Tunnels:[-:-:-] %d\n", len(gateway.Tunnels))) if len(gateway.Interfaces) > 0 { - detailText.WriteString("\n[yellow::b]Interfaces:[-:-:-]\n") + b.WriteString("\n[yellow::b]Interfaces:[-:-:-]\n") for _, iface := range gateway.Interfaces { - detailText.WriteString(fmt.Sprintf(" Interface #%d: %s\n", iface.Id, iface.IpAddress)) + b.WriteString(fmt.Sprintf(" Interface #%d: %s\n", iface.Id, iface.IpAddress)) } } if len(gateway.Labels) > 0 { - detailText.WriteString("\n[yellow::b]Labels:[-:-:-]\n") + b.WriteString("\n[yellow::b]Labels:[-:-:-]\n") for k, v := range gateway.Labels { - detailText.WriteString(fmt.Sprintf(" %s: %s\n", k, v)) + b.WriteString(fmt.Sprintf(" %s: %s\n", k, v)) } } - detailText.WriteString("\n[darkgray]Press Esc to close[-]") - - detailView := tview.NewTextView(). - SetDynamicColors(true). - SetText(detailText.String()). - SetScrollable(true). - SetWordWrap(true) - detailView.SetBorder(true).SetTitle(fmt.Sprintf(" %s ", gateway.Name)) - - // Create status bar for detail view - detailStatus := tview.NewTextView(). - SetDynamicColors(true). - SetText(" [yellow]Esc[-] back [yellow]up/down[-] scroll") - - // Create fullscreen detail layout - detailFlex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(detailView, 0, 1, true). - AddItem(detailStatus, 1, 0, false) - - // Set up input handler - detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - if onClose != nil { - onClose() - } - return nil - } - return event - }) - - app.SetRoot(detailFlex, true).SetFocus(detailView) + b.WriteString("\n[darkgray]Press Esc to close[-]") + return b.String() } -// ShowVPNTunnelDetails displays a fullscreen modal with detailed VPN tunnel information. -// This is a reusable function that can be called from both the VPN view and global search. -func ShowVPNTunnelDetails(app *tview.Application, tunnel *gcp.VPNTunnelInfo, onClose func()) { - var detailText strings.Builder +// buildTunnelDetailText builds the detail text for a tunnel +func buildTunnelDetailText(tunnel *gcp.VPNTunnelInfo) string { + var b strings.Builder - detailText.WriteString("[yellow::b]VPN Tunnel Details[-:-:-]\n\n") - detailText.WriteString(fmt.Sprintf("[white::b]Name:[-:-:-] %s\n", tunnel.Name)) - detailText.WriteString(fmt.Sprintf("[white::b]Region:[-:-:-] %s\n", tunnel.Region)) - detailText.WriteString(fmt.Sprintf("[white::b]Status:[-:-:-] %s\n", tunnel.Status)) + b.WriteString("[yellow::b]VPN Tunnel Details[-:-:-]\n\n") + b.WriteString(fmt.Sprintf("[white::b]Name:[-:-:-] %s\n", tunnel.Name)) + b.WriteString(fmt.Sprintf("[white::b]Region:[-:-:-] %s\n", tunnel.Region)) + b.WriteString(fmt.Sprintf("[white::b]Status:[-:-:-] %s\n", tunnel.Status)) if tunnel.DetailedStatus != "" { - detailText.WriteString(fmt.Sprintf("[white::b]Detailed Status:[-:-:-] %s\n", tunnel.DetailedStatus)) + b.WriteString(fmt.Sprintf("[white::b]Detailed Status:[-:-:-] %s\n", tunnel.DetailedStatus)) } if tunnel.Description != "" { - detailText.WriteString(fmt.Sprintf("[white::b]Description:[-:-:-] %s\n", tunnel.Description)) + b.WriteString(fmt.Sprintf("[white::b]Description:[-:-:-] %s\n", tunnel.Description)) } - detailText.WriteString(fmt.Sprintf("[white::b]Local Gateway IP:[-:-:-] %s\n", tunnel.LocalGatewayIP)) - detailText.WriteString(fmt.Sprintf("[white::b]Peer IP:[-:-:-] %s\n", tunnel.PeerIP)) + b.WriteString(fmt.Sprintf("[white::b]Local Gateway IP:[-:-:-] %s\n", tunnel.LocalGatewayIP)) + b.WriteString(fmt.Sprintf("[white::b]Peer IP:[-:-:-] %s\n", tunnel.PeerIP)) if tunnel.PeerGateway != "" { - detailText.WriteString(fmt.Sprintf("[white::b]Peer Gateway:[-:-:-] %s\n", extractNetworkName(tunnel.PeerGateway))) + b.WriteString(fmt.Sprintf("[white::b]Peer Gateway:[-:-:-] %s\n", extractNetworkName(tunnel.PeerGateway))) } if tunnel.RouterName != "" { - detailText.WriteString(fmt.Sprintf("[white::b]Router:[-:-:-] %s\n", tunnel.RouterName)) + b.WriteString(fmt.Sprintf("[white::b]Router:[-:-:-] %s\n", tunnel.RouterName)) } if tunnel.IkeVersion > 0 { - detailText.WriteString(fmt.Sprintf("[white::b]IKE Version:[-:-:-] %d\n", tunnel.IkeVersion)) + b.WriteString(fmt.Sprintf("[white::b]IKE Version:[-:-:-] %d\n", tunnel.IkeVersion)) } // BGP Sessions if len(tunnel.BgpSessions) > 0 { - detailText.WriteString("\n[yellow::b]BGP Sessions:[-:-:-]\n") + b.WriteString("\n[yellow::b]BGP Sessions:[-:-:-]\n") for _, bgp := range tunnel.BgpSessions { - detailText.WriteString(fmt.Sprintf("\n [white::b]%s[-:-:-]\n", bgp.Name)) - detailText.WriteString(fmt.Sprintf(" Status: %s / %s\n", bgp.SessionStatus, bgp.SessionState)) - detailText.WriteString(fmt.Sprintf(" Local: %s (AS%d)\n", bgp.LocalIP, bgp.LocalASN)) - detailText.WriteString(fmt.Sprintf(" Peer: %s (AS%d)\n", bgp.PeerIP, bgp.PeerASN)) - detailText.WriteString(fmt.Sprintf(" Priority: %d\n", bgp.RoutePriority)) - detailText.WriteString(fmt.Sprintf(" Enabled: %v\n", bgp.Enabled)) + b.WriteString(fmt.Sprintf("\n [white::b]%s[-:-:-]\n", bgp.Name)) + b.WriteString(fmt.Sprintf(" Status: %s / %s\n", bgp.SessionStatus, bgp.SessionState)) + b.WriteString(fmt.Sprintf(" Local: %s (AS%d)\n", bgp.LocalIP, bgp.LocalASN)) + b.WriteString(fmt.Sprintf(" Peer: %s (AS%d)\n", bgp.PeerIP, bgp.PeerASN)) + b.WriteString(fmt.Sprintf(" Priority: %d\n", bgp.RoutePriority)) + b.WriteString(fmt.Sprintf(" Enabled: %v\n", bgp.Enabled)) if len(bgp.AdvertisedPrefixes) > 0 { - detailText.WriteString(fmt.Sprintf(" [green]Advertised:[-] %d prefixes\n", len(bgp.AdvertisedPrefixes))) + b.WriteString(fmt.Sprintf(" [green]Advertised:[-] %d prefixes\n", len(bgp.AdvertisedPrefixes))) for _, prefix := range bgp.AdvertisedPrefixes { - detailText.WriteString(fmt.Sprintf(" %s\n", prefix)) + b.WriteString(fmt.Sprintf(" %s\n", prefix)) } } else { - detailText.WriteString(" [gray]Advertised: 0 prefixes[-]\n") + b.WriteString(" [gray]Advertised: 0 prefixes[-]\n") } if len(bgp.LearnedPrefixes) > 0 { - detailText.WriteString(fmt.Sprintf(" [green]Learned:[-] %d prefixes\n", len(bgp.LearnedPrefixes))) + b.WriteString(fmt.Sprintf(" [green]Learned:[-] %d prefixes\n", len(bgp.LearnedPrefixes))) for _, prefix := range bgp.LearnedPrefixes { - detailText.WriteString(fmt.Sprintf(" %s\n", prefix)) + b.WriteString(fmt.Sprintf(" %s\n", prefix)) } } else { - detailText.WriteString(" [gray]Learned: 0 prefixes[-]\n") + b.WriteString(" [gray]Learned: 0 prefixes[-]\n") } } } - detailText.WriteString("\n[darkgray]Press Esc to close[-]") + b.WriteString("\n[darkgray]Press Esc to close[-]") + return b.String() +} + +// buildBGPDetailText builds the detail text for a BGP session +func buildBGPDetailText(bgp *gcp.BGPSessionInfo) string { + var b strings.Builder + + b.WriteString("[yellow::b]BGP Session Details[-:-:-]\n\n") + b.WriteString(fmt.Sprintf("[white::b]Name:[-:-:-] %s\n", bgp.Name)) + b.WriteString(fmt.Sprintf("[white::b]Region:[-:-:-] %s\n", bgp.Region)) + b.WriteString(fmt.Sprintf("[white::b]Router:[-:-:-] %s\n", bgp.RouterName)) + b.WriteString(fmt.Sprintf("[white::b]Interface:[-:-:-] %s\n", bgp.Interface)) + b.WriteString(fmt.Sprintf("[white::b]Session Status:[-:-:-] %s\n", bgp.SessionStatus)) + b.WriteString(fmt.Sprintf("[white::b]Session State:[-:-:-] %s\n", bgp.SessionState)) + b.WriteString(fmt.Sprintf("[white::b]Enabled:[-:-:-] %v\n", bgp.Enabled)) + b.WriteString(fmt.Sprintf("\n[white::b]Local IP:[-:-:-] %s\n", bgp.LocalIP)) + b.WriteString(fmt.Sprintf("[white::b]Local ASN:[-:-:-] %d\n", bgp.LocalASN)) + b.WriteString(fmt.Sprintf("[white::b]Peer IP:[-:-:-] %s\n", bgp.PeerIP)) + b.WriteString(fmt.Sprintf("[white::b]Peer ASN:[-:-:-] %d\n", bgp.PeerASN)) + b.WriteString(fmt.Sprintf("[white::b]Route Priority:[-:-:-] %d\n", bgp.RoutePriority)) + + if bgp.AdvertisedMode != "" { + b.WriteString(fmt.Sprintf("[white::b]Advertise Mode:[-:-:-] %s\n", bgp.AdvertisedMode)) + } + + if len(bgp.AdvertisedGroups) > 0 { + b.WriteString(fmt.Sprintf("\n[white::b]Advertised Groups:[-:-:-] %s\n", strings.Join(bgp.AdvertisedGroups, ", "))) + } + if len(bgp.AdvertisedPrefixes) > 0 { + b.WriteString(fmt.Sprintf("\n[green::b]Advertised Prefixes (%d):[-:-:-]\n", len(bgp.AdvertisedPrefixes))) + for _, prefix := range bgp.AdvertisedPrefixes { + b.WriteString(fmt.Sprintf(" %s\n", prefix)) + } + } else { + b.WriteString("\n[gray]No advertised prefixes[-]\n") + } + + if len(bgp.LearnedPrefixes) > 0 { + b.WriteString(fmt.Sprintf("\n[green::b]Learned Prefixes (%d):[-:-:-]\n", len(bgp.LearnedPrefixes))) + for _, prefix := range bgp.LearnedPrefixes { + b.WriteString(fmt.Sprintf(" %s\n", prefix)) + } + } else { + b.WriteString("\n[gray]No learned prefixes[-]\n") + } + + if len(bgp.BestRoutePrefixes) > 0 { + b.WriteString(fmt.Sprintf("\n[yellow::b]Best Routes (%d):[-:-:-]\n", len(bgp.BestRoutePrefixes))) + for _, prefix := range bgp.BestRoutePrefixes { + b.WriteString(fmt.Sprintf(" %s\n", prefix)) + } + } + + b.WriteString("\n[darkgray]Press Esc to close[-]") + return b.String() +} + +// showDetailModal shows a detail modal with the given content +func showDetailModal(app *tview.Application, title, text string, onClose func()) { detailView := tview.NewTextView(). SetDynamicColors(true). - SetText(detailText.String()). + SetText(text). SetScrollable(true). SetWordWrap(true) - detailView.SetBorder(true).SetTitle(fmt.Sprintf(" %s ", tunnel.Name)) + detailView.SetBorder(true).SetTitle(fmt.Sprintf(" %s ", title)) - // Create status bar for detail view detailStatus := tview.NewTextView(). SetDynamicColors(true). - SetText(" [yellow]Esc[-] back [yellow]up/down[-] scroll") + SetText(vpnStatusDetailScroll) - // Create fullscreen detail layout detailFlex := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(detailView, 0, 1, true). AddItem(detailStatus, 1, 0, false) - // Set up input handler detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEscape { if onClose != nil { @@ -684,16 +815,16 @@ func ShowVPNTunnelDetails(app *tview.Application, tunnel *gcp.VPNTunnelInfo, onC app.SetRoot(detailFlex, true).SetFocus(detailView) } +// showVPNDetail shows details for a VPN entry func showVPNDetail(app *tview.Application, table *tview.Table, mainFlex *tview.Flex, entry vpnEntry, modalOpen *bool, status *tview.TextView, currentFilter string) { - // Prepare onClose callback to restore the VPN view onClose := func() { *modalOpen = false app.SetRoot(mainFlex, true) app.SetFocus(table) if currentFilter != "" { - status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) + status.SetText(fmt.Sprintf(vpnStatusFilterActive, currentFilter)) } else { - status.SetText(" [yellow]Esc[-] back [yellow]d[-] details [yellow]r[-] refresh [yellow]/[-] filter [yellow]?[-] help") + status.SetText(vpnStatusDefaultNoR) } } @@ -704,130 +835,21 @@ func showVPNDetail(app *tview.Application, table *tview.Table, mainFlex *tview.F if entry.Gateway != nil { ShowVPNGatewayDetails(app, entry.Gateway, onClose) } - case "tunnel", "orphan-tunnel": if entry.Tunnel != nil { ShowVPNTunnelDetails(app, entry.Tunnel, onClose) } - case "bgp", "orphan-bgp": if entry.BGP != nil { - // For BGP sessions, keep inline formatting as they aren't searchable resources - var detailText strings.Builder - bgp := entry.BGP - detailText.WriteString("[yellow::b]BGP Session Details[-:-:-]\n\n") - detailText.WriteString(fmt.Sprintf("[white::b]Name:[-:-:-] %s\n", bgp.Name)) - detailText.WriteString(fmt.Sprintf("[white::b]Region:[-:-:-] %s\n", bgp.Region)) - detailText.WriteString(fmt.Sprintf("[white::b]Router:[-:-:-] %s\n", bgp.RouterName)) - detailText.WriteString(fmt.Sprintf("[white::b]Interface:[-:-:-] %s\n", bgp.Interface)) - detailText.WriteString(fmt.Sprintf("[white::b]Session Status:[-:-:-] %s\n", bgp.SessionStatus)) - detailText.WriteString(fmt.Sprintf("[white::b]Session State:[-:-:-] %s\n", bgp.SessionState)) - detailText.WriteString(fmt.Sprintf("[white::b]Enabled:[-:-:-] %v\n", bgp.Enabled)) - detailText.WriteString(fmt.Sprintf("\n[white::b]Local IP:[-:-:-] %s\n", bgp.LocalIP)) - detailText.WriteString(fmt.Sprintf("[white::b]Local ASN:[-:-:-] %d\n", bgp.LocalASN)) - detailText.WriteString(fmt.Sprintf("[white::b]Peer IP:[-:-:-] %s\n", bgp.PeerIP)) - detailText.WriteString(fmt.Sprintf("[white::b]Peer ASN:[-:-:-] %d\n", bgp.PeerASN)) - detailText.WriteString(fmt.Sprintf("[white::b]Route Priority:[-:-:-] %d\n", bgp.RoutePriority)) - - if bgp.AdvertisedMode != "" { - detailText.WriteString(fmt.Sprintf("[white::b]Advertise Mode:[-:-:-] %s\n", bgp.AdvertisedMode)) - } - - if len(bgp.AdvertisedGroups) > 0 { - detailText.WriteString(fmt.Sprintf("\n[white::b]Advertised Groups:[-:-:-] %s\n", strings.Join(bgp.AdvertisedGroups, ", "))) - } - - if len(bgp.AdvertisedPrefixes) > 0 { - detailText.WriteString(fmt.Sprintf("\n[green::b]Advertised Prefixes (%d):[-:-:-]\n", len(bgp.AdvertisedPrefixes))) - for _, prefix := range bgp.AdvertisedPrefixes { - detailText.WriteString(fmt.Sprintf(" %s\n", prefix)) - } - } else { - detailText.WriteString("\n[gray]No advertised prefixes[-]\n") - } - - if len(bgp.LearnedPrefixes) > 0 { - detailText.WriteString(fmt.Sprintf("\n[green::b]Learned Prefixes (%d):[-:-:-]\n", len(bgp.LearnedPrefixes))) - for _, prefix := range bgp.LearnedPrefixes { - detailText.WriteString(fmt.Sprintf(" %s\n", prefix)) - } - } else { - detailText.WriteString("\n[gray]No learned prefixes[-]\n") - } - - if len(bgp.BestRoutePrefixes) > 0 { - detailText.WriteString(fmt.Sprintf("\n[yellow::b]Best Routes (%d):[-:-:-]\n", len(bgp.BestRoutePrefixes))) - for _, prefix := range bgp.BestRoutePrefixes { - detailText.WriteString(fmt.Sprintf(" %s\n", prefix)) - } - } - - detailText.WriteString("\n[darkgray]Press Esc to close[-]") - - detailView := tview.NewTextView(). - SetDynamicColors(true). - SetText(detailText.String()). - SetScrollable(true). - SetWordWrap(true) - detailView.SetBorder(true).SetTitle(fmt.Sprintf(" %s ", entry.Name)) - - // Create status bar for detail view - detailStatus := tview.NewTextView(). - SetDynamicColors(true). - SetText(" [yellow]Esc[-] back [yellow]up/down[-] scroll") - - // Create fullscreen detail layout - detailFlex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(detailView, 0, 1, true). - AddItem(detailStatus, 1, 0, false) - - // Set up input handler - detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - onClose() - return nil - } - return event - }) - - app.SetRoot(detailFlex, true).SetFocus(detailView) + detailText := buildBGPDetailText(entry.BGP) + showDetailModal(app, entry.Name, detailText, onClose) } - default: - // Handle unknown type - var detailText strings.Builder - detailText.WriteString("[red]No details available[-]") - detailText.WriteString("\n[darkgray]Press Esc to close[-]") - - detailView := tview.NewTextView(). - SetDynamicColors(true). - SetText(detailText.String()). - SetScrollable(true). - SetWordWrap(true) - detailView.SetBorder(true).SetTitle(fmt.Sprintf(" %s ", entry.Name)) - - detailStatus := tview.NewTextView(). - SetDynamicColors(true). - SetText(" [yellow]Esc[-] back") - - detailFlex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(detailView, 0, 1, true). - AddItem(detailStatus, 1, 0, false) - - detailView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - onClose() - return nil - } - return event - }) - - app.SetRoot(detailFlex, true).SetFocus(detailView) + showDetailModal(app, entry.Name, "[red]No details available[-]\n\n[darkgray]Press Esc to close[-]", onClose) } } +// showVPNHelp displays the help screen func showVPNHelp(app *tview.Application, table *tview.Table, mainFlex *tview.Flex, modalOpen *bool, currentFilter string, status *tview.TextView) { helpText := `[yellow::b]VPN View - Keyboard Shortcuts[-:-:-] @@ -838,6 +860,7 @@ func showVPNHelp(app *tview.Application, table *tview.Table, mainFlex *tview.Fle [white]End/G[-] Jump to last item [yellow]Actions[-] + [white]b[-] Open selected item in Cloud Console [white]d[-] Show details for selected item [white]Shift+R[-] Refresh VPN data from GCP [white]/[-] Enter filter/search mode @@ -856,27 +879,24 @@ func showVPNHelp(app *tview.Application, table *tview.Table, mainFlex *tview.Fle SetTitle(" VPN Help "). SetTitleAlign(tview.AlignCenter) - // Create status bar for help view helpStatus := tview.NewTextView(). SetDynamicColors(true). - SetText(" [yellow]Esc[-] back [yellow]?[-] close help") + SetText(vpnStatusHelpClose) - // Create fullscreen help layout helpFlex := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(helpView, 0, 1, true). AddItem(helpStatus, 1, 0, false) - // Set up input handler helpView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEscape || (event.Key() == tcell.KeyRune && event.Rune() == '?') { *modalOpen = false app.SetRoot(mainFlex, true) app.SetFocus(table) if currentFilter != "" { - status.SetText(fmt.Sprintf(" [green]Filter active: %s[-]", currentFilter)) + status.SetText(fmt.Sprintf(vpnStatusFilterActive, currentFilter)) } else { - status.SetText(" [yellow]Esc[-] back [yellow]d[-] details [yellow]r[-] refresh [yellow]/[-] filter [yellow]?[-] help") + status.SetText(vpnStatusDefaultNoR) } return nil }