Skip to content

Add terminal exec and port forwarding features - #2

Merged
nadaverell merged 2 commits into
mainfrom
feature/terminal-exec-portforward
Jan 22, 2026
Merged

nadaverell merged 2 commits into
mainfrom
feature/terminal-exec-portforward

Conversation

@nadaverell

@nadaverell nadaverell commented Jan 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds two major interactive features to Explorer:

  • Terminal Exec: Open a shell session into any running container directly from the UI. Click the terminal icon next to any running container in the pod details drawer to open a fully interactive shell with xterm.js terminal emulation.

  • Port Forwarding: Forward container ports to localhost with one click. Click port numbers in pod/service views to start forwarding. Active forwards are shown in a manager dropdown in the header with status and easy cleanup.

Key Changes

Backend (Go)

  • exec.go - WebSocket handler proxying kubectl exec with PTY support
  • portforward.go - HTTP handlers for managing port forwards (start/stop/list)
  • server.go - Route registration and WebSocket upgrade handling

Frontend (React)

  • New dock system with tabs for terminals and logs that preserve state when switching
  • Port forward manager showing all active forwards with status indicators
  • Updated pod/service renderers with action buttons for terminal and port forward
  • xterm.js integration with automatic resize handling

nadaverell and others added 2 commits January 22, 2026 03:59
- Add internal/k8s/update.go with UpdateResource and DeleteResource
  functions using dynamic K8s client
- Add YamlEditor component with Monaco editor, YAML validation,
  and inline highlighting for Pod editable fields
Adds two major interactive features to Explorer:

1. **Terminal Exec**: Open a shell session into any running container directly
   from the UI. Uses WebSocket to proxy kubectl exec with full terminal
   emulation via xterm.js. Supports multiple concurrent sessions with a
   tabbed dock interface that preserves state when switching tabs.

2. **Port Forwarding**: Forward container ports to localhost with one click.
   Includes a port forward manager in the UI header showing all active
   forwards with status indicators and easy cleanup.

Backend changes:
- exec.go: WebSocket handler for kubectl exec with PTY support
- portforward.go: HTTP handlers for starting/stopping/listing port forwards
- server.go: Register new routes and CORS for WebSocket

Frontend changes:
- New dock system (BottomDock, DockContext, TerminalTab, LogsTab)
- Port forward components (PortForwardButton, PortForwardManager)
- Updated PodRenderer with terminal/logs buttons per container
- Updated ServiceRenderer with port forward buttons
- xterm.js integration with FitAddon for responsive terminals

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@nadaverell
nadaverell merged commit 5844fd6 into main Jan 22, 2026
1 of 2 checks passed
@nadaverell
nadaverell deleted the feature/terminal-exec-portforward branch February 6, 2026 13:07
nadaverell added a commit that referenced this pull request May 2, 2026
…#570)

* fix(ui): keep tooltips anchored to their trigger and dismiss on click

Three reported tooltip bugs on app.radarhq.io that share a single root cause
in the shared Tooltip primitive (SKY-818):

1. Hovering a nav button (Home, Topology, Resources, Timeline, Helm,
   Traffic, Audit) flashed the tooltip text at the viewport's top-left
   (0, 0) before snapping into place, overlapping the "Radar / By Skyhook"
   logo. On Timeline the logo literally rendered as "adar" because
   "Timeline" painted over the "R".
2. Clicking a pod row left the row's full-name tooltip pinned above the
   table header, obscuring the column headers after the cursor moved away.
3. Clicking a tab pill left the tab's tooltip stuck under the active pill
   instead of dismissing on click.

Root causes:

- The portal's initial render used `top: 0; left: 0` because position
  state was seeded with zeros and only updated one rAF later. On slower
  clients (or whenever the requestAnimationFrame callback was delayed by
  layout work) that single frame painted at the viewport origin was
  visible to the user.
- Dismissal relied on `mouseleave` and on the trigger unmounting, but for
  nav buttons / tab pills the click triggers a route change without
  unmounting the trigger, and the cursor stays over it — so neither path
  fires and the tooltip lingers.

Fix:

- Extract the position math into a pure helper `computeTooltipPosition`
  with full unit coverage (`tooltip-position.ts` + `.test.ts`). It returns
  `null` when the trigger has no layout, so we never show coords we can't
  trust.
- Track coords as `{ top, left } | null`. While null, render the portal
  with `visibility: hidden` and `aria-hidden`, so no (0, 0) frame is
  ever visible.
