Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ radar
| `--kubeconfig` | `~/.kube/config` | Path to kubeconfig file |
| `--kubeconfig-dir` | | Comma-separated directories containing kubeconfig files |
| `--namespace` | (all) | Initial namespace filter (supports multi-select in the UI; also used as RBAC fallback for namespace-scoped users) |
| `--namespaces` | (all) | Initial namespace filters as a comma-separated list, e.g. `--namespaces ns1,ns2,ns3`. Use this when your identity can list resources in specific namespaces but cannot list namespaces cluster-wide. |
| `--namespace-scope` | `false` | Pin namespaced informer caches to a **single** namespace for large clusters (scoping to multiple namespaces is not supported yet). Requires `--namespace`, a kubeconfig context namespace, or a saved local single-namespace pick. Local mode can rebuild the cache when switching namespaces; auth/cloud mode locks the shared cache to the startup namespace. |
| `--port` | `9280` | Server port |
| `--no-browser` | `false` | Don't auto-open browser |
Expand Down
21 changes: 19 additions & 2 deletions cmd/desktop/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func main() {
kubeconfig := flag.String("kubeconfig", fileCfg.Kubeconfig, "Path to kubeconfig file (default: ~/.kube/config)")
kubeconfigDir := flag.String("kubeconfig-dir", fileCfg.KubeconfigDirsFlag(), "Comma-separated directories containing kubeconfig files (mutually exclusive with --kubeconfig)")
namespace := flag.String("namespace", fileCfg.Namespace, "Initial namespace filter (empty = all namespaces)")
namespaces := flag.String("namespaces", fileCfg.NamespacesFlag(), "Initial namespace filters as a comma-separated list (e.g. ns1,ns2,ns3). Use this when you can list resources in specific namespaces but cannot list namespaces cluster-wide.")
showVersion := flag.Bool("version", false, "Show version and exit")
historyLimit := flag.Int("history-limit", fileCfg.HistoryLimitOr(10000), "Maximum number of events to retain in timeline")
debugEvents := flag.Bool("debug-events", false, "Enable verbose event debugging")
Expand Down Expand Up @@ -85,6 +86,16 @@ func main() {
log.Printf("ERROR: --kubeconfig and --kubeconfig-dir are mutually exclusive")
os.Exit(1)
}
namespaceFlagSet := false
namespacesFlagSet := false
flag.Visit(func(f *flag.Flag) {
switch f.Name {
case "namespace":
namespaceFlagSet = true
case "namespaces":
namespacesFlagSet = true
}
})
timelineMaxSizeBytes, err := config.ParseByteSize(*timelineMaxSize)
if err != nil {
log.Printf("ERROR: invalid --timeline-max-size %q: %v", *timelineMaxSize, err)
Expand All @@ -95,11 +106,17 @@ func main() {
log.Printf("ERROR: invalid Prometheus header configuration: %v", err)
os.Exit(1)
}
resolvedNamespace, resolvedNamespaces, err := app.ResolveNamespaceSelection(*namespace, *namespaces, namespaceFlagSet, namespacesFlagSet)
if err != nil {
log.Printf("ERROR: %v", err)
os.Exit(1)
}

cfg := app.AppConfig{
Kubeconfig: *kubeconfig,
KubeconfigDirs: app.ParseKubeconfigDirs(*kubeconfigDir),
Namespace: *namespace,
Namespace: resolvedNamespace,
Namespaces: resolvedNamespaces,
Port: fileCfg.PortOr(0), // Configured port, or random to avoid conflicts with CLI
DevMode: false,
HistoryLimit: *historyLimit,
Expand Down Expand Up @@ -182,7 +199,7 @@ func main() {
WindowStartState: options.Maximised,

AssetServer: &assetserver.Options{
Handler: NewRedirectHandler(srv.ActualAddr(), cfg.Namespace),
Handler: NewRedirectHandler(srv.ActualAddr(), cfg.Namespace, cfg.Namespaces),
},

Menu: createMenu(desktopApp, version),
Expand Down
14 changes: 9 additions & 5 deletions cmd/desktop/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,28 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
)

// RedirectHandler serves a minimal HTML page that redirects the Wails webview
// to the real localhost server. Once the redirect fires, the webview is on a
// real localhost URL — all fetch, SSE, and WebSocket work identically to
// browser mode.
type RedirectHandler struct {
serverAddr string // e.g. "localhost:54321"
namespace string // initial namespace filter (empty = all)
serverAddr string // e.g. "localhost:54321"
namespace string // initial namespace filter (empty = all)
namespaces []string // initial namespace filters from --namespaces
}

func NewRedirectHandler(serverAddr, namespace string) *RedirectHandler {
return &RedirectHandler{serverAddr: serverAddr, namespace: namespace}
func NewRedirectHandler(serverAddr, namespace string, namespaces []string) *RedirectHandler {
return &RedirectHandler{serverAddr: serverAddr, namespace: namespace, namespaces: append([]string(nil), namespaces...)}
}

func (h *RedirectHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
target := "http://" + h.serverAddr
if h.namespace != "" {
if len(h.namespaces) > 0 {
target += "?namespaces=" + url.QueryEscape(strings.Join(h.namespaces, ","))
} else if h.namespace != "" {
target += "?namespace=" + url.QueryEscape(h.namespace)
}

Expand Down
35 changes: 29 additions & 6 deletions cmd/explorer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"log"
"maps"
"net"
neturl "net/url"
"os"
"os/signal"
"sort"
Expand Down Expand Up @@ -49,6 +50,7 @@ func main() {
kubeconfig := flag.String("kubeconfig", fileCfg.Kubeconfig, "Path to kubeconfig file (default: ~/.kube/config)")
kubeconfigDir := flag.String("kubeconfig-dir", fileCfg.KubeconfigDirsFlag(), "Comma-separated directories containing kubeconfig files (mutually exclusive with --kubeconfig)")
namespace := flag.String("namespace", fileCfg.Namespace, "Initial namespace filter (empty = all namespaces)")
namespaces := flag.String("namespaces", fileCfg.NamespacesFlag(), "Initial namespace filters as a comma-separated list (e.g. ns1,ns2,ns3). Use this when you can list resources in specific namespaces but cannot list namespaces cluster-wide.")
port := flag.Int("port", fileCfg.PortOr(9280), "Server port")
noBrowser := flag.Bool("no-browser", fileCfg.NoBrowser, "Don't auto-open browser")
browser := flag.String("browser", fileCfg.Browser, "Browser to use when opening the UI (default: OS default browser; macOS app names supported)")
Expand Down Expand Up @@ -176,9 +178,16 @@ func main() {
log.Fatalf("Invalid --timeline-max-size %q: %v", *timelineMaxSize, err)
}
noMCPFlagSet := false
namespaceFlagSet := false
namespacesFlagSet := false
flag.Visit(func(f *flag.Flag) {
if f.Name == "no-mcp" {
switch f.Name {
case "no-mcp":
noMCPFlagSet = true
case "namespace":
namespaceFlagSet = true
case "namespaces":
namespacesFlagSet = true
}
})
if *mcpCatalogOnly && noMCPFlagSet && *noMCP {
Expand All @@ -191,6 +200,17 @@ func main() {
if err != nil {
log.Fatalf("Invalid Prometheus header configuration: %v", err)
}
resolvedNamespace, resolvedNamespaces, err := app.ResolveNamespaceSelection(*namespace, *namespaces, namespaceFlagSet, namespacesFlagSet)
if err != nil {
log.Fatalf("%v", err)
}
if *namespaceScope && len(resolvedNamespaces) > 1 {
log.Fatalf("--namespace-scope supports a single namespace; use --namespace instead of --namespaces with multiple values")
}
if *namespaceScope && len(resolvedNamespaces) == 1 {
resolvedNamespace = resolvedNamespaces[0]
resolvedNamespaces = nil
}
mcpEnabled := !*noMCP
if *mcpCatalogOnly || *mcpCatalogStdio {
mcpEnabled = true
Expand All @@ -199,7 +219,8 @@ func main() {
cfg := app.AppConfig{
Kubeconfig: *kubeconfig,
KubeconfigDirs: app.ParseKubeconfigDirs(*kubeconfigDir),
Namespace: *namespace,
Namespace: resolvedNamespace,
Namespaces: resolvedNamespaces,
Port: *port,
NoBrowser: *noBrowser,
Browser: *browser,
Expand Down Expand Up @@ -302,11 +323,13 @@ func main() {

// Open browser — server is confirmed ready to accept connections
if !cfg.NoBrowser {
url := fmt.Sprintf("http://localhost:%d", cfg.Port)
if cfg.Namespace != "" {
url += fmt.Sprintf("?namespace=%s", cfg.Namespace)
targetURL := fmt.Sprintf("http://localhost:%d", cfg.Port)
if len(cfg.Namespaces) > 0 {
targetURL += "?namespaces=" + neturl.QueryEscape(strings.Join(cfg.Namespaces, ","))
} else if cfg.Namespace != "" {
targetURL += "?namespace=" + neturl.QueryEscape(cfg.Namespace)
}
go app.OpenBrowser(url, cfg.Browser)
go app.OpenBrowser(targetURL, cfg.Browser)
}

// Now initialize cluster connection and caches (browser will see progress via SSE)
Expand Down
10 changes: 10 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Persistent defaults for CLI flags. CLI flags always override these values. Manag
"kubeconfig": "",
"kubeconfigDirs": [],
"namespace": "",
"namespaces": [],
"port": 9280,
"noBrowser": false,
"browser": "",
Expand All @@ -36,6 +37,7 @@ All fields are optional — omitted fields use built-in defaults.
| `kubeconfig` | Path to kubeconfig file (same as `--kubeconfig`) |
| `kubeconfigDirs` | Directories containing kubeconfig files (same as `--kubeconfig-dir`) |
| `namespace` | Initial namespace filter |
| `namespaces` | Initial namespace filters as a list (same as `--namespaces ns1,ns2,ns3`) |
| `port` | Server port (default 9280) |
| `noBrowser` | Don't auto-open browser |
| `browser` | Browser for automatic launch (same as `--browser`; on macOS, app names like `Google Chrome` are supported) |
Expand Down Expand Up @@ -124,6 +126,14 @@ The header has a namespace picker on the right. Pick a single namespace to focus

The pick is a per-user view filter — it doesn't change anything for other users sharing the same Radar instance. Locally, your pick is remembered per kubeconfig context across restarts. In shared (auth-enabled) deployments the pick lives for the session.

If your account can list resources inside several namespaces but cannot list namespaces cluster-wide, start Radar with an explicit list:

```bash
kubectl radar --namespaces ns1,ns2,ns3
```

Radar uses that list as the initial picker selection and as the RBAC fallback candidate set. The picker can then switch between those namespaces or keep several selected at once.

When Radar starts with `--namespace-scope`, the picker controls the process-wide cache scope instead of just a view filter. Namespaced informer caches are pinned to one namespace while cluster-scoped resources remain cluster-wide. Local/no-auth sessions can switch the scoped namespace, which rebuilds the cache in place. Auth-enabled and Radar Cloud sessions lock the picker to the startup namespace so one user cannot reshape the shared backend cache for everyone.

**Single namespace only.** `--namespace-scope` pins the cache to exactly one namespace; scoping to several namespaces at once is not supported yet. Passing more than one (e.g. `--namespace=a,b`) fails at startup with a clear error rather than silently caching nothing. When scoped, the namespace picker becomes single-select, and a switch re-points the whole cache to the new namespace rather than adding to it.
Expand Down
58 changes: 52 additions & 6 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type AppConfig struct {
Kubeconfig string
KubeconfigDirs []string
Namespace string
Namespaces []string
Port int
NoBrowser bool
Browser string
Expand Down Expand Up @@ -82,7 +83,9 @@ func InitializeK8s(cfg AppConfig) error {
return fmt.Errorf("failed to initialize K8s client: %w", err)
}

if cfg.Namespace != "" {
if len(cfg.Namespaces) > 0 {
k8s.SetFallbackNamespaces(cfg.Namespaces)
} else if cfg.Namespace != "" {
k8s.SetFallbackNamespace(cfg.Namespace)
}
configureNamespaceScopePreferenceResolver(cfg)
Expand Down Expand Up @@ -229,6 +232,7 @@ func CreateServer(cfg AppConfig) *server.Server {
Kubeconfig: cfg.Kubeconfig,
KubeconfigDirs: cfg.KubeconfigDirs,
Namespace: cfg.Namespace,
Namespaces: cfg.Namespaces,
Port: cfg.Port,
NoBrowser: cfg.NoBrowser,
Browser: cfg.Browser,
Expand All @@ -244,11 +248,12 @@ func CreateServer(cfg AppConfig) *server.Server {
}

serverCfg := server.Config{
Port: cfg.Port,
DevMode: cfg.DevMode,
StaticFS: static.FS,
StaticRoot: "dist",
EffectiveConfig: effectiveCfg,
Port: cfg.Port,
DevMode: cfg.DevMode,
StaticFS: static.FS,
StaticRoot: "dist",
EffectiveConfig: effectiveCfg,
ConfiguredNamespaces: cfg.Namespaces,
DiagConfig: &server.DiagConfig{
Port: cfg.Port,
DevMode: cfg.DevMode,
Expand Down Expand Up @@ -559,3 +564,44 @@ func ParseKubeconfigDirs(dirs string) []string {
}
return result
}

// ParseNamespaces splits a comma-separated namespace string into a de-duplicated
// slice. Empty items are ignored so flags like "--namespaces a,,b" behave like
// kubectl's comma-separated lists instead of creating an empty namespace pick.
func ParseNamespaces(namespaces string) []string {
if namespaces == "" {
return nil
}
seen := map[string]struct{}{}
var result []string
for ns := range strings.SplitSeq(namespaces, ",") {
ns = strings.TrimSpace(ns)
if ns == "" {
continue
}
if _, ok := seen[ns]; ok {
continue
}
seen[ns] = struct{}{}
result = append(result, ns)
}
return result
}

// ResolveNamespaceSelection applies CLI/config precedence for --namespace and
// --namespaces. Explicit flags win over config defaults; setting both flags
// explicitly is ambiguous and returns an error.
func ResolveNamespaceSelection(namespace, namespaces string, namespaceSet, namespacesSet bool) (string, []string, error) {
namespace = strings.TrimSpace(namespace)
parsedNamespaces := ParseNamespaces(namespaces)
if namespaceSet && namespacesSet && namespace != "" && len(parsedNamespaces) > 0 {
return "", nil, fmt.Errorf("--namespace and --namespaces are mutually exclusive")
}
if len(parsedNamespaces) > 0 && (namespacesSet || !namespaceSet) {
return "", parsedNamespaces, nil
}
if namespace == "" {
return "", nil, nil
}
return namespace, nil, nil
}
39 changes: 39 additions & 0 deletions internal/app/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,45 @@ func TestValidateNamespaceScopeTarget(t *testing.T) {
}
}

func TestParseNamespaces(t *testing.T) {
got := ParseNamespaces(" team-a,team-b,,team-a , team-c ")
want := []string{"team-a", "team-b", "team-c"}
if len(got) != len(want) {
t.Fatalf("ParseNamespaces length = %d, want %d (%v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("ParseNamespaces[%d] = %q, want %q (all=%v)", i, got[i], want[i], got)
}
}
}

func TestResolveNamespaceSelection(t *testing.T) {
t.Run("namespaces default wins over namespace config", func(t *testing.T) {
ns, nss, err := ResolveNamespaceSelection("legacy", "team-a,team-b", false, false)
if err != nil {
t.Fatalf("ResolveNamespaceSelection: %v", err)
}
if ns != "" || len(nss) != 2 || nss[0] != "team-a" || nss[1] != "team-b" {
t.Fatalf("got namespace=%q namespaces=%v, want namespaces team-a/team-b", ns, nss)
}
})
t.Run("explicit namespace overrides namespaces config", func(t *testing.T) {
ns, nss, err := ResolveNamespaceSelection("prod", "team-a,team-b", true, false)
if err != nil {
t.Fatalf("ResolveNamespaceSelection: %v", err)
}
if ns != "prod" || len(nss) != 0 {
t.Fatalf("got namespace=%q namespaces=%v, want prod only", ns, nss)
}
})
t.Run("explicit conflict", func(t *testing.T) {
if _, _, err := ResolveNamespaceSelection("prod", "team-a,team-b", true, true); err == nil {
t.Fatal("ResolveNamespaceSelection conflict returned nil error")
}
})
}

func TestConfigureNamespaceScopePreferenceResolverUsesSingleSavedLocalPick(t *testing.T) {
t.Setenv("HOME", t.TempDir())
k8s.ResetTestState()
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Config struct {
Kubeconfig string `json:"kubeconfig,omitempty"`
KubeconfigDirs []string `json:"kubeconfigDirs,omitempty"`
Namespace string `json:"namespace,omitempty"`
Namespaces []string `json:"namespaces,omitempty"`
Port int `json:"port,omitempty"`
NoBrowser bool `json:"noBrowser,omitempty"`
Browser string `json:"browser,omitempty"`
Expand Down Expand Up @@ -212,3 +213,9 @@ func ParseByteSize(raw string) (int64, error) {
func (c Config) KubeconfigDirsFlag() string {
return strings.Join(c.KubeconfigDirs, ",")
}

// NamespacesFlag returns Namespaces joined as a comma-separated string
// suitable for use as a flag default value.
func (c Config) NamespacesFlag() string {
return strings.Join(c.Namespaces, ",")
}
14 changes: 14 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func TestSaveAndLoad(t *testing.T) {
Kubeconfig: "/tmp/kubeconfig",
KubeconfigDirs: []string{"/dir1", "/dir2"},
Namespace: "prod",
Namespaces: []string{"dev", "staging"},
Port: 9999,
NoBrowser: true,
Browser: "firefox",
Expand Down Expand Up @@ -71,6 +72,9 @@ func TestSaveAndLoad(t *testing.T) {
if got.Namespace != want.Namespace {
t.Errorf("Namespace = %q, want %q", got.Namespace, want.Namespace)
}
if len(got.Namespaces) != 2 || got.Namespaces[0] != "dev" || got.Namespaces[1] != "staging" {
t.Errorf("Namespaces = %v, want %v", got.Namespaces, want.Namespaces)
}
if got.NoBrowser != want.NoBrowser {
t.Errorf("NoBrowser = %v, want %v", got.NoBrowser, want.NoBrowser)
}
Expand Down Expand Up @@ -224,6 +228,16 @@ func TestHelpers(t *testing.T) {
t.Errorf("got %q, want %q", c.KubeconfigDirsFlag(), "/a,/b")
}
})

t.Run("NamespacesFlag", func(t *testing.T) {
if (Config{}).NamespacesFlag() != "" {
t.Error("nil namespaces should return empty string")
}
c := Config{Namespaces: []string{"dev", "prod"}}
if c.NamespacesFlag() != "dev,prod" {
t.Errorf("got %q, want %q", c.NamespacesFlag(), "dev,prod")
}
})
}

func TestLoadInvalidJSON(t *testing.T) {
Expand Down
Loading
Loading