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
55 changes: 29 additions & 26 deletions cmd/desktop/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func main() {
// Parse flags (defaults come from config file, falling back to hardcoded values)
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)")
kubecontext := flag.String("context", fileCfg.Context, "Kubeconfig context to start on (default: the last cluster used, then the kubeconfig's current-context)")
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")
Expand Down Expand Up @@ -145,32 +146,34 @@ func main() {
}

cfg := app.AppConfig{
Kubeconfig: *kubeconfig,
KubeconfigDirs: app.ParseKubeconfigDirs(*kubeconfigDir),
Namespace: resolvedNamespace,
Namespaces: resolvedNamespaces,
Port: fileCfg.PortOr(0), // Configured port, or random to avoid conflicts with CLI
ListenAddress: "127.0.0.1",
DevMode: false,
HistoryLimit: *historyLimit,
DebugEvents: *debugEvents,
FakeInCluster: *fakeInCluster,
DisableHelmWrite: *disableHelmWrite,
DisableExec: *disableExec,
PodShellDefault: *podShellDefault,
TimelineStorage: *timelineStorage,
TimelineDBPath: *timelineDBPath,
TimelineRetention: *timelineRetention,
TimelineMaxSizeBytes: timelineMaxSizeBytes,
PrometheusURL: *prometheusURL,
PrometheusHeaders: resolvedPrometheusHeaders,
PrometheusHeadersFromEnv: fileCfg.PrometheusHeadersFromEnv,
Version: version,
HubAPIURL: hubAPIURL,
HubAppURL: hubAppURL,
MCPEnabled: fileCfg.MCPEnabledOr(true),
AIHistory: fileCfg.AIHistoryOr(true),
AIHistoryDBPath: fileCfg.AIHistoryDBPath,
Kubeconfig: *kubeconfig,
KubeconfigDirs: app.ParseKubeconfigDirs(*kubeconfigDir),
Context: *kubecontext,
DisableRestoreLastContext: !fileCfg.RestoreLastContextOr(true),
Namespace: resolvedNamespace,
Namespaces: resolvedNamespaces,
Port: fileCfg.PortOr(0), // Configured port, or random to avoid conflicts with CLI
ListenAddress: "127.0.0.1",
DevMode: false,
HistoryLimit: *historyLimit,
DebugEvents: *debugEvents,
FakeInCluster: *fakeInCluster,
DisableHelmWrite: *disableHelmWrite,
DisableExec: *disableExec,
PodShellDefault: *podShellDefault,
TimelineStorage: *timelineStorage,
TimelineDBPath: *timelineDBPath,
TimelineRetention: *timelineRetention,
TimelineMaxSizeBytes: timelineMaxSizeBytes,
PrometheusURL: *prometheusURL,
PrometheusHeaders: resolvedPrometheusHeaders,
PrometheusHeadersFromEnv: fileCfg.PrometheusHeadersFromEnv,
Version: version,
HubAPIURL: hubAPIURL,
HubAppURL: hubAppURL,
MCPEnabled: fileCfg.MCPEnabledOr(true),
AIHistory: fileCfg.AIHistoryOr(true),
AIHistoryDBPath: fileCfg.AIHistoryDBPath,
}

app.SetGlobals(cfg)
Expand Down
77 changes: 40 additions & 37 deletions cmd/explorer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ func main() {
// Parse flags (defaults come from config file, falling back to hardcoded values)
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)")
kubecontext := flag.String("context", fileCfg.Context, "Kubeconfig context to start on (default: the last cluster used, then the kubeconfig's current-context)")
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")
Expand Down Expand Up @@ -322,43 +323,45 @@ func main() {
}