- Two-pass measurement: rAF #1 captures the trigger rect with the
  unmeasured tooltip (size 0); rAF #2 re-runs with the now-known tooltip
  size for exact centering. Cleaned up via the existing rafRef so HMR /
  rapid re-renders can't leak frames.
- Add `onPointerDown` on the wrapper that fires before any child onClick,
  guaranteeing the tooltip is hidden before nav/tab/route-change actions
  run. Fixes bugs 2 and 3.
- Add window-level `popstate` and Escape listeners while a tooltip is
  visible, as belt-and-braces for in-app navigation that doesn't go
  through pointerdown on the trigger (e.g. keyboard shortcuts).

No behavior change for the singleton coordinator, the disabled flow, or
the public `Tooltip` / `WithTooltip` API.

Linear: SKY-818
Made-with: Cursor

* review: trim bug-narrative comments from tooltip primitive

No behavior change — keeps the load-bearing WHYs (popstate dismissal
mechanism, second-pass rAF, visibility:hidden guard) but drops the
incident-history framing that would rot.

---------

Co-authored-by: eliran-mic <eliran.mic@gmail.com>
Co-authored-by: Nadav Erell <nadaverell@gmail.com>
nadaverell added a commit that referenced this pull request May 18, 2026
Pod/Workload drawer was placing the new "Permissions via ServiceAccount"
section at position #2 — right under Status, before Containers and
Resource Usage. That's incident/audit framing on a daily-browsing
surface. Operators opening a Pod want to see what's running and how it's
behaving; "what could an attacker do with this SA" comes after.

Move it below the diagnostic block (Status → Containers → Resource
Usage → Conditions). Combined with the earlier collapse-by-default
change (auto-expands only when blast-radius detected), the section
now stays out of the way unless it's actually load-bearing for the
operator's task.

Same move in PodRenderer and WorkloadRenderer.
nadaverell added a commit that referenced this pull request May 19, 2026
Two follow-ups from #655 on per-user RBAC visual test:

1. Topology preview card on Home: the SSE topology stream is cluster-
   wide for small/medium clusters; only the dashboard summary is
   namespace-filtered server-side. That left the kind legend showing
   cluster-wide totals while the "<X> resources · <Y> conn" header had
   already narrowed to the active namespace. Filter the topology
   client-side in HomeView before passing it to TopologyPreview (and
   ActivitySummary) so the legend matches the title. No-op on large
   clusters where forceNamespaceFilter already filters SSE server-side.

2. Cluster info card "0 namespaces" for restricted users: dashboard.go
   only populated resourceCounts.Namespaces when the user could list
   namespaces cluster-wide, leaving a misleading "0" for users with a
   restricted namespace view. Fall back to len(allowedNamespaces),
   mirroring the same pattern in resource_counts.go.

Item #2 from the issue (sidebar count "1" vs page "No Namespace found"
asymmetry) is left as-is — genuine product call, neither option is
obviously better than the other.
nadaverell added a commit that referenced this pull request May 29, 2026
…aps, CRD noise

#1 detector monotonicity (internal/k8s/health.go): classify crashloop from
   RestartCount + LastTerminationState, stable across Waiting->Running->Waiting
   so the category-hashed issue_id stops churning.
#2 suppress parent workload_degraded/rollout_stalled when an equal-or-worse child
   symptom exists on the same subject — severity-gated so a critical rollup is
   never downgraded to a warning child.
#3 PVC Lost + Job/CronJob failures classified (were discarded to unknown).
#5 CRD-condition noise floor: transient-aware via shared
   packages.IsTransientConditionReason (one source of truth with the GitOps path).
+ table tests. Verified: go build/vet/test green (internal/issues, internal/k8s, pkg/packages).
nadaverell added a commit that referenced this pull request May 30, 2026
…aps, CRD noise

#1 detector monotonicity (internal/k8s/health.go): classify crashloop from
   RestartCount + LastTerminationState, stable across Waiting->Running->Waiting
   so the category-hashed issue_id stops churning.
#2 suppress parent workload_degraded/rollout_stalled when an equal-or-worse child
   symptom exists on the same subject — severity-gated so a critical rollup is
   never downgraded to a warning child.
#3 PVC Lost + Job/CronJob failures classified (were discarded to unknown).
#5 CRD-condition noise floor: transient-aware via shared
   packages.IsTransientConditionReason (one source of truth with the GitOps path).
+ table tests. Verified: go build/vet/test green (internal/issues, internal/k8s, pkg/packages).
nadaverell added a commit that referenced this pull request May 30, 2026
…aps, CRD noise

#1 detector monotonicity (internal/k8s/health.go): classify crashloop from
   RestartCount + LastTerminationState, stable across Waiting->Running->Waiting
   so the category-hashed issue_id stops churning.
#2 suppress parent workload_degraded/rollout_stalled when an equal-or-worse child
   symptom exists on the same subject — severity-gated so a critical rollup is
   never downgraded to a warning child.
#3 PVC Lost + Job/CronJob failures classified (were discarded to unknown).
#5 CRD-condition noise floor: transient-aware via shared
   packages.IsTransientConditionReason (one source of truth with the GitOps path).
+ table tests. Verified: go build/vet/test green (internal/issues, internal/k8s, pkg/packages).
nadaverell added a commit that referenced this pull request May 30, 2026
…aps, CRD noise

#1 detector monotonicity (internal/k8s/health.go): classify crashloop from
   RestartCount + LastTerminationState, stable across Waiting->Running->Waiting
   so the category-hashed issue_id stops churning.
#2 suppress parent workload_degraded/rollout_stalled when an equal-or-worse child
   symptom exists on the same subject — severity-gated so a critical rollup is
   never downgraded to a warning child.
#3 PVC Lost + Job/CronJob failures classified (were discarded to unknown).
#5 CRD-condition noise floor: transient-aware via shared
   packages.IsTransientConditionReason (one source of truth with the GitOps path).
+ table tests. Verified: go build/vet/test green (internal/issues, internal/k8s, pkg/packages).
nadaverell added a commit that referenced this pull request May 31, 2026
- #2 (Service): a Service that is BOTH no-ready-endpoints AND has an unresolved
  named targetPort now stays two issues (distinct fixes — the workload vs the
  Service port spec) via stable fingerprints, instead of collapsing under one
  service_no_endpoints row.
- #5 (cluster): removed the 'cluster' CEL binding + the always-empty Issue.Cluster
  field + its activation projection. A single Radar is one cluster, so
  Issue.Cluster was always empty and a forwarded 'cluster == x' matched nothing —
  the advertised filter returned the wrong answer. Cross-cluster scoping is the
  hub's clusters=/target mechanism (applied at fan-out), not a per-issue
  predicate. MCP/filter docs updated to say so.
