Development Guide
Prerequisites
| Tool | Version | Notes |
|---|---|---|
| Node.js | v20+ (v22 recommended) | |
| Go | v1.22+ | CGO must be enabled |
| C compiler | any | Required by CGO — gcc on Linux, Xcode CLT on macOS, MinGW on Windows |
| Trivy CLI | optional | For image vulnerability scanning in Security Hub |
Install Trivy (macOS):
brew install trivy
Setup
# Install Node dependencies (also runs postinstall which rebuilds node-pty)
npm install
# Build the Go sidecar — required before first dev run
cd go-core && go build ./cmd/podscape-core/ && cd ..
Development
npm run dev # Start Electron + Vite dev server with hot reload
Note: If
window.kubectl.*methods appear asundefinedat runtime, the preload build is stale. Restartnpm run dev.
Codebase Structure
Renderer (src/renderer/)
components/core/: High-level orchestration components (Layout,SectionRouter,OverlayManager,ErrorBoundary).store/: Zustand state management split into domain-specific slices.types/: Granular TypeScript definitions (api.ts,k8s.ts,ui.ts,utils.ts,common.ts).config.ts: Centralized UI configuration —LIST_SECTIONS,CLUSTER_SCOPED_SECTIONS,PROVIDER_SECTIONS,SECTION_LABELS,COLUMNS— all typed withResourceKind. Adding a new section requires only updating this file and the appropriate dispatch map inSectionRouter.utils/prefetch.ts: Background eager-loads lazy panel chunks after mount; failures are logged as warnings rather than swallowed silently.
Go Sidecar (go-core/)
internal/handlers/: HTTP resource handlers and operation logic.internal/k8sutil/: Canonical Kubernetes resource metadata —KindGVR,KindGVRFallback,ClusterScopedKinds. This is the single source of truth for GVR resolution;internal/opsandinternal/handlers/operations.goboth delegate here. Adding a new resource type or alias requires only a change in this package.internal/rbac/: Core RBAC probing engine.
Architecture patterns
Stale-context guard
Every async action that touches the store must capture the active context at call time and discard results when the context has changed mid-flight:
const ctx = get().selectedContext
// ... await something expensive ...
if (get().selectedContext !== ctx) return // discard stale result
A context-string comparison alone can be bypassed by rapid A→B→A switching (the result from B resolves when the context is back to A, so the guard passes). Monotonic sequence counters prevent this:
let mySeq = ++sliceSeq // module-level counter
// ... await something expensive ...
if (mySeq !== sliceSeq || get().selectedContext !== ctx) return
Both checks are required. providersSlice, analysisSlice, and clusterSlice all use this pattern.
Port-forward race guard
portForward and stopPortForward can race: stop may arrive while the initial sidecar HTTP request is still in flight. The pendingStops: Set<string> in src/main/ipc/kubectl.ts bridges the gap — stopPortForward records the id before calling the sidecar, and portForward discards the alive-poll timer immediately if the id is already pending. Without this, a timer is started and never cleared.
SSE stream resolution (settled flag)
The handleScanResponse helper in src/main/ipc/kubectl.ts resolves or rejects the wrapping Promise via SSE result / error events. A settled boolean is set when either terminal event fires. The end listener rejects if !settled — this surfaces unexpected stream closes (sidecar crash, network reset) as real errors rather than silent null results.
Gorilla WebSocket Concurrency Safety
Gorilla WebSocket (github.com/gorilla/websocket) does not allow concurrent write operations or concurrent read operations on the same connection.
- Writes: outgoing frames (stdout/stderr goroutines writing concurrently) must be synchronized using a
sync.Mutexinside thewsStream.Writehelper. - Reads (Stdin): To avoid concurrent reads, a single read-pump goroutine is established to read incoming frames from the WebSocket and write them to an
io.Pipe. The execution handler (exec.Exec) reads stdin from the other end of the pipe.
Double-Checked Locking (DCL) on Managers
For operations involving slow I/O or network calls (such as loading Helm index files from disk or parsing Kubernetes client configurations), avoid holding a global manager lock. Instead, use Double-Checked Locking (DCL):
- Acquire the lock, check if the value is in the cache, and return immediately if found.
- Release the lock before performing the slow operation (so concurrent commands are not blocked).
- Perform the disk/network operation.
- Re-acquire the lock, verify that no other goroutine has populated the cache entry in the meantime, write the result, and unlock.
Atomic Settings Configuration Writes
To prevent corrupted or empty settings files on sudden process termination or write races:
- Write the configuration string to a temporary file (
settings.json.tmp) first, then rename it atomically tosettings.json. - Serialize concurrent saves in the main process using a promise queue (
writeLock = writeLock.then(...)) to prevent interleaved read-modify-write sequences from clobbering each other.
Go rest.Config Shallow Copying
The NewSPDYExecutor method in client-go (k8s.io/client-go/tools/remotecommand) modifies the passed *rest.Config pointer in-place to set the negotiated serializer. If multiple executions (terminals/logs) run concurrently, they will race on the shared config pointer. Always shallow-copy the configuration (cfgCopy := *config) before creating a new executor:
cfgCopy := *config
executor, err := remotecommand.NewSPDYExecutor(&cfgCopy, "POST", req.URL())
Topology Dangling Edge Pruning
When building topology maps, check that both Source and Target endpoints of an edge exist in the list of built nodes (topo.Nodes). Any dangling edge (which can occur due to namespace filtering or RBAC exclusions of resources like services/PVCs) will cause undefined pointer dereferences and crashes in the frontend layout. Prune these edges during the final assembly step.
Running Tests
# Frontend (Vitest)
npm run test
npm run test:watch # watch mode
# Go sidecar (handlers, rbac, helm, portforward, prometheus, ownerchain)
cd go-core && go test ./...
Slice test conventions
Store slices are tested in isolation using vi.fn() set/get pairs — no Zustand store needed. Every slice test file must call setupMocks() from src/renderer/store/slices/test-utils.ts before any import that references window:
import { setupMocks } from './test-utils'
const { windowMock } = setupMocks()
import { createMySlice } from './mySlice'
setupMocks() wires window.kubectl, window.exec, window.settings, localStorage, and document. If a new window.* namespace is added to the preload, add it to the windowMock object in test-utils.ts so all existing slice tests continue to work without per-test boilerplate.
The window.exec.kill mock is included (vi.fn().mockResolvedValue(undefined)). Any test for closeExecTab or closeExec will call it automatically — assert on windowMock.exec.kill if the test needs to verify the call.
Building
The full build compiles the Go sidecar first, then runs electron-vite:
npm run build # Go sidecar + renderer + main + preload
Individual steps:
cd go-core && go build ./cmd/podscape-core/ # sidecar binary only
cd go-core && go build ./cmd/podscape-mcp/ # MCP server binary only
npx electron-vite build # Electron assets only
Distribution
Packages are produced by electron-builder. Artifacts land in dist/.
npm run build:mac # macOS — .dmg + .zip (arm64 and x64 separately)
npm run build:win # Windows — NSIS installer (.exe)
npm run build:linux # Linux — AppImage + .deb
Icon generation
Icons must be generated before packaging if they don’t already exist:
npm run icon:icns # macOS .icns from resources/icon.png
npm run icon:ico # Windows .ico from resources/icon.png
Requirements: imagemagick (Linux/macOS) or the scripts handle it via sips on macOS.
CI / Release
Releases are triggered by pushing a v* git tag. The workflow (.github/workflows/release.yml) runs three parallel jobs:
| Job | Runner | Output |
|---|---|---|
release-mac (arm64) | macos-latest | .dmg + .zip for Apple Silicon |
release-mac (x64) | macos-13 | .dmg + .zip for Intel |
release-win | windows-latest | .exe NSIS installer |
release-linux | ubuntu-latest | .AppImage + .deb |
Each job also uploads a checksums-<platform>.txt file to the GitHub release so users can verify downloads with SHA256.
Required GitHub secret: GH_TOKEN with contents: write permission on the repository.
Settings Schema
App settings are stored in ~/.podscape/settings.json. The file is created automatically on first write. All fields are optional — missing keys fall back to the defaults shown below.
{
"kubeconfigPath": "",
"shellPath": "",
"theme": "dark",
"prodContexts": [],
"prometheusUrls": {},
"tourCompleted": false,
"pluginsEnabled": true,
"gitopsEnabled": true,
"networkEnabled": true
}
| Field | Type | Default | Description |
|---|---|---|---|
kubeconfigPath | string | "" | Absolute path to a kubeconfig file. Empty string means use $KUBECONFIG env var, then ~/.kube/config. |
shellPath | string | "" | Absolute path to the shell binary used for PTY terminals (e.g. /bin/zsh). Empty string means auto-detect from the user’s environment. |
theme | "dark" \| "light" \| "" | "dark" | UI colour theme. Empty string defers to the last-used or OS preference. |
prodContexts | string[] | [] | List of kubeconfig context names to treat as production. Any matching context activates the red border + banner in the UI. |
prometheusUrls | Record<string, string> | {} | Per-context manual Prometheus base URLs (e.g. { "my-ctx": "https://prometheus.example.com" }). Empty string for a context means auto-discover via Kubernetes service proxy. |
tourCompleted | boolean | false | Whether the post-connection onboarding tour has been shown and dismissed. |
pluginsEnabled | boolean | true | Show the Plugins (Krew) panel in the sidebar. |
gitopsEnabled | boolean | true | Show the GitOps panel in the sidebar. |
networkEnabled | boolean | true | Show the Network Map and Connectivity Tester panels in the sidebar. |
The settings file is read and written by src/main/settings/settings_storage.ts (getSettings / saveSettings). IPC handlers in src/main/ipc/settings.ts expose settings:get and settings:set channels to the renderer via window.settings.
Auto-Updater
Podscape uses electron-updater to deliver in-app updates. The updater is configured in src/main/system/updater.ts and only activates in production builds (is.dev guard — no update checks during npm run dev).
Update source: electron-updater reads the publish configuration from package.json (GitHub releases). It fetches latest.yml / latest-mac.yml from the GitHub release assets to compare the current version against the latest published release.
Behaviour:
autoDownloadis set tofalse— updates are downloaded only when the user explicitly confirms.autoInstallOnAppQuitistrue— a downloaded update is installed automatically the next time the app quits.- An initial check fires 5 seconds after launch (delayed to avoid racing with sidecar startup). The timer is cancelled on
before-quitto prevent a network request into a partially torn-down process. - Events fired before the renderer window is ready are queued (up to 20) and flushed once
did-finish-loadfires, so no update notification is lost on a fast machine.
IPC channels:
| Channel | Direction | Description |
|---|---|---|
updater:check | Renderer → Main (handle) | Trigger an immediate update check |
updater:download | Renderer → Main (handle) | Start downloading the available update |
updater:install | Renderer → Main (handle) | Quit and install the downloaded update |
updater:checking | Main → Renderer (send) | Update check started |
updater:available | Main → Renderer (send) | New version found; payload is the UpdateInfo object |
updater:not-available | Main → Renderer (send) | Already on the latest version |
updater:progress | Main → Renderer (send) | Download progress; payload is a ProgressInfo object |
updater:downloaded | Main → Renderer (send) | Download complete; payload is the UpdateDownloadedEvent object |
updater:error | Main → Renderer (send) | Error during check or download; payload is the error message string |
The UpdateBanner component in src/renderer/components/core/UpdateBanner.tsx listens for these events and renders an in-app notification bar when an update is available or has been downloaded.
Testing updates locally: electron-updater skips the update check entirely when is.dev is true, so local testing requires a production build. Point autoUpdater.updateConfigPath to a local dev-app-update.yml file or use autoUpdater.forceDevUpdateConfig = true with a matching release on a local file server.
Known Build Notes
- Native module rebuild:
npm installrunselectron-builder install-app-depsviapostinstall, which rebuilds any native modules for the target Electron version. - CGO on Windows: MinGW must be on
PATHbefore building the Go sidecar. The CI workflow adds it via$env:PATH. - Sidecar location: In dev, the binary is expected at
go-core/podscape-core. In production, electron-builder copies it toresources/bin/podscape-coreviaextraResources. - Stale preload: If
window.kubectl.*(orwindow.krew.*) methods appear asundefinedat runtime, the preload build is stale. Restartnpm run devto pick up the latest preload.
Kubectl Plugin Development
The Plugin Panel (src/renderer/components/plugins/) uses a registry-driven architecture. Each plugin is a self-contained module with an InfoPanel and a RunPanel.
File layout
src/renderer/components/plugins/
<plugin-name>/
InfoPanel.tsx # Info tab — description, install/uninstall button
RunPanel.tsx # Run tab — inputs + live output
PluginContract.ts # Shared prop types (PluginRunPanelProps, PluginInfoPanelProps)
PluginInfoLayout.tsx # Reusable wrapper for InfoPanel with install/uninstall logic
pluginRegistry.ts # Lazy-load map: name → () => import('./name')
usePluginRun.ts # Hook: run plugin, stream output lines, track running state
NamespaceSelect.tsx # Dropdown backed by live cluster namespaces from the store
Plugin metadata (name, description, category, homepage) lives in src/renderer/config/krewPlugins.json.
Key hooks and components
usePluginRun() — call run(pluginName, args) to invoke kubectl <plugin> <args>. Returns { lines, running, exitCode, run }. Lines are pre-split by newline with [stderr] prefix on stderr. Leading whitespace is preserved (important for YAML/tree output).
PluginInfoLayout — pass plugin, onInstall, onUninstall, and onOpen props. Handles loading state, error display, and the install/uninstall button rendering.
NamespaceSelect — reads namespaces from the Zustand store and renders a live dropdown. Falls back to a plain text input when no namespaces are loaded yet. Accepts includeAll prop to add an “all namespaces” option.
Adding a plugin
- Add an entry to
src/renderer/config/krewPlugins.json. - Create
src/renderer/components/plugins/<name>/InfoPanel.tsx— usePluginInfoLayoutas the wrapper. - Create
src/renderer/components/plugins/<name>/RunPanel.tsx— useusePluginRunto invoke and stream output. - Register the loader in
src/renderer/components/plugins/pluginRegistry.ts.
See stern or tree for simple examples; neat for Monaco editor output; outdated for custom parsed table output.