cfg := app.AppConfig{
Kubeconfig: *kubeconfig,
KubeconfigDirs: app.ParseKubeconfigDirs(*kubeconfigDir),
Namespace: resolvedNamespace,
Namespaces: resolvedNamespaces,
Port: *port,
ListenAddress: normalizedListenAddress,
ShowRemoteAccessHint: true,
BasePath: normalizedBasePath,
NoBrowser: *noBrowser,
Browser: *browser,
DevMode: *devMode,
HistoryLimit: *historyLimit,
DebugEvents: *debugEvents,
FakeInCluster: *fakeInCluster,
DisableHelmWrite: *disableHelmWrite,
DisableExec: *disableExec,
DisableLocalTerminal: *disableLocalTerminal,
PodShellDefault: *podShellDefault,
DebugImage: *debugImage,
ReachabilityImage: *reachabilityImage,
ListPageSize: *listPageSize,
NamespaceScope: *namespaceScope,
TimelineStorage: *timelineStorage,
TimelineDBPath: *timelineDBPath,
TimelineRetention: *timelineRetention,
TimelineMaxSizeBytes: timelineMaxSizeBytes,
PrometheusURL: *prometheusURL,
PrometheusHeaders: resolvedPrometheusHeaders,
PrometheusHeadersFromEnv: promHeadersFromEnv.value(),
BeylaJobSelector: *beylaJobSelector,
MCPEnabled: mcpEnabled,
AIHistory: *aiHistory,
AIHistoryDBPath: fileCfg.AIHistoryDBPath,
Version: version,
HubAPIURL: hubAPIURL,
HubAppURL: hubAppURL,
CloudTunnelConfigured: *cloudURL != "",
Kubeconfig: *kubeconfig,
KubeconfigDirs: app.ParseKubeconfigDirs(*kubeconfigDir),
Context: *kubecontext,
DisableRestoreLastContext: !fileCfg.RestoreLastContextOr(true),
Namespace: resolvedNamespace,
Namespaces: resolvedNamespaces,
Port: *port,
ListenAddress: normalizedListenAddress,
ShowRemoteAccessHint: true,
BasePath: normalizedBasePath,
NoBrowser: *noBrowser,
Browser: *browser,
DevMode: *devMode,
HistoryLimit: *historyLimit,
DebugEvents: *debugEvents,
FakeInCluster: *fakeInCluster,
DisableHelmWrite: *disableHelmWrite,
DisableExec: *disableExec,
DisableLocalTerminal: *disableLocalTerminal,
PodShellDefault: *podShellDefault,
DebugImage: *debugImage,
ReachabilityImage: *reachabilityImage,
ListPageSize: *listPageSize,
NamespaceScope: *namespaceScope,
TimelineStorage: *timelineStorage,
TimelineDBPath: *timelineDBPath,
TimelineRetention: *timelineRetention,
TimelineMaxSizeBytes: timelineMaxSizeBytes,
PrometheusURL: *prometheusURL,
PrometheusHeaders: resolvedPrometheusHeaders,
PrometheusHeadersFromEnv: promHeadersFromEnv.value(),
BeylaJobSelector: *beylaJobSelector,
MCPEnabled: mcpEnabled,
AIHistory: *aiHistory,
AIHistoryDBPath: fileCfg.AIHistoryDBPath,
Version: version,
HubAPIURL: hubAPIURL,
HubAppURL: hubAppURL,
CloudTunnelConfigured: *cloudURL != "",
AuthConfig: auth.Config{
Mode: *authMode,
Secret: *authSecret,
Expand Down
38 changes: 38 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ 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`) |
| `context` | Kubeconfig context to start on (same as `--context`) |
| `restoreLastContext` | Start on the cluster last used (default: enabled). `false` always starts on the kubeconfig's `current-context` — see [Startup Context](#startup-context) |
| `namespace` | Initial namespace filter |
| `namespaces` | Initial namespace filters as a list (same as `--namespaces ns1,ns2,ns3`) |
| `port` | Server port (default 9280) |
Expand Down Expand Up @@ -93,6 +95,7 @@ User preferences for the UI. Managed via the Settings dialog or `PUT /api/settin
|-------|--------|-------------|
| `theme` | `light`, `dark`, `system` | UI theme preference |
| `pinnedKinds` | Array of `{name, kind, group}` | Resource kinds pinned to the sidebar |
| `lastContext` | `{name, sourceFile, inFileName}` | The cluster last switched to, restored on the next start. Written by Radar — see [Startup Context](#startup-context) |

## Cluster Connection Precedence

Expand Down Expand Up @@ -145,6 +148,41 @@ Radar supports switching between Kubernetes contexts at runtime through the UI.

When running in-cluster (using the pod's service account), context switching is disabled.

Switching contexts in the UI never rewrites your kubeconfig — `kubectl` keeps pointing wherever it pointed before.

## Startup Context

Radar starts on the cluster you were last working in. Every context switch is recorded in `~/.radar/settings.json` (as `lastContext`), and the next start reconnects to it.

| Priority | Source | Description |
|----------|--------|-------------|
| 1 | `--context` flag / `"context"` in `config.json` | Explicit context to start on. Also becomes the remembered one, so a later start without the flag stays there |
| 2 | Last used context | The context the previous session was switched to |
| 3 | Kubeconfig `current-context` | The `kubectl` default |

Details worth knowing:

- A remembered context that no longer exists (renamed, deleted, or in a kubeconfig you no longer load) is ignored — Radar falls back to `current-context`.
- The remembered context records the kubeconfig file it came from, not just its name. With several kubeconfigs loaded, two files can define the same context name, and which one keeps the unqualified name depends on the order the files are read — so adding a file could otherwise hand that name to a different cluster. Radar matches the file first and falls back to the name only when that file has moved.
- The same applies to `--context`: a name the kubeconfig doesn't define falls back to `current-context`, and that is what gets remembered — so a typo doesn't leave an unusable name behind. Switch clusters from the header to correct it.
- `--context` is only remembered once the cluster answers. Pointing it at a cluster you can't reach leaves the previous memory intact, so the next plain start still comes up where you were working.
- `radar diagnose --standalone` starts on the remembered cluster too. Pass `radar diagnose ... --standalone --context <name>` to aim it elsewhere.
- If the remembered cluster is unreachable (VPN down, for instance), Radar reports the connection failure rather than silently connecting to a different cluster. Pick another cluster from the header, or pass `--context`.
- Clusters connected through CAPI are never remembered: their kubeconfig is a temporary file that no longer exists on the next run.
- Nothing is remembered, restored, or served over `/api/settings` when auth is enabled or when serving through a cloud tunnel — there the kubeconfig context is shared state, and one user's switch must not steer everyone else's next start.

To always start on the kubeconfig's `current-context`, set in `~/.radar/config.json`:

```json
{
"restoreLastContext": false
}
```

`--context` still works with restore turned off.

Turning it off also stops Radar recording switches, so the memory is frozen at whatever it held. Turning it back on later resumes from that value, not from the cluster you were most recently using.

## Namespace Picker

The header has a namespace picker on the right. Pick a single namespace to focus the view, or **All namespaces** to see everything you have access to. Cluster-scoped resources (Nodes, Namespaces, PVs, StorageClasses) appear regardless of the pick if your RBAC permits them — they have no namespace to filter on. Namespace-restricted users without their own cluster-scoped RBAC won't see cluster-scoped sections at all.
Expand Down
91 changes: 51 additions & 40 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,44 +32,48 @@ var clusterConnectionProbe = k8s.TestClusterConnection

// AppConfig holds all parsed configuration for the Radar application.
type AppConfig struct {
Kubeconfig string
KubeconfigDirs []string
Namespace string
Namespaces []string
Port int
ListenAddress string
ShowRemoteAccessHint bool
BasePath string
NoBrowser bool
Browser string
DevMode bool
HistoryLimit int
DebugEvents bool
FakeInCluster bool
DisableHelmWrite bool
DisableExec bool
DisableLocalTerminal bool
PodShellDefault string
DebugImage string
ReachabilityImage string
ListPageSize int64
NamespaceScope bool
TimelineStorage string
TimelineDBPath string
TimelineRetention time.Duration
TimelineMaxSizeBytes int64
PrometheusURL string
PrometheusHeaders map[string]string
PrometheusHeadersFromEnv map[string]string
BeylaJobSelector string
Version string
MCPEnabled bool
AIHistory bool // persist AI investigations across restarts
AIHistoryDBPath string // "" = ~/.radar/ai-runs.db
AuthConfig auth.Config
HubAPIURL string // Hub API origin override ("" = hosted default)
HubAppURL string // Hub frontend origin override ("" = derived)
CloudTunnelConfigured bool // --cloud-url was set on this process
Kubeconfig string
KubeconfigDirs []string
Context string // --context: kubeconfig context to start on
// DisableRestoreLastContext turns off starting on the last used cluster
// (config.json "restoreLastContext": false). --context is unaffected.
DisableRestoreLastContext bool
Namespace string
Namespaces []string
Port int
ListenAddress string
ShowRemoteAccessHint bool
BasePath string
NoBrowser bool
Browser string
DevMode bool
HistoryLimit int
DebugEvents bool
FakeInCluster bool
DisableHelmWrite bool
DisableExec bool
DisableLocalTerminal bool
PodShellDefault string
DebugImage string
ReachabilityImage string
ListPageSize int64
NamespaceScope bool
TimelineStorage string
TimelineDBPath string
TimelineRetention time.Duration
TimelineMaxSizeBytes int64
PrometheusURL string
PrometheusHeaders map[string]string
PrometheusHeadersFromEnv map[string]string
BeylaJobSelector string
Version string
MCPEnabled bool
AIHistory bool // persist AI investigations across restarts
AIHistoryDBPath string // "" = ~/.radar/ai-runs.db
AuthConfig auth.Config
HubAPIURL string // Hub API origin override ("" = hosted default)
HubAppURL string // Hub frontend origin override ("" = derived)
CloudTunnelConfigured bool // --cloud-url was set on this process
}

// SetGlobals applies debug/test flags to global state.
Expand Down Expand Up @@ -107,8 +111,9 @@ func validateNamespaceFanout(namespaces []string, ctxNs string, maxCandidates in
// InitializeK8s creates and configures the Kubernetes client.
func InitializeK8s(cfg AppConfig) error {
err := k8s.Initialize(k8s.InitOptions{
KubeconfigPath: cfg.Kubeconfig,
KubeconfigDirs: cfg.KubeconfigDirs,
KubeconfigPath: cfg.Kubeconfig,
KubeconfigDirs: cfg.KubeconfigDirs,
PreferredContext: startupContextPreference(cfg),
})
if err != nil {
return fmt.Errorf("failed to initialize K8s client: %w", err)
Expand Down Expand Up @@ -219,6 +224,8 @@ func BuildTimelineStoreConfig(cfg AppConfig) timeline.StoreConfig {
func RegisterCallbacks(cfg AppConfig, timelineStoreCfg timeline.StoreConfig) {
k8s.RegisterHelmFuncs(helm.ResetClient, helm.ReinitClient)

RegisterLastContextMemory(cfg)

k8s.RegisterTimelineFuncs(func() {
// Reset the store AND the per-cluster event-pipeline metrics: RecentDrops
// name resources from the previous cluster and must not survive the switch.
Expand Down Expand Up @@ -262,6 +269,7 @@ func RegisterCallbacks(cfg AppConfig, timelineStoreCfg timeline.StoreConfig) {

// CreateServer creates the HTTP server with the given configuration.
func CreateServer(cfg AppConfig) *server.Server {
restoreLastContext := remembersLastContext(cfg)
effectiveCfg := &config.Config{
Kubeconfig: cfg.Kubeconfig,
KubeconfigDirs: cfg.KubeconfigDirs,
Expand All @@ -280,6 +288,8 @@ func CreateServer(cfg AppConfig) *server.Server {
DebugImage: cfg.DebugImage,
ReachabilityImage: cfg.ReachabilityImage,
MCP: &cfg.MCPEnabled,
Context: cfg.Context,
RestoreLastContext: &restoreLastContext,
}

serverCfg := server.Config{
Expand Down Expand Up @@ -421,6 +431,7 @@ func InitializeCluster() {
return
}
connected.Store(true)
seedLastContextOnConnect()
k8s.LogTiming(" Cluster access check: %v", time.Since(clusterStart))

// Connectivity confirmed — kick off progress updates for remaining init
Expand Down
Loading
Loading