mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-05 23:03:40 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50f94bda87 | |||
| d9888f1a56 | |||
| 7dec532e18 | |||
| aa7695b3be | |||
| 1bacd31f27 | |||
| bfecbdf056 | |||
| 754108474c | |||
| 690c587c0a | |||
| 0d03f228f9 | |||
| 2a44c6ff9c | |||
| 8672d932bb | |||
| 87317adb91 | |||
| ced7e80a06 | |||
| 13723733df | |||
| 9538623bcb | |||
| 8bb377909c | |||
| 263a0370d3 | |||
| 62eca4a9a1 | |||
| ea20a037b9 | |||
| 46db732f87 | |||
| a297ba7073 | |||
| 518879dc56 | |||
| 649227b201 | |||
| 28e2fc315a |
@@ -5,69 +5,16 @@ on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
|
||||
env:
|
||||
GO_VERSION: ">=1.21"
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
pull-requests: write
|
||||
security-events: write
|
||||
|
||||
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"
|
||||
autoupdate:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
|
||||
with:
|
||||
go-version: ">=1.24"
|
||||
release-workflow: "release.yml"
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Pull Request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
- "!main"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
pull-requests: write
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
pr-checks:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
|
||||
with:
|
||||
go-version: ">=1.24"
|
||||
@@ -5,90 +5,17 @@ on:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '**.go'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Run tests with race detector
|
||||
run: go test -race -v ./...
|
||||
|
||||
version:
|
||||
name: Calculate Version
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version_formatted.outputs.version }}
|
||||
version_tag: ${{ steps.version_formatted.outputs.version_tag }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Calculate semantic version
|
||||
id: semver
|
||||
uses: lukaszraczylo/semver-generator@v1
|
||||
with:
|
||||
config_file: semver.yaml
|
||||
repository_local: true
|
||||
|
||||
- name: Format version
|
||||
id: version_formatted
|
||||
run: |
|
||||
VERSION="${{ steps.semver.outputs.semantic_version }}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "version_tag=v${VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Display version
|
||||
run: |
|
||||
echo "Calculated version: ${{ steps.version_formatted.outputs.version }}"
|
||||
echo "Version tag: ${{ steps.version_formatted.outputs.version_tag }}"
|
||||
|
||||
release:
|
||||
name: Release with GoReleaser
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a ${{ needs.version.outputs.version_tag }} -m "Release ${{ needs.version.outputs.version_tag }}"
|
||||
git push origin ${{ needs.version.outputs.version_tag }}
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
|
||||
with:
|
||||
go-version: ">=1.24"
|
||||
secrets: inherit
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- "docs/**"
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
# Upload entire repository
|
||||
path: 'docs/'
|
||||
path: "docs/"
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
+14
-10
@@ -23,11 +23,11 @@ builds:
|
||||
|
||||
archives:
|
||||
- id: kportal
|
||||
format: tar.gz
|
||||
formats: [tar.gz]
|
||||
name_template: "kportal-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
formats: [zip]
|
||||
files:
|
||||
- LICENSE
|
||||
- README.md
|
||||
@@ -53,17 +53,21 @@ release:
|
||||
draft: false
|
||||
prerelease: auto
|
||||
|
||||
brews:
|
||||
homebrew_casks:
|
||||
- repository:
|
||||
owner: lukaszraczylo
|
||||
name: brew-taps
|
||||
name: homebrew-taps
|
||||
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
|
||||
directory: Formula
|
||||
directory: Casks
|
||||
homepage: https://lukaszraczylo.github.io/kportal
|
||||
description: "Modern Kubernetes port-forward manager with interactive TUI"
|
||||
license: MIT
|
||||
test: |
|
||||
system "#{bin}/kportal", "--version"
|
||||
dependencies:
|
||||
- name: kubernetes-cli
|
||||
type: optional
|
||||
url:
|
||||
verified: github.com/lukaszraczylo/kportal
|
||||
hooks:
|
||||
post:
|
||||
install: |
|
||||
if OS.mac?
|
||||
system_command "/usr/bin/xattr",
|
||||
args: ["-dr", "com.apple.quarantine", "#{staged_path}/kportal"]
|
||||
end
|
||||
|
||||
@@ -54,12 +54,17 @@ kportal manages multiple Kubernetes port-forwards with an interactive terminal i
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Homebrew (macOS/Linux)
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew install lukaszraczylo/brew-taps/kportal
|
||||
brew install --cask lukaszraczylo/taps/kportal
|
||||
```
|
||||
|
||||
> **Note**: If you previously installed via `brew install lukaszraczylo/taps/kportal` (formula), uninstall first:
|
||||
> ```bash
|
||||
> brew uninstall kportal
|
||||
> ```
|
||||
|
||||
### Quick Install
|
||||
|
||||
```bash
|
||||
|
||||
+48
-6
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -49,6 +50,23 @@ var (
|
||||
appVersion = "0.1.0" // Set via ldflags during build
|
||||
)
|
||||
|
||||
// promptCreateConfig asks the user if they want to create a new config file.
|
||||
// Returns true if the user answers yes, false otherwise.
|
||||
func promptCreateConfig(path string) bool {
|
||||
fmt.Printf("Configuration file not found: %s\n", path)
|
||||
fmt.Print("Would you like to create an empty configuration? [Y/n] ")
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
// Empty response (just Enter) defaults to yes
|
||||
return response == "" || response == "y" || response == "yes"
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
@@ -173,14 +191,38 @@ func main() {
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.LoadConfig(*configFile)
|
||||
configIsNew := false
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
if err == config.ErrConfigNotFound {
|
||||
// Config file doesn't exist - offer to create it
|
||||
if !promptCreateConfig(*configFile) {
|
||||
os.Exit(0)
|
||||
}
|
||||
// Create empty config file
|
||||
if err := config.CreateEmptyConfigFile(*configFile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating config file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Created %s\n", *configFile)
|
||||
fmt.Println("Use 'n' in the UI to add port forwards, or edit the file manually.")
|
||||
fmt.Println()
|
||||
|
||||
// Load the newly created config
|
||||
cfg, err = config.LoadConfig(*configFile)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
configIsNew = true
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
// Validate configuration (allow empty configs for newly created files)
|
||||
validator := config.NewValidator()
|
||||
if errs := validator.ValidateConfig(cfg); len(errs) > 0 {
|
||||
if errs := validator.ValidateConfigWithOptions(cfg, configIsNew || cfg.IsEmpty()); len(errs) > 0 {
|
||||
fmt.Fprint(os.Stderr, config.FormatValidationErrors(errs))
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -253,9 +295,9 @@ func main() {
|
||||
// Interactive mode with bubbletea
|
||||
bubbleTeaUI = ui.NewBubbleTeaUI(func(id string, enable bool) {
|
||||
if enable {
|
||||
manager.EnableForward(id)
|
||||
_ = manager.EnableForward(id)
|
||||
} else {
|
||||
manager.DisableForward(id)
|
||||
_ = manager.DisableForward(id)
|
||||
}
|
||||
}, appVersion)
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
kportal.raczylo.com
|
||||
+3
-3
@@ -556,11 +556,11 @@
|
||||
<i class="fas fa-beer text-orange-500 dark:text-orange-400 text-2xl mr-3"></i>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Homebrew</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">macOS & Linux</p>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">macOS</p>
|
||||
</div>
|
||||
</div>
|
||||
<div onclick="copyToClipboard('brew install lukaszraczylo/brew-taps/kportal', this)" class="relative bg-gradient-to-br from-gray-900 to-gray-800 dark:from-gray-950 dark:to-black text-gray-100 p-4 rounded-lg text-sm cursor-pointer group overflow-x-auto border border-gray-700 hover:border-orange-500 transition-all duration-300">
|
||||
<code class="block whitespace-nowrap font-mono">brew install lukaszraczylo/brew-taps/kportal</code>
|
||||
<div onclick="copyToClipboard('brew install --cask lukaszraczylo/taps/kportal', this)" class="relative bg-gradient-to-br from-gray-900 to-gray-800 dark:from-gray-950 dark:to-black text-gray-100 p-4 rounded-lg text-sm cursor-pointer group overflow-x-auto border border-gray-700 hover:border-orange-500 transition-all duration-300">
|
||||
<code class="block whitespace-nowrap font-mono">brew install --cask lukaszraczylo/taps/kportal</code>
|
||||
<div class="absolute top-3 right-3"><i class="fas fa-copy text-gray-500 group-hover:text-orange-400 transition-colors duration-300"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,18 +20,18 @@ require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.3 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.2 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.6.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.6.1 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.4 // indirect
|
||||
github.com/go-openapi/swag v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
@@ -70,15 +70,15 @@ require (
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/net v0.47.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/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/term v0.38.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
|
||||
@@ -12,16 +12,16 @@ github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGx
|
||||
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk=
|
||||
github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0=
|
||||
github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs=
|
||||
github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
|
||||
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||
github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
|
||||
github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
@@ -40,10 +40,10 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
|
||||
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
|
||||
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
|
||||
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
|
||||
@@ -166,37 +166,37 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQz
|
||||
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.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/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
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-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.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-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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -204,8 +204,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-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.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -221,10 +221,3 @@ func (r *Runner) makeRequest(ctx context.Context, cfg Config) (statusCode int, b
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -10,6 +11,9 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ErrConfigNotFound is returned when the configuration file does not exist
|
||||
var ErrConfigNotFound = fmt.Errorf("config file not found")
|
||||
|
||||
const (
|
||||
// maxConfigSize is the maximum allowed configuration file size (10MB)
|
||||
maxConfigSize = 10 * 1024 * 1024
|
||||
@@ -282,6 +286,9 @@ func LoadConfig(path string) (*Config, error) {
|
||||
// Validate file size before reading
|
||||
fileInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to stat config file: %w", err)
|
||||
}
|
||||
|
||||
@@ -289,6 +296,7 @@ func LoadConfig(path string) (*Config, error) {
|
||||
return nil, fmt.Errorf("config file too large: %d bytes (max %d)", fileInfo.Size(), maxConfigSize)
|
||||
}
|
||||
|
||||
// #nosec G304 -- path is validated in main.go (no system dirs, absolute path)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
@@ -337,3 +345,58 @@ func (c *Config) GetAllForwards() []Forward {
|
||||
|
||||
return forwards
|
||||
}
|
||||
|
||||
// NewEmptyConfig returns a minimal empty configuration with no forwards.
|
||||
// This is used when creating a new config file for the first time.
|
||||
func NewEmptyConfig() *Config {
|
||||
return &Config{
|
||||
Contexts: []Context{},
|
||||
}
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the configuration has no forwards defined.
|
||||
func (c *Config) IsEmpty() bool {
|
||||
return len(c.Contexts) == 0 || len(c.GetAllForwards()) == 0
|
||||
}
|
||||
|
||||
// CreateEmptyConfigFile creates a new empty configuration file at the given path.
|
||||
// Returns an error if the file already exists or cannot be created.
|
||||
func CreateEmptyConfigFile(path string) error {
|
||||
// Check if file already exists
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return fmt.Errorf("config file already exists: %s", path)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("failed to check config file: %w", err)
|
||||
}
|
||||
|
||||
cfg := NewEmptyConfig()
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal empty config: %w", err)
|
||||
}
|
||||
|
||||
// Add a helpful comment header
|
||||
header := `# kportal configuration file
|
||||
# Add port forwards using the 'n' key in the TUI, or manually add them below.
|
||||
#
|
||||
# Example forward:
|
||||
# contexts:
|
||||
# - name: my-cluster
|
||||
# namespaces:
|
||||
# - name: default
|
||||
# forwards:
|
||||
# - resource: service/my-service
|
||||
# protocol: tcp
|
||||
# port: 8080
|
||||
# localPort: 8080
|
||||
#
|
||||
`
|
||||
content := header + string(data)
|
||||
|
||||
// Write with restrictive permissions (0600)
|
||||
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write config file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestLoadConfig_FileNotFound(t *testing.T) {
|
||||
cfg, err := LoadConfig("/non/existent/path/.kportal.yaml")
|
||||
assert.Error(t, err, "LoadConfig should fail with non-existent file")
|
||||
assert.Nil(t, cfg, "config should be nil on error")
|
||||
assert.Contains(t, err.Error(), "failed to stat config file", "error should mention stat failure")
|
||||
assert.Equal(t, ErrConfigNotFound, err, "should return ErrConfigNotFound")
|
||||
}
|
||||
|
||||
func TestForward_ID(t *testing.T) {
|
||||
@@ -397,3 +397,123 @@ func TestHTTPLogSpec_UnmarshalYAML(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEmptyConfig(t *testing.T) {
|
||||
cfg := NewEmptyConfig()
|
||||
assert.NotNil(t, cfg, "NewEmptyConfig should return non-nil config")
|
||||
assert.Empty(t, cfg.Contexts, "NewEmptyConfig should have empty contexts")
|
||||
assert.True(t, cfg.IsEmpty(), "NewEmptyConfig should be considered empty")
|
||||
}
|
||||
|
||||
func TestConfig_IsEmpty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil contexts",
|
||||
config: &Config{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "empty contexts slice",
|
||||
config: &Config{Contexts: []Context{}},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "context with empty namespaces",
|
||||
config: &Config{
|
||||
Contexts: []Context{
|
||||
{Name: "test", Namespaces: []Namespace{}},
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "context with namespace but no forwards",
|
||||
config: &Config{
|
||||
Contexts: []Context{
|
||||
{
|
||||
Name: "test",
|
||||
Namespaces: []Namespace{
|
||||
{Name: "default", Forwards: []Forward{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "config with forward",
|
||||
config: &Config{
|
||||
Contexts: []Context{
|
||||
{
|
||||
Name: "test",
|
||||
Namespaces: []Namespace{
|
||||
{
|
||||
Name: "default",
|
||||
Forwards: []Forward{
|
||||
{Resource: "pod/app", Port: 8080, LocalPort: 8080},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, tt.config.IsEmpty())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEmptyConfigFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||
|
||||
// Create empty config file
|
||||
err := CreateEmptyConfigFile(configPath)
|
||||
assert.NoError(t, err, "CreateEmptyConfigFile should succeed")
|
||||
|
||||
// Verify file exists
|
||||
_, err = os.Stat(configPath)
|
||||
assert.NoError(t, err, "config file should exist")
|
||||
|
||||
// Verify file is readable and parseable
|
||||
cfg, err := LoadConfig(configPath)
|
||||
assert.NoError(t, err, "should be able to load created config")
|
||||
assert.NotNil(t, cfg, "config should not be nil")
|
||||
assert.True(t, cfg.IsEmpty(), "created config should be empty")
|
||||
|
||||
// Verify file permissions (0600)
|
||||
info, _ := os.Stat(configPath)
|
||||
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "file should have 0600 permissions")
|
||||
|
||||
// Verify file contains helpful header
|
||||
content, _ := os.ReadFile(configPath)
|
||||
assert.Contains(t, string(content), "# kportal configuration file", "should contain header comment")
|
||||
assert.Contains(t, string(content), "Example forward", "should contain example")
|
||||
}
|
||||
|
||||
func TestCreateEmptyConfigFile_AlreadyExists(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||
|
||||
// Create existing file
|
||||
err := os.WriteFile(configPath, []byte("existing content"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Try to create config file - should fail
|
||||
err = CreateEmptyConfigFile(configPath)
|
||||
assert.Error(t, err, "CreateEmptyConfigFile should fail when file exists")
|
||||
assert.Contains(t, err.Error(), "already exists")
|
||||
|
||||
// Verify original content is preserved
|
||||
content, _ := os.ReadFile(configPath)
|
||||
assert.Equal(t, "existing content", string(content))
|
||||
}
|
||||
|
||||
@@ -264,8 +264,8 @@ func (m *Mutator) writeAtomic(cfg *Config) error {
|
||||
|
||||
// Atomic rename
|
||||
if err := os.Rename(tmpFile, m.configPath); err != nil {
|
||||
// Clean up temp file on failure
|
||||
os.Remove(tmpFile)
|
||||
// Clean up temp file on failure - error ignored as we're already handling the rename error
|
||||
_ = os.Remove(tmpFile)
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,13 @@ func NewValidator() *Validator {
|
||||
|
||||
// ValidateConfig validates the entire configuration and returns all errors found.
|
||||
func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
|
||||
return v.ValidateConfigWithOptions(cfg, false)
|
||||
}
|
||||
|
||||
// ValidateConfigWithOptions validates configuration with configurable strictness.
|
||||
// When allowEmpty is true, empty configurations (no contexts/forwards) are allowed.
|
||||
// This is useful for newly created config files where the user will add forwards via the TUI.
|
||||
func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []ValidationError {
|
||||
var errs []ValidationError
|
||||
|
||||
if cfg == nil {
|
||||
@@ -46,6 +53,12 @@ func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
|
||||
}}
|
||||
}
|
||||
|
||||
// If empty configs are allowed and this config is empty, skip structure validation
|
||||
if allowEmpty && cfg.IsEmpty() {
|
||||
// Still validate health check and reliability if present (they don't require forwards)
|
||||
return errs
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
errs = append(errs, v.validateStructure(cfg)...)
|
||||
|
||||
|
||||
@@ -972,3 +972,141 @@ func TestIsAlphanumeric(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_ValidateConfigWithOptions(t *testing.T) {
|
||||
validator := NewValidator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
allowEmpty bool
|
||||
expectErrors bool
|
||||
}{
|
||||
{
|
||||
name: "empty config - strict mode",
|
||||
config: &Config{Contexts: []Context{}},
|
||||
allowEmpty: false,
|
||||
expectErrors: true,
|
||||
},
|
||||
{
|
||||
name: "empty config - allow empty",
|
||||
config: &Config{Contexts: []Context{}},
|
||||
allowEmpty: true,
|
||||
expectErrors: false,
|
||||
},
|
||||
{
|
||||
name: "nil contexts - allow empty",
|
||||
config: &Config{},
|
||||
allowEmpty: true,
|
||||
expectErrors: false,
|
||||
},
|
||||
{
|
||||
name: "context with no forwards - allow empty",
|
||||
config: &Config{
|
||||
Contexts: []Context{
|
||||
{
|
||||
Name: "dev",
|
||||
Namespaces: []Namespace{
|
||||
{Name: "default", Forwards: []Forward{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
allowEmpty: true,
|
||||
expectErrors: false,
|
||||
},
|
||||
{
|
||||
name: "valid config - strict mode",
|
||||
config: &Config{
|
||||
Contexts: []Context{
|
||||
{
|
||||
Name: "dev-cluster",
|
||||
Namespaces: []Namespace{
|
||||
{
|
||||
Name: "default",
|
||||
Forwards: []Forward{
|
||||
{
|
||||
Resource: "pod/my-app",
|
||||
Protocol: "tcp",
|
||||
Port: 8080,
|
||||
LocalPort: 8080,
|
||||
contextName: "dev-cluster",
|
||||
namespaceName: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
allowEmpty: false,
|
||||
expectErrors: false,
|
||||
},
|
||||
{
|
||||
name: "valid config - allow empty (should still validate)",
|
||||
config: &Config{
|
||||
Contexts: []Context{
|
||||
{
|
||||
Name: "dev-cluster",
|
||||
Namespaces: []Namespace{
|
||||
{
|
||||
Name: "default",
|
||||
Forwards: []Forward{
|
||||
{
|
||||
Resource: "pod/my-app",
|
||||
Protocol: "tcp",
|
||||
Port: 8080,
|
||||
LocalPort: 8080,
|
||||
contextName: "dev-cluster",
|
||||
namespaceName: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
allowEmpty: true,
|
||||
expectErrors: false,
|
||||
},
|
||||
{
|
||||
name: "invalid forward in non-empty config - allow empty still validates",
|
||||
config: &Config{
|
||||
Contexts: []Context{
|
||||
{
|
||||
Name: "dev-cluster",
|
||||
Namespaces: []Namespace{
|
||||
{
|
||||
Name: "default",
|
||||
Forwards: []Forward{
|
||||
{
|
||||
Resource: "pod/my-app",
|
||||
Protocol: "tcp",
|
||||
Port: 0, // Invalid port
|
||||
LocalPort: 8080,
|
||||
contextName: "dev-cluster",
|
||||
namespaceName: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
allowEmpty: true,
|
||||
expectErrors: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
errs := validator.ValidateConfigWithOptions(tt.config, tt.allowEmpty)
|
||||
|
||||
if tt.expectErrors {
|
||||
assert.NotEmpty(t, errs, "expected validation errors")
|
||||
} else {
|
||||
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watc
|
||||
|
||||
absPath, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
watcher.Close()
|
||||
_ = watcher.Close()
|
||||
return nil, fmt.Errorf("failed to resolve absolute path: %w", err)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watc
|
||||
// (many editors delete and recreate files on save)
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := watcher.Add(dir); err != nil {
|
||||
watcher.Close()
|
||||
_ = watcher.Close()
|
||||
return nil, fmt.Errorf("failed to watch directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func (w *Watcher) Start() {
|
||||
// Stop stops watching the configuration file and waits for the watch goroutine to exit.
|
||||
func (w *Watcher) Stop() {
|
||||
close(w.done)
|
||||
w.watcher.Close()
|
||||
_ = w.watcher.Close()
|
||||
w.wg.Wait() // Wait for watch goroutine to exit
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ type KFTrayConfig struct {
|
||||
// ConvertKFTrayToKPortal converts kftray JSON configuration to kportal YAML format
|
||||
func ConvertKFTrayToKPortal(inputFile, outputFile string) error {
|
||||
// Read kftray JSON config
|
||||
// #nosec G304 -- inputFile is from command line argument for explicit conversion
|
||||
data, err := os.ReadFile(inputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read input file: %w", err)
|
||||
@@ -57,6 +58,7 @@ func ConvertKFTrayToKPortal(inputFile, outputFile string) error {
|
||||
|
||||
// GetConversionSummary returns statistics about the kftray configuration
|
||||
func GetConversionSummary(inputFile string) (map[string]map[string]int, int, error) {
|
||||
// #nosec G304 -- inputFile is from command line argument for explicit conversion
|
||||
data, err := os.ReadFile(inputFile)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to read input file: %w", err)
|
||||
|
||||
@@ -169,8 +169,10 @@ func (m *Manager) Start(cfg *config.Config) error {
|
||||
// Get all forwards from config
|
||||
forwards := cfg.GetAllForwards()
|
||||
|
||||
// Empty config is valid - user can add forwards later via TUI
|
||||
if len(forwards) == 0 {
|
||||
return fmt.Errorf("no forwards configured")
|
||||
log.Printf("No forwards configured - use 'n' to add forwards")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check port availability before starting
|
||||
|
||||
@@ -124,8 +124,8 @@ func TestManager_Start_EmptyForwards(t *testing.T) {
|
||||
|
||||
cfg := &config.Config{}
|
||||
err = manager.Start(cfg)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no forwards configured")
|
||||
// Empty config is now valid - allows users to add forwards via TUI
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestManager_Reload_NilConfig tests reloading with nil config
|
||||
|
||||
@@ -77,6 +77,7 @@ func getProcessNameByPID(pid string) string {
|
||||
|
||||
// getProcessNameByPIDWindows retrieves the process name for a given PID on Windows
|
||||
func getProcessNameByPIDWindows(pid string) string {
|
||||
// #nosec G204 -- pid is validated by isValidPID() to contain only digits
|
||||
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %s", pid), "/FO", "CSV", "/NH")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
@@ -145,7 +146,7 @@ func (pc *PortChecker) isPortAvailable(port int) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
listener.Close()
|
||||
_ = listener.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -166,6 +167,7 @@ func (pc *PortChecker) getProcessUsingPort(port int) string {
|
||||
func (pc *PortChecker) getProcessUsingPortUnix(port int) string {
|
||||
// Use lsof to find the process
|
||||
// lsof -i :PORT -sTCP:LISTEN -t returns PIDs
|
||||
// #nosec G204 -- port is an integer from config validation, not user input
|
||||
cmd := exec.Command("lsof", "-i", fmt.Sprintf(":%d", port), "-sTCP:LISTEN", "-t")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
|
||||
@@ -409,7 +409,7 @@ func (c *Checker) checkTCPDial(port int) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -427,7 +427,7 @@ func (c *Checker) checkDataTransfer(port int) error {
|
||||
|
||||
// Set a short read deadline to detect hung connections
|
||||
// We don't expect to receive data, but we want to verify the connection isn't hung
|
||||
conn.SetReadDeadline(time.Now().Add(c.timeout))
|
||||
_ = conn.SetReadDeadline(time.Now().Add(c.timeout))
|
||||
|
||||
// Try to read a small amount of data
|
||||
// Most servers will either:
|
||||
|
||||
@@ -51,6 +51,7 @@ func NewLogger(forwardID, logFile string, maxBodyLen int) (*Logger, error) {
|
||||
// Log entries are delivered via callbacks to the UI
|
||||
l.output = io.Discard
|
||||
} else {
|
||||
// #nosec G304 -- logFile is from config validation, not arbitrary user input
|
||||
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -85,12 +85,13 @@ func (p *Proxy) Start() error {
|
||||
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()))
|
||||
_, _ = w.Write([]byte("Proxy error: " + err.Error()))
|
||||
},
|
||||
}
|
||||
|
||||
p.server = &http.Server{
|
||||
Handler: proxy,
|
||||
Handler: proxy,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
p.running = true
|
||||
@@ -122,8 +123,8 @@ func (p *Proxy) Stop() error {
|
||||
defer cancel()
|
||||
|
||||
if err := p.server.Shutdown(ctx); err != nil {
|
||||
// Force close
|
||||
p.server.Close()
|
||||
// Force close - error ignored as we're already shutting down
|
||||
_ = p.server.Close()
|
||||
}
|
||||
|
||||
if err := p.logger.Close(); err != nil {
|
||||
@@ -173,7 +174,7 @@ func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
reqEntry.Headers = flattenHeaders(req.Header)
|
||||
}
|
||||
|
||||
t.proxy.logger.Log(reqEntry)
|
||||
_ = t.proxy.logger.Log(reqEntry)
|
||||
|
||||
// Make the request
|
||||
resp, err := t.transport.RoundTrip(req)
|
||||
@@ -207,7 +208,7 @@ func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
respEntry.Headers = flattenHeaders(resp.Header)
|
||||
}
|
||||
|
||||
t.proxy.logger.Log(respEntry)
|
||||
_ = t.proxy.logger.Log(respEntry)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -269,7 +270,7 @@ func (p *Proxy) logError(req *http.Request, err error) {
|
||||
Path: req.URL.Path,
|
||||
Error: err.Error(),
|
||||
}
|
||||
p.logger.Log(entry)
|
||||
_ = p.logger.Log(entry)
|
||||
}
|
||||
|
||||
// flattenHeaders converts http.Header to map[string]string
|
||||
|
||||
@@ -356,6 +356,6 @@ func CheckPortAvailability(port int) (bool, string, error) {
|
||||
}
|
||||
|
||||
// Port is available, close the listener
|
||||
listener.Close()
|
||||
_ = listener.Close()
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
@@ -89,6 +89,11 @@ func (p *Publisher) Register(forwardID, alias string, localPort int) error {
|
||||
p.servers[forwardID] = server
|
||||
p.aliases[forwardID] = alias
|
||||
|
||||
// Allow zeroconf's internal goroutines (recv4, recv6) to fully initialize.
|
||||
// This prevents a race condition where shutdown() could set connections to nil
|
||||
// while recv goroutines are still starting up.
|
||||
time.Sleep(startupSettleTime)
|
||||
|
||||
logger.Info("mDNS hostname registered", map[string]interface{}{
|
||||
"forward_id": forwardID,
|
||||
"hostname": GetHostname(alias),
|
||||
@@ -151,6 +156,13 @@ func (p *Publisher) Stop() {
|
||||
logger.Info("mDNS publisher stopped", nil)
|
||||
}
|
||||
|
||||
// startupSettleTime is a small delay after zeroconf registration to allow internal
|
||||
// goroutines (recv4, recv6) to fully initialize before any shutdown can occur.
|
||||
// This works around a race condition in grandcat/zeroconf where shutdown() sets
|
||||
// connections to nil while recv goroutines may still be initializing.
|
||||
// See: https://github.com/grandcat/zeroconf/issues/95
|
||||
const startupSettleTime = 50 * time.Millisecond
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -24,7 +24,8 @@ type Backoff struct {
|
||||
func NewBackoff() *Backoff {
|
||||
return &Backoff{
|
||||
attempt: 0,
|
||||
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
// #nosec G404 -- math/rand is appropriate for backoff jitter; cryptographic randomness not needed
|
||||
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+82
-15
@@ -602,20 +602,82 @@ func (m model) renderMainView() string {
|
||||
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
|
||||
|
||||
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("jk"),
|
||||
keyStyle.Render("Space"),
|
||||
keyStyle.Render("n"),
|
||||
keyStyle.Render("e"),
|
||||
keyStyle.Render("d"),
|
||||
keyStyle.Render("b"),
|
||||
keyStyle.Render("l"),
|
||||
keyStyle.Render("q"),
|
||||
len(m.ui.forwardOrder))
|
||||
// Get terminal width for footer wrapping
|
||||
termWidth := m.termWidth
|
||||
if termWidth == 0 {
|
||||
termWidth = 120
|
||||
}
|
||||
|
||||
// Fill space to push footer to bottom (reserve 2 lines: 1 for spacing, 1 for footer)
|
||||
footerHeight := 2
|
||||
// Define key bindings as structured data for flexible rendering
|
||||
type keyBinding struct {
|
||||
key string
|
||||
desc string
|
||||
}
|
||||
bindings := []keyBinding{
|
||||
{"↑↓/jk", "Navigate"},
|
||||
{"Space", "Toggle"},
|
||||
{"n", "New"},
|
||||
{"e", "Edit"},
|
||||
{"d", "Delete"},
|
||||
{"b", "Bench"},
|
||||
{"l", "Logs"},
|
||||
{"q", "Quit"},
|
||||
}
|
||||
|
||||
// Build footer lines that fit within terminal width
|
||||
var footerLines []string
|
||||
var currentLine strings.Builder
|
||||
currentLineVisualLen := 0
|
||||
|
||||
// Calculate how much space we need for the total count suffix
|
||||
totalSuffix := fmt.Sprintf(" │ Total: %d", len(m.ui.forwardOrder))
|
||||
totalSuffixLen := len(totalSuffix)
|
||||
|
||||
// Available width (account for some margin)
|
||||
availableWidth := termWidth - 4
|
||||
|
||||
for i, binding := range bindings {
|
||||
// Build this binding's text
|
||||
keyRendered := keyStyle.Render(binding.key)
|
||||
bindingText := keyRendered + ": " + binding.desc
|
||||
// Visual length without ANSI codes
|
||||
bindingVisualLen := len(binding.key) + 2 + len(binding.desc)
|
||||
|
||||
// Add separator if not first item on line
|
||||
separator := ""
|
||||
separatorLen := 0
|
||||
if currentLine.Len() > 0 {
|
||||
separator = " "
|
||||
separatorLen = 2
|
||||
}
|
||||
|
||||
// Check if this binding fits on current line
|
||||
// For the last binding, also need to fit the total suffix
|
||||
neededWidth := currentLineVisualLen + separatorLen + bindingVisualLen
|
||||
if i == len(bindings)-1 {
|
||||
neededWidth += totalSuffixLen
|
||||
}
|
||||
|
||||
if neededWidth > availableWidth && currentLine.Len() > 0 {
|
||||
// Start a new line
|
||||
footerLines = append(footerLines, currentLine.String())
|
||||
currentLine.Reset()
|
||||
currentLineVisualLen = 0
|
||||
separator = ""
|
||||
separatorLen = 0
|
||||
}
|
||||
|
||||
currentLine.WriteString(separator)
|
||||
currentLine.WriteString(bindingText)
|
||||
currentLineVisualLen += separatorLen + bindingVisualLen
|
||||
}
|
||||
|
||||
// Add total count to the last line
|
||||
currentLine.WriteString(totalSuffix)
|
||||
footerLines = append(footerLines, currentLine.String())
|
||||
|
||||
// Calculate footer height
|
||||
footerHeight := len(footerLines) + 1 // +1 for the blank line before footer
|
||||
remainingLines := termHeight - currentLines - footerHeight
|
||||
if remainingLines > 0 {
|
||||
b.WriteString(strings.Repeat("\n", remainingLines))
|
||||
@@ -623,7 +685,12 @@ func (m model) renderMainView() string {
|
||||
|
||||
// Add footer at bottom
|
||||
b.WriteString("\n")
|
||||
b.WriteString(footerStyle.Render(footer))
|
||||
for i, line := range footerLines {
|
||||
if i > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(footerStyle.Render(line))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -736,7 +803,7 @@ func (m model) renderDeleteConfirmation() string {
|
||||
}
|
||||
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(helpStyle.Render("←/→: Navigate Enter: Confirm Esc: Cancel"))
|
||||
b.WriteString(wrapHelpText("←/→: Navigate Enter: Confirm Esc: Cancel", wizardHelpWidth(m.termWidth)))
|
||||
|
||||
// Wrap in a box using wizard style
|
||||
boxStyle := lipgloss.NewStyle().
|
||||
|
||||
@@ -194,6 +194,96 @@ func renderTextInput(label, value string, valid bool) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// wizardHelpWidth returns an appropriate width for wizard help text
|
||||
// based on terminal width. For modals, we use a sensible maximum.
|
||||
func wizardHelpWidth(termWidth int) int {
|
||||
if termWidth == 0 {
|
||||
termWidth = 80
|
||||
}
|
||||
// Wizard modals shouldn't be wider than 70 chars typically
|
||||
// but on narrow terminals, use available space minus padding
|
||||
maxWidth := 70
|
||||
available := termWidth - 10 // account for modal borders and padding
|
||||
if available < maxWidth {
|
||||
return available
|
||||
}
|
||||
return maxWidth
|
||||
}
|
||||
|
||||
// wrapHelpText wraps help text to fit within the given width.
|
||||
// Help text is expected to be in the format "key: action key: action ..."
|
||||
// separated by double spaces. On smaller screens, it wraps to multiple lines.
|
||||
func wrapHelpText(text string, width int) string {
|
||||
if width <= 0 {
|
||||
width = 80 // Default width
|
||||
}
|
||||
|
||||
// Account for some padding/margin
|
||||
availableWidth := width - 4
|
||||
if availableWidth < 20 {
|
||||
availableWidth = 20
|
||||
}
|
||||
|
||||
// If text fits, return as-is
|
||||
if len(text) <= availableWidth {
|
||||
return helpStyle.Render(text)
|
||||
}
|
||||
|
||||
// Split by double-space separator (common in help text)
|
||||
parts := strings.Split(text, " ")
|
||||
if len(parts) <= 1 {
|
||||
// No double-space separators, just truncate
|
||||
if len(text) > availableWidth-3 {
|
||||
return helpStyle.Render(text[:availableWidth-3] + "...")
|
||||
}
|
||||
return helpStyle.Render(text)
|
||||
}
|
||||
|
||||
var lines []string
|
||||
var currentLine strings.Builder
|
||||
|
||||
for i, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if adding this part would exceed width
|
||||
addition := part
|
||||
if currentLine.Len() > 0 {
|
||||
addition = " " + part
|
||||
}
|
||||
|
||||
if currentLine.Len()+len(addition) > availableWidth && currentLine.Len() > 0 {
|
||||
// Start new line
|
||||
lines = append(lines, currentLine.String())
|
||||
currentLine.Reset()
|
||||
currentLine.WriteString(part)
|
||||
} else {
|
||||
if currentLine.Len() > 0 {
|
||||
currentLine.WriteString(" ")
|
||||
}
|
||||
currentLine.WriteString(part)
|
||||
}
|
||||
|
||||
// Handle last part
|
||||
if i == len(parts)-1 && currentLine.Len() > 0 {
|
||||
lines = append(lines, currentLine.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Join with newlines and apply style to each line
|
||||
var result strings.Builder
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
result.WriteString("\n")
|
||||
}
|
||||
result.WriteString(helpStyle.Render(line))
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// overlayContent overlays modal content centered on the base view
|
||||
// Note: base parameter is kept for API compatibility but not used since
|
||||
// lipgloss.Place provides cleaner centering without background artifacts
|
||||
|
||||
+29
-24
@@ -109,10 +109,11 @@ func (m model) renderSelectContext() string {
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
helpWidth := wizardHelpWidth(m.termWidth)
|
||||
if wizard.searchFilter != "" {
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Cancel", len(wizard.getFilteredContexts()), len(wizard.contexts))))
|
||||
b.WriteString(wrapHelpText(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Cancel", len(wizard.getFilteredContexts()), len(wizard.contexts)), helpWidth))
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc/Ctrl+C: Cancel"))
|
||||
b.WriteString(wrapHelpText("Type to filter ↑/↓: Navigate Enter: Select Esc/Ctrl+C: Cancel", helpWidth))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
@@ -150,10 +151,11 @@ func (m model) renderSelectNamespace() string {
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
helpWidth := wizardHelpWidth(m.termWidth)
|
||||
if wizard.searchFilter != "" {
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredNamespaces()), len(wizard.namespaces))))
|
||||
b.WriteString(wrapHelpText(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredNamespaces()), len(wizard.namespaces)), helpWidth))
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
|
||||
b.WriteString(wrapHelpText("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", helpWidth))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
@@ -192,7 +194,7 @@ func (m model) renderSelectResourceType() string {
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
|
||||
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -305,14 +307,15 @@ func (m model) renderEnterResource() string {
|
||||
|
||||
b.WriteString("\n")
|
||||
// Show appropriate help text based on resource type and filter state
|
||||
helpWidth := wizardHelpWidth(m.termWidth)
|
||||
if wizard.selectedResourceType == ResourceTypeService {
|
||||
if wizard.searchFilter != "" {
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredServices()), len(wizard.services))))
|
||||
b.WriteString(wrapHelpText(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredServices()), len(wizard.services)), helpWidth))
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
|
||||
b.WriteString(wrapHelpText("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", helpWidth))
|
||||
}
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
|
||||
b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", helpWidth))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
@@ -403,7 +406,7 @@ func (m model) renderEnterRemotePort() string {
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
|
||||
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
|
||||
} else {
|
||||
// Text input mode (no detected ports or user chose manual entry)
|
||||
if len(wizard.detectedPorts) > 0 {
|
||||
@@ -436,7 +439,7 @@ func (m model) renderEnterRemotePort() string {
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
|
||||
b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
@@ -477,7 +480,7 @@ func (m model) renderEnterLocalPort() string {
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
|
||||
b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -535,7 +538,7 @@ func (m model) renderConfirmation() string {
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("↑/↓/Tab: Navigate Enter: Confirm Esc: Back"))
|
||||
b.WriteString(wrapHelpText("↑/↓/Tab: Navigate Enter: Confirm Esc: Back", wizardHelpWidth(m.termWidth)))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -578,7 +581,7 @@ func (m model) renderSuccess() string {
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select"))
|
||||
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Select", wizardHelpWidth(m.termWidth)))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -641,7 +644,7 @@ func (m model) renderRemoveSelection() string {
|
||||
selectedCount := wizard.getSelectedCount()
|
||||
b.WriteString(fmt.Sprintf("%d of %d selected\n\n", selectedCount, len(wizard.forwards)))
|
||||
|
||||
b.WriteString(helpStyle.Render("Space: Toggle a: All n: None Enter: Remove Esc: Cancel"))
|
||||
b.WriteString(wrapHelpText("Space: Toggle a: All n: None Enter: Remove Esc: Cancel", wizardHelpWidth(m.termWidth)))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -676,7 +679,7 @@ func (m model) renderRemoveConfirmation() string {
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Confirm Esc: Cancel"))
|
||||
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Confirm Esc: Cancel", wizardHelpWidth(m.termWidth)))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -740,7 +743,7 @@ func (m model) renderBenchmarkConfig() string {
|
||||
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"))
|
||||
b.WriteString(wrapHelpText("↑/↓/Tab: Navigate Type to edit Enter: Run Esc: Cancel", wizardHelpWidth(m.termWidth)))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -780,7 +783,7 @@ func (m model) renderBenchmarkRunning() string {
|
||||
b.WriteString(mutedStyle.Render(fmt.Sprintf("Method: %s Concurrency: %d", state.method, state.concurrency)))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString(helpStyle.Render("Please wait..."))
|
||||
b.WriteString(wrapHelpText("Please wait...", wizardHelpWidth(m.termWidth)))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -796,14 +799,14 @@ func (m model) renderBenchmarkResults() string {
|
||||
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"))
|
||||
b.WriteString(wrapHelpText("Press Enter or Esc to return", wizardHelpWidth(m.termWidth)))
|
||||
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"))
|
||||
b.WriteString(wrapHelpText("Press Enter or Esc to return", wizardHelpWidth(m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -872,7 +875,7 @@ func (m model) renderBenchmarkResults() string {
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("Press Enter or Esc to return"))
|
||||
b.WriteString(wrapHelpText("Press Enter or Esc to return", wizardHelpWidth(m.termWidth)))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -1076,8 +1079,10 @@ func (m model) renderHTTPLog() string {
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Help line at bottom
|
||||
b.WriteString(helpStyle.Render(" ↑/↓: Navigate Enter: Details a: Auto-scroll f: Filter /: Search c: Clear q: Close"))
|
||||
// Help line at bottom (wrap for smaller screens)
|
||||
helpText := "↑/↓: Navigate Enter: Details a: Auto-scroll f: Filter /: Search c: Clear q: Close"
|
||||
b.WriteString(" ")
|
||||
b.WriteString(wrapHelpText(helpText, termWidth-4))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -1273,9 +1278,9 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int
|
||||
if state.copyMessage != "" {
|
||||
b.WriteString(successStyle.Render(state.copyMessage))
|
||||
b.WriteString(" ")
|
||||
b.WriteString(helpStyle.Render("↑/↓: Scroll c: Copy Esc: Back"))
|
||||
b.WriteString(wrapHelpText("↑/↓: Scroll c: Copy Esc: Back", termWidth-10))
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("↑/↓/PgUp/PgDn: Scroll g: Top c: Copy response Esc: Back"))
|
||||
b.WriteString(wrapHelpText("↑/↓/PgUp/PgDn: Scroll g: Top c: Copy response Esc: Back", termWidth-10))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
|
||||
@@ -144,7 +144,7 @@ func parseVersion(v string) []int {
|
||||
|
||||
for _, p := range parts {
|
||||
var num int
|
||||
fmt.Sscanf(p, "%d", &num)
|
||||
_, _ = fmt.Sscanf(p, "%d", &num)
|
||||
result = append(result, num)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user