mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-30 05:44:37 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f5c1d3a5f | |||
| 035b1cdd01 | |||
| 32e88efd9a | |||
| 6d8677026f | |||
| b7a32e4aab |
@@ -0,0 +1,2 @@
|
|||||||
|
github: [lukaszraczylo]
|
||||||
|
custom: [monzo.me/lukaszraczylo]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
name: Autoupdate go.mod and go.sum
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *"
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: ">=1.21"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# This job is responsible for preparation of the build
|
||||||
|
# environment variables.
|
||||||
|
prepare:
|
||||||
|
name: Preparing build context
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
id: cache
|
||||||
|
with:
|
||||||
|
go-version: ${{env.GO_VERSION}}
|
||||||
|
cache-dependency-path: "**/*.sum"
|
||||||
|
|
||||||
|
- name: Go get dependencies
|
||||||
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
go get ./...
|
||||||
|
|
||||||
|
# This job is responsible for running tests and linting the codebase
|
||||||
|
test:
|
||||||
|
name: "Unit testing"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: golang:1
|
||||||
|
needs: [prepare]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Ensure full history is checked out
|
||||||
|
token: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{env.GO_VERSION}}
|
||||||
|
cache-dependency-path: "**/*.sum"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install ca-certificates make -y
|
||||||
|
update-ca-certificates
|
||||||
|
go mod tidy
|
||||||
|
go get -u -v ./...
|
||||||
|
go mod tidy -v
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: |
|
||||||
|
CI_RUN=${CI} make test
|
||||||
|
git config --global --add safe.directory /__w/kportal/kportal
|
||||||
|
|
||||||
|
- name: Commit changes
|
||||||
|
uses: stefanzweifel/git-auto-commit-action@v5
|
||||||
|
with:
|
||||||
|
commit_message: "Update go.mod and go.sum"
|
||||||
|
commit_options: "--no-verify --signoff"
|
||||||
|
file_pattern: "go.mod go.sum"
|
||||||
@@ -28,6 +28,9 @@ kportal manages multiple Kubernetes port-forwards with an interactive terminal i
|
|||||||
- **Label selectors** - Dynamic pod targeting using label selectors
|
- **Label selectors** - Dynamic pod targeting using label selectors
|
||||||
- **Port conflict detection** - Validates port availability with PID information
|
- **Port conflict detection** - Validates port availability with PID information
|
||||||
- **mDNS hostnames** - Access forwards via `.local` hostnames
|
- **mDNS hostnames** - Access forwards via `.local` hostnames
|
||||||
|
- **HTTP traffic logging** - Real-time HTTP request/response logging for debugging
|
||||||
|
- **Connection benchmarking** - Built-in HTTP benchmarking with latency statistics
|
||||||
|
- **Headless mode** - Background operation for scripting and automation
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
@@ -71,10 +74,12 @@ contexts:
|
|||||||
localPort: 5432
|
localPort: 5432
|
||||||
alias: prod-db
|
alias: prod-db
|
||||||
|
|
||||||
- resource: service/redis
|
- resource: service/api
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
port: 6379
|
port: 8080
|
||||||
localPort: 6379
|
localPort: 8080
|
||||||
|
alias: api
|
||||||
|
httpLog: true # Enable HTTP traffic logging
|
||||||
```
|
```
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
@@ -89,9 +94,11 @@ kportal
|
|||||||
|-----|--------|
|
|-----|--------|
|
||||||
| `↑↓` / `j/k` | Navigate |
|
| `↑↓` / `j/k` | Navigate |
|
||||||
| `Space` / `Enter` | Toggle forward |
|
| `Space` / `Enter` | Toggle forward |
|
||||||
| `a` | Add forward |
|
| `n` | Add new forward |
|
||||||
| `e` | Edit forward |
|
| `e` | Edit forward |
|
||||||
| `d` | Delete forward |
|
| `d` | Delete forward |
|
||||||
|
| `b` | Benchmark connection |
|
||||||
|
| `l` | View HTTP logs |
|
||||||
| `q` | Quit |
|
| `q` | Quit |
|
||||||
|
|
||||||
## 📖 Configuration
|
## 📖 Configuration
|
||||||
@@ -110,6 +117,7 @@ contexts:
|
|||||||
localPort: <local-port>
|
localPort: <local-port>
|
||||||
alias: <display-name> # optional
|
alias: <display-name> # optional
|
||||||
selector: <label-selector> # optional
|
selector: <label-selector> # optional
|
||||||
|
httpLog: true # optional - enable HTTP logging
|
||||||
```
|
```
|
||||||
|
|
||||||
### Forward Options
|
### Forward Options
|
||||||
@@ -122,6 +130,7 @@ contexts:
|
|||||||
| `localPort` | Yes | Local port |
|
| `localPort` | Yes | Local port |
|
||||||
| `alias` | No | Display name and mDNS hostname |
|
| `alias` | No | Display name and mDNS hostname |
|
||||||
| `selector` | No | Label selector for pod resolution |
|
| `selector` | No | Label selector for pod resolution |
|
||||||
|
| `httpLog` | No | Enable HTTP traffic logging (`true`/`false`) |
|
||||||
|
|
||||||
### Resource Formats
|
### Resource Formats
|
||||||
|
|
||||||
@@ -198,6 +207,20 @@ kportal
|
|||||||
kportal -v
|
kportal -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Headless Mode
|
||||||
|
|
||||||
|
Run without TUI for scripting and automation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kportal -headless
|
||||||
|
```
|
||||||
|
|
||||||
|
Combines well with verbose mode for background operation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kportal -headless -v &
|
||||||
|
```
|
||||||
|
|
||||||
### Validate Configuration
|
### Validate Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -222,6 +245,51 @@ kportal -c /path/to/config.yaml
|
|||||||
|
|
||||||
## Advanced Features
|
## Advanced Features
|
||||||
|
|
||||||
|
### HTTP Traffic Logging
|
||||||
|
|
||||||
|
Press `l` in the TUI to view real-time HTTP traffic for a selected forward. The log viewer shows:
|
||||||
|
|
||||||
|
| Column | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| TIME | Request timestamp |
|
||||||
|
| METHOD | HTTP method (GET, POST, etc.) |
|
||||||
|
| STATUS | Response status code |
|
||||||
|
| LATENCY | Request duration |
|
||||||
|
| PATH | Request path |
|
||||||
|
|
||||||
|
**Keyboard shortcuts:**
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `↑/↓` | Navigate entries |
|
||||||
|
| `g/G` | Jump to top/bottom |
|
||||||
|
| `a` | Toggle auto-scroll |
|
||||||
|
| `f` | Cycle filter mode (All → Non-2xx → Errors) |
|
||||||
|
| `/` | Search by path or method |
|
||||||
|
| `c` | Clear all filters |
|
||||||
|
| `q` | Close log viewer |
|
||||||
|
|
||||||
|
**Filter modes:**
|
||||||
|
- **All** - Show all entries
|
||||||
|
- **Non-2xx** - Hide successful (2xx) responses
|
||||||
|
- **Errors** - Show only 4xx and 5xx responses
|
||||||
|
|
||||||
|
### Connection Benchmarking
|
||||||
|
|
||||||
|
Press `b` in the TUI to benchmark a selected forward. Configure:
|
||||||
|
|
||||||
|
- **URL Path** - Target endpoint (default: `/`)
|
||||||
|
- **Method** - HTTP method (GET, POST, etc.)
|
||||||
|
- **Concurrency** - Number of parallel workers
|
||||||
|
- **Requests** - Total number of requests
|
||||||
|
|
||||||
|
Results include:
|
||||||
|
- Success/failure counts
|
||||||
|
- Min/Max/Avg latency
|
||||||
|
- P50/P95/P99 percentiles
|
||||||
|
- Throughput (requests/sec)
|
||||||
|
- Status code distribution
|
||||||
|
|
||||||
### Hot-Reload
|
### Hot-Reload
|
||||||
|
|
||||||
Configuration changes are applied automatically. Manual reload:
|
Configuration changes are applied automatically. Manual reload:
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
# Release Infrastructure
|
|
||||||
|
|
||||||
Documentation for kportal's release automation and distribution.
|
|
||||||
|
|
||||||
## 🔄 CI/CD Pipeline
|
|
||||||
|
|
||||||
**File**: `.github/workflows/release.yml`
|
|
||||||
|
|
||||||
The pipeline builds multi-platform binaries, creates GitHub releases, and updates Homebrew on version tags.
|
|
||||||
|
|
||||||
### Trigger a Release
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git tag -a v0.2.0 -m "Release v0.2.0"
|
|
||||||
git push origin v0.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
The pipeline will:
|
|
||||||
1. Build binaries for all platforms
|
|
||||||
2. Create GitHub release with binaries and checksums
|
|
||||||
3. Update Homebrew tap formula
|
|
||||||
|
|
||||||
## 📦 Installation Methods
|
|
||||||
|
|
||||||
### Homebrew
|
|
||||||
|
|
||||||
**File**: `Formula/kportal.rb`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install lukaszraczylo/tap/kportal
|
|
||||||
```
|
|
||||||
|
|
||||||
Formula is automatically updated by CI/CD. Requires:
|
|
||||||
- Tap repository: `https://github.com/lukaszraczylo/brew-taps`
|
|
||||||
- Secret: `HOMEBREW_TAP_TOKEN` with `repo` scope
|
|
||||||
|
|
||||||
### Install Script
|
|
||||||
|
|
||||||
**File**: `install.sh`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Auto-detects OS/architecture and installs to `/usr/local/bin`.
|
|
||||||
|
|
||||||
### Manual Download
|
|
||||||
|
|
||||||
Download from [releases page](https://github.com/lukaszraczylo/kportal/releases).
|
|
||||||
|
|
||||||
## Platform Support
|
|
||||||
|
|
||||||
| OS | Architecture | Format |
|
|
||||||
|----|--------------|--------|
|
|
||||||
| Linux | amd64, arm64 | tar.gz |
|
|
||||||
| macOS | amd64, arm64 | tar.gz |
|
|
||||||
| Windows | amd64, arm64 | zip |
|
|
||||||
|
|
||||||
## 🚀 Release Process
|
|
||||||
|
|
||||||
1. **Make changes and test**
|
|
||||||
```bash
|
|
||||||
make test && make all
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Update CHANGELOG.md**
|
|
||||||
|
|
||||||
3. **Tag and push**
|
|
||||||
```bash
|
|
||||||
git tag -a v0.2.0 -m "Release v0.2.0"
|
|
||||||
git push origin main
|
|
||||||
git push origin v0.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Version Bumping
|
|
||||||
|
|
||||||
Version determined by commit message keywords:
|
|
||||||
|
|
||||||
| Bump | Keywords |
|
|
||||||
|------|----------|
|
|
||||||
| Patch (0.0.X) | `fix`, `bugfix`, `docs`, `test`, `refactor` |
|
|
||||||
| Minor (0.X.0) | `feat`, `feature`, `add`, `enhance`, `update` |
|
|
||||||
| Major (X.0.0) | `breaking`, `major`, `BREAKING CHANGE` |
|
|
||||||
|
|
||||||
## Required Secrets
|
|
||||||
|
|
||||||
| Secret | Purpose |
|
|
||||||
|--------|---------|
|
|
||||||
| `GITHUB_TOKEN` | Provided by GitHub Actions |
|
|
||||||
| `HOMEBREW_TAP_TOKEN` | Personal access token with `repo` scope |
|
|
||||||
|
|
||||||
## ⚙️ Initial Setup
|
|
||||||
|
|
||||||
### 1. Enable GitHub Pages
|
|
||||||
|
|
||||||
Repository Settings → Pages → Source: main branch, /docs folder
|
|
||||||
|
|
||||||
### 2. Create Homebrew Tap
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh repo create lukaszraczylo/brew-taps --public
|
|
||||||
cd brew-taps
|
|
||||||
mkdir Formula
|
|
||||||
# Formula will be auto-updated by CI
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Add Token Secret
|
|
||||||
|
|
||||||
Repository Settings → Secrets → Actions → New secret:
|
|
||||||
- Name: `HOMEBREW_TAP_TOKEN`
|
|
||||||
- Value: Personal access token with `repo` scope
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
|
||||||
|
|
||||||
### Release workflow fails
|
|
||||||
- Check GitHub Actions logs
|
|
||||||
- Verify secrets are configured
|
|
||||||
- Ensure tag follows `v\d+.\d+.\d+` format
|
|
||||||
|
|
||||||
### Homebrew not updating
|
|
||||||
- Verify `HOMEBREW_TAP_TOKEN` is valid
|
|
||||||
- Check tap repository permissions
|
|
||||||
|
|
||||||
### Install script fails
|
|
||||||
- Verify release binaries are attached
|
|
||||||
- Check binary naming matches script expectations
|
|
||||||
+160
-30
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/nvm/kportal/internal/config"
|
"github.com/nvm/kportal/internal/config"
|
||||||
"github.com/nvm/kportal/internal/converter"
|
"github.com/nvm/kportal/internal/converter"
|
||||||
"github.com/nvm/kportal/internal/forward"
|
"github.com/nvm/kportal/internal/forward"
|
||||||
|
"github.com/nvm/kportal/internal/httplog"
|
||||||
"github.com/nvm/kportal/internal/k8s"
|
"github.com/nvm/kportal/internal/k8s"
|
||||||
"github.com/nvm/kportal/internal/logger"
|
"github.com/nvm/kportal/internal/logger"
|
||||||
"github.com/nvm/kportal/internal/mdns"
|
"github.com/nvm/kportal/internal/mdns"
|
||||||
@@ -38,6 +39,7 @@ const (
|
|||||||
var (
|
var (
|
||||||
configFile = flag.String("c", defaultConfigFile, "Path to configuration file")
|
configFile = flag.String("c", defaultConfigFile, "Path to configuration file")
|
||||||
verbose = flag.Bool("v", false, "Enable verbose logging")
|
verbose = flag.Bool("v", false, "Enable verbose logging")
|
||||||
|
headless = flag.Bool("headless", false, "Run in headless mode (no UI, for background/daemon use)")
|
||||||
logFormat = flag.String("log-format", "text", "Log format: text or json")
|
logFormat = flag.String("log-format", "text", "Log format: text or json")
|
||||||
check = flag.Bool("check", false, "Validate configuration and exit")
|
check = flag.Bool("check", false, "Validate configuration and exit")
|
||||||
showVersion = flag.Bool("version", false, "Show version and exit")
|
showVersion = flag.Bool("version", false, "Show version and exit")
|
||||||
@@ -91,7 +93,7 @@ func main() {
|
|||||||
logOutput = os.Stderr
|
logOutput = os.Stderr
|
||||||
} else {
|
} else {
|
||||||
logLevel = logger.LevelInfo
|
logLevel = logger.LevelInfo
|
||||||
logOutput = io.Discard // Silence logger in non-verbose mode to prevent UI corruption
|
logOutput = io.Discard // Silence logger in non-verbose/headless mode to prevent UI corruption
|
||||||
}
|
}
|
||||||
|
|
||||||
switch *logFormat {
|
switch *logFormat {
|
||||||
@@ -218,37 +220,20 @@ func main() {
|
|||||||
log.Printf("mDNS hostname publishing enabled - aliases will be accessible via <alias>.local")
|
log.Printf("mDNS hostname publishing enabled - aliases will be accessible via <alias>.local")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create UI (bubbletea for interactive, simple table for verbose)
|
// Create UI based on mode:
|
||||||
|
// - headless: no UI at all (background daemon)
|
||||||
|
// - verbose: simple table UI with logging
|
||||||
|
// - default: interactive bubbletea TUI
|
||||||
var bubbleTeaUI *ui.BubbleTeaUI
|
var bubbleTeaUI *ui.BubbleTeaUI
|
||||||
var tableUI *ui.TableUI
|
var tableUI *ui.TableUI
|
||||||
|
|
||||||
if !*verbose {
|
if *headless {
|
||||||
// Interactive mode with bubbletea
|
// Headless mode - no UI, just run forwards in background
|
||||||
bubbleTeaUI = ui.NewBubbleTeaUI(func(id string, enable bool) {
|
// StatusUI remains nil, manager will handle this gracefully
|
||||||
if enable {
|
if *verbose {
|
||||||
manager.EnableForward(id)
|
log.Printf("Running in headless mode with verbose logging")
|
||||||
} else {
|
}
|
||||||
manager.DisableForward(id)
|
} else if *verbose {
|
||||||
}
|
|
||||||
}, appVersion)
|
|
||||||
|
|
||||||
// Set wizard dependencies
|
|
||||||
// Note: mutator is always available (for delete/edit), discovery requires valid kubeconfig (for add)
|
|
||||||
bubbleTeaUI.SetWizardDependencies(discovery, mutator, *configFile)
|
|
||||||
|
|
||||||
// Check for updates in background (non-blocking)
|
|
||||||
go func() {
|
|
||||||
checker := version.NewChecker(githubOwner, githubRepo, appVersion)
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if update := checker.CheckForUpdate(ctx); update != nil {
|
|
||||||
bubbleTeaUI.SetUpdateAvailable(update.LatestVersion, update.ReleaseURL)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
manager.SetStatusUI(bubbleTeaUI)
|
|
||||||
} else {
|
|
||||||
// Verbose mode with simple table
|
// Verbose mode with simple table
|
||||||
tableUI = ui.NewTableUI(*verbose)
|
tableUI = ui.NewTableUI(*verbose)
|
||||||
manager.SetStatusUI(tableUI)
|
manager.SetStatusUI(tableUI)
|
||||||
@@ -264,6 +249,68 @@ func main() {
|
|||||||
update.LatestVersion, update.CurrentVersion, update.ReleaseURL)
|
update.LatestVersion, update.CurrentVersion, update.ReleaseURL)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
} else {
|
||||||
|
// Interactive mode with bubbletea
|
||||||
|
bubbleTeaUI = ui.NewBubbleTeaUI(func(id string, enable bool) {
|
||||||
|
if enable {
|
||||||
|
manager.EnableForward(id)
|
||||||
|
} else {
|
||||||
|
manager.DisableForward(id)
|
||||||
|
}
|
||||||
|
}, appVersion)
|
||||||
|
|
||||||
|
// Set wizard dependencies
|
||||||
|
// Note: mutator is always available (for delete/edit), discovery requires valid kubeconfig (for add)
|
||||||
|
bubbleTeaUI.SetWizardDependencies(discovery, mutator, *configFile)
|
||||||
|
|
||||||
|
// Set HTTP log subscriber to enable live log viewing
|
||||||
|
bubbleTeaUI.SetHTTPLogSubscriber(func(forwardID string, callback func(entry ui.HTTPLogEntry)) func() {
|
||||||
|
worker := manager.GetWorker(forwardID)
|
||||||
|
if worker == nil {
|
||||||
|
return func() {} // No-op cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := worker.GetHTTPProxy()
|
||||||
|
if proxy == nil {
|
||||||
|
return func() {} // HTTP logging not enabled for this forward
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyLogger := proxy.GetLogger()
|
||||||
|
if proxyLogger == nil {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to log entries
|
||||||
|
proxyLogger.AddCallback(func(entry httplog.Entry) {
|
||||||
|
callback(ui.HTTPLogEntry{
|
||||||
|
Timestamp: entry.Timestamp.Format("15:04:05"),
|
||||||
|
Direction: entry.Direction,
|
||||||
|
Method: entry.Method,
|
||||||
|
Path: entry.Path,
|
||||||
|
StatusCode: entry.StatusCode,
|
||||||
|
LatencyMs: entry.LatencyMs,
|
||||||
|
BodySize: entry.BodySize,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return func() {
|
||||||
|
proxyLogger.ClearCallbacks()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check for updates in background (non-blocking)
|
||||||
|
go func() {
|
||||||
|
checker := version.NewChecker(githubOwner, githubRepo, appVersion)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if update := checker.CheckForUpdate(ctx); update != nil {
|
||||||
|
bubbleTeaUI.SetUpdateAvailable(update.LatestVersion, update.ReleaseURL)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
manager.SetStatusUI(bubbleTeaUI)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start forwards
|
// Start forwards
|
||||||
@@ -272,7 +319,90 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if *verbose {
|
if *headless {
|
||||||
|
// Headless mode - no UI, run as background daemon
|
||||||
|
// Setup signal handling
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
|
||||||
|
|
||||||
|
// Setup config watcher for hot-reload
|
||||||
|
watcher, err := config.NewWatcher(*configFile, func(newCfg *config.Config) error {
|
||||||
|
return manager.Reload(newCfg)
|
||||||
|
}, *verbose)
|
||||||
|
if err != nil {
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Warning: Failed to setup config watcher: %v", err)
|
||||||
|
log.Printf("Hot-reload will not be available")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
watcher.Start()
|
||||||
|
defer watcher.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Headless mode started. Press Ctrl+C to stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for signals
|
||||||
|
for {
|
||||||
|
sig := <-sigChan
|
||||||
|
switch sig {
|
||||||
|
case syscall.SIGHUP:
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Received SIGHUP, reloading configuration...")
|
||||||
|
}
|
||||||
|
newCfg, err := config.LoadConfig(*configFile)
|
||||||
|
if err != nil {
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Failed to reload config: %v", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if errs := validator.ValidateConfig(newCfg); len(errs) > 0 {
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Config validation failed:")
|
||||||
|
log.Print(config.FormatValidationErrors(errs))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := manager.Reload(newCfg); err != nil {
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Failed to reload: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case os.Interrupt, syscall.SIGTERM:
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Received shutdown signal, stopping...")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown with timeout
|
||||||
|
shutdownDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
manager.Stop()
|
||||||
|
close(shutdownDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-shutdownDone:
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Graceful shutdown complete")
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Shutdown timed out, forcing exit...")
|
||||||
|
}
|
||||||
|
case sig := <-sigChan:
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Received second signal (%v), forcing exit...", sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if *verbose {
|
||||||
// Verbose mode - use simple table with periodic updates
|
// Verbose mode - use simple table with periodic updates
|
||||||
tableUI.RenderInitial()
|
tableUI.RenderInitial()
|
||||||
|
|
||||||
|
|||||||
+79
-4
@@ -265,6 +265,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-teal-500 to-teal-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
|
||||||
|
<i class="fas fa-stream text-white"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">HTTP Traffic Logging</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Real-time HTTP logging with filters (Non-2xx, Errors, Search)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-pink-500 to-pink-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
|
||||||
|
<i class="fas fa-tachometer-alt text-white"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Connection Benchmarking</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Built-in HTTP benchmarking with latency percentiles</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-slate-500 to-slate-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
|
||||||
|
<i class="fas fa-server text-white"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Headless Mode</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Background operation for scripting and automation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -359,6 +392,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Use a custom configuration file instead of .kportal.yaml</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">Use a custom configuration file instead of .kportal.yaml</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="glass p-6 rounded-xl">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"><i class="fas fa-server text-slate-500 mr-2"></i>Headless Mode</h3>
|
||||||
|
<div onclick="copyToClipboard('kportal -headless -v &', this)" class="relative bg-gradient-to-br from-gray-900 to-gray-800 text-gray-100 p-4 rounded-lg text-sm cursor-pointer group border border-gray-700 hover:border-slate-500 transition-all duration-300 mb-3">
|
||||||
|
<code class="font-mono">kportal -headless -v &</code>
|
||||||
|
<div class="absolute top-3 right-3"><i class="fas fa-copy text-gray-500 group-hover:text-slate-400 transition-colors"></i></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Run without TUI for scripting and background operation.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Keyboard Shortcuts -->
|
<!-- Keyboard Shortcuts -->
|
||||||
@@ -390,12 +431,12 @@
|
|||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Vim navigation</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Vim navigation</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg">
|
<div class="flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg">
|
||||||
<kbd class="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono font-semibold">q</kbd>
|
<kbd class="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono font-semibold">b</kbd>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Quit</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Benchmark</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg">
|
<div class="flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg">
|
||||||
<kbd class="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono font-semibold">?</kbd>
|
<kbd class="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono font-semibold">l</kbd>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Help</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">HTTP logs</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -604,6 +645,40 @@ contexts:
|
|||||||
<span class="px-2 py-1 bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300 rounded">10s max</span>
|
<span class="px-2 py-1 bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300 rounded">10s max</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- HTTP Traffic Logging -->
|
||||||
|
<div class="glass p-6 rounded-xl">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"><i class="fas fa-stream text-teal-500 mr-2"></i>HTTP Traffic Logging</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Press <kbd class="px-2 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">l</kbd> to view real-time HTTP traffic for debugging.</p>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div class="text-gray-700 dark:text-gray-300">
|
||||||
|
<span class="font-medium">Columns:</span> Time, Method, Status, Latency, Path
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">f</kbd>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Filter mode</span>
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">/</kbd>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Search</span>
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">c</kbd>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Clear</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-300">Filters:</span> All, Non-2xx, Errors (4xx/5xx)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection Benchmarking -->
|
||||||
|
<div class="glass p-6 rounded-xl">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"><i class="fas fa-tachometer-alt text-pink-500 mr-2"></i>Connection Benchmarking</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Press <kbd class="px-2 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">b</kbd> to benchmark a connection with configurable parameters.</p>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex justify-between"><span class="text-gray-600 dark:text-gray-400">Concurrency</span><span class="text-gray-900 dark:text-gray-100">Parallel workers</span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-gray-600 dark:text-gray-400">Requests</span><span class="text-gray-900 dark:text-gray-100">Total request count</span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-gray-600 dark:text-gray-400">Latency</span><span class="text-gray-900 dark:text-gray-100">P50/P95/P99 percentiles</span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-gray-600 dark:text-gray-400">Throughput</span><span class="text-gray-900 dark:text-gray-100">Requests per second</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ require (
|
|||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
|
github.com/go-logr/logr v1.4.3
|
||||||
|
github.com/grandcat/zeroconf v1.0.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/term v0.37.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
k8s.io/api v0.34.2
|
k8s.io/api v0.34.2
|
||||||
k8s.io/apimachinery v0.34.2
|
k8s.io/apimachinery v0.34.2
|
||||||
@@ -26,11 +27,9 @@ require (
|
|||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
|
||||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||||
github.com/go-openapi/swag v0.25.3 // indirect
|
github.com/go-openapi/swag v0.25.3 // indirect
|
||||||
@@ -49,13 +48,12 @@ require (
|
|||||||
github.com/google/gnostic-models v0.7.1 // indirect
|
github.com/google/gnostic-models v0.7.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||||
github.com/grandcat/zeroconf v1.0.0 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
github.com/miekg/dns v1.1.27 // indirect
|
github.com/miekg/dns v1.1.68 // indirect
|
||||||
github.com/moby/spdystream v0.5.0 // indirect
|
github.com/moby/spdystream v0.5.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
@@ -71,16 +69,19 @@ require (
|
|||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.44.0 // indirect
|
golang.org/x/mod v0.30.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/oauth2 v0.33.0 // indirect
|
golang.org/x/oauth2 v0.33.0 // indirect
|
||||||
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
golang.org/x/term v0.37.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
|
golang.org/x/tools v0.39.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20251121143641-b6aabc6c6745 // indirect
|
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect
|
||||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
@@ -104,8 +102,9 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
|
|||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/miekg/dns v1.1.27 h1:aEH/kqUzUxGJ/UHcEKdJY+ugH6WEzsEBBSPa8zuy1aM=
|
|
||||||
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
|
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
|
||||||
|
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
|
||||||
|
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
||||||
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
|
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
|
||||||
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -128,6 +127,7 @@ github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM
|
|||||||
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||||
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||||
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
@@ -156,13 +156,13 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
|||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
|
||||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
|
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
@@ -176,6 +176,8 @@ golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
|
|||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -197,8 +199,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
|
|||||||
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -222,8 +224,8 @@ k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
|
|||||||
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
|
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
|
||||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||||
k8s.io/kube-openapi v0.0.0-20251121143641-b6aabc6c6745 h1:c3rI/4s8ibM4vV5UOIlbgkBpwkylI5I9YiPlOtf2g4Q=
|
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
|
||||||
k8s.io/kube-openapi v0.0.0-20251121143641-b6aabc6c6745/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package benchmark
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Results holds the aggregated results of a benchmark run
|
||||||
|
type Results struct {
|
||||||
|
ForwardID string `json:"forward_id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
StartTime time.Time `json:"start_time"`
|
||||||
|
EndTime time.Time `json:"end_time"`
|
||||||
|
TotalRequests int `json:"total_requests"`
|
||||||
|
Successful int `json:"successful"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Latencies []time.Duration `json:"-"` // Raw latencies for percentile calculation
|
||||||
|
StatusCodes map[int]int `json:"status_codes"`
|
||||||
|
Errors map[string]int `json:"errors,omitempty"`
|
||||||
|
BytesRead int64 `json:"bytes_read"`
|
||||||
|
BytesWritten int64 `json:"bytes_written"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats holds calculated statistics
|
||||||
|
type Stats struct {
|
||||||
|
MinLatency time.Duration `json:"min_latency_ms"`
|
||||||
|
MaxLatency time.Duration `json:"max_latency_ms"`
|
||||||
|
AvgLatency time.Duration `json:"avg_latency_ms"`
|
||||||
|
P50Latency time.Duration `json:"p50_latency_ms"`
|
||||||
|
P95Latency time.Duration `json:"p95_latency_ms"`
|
||||||
|
P99Latency time.Duration `json:"p99_latency_ms"`
|
||||||
|
Throughput float64 `json:"throughput_rps"`
|
||||||
|
Duration time.Duration `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewResults creates a new Results instance
|
||||||
|
func NewResults(forwardID, url, method string) *Results {
|
||||||
|
return &Results{
|
||||||
|
ForwardID: forwardID,
|
||||||
|
URL: url,
|
||||||
|
Method: method,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
Latencies: make([]time.Duration, 0),
|
||||||
|
StatusCodes: make(map[int]int),
|
||||||
|
Errors: make(map[string]int),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSuccess records a successful HTTP request (transport succeeded)
|
||||||
|
// Note: only 2xx status codes are counted as successful for statistics
|
||||||
|
func (r *Results) RecordSuccess(statusCode int, latency time.Duration, bytesRead, bytesWritten int64) {
|
||||||
|
r.TotalRequests++
|
||||||
|
// Only count 2xx as successful
|
||||||
|
if statusCode >= 200 && statusCode < 300 {
|
||||||
|
r.Successful++
|
||||||
|
} else {
|
||||||
|
r.Failed++
|
||||||
|
}
|
||||||
|
r.Latencies = append(r.Latencies, latency)
|
||||||
|
r.StatusCodes[statusCode]++
|
||||||
|
r.BytesRead += bytesRead
|
||||||
|
r.BytesWritten += bytesWritten
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordFailure records a failed request
|
||||||
|
func (r *Results) RecordFailure(err error, latency time.Duration) {
|
||||||
|
r.TotalRequests++
|
||||||
|
r.Failed++
|
||||||
|
r.Latencies = append(r.Latencies, latency)
|
||||||
|
r.Errors[err.Error()]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize marks the benchmark as complete
|
||||||
|
func (r *Results) Finalize() {
|
||||||
|
r.EndTime = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateStats calculates statistics from the results
|
||||||
|
func (r *Results) CalculateStats() Stats {
|
||||||
|
stats := Stats{
|
||||||
|
Duration: r.EndTime.Sub(r.StartTime),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(r.Latencies) == 0 {
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort latencies for percentile calculation
|
||||||
|
sorted := make([]time.Duration, len(r.Latencies))
|
||||||
|
copy(sorted, r.Latencies)
|
||||||
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
|
return sorted[i] < sorted[j]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate min, max, avg
|
||||||
|
var total time.Duration
|
||||||
|
stats.MinLatency = sorted[0]
|
||||||
|
stats.MaxLatency = sorted[len(sorted)-1]
|
||||||
|
|
||||||
|
for _, lat := range sorted {
|
||||||
|
total += lat
|
||||||
|
}
|
||||||
|
stats.AvgLatency = total / time.Duration(len(sorted))
|
||||||
|
|
||||||
|
// Calculate percentiles
|
||||||
|
stats.P50Latency = percentile(sorted, 50)
|
||||||
|
stats.P95Latency = percentile(sorted, 95)
|
||||||
|
stats.P99Latency = percentile(sorted, 99)
|
||||||
|
|
||||||
|
// Calculate throughput
|
||||||
|
if stats.Duration > 0 {
|
||||||
|
stats.Throughput = float64(r.TotalRequests) / stats.Duration.Seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// percentile calculates the p-th percentile of sorted durations
|
||||||
|
func percentile(sorted []time.Duration, p int) time.Duration {
|
||||||
|
if len(sorted) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := (p * len(sorted)) / 100
|
||||||
|
if idx >= len(sorted) {
|
||||||
|
idx = len(sorted) - 1
|
||||||
|
}
|
||||||
|
return sorted[idx]
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package benchmark
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProgressCallback is called periodically with benchmark progress
|
||||||
|
type ProgressCallback func(completed, total int)
|
||||||
|
|
||||||
|
// Config holds the benchmark configuration
|
||||||
|
type Config struct {
|
||||||
|
URL string // Target URL
|
||||||
|
Method string // HTTP method
|
||||||
|
Headers map[string]string // Custom headers
|
||||||
|
Body []byte // Request body
|
||||||
|
Concurrency int // Number of concurrent workers
|
||||||
|
Requests int // Total number of requests (0 = use duration)
|
||||||
|
Duration time.Duration // Duration to run (0 = use requests)
|
||||||
|
Timeout time.Duration // Request timeout
|
||||||
|
ProgressCallback ProgressCallback // Optional callback for progress updates
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a default benchmark configuration
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Method: "GET",
|
||||||
|
Concurrency: 10,
|
||||||
|
Requests: 100,
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner executes HTTP benchmarks
|
||||||
|
type Runner struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunner creates a new benchmark runner
|
||||||
|
func NewRunner() *Runner {
|
||||||
|
return &Runner{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 100,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the benchmark and returns results
|
||||||
|
func (r *Runner) Run(ctx context.Context, forwardID string, cfg Config) (*Results, error) {
|
||||||
|
if cfg.URL == "" {
|
||||||
|
return nil, fmt.Errorf("URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Concurrency < 1 {
|
||||||
|
cfg.Concurrency = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure concurrency doesn't exceed number of requests (for request-based mode)
|
||||||
|
if cfg.Duration == 0 && cfg.Requests > 0 && cfg.Concurrency > cfg.Requests {
|
||||||
|
cfg.Concurrency = cfg.Requests
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Timeout > 0 {
|
||||||
|
r.client.Timeout = cfg.Timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
results := NewResults(forwardID, cfg.URL, cfg.Method)
|
||||||
|
|
||||||
|
// Create work channel
|
||||||
|
workCh := make(chan struct{}, cfg.Concurrency*2)
|
||||||
|
|
||||||
|
// Create context for cancellation
|
||||||
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Start workers
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var completed int64
|
||||||
|
var resultsMu sync.Mutex // Shared mutex for results access
|
||||||
|
|
||||||
|
for i := 0; i < cfg.Concurrency; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
r.worker(runCtx, cfg, results, &resultsMu, workCh, &completed)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start progress reporter if callback is provided
|
||||||
|
if cfg.ProgressCallback != nil {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-runCtx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
cfg.ProgressCallback(int(atomic.LoadInt64(&completed)), cfg.Requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine how to dispatch work
|
||||||
|
if cfg.Duration > 0 {
|
||||||
|
// Duration-based: keep sending work until duration expires
|
||||||
|
timer := time.NewTimer(cfg.Duration)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
dispatchLoop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
cancel()
|
||||||
|
break dispatchLoop
|
||||||
|
case <-ctx.Done():
|
||||||
|
cancel()
|
||||||
|
break dispatchLoop
|
||||||
|
case workCh <- struct{}{}:
|
||||||
|
// Work dispatched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Request-based: send exactly N requests
|
||||||
|
requestLoop:
|
||||||
|
for i := 0; i < cfg.Requests; i++ {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
cancel()
|
||||||
|
break requestLoop
|
||||||
|
case workCh <- struct{}{}:
|
||||||
|
// Work dispatched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close work channel and wait for workers
|
||||||
|
close(workCh)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
results.Finalize()
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// worker processes requests from the work channel
|
||||||
|
func (r *Runner) worker(ctx context.Context, cfg Config, results *Results, resultsMu *sync.Mutex, workCh <-chan struct{}, completed *int64) {
|
||||||
|
for range workCh {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
statusCode, bytesRead, bytesWritten, err := r.makeRequestSafe(ctx, cfg)
|
||||||
|
latency := time.Since(start)
|
||||||
|
|
||||||
|
resultsMu.Lock()
|
||||||
|
if err != nil {
|
||||||
|
results.RecordFailure(err, latency)
|
||||||
|
} else {
|
||||||
|
results.RecordSuccess(statusCode, latency, bytesRead, bytesWritten)
|
||||||
|
}
|
||||||
|
resultsMu.Unlock()
|
||||||
|
|
||||||
|
atomic.AddInt64(completed, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeRequestSafe wraps makeRequest with panic recovery
|
||||||
|
func (r *Runner) makeRequestSafe(ctx context.Context, cfg Config) (statusCode int, bytesRead, bytesWritten int64, err error) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
err = fmt.Errorf("request panic: %v", rec)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return r.makeRequest(ctx, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeRequest makes a single HTTP request
|
||||||
|
func (r *Runner) makeRequest(ctx context.Context, cfg Config) (statusCode int, bytesRead, bytesWritten int64, err error) {
|
||||||
|
var body io.Reader
|
||||||
|
if len(cfg.Body) > 0 {
|
||||||
|
body = bytes.NewReader(cfg.Body)
|
||||||
|
bytesWritten = int64(len(cfg.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, cfg.Method, cfg.URL, body)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
for k, v := range cfg.Headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := r.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, bytesWritten, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Read response body to measure bytes
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return resp.StatusCode, 0, bytesWritten, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.StatusCode, int64(len(respBody)), bytesWritten, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress represents the current progress of a benchmark run
|
||||||
|
type Progress struct {
|
||||||
|
Completed int
|
||||||
|
Total int
|
||||||
|
Elapsed time.Duration
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package benchmark
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResults(t *testing.T) {
|
||||||
|
r := NewResults("test-forward", "http://localhost/test", "GET")
|
||||||
|
|
||||||
|
// Record some 2xx successes
|
||||||
|
r.RecordSuccess(200, 10*time.Millisecond, 100, 0)
|
||||||
|
r.RecordSuccess(200, 20*time.Millisecond, 150, 0)
|
||||||
|
r.RecordSuccess(201, 15*time.Millisecond, 120, 0)
|
||||||
|
|
||||||
|
// Record a transport failure
|
||||||
|
r.RecordFailure(assert.AnError, 5*time.Millisecond)
|
||||||
|
|
||||||
|
r.Finalize()
|
||||||
|
|
||||||
|
assert.Equal(t, 4, r.TotalRequests)
|
||||||
|
assert.Equal(t, 3, r.Successful)
|
||||||
|
assert.Equal(t, 1, r.Failed)
|
||||||
|
assert.Equal(t, int64(370), r.BytesRead)
|
||||||
|
assert.Equal(t, 2, r.StatusCodes[200])
|
||||||
|
assert.Equal(t, 1, r.StatusCodes[201])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultsNon2xxCountsAsFailure(t *testing.T) {
|
||||||
|
r := NewResults("test-forward", "http://localhost/test", "GET")
|
||||||
|
|
||||||
|
// Record a 200 success
|
||||||
|
r.RecordSuccess(200, 10*time.Millisecond, 100, 0)
|
||||||
|
|
||||||
|
// Record 4xx and 5xx - these should count as failures
|
||||||
|
r.RecordSuccess(404, 10*time.Millisecond, 50, 0)
|
||||||
|
r.RecordSuccess(500, 10*time.Millisecond, 30, 0)
|
||||||
|
|
||||||
|
r.Finalize()
|
||||||
|
|
||||||
|
assert.Equal(t, 3, r.TotalRequests)
|
||||||
|
assert.Equal(t, 1, r.Successful, "Only 2xx should count as successful")
|
||||||
|
assert.Equal(t, 2, r.Failed, "4xx and 5xx should count as failed")
|
||||||
|
assert.Equal(t, 1, r.StatusCodes[200])
|
||||||
|
assert.Equal(t, 1, r.StatusCodes[404])
|
||||||
|
assert.Equal(t, 1, r.StatusCodes[500])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultsStats(t *testing.T) {
|
||||||
|
r := NewResults("test", "http://localhost", "GET")
|
||||||
|
|
||||||
|
// Add latencies
|
||||||
|
latencies := []time.Duration{
|
||||||
|
10 * time.Millisecond,
|
||||||
|
20 * time.Millisecond,
|
||||||
|
30 * time.Millisecond,
|
||||||
|
40 * time.Millisecond,
|
||||||
|
50 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, lat := range latencies {
|
||||||
|
r.RecordSuccess(200, lat, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.EndTime = r.StartTime.Add(1 * time.Second)
|
||||||
|
|
||||||
|
stats := r.CalculateStats()
|
||||||
|
|
||||||
|
assert.Equal(t, 10*time.Millisecond, stats.MinLatency)
|
||||||
|
assert.Equal(t, 50*time.Millisecond, stats.MaxLatency)
|
||||||
|
assert.Equal(t, 30*time.Millisecond, stats.AvgLatency)
|
||||||
|
assert.Equal(t, float64(5), stats.Throughput)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPercentile(t *testing.T) {
|
||||||
|
sorted := []time.Duration{
|
||||||
|
1 * time.Millisecond,
|
||||||
|
2 * time.Millisecond,
|
||||||
|
3 * time.Millisecond,
|
||||||
|
4 * time.Millisecond,
|
||||||
|
5 * time.Millisecond,
|
||||||
|
6 * time.Millisecond,
|
||||||
|
7 * time.Millisecond,
|
||||||
|
8 * time.Millisecond,
|
||||||
|
9 * time.Millisecond,
|
||||||
|
10 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
// P50 = index 5 (50*10/100 = 5) = 6ms
|
||||||
|
assert.Equal(t, 6*time.Millisecond, percentile(sorted, 50))
|
||||||
|
// P95 = index 9 (95*10/100 = 9) = 10ms
|
||||||
|
assert.Equal(t, 10*time.Millisecond, percentile(sorted, 95))
|
||||||
|
// P99 = index 9 (99*10/100 = 9) = 10ms
|
||||||
|
assert.Equal(t, 10*time.Millisecond, percentile(sorted, 99))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunner(t *testing.T) {
|
||||||
|
// Create a test server
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(5 * time.Millisecond) // Simulate some latency
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
runner := NewRunner()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
URL: server.URL,
|
||||||
|
Method: "GET",
|
||||||
|
Concurrency: 2,
|
||||||
|
Requests: 10,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := runner.Run(context.Background(), "test-forward", cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 10, results.TotalRequests)
|
||||||
|
assert.Equal(t, 10, results.Successful)
|
||||||
|
assert.Equal(t, 0, results.Failed)
|
||||||
|
assert.Equal(t, 10, results.StatusCodes[200])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerWithDuration(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`ok`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
runner := NewRunner()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
URL: server.URL,
|
||||||
|
Method: "GET",
|
||||||
|
Concurrency: 2,
|
||||||
|
Duration: 100 * time.Millisecond,
|
||||||
|
Timeout: 1 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := runner.Run(context.Background(), "test-forward", cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should have made some requests in 100ms
|
||||||
|
assert.Greater(t, results.TotalRequests, 0)
|
||||||
|
assert.Equal(t, results.Successful, results.StatusCodes[200])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerWithHeaders(t *testing.T) {
|
||||||
|
var receivedHeader string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedHeader = r.Header.Get("X-Custom-Header")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
runner := NewRunner()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
URL: server.URL,
|
||||||
|
Method: "GET",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"X-Custom-Header": "test-value",
|
||||||
|
},
|
||||||
|
Concurrency: 1,
|
||||||
|
Requests: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := runner.Run(context.Background(), "test", cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "test-value", receivedHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerWithBody(t *testing.T) {
|
||||||
|
var receivedBody string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := http.MaxBytesReader(w, r.Body, 1024).Read(make([]byte, 1024))
|
||||||
|
_ = body
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
runner := NewRunner()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
URL: server.URL,
|
||||||
|
Method: "POST",
|
||||||
|
Body: []byte(`{"test":"data"}`),
|
||||||
|
Concurrency: 1,
|
||||||
|
Requests: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := runner.Run(context.Background(), "test", cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_ = receivedBody // Used for debugging
|
||||||
|
assert.Equal(t, int64(15), results.BytesWritten)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
assert.Equal(t, "GET", cfg.Method)
|
||||||
|
assert.Equal(t, 10, cfg.Concurrency)
|
||||||
|
assert.Equal(t, 100, cfg.Requests)
|
||||||
|
assert.Equal(t, 30*time.Second, cfg.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerWithProgressCallback(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(10 * time.Millisecond) // Add small delay so progress ticker can fire
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`ok`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
runner := NewRunner()
|
||||||
|
|
||||||
|
var progressUpdates []int
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
URL: server.URL,
|
||||||
|
Method: "GET",
|
||||||
|
Concurrency: 5,
|
||||||
|
Requests: 50, // More requests to ensure progress callbacks fire
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
ProgressCallback: func(completed, total int) {
|
||||||
|
mu.Lock()
|
||||||
|
progressUpdates = append(progressUpdates, completed)
|
||||||
|
mu.Unlock()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := runner.Run(context.Background(), "test-forward", cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 50, results.TotalRequests)
|
||||||
|
|
||||||
|
// Should have received some progress updates (ticker fires every 100ms)
|
||||||
|
mu.Lock()
|
||||||
|
updates := len(progressUpdates)
|
||||||
|
mu.Unlock()
|
||||||
|
assert.Greater(t, updates, 0, "Should have received progress updates")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerConcurrencyCappedAtRequests(t *testing.T) {
|
||||||
|
requestCount := 0
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
requestCount++
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
runner := NewRunner()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
URL: server.URL,
|
||||||
|
Method: "GET",
|
||||||
|
Concurrency: 100, // Higher than requests
|
||||||
|
Requests: 5,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := runner.Run(context.Background(), "test", cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 5, results.TotalRequests)
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ const (
|
|||||||
DefaultTCPKeepalive = 30 * time.Second // OS-level TCP keepalive interval
|
DefaultTCPKeepalive = 30 * time.Second // OS-level TCP keepalive interval
|
||||||
DefaultDialTimeout = 30 * time.Second // Connection establishment timeout
|
DefaultDialTimeout = 30 * time.Second // Connection establishment timeout
|
||||||
DefaultWatchdogPeriod = 30 * time.Second // Goroutine health check interval
|
DefaultWatchdogPeriod = 30 * time.Second // Goroutine health check interval
|
||||||
|
|
||||||
|
// Default HTTP logging settings
|
||||||
|
DefaultHTTPLogMaxBodySize = 1024 * 1024 // 1MB max body size for logging
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the root configuration structure from .kportal.yaml
|
// Config represents the root configuration structure from .kportal.yaml
|
||||||
@@ -158,14 +161,44 @@ type Namespace struct {
|
|||||||
Forwards []Forward `yaml:"forwards"`
|
Forwards []Forward `yaml:"forwards"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTPLogSpec configures HTTP traffic logging for a forward
|
||||||
|
type HTTPLogSpec struct {
|
||||||
|
Enabled bool `yaml:"enabled"` // Enable HTTP logging
|
||||||
|
LogFile string `yaml:"logFile,omitempty"` // Output file (empty = stdout)
|
||||||
|
MaxBodySize int `yaml:"maxBodySize,omitempty"` // Max body size to log (default 1MB)
|
||||||
|
IncludeHeaders bool `yaml:"includeHeaders,omitempty"` // Include headers in log
|
||||||
|
FilterPath string `yaml:"filterPath,omitempty"` // Optional glob filter for paths
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalYAML implements custom unmarshaling to support both bool and struct formats
|
||||||
|
// Allows: httpLog: true OR httpLog: { enabled: true, ... }
|
||||||
|
func (h *HTTPLogSpec) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||||
|
// First try to unmarshal as a boolean
|
||||||
|
var boolVal bool
|
||||||
|
if err := unmarshal(&boolVal); err == nil {
|
||||||
|
h.Enabled = boolVal
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise try to unmarshal as a struct
|
||||||
|
type httpLogSpecAlias HTTPLogSpec // Use alias to avoid infinite recursion
|
||||||
|
var spec httpLogSpecAlias
|
||||||
|
if err := unmarshal(&spec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*h = HTTPLogSpec(spec)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Forward represents a single port-forward configuration
|
// Forward represents a single port-forward configuration
|
||||||
type Forward struct {
|
type Forward struct {
|
||||||
Resource string `yaml:"resource"` // e.g., "pod/my-app", "service/postgres", "pod"
|
Resource string `yaml:"resource"` // e.g., "pod/my-app", "service/postgres", "pod"
|
||||||
Selector string `yaml:"selector"` // Label selector for pod resolution (e.g., "app=nginx,env=prod")
|
Selector string `yaml:"selector"` // Label selector for pod resolution (e.g., "app=nginx,env=prod")
|
||||||
Protocol string `yaml:"protocol"` // tcp or udp
|
Protocol string `yaml:"protocol"` // tcp or udp
|
||||||
Port int `yaml:"port"` // Remote port
|
Port int `yaml:"port"` // Remote port
|
||||||
LocalPort int `yaml:"localPort"` // Local port
|
LocalPort int `yaml:"localPort"` // Local port
|
||||||
Alias string `yaml:"alias,omitempty"` // Optional human-readable alias for this forward
|
Alias string `yaml:"alias,omitempty"` // Optional human-readable alias for this forward
|
||||||
|
HTTPLog *HTTPLogSpec `yaml:"httpLog,omitempty"` // Optional HTTP traffic logging
|
||||||
|
|
||||||
// Runtime fields (not in YAML)
|
// Runtime fields (not in YAML)
|
||||||
contextName string
|
contextName string
|
||||||
@@ -212,6 +245,19 @@ func (f *Forward) GetNamespace() string {
|
|||||||
return f.namespaceName
|
return f.namespaceName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsHTTPLogEnabled returns true if HTTP logging is enabled for this forward
|
||||||
|
func (f *Forward) IsHTTPLogEnabled() bool {
|
||||||
|
return f.HTTPLog != nil && f.HTTPLog.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHTTPLogMaxBodySize returns the max body size for HTTP logging
|
||||||
|
func (f *Forward) GetHTTPLogMaxBodySize() int {
|
||||||
|
if f.HTTPLog == nil || f.HTTPLog.MaxBodySize <= 0 {
|
||||||
|
return DefaultHTTPLogMaxBodySize
|
||||||
|
}
|
||||||
|
return f.HTTPLog.MaxBodySize
|
||||||
|
}
|
||||||
|
|
||||||
// GetMDNSAlias returns the alias to use for mDNS hostname registration.
|
// GetMDNSAlias returns the alias to use for mDNS hostname registration.
|
||||||
// If an explicit alias is set, it returns that.
|
// If an explicit alias is set, it returns that.
|
||||||
// Otherwise, it generates one from the resource name (e.g., "service/logto" -> "logto").
|
// Otherwise, it generates one from the resource name (e.g., "service/logto" -> "logto").
|
||||||
|
|||||||
@@ -313,3 +313,87 @@ func TestForward_SetContext(t *testing.T) {
|
|||||||
assert.Equal(t, "my-cluster", fwd.GetContext())
|
assert.Equal(t, "my-cluster", fwd.GetContext())
|
||||||
assert.Equal(t, "my-namespace", fwd.GetNamespace())
|
assert.Equal(t, "my-namespace", fwd.GetNamespace())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHTTPLogSpec_UnmarshalYAML(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "httpLog as boolean true",
|
||||||
|
yaml: `contexts:
|
||||||
|
- name: test
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: service/api
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
httpLog: true
|
||||||
|
`,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "httpLog as boolean false",
|
||||||
|
yaml: `contexts:
|
||||||
|
- name: test
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: service/api
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
httpLog: false
|
||||||
|
`,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "httpLog as struct",
|
||||||
|
yaml: `contexts:
|
||||||
|
- name: test
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: service/api
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
httpLog:
|
||||||
|
enabled: true
|
||||||
|
includeHeaders: true
|
||||||
|
`,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "httpLog not specified",
|
||||||
|
yaml: `contexts:
|
||||||
|
- name: test
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: service/api
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg, err := ParseConfig([]byte(tt.yaml))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, cfg)
|
||||||
|
|
||||||
|
fwd := cfg.Contexts[0].Namespaces[0].Forwards[0]
|
||||||
|
if tt.expected {
|
||||||
|
assert.NotNil(t, fwd.HTTPLog, "HTTPLog should not be nil")
|
||||||
|
assert.True(t, fwd.HTTPLog.Enabled, "HTTPLog.Enabled should be true")
|
||||||
|
} else {
|
||||||
|
if fwd.HTTPLog != nil {
|
||||||
|
assert.False(t, fwd.HTTPLog.Enabled, "HTTPLog.Enabled should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -483,6 +483,14 @@ func (m *Manager) GetWorkerCount() int {
|
|||||||
return len(m.workers)
|
return len(m.workers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetWorker returns a worker by ID, or nil if not found.
|
||||||
|
func (m *Manager) GetWorker(id string) *ForwardWorker {
|
||||||
|
m.workersMu.RLock()
|
||||||
|
defer m.workersMu.RUnlock()
|
||||||
|
|
||||||
|
return m.workers[id]
|
||||||
|
}
|
||||||
|
|
||||||
// extractPorts extracts all local ports from a list of forwards.
|
// extractPorts extracts all local ports from a list of forwards.
|
||||||
func (m *Manager) extractPorts(forwards []config.Forward) []int {
|
func (m *Manager) extractPorts(forwards []config.Forward) []int {
|
||||||
ports := make([]int, len(forwards))
|
ports := make([]int, len(forwards))
|
||||||
|
|||||||
@@ -154,12 +154,21 @@ func (s *WatchdogTestSuite) TestMultipleWorkers() {
|
|||||||
s.watchdog.RegisterWorker("worker-3", makeCallback("worker-3"))
|
s.watchdog.RegisterWorker("worker-3", makeCallback("worker-3"))
|
||||||
|
|
||||||
// worker-1: Keep sending heartbeats (healthy)
|
// worker-1: Keep sending heartbeats (healthy)
|
||||||
|
// Use a done channel to ensure goroutine exits before test ends
|
||||||
ticker1 := time.NewTicker(50 * time.Millisecond)
|
ticker1 := time.NewTicker(50 * time.Millisecond)
|
||||||
defer ticker1.Stop()
|
done := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer ticker1.Stop()
|
||||||
for i := 0; i < 10; i++ {
|
for i := 0; i < 10; i++ {
|
||||||
<-ticker1.C
|
select {
|
||||||
s.watchdog.Heartbeat("worker-1")
|
case <-ticker1.C:
|
||||||
|
s.watchdog.Heartbeat("worker-1")
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -172,6 +181,10 @@ func (s *WatchdogTestSuite) TestMultipleWorkers() {
|
|||||||
// Wait for hung workers to be detected
|
// Wait for hung workers to be detected
|
||||||
time.Sleep(600 * time.Millisecond)
|
time.Sleep(600 * time.Millisecond)
|
||||||
|
|
||||||
|
// Signal goroutine to stop and wait for it
|
||||||
|
close(done)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
// Check results
|
// Check results
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/nvm/kportal/internal/config"
|
"github.com/nvm/kportal/internal/config"
|
||||||
"github.com/nvm/kportal/internal/healthcheck"
|
"github.com/nvm/kportal/internal/healthcheck"
|
||||||
|
"github.com/nvm/kportal/internal/httplog"
|
||||||
"github.com/nvm/kportal/internal/k8s"
|
"github.com/nvm/kportal/internal/k8s"
|
||||||
"github.com/nvm/kportal/internal/logger"
|
"github.com/nvm/kportal/internal/logger"
|
||||||
"github.com/nvm/kportal/internal/retry"
|
"github.com/nvm/kportal/internal/retry"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
portForwardReadyTimeout = 30 * time.Second
|
portForwardReadyTimeout = 30 * time.Second
|
||||||
|
httpLogPortOffset = 10000 // Offset for internal port when HTTP logging is enabled
|
||||||
)
|
)
|
||||||
|
|
||||||
// ForwardWorker manages a single port-forward connection with automatic retry.
|
// ForwardWorker manages a single port-forward connection with automatic retry.
|
||||||
@@ -36,6 +38,7 @@ type ForwardWorker struct {
|
|||||||
startTime time.Time // Track when the worker started
|
startTime time.Time // Track when the worker started
|
||||||
forwardCancel context.CancelFunc // Cancel function for current forward attempt
|
forwardCancel context.CancelFunc // Cancel function for current forward attempt
|
||||||
forwardCancelMu sync.Mutex // Protects forwardCancel
|
forwardCancelMu sync.Mutex // Protects forwardCancel
|
||||||
|
httpProxy *httplog.Proxy // HTTP logging proxy (nil if not enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewForwardWorker creates a new ForwardWorker for a single forward configuration.
|
// NewForwardWorker creates a new ForwardWorker for a single forward configuration.
|
||||||
@@ -100,6 +103,7 @@ func (w *ForwardWorker) Stop() {
|
|||||||
// run is the main worker loop that handles retries.
|
// run is the main worker loop that handles retries.
|
||||||
func (w *ForwardWorker) run() {
|
func (w *ForwardWorker) run() {
|
||||||
defer close(w.doneChan)
|
defer close(w.doneChan)
|
||||||
|
defer w.stopHTTPProxy() // Ensure proxy is stopped on exit
|
||||||
|
|
||||||
// Start heartbeat goroutine to continuously send heartbeats to watchdog
|
// Start heartbeat goroutine to continuously send heartbeats to watchdog
|
||||||
// This prevents false "hung worker" detection when connections are long-lived
|
// This prevents false "hung worker" detection when connections are long-lived
|
||||||
@@ -107,6 +111,15 @@ func (w *ForwardWorker) run() {
|
|||||||
go w.heartbeatLoop()
|
go w.heartbeatLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start HTTP logging proxy if enabled
|
||||||
|
if err := w.startHTTPProxy(); err != nil {
|
||||||
|
logger.Error("Failed to start HTTP logging proxy", map[string]interface{}{
|
||||||
|
"forward_id": w.forward.ID(),
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
// Continue without HTTP logging
|
||||||
|
}
|
||||||
|
|
||||||
backoff := retry.NewBackoff()
|
backoff := retry.NewBackoff()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -276,13 +289,20 @@ func (w *ForwardWorker) establishForward(podName string) error {
|
|||||||
errOut = io.Discard
|
errOut = io.Discard
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine local port for k8s port-forward
|
||||||
|
// If HTTP logging is enabled, we bind to an internal port and the proxy listens on the user-facing port
|
||||||
|
localPort := w.forward.LocalPort
|
||||||
|
if w.httpProxy != nil {
|
||||||
|
localPort = w.httpProxy.GetTargetPort()
|
||||||
|
}
|
||||||
|
|
||||||
// Create forward request
|
// Create forward request
|
||||||
req := &k8s.ForwardRequest{
|
req := &k8s.ForwardRequest{
|
||||||
ContextName: w.forward.GetContext(),
|
ContextName: w.forward.GetContext(),
|
||||||
Namespace: w.forward.GetNamespace(),
|
Namespace: w.forward.GetNamespace(),
|
||||||
Resource: w.forward.Resource,
|
Resource: w.forward.Resource,
|
||||||
Selector: w.forward.Selector,
|
Selector: w.forward.Selector,
|
||||||
LocalPort: w.forward.LocalPort,
|
LocalPort: localPort,
|
||||||
RemotePort: w.forward.Port,
|
RemotePort: w.forward.Port,
|
||||||
StopChan: stopChan,
|
StopChan: stopChan,
|
||||||
ReadyChan: readyChan,
|
ReadyChan: readyChan,
|
||||||
@@ -355,6 +375,53 @@ func (w *ForwardWorker) IsRunning() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startHTTPProxy starts the HTTP logging proxy if enabled
|
||||||
|
func (w *ForwardWorker) startHTTPProxy() error {
|
||||||
|
if !w.forward.IsHTTPLogEnabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate internal port for k8s tunnel
|
||||||
|
targetPort := w.forward.LocalPort + httpLogPortOffset
|
||||||
|
|
||||||
|
proxy, err := httplog.NewProxy(&w.forward, targetPort)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create HTTP proxy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := proxy.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start HTTP proxy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.httpProxy = proxy
|
||||||
|
|
||||||
|
logger.Info("HTTP logging proxy started", map[string]interface{}{
|
||||||
|
"forward_id": w.forward.ID(),
|
||||||
|
"local_port": w.forward.LocalPort,
|
||||||
|
"target_port": targetPort,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopHTTPProxy stops the HTTP logging proxy if running
|
||||||
|
func (w *ForwardWorker) stopHTTPProxy() {
|
||||||
|
if w.httpProxy != nil {
|
||||||
|
if err := w.httpProxy.Stop(); err != nil {
|
||||||
|
logger.Warn("Failed to stop HTTP proxy", map[string]interface{}{
|
||||||
|
"forward_id": w.forward.ID(),
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.httpProxy = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHTTPProxy returns the HTTP logging proxy if active
|
||||||
|
func (w *ForwardWorker) GetHTTPProxy() *httplog.Proxy {
|
||||||
|
return w.httpProxy
|
||||||
|
}
|
||||||
|
|
||||||
// logWriter implements io.Writer to write log messages with a prefix.
|
// logWriter implements io.Writer to write log messages with a prefix.
|
||||||
type logWriter struct {
|
type logWriter struct {
|
||||||
prefix string
|
prefix string
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package httplog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry represents a single HTTP log entry
|
||||||
|
type Entry struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
ForwardID string `json:"forward_id"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Direction string `json:"direction"` // "request" or "response"
|
||||||
|
Method string `json:"method,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
StatusCode int `json:"status_code,omitempty"`
|
||||||
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
|
BodySize int `json:"body_size"`
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
LatencyMs int64 `json:"latency_ms,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogCallback is a function that receives log entries
|
||||||
|
type LogCallback func(entry Entry)
|
||||||
|
|
||||||
|
// Logger writes HTTP log entries to an output stream
|
||||||
|
type Logger struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
output io.Writer
|
||||||
|
file *os.File // Only set if we opened the file ourselves
|
||||||
|
forwardID string
|
||||||
|
maxBodyLen int
|
||||||
|
callbacks []LogCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogger creates a new HTTP logger
|
||||||
|
// If logFile is empty, logs only go to registered callbacks (no file output)
|
||||||
|
// This prevents stdout corruption when running in TUI mode
|
||||||
|
func NewLogger(forwardID, logFile string, maxBodyLen int) (*Logger, error) {
|
||||||
|
l := &Logger{
|
||||||
|
forwardID: forwardID,
|
||||||
|
maxBodyLen: maxBodyLen,
|
||||||
|
}
|
||||||
|
|
||||||
|
if logFile == "" {
|
||||||
|
// Don't write to stdout - use io.Discard
|
||||||
|
// Log entries are delivered via callbacks to the UI
|
||||||
|
l.output = io.Discard
|
||||||
|
} else {
|
||||||
|
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
l.file = f
|
||||||
|
l.output = f
|
||||||
|
}
|
||||||
|
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddCallback registers a callback to receive log entries
|
||||||
|
func (l *Logger) AddCallback(cb LogCallback) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.callbacks = append(l.callbacks, cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearCallbacks removes all registered callbacks
|
||||||
|
func (l *Logger) ClearCallbacks() {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.callbacks = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log writes a log entry as JSON
|
||||||
|
func (l *Logger) Log(entry Entry) error {
|
||||||
|
entry.ForwardID = l.forwardID
|
||||||
|
entry.Timestamp = time.Now()
|
||||||
|
|
||||||
|
// Truncate body if too large
|
||||||
|
if len(entry.Body) > l.maxBodyLen {
|
||||||
|
entry.Body = entry.Body[:l.maxBodyLen] + "...(truncated)"
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
// Notify callbacks
|
||||||
|
for _, cb := range l.callbacks {
|
||||||
|
cb(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = l.output.Write(append(data, '\n'))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the logger
|
||||||
|
func (l *Logger) Close() error {
|
||||||
|
if l.file != nil {
|
||||||
|
return l.file.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package httplog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Proxy is an HTTP reverse proxy with logging capabilities
|
||||||
|
type Proxy struct {
|
||||||
|
localPort int // Port to listen on (user-facing)
|
||||||
|
targetPort int // Port to forward to (k8s tunnel)
|
||||||
|
logger *Logger
|
||||||
|
server *http.Server
|
||||||
|
forwardID string
|
||||||
|
filterPath string // Glob pattern for path filtering
|
||||||
|
includeHdrs bool
|
||||||
|
listener net.Listener
|
||||||
|
requestCount uint64
|
||||||
|
mu sync.Mutex
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProxy creates a new HTTP logging proxy
|
||||||
|
func NewProxy(fwd *config.Forward, targetPort int) (*Proxy, error) {
|
||||||
|
httpCfg := fwd.HTTPLog
|
||||||
|
if httpCfg == nil {
|
||||||
|
return nil, fmt.Errorf("HTTP log config is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger, err := NewLogger(fwd.ID(), httpCfg.LogFile, fwd.GetHTTPLogMaxBodySize())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create logger: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Proxy{
|
||||||
|
localPort: fwd.LocalPort,
|
||||||
|
targetPort: targetPort,
|
||||||
|
logger: logger,
|
||||||
|
forwardID: fwd.ID(),
|
||||||
|
filterPath: httpCfg.FilterPath,
|
||||||
|
includeHdrs: httpCfg.IncludeHeaders,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the HTTP proxy server
|
||||||
|
func (p *Proxy) Start() error {
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.running {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return fmt.Errorf("proxy already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create listener
|
||||||
|
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p.localPort))
|
||||||
|
if err != nil {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return fmt.Errorf("failed to listen on port %d: %w", p.localPort, err)
|
||||||
|
}
|
||||||
|
p.listener = ln
|
||||||
|
|
||||||
|
// Create reverse proxy
|
||||||
|
director := func(req *http.Request) {
|
||||||
|
req.URL.Scheme = "http"
|
||||||
|
req.URL.Host = fmt.Sprintf("127.0.0.1:%d", p.targetPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := &httputil.ReverseProxy{
|
||||||
|
Director: director,
|
||||||
|
Transport: &loggingTransport{
|
||||||
|
proxy: p,
|
||||||
|
transport: http.DefaultTransport,
|
||||||
|
},
|
||||||
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
p.logError(r, err)
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
w.Write([]byte("Proxy error: " + err.Error()))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
p.server = &http.Server{
|
||||||
|
Handler: proxy,
|
||||||
|
}
|
||||||
|
|
||||||
|
p.running = true
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// Start serving (blocking)
|
||||||
|
go func() {
|
||||||
|
if err := p.server.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||||
|
// Log error but don't crash - proxy will be replaced on reconnect
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the HTTP proxy server
|
||||||
|
func (p *Proxy) Stop() error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
if !p.running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.running = false
|
||||||
|
|
||||||
|
// Shutdown with timeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := p.server.Shutdown(ctx); err != nil {
|
||||||
|
// Force close
|
||||||
|
p.server.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.logger.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loggingTransport wraps http.RoundTripper to log requests and responses
|
||||||
|
type loggingTransport struct {
|
||||||
|
proxy *Proxy
|
||||||
|
transport http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
// Generate request ID
|
||||||
|
reqID := fmt.Sprintf("%d", atomic.AddUint64(&t.proxy.requestCount, 1))
|
||||||
|
|
||||||
|
// Check if we should log this request based on path filter
|
||||||
|
if !t.proxy.shouldLog(req.URL.Path) {
|
||||||
|
return t.transport.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Read request body
|
||||||
|
var reqBody []byte
|
||||||
|
if req.Body != nil {
|
||||||
|
reqBody, _ = io.ReadAll(req.Body)
|
||||||
|
req.Body = io.NopCloser(bytes.NewBuffer(reqBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log request
|
||||||
|
reqEntry := Entry{
|
||||||
|
RequestID: reqID,
|
||||||
|
Direction: "request",
|
||||||
|
Method: req.Method,
|
||||||
|
Path: req.URL.Path,
|
||||||
|
BodySize: len(reqBody),
|
||||||
|
Body: string(reqBody),
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.proxy.includeHdrs {
|
||||||
|
reqEntry.Headers = flattenHeaders(req.Header)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.proxy.logger.Log(reqEntry)
|
||||||
|
|
||||||
|
// Make the request
|
||||||
|
resp, err := t.transport.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read response body
|
||||||
|
var respBody []byte
|
||||||
|
if resp.Body != nil {
|
||||||
|
respBody, _ = io.ReadAll(resp.Body)
|
||||||
|
resp.Body = io.NopCloser(bytes.NewBuffer(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
latency := time.Since(startTime)
|
||||||
|
|
||||||
|
// Log response
|
||||||
|
respEntry := Entry{
|
||||||
|
RequestID: reqID,
|
||||||
|
Direction: "response",
|
||||||
|
Method: req.Method,
|
||||||
|
Path: req.URL.Path,
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
BodySize: len(respBody),
|
||||||
|
Body: string(respBody),
|
||||||
|
LatencyMs: latency.Milliseconds(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.proxy.includeHdrs {
|
||||||
|
respEntry.Headers = flattenHeaders(resp.Header)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.proxy.logger.Log(respEntry)
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldLog checks if the request path matches the filter
|
||||||
|
func (p *Proxy) shouldLog(path string) bool {
|
||||||
|
if p.filterPath == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
matched, err := filepath.Match(p.filterPath, path)
|
||||||
|
if err != nil {
|
||||||
|
// Invalid pattern, log everything
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try matching with ** for prefix patterns like /api/*
|
||||||
|
if !matched && strings.HasSuffix(p.filterPath, "/*") {
|
||||||
|
prefix := strings.TrimSuffix(p.filterPath, "/*")
|
||||||
|
matched = strings.HasPrefix(path, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
// logError logs an error entry
|
||||||
|
func (p *Proxy) logError(req *http.Request, err error) {
|
||||||
|
entry := Entry{
|
||||||
|
RequestID: fmt.Sprintf("%d", atomic.AddUint64(&p.requestCount, 1)),
|
||||||
|
Direction: "error",
|
||||||
|
Method: req.Method,
|
||||||
|
Path: req.URL.Path,
|
||||||
|
Error: err.Error(),
|
||||||
|
}
|
||||||
|
p.logger.Log(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenHeaders converts http.Header to map[string]string
|
||||||
|
func flattenHeaders(h http.Header) map[string]string {
|
||||||
|
result := make(map[string]string, len(h))
|
||||||
|
for k, v := range h {
|
||||||
|
result[k] = strings.Join(v, ", ")
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTargetPort returns the target port for the k8s tunnel
|
||||||
|
func (p *Proxy) GetTargetPort() int {
|
||||||
|
return p.targetPort
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogger returns the HTTP logger for subscribing to log entries
|
||||||
|
func (p *Proxy) GetLogger() *Logger {
|
||||||
|
return p.logger
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package httplog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/config"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogger(t *testing.T) {
|
||||||
|
// Create a buffer to capture output
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
l := &Logger{
|
||||||
|
forwardID: "test-forward",
|
||||||
|
maxBodyLen: 100,
|
||||||
|
output: &buf,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log an entry
|
||||||
|
err := l.Log(Entry{
|
||||||
|
Direction: "request",
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/test",
|
||||||
|
BodySize: 0,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Parse the output
|
||||||
|
var entry Entry
|
||||||
|
err = json.Unmarshal(buf.Bytes(), &entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "test-forward", entry.ForwardID)
|
||||||
|
assert.Equal(t, "request", entry.Direction)
|
||||||
|
assert.Equal(t, "GET", entry.Method)
|
||||||
|
assert.Equal(t, "/test", entry.Path)
|
||||||
|
assert.False(t, entry.Timestamp.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggerBodyTruncation(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
l := &Logger{
|
||||||
|
forwardID: "test-forward",
|
||||||
|
maxBodyLen: 10,
|
||||||
|
output: &buf,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log an entry with a long body
|
||||||
|
err := l.Log(Entry{
|
||||||
|
Direction: "request",
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/test",
|
||||||
|
Body: "this is a very long body that should be truncated",
|
||||||
|
BodySize: 50,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Parse the output
|
||||||
|
var entry Entry
|
||||||
|
err = json.Unmarshal(buf.Bytes(), &entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "this is a ...(truncated)", entry.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyShouldLog(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filterPath string
|
||||||
|
path string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"no filter", "", "/anything", true},
|
||||||
|
{"exact match", "/api", "/api", true},
|
||||||
|
{"no match", "/api", "/other", false},
|
||||||
|
{"prefix match", "/api/*", "/api/users", true},
|
||||||
|
{"prefix no match", "/api/*", "/other/users", false},
|
||||||
|
{"wildcard", "/api/*/test", "/api/v1/test", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
p := &Proxy{filterPath: tt.filterPath}
|
||||||
|
assert.Equal(t, tt.expected, p.shouldLog(tt.path))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyIntegration(t *testing.T) {
|
||||||
|
// Create a buffer for log output
|
||||||
|
var logBuf bytes.Buffer
|
||||||
|
|
||||||
|
// Create config
|
||||||
|
fwd := &config.Forward{
|
||||||
|
LocalPort: 0, // Will be assigned dynamically
|
||||||
|
HTTPLog: &config.HTTPLogSpec{
|
||||||
|
Enabled: true,
|
||||||
|
IncludeHeaders: true,
|
||||||
|
MaxBodySize: 1024,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create logger with buffer
|
||||||
|
logger := &Logger{
|
||||||
|
forwardID: "test",
|
||||||
|
maxBodyLen: 1024,
|
||||||
|
output: &logBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create proxy manually for testing
|
||||||
|
proxy := &Proxy{
|
||||||
|
localPort: 0, // Will use ephemeral port
|
||||||
|
targetPort: 0, // Not used in this test
|
||||||
|
logger: logger,
|
||||||
|
forwardID: fwd.ID(),
|
||||||
|
filterPath: "",
|
||||||
|
includeHdrs: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test shouldLog
|
||||||
|
assert.True(t, proxy.shouldLog("/any/path"))
|
||||||
|
|
||||||
|
// Test logging through logger directly
|
||||||
|
err := logger.Log(Entry{
|
||||||
|
RequestID: "1",
|
||||||
|
Direction: "request",
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/test",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify log output
|
||||||
|
assert.Contains(t, logBuf.String(), `"direction":"request"`)
|
||||||
|
assert.Contains(t, logBuf.String(), `"method":"GET"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlattenHeaders(t *testing.T) {
|
||||||
|
h := http.Header{
|
||||||
|
"Content-Type": []string{"application/json"},
|
||||||
|
"Accept": []string{"text/html", "application/json"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := flattenHeaders(h)
|
||||||
|
|
||||||
|
assert.Equal(t, "application/json", result["Content-Type"])
|
||||||
|
assert.Equal(t, "text/html, application/json", result["Accept"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogger(t *testing.T) {
|
||||||
|
// Test stdout logger
|
||||||
|
l, err := NewLogger("test-forward", "", 1024)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, l)
|
||||||
|
assert.Nil(t, l.file) // No file when using stdout
|
||||||
|
l.Close()
|
||||||
|
|
||||||
|
// Test file logger (using temp file)
|
||||||
|
tmpFile := t.TempDir() + "/test.log"
|
||||||
|
l, err = NewLogger("test-forward", tmpFile, 1024)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, l)
|
||||||
|
assert.NotNil(t, l.file)
|
||||||
|
|
||||||
|
// Write something
|
||||||
|
err = l.Log(Entry{Direction: "request", Method: "GET"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
l.Close()
|
||||||
|
|
||||||
|
// Verify file has content
|
||||||
|
data, err := os.ReadFile(tmpFile)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(data), `"direction":"request"`)
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/util/intstr"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Discovery provides cluster introspection capabilities for the UI wizards.
|
// Discovery provides cluster introspection capabilities for the UI wizards.
|
||||||
@@ -41,9 +43,10 @@ type ContainerInfo struct {
|
|||||||
|
|
||||||
// PortInfo describes a port exposed by a container or service.
|
// PortInfo describes a port exposed by a container or service.
|
||||||
type PortInfo struct {
|
type PortInfo struct {
|
||||||
Name string
|
Name string
|
||||||
Port int32
|
Port int32
|
||||||
Protocol string
|
TargetPort int32 // For services: the actual pod port to forward to
|
||||||
|
Protocol string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceInfo contains information about a service.
|
// ServiceInfo contains information about a service.
|
||||||
@@ -205,7 +208,60 @@ func (d *Discovery) ListPodsWithSelector(ctx context.Context, contextName, names
|
|||||||
return pods, nil
|
return pods, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveTargetPort resolves a service's targetPort to an actual port number.
|
||||||
|
// If targetPort is numeric, it returns that number directly.
|
||||||
|
// If targetPort is a named port, it looks up the port number from the backing pods.
|
||||||
|
// Falls back to the service port if resolution fails.
|
||||||
|
func (d *Discovery) resolveTargetPort(ctx context.Context, client kubernetes.Interface, namespace string, svc *corev1.Service, port *corev1.ServicePort) int32 {
|
||||||
|
// If targetPort is not set, Kubernetes defaults to the service port
|
||||||
|
if port.TargetPort.Type == intstr.Int && port.TargetPort.IntVal == 0 {
|
||||||
|
return port.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
// If targetPort is numeric, use it directly
|
||||||
|
if port.TargetPort.Type == intstr.Int {
|
||||||
|
return port.TargetPort.IntVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetPort is a named port - need to look up from pods
|
||||||
|
namedPort := port.TargetPort.StrVal
|
||||||
|
if namedPort == "" {
|
||||||
|
return port.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a backing pod to resolve the named port
|
||||||
|
if len(svc.Spec.Selector) == 0 {
|
||||||
|
// No selector, can't resolve - fall back to service port
|
||||||
|
return port.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: svc.Spec.Selector})
|
||||||
|
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||||
|
LabelSelector: selector,
|
||||||
|
Limit: 1, // We only need one pod to resolve the port name
|
||||||
|
})
|
||||||
|
if err != nil || len(pods.Items) == 0 {
|
||||||
|
// Can't get pods - fall back to service port
|
||||||
|
return port.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the named port in the pod's containers
|
||||||
|
pod := &pods.Items[0]
|
||||||
|
for _, container := range pod.Spec.Containers {
|
||||||
|
for _, containerPort := range container.Ports {
|
||||||
|
if containerPort.Name == namedPort {
|
||||||
|
return containerPort.ContainerPort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Named port not found - fall back to service port
|
||||||
|
return port.Port
|
||||||
|
}
|
||||||
|
|
||||||
// ListServices returns all services in the given namespace.
|
// ListServices returns all services in the given namespace.
|
||||||
|
// For each service port, it resolves the targetPort to an actual port number
|
||||||
|
// by looking up the backing pods when the targetPort is a named port.
|
||||||
func (d *Discovery) ListServices(ctx context.Context, contextName, namespace string) ([]ServiceInfo, error) {
|
func (d *Discovery) ListServices(ctx context.Context, contextName, namespace string) ([]ServiceInfo, error) {
|
||||||
client, err := d.pool.GetClient(contextName)
|
client, err := d.pool.GetClient(contextName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -221,10 +277,13 @@ func (d *Discovery) ListServices(ctx context.Context, contextName, namespace str
|
|||||||
for _, svc := range svcList.Items {
|
for _, svc := range svcList.Items {
|
||||||
ports := make([]PortInfo, 0, len(svc.Spec.Ports))
|
ports := make([]PortInfo, 0, len(svc.Spec.Ports))
|
||||||
for _, port := range svc.Spec.Ports {
|
for _, port := range svc.Spec.Ports {
|
||||||
|
targetPort := d.resolveTargetPort(ctx, client, namespace, &svc, &port)
|
||||||
|
|
||||||
ports = append(ports, PortInfo{
|
ports = append(ports, PortInfo{
|
||||||
Name: port.Name,
|
Name: port.Name,
|
||||||
Port: port.Port,
|
Port: port.Port,
|
||||||
Protocol: string(port.Protocol),
|
TargetPort: targetPort,
|
||||||
|
Protocol: string(port.Protocol),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package k8s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apimachinery/pkg/util/intstr"
|
||||||
|
"k8s.io/client-go/kubernetes/fake"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveTargetPort(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
servicePort corev1.ServicePort
|
||||||
|
service *corev1.Service
|
||||||
|
pods []corev1.Pod
|
||||||
|
expectedPort int32
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "numeric targetPort",
|
||||||
|
servicePort: corev1.ServicePort{
|
||||||
|
Port: 80,
|
||||||
|
TargetPort: intstr.FromInt(8000),
|
||||||
|
},
|
||||||
|
service: &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-svc",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pods: nil, // No pods needed for numeric targetPort
|
||||||
|
expectedPort: 8000,
|
||||||
|
description: "should use numeric targetPort directly",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "named targetPort resolved from pod",
|
||||||
|
servicePort: corev1.ServicePort{
|
||||||
|
Name: "http",
|
||||||
|
Port: 80,
|
||||||
|
TargetPort: intstr.FromString("http"),
|
||||||
|
},
|
||||||
|
service: &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-svc",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pods: []corev1.Pod{
|
||||||
|
{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-pod",
|
||||||
|
Namespace: "default",
|
||||||
|
Labels: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
Containers: []corev1.Container{
|
||||||
|
{
|
||||||
|
Name: "main",
|
||||||
|
Ports: []corev1.ContainerPort{
|
||||||
|
{Name: "http", ContainerPort: 8000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedPort: 8000,
|
||||||
|
description: "should resolve named port from pod container",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "targetPort not set - defaults to service port",
|
||||||
|
servicePort: corev1.ServicePort{
|
||||||
|
Port: 80,
|
||||||
|
TargetPort: intstr.FromInt(0), // Not set
|
||||||
|
},
|
||||||
|
service: &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-svc",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pods: nil,
|
||||||
|
expectedPort: 80,
|
||||||
|
description: "should fall back to service port when targetPort is not set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "named targetPort with no matching pod",
|
||||||
|
servicePort: corev1.ServicePort{
|
||||||
|
Name: "http",
|
||||||
|
Port: 80,
|
||||||
|
TargetPort: intstr.FromString("http"),
|
||||||
|
},
|
||||||
|
service: &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-svc",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pods: nil, // No pods available
|
||||||
|
expectedPort: 80,
|
||||||
|
description: "should fall back to service port when no pods found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "service without selector",
|
||||||
|
servicePort: corev1.ServicePort{
|
||||||
|
Name: "http",
|
||||||
|
Port: 80,
|
||||||
|
TargetPort: intstr.FromString("http"),
|
||||||
|
},
|
||||||
|
service: &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-svc",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: nil, // No selector
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pods: nil,
|
||||||
|
expectedPort: 80,
|
||||||
|
description: "should fall back to service port when service has no selector",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "named targetPort not found in pod containers",
|
||||||
|
servicePort: corev1.ServicePort{
|
||||||
|
Name: "http",
|
||||||
|
Port: 80,
|
||||||
|
TargetPort: intstr.FromString("nonexistent"),
|
||||||
|
},
|
||||||
|
service: &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-svc",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pods: []corev1.Pod{
|
||||||
|
{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-pod",
|
||||||
|
Namespace: "default",
|
||||||
|
Labels: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
Containers: []corev1.Container{
|
||||||
|
{
|
||||||
|
Name: "main",
|
||||||
|
Ports: []corev1.ContainerPort{
|
||||||
|
{Name: "http", ContainerPort: 8000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedPort: 80,
|
||||||
|
description: "should fall back to service port when named port not found in pod",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple containers with named port in second container",
|
||||||
|
servicePort: corev1.ServicePort{
|
||||||
|
Name: "metrics",
|
||||||
|
Port: 9090,
|
||||||
|
TargetPort: intstr.FromString("metrics"),
|
||||||
|
},
|
||||||
|
service: &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-svc",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pods: []corev1.Pod{
|
||||||
|
{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-pod",
|
||||||
|
Namespace: "default",
|
||||||
|
Labels: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
Containers: []corev1.Container{
|
||||||
|
{
|
||||||
|
Name: "main",
|
||||||
|
Ports: []corev1.ContainerPort{
|
||||||
|
{Name: "http", ContainerPort: 8000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "sidecar",
|
||||||
|
Ports: []corev1.ContainerPort{
|
||||||
|
{Name: "metrics", ContainerPort: 9100},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedPort: 9100,
|
||||||
|
description: "should find named port in any container",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create fake client with pods
|
||||||
|
var objects []runtime.Object
|
||||||
|
for i := range tt.pods {
|
||||||
|
objects = append(objects, &tt.pods[i])
|
||||||
|
}
|
||||||
|
fakeClient := fake.NewSimpleClientset(objects...)
|
||||||
|
|
||||||
|
// Create discovery instance (we only need it to call resolveTargetPort)
|
||||||
|
d := &Discovery{}
|
||||||
|
|
||||||
|
// Call resolveTargetPort
|
||||||
|
result := d.resolveTargetPort(
|
||||||
|
context.Background(),
|
||||||
|
fakeClient,
|
||||||
|
"default",
|
||||||
|
tt.service,
|
||||||
|
&tt.servicePort,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectedPort, result, tt.description)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPortInfoTargetPort(t *testing.T) {
|
||||||
|
// Test that PortInfo correctly stores TargetPort
|
||||||
|
portInfo := PortInfo{
|
||||||
|
Name: "http",
|
||||||
|
Port: 80,
|
||||||
|
TargetPort: 8000,
|
||||||
|
Protocol: "TCP",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, int32(80), portInfo.Port)
|
||||||
|
assert.Equal(t, int32(8000), portInfo.TargetPort)
|
||||||
|
assert.Equal(t, "http", portInfo.Name)
|
||||||
|
assert.Equal(t, "TCP", portInfo.Protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUniquePorts(t *testing.T) {
|
||||||
|
// Test GetUniquePorts still works with the new PortInfo struct
|
||||||
|
pods := []PodInfo{
|
||||||
|
{
|
||||||
|
Name: "pod1",
|
||||||
|
Containers: []ContainerInfo{
|
||||||
|
{
|
||||||
|
Name: "main",
|
||||||
|
Ports: []PortInfo{
|
||||||
|
{Name: "http", Port: 8080},
|
||||||
|
{Name: "metrics", Port: 9090},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "pod2",
|
||||||
|
Containers: []ContainerInfo{
|
||||||
|
{
|
||||||
|
Name: "main",
|
||||||
|
Ports: []PortInfo{
|
||||||
|
{Name: "http", Port: 8080}, // Duplicate
|
||||||
|
{Name: "grpc", Port: 50051},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ports := GetUniquePorts(pods)
|
||||||
|
|
||||||
|
// Should have 3 unique ports
|
||||||
|
assert.Len(t, ports, 3)
|
||||||
|
|
||||||
|
// Should be sorted by port number
|
||||||
|
assert.Equal(t, int32(8080), ports[0].Port)
|
||||||
|
assert.Equal(t, int32(9090), ports[1].Port)
|
||||||
|
assert.Equal(t, int32(50051), ports[2].Port)
|
||||||
|
|
||||||
|
// Names should be preserved
|
||||||
|
assert.Equal(t, "http", ports[0].Name)
|
||||||
|
assert.Equal(t, "metrics", ports[1].Name)
|
||||||
|
assert.Equal(t, "grpc", ports[2].Name)
|
||||||
|
}
|
||||||
@@ -151,6 +151,13 @@ func (p *Publisher) Stop() {
|
|||||||
logger.Info("mDNS publisher stopped", nil)
|
logger.Info("mDNS publisher stopped", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shutdownSettleTime is a small delay after zeroconf shutdown to allow internal
|
||||||
|
// goroutines to exit cleanly. This works around a race condition in the
|
||||||
|
// grandcat/zeroconf library where recv4() can access ipv4conn after shutdown()
|
||||||
|
// sets it to nil. See: https://github.com/grandcat/zeroconf/issues/95
|
||||||
|
// Note: 100ms is needed for CI environments where timing can be more variable.
|
||||||
|
const shutdownSettleTime = 100 * time.Millisecond
|
||||||
|
|
||||||
// shutdownWithTimeout attempts to shutdown a zeroconf server with a timeout.
|
// shutdownWithTimeout attempts to shutdown a zeroconf server with a timeout.
|
||||||
// If shutdown hangs, it logs a warning and returns anyway.
|
// If shutdown hangs, it logs a warning and returns anyway.
|
||||||
func shutdownWithTimeout(server *zeroconf.Server, forwardID string) {
|
func shutdownWithTimeout(server *zeroconf.Server, forwardID string) {
|
||||||
@@ -164,6 +171,10 @@ func shutdownWithTimeout(server *zeroconf.Server, forwardID string) {
|
|||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
// Shutdown completed successfully
|
// Shutdown completed successfully
|
||||||
|
// Add a small settle time to let internal goroutines exit cleanly.
|
||||||
|
// This works around a race condition in zeroconf where recv4() can
|
||||||
|
// access ipv4conn after shutdown() sets it to nil.
|
||||||
|
time.Sleep(shutdownSettleTime)
|
||||||
case <-time.After(shutdownTimeout):
|
case <-time.After(shutdownTimeout):
|
||||||
logger.Warn("mDNS shutdown timed out, continuing anyway", map[string]interface{}{
|
logger.Warn("mDNS shutdown timed out, continuing anyway", map[string]interface{}{
|
||||||
"forward_id": forwardID,
|
"forward_id": forwardID,
|
||||||
|
|||||||
+134
-93
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/lipgloss/table"
|
||||||
"github.com/nvm/kportal/internal/config"
|
"github.com/nvm/kportal/internal/config"
|
||||||
"github.com/nvm/kportal/internal/k8s"
|
"github.com/nvm/kportal/internal/k8s"
|
||||||
)
|
)
|
||||||
@@ -34,6 +35,10 @@ type ForwardRemoveMsg struct {
|
|||||||
ID string
|
ID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTPLogSubscriber is a function that subscribes to HTTP logs for a forward
|
||||||
|
// It returns a cleanup function to call when unsubscribing
|
||||||
|
type HTTPLogSubscriber func(forwardID string, callback func(entry HTTPLogEntry)) func()
|
||||||
|
|
||||||
// BubbleTeaUI is a bubbletea-based terminal UI
|
// BubbleTeaUI is a bubbletea-based terminal UI
|
||||||
type BubbleTeaUI struct {
|
type BubbleTeaUI struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -62,10 +67,22 @@ type BubbleTeaUI struct {
|
|||||||
deleteConfirmAlias string
|
deleteConfirmAlias string
|
||||||
deleteConfirmCursor int // 0 = Yes, 1 = No
|
deleteConfirmCursor int // 0 = Yes, 1 = No
|
||||||
|
|
||||||
|
// Benchmark state
|
||||||
|
benchmarkState *BenchmarkState
|
||||||
|
|
||||||
|
// HTTP log viewing state
|
||||||
|
httpLogState *HTTPLogState
|
||||||
|
|
||||||
|
// Log callback cleanup function
|
||||||
|
httpLogCleanup func()
|
||||||
|
|
||||||
// Dependencies for wizards
|
// Dependencies for wizards
|
||||||
discovery *k8s.Discovery
|
discovery *k8s.Discovery
|
||||||
mutator *config.Mutator
|
mutator *config.Mutator
|
||||||
configPath string
|
configPath string
|
||||||
|
|
||||||
|
// Manager for accessing workers
|
||||||
|
httpLogSubscriber HTTPLogSubscriber
|
||||||
}
|
}
|
||||||
|
|
||||||
// bubbletea model
|
// bubbletea model
|
||||||
@@ -101,6 +118,14 @@ func (ui *BubbleTeaUI) SetWizardDependencies(discovery *k8s.Discovery, mutator *
|
|||||||
ui.configPath = configPath
|
ui.configPath = configPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetHTTPLogSubscriber sets the function to subscribe to HTTP logs
|
||||||
|
func (ui *BubbleTeaUI) SetHTTPLogSubscriber(subscriber HTTPLogSubscriber) {
|
||||||
|
ui.mu.Lock()
|
||||||
|
defer ui.mu.Unlock()
|
||||||
|
|
||||||
|
ui.httpLogSubscriber = subscriber
|
||||||
|
}
|
||||||
|
|
||||||
// SetUpdateAvailable sets the update notification to be displayed
|
// SetUpdateAvailable sets the update notification to be displayed
|
||||||
func (ui *BubbleTeaUI) SetUpdateAvailable(version, url string) {
|
func (ui *BubbleTeaUI) SetUpdateAvailable(version, url string) {
|
||||||
ui.mu.Lock()
|
ui.mu.Lock()
|
||||||
@@ -253,6 +278,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m.handleAddWizardKeys(msg)
|
return m.handleAddWizardKeys(msg)
|
||||||
case ViewModeRemoveWizard:
|
case ViewModeRemoveWizard:
|
||||||
return m.handleRemoveWizardKeys(msg)
|
return m.handleRemoveWizardKeys(msg)
|
||||||
|
case ViewModeBenchmark:
|
||||||
|
return m.handleBenchmarkKeys(msg)
|
||||||
|
case ViewModeHTTPLog:
|
||||||
|
return m.handleHTTPLogKeys(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward management messages (always update main view data)
|
// Forward management messages (always update main view data)
|
||||||
@@ -283,6 +312,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.ui.removeWizard = nil
|
m.ui.removeWizard = nil
|
||||||
m.ui.mu.Unlock()
|
m.ui.mu.Unlock()
|
||||||
return m, tea.ClearScreen
|
return m, tea.ClearScreen
|
||||||
|
|
||||||
|
case BenchmarkCompleteMsg:
|
||||||
|
return m.handleBenchmarkComplete(msg)
|
||||||
|
|
||||||
|
case BenchmarkProgressMsg:
|
||||||
|
return m.handleBenchmarkProgress(msg)
|
||||||
|
|
||||||
|
case HTTPLogEntryMsg:
|
||||||
|
return m.handleHTTPLogEntry(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -323,6 +361,12 @@ func (m model) View() string {
|
|||||||
case ViewModeRemoveWizard:
|
case ViewModeRemoveWizard:
|
||||||
modal := m.renderRemoveWizard()
|
modal := m.renderRemoveWizard()
|
||||||
return overlayContent(mainView, modal, termWidth, termHeight)
|
return overlayContent(mainView, modal, termWidth, termHeight)
|
||||||
|
case ViewModeBenchmark:
|
||||||
|
modal := m.renderBenchmark()
|
||||||
|
return overlayContent(mainView, modal, termWidth, termHeight)
|
||||||
|
case ViewModeHTTPLog:
|
||||||
|
// HTTP Log is full-screen, don't overlay on main view
|
||||||
|
return m.renderHTTPLog()
|
||||||
default:
|
default:
|
||||||
return mainView
|
return mainView
|
||||||
}
|
}
|
||||||
@@ -340,36 +384,21 @@ func (m model) renderMainView() string {
|
|||||||
termHeight = 40 // Fallback
|
termHeight = 40 // Fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// Styles
|
// Color palette
|
||||||
titleStyle := lipgloss.NewStyle().
|
headerColor := lipgloss.Color("220") // Yellow
|
||||||
Bold(true).
|
activeColor := lipgloss.Color("46") // Green
|
||||||
Foreground(lipgloss.Color("220")).
|
warningColor := lipgloss.Color("220") // Yellow
|
||||||
Padding(0, 1)
|
errorColor := lipgloss.Color("196") // Red
|
||||||
|
mutedColor := lipgloss.Color("240") // Gray
|
||||||
headerStyle := lipgloss.NewStyle().
|
selectedBg := lipgloss.Color("240") // Gray background
|
||||||
Bold(true).
|
selectedFg := lipgloss.Color("230") // Light foreground
|
||||||
Foreground(lipgloss.Color("220"))
|
|
||||||
|
|
||||||
separatorStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("240"))
|
|
||||||
|
|
||||||
selectedStyle := lipgloss.NewStyle().
|
|
||||||
Background(lipgloss.Color("240")).
|
|
||||||
Foreground(lipgloss.Color("230"))
|
|
||||||
|
|
||||||
disabledStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("240"))
|
|
||||||
|
|
||||||
activeStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("46"))
|
|
||||||
|
|
||||||
startingStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("220"))
|
|
||||||
|
|
||||||
errorStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("196"))
|
|
||||||
|
|
||||||
// Title with version
|
// Title with version
|
||||||
|
titleStyle := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(headerColor).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
title := fmt.Sprintf("kportal v%s - Port Forwarding Status", m.ui.version)
|
title := fmt.Sprintf("kportal v%s - Port Forwarding Status", m.ui.version)
|
||||||
b.WriteString(titleStyle.Render(title))
|
b.WriteString(titleStyle.Render(title))
|
||||||
|
|
||||||
@@ -383,94 +412,104 @@ func (m model) renderMainView() string {
|
|||||||
}
|
}
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
// Header
|
|
||||||
header := fmt.Sprintf("%-15s %-18s %-20s %-10s %-21s %7s %7s %s",
|
|
||||||
"CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS")
|
|
||||||
b.WriteString(headerStyle.Render(header))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(separatorStyle.Render(strings.Repeat("─", 120)))
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// No forwards
|
// No forwards
|
||||||
if len(m.ui.forwardOrder) == 0 {
|
if len(m.ui.forwardOrder) == 0 {
|
||||||
b.WriteString(disabledStyle.Render("\nNo forwards configured\n"))
|
disabledStyle := lipgloss.NewStyle().Foreground(mutedColor)
|
||||||
|
b.WriteString(disabledStyle.Render("No forwards configured\n"))
|
||||||
} else {
|
} else {
|
||||||
// Display forwards
|
// Build table rows
|
||||||
for idx, id := range m.ui.forwardOrder {
|
var rows [][]string
|
||||||
|
for _, id := range m.ui.forwardOrder {
|
||||||
fwd, ok := m.ui.forwards[id]
|
fwd, ok := m.ui.forwards[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
isSelected := (idx == m.ui.selectedIndex)
|
|
||||||
isDisabled := m.ui.disabledMap[id] || fwd.Status == "Disabled"
|
isDisabled := m.ui.disabledMap[id] || fwd.Status == "Disabled"
|
||||||
|
|
||||||
// Selection indicator
|
|
||||||
indicator := " "
|
|
||||||
if isSelected {
|
|
||||||
indicator = "> "
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status icon and text
|
// Status icon and text
|
||||||
statusIcon := "● "
|
statusIcon := "●"
|
||||||
statusText := fwd.Status
|
statusText := fwd.Status
|
||||||
|
|
||||||
if isDisabled {
|
if isDisabled {
|
||||||
statusIcon = "○ "
|
statusIcon = "○"
|
||||||
statusText = "Disabled"
|
statusText = "Disabled"
|
||||||
} else {
|
} else {
|
||||||
switch fwd.Status {
|
switch fwd.Status {
|
||||||
case "Starting":
|
case "Starting":
|
||||||
statusIcon = "○ "
|
statusIcon = "○"
|
||||||
case "Reconnecting":
|
case "Reconnecting":
|
||||||
statusIcon = "◐ "
|
statusIcon = "◐"
|
||||||
case "Error":
|
case "Error":
|
||||||
statusIcon = "✗ "
|
statusIcon = "✗"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format row
|
rows = append(rows, []string{
|
||||||
row := fmt.Sprintf("%s%-15s %-18s %-20s %-10s %-21s %7d %7d %s%s",
|
truncate(fwd.Context, 14),
|
||||||
indicator,
|
truncate(fwd.Namespace, 16),
|
||||||
truncate(fwd.Context, 15),
|
truncate(fwd.Alias, 18),
|
||||||
truncate(fwd.Namespace, 18),
|
truncate(fwd.Type, 8),
|
||||||
truncate(fwd.Alias, 20),
|
truncate(fwd.Resource, 20),
|
||||||
truncate(fwd.Type, 10),
|
fmt.Sprintf("%d", fwd.RemotePort),
|
||||||
truncate(fwd.Resource, 21),
|
fmt.Sprintf("%d", fwd.LocalPort),
|
||||||
fwd.RemotePort,
|
statusIcon + " " + statusText,
|
||||||
fwd.LocalPort,
|
})
|
||||||
statusIcon,
|
|
||||||
statusText)
|
|
||||||
|
|
||||||
// Apply styling
|
|
||||||
if isSelected {
|
|
||||||
row = selectedStyle.Render(row)
|
|
||||||
} else if isDisabled {
|
|
||||||
row = disabledStyle.Render(row)
|
|
||||||
} else {
|
|
||||||
// Color the status part
|
|
||||||
switch fwd.Status {
|
|
||||||
case "Active":
|
|
||||||
parts := strings.Split(row, statusIcon)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
row = parts[0] + activeStyle.Render(statusIcon+statusText)
|
|
||||||
}
|
|
||||||
case "Starting", "Reconnecting":
|
|
||||||
parts := strings.Split(row, statusIcon)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
row = parts[0] + startingStyle.Render(statusIcon+statusText)
|
|
||||||
}
|
|
||||||
case "Error":
|
|
||||||
parts := strings.Split(row, statusIcon)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
row = parts[0] + errorStyle.Render(statusIcon+statusText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString(row)
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create table with styling (no borders for cleaner look)
|
||||||
|
t := table.New().
|
||||||
|
Border(lipgloss.HiddenBorder()).
|
||||||
|
Headers("CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS").
|
||||||
|
Rows(rows...).
|
||||||
|
StyleFunc(func(row, col int) lipgloss.Style {
|
||||||
|
// Header row
|
||||||
|
if row == table.HeaderRow {
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(headerColor).
|
||||||
|
Padding(0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the forward for this row to check its status
|
||||||
|
baseStyle := lipgloss.NewStyle().Padding(0, 1)
|
||||||
|
|
||||||
|
if row >= 0 && row < len(m.ui.forwardOrder) {
|
||||||
|
id := m.ui.forwardOrder[row]
|
||||||
|
fwd, ok := m.ui.forwards[id]
|
||||||
|
isSelected := row == m.ui.selectedIndex
|
||||||
|
isDisabled := m.ui.disabledMap[id] || (ok && fwd.Status == "Disabled")
|
||||||
|
|
||||||
|
// Selected row gets background highlight
|
||||||
|
if isSelected {
|
||||||
|
return baseStyle.
|
||||||
|
Background(selectedBg).
|
||||||
|
Foreground(selectedFg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabled rows are muted
|
||||||
|
if isDisabled {
|
||||||
|
return baseStyle.Foreground(mutedColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status column gets colored based on status
|
||||||
|
if col == 7 && ok { // STATUS column
|
||||||
|
switch fwd.Status {
|
||||||
|
case "Active":
|
||||||
|
return baseStyle.Foreground(activeColor)
|
||||||
|
case "Starting", "Reconnecting":
|
||||||
|
return baseStyle.Foreground(warningColor)
|
||||||
|
case "Error":
|
||||||
|
return baseStyle.Foreground(errorColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseStyle
|
||||||
|
})
|
||||||
|
|
||||||
|
b.WriteString(t.Render())
|
||||||
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display errors if any (before footer)
|
// Display errors if any (before footer)
|
||||||
@@ -524,13 +563,15 @@ func (m model) renderMainView() string {
|
|||||||
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||||
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
|
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
|
||||||
|
|
||||||
footer := fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: New %s: Edit %s: Delete %s: Quit │ Total: %d",
|
footer := fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: New %s: Edit %s: Delete %s: Bench %s: Logs %s: Quit │ Total: %d",
|
||||||
keyStyle.Render("↑↓"),
|
keyStyle.Render("↑↓"),
|
||||||
keyStyle.Render("jk"),
|
keyStyle.Render("jk"),
|
||||||
keyStyle.Render("Space"),
|
keyStyle.Render("Space"),
|
||||||
keyStyle.Render("n"),
|
keyStyle.Render("n"),
|
||||||
keyStyle.Render("e"),
|
keyStyle.Render("e"),
|
||||||
keyStyle.Render("d"),
|
keyStyle.Render("d"),
|
||||||
|
keyStyle.Render("b"),
|
||||||
|
keyStyle.Render("l"),
|
||||||
keyStyle.Render("q"),
|
keyStyle.Render("q"),
|
||||||
len(m.ui.forwardOrder))
|
len(m.ui.forwardOrder))
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/nvm/kportal/internal/benchmark"
|
||||||
"github.com/nvm/kportal/internal/config"
|
"github.com/nvm/kportal/internal/config"
|
||||||
"github.com/nvm/kportal/internal/k8s"
|
"github.com/nvm/kportal/internal/k8s"
|
||||||
)
|
)
|
||||||
@@ -237,3 +238,77 @@ func removeForwardByIDCmd(mutator *config.Mutator, id string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BenchmarkCompleteMsg is sent when a benchmark run completes
|
||||||
|
type BenchmarkCompleteMsg struct {
|
||||||
|
ForwardID string
|
||||||
|
Results *benchmark.Results
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkProgressMsg is sent periodically during benchmark execution
|
||||||
|
type BenchmarkProgressMsg struct {
|
||||||
|
ForwardID string
|
||||||
|
Completed int
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPLogEntryMsg is sent when a new HTTP log entry is received
|
||||||
|
type HTTPLogEntryMsg struct {
|
||||||
|
Entry HTTPLogEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenBenchmarkProgressCmd listens for progress updates from the benchmark
|
||||||
|
func listenBenchmarkProgressCmd(progressCh <-chan BenchmarkProgressMsg) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
msg, ok := <-progressCh
|
||||||
|
if !ok {
|
||||||
|
// Channel closed, benchmark complete
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBenchmarkCmd runs a benchmark against the given port forward
|
||||||
|
// It sends progress updates via tea.Batch until completion
|
||||||
|
func runBenchmarkCmd(forwardID string, localPort int, urlPath, method string, concurrency, requests int, progressCh chan<- BenchmarkProgressMsg) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
runner := benchmark.NewRunner()
|
||||||
|
|
||||||
|
url := fmt.Sprintf("http://localhost:%d%s", localPort, urlPath)
|
||||||
|
cfg := benchmark.Config{
|
||||||
|
URL: url,
|
||||||
|
Method: method,
|
||||||
|
Concurrency: concurrency,
|
||||||
|
Requests: requests,
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
ProgressCallback: func(completed, total int) {
|
||||||
|
// Non-blocking send to progress channel
|
||||||
|
select {
|
||||||
|
case progressCh <- BenchmarkProgressMsg{
|
||||||
|
ForwardID: forwardID,
|
||||||
|
Completed: completed,
|
||||||
|
Total: total,
|
||||||
|
}:
|
||||||
|
default:
|
||||||
|
// Drop if channel is full
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
results, err := runner.Run(ctx, forwardID, cfg)
|
||||||
|
|
||||||
|
// Close the progress channel when done
|
||||||
|
close(progressCh)
|
||||||
|
|
||||||
|
return BenchmarkCompleteMsg{
|
||||||
|
ForwardID: forwardID,
|
||||||
|
Results: results,
|
||||||
|
Error: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "down", "j":
|
case "down", "j":
|
||||||
m.ui.moveSelection(1)
|
m.ui.moveSelection(1)
|
||||||
|
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
m.ui.moveSelection(-10)
|
||||||
|
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
m.ui.moveSelection(10)
|
||||||
|
|
||||||
case " ", "enter":
|
case " ", "enter":
|
||||||
m.ui.toggleSelected()
|
m.ui.toggleSelected()
|
||||||
|
|
||||||
@@ -159,6 +165,75 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.ui.deleteConfirmAlias = selectedForward.Alias
|
m.ui.deleteConfirmAlias = selectedForward.Alias
|
||||||
m.ui.deleteConfirmCursor = 0 // Default to "No" for safety
|
m.ui.deleteConfirmCursor = 0 // Default to "No" for safety
|
||||||
|
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "b": // Benchmark selected forward
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
|
||||||
|
if len(m.ui.forwardOrder) == 0 {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSelectedIndex := m.ui.selectedIndex
|
||||||
|
if currentSelectedIndex < 0 || currentSelectedIndex >= len(m.ui.forwardOrder) {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedID := m.ui.forwardOrder[currentSelectedIndex]
|
||||||
|
selectedForward, ok := m.ui.forwards[selectedID]
|
||||||
|
if !ok {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create benchmark state
|
||||||
|
m.ui.viewMode = ViewModeBenchmark
|
||||||
|
m.ui.benchmarkState = newBenchmarkState(selectedID, selectedForward.Alias, selectedForward.LocalPort)
|
||||||
|
// Initialize textInput with the first field's value
|
||||||
|
m.ui.benchmarkState.textInput = m.ui.benchmarkState.urlPath
|
||||||
|
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "l": // View HTTP logs for selected forward
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
|
||||||
|
if len(m.ui.forwardOrder) == 0 {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSelectedIndex := m.ui.selectedIndex
|
||||||
|
if currentSelectedIndex < 0 || currentSelectedIndex >= len(m.ui.forwardOrder) {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedID := m.ui.forwardOrder[currentSelectedIndex]
|
||||||
|
selectedForward, ok := m.ui.forwards[selectedID]
|
||||||
|
if !ok {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP log state
|
||||||
|
m.ui.viewMode = ViewModeHTTPLog
|
||||||
|
m.ui.httpLogState = newHTTPLogState(selectedID, selectedForward.Alias)
|
||||||
|
|
||||||
|
// Subscribe to HTTP logs if subscriber is available
|
||||||
|
if m.ui.httpLogSubscriber != nil {
|
||||||
|
cleanup := m.ui.httpLogSubscriber(selectedID, func(entry HTTPLogEntry) {
|
||||||
|
// Add entry to state (thread-safe via Send)
|
||||||
|
if m.ui.program != nil {
|
||||||
|
m.ui.program.Send(HTTPLogEntryMsg{Entry: entry})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
m.ui.httpLogCleanup = cleanup
|
||||||
|
}
|
||||||
|
|
||||||
m.ui.mu.Unlock()
|
m.ui.mu.Unlock()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -290,6 +365,14 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
wizard.moveCursor(1)
|
wizard.moveCursor(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
// Page up - move 10 items
|
||||||
|
wizard.moveCursor(-10)
|
||||||
|
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
// Page down - move 10 items
|
||||||
|
wizard.moveCursor(10)
|
||||||
|
|
||||||
case "tab":
|
case "tab":
|
||||||
// Tab moves between alias field and buttons in confirmation
|
// Tab moves between alias field and buttons in confirmation
|
||||||
if wizard.step == StepConfirmation {
|
if wizard.step == StepConfirmation {
|
||||||
@@ -468,7 +551,14 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
|||||||
wizard.clearTextInput()
|
wizard.clearTextInput()
|
||||||
} else if wizard.cursor >= 0 && wizard.cursor < len(wizard.detectedPorts) {
|
} else if wizard.cursor >= 0 && wizard.cursor < len(wizard.detectedPorts) {
|
||||||
// Selected a detected port
|
// Selected a detected port
|
||||||
wizard.remotePort = int(wizard.detectedPorts[wizard.cursor].Port)
|
// For services, use TargetPort (actual pod port) if available
|
||||||
|
// For pods, TargetPort is 0, so use Port (container port)
|
||||||
|
selectedPort := wizard.detectedPorts[wizard.cursor]
|
||||||
|
if selectedPort.TargetPort > 0 {
|
||||||
|
wizard.remotePort = int(selectedPort.TargetPort)
|
||||||
|
} else {
|
||||||
|
wizard.remotePort = int(selectedPort.Port)
|
||||||
|
}
|
||||||
wizard.step = StepEnterLocalPort
|
wizard.step = StepEnterLocalPort
|
||||||
wizard.clearTextInput()
|
wizard.clearTextInput()
|
||||||
wizard.inputMode = InputModeText
|
wizard.inputMode = InputModeText
|
||||||
@@ -602,6 +692,12 @@ func (m model) handleRemoveWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "down", "j":
|
case "down", "j":
|
||||||
wizard.moveCursor(1)
|
wizard.moveCursor(1)
|
||||||
|
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
wizard.moveCursor(-10)
|
||||||
|
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
wizard.moveCursor(10)
|
||||||
|
|
||||||
case " ":
|
case " ":
|
||||||
if !wizard.confirming {
|
if !wizard.confirming {
|
||||||
wizard.toggleSelection()
|
wizard.toggleSelection()
|
||||||
@@ -817,3 +913,360 @@ func (m model) handleForwardsRemoved(msg ForwardsRemovedMsg) (tea.Model, tea.Cmd
|
|||||||
|
|
||||||
return m, tea.ClearScreen
|
return m, tea.ClearScreen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleBenchmarkKeys handles keyboard input in the benchmark view
|
||||||
|
func (m model) handleBenchmarkKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
defer m.ui.mu.Unlock()
|
||||||
|
|
||||||
|
state := m.ui.benchmarkState
|
||||||
|
if state == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+c", "esc":
|
||||||
|
// Cancel and return to main view
|
||||||
|
m.ui.viewMode = ViewModeMain
|
||||||
|
m.ui.benchmarkState = nil
|
||||||
|
return m, tea.ClearScreen
|
||||||
|
|
||||||
|
case "up", "k":
|
||||||
|
if state.step == BenchmarkStepConfig && state.cursor > 0 {
|
||||||
|
state.cursor--
|
||||||
|
// Load current field value into textInput
|
||||||
|
state.textInput = m.getBenchmarkFieldValue(state.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "down", "j":
|
||||||
|
if state.step == BenchmarkStepConfig && state.cursor < 3 {
|
||||||
|
state.cursor++
|
||||||
|
// Load current field value into textInput
|
||||||
|
state.textInput = m.getBenchmarkFieldValue(state.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tab":
|
||||||
|
// Tab also cycles through fields
|
||||||
|
if state.step == BenchmarkStepConfig {
|
||||||
|
state.cursor = (state.cursor + 1) % 4
|
||||||
|
state.textInput = m.getBenchmarkFieldValue(state.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "enter":
|
||||||
|
switch state.step {
|
||||||
|
case BenchmarkStepConfig:
|
||||||
|
// Start running the benchmark
|
||||||
|
state.step = BenchmarkStepRunning
|
||||||
|
state.running = true
|
||||||
|
state.progress = 0
|
||||||
|
state.total = state.requests
|
||||||
|
// Create progress channel with buffer for non-blocking sends
|
||||||
|
state.progressCh = make(chan BenchmarkProgressMsg, 10)
|
||||||
|
// Return batch command to run benchmark and listen for progress
|
||||||
|
return m, tea.Batch(
|
||||||
|
runBenchmarkCmd(state.forwardID, state.localPort, state.urlPath, state.method, state.concurrency, state.requests, state.progressCh),
|
||||||
|
listenBenchmarkProgressCmd(state.progressCh),
|
||||||
|
)
|
||||||
|
case BenchmarkStepResults:
|
||||||
|
// Return to main view
|
||||||
|
m.ui.viewMode = ViewModeMain
|
||||||
|
m.ui.benchmarkState = nil
|
||||||
|
return m, tea.ClearScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
case "backspace":
|
||||||
|
if state.step == BenchmarkStepConfig {
|
||||||
|
if len(state.textInput) > 0 {
|
||||||
|
state.textInput = state.textInput[:len(state.textInput)-1]
|
||||||
|
m.applyBenchmarkTextInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Handle text input in config step
|
||||||
|
if state.step == BenchmarkStepConfig && len(msg.String()) == 1 {
|
||||||
|
char := rune(msg.String()[0])
|
||||||
|
if char >= 32 && char < 127 {
|
||||||
|
state.textInput += string(char)
|
||||||
|
m.applyBenchmarkTextInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBenchmarkFieldValue returns the current value of the selected benchmark field
|
||||||
|
func (m model) getBenchmarkFieldValue(cursor int) string {
|
||||||
|
state := m.ui.benchmarkState
|
||||||
|
if state == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cursor {
|
||||||
|
case 0:
|
||||||
|
return state.urlPath
|
||||||
|
case 1:
|
||||||
|
return state.method
|
||||||
|
case 2:
|
||||||
|
return fmt.Sprintf("%d", state.concurrency)
|
||||||
|
case 3:
|
||||||
|
return fmt.Sprintf("%d", state.requests)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyBenchmarkTextInput applies the current text input to the selected field
|
||||||
|
func (m model) applyBenchmarkTextInput() {
|
||||||
|
state := m.ui.benchmarkState
|
||||||
|
if state == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch state.cursor {
|
||||||
|
case 0: // URL path
|
||||||
|
state.urlPath = state.textInput
|
||||||
|
case 1: // Method
|
||||||
|
state.method = strings.ToUpper(state.textInput)
|
||||||
|
case 2: // Concurrency
|
||||||
|
if val, err := strconv.Atoi(state.textInput); err == nil && val > 0 {
|
||||||
|
state.concurrency = val
|
||||||
|
// Cap concurrency at requests
|
||||||
|
if state.concurrency > state.requests {
|
||||||
|
state.concurrency = state.requests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 3: // Requests
|
||||||
|
if val, err := strconv.Atoi(state.textInput); err == nil && val > 0 {
|
||||||
|
state.requests = val
|
||||||
|
// Cap concurrency at requests
|
||||||
|
if state.concurrency > state.requests {
|
||||||
|
state.concurrency = state.requests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHTTPLogKeys handles keyboard input in the HTTP log view
|
||||||
|
func (m model) handleHTTPLogKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
defer m.ui.mu.Unlock()
|
||||||
|
|
||||||
|
state := m.ui.httpLogState
|
||||||
|
if state == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If filter input is active, handle text input
|
||||||
|
if state.filterActive {
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc":
|
||||||
|
// Cancel filter input, clear text
|
||||||
|
state.filterActive = false
|
||||||
|
state.filterText = ""
|
||||||
|
state.cursor = 0
|
||||||
|
state.scrollOffset = 0
|
||||||
|
return m, nil
|
||||||
|
case "enter":
|
||||||
|
// Confirm filter
|
||||||
|
state.filterActive = false
|
||||||
|
state.cursor = 0
|
||||||
|
state.scrollOffset = 0
|
||||||
|
return m, nil
|
||||||
|
case "backspace":
|
||||||
|
if len(state.filterText) > 0 {
|
||||||
|
state.filterText = state.filterText[:len(state.filterText)-1]
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
default:
|
||||||
|
// Add character to filter
|
||||||
|
if len(msg.String()) == 1 {
|
||||||
|
char := rune(msg.String()[0])
|
||||||
|
if char >= 32 && char < 127 {
|
||||||
|
state.filterText += string(char)
|
||||||
|
state.cursor = 0
|
||||||
|
state.scrollOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredEntries := state.getFilteredEntries()
|
||||||
|
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+c", "esc", "q":
|
||||||
|
// Cleanup subscription before closing
|
||||||
|
if m.ui.httpLogCleanup != nil {
|
||||||
|
m.ui.httpLogCleanup()
|
||||||
|
m.ui.httpLogCleanup = nil
|
||||||
|
}
|
||||||
|
// Return to main view
|
||||||
|
m.ui.viewMode = ViewModeMain
|
||||||
|
m.ui.httpLogState = nil
|
||||||
|
return m, tea.ClearScreen
|
||||||
|
|
||||||
|
case "up", "k":
|
||||||
|
if state.cursor > 0 {
|
||||||
|
state.cursor--
|
||||||
|
state.autoScroll = false
|
||||||
|
}
|
||||||
|
|
||||||
|
case "down", "j":
|
||||||
|
if state.cursor < len(filteredEntries)-1 {
|
||||||
|
state.cursor++
|
||||||
|
}
|
||||||
|
// If at bottom, enable auto-scroll
|
||||||
|
if state.cursor >= len(filteredEntries)-1 {
|
||||||
|
state.autoScroll = true
|
||||||
|
}
|
||||||
|
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
// Page up - move 20 entries
|
||||||
|
state.cursor -= 20
|
||||||
|
if state.cursor < 0 {
|
||||||
|
state.cursor = 0
|
||||||
|
}
|
||||||
|
state.autoScroll = false
|
||||||
|
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
// Page down - move 20 entries
|
||||||
|
state.cursor += 20
|
||||||
|
if state.cursor >= len(filteredEntries) {
|
||||||
|
state.cursor = len(filteredEntries) - 1
|
||||||
|
}
|
||||||
|
if state.cursor < 0 {
|
||||||
|
state.cursor = 0
|
||||||
|
}
|
||||||
|
// If at bottom, enable auto-scroll
|
||||||
|
if state.cursor >= len(filteredEntries)-1 {
|
||||||
|
state.autoScroll = true
|
||||||
|
}
|
||||||
|
|
||||||
|
case "g":
|
||||||
|
// Go to top
|
||||||
|
state.cursor = 0
|
||||||
|
state.scrollOffset = 0
|
||||||
|
state.autoScroll = false
|
||||||
|
|
||||||
|
case "G":
|
||||||
|
// Go to bottom
|
||||||
|
if len(filteredEntries) > 0 {
|
||||||
|
state.cursor = len(filteredEntries) - 1
|
||||||
|
state.autoScroll = true
|
||||||
|
}
|
||||||
|
|
||||||
|
case "a":
|
||||||
|
// Toggle auto-scroll
|
||||||
|
state.autoScroll = !state.autoScroll
|
||||||
|
|
||||||
|
case "f":
|
||||||
|
// Cycle filter mode
|
||||||
|
state.cycleFilterMode()
|
||||||
|
|
||||||
|
case "/":
|
||||||
|
// Enter text filter mode
|
||||||
|
state.filterActive = true
|
||||||
|
state.filterText = ""
|
||||||
|
|
||||||
|
case "c":
|
||||||
|
// Clear all filters
|
||||||
|
state.filterMode = HTTPLogFilterNone
|
||||||
|
state.filterText = ""
|
||||||
|
state.cursor = 0
|
||||||
|
state.scrollOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHTTPLogEntry handles incoming HTTP log entries
|
||||||
|
func (m model) handleHTTPLogEntry(msg HTTPLogEntryMsg) (tea.Model, tea.Cmd) {
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
defer m.ui.mu.Unlock()
|
||||||
|
|
||||||
|
if m.ui.httpLogState == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
state := m.ui.httpLogState
|
||||||
|
state.entries = append(state.entries, msg.Entry)
|
||||||
|
|
||||||
|
// Cap entries to prevent memory growth (keep last 10000 entries)
|
||||||
|
const maxEntries = 10000
|
||||||
|
if len(state.entries) > maxEntries {
|
||||||
|
// Remove oldest entries
|
||||||
|
state.entries = state.entries[len(state.entries)-maxEntries:]
|
||||||
|
// Adjust cursor if needed
|
||||||
|
if state.cursor >= len(state.entries) {
|
||||||
|
state.cursor = len(state.entries) - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll to bottom if enabled
|
||||||
|
if state.autoScroll && len(state.entries) > 0 {
|
||||||
|
state.cursor = len(state.entries) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBenchmarkProgress handles progress updates during benchmark execution
|
||||||
|
func (m model) handleBenchmarkProgress(msg BenchmarkProgressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
defer m.ui.mu.Unlock()
|
||||||
|
|
||||||
|
if m.ui.benchmarkState == nil || !m.ui.benchmarkState.running {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
state := m.ui.benchmarkState
|
||||||
|
state.progress = msg.Completed
|
||||||
|
state.total = msg.Total
|
||||||
|
|
||||||
|
// Continue listening for more progress updates
|
||||||
|
if state.progressCh != nil {
|
||||||
|
return m, listenBenchmarkProgressCmd(state.progressCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBenchmarkComplete handles the benchmark completion message
|
||||||
|
func (m model) handleBenchmarkComplete(msg BenchmarkCompleteMsg) (tea.Model, tea.Cmd) {
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
defer m.ui.mu.Unlock()
|
||||||
|
|
||||||
|
if m.ui.benchmarkState == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
state := m.ui.benchmarkState
|
||||||
|
state.running = false
|
||||||
|
state.step = BenchmarkStepResults
|
||||||
|
state.progressCh = nil // Clear progress channel since benchmark is complete
|
||||||
|
|
||||||
|
if msg.Error != nil {
|
||||||
|
state.error = msg.Error
|
||||||
|
state.results = nil
|
||||||
|
} else if msg.Results != nil {
|
||||||
|
stats := msg.Results.CalculateStats()
|
||||||
|
state.results = &BenchmarkResults{
|
||||||
|
TotalRequests: msg.Results.TotalRequests,
|
||||||
|
Successful: msg.Results.Successful,
|
||||||
|
Failed: msg.Results.Failed,
|
||||||
|
MinLatency: float64(stats.MinLatency.Milliseconds()),
|
||||||
|
MaxLatency: float64(stats.MaxLatency.Milliseconds()),
|
||||||
|
AvgLatency: float64(stats.AvgLatency.Milliseconds()),
|
||||||
|
P50Latency: float64(stats.P50Latency.Milliseconds()),
|
||||||
|
P95Latency: float64(stats.P95Latency.Milliseconds()),
|
||||||
|
P99Latency: float64(stats.P99Latency.Milliseconds()),
|
||||||
|
Throughput: stats.Throughput,
|
||||||
|
BytesRead: msg.Results.BytesRead,
|
||||||
|
StatusCodes: msg.Results.StatusCodes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ const (
|
|||||||
ViewModeMain ViewMode = iota
|
ViewModeMain ViewMode = iota
|
||||||
ViewModeAddWizard
|
ViewModeAddWizard
|
||||||
ViewModeRemoveWizard
|
ViewModeRemoveWizard
|
||||||
|
ViewModeBenchmark
|
||||||
|
ViewModeHTTPLog
|
||||||
)
|
)
|
||||||
|
|
||||||
// InputMode represents whether the wizard is in list selection or text input mode
|
// InputMode represents whether the wizard is in list selection or text input mode
|
||||||
@@ -373,3 +375,179 @@ func (w *AddWizardState) resetInput() {
|
|||||||
w.scrollOffset = 0
|
w.scrollOffset = 0
|
||||||
w.error = nil
|
w.error = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BenchmarkStep represents the current step in the benchmark wizard
|
||||||
|
type BenchmarkStep int
|
||||||
|
|
||||||
|
const (
|
||||||
|
BenchmarkStepConfig BenchmarkStep = iota
|
||||||
|
BenchmarkStepRunning
|
||||||
|
BenchmarkStepResults
|
||||||
|
)
|
||||||
|
|
||||||
|
// BenchmarkState maintains the state for the benchmark wizard
|
||||||
|
type BenchmarkState struct {
|
||||||
|
step BenchmarkStep
|
||||||
|
forwardID string
|
||||||
|
forwardAlias string
|
||||||
|
localPort int
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
urlPath string
|
||||||
|
method string
|
||||||
|
concurrency int
|
||||||
|
requests int
|
||||||
|
cursor int // Current field being edited
|
||||||
|
textInput string
|
||||||
|
|
||||||
|
// Running state
|
||||||
|
running bool
|
||||||
|
progress int
|
||||||
|
total int
|
||||||
|
progressCh chan BenchmarkProgressMsg // Channel for progress updates
|
||||||
|
|
||||||
|
// Results
|
||||||
|
results *BenchmarkResults
|
||||||
|
error error
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkResults holds benchmark results for display
|
||||||
|
type BenchmarkResults struct {
|
||||||
|
TotalRequests int
|
||||||
|
Successful int
|
||||||
|
Failed int
|
||||||
|
MinLatency float64 // milliseconds
|
||||||
|
MaxLatency float64
|
||||||
|
AvgLatency float64
|
||||||
|
P50Latency float64
|
||||||
|
P95Latency float64
|
||||||
|
P99Latency float64
|
||||||
|
Throughput float64 // requests per second
|
||||||
|
BytesRead int64
|
||||||
|
StatusCodes map[int]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBenchmarkState creates a new benchmark state for a forward
|
||||||
|
func newBenchmarkState(forwardID, alias string, localPort int) *BenchmarkState {
|
||||||
|
return &BenchmarkState{
|
||||||
|
step: BenchmarkStepConfig,
|
||||||
|
forwardID: forwardID,
|
||||||
|
forwardAlias: alias,
|
||||||
|
localPort: localPort,
|
||||||
|
urlPath: "/",
|
||||||
|
method: "GET",
|
||||||
|
concurrency: 10,
|
||||||
|
requests: 100,
|
||||||
|
cursor: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPLogFilterMode represents the active filter type
|
||||||
|
type HTTPLogFilterMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
HTTPLogFilterNone HTTPLogFilterMode = iota
|
||||||
|
HTTPLogFilterText
|
||||||
|
HTTPLogFilterNon200
|
||||||
|
HTTPLogFilterErrors // 4xx and 5xx only
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPLogState maintains the state for HTTP log viewing
|
||||||
|
type HTTPLogState struct {
|
||||||
|
forwardID string
|
||||||
|
forwardAlias string
|
||||||
|
entries []HTTPLogEntry
|
||||||
|
cursor int
|
||||||
|
scrollOffset int
|
||||||
|
autoScroll bool
|
||||||
|
|
||||||
|
// Filtering
|
||||||
|
filterMode HTTPLogFilterMode
|
||||||
|
filterText string
|
||||||
|
filterActive bool // true when typing in filter input
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPLogEntry represents a single HTTP log entry for display
|
||||||
|
type HTTPLogEntry struct {
|
||||||
|
Timestamp string
|
||||||
|
Direction string
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
|
StatusCode int
|
||||||
|
LatencyMs int64
|
||||||
|
BodySize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// newHTTPLogState creates a new HTTP log viewing state
|
||||||
|
func newHTTPLogState(forwardID, alias string) *HTTPLogState {
|
||||||
|
return &HTTPLogState{
|
||||||
|
forwardID: forwardID,
|
||||||
|
forwardAlias: alias,
|
||||||
|
entries: make([]HTTPLogEntry, 0),
|
||||||
|
autoScroll: true,
|
||||||
|
filterMode: HTTPLogFilterNone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFilteredEntries returns entries matching the current filter
|
||||||
|
// Only returns entries with status codes (responses) since requests don't have useful info
|
||||||
|
func (s *HTTPLogState) getFilteredEntries() []HTTPLogEntry {
|
||||||
|
filtered := make([]HTTPLogEntry, 0, len(s.entries))
|
||||||
|
filterLower := strings.ToLower(s.filterText)
|
||||||
|
|
||||||
|
for _, entry := range s.entries {
|
||||||
|
// Only show entries with status codes (completed responses)
|
||||||
|
// Requests, streaming connections, and errors without status are filtered out
|
||||||
|
if entry.StatusCode == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filter mode
|
||||||
|
switch s.filterMode {
|
||||||
|
case HTTPLogFilterNon200:
|
||||||
|
if entry.StatusCode >= 200 && entry.StatusCode < 300 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case HTTPLogFilterErrors:
|
||||||
|
if entry.StatusCode < 400 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply text filter
|
||||||
|
if s.filterText != "" {
|
||||||
|
matchPath := strings.Contains(strings.ToLower(entry.Path), filterLower)
|
||||||
|
matchMethod := strings.Contains(strings.ToLower(entry.Method), filterLower)
|
||||||
|
if !matchPath && !matchMethod {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// cycleFilterMode cycles through filter modes
|
||||||
|
func (s *HTTPLogState) cycleFilterMode() {
|
||||||
|
s.filterMode = (s.filterMode + 1) % 4
|
||||||
|
s.cursor = 0
|
||||||
|
s.scrollOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFilterModeLabel returns a label for the current filter mode
|
||||||
|
func (s *HTTPLogState) getFilterModeLabel() string {
|
||||||
|
switch s.filterMode {
|
||||||
|
case HTTPLogFilterNone:
|
||||||
|
return "All"
|
||||||
|
case HTTPLogFilterText:
|
||||||
|
return "Text"
|
||||||
|
case HTTPLogFilterNon200:
|
||||||
|
return "Non-2xx"
|
||||||
|
case HTTPLogFilterErrors:
|
||||||
|
return "Errors (4xx/5xx)"
|
||||||
|
default:
|
||||||
|
return "All"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ var (
|
|||||||
spinnerStyle = lipgloss.NewStyle().
|
spinnerStyle = lipgloss.NewStyle().
|
||||||
Foreground(accentColor).
|
Foreground(accentColor).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
|
accentStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(accentColor).
|
||||||
|
Bold(true)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Input styles
|
// Input styles
|
||||||
@@ -82,11 +86,11 @@ var (
|
|||||||
|
|
||||||
// Container styles
|
// Container styles
|
||||||
var (
|
var (
|
||||||
|
// wizardBoxStyle creates a bordered modal box
|
||||||
wizardBoxStyle = lipgloss.NewStyle().
|
wizardBoxStyle = lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(accentColor).
|
BorderForeground(accentColor).
|
||||||
Padding(1, 2).
|
Padding(1, 2)
|
||||||
Width(60)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Helper functions for rendering
|
// Helper functions for rendering
|
||||||
@@ -166,46 +170,17 @@ func renderTextInput(label, value string, valid bool) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// overlayContent overlays modal content centered on the base view
|
// overlayContent overlays modal content centered on the base view
|
||||||
func overlayContent(base, modal string, termWidth, termHeight int) string {
|
// Note: base parameter is kept for API compatibility but not used since
|
||||||
baseLines := strings.Split(base, "\n")
|
// lipgloss.Place provides cleaner centering without background artifacts
|
||||||
modalLines := strings.Split(modal, "\n")
|
func overlayContent(_, modal string, termWidth, termHeight int) string {
|
||||||
|
// Use lipgloss.Place to center the modal in the terminal viewport
|
||||||
// Ensure base has enough lines
|
// This handles all alignment properly and respects ANSI styling
|
||||||
for len(baseLines) < termHeight {
|
return lipgloss.Place(
|
||||||
baseLines = append(baseLines, "")
|
termWidth,
|
||||||
}
|
termHeight,
|
||||||
|
lipgloss.Center,
|
||||||
modalHeight := len(modalLines)
|
lipgloss.Center,
|
||||||
modalWidth := 0
|
modal,
|
||||||
for _, line := range modalLines {
|
lipgloss.WithWhitespaceChars(" "),
|
||||||
w := lipgloss.Width(line)
|
)
|
||||||
if w > modalWidth {
|
|
||||||
modalWidth = w
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate center position
|
|
||||||
startRow := (termHeight - modalHeight) / 2
|
|
||||||
if startRow < 0 {
|
|
||||||
startRow = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create result with modal overlaid
|
|
||||||
result := make([]string, len(baseLines))
|
|
||||||
copy(result, baseLines)
|
|
||||||
|
|
||||||
for i, modalLine := range modalLines {
|
|
||||||
row := startRow + i
|
|
||||||
if row >= 0 && row < len(result) {
|
|
||||||
// Center the modal line
|
|
||||||
padding := (termWidth - lipgloss.Width(modalLine)) / 2
|
|
||||||
if padding < 0 {
|
|
||||||
padding = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
result[row] = strings.Repeat(" ", padding) + modalLine
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(result, "\n")
|
|
||||||
}
|
}
|
||||||
|
|||||||
+429
-10
@@ -325,11 +325,13 @@ func (m model) renderEnterRemotePort() string {
|
|||||||
if wizard.selector != "" {
|
if wizard.selector != "" {
|
||||||
resourceInfo = fmt.Sprintf("%s [%s]", wizard.resourceValue, wizard.selector)
|
resourceInfo = fmt.Sprintf("%s [%s]", wizard.resourceValue, wizard.selector)
|
||||||
}
|
}
|
||||||
b.WriteString(mutedStyle.Render(fmt.Sprintf("Resource: %s\n\n", resourceInfo)))
|
b.WriteString(mutedStyle.Render(fmt.Sprintf("Resource: %s", resourceInfo)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
// If we have detected ports and in list mode, show them as a list
|
// If we have detected ports and in list mode, show them as a list
|
||||||
if len(wizard.detectedPorts) > 0 && wizard.inputMode == InputModeList {
|
if len(wizard.detectedPorts) > 0 && wizard.inputMode == InputModeList {
|
||||||
b.WriteString("Select remote port:\n\n")
|
b.WriteString("Select remote port:")
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
const viewportHeight = 20
|
const viewportHeight = 20
|
||||||
totalItems := len(wizard.detectedPorts) + 1 // +1 for manual entry option
|
totalItems := len(wizard.detectedPorts) + 1 // +1 for manual entry option
|
||||||
@@ -349,9 +351,20 @@ func (m model) renderEnterRemotePort() string {
|
|||||||
// Render detected ports within viewport
|
// Render detected ports within viewport
|
||||||
for i := start; i < end && i < len(wizard.detectedPorts); i++ {
|
for i := start; i < end && i < len(wizard.detectedPorts); i++ {
|
||||||
port := wizard.detectedPorts[i]
|
port := wizard.detectedPorts[i]
|
||||||
portDesc := fmt.Sprintf("%d", port.Port)
|
// For services, show both service port and target port if they differ
|
||||||
if port.Name != "" {
|
var portDesc string
|
||||||
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
if port.TargetPort > 0 && port.TargetPort != port.Port {
|
||||||
|
// Service with different target port: "80 → 8000 (http)"
|
||||||
|
portDesc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort)
|
||||||
|
if port.Name != "" {
|
||||||
|
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Pod port or service with same port
|
||||||
|
portDesc = fmt.Sprintf("%d", port.Port)
|
||||||
|
if port.Name != "" {
|
||||||
|
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix := " "
|
prefix := " "
|
||||||
@@ -390,9 +403,17 @@ func (m model) renderEnterRemotePort() string {
|
|||||||
if len(wizard.detectedPorts) > 0 {
|
if len(wizard.detectedPorts) > 0 {
|
||||||
b.WriteString(mutedStyle.Render("Detected ports:\n"))
|
b.WriteString(mutedStyle.Render("Detected ports:\n"))
|
||||||
for _, port := range wizard.detectedPorts {
|
for _, port := range wizard.detectedPorts {
|
||||||
portDesc := fmt.Sprintf("%d", port.Port)
|
var portDesc string
|
||||||
if port.Name != "" {
|
if port.TargetPort > 0 && port.TargetPort != port.Port {
|
||||||
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
portDesc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort)
|
||||||
|
if port.Name != "" {
|
||||||
|
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
portDesc = fmt.Sprintf("%d", port.Port)
|
||||||
|
if port.Name != "" {
|
||||||
|
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", portDesc)))
|
b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", portDesc)))
|
||||||
}
|
}
|
||||||
@@ -427,8 +448,10 @@ func (m model) renderEnterLocalPort() string {
|
|||||||
if wizard.selector != "" {
|
if wizard.selector != "" {
|
||||||
resourceInfo = fmt.Sprintf("%s [%s]", wizard.resourceValue, wizard.selector)
|
resourceInfo = fmt.Sprintf("%s [%s]", wizard.resourceValue, wizard.selector)
|
||||||
}
|
}
|
||||||
b.WriteString(mutedStyle.Render(fmt.Sprintf("Resource: %s\n", resourceInfo)))
|
b.WriteString(mutedStyle.Render(fmt.Sprintf("Resource: %s", resourceInfo)))
|
||||||
b.WriteString(mutedStyle.Render(fmt.Sprintf("Remote port: %d\n\n", wizard.remotePort)))
|
b.WriteString("\n")
|
||||||
|
b.WriteString(mutedStyle.Render(fmt.Sprintf("Remote port: %d", wizard.remotePort)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
b.WriteString(renderTextInput("Local port: ", wizard.textInput, wizard.error == nil))
|
b.WriteString(renderTextInput("Local port: ", wizard.textInput, wizard.error == nil))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
@@ -651,3 +674,399 @@ func (m model) renderRemoveConfirmation() string {
|
|||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderBenchmark renders the benchmark wizard
|
||||||
|
func (m model) renderBenchmark() string {
|
||||||
|
if m.ui.benchmarkState == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
state := m.ui.benchmarkState
|
||||||
|
|
||||||
|
var content string
|
||||||
|
switch state.step {
|
||||||
|
case BenchmarkStepConfig:
|
||||||
|
content = m.renderBenchmarkConfig()
|
||||||
|
case BenchmarkStepRunning:
|
||||||
|
content = m.renderBenchmarkRunning()
|
||||||
|
case BenchmarkStepResults:
|
||||||
|
content = m.renderBenchmarkResults()
|
||||||
|
default:
|
||||||
|
content = "Unknown step"
|
||||||
|
}
|
||||||
|
|
||||||
|
return wizardBoxStyle.Render(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) renderBenchmarkConfig() string {
|
||||||
|
state := m.ui.benchmarkState
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString(renderHeader("HTTP Benchmark", ""))
|
||||||
|
b.WriteString(fmt.Sprintf("Target: %s (localhost:%d)", breadcrumbStyle.Render(state.forwardAlias), state.localPort))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
b.WriteString("Configure benchmark parameters:")
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
fields := []struct {
|
||||||
|
label string
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{"URL Path", state.urlPath},
|
||||||
|
{"Method", state.method},
|
||||||
|
{"Concurrency", fmt.Sprintf("%d", state.concurrency)},
|
||||||
|
{"Requests", fmt.Sprintf("%d", state.requests)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, field := range fields {
|
||||||
|
prefix := " "
|
||||||
|
if i == state.cursor {
|
||||||
|
prefix = "▸ "
|
||||||
|
b.WriteString(selectedStyle.Render(fmt.Sprintf("%s%-12s", prefix, field.label+":")))
|
||||||
|
b.WriteString(validInputStyle.Render(field.value + "█"))
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf("%s%-12s %s", prefix, field.label+":", field.value))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(mutedStyle.Render(fmt.Sprintf("Will send %d requests with %d concurrent workers", state.requests, state.concurrency)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(helpStyle.Render("↑/↓/Tab: Navigate Type to edit Enter: Run Esc: Cancel"))
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) renderBenchmarkRunning() string {
|
||||||
|
state := m.ui.benchmarkState
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString(renderHeader("HTTP Benchmark", ""))
|
||||||
|
b.WriteString(fmt.Sprintf("Target: %s", breadcrumbStyle.Render(state.forwardAlias)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
progress := float64(state.progress) / float64(state.total)
|
||||||
|
if state.total == 0 {
|
||||||
|
progress = 0
|
||||||
|
}
|
||||||
|
barWidth := 30
|
||||||
|
filled := int(progress * float64(barWidth))
|
||||||
|
if filled > barWidth {
|
||||||
|
filled = barWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
|
||||||
|
percent := int(progress * 100)
|
||||||
|
|
||||||
|
b.WriteString(spinnerStyle.Render("Running benchmark..."))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
b.WriteString(fmt.Sprintf(" [%s] %d%%", successStyle.Render(bar), percent))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d / %d requests completed", state.progress, state.total)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
b.WriteString(mutedStyle.Render(fmt.Sprintf("URL: http://localhost:%d%s", state.localPort, state.urlPath)))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(mutedStyle.Render(fmt.Sprintf("Method: %s Concurrency: %d", state.method, state.concurrency)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
b.WriteString(helpStyle.Render("Please wait..."))
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) renderBenchmarkResults() string {
|
||||||
|
state := m.ui.benchmarkState
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString(renderHeader("Benchmark Results", ""))
|
||||||
|
b.WriteString(fmt.Sprintf("Target: %s", breadcrumbStyle.Render(state.forwardAlias)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
if state.error != nil {
|
||||||
|
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", state.error)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(helpStyle.Render("Press Enter or Esc to return"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.results == nil {
|
||||||
|
b.WriteString(mutedStyle.Render("No results available"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(helpStyle.Render("Press Enter or Esc to return"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
r := state.results
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
successRate := float64(r.Successful) / float64(r.TotalRequests) * 100
|
||||||
|
if r.TotalRequests == 0 {
|
||||||
|
successRate = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(fmt.Sprintf("Total Requests: %d", r.TotalRequests))
|
||||||
|
b.WriteString("\n")
|
||||||
|
if r.Failed == 0 {
|
||||||
|
b.WriteString(successStyle.Render(fmt.Sprintf("Successful: %d (%.1f%%)", r.Successful, successRate)))
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf("Successful: %d (%.1f%%)", r.Successful, successRate))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
if r.Failed > 0 {
|
||||||
|
b.WriteString(errorStyle.Render(fmt.Sprintf("Failed: %d", r.Failed)))
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf("Failed: %d", r.Failed))
|
||||||
|
}
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Latency stats
|
||||||
|
b.WriteString(breadcrumbStyle.Render("Latency (ms)"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" Min: %.2f", r.MinLatency))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" Max: %.2f", r.MaxLatency))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" Avg: %.2f", r.AvgLatency))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" P50: %.2f", r.P50Latency))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" P95: %.2f", r.P95Latency))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" P99: %.2f", r.P99Latency))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Throughput
|
||||||
|
b.WriteString(breadcrumbStyle.Render("Throughput"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" Requests/sec: %.2f", r.Throughput))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" Bytes read: %d", r.BytesRead))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Status codes if interesting
|
||||||
|
if len(r.StatusCodes) > 0 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(breadcrumbStyle.Render("Status Codes"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
for code, count := range r.StatusCodes {
|
||||||
|
if code >= 200 && code < 300 {
|
||||||
|
b.WriteString(successStyle.Render(fmt.Sprintf(" %d: %d", code, count)))
|
||||||
|
} else if code >= 400 {
|
||||||
|
b.WriteString(errorStyle.Render(fmt.Sprintf(" %d: %d", code, count)))
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf(" %d: %d", code, count))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(helpStyle.Render("Press Enter or Esc to return"))
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderHTTPLog renders the HTTP log viewer as a full-screen table
|
||||||
|
func (m model) renderHTTPLog() string {
|
||||||
|
if m.ui.httpLogState == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
state := m.ui.httpLogState
|
||||||
|
|
||||||
|
// Get terminal dimensions
|
||||||
|
termWidth := m.termWidth
|
||||||
|
termHeight := m.termHeight
|
||||||
|
if termWidth == 0 {
|
||||||
|
termWidth = 120
|
||||||
|
}
|
||||||
|
if termHeight == 0 {
|
||||||
|
termHeight = 40
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get filtered entries
|
||||||
|
filteredEntries := state.getFilteredEntries()
|
||||||
|
totalEntries := len(filteredEntries)
|
||||||
|
totalUnfiltered := len(state.entries)
|
||||||
|
|
||||||
|
// Build output
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Header line
|
||||||
|
title := wizardHeaderStyle.Render("HTTP Traffic Log")
|
||||||
|
b.WriteString(title)
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(breadcrumbStyle.Render(state.forwardAlias))
|
||||||
|
|
||||||
|
// Status indicators
|
||||||
|
b.WriteString(" ")
|
||||||
|
filterLabel := state.getFilterModeLabel()
|
||||||
|
if state.filterMode != HTTPLogFilterNone {
|
||||||
|
b.WriteString(accentStyle.Render(fmt.Sprintf("[Filter: %s]", filterLabel)))
|
||||||
|
}
|
||||||
|
if state.filterText != "" {
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(accentStyle.Render(fmt.Sprintf("[Search: \"%s\"]", state.filterText)))
|
||||||
|
}
|
||||||
|
if state.autoScroll {
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(successStyle.Render("[Auto-scroll]"))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Filter input line (if active)
|
||||||
|
if state.filterActive {
|
||||||
|
b.WriteString(accentStyle.Render("Search: "))
|
||||||
|
b.WriteString(state.filterText)
|
||||||
|
b.WriteString(accentStyle.Render("_"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table or empty message
|
||||||
|
if totalEntries == 0 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
if totalUnfiltered == 0 {
|
||||||
|
b.WriteString(mutedStyle.Render(" No HTTP traffic logged yet.\n"))
|
||||||
|
b.WriteString(mutedStyle.Render(" Enable with: httpLog: true in .kportal.yaml\n"))
|
||||||
|
} else {
|
||||||
|
b.WriteString(mutedStyle.Render(fmt.Sprintf(" No entries match filter. (%d total entries)\n", totalUnfiltered)))
|
||||||
|
b.WriteString(mutedStyle.Render(" Press 'c' to clear filters.\n"))
|
||||||
|
}
|
||||||
|
// Pad to fill screen
|
||||||
|
for i := 0; i < termHeight-10; i++ {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Render simple table without lipgloss table (for better control)
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Header
|
||||||
|
header := fmt.Sprintf(" %-10s %-7s %-6s %-8s %s",
|
||||||
|
"TIME", "METHOD", "STATUS", "LATENCY", "PATH")
|
||||||
|
b.WriteString(mutedStyle.Render(header))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(mutedStyle.Render(strings.Repeat("─", termWidth-2)))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Calculate visible range
|
||||||
|
viewportHeight := termHeight - 8 // header, filter bar, table header, separator, footer, help
|
||||||
|
if viewportHeight < 5 {
|
||||||
|
viewportHeight = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure cursor is in valid range
|
||||||
|
if state.cursor < 0 {
|
||||||
|
state.cursor = 0
|
||||||
|
}
|
||||||
|
if state.cursor >= totalEntries {
|
||||||
|
state.cursor = totalEntries - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate scroll offset to keep cursor visible
|
||||||
|
if state.cursor < state.scrollOffset {
|
||||||
|
state.scrollOffset = state.cursor
|
||||||
|
}
|
||||||
|
if state.cursor >= state.scrollOffset+viewportHeight {
|
||||||
|
state.scrollOffset = state.cursor - viewportHeight + 1
|
||||||
|
}
|
||||||
|
if state.scrollOffset < 0 {
|
||||||
|
state.scrollOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
start := state.scrollOffset
|
||||||
|
end := start + viewportHeight
|
||||||
|
if end > totalEntries {
|
||||||
|
end = totalEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate max path width
|
||||||
|
maxPathWidth := termWidth - 48
|
||||||
|
if maxPathWidth < 10 {
|
||||||
|
maxPathWidth = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := start; i < end; i++ {
|
||||||
|
entry := filteredEntries[i]
|
||||||
|
|
||||||
|
// Format fields
|
||||||
|
statusStr := ""
|
||||||
|
if entry.StatusCode > 0 {
|
||||||
|
statusStr = fmt.Sprintf("%d", entry.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
latencyStr := ""
|
||||||
|
if entry.LatencyMs > 0 {
|
||||||
|
if entry.LatencyMs >= 1000 {
|
||||||
|
latencyStr = fmt.Sprintf("%.1fs", float64(entry.LatencyMs)/1000)
|
||||||
|
} else {
|
||||||
|
latencyStr = fmt.Sprintf("%dms", entry.LatencyMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate path
|
||||||
|
path := entry.Path
|
||||||
|
if len(path) > maxPathWidth {
|
||||||
|
path = path[:maxPathWidth-3] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build line
|
||||||
|
line := fmt.Sprintf("%-10s %-7s %-6s %-8s %s",
|
||||||
|
entry.Timestamp,
|
||||||
|
entry.Method,
|
||||||
|
statusStr,
|
||||||
|
latencyStr,
|
||||||
|
path)
|
||||||
|
|
||||||
|
// Selection prefix
|
||||||
|
prefix := " "
|
||||||
|
if i == state.cursor {
|
||||||
|
prefix = "▸ "
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply color based on status
|
||||||
|
// 200s = normal text, 400s = warning (orange), 500s = error (red)
|
||||||
|
var styledLine string
|
||||||
|
if entry.StatusCode >= 500 {
|
||||||
|
styledLine = errorStyle.Render(line)
|
||||||
|
} else if entry.StatusCode >= 400 {
|
||||||
|
styledLine = warningStyle.Render(line)
|
||||||
|
} else {
|
||||||
|
// 200s and other codes - normal text color
|
||||||
|
styledLine = line
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == state.cursor {
|
||||||
|
b.WriteString(selectedStyle.Render(prefix))
|
||||||
|
} else {
|
||||||
|
b.WriteString(prefix)
|
||||||
|
}
|
||||||
|
b.WriteString(styledLine)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad remaining lines
|
||||||
|
linesRendered := end - start
|
||||||
|
for i := linesRendered; i < viewportHeight; i++ {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer with entry count
|
||||||
|
b.WriteString("\n")
|
||||||
|
if totalEntries != totalUnfiltered {
|
||||||
|
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d of %d entries (filtered from %d)", totalEntries, totalEntries, totalUnfiltered)))
|
||||||
|
} else {
|
||||||
|
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d entries", totalEntries)))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Help line at bottom
|
||||||
|
b.WriteString(helpStyle.Render(" ↑/↓/PgUp/PgDn: Navigate g/G: Top/Bottom a: Auto-scroll f: Filter /: Search c: Clear q: Close"))
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user