shivram-experience added a commit to shivram-personal/kompass that referenced this pull request Jul 7, 2026
The Phase-0 rebrand only covered index.html + the top-bar Logo; the
standalone nav-rail brand, Settings dialog, and ~30 user-facing strings
still read "Radar"/"by Skyhook" (CLAUDE.md rule skyhook-io#2 / SPEC §1.3).

Rebrand:
- nav rail: Kompass mark (reuse /images/kompass/kompass-icon.svg) + "Kompass"
  wordmark, drop the "by Skyhook" byline; Settings header now "Kompass v…".
- 35 user-facing string replacements across web/src + packages/k8s-ui/src:
  error/help/tooltip copy, browser-tab titles (— Kompass), Helm/RBAC/GitOps/
  timeline text, MCP dialog, permission-denied reasons, console prefixes.
- delete the unreferenced upstream Skyhook logo pack (web/public/assets/skyhook).
- harden test_no_upstream_branding from an index.html-only check into a
  source-tree gate: JSX text + aria-label/alt/title/placeholder/content, a
  hard "by Skyhook" ban, and a stray-asset check (caught a real leak:
  "Your Radar Cloud role …" in api/client.ts).

Cluster onboarding UI gap:
- add Connect (inject via seam skyhook-io#3, admin) + Select (activate) actions to the
  Cluster registry panel; previously register/delete only, forcing curl.
  Frontend-only: reuses existing /connect + /select endpoints, no new
  backend/data-model/security surface.

Gate: 135 pytest passed, tsc clean, all 5 fatal scans ok, seam-drift ok,
two-container smoke confirms engine reachable only via core.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nadaverell added a commit that referenced this pull request Jul 7, 2026
New `radar cloud install` (#2) — the recommended way to connect a cluster:
installs Radar INTO the current-context cluster with Cloud mode enabled, using
the operator's own kubeconfig (the only identity that can provision the
impersonation RBAC). Ordering matters: SSAR preflight runs BEFORE the device-flow
token mint, so a permission failure can't orphan a cluster + live token. On an
existing release it hands off a guided `helm upgrade` command. Does NOT start a
local dialer — the in-cluster agent it installs is what dials the tunnel.

--dry-run runs preflight + prints the plan without minting or installing.
nadaverell added a commit that referenced this pull request Aug 8, 2026
Six of seven findings triaged as real; #2 rejected on evidence.

An all-empty cluster now watches every readable report family, not just the
first. On a cluster that has enabled openreports but not yet produced a
violation, both families are legitimately empty; locking onto the first left
the group Kyverno was about to write to unwatched, so reports would appear in
the cluster and never in Radar while the status still claimed `ready`. Same
class as the served-but-empty bug already fixed, in the case where nothing has
data yet.

Partial coverage is now nameable. Watching what we can read is right, but
publishing it as unqualified coverage is not: a family we were denied is
recorded in DeniedGroups and surfaced on the capability, so a consumer that
renders "no violations" can tell it is speaking for part of the cluster.

A frozen index no longer claims to be live. When change-handler registration
fails the index is populated but never updates again until context switch,
while `ready` promises live data; LiveUpdates carries that distinction.

"Discovery unavailable" is no longer reported as absence. It was mapped to
not_installed, which OmittedReason keeps silent — concluding there is no
Kyverno from a lookup that never happened. Genuine, established absence stays
silent; an unestablished one now says the index is cold. This narrows, rather
than undoes, the spurious-omit fix: the permanent-undecided case that bug was
about has discovery present and is still resolved by asking it.

A MutatingPolicy with mutateExisting is no longer called Inactive. Verified
against the upstream CRD: spec.evaluation.mutateExisting.enabled defaults to
false, the opposite of admission/background, and rewrites resources that
already exist independently of the admission path. Labelling that "Inactive —
modifies nothing" told an operator nothing was changing while the policy
actively rewrote their cluster.

Ready status no longer races its own metadata. The index and the status live
in separate atomics and publishReady stores the index first, so a reader
sampling metadata before that store and the index after it reported `ready`
with an empty ObservedCount and no WatchedGroups — a status describing no real
cluster.

Rejected: "a failed GVR list commits a partial Replace, erasing findings".
listPolicyReportsAll is unchanged by this PR, and its premise does not hold —
ListWatched reads local informer indexers with no network path, and for this
caller (namespace "") indexerItems calls idx.List(), which has no error return
at all. There is no transient failure mode to erase anything; the only error
is the nil-cache guard, which fails every GVR alike.
nadaverell added a commit that referenced this pull request Aug 8, 2026
Six of seven findings triaged as real; #2 rejected on evidence.

An all-empty cluster now watches every readable report family, not just the
first. On a cluster that has enabled openreports but not yet produced a
violation, both families are legitimately empty; locking onto the first left
the group Kyverno was about to write to unwatched, so reports would appear in
the cluster and never in Radar while the status still claimed `ready`. Same
class as the served-but-empty bug already fixed, in the case where nothing has
data yet.

Partial coverage is now nameable. Watching what we can read is right, but
publishing it as unqualified coverage is not: a family we were denied is
recorded in DeniedGroups and surfaced on the capability, so a consumer that
renders "no violations" can tell it is speaking for part of the cluster.

A frozen index no longer claims to be live. When change-handler registration
fails the index is populated but never updates again until context switch,
while `ready` promises live data; LiveUpdates carries that distinction.

"Discovery unavailable" is no longer reported as absence. It was mapped to
not_installed, which OmittedReason keeps silent — concluding there is no
Kyverno from a lookup that never happened. Genuine, established absence stays
silent; an unestablished one now says the index is cold. This narrows, rather
than undoes, the spurious-omit fix: the permanent-undecided case that bug was
about has discovery present and is still resolved by asking it.

A MutatingPolicy with mutateExisting is no longer called Inactive. Verified
against the upstream CRD: spec.evaluation.mutateExisting.enabled defaults to
false, the opposite of admission/background, and rewrites resources that
already exist independently of the admission path. Labelling that "Inactive —
modifies nothing" told an operator nothing was changing while the policy
actively rewrote their cluster.

Ready status no longer races its own metadata. The index and the status live
in separate atomics and publishReady stores the index first, so a reader
sampling metadata before that store and the index after it reported `ready`
with an empty ObservedCount and no WatchedGroups — a status describing no real
cluster.

Rejected: "a failed GVR list commits a partial Replace, erasing findings".
listPolicyReportsAll is unchanged by this PR, and its premise does not hold —
ListWatched reads local informer indexers with no network path, and for this
caller (namespace "") indexerItems calls idx.List(), which has no error return
at all. There is no transient failure mode to erase anything; the only error
is the nil-cache guard, which fails every GVR alike.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant