@skyhook-io/radar-app 1.11.0 → 1.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/package.json +7 -7
  2. package/src/App.tsx +37 -17
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +244 -39
  5. package/src/api/client.yaml.test.ts +3 -3
  6. package/src/api/version-check.test.ts +78 -0
  7. package/src/components/CloudConnectFlow.tsx +46 -26
  8. package/src/components/CloudFunnelButton.tsx +166 -129
  9. package/src/components/ConnectionErrorView.test.tsx +74 -1
  10. package/src/components/ConnectionErrorView.tsx +22 -20
  11. package/src/components/ContextSwitcher.tsx +10 -13
  12. package/src/components/applications/ApplicationsView.tsx +10 -9
  13. package/src/components/audit/AuditView.tsx +6 -3
  14. package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
  15. package/src/components/audit/UpgradeReadinessView.tsx +22 -12
  16. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  17. package/src/components/capacity/schedulingBar.test.ts +10 -0
  18. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  19. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  20. package/src/components/cost/CostTrendChart.tsx +20 -10
  21. package/src/components/cost/CostView.tsx +79 -28
  22. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  23. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  24. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  25. package/src/components/cost/format.test.ts +27 -8
  26. package/src/components/cost/format.ts +78 -27
  27. package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
  28. package/src/components/gitops/GitOpsView.tsx +8 -3
  29. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  30. package/src/components/helm/OwnedResources.tsx +10 -2
  31. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  32. package/src/components/home/ClusterHealthCard.tsx +63 -1
  33. package/src/components/home/CostCard.tsx +12 -7
  34. package/src/components/home/HomeView.tsx +36 -11
  35. package/src/components/home/MCPSetupDialog.tsx +5 -4
  36. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  37. package/src/components/home/RadarVersionLine.tsx +137 -0
  38. package/src/components/home/mcpToolCatalog.ts +12 -0
  39. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  40. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  41. package/src/components/resources/ResourcesView.tsx +31 -8
  42. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  43. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  44. package/src/components/settings/SettingsDialog.tsx +181 -40
  45. package/src/components/settings/currency-options.test.ts +49 -0
  46. package/src/components/settings/currency-options.ts +38 -0
  47. package/src/components/ui/DiagnosticsOverlay.test.ts +40 -0
  48. package/src/components/ui/DiagnosticsOverlay.tsx +18 -6
  49. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  50. package/src/components/ui/ErrorBoundary.tsx +17 -2
  51. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  52. package/src/components/ui/UpdateNotification.tsx +6 -2
  53. package/src/components/ui/command-items.ts +4 -14
  54. package/src/components/workload/WorkloadView.test.ts +60 -0
  55. package/src/components/workload/WorkloadView.tsx +272 -30
  56. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  57. package/src/contexts/CapabilitiesContext.tsx +7 -3
  58. package/src/main.tsx +4 -114
  59. package/src/utils/context-name.test.ts +63 -0
  60. package/src/utils/context-name.ts +22 -0
  61. package/src/utils/navigation.test.ts +45 -0
  62. package/src/utils/navigation.ts +5 -5
  63. package/src/utils/topology-selection.ts +3 -2
  64. package/src/utils/version.test.ts +37 -0
  65. package/src/utils/version.ts +56 -0
  66. package/src/utils/wails-clipboard.test.ts +109 -0
  67. package/src/utils/wails-clipboard.ts +127 -0
@@ -16,10 +16,12 @@ import {
16
16
  ResourcesView as BaseResourcesView,
17
17
  CORE_RESOURCES,
18
18
  intersectWorkloadWrites,
19
+ hasCuratedColumns,
20
+ sanitizePrinterTable,
19
21
  } from '@skyhook-io/k8s-ui'
20
- import type { Capabilities, ResourceQueryResult, WorkloadWritePermissions } from '@skyhook-io/k8s-ui'
22
+ import type { Capabilities, PrinterTable, ResourceQueryResult, WorkloadWritePermissions } from '@skyhook-io/k8s-ui'
21
23
  import type { SelectedResource } from '../../types'
22
- import { kindToPlural, type NavigateToResource } from '../../utils/navigation'
24
+ import { apiVersionToGroup, kindToPluralWithGroup, type NavigateToResource } from '../../utils/navigation'
23
25
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
24
26
  import { getSkeletonYaml } from '../../utils/skeleton-yaml'
25
27
 
@@ -229,12 +231,19 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
229
231
  // Fetch full data only for the selected kind
230
232
  const selectedKindQuery = useQuery({
231
233
  queryKey: ['resources', selectedKind?.name, isSelectedCrd ? selectedKind?.group : '', namespaces],
232
- queryFn: async () => {
233
- if (!selectedKind) return []
234
+ queryFn: async (): Promise<{ items: any[]; printerTable: PrinterTable | null }> => {
235
+ if (!selectedKind) return { items: [], printerTable: null }
234
236
  const params = new URLSearchParams()
235
237
  if (namespaces.length > 0) params.set('namespaces', namespacesParam)
236
238
  if (isSelectedCrd && selectedKind.group) params.set('group', selectedKind.group)
237
239
  if (selectedKindSummaryServed) params.set('include', 'summary')
240
+ // Only CRDs can declare printer columns, and a curated kind discards the
241
+ // result — so table mode is requested from exactly the kinds that can use
242
+ // it. Resolving a table costs the server a CRD read per request; doing
243
+ // that for a kind whose columns are hand-curated is pure waste.
244
+ const wantsTable = isSelectedCrd && !!selectedKind.group &&
245
+ !hasCuratedColumns(selectedKind.name, selectedKind.group)
246
+ if (wantsTable) params.set('table', '1')
238
247
  const startedAt = performance.now()
239
248
  debugNamespaceLog('resources:selected-kind-fetch-start', {
240
249
  kind: selectedKind.name,
@@ -258,7 +267,19 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
258
267
  const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
259
268
  throw new ApiError(errorData.error || `Failed to fetch ${selectedKind.name}`, res.status, errorData)
260
269
  }
261
- return res.json()
270
+ const body = await res.json()
271
+ // Both branches are current shapes, not a guess at a legacy one: a Radar
272
+ // backend that predates `table` ignores the parameter and answers with
273
+ // the bare array. @skyhook-io/radar-app is versioned independently of the
274
+ // backend it points at, so a consumer can pair a new frontend with an
275
+ // older Radar — and reading that array as a missing envelope would render
276
+ // every CRD list empty. Items and columns still come from one response,
277
+ // so a row can never render against another fetch's cells.
278
+ if (!wantsTable || Array.isArray(body)) return { items: body as any[], printerTable: null }
279
+ return {
280
+ items: Array.isArray(body?.items) ? body.items as any[] : [],
281
+ printerTable: sanitizePrinterTable(body),
282
+ }
262
283
  },
263
284
  enabled: !!selectedKind && !selectedKindQueryBlocked,
264
285
  staleTime: 30000,
@@ -275,13 +296,13 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
275
296
  return {
276
297
  resourceName: selectedKind.name,
277
298
  group: selectedKind.group,
278
- data: selectedKindQueryBlocked ? [] : selectedKindQuery.data as any[] | undefined,
299
+ data: selectedKindQueryBlocked ? [] : selectedKindQuery.data?.items,
279
300
  isLoading: waitingForGuardCount || selectedKindQuery.isLoading,
280
301
  error: selectedKindQueryBlocked ? undefined : selectedKindQuery.error,
281
302
  refetch: selectedKindQuery.refetch,
282
303
  dataUpdatedAt: selectedKindQuery.dataUpdatedAt,
283
304
  }
284
- }, [selectedKind, selectedKindQueryBlocked, waitingForGuardCount, selectedKindQuery.data, selectedKindQuery.isLoading, selectedKindQuery.error, selectedKindQuery.refetch, selectedKindQuery.dataUpdatedAt])
305
+ }, [selectedKind, selectedKindQueryBlocked, waitingForGuardCount, selectedKindQuery.data?.items, selectedKindQuery.isLoading, selectedKindQuery.error, selectedKindQuery.refetch, selectedKindQuery.dataUpdatedAt])
285
306
 
286
307
  // Metrics
287
308
  const { data: topPodMetrics } = useTopPodMetrics({ enabled: topPodMetricsEnabled, namespaces })
@@ -352,6 +373,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
352
373
  resourceReasons={countsData?.reasons}
353
374
  resourceUnavailable={countsData?.unavailable}
354
375
  selectedKindQuery={selectedKindQueryResult}
376
+ printerTable={selectedKindQueryBlocked ? null : selectedKindQuery.data?.printerTable ?? null}
355
377
  connectionState={connection.state}
356
378
  largeListGuard={largeListGuard}
357
379
  onSelectedKindChange={setSelectedKind}
@@ -394,7 +416,8 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
394
416
  initialYaml={createDialogYaml}
395
417
  title={createDialogTitle}
396
418
  onCreated={(result) => {
397
- onResourceClick?.({ kind: kindToPlural(result.kind), namespace: result.namespace, name: result.name, group: '' })
419
+ const group = apiVersionToGroup(result.apiVersion)
420
+ onResourceClick?.({ kind: kindToPluralWithGroup(result.kind, group), namespace: result.namespace, name: result.name, group })
398
421
  }}
399
422
  />
400
423
  </>
@@ -4,8 +4,8 @@ import { useScaleWorkload, fetchJSON } from '../../../api/client'
4
4
  import { useRBACSubject } from '../../../api/rbac'
5
5
  import { usePolicyResource } from '../../../api/policy'
6
6
  import { useQueries, useQueryClient } from '@tanstack/react-query'
7
- import { kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
8
- import type { Relationships, ResourceRef, ResourceWithRelationships } from '../../../types'
7
+ import { kindToPlural, kindToPluralWithGroup } from '@skyhook-io/k8s-ui/utils/navigation'
8
+ import type { Relationships, ResourceRef, ResourceWithRelationships, WorkloadPodInfo } from '../../../types'
9
9
  import type { ScalerDiagnosis } from '@skyhook-io/k8s-ui/components/resources/renderers/WorkloadRenderer'
10
10
 
11
11
  // Map plural lowercase kind to singular PascalCase for ownerReferences matching
@@ -26,9 +26,10 @@ interface WorkloadRendererProps {
26
26
  onNavigate?: (ref: ResourceRef) => void
27
27
  relationships?: Relationships
28
28
  scaleBlockedBy?: ResourceRef[]
29
+ workloadPods?: WorkloadPodInfo[]
29
30
  }
30
31
 
31
- export function WorkloadRenderer({ kind, data, onNavigate, scaleBlockedBy }: WorkloadRendererProps) {
32
+ export function WorkloadRenderer({ kind, data, onNavigate, scaleBlockedBy, workloadPods }: WorkloadRendererProps) {
32
33
  const navigate = useNavigate()
33
34
  const queryClient = useQueryClient()
34
35
  const scaleMutation = useScaleWorkload()
@@ -54,13 +55,19 @@ export function WorkloadRenderer({ kind, data, onNavigate, scaleBlockedBy }: Wor
54
55
  })
55
56
  const hpaQueries = useQueries({
56
57
  queries: hpaRefs.map(ref => ({
57
- queryKey: ['resource', kindToPlural(ref.kind), ref.namespace, ref.name, ref.group],
58
+ queryKey: [
59
+ 'resource',
60
+ kindToPluralWithGroup(ref.kind, ref.group ?? ''),
61
+ ref.namespace,
62
+ ref.name,
63
+ ref.group,
64
+ ],
58
65
  queryFn: () => {
59
66
  const ns = ref.namespace || '_'
60
67
  const params = new URLSearchParams()
61
68
  if (ref.group) params.set('group', ref.group)
62
69
  const query = params.toString()
63
- return fetchJSON<ResourceWithRelationships<any>>(`/resources/${kindToPlural(ref.kind)}/${ns}/${ref.name}${query ? `?${query}` : ''}`)
70
+ return fetchJSON<ResourceWithRelationships<any>>(`/resources/${kindToPluralWithGroup(ref.kind, ref.group ?? '')}/${ns}/${ref.name}${query ? `?${query}` : ''}`)
64
71
  },
65
72
  enabled: Boolean(ref.kind && ref.name),
66
73
  staleTime: 10000,
@@ -90,6 +97,7 @@ export function WorkloadRenderer({ kind, data, onNavigate, scaleBlockedBy }: Wor
90
97
  policyLoading={policyLoading}
91
98
  policyError={policyError as Error | null}
92
99
  scaleBlockedBy={scaleBlockedBy}
100
+ workloadPods={workloadPods}
93
101
  scalerDiagnostics={scalerDiagnostics}
94
102
  onScale={async (replicas) => {
95
103
  await scaleMutation.mutateAsync({
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
2
2
  import { useNavigate, useSearchParams } from 'react-router-dom'
3
- import { AlertTriangle, DollarSign, ExternalLink, Gauge, Loader2, RefreshCw } from 'lucide-react'
3
+ import { AlertTriangle, Coins, ExternalLink, Gauge, Loader2, RefreshCw } from 'lucide-react'
4
4
  import {
5
5
  Collapse,
6
6
  CollapseChevron,
@@ -194,7 +194,7 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
194
194
  <div className="flex-1 min-h-0 overflow-y-auto">
195
195
  <div className="mx-auto flex w-full max-w-[1920px] flex-col gap-4 px-6 py-6">
196
196
  <PageHeader
197
- icon={DollarSign}
197
+ icon={Coins}
198
198
  title="Cost Insights"
199
199
  description="Understand current allocation and find CPU and memory requests worth tuning."
200
200
  />
@@ -3,22 +3,25 @@ import { createPortal } from 'react-dom'
3
3
  import {
4
4
  Settings, X, RotateCcw, RotateCw, Loader2, Copy, Check, Pin, Shield, Lock, Plug,
5
5
  Plus, Terminal, Boxes, Activity, GitBranch, Sparkles, SlidersHorizontal, Zap,
6
- LayoutDashboard, ChevronRight, ExternalLink, Download, AlertTriangle,
6
+ LayoutDashboard, ChevronRight, ExternalLink, Download, AlertTriangle, Coins,
7
7
  type LucideIcon,
8
8
  } from 'lucide-react'
9
9
  import { clsx } from 'clsx'
10
+ import { useQueryClient } from '@tanstack/react-query'
10
11
  import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
11
12
  import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
12
13
  import { apiUrl, getAuthHeaders, getCredentialsMode, routePath } from '../../api/config'
13
14
  import {
14
- useCloudRole, useVersionCheck, useClusterInfo, usePrometheusStatus, useArgoStatus,
15
+ useCloudRole, useVersionCheck, useClusterInfo, usePrometheusStatus, useArgoStatus, useCapabilities,
15
16
  } from '../../api/client'
16
17
  import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
17
- import { Input } from '@skyhook-io/k8s-ui'
18
+ import { Input, SelectMenu } from '@skyhook-io/k8s-ui'
18
19
  import { Tooltip } from '../ui/Tooltip'
19
20
  import { AISettingsSection, type AIDraft } from '../diagnose/AISettings'
20
21
  import { MyPermissionsContent } from './MyPermissionsDialog'
21
22
  import { useDiagnose } from '../diagnose/DiagnoseContext'
23
+ import { currencyOptionsForValue } from './currency-options'
24
+ import { versionUpdateURL } from '../../utils/version'
22
25
 
23
26
  // The loopback URL an MCP client is told to connect to. Shared by the overview
24
27
  // row and the MCP section: both must carry the base path, or the URL they
@@ -39,15 +42,18 @@ interface Config {
39
42
  timelineDbPath?: string
40
43
  historyLimit?: number
41
44
  prometheusUrl?: string
45
+ opencostCurrency?: string
42
46
  argoCdUrl?: string
43
47
  argoCdInsecureTls?: boolean
44
48
  mcp?: boolean | null
49
+ restoreLastDesktopContext?: boolean | null
45
50
  }
46
51
 
47
52
  interface ConfigResponse {
48
53
  file: Config
49
54
  effective: Config
50
55
  isDesktop: boolean
56
+ openCostCurrencyManaged?: boolean
51
57
  prometheusHeaderKeys?: string[]
52
58
  // True when an Argo CD auth token is stored. The token itself is never
53
59
  // returned — the card shows a "configured" placeholder and omits the token
@@ -72,17 +78,19 @@ interface SettingsDialogProps {
72
78
  }
73
79
 
74
80
  // The settings surface splits into three honest apply buckets:
75
- // • Startup config (kubeconfig, server, timeline, MCP) — persisted by the
76
- // owner-gated footer to the config file; effect on next launch.
81
+ // • Persisted config (kubeconfig, server, timeline, MCP, cost currency) —
82
+ // saved by the owner-gated footer. Currency applies live unless a startup
83
+ // flag owns it; the rest restart.
77
84
  // • Live integrations (Prometheus, Argo CD) — their own Apply/Connect endpoints
78
85
  // re-point the running server; effect immediately, NOT part of footer dirty.
79
86
  // • AI diagnose — client-side prefs, self-saving, editable by everyone.
80
87
  export type SettingsSectionId =
81
- | 'overview' | 'perms' | 'connection' | 'prometheus' | 'argocd' | 'ai' | 'advanced'
88
+ | 'overview' | 'perms' | 'connection' | 'prometheus' | 'cost' | 'argocd' | 'ai' | 'advanced'
82
89
 
83
- // Only STARTUP fields count toward footer dirty. Integration fields (prometheusUrl,
84
- // argoCdUrl, argoCdInsecureTls) apply live and are excluded here. Every field is
85
- // normalized so unset≡default doesn't read as a change.
90
+ // Persisted footer fields include startup settings plus the live currency override.
91
+ // Integration fields (prometheusUrl, argoCdUrl, argoCdInsecureTls) apply through
92
+ // their own controls and are excluded here. Every field is normalized so
93
+ // unset≡default doesn't read as a change.
86
94
  function normalizeStartup(c: Config) {
87
95
  return {
88
96
  kubeconfig: c.kubeconfig ?? '',
@@ -95,6 +103,8 @@ function normalizeStartup(c: Config) {
95
103
  timelineDbPath: c.timelineDbPath ?? '',
96
104
  historyLimit: c.historyLimit ?? null,
97
105
  mcp: c.mcp ?? true,
106
+ opencostCurrency: c.opencostCurrency?.trim().toUpperCase() ?? '',
107
+ restoreLastDesktopContext: c.restoreLastDesktopContext ?? true,
98
108
  }
99
109
  }
100
110
 
@@ -103,6 +113,7 @@ export function SettingsDialog({
103
113
  onClose,
104
114
  initialSection = 'overview',
105
115
  }: SettingsDialogProps) {
116
+ const queryClient = useQueryClient()
106
117
  const dialogRef = useRef<HTMLDivElement>(null)
107
118
  const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
108
119
  const { data: versionInfo } = useVersionCheck()
@@ -122,6 +133,9 @@ export function SettingsDialog({
122
133
  const [loadError, setLoadError] = useState<string | null>(null)
123
134
  const [section, setSection] = useState<SettingsSectionId>('overview')
124
135
  const [confirmingClose, setConfirmingClose] = useState(false)
136
+ const { data: argoSectionStatus, refetch: refetchArgoSectionStatus } = useArgoStatus(
137
+ open && section === 'argocd'
138
+ )
125
139
 
126
140
  // AI Diagnosis prefs are client-side (localStorage) and now SELF-SAVING: the
127
141
  // section has its own Save that commits the draft to DiagnoseContext, so it's
@@ -148,7 +162,8 @@ export function SettingsDialog({
148
162
  const clusterDirty =
149
163
  edN.kubeconfig !== svN.kubeconfig ||
150
164
  edN.kubeconfigDirs !== svN.kubeconfigDirs ||
151
- edN.namespace !== svN.namespace
165
+ edN.namespace !== svN.namespace ||
166
+ edN.restoreLastDesktopContext !== svN.restoreLastDesktopContext
152
167
  const serverDirty =
153
168
  edN.port !== svN.port || edN.noBrowser !== svN.noBrowser || edN.browser !== svN.browser
154
169
  const mcpDirty = edN.mcp !== svN.mcp
@@ -156,10 +171,11 @@ export function SettingsDialog({
156
171
  edN.timelineStorage !== svN.timelineStorage ||
157
172
  edN.timelineDbPath !== svN.timelineDbPath ||
158
173
  edN.historyLimit !== svN.historyLimit
174
+ const costDirty = edN.opencostCurrency !== svN.opencostCurrency
159
175
  // Merged-pane dirty for the flat nav (Connection = cluster+server, Advanced = mcp+timeline).
160
176
  const connectionDirty = clusterDirty || serverDirty
161
177
  const advancedDirty = mcpDirty || timelineDirty
162
- const startupDirty = configData != null && (connectionDirty || advancedDirty)
178
+ const configDirty = configData != null && (connectionDirty || costDirty || advancedDirty)
163
179
 
164
180
  // Load config on open + snapshot AI prefs + pick a default section that's
165
181
  // actually accessible to the current identity.
@@ -237,9 +253,27 @@ export function SettingsDialog({
237
253
  setSaveMessage(`Error: ${data?.error || res.statusText}`)
238
254
  return false
239
255
  }
240
- // Advance the committed snapshot so startupDirty settles to false.
241
- setConfigData((prev) => (prev ? { ...prev, file: body } : prev))
242
- setSaveMessage('Saved. Restart Radar to apply.')
256
+ const saved = await res.json() as Config
257
+ const committed = { ...body, opencostCurrency: saved.opencostCurrency }
258
+ setEditedConfig((prev) => ({ ...prev, opencostCurrency: saved.opencostCurrency }))
259
+ setConfigData((prev) => (prev ? { ...prev, file: committed } : prev))
260
+ if (costDirty && !configData.openCostCurrencyManaged) {
261
+ void queryClient.invalidateQueries({
262
+ predicate: (query) =>
263
+ typeof query.queryKey[0] === 'string' && query.queryKey[0].startsWith('opencost-'),
264
+ })
265
+ }
266
+ if (costDirty && configData.openCostCurrencyManaged) {
267
+ setSaveMessage(connectionDirty || advancedDirty
268
+ ? 'Saved. CLI/Helm currency remains active; restart without that override to apply it. Restart Radar for other changes.'
269
+ : 'Saved. CLI/Helm currency remains active; restart without that override to apply this setting.')
270
+ } else {
271
+ setSaveMessage(connectionDirty || advancedDirty
272
+ ? costDirty
273
+ ? 'Saved. Currency applied immediately; restart Radar for other changes.'
274
+ : 'Saved. Restart Radar to apply.'
275
+ : 'Saved. Applied immediately.')
276
+ }
243
277
  return true
244
278
  } catch (err) {
245
279
  setSaveMessage(`Error: ${err}`)
@@ -247,7 +281,7 @@ export function SettingsDialog({
247
281
  } finally {
248
282
  setSaving(false)
249
283
  }
250
- }, [editedConfig, configData])
284
+ }, [editedConfig, configData, costDirty, connectionDirty, advancedDirty, queryClient])
251
285
 
252
286
  // AI prefs are client-side (localStorage) — commit the staged draft now.
253
287
  // setSelectedAgent clears model/effort (they're agent-specific), so set the
@@ -277,24 +311,26 @@ export function SettingsDialog({
277
311
 
278
312
  // Close guard: a pending startup edit prompts an inline confirm rather than
279
313
  // silently discarding. An unsaved AI draft is re-derivable, so it's fine to
280
- // drop it on close. Held in a ref so the ESC listener reads current dirtiness.
314
+ // drop it on close.
281
315
  const requestCloseRef = useRef<() => void>(() => {})
282
316
  requestCloseRef.current = () => {
283
- if (canEditConfig && startupDirty) setConfirmingClose(true)
317
+ if (canEditConfig && configDirty) setConfirmingClose(true)
284
318
  else onClose()
285
319
  }
286
320
 
287
- // ESC key
288
321
  useEffect(() => {
289
322
  if (!open) return
290
- const handleKeyDown = (e: KeyboardEvent) => {
291
- if (e.key === 'Escape') {
292
- e.stopPropagation()
293
- requestCloseRef.current()
294
- }
323
+ const handleDocumentKeyDown = (event: KeyboardEvent) => {
324
+ if (event.key !== 'Escape') return
325
+ const modalDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]'))
326
+ const topDialog = modalDialogs[modalDialogs.length - 1]
327
+ if (topDialog !== dialogRef.current || dialogRef.current?.contains(event.target as Node)) return
328
+ event.preventDefault()
329
+ event.stopPropagation()
330
+ requestCloseRef.current()
295
331
  }
296
- document.addEventListener('keydown', handleKeyDown, true)
297
- return () => document.removeEventListener('keydown', handleKeyDown, true)
332
+ document.addEventListener('keydown', handleDocumentKeyDown, true)
333
+ return () => document.removeEventListener('keydown', handleDocumentKeyDown, true)
298
334
  }, [open])
299
335
 
300
336
  // Post-save feedback ("Saved. Restart Radar to apply.") is scoped to the
@@ -326,12 +362,13 @@ export function SettingsDialog({
326
362
  { id: 'perms', label: 'My permissions', icon: Shield, ownerOnly: false, dirty: false },
327
363
  { id: 'connection', label: 'Connection', icon: Boxes, ownerOnly: true, dirty: connectionDirty },
328
364
  { id: 'prometheus', label: 'Prometheus', icon: Activity, ownerOnly: true, dirty: false },
365
+ { id: 'cost', label: 'Cost', icon: Coins, ownerOnly: true, dirty: costDirty },
329
366
  { id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty: false },
330
367
  { id: 'ai', label: 'AI diagnose', icon: Sparkles, ownerOnly: false, dirty: aiDirty },
331
368
  { id: 'advanced', label: 'Advanced', icon: SlidersHorizontal, ownerOnly: true, dirty: advancedDirty },
332
369
  ]
333
370
 
334
- const showFooter = canEditConfig && (confirmingClose || startupDirty || !!saveMessage)
371
+ const showFooter = canEditConfig && (confirmingClose || configDirty || !!saveMessage)
335
372
 
336
373
  return createPortal(
337
374
  <div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -348,7 +385,16 @@ export function SettingsDialog({
348
385
  {/* Dialog */}
349
386
  <div
350
387
  ref={dialogRef}
388
+ role="dialog"
389
+ aria-modal="true"
390
+ aria-labelledby="settings-dialog-title"
351
391
  tabIndex={-1}
392
+ onKeyDown={(event) => {
393
+ if (event.key !== 'Escape') return
394
+ event.preventDefault()
395
+ event.stopPropagation()
396
+ requestCloseRef.current()
397
+ }}
352
398
  className={clsx(
353
399
  'relative bg-theme-surface border border-theme-border shadow-theme-lg w-full outline-none flex flex-col',
354
400
  'max-sm:inset-0 max-sm:absolute max-sm:rounded-none max-sm:max-h-full max-sm:border-0',
@@ -365,7 +411,7 @@ export function SettingsDialog({
365
411
  <div className="flex items-center gap-2">
366
412
  <Settings className="w-5 h-5 text-theme-text-secondary" />
367
413
  <div className="flex items-baseline gap-2">
368
- <h2 className="text-lg font-semibold text-theme-text-primary">Settings</h2>
414
+ <h2 id="settings-dialog-title" className="text-lg font-semibold text-theme-text-primary">Settings</h2>
369
415
  <span className="text-[11px] text-theme-text-tertiary">
370
416
  Radar{versionInfo?.currentVersion ? ` v${versionInfo.currentVersion}` : ''}
371
417
  <span className="text-theme-text-disabled"> · by Skyhook</span>
@@ -462,6 +508,7 @@ export function SettingsDialog({
462
508
  <ClusterSection
463
509
  config={editedConfig}
464
510
  effectiveConfig={configData?.effective}
511
+ isDesktop={isDesktop}
465
512
  onChange={updateConfigField}
466
513
  />
467
514
  </div>
@@ -498,6 +545,24 @@ export function SettingsDialog({
498
545
  />
499
546
  </SectionPane>
500
547
 
548
+ <SectionPane
549
+ id="cost"
550
+ active={section}
551
+ title="Cost"
552
+ caption={configData?.openCostCurrencyManaged
553
+ ? 'Saved to config. A CLI or Helm override is currently active.'
554
+ : 'Saved to config and applied immediately.'}
555
+ live={!configData?.openCostCurrencyManaged}
556
+ locked={!canEditConfig}
557
+ >
558
+ <CostSection
559
+ currency={editedConfig.opencostCurrency ?? ''}
560
+ managed={configData?.openCostCurrencyManaged ?? false}
561
+ effectiveCurrency={configData?.effective.opencostCurrency ?? ''}
562
+ onChange={(value) => updateConfigField('opencostCurrency', value || undefined)}
563
+ />
564
+ </SectionPane>
565
+
501
566
  {/* Argo CD — live */}
502
567
  <SectionPane
503
568
  id="argocd"
@@ -514,9 +579,14 @@ export function SettingsDialog({
514
579
  envManaged={configData?.argoCdEnvManaged ?? false}
515
580
  envError={configData?.argoCdEnvError}
516
581
  cliSession={configData?.argoCdCliSession}
582
+ statusReason={
583
+ argoSectionStatus?.configured && !argoSectionStatus.connected
584
+ ? argoSectionStatus.reason
585
+ : undefined
586
+ }
517
587
  onChangeUrl={(v) => updateConfigField('argoCdUrl', v || undefined)}
518
588
  onChangeInsecureTls={(v) => updateConfigField('argoCdInsecureTls', v || undefined)}
519
- onApplied={({ url, insecureTls, tokenSet }) =>
589
+ onApplied={({ url, insecureTls, tokenSet }) => {
520
590
  setConfigData((prev) =>
521
591
  prev
522
592
  ? {
@@ -526,7 +596,8 @@ export function SettingsDialog({
526
596
  }
527
597
  : prev
528
598
  )
529
- }
599
+ void refetchArgoSectionStatus()
600
+ }}
530
601
  />
531
602
  </SectionPane>
532
603
 
@@ -609,8 +680,8 @@ export function SettingsDialog({
609
680
  </div>
610
681
  </div>
611
682
 
612
- {/* Footer — owner-gated. Startup config only: AI self-saves, integrations
613
- apply live. Shown whenever a startup edit is pending (any section),
683
+ {/* Footer — owner-gated persisted config. AI self-saves and integrations
684
+ apply separately. Shown whenever an edit is pending (any section),
614
685
  while confirming a close, or briefly after a save. */}
615
686
  <div
616
687
  className={clsx(
@@ -653,7 +724,7 @@ export function SettingsDialog({
653
724
  <Tooltip content="Discard unsaved changes and revert to the last saved values">
654
725
  <button
655
726
  onClick={discardChanges}
656
- disabled={saving || !startupDirty}
727
+ disabled={saving || !configDirty}
657
728
  className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-md transition-colors disabled:opacity-50 disabled:pointer-events-none"
658
729
  >
659
730
  <RotateCcw className="w-3.5 h-3.5" />
@@ -668,7 +739,7 @@ export function SettingsDialog({
668
739
  </div>
669
740
  <button
670
741
  onClick={saveConfig}
671
- disabled={saving || !startupDirty}
742
+ disabled={saving || !configDirty}
672
743
  className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
673
744
  >
674
745
  {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
@@ -841,6 +912,8 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
841
912
  const { data: cluster } = useClusterInfo()
842
913
  const { data: prom } = usePrometheusStatus()
843
914
  const { data: argo } = useArgoStatus(active)
915
+ const { data: capabilitiesData } = useCapabilities()
916
+ const deploymentMode = capabilitiesData ? (capabilitiesData.deployment?.mode ?? 'local') : undefined
844
917
  const { data: version } = useVersionCheck()
845
918
  const capabilities = useCapabilitiesContext()
846
919
  const diag = useDiagnose()
@@ -872,7 +945,7 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
872
945
  // token, not a transient reconnect — "Not reachable" matches Prometheus and
873
946
  // doesn't imply it will recover on its own.
874
947
  value: argo?.connected ? 'Connected' : argo?.configured ? 'Not reachable' : 'Not connected',
875
- detail: argo?.connected ? argo.address : undefined,
948
+ detail: argo?.connected ? argo.address : argo?.reason,
876
949
  },
877
950
  {
878
951
  id: 'advanced', icon: Zap, label: 'MCP',
@@ -897,9 +970,9 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
897
970
 
898
971
  return (
899
972
  <div className="space-y-4">
900
- {version?.updateAvailable && (
973
+ {version?.updateAvailable && deploymentMode !== undefined && deploymentMode !== 'cloud' && (
901
974
  <a
902
- href={version.releaseUrl}
975
+ href={versionUpdateURL(deploymentMode, version.releaseUrl)}
903
976
  target="_blank"
904
977
  rel="noreferrer"
905
978
  className="flex items-center gap-2 px-3 py-2 text-xs rounded-md border border-skyhook-500/30 bg-skyhook-500/10 hover:bg-skyhook-500/15 transition-colors"
@@ -986,25 +1059,30 @@ function AIUnavailableNotice() {
986
1059
  function ClusterSection({
987
1060
  config,
988
1061
  effectiveConfig,
1062
+ isDesktop,
989
1063
  onChange,
990
1064
  }: {
991
1065
  config: Config
992
1066
  effectiveConfig?: Config
1067
+ isDesktop: boolean
993
1068
  onChange: <K extends keyof Config>(field: K, value: Config[K]) => void
994
1069
  }) {
1070
+ const kubeconfigDirs = effectiveConfig ? (effectiveConfig.kubeconfigDirs ?? []) : config.kubeconfigDirs
1071
+ const hasKubeconfigDirs = (kubeconfigDirs?.length ?? 0) > 0
1072
+
995
1073
  return (
996
1074
  <>
997
1075
  <ConfigField
998
- label="Kubeconfig"
999
- help="Path to kubeconfig file"
1076
+ label="Primary Kubeconfig"
1077
+ help="File path loaded before additional directories"
1000
1078
  value={config.kubeconfig ?? ''}
1001
1079
  effectiveValue={effectiveConfig?.kubeconfig}
1002
- placeholder="~/.kube/config"
1080
+ placeholder={hasKubeconfigDirs ? 'No primary file configured' : '~/.kube/config'}
1003
1081
  onChange={(v) => onChange('kubeconfig', v || undefined)}
1004
1082
  />
1005
1083
  <ConfigArrayField
1006
1084
  label="Kubeconfig Directories"
1007
- help="Comma-separated directories containing kubeconfig files"
1085
+ help="Additional kubeconfig directories. Without a primary file, they replace KUBECONFIG"
1008
1086
  value={config.kubeconfigDirs}
1009
1087
  effectiveValue={effectiveConfig?.kubeconfigDirs}
1010
1088
  placeholder="/path/to/dir1, /path/to/dir2"
@@ -1018,6 +1096,14 @@ function ClusterSection({
1018
1096
  placeholder="All namespaces"
1019
1097
  onChange={(v) => onChange('namespace', v || undefined)}
1020
1098
  />
1099
+ {isDesktop && (
1100
+ <ConfigToggle
1101
+ label="Reopen on the last used cluster"
1102
+ description="Come back to the cluster you were working in. Turn off to use your kubeconfig's current context on the next Desktop start."
1103
+ value={config.restoreLastDesktopContext ?? true}
1104
+ onChange={(v) => onChange('restoreLastDesktopContext', v ? undefined : false)}
1105
+ />
1106
+ )}
1021
1107
  </>
1022
1108
  )
1023
1109
  }
@@ -1108,6 +1194,46 @@ function TimelineSection({
1108
1194
  )
1109
1195
  }
1110
1196
 
1197
+ function CostSection({
1198
+ currency,
1199
+ managed,
1200
+ effectiveCurrency,
1201
+ onChange,
1202
+ }: {
1203
+ currency: string
1204
+ managed: boolean
1205
+ effectiveCurrency: string
1206
+ onChange: (value: string) => void
1207
+ }) {
1208
+ return (
1209
+ <div>
1210
+ <label className="mb-1 block text-sm font-medium text-theme-text-primary">
1211
+ Currency override
1212
+ </label>
1213
+ <p className="mb-1 text-xs text-theme-text-tertiary">
1214
+ Choose a currency, or use Auto to read <code>currencyCode</code> or{' '}
1215
+ <code>DISPLAY_CURRENCY</code> from an active OpenCost/Kubecost installation, then fall back
1216
+ to USD. A custom Prometheus URL disables detection. Radar labels values but does not convert
1217
+ them.
1218
+ </p>
1219
+ <SelectMenu
1220
+ value={currency}
1221
+ options={currencyOptionsForValue(currency)}
1222
+ onChange={onChange}
1223
+ ariaLabel="Currency override"
1224
+ searchPlaceholder="Search currencies by name or code"
1225
+ className="w-full"
1226
+ />
1227
+ {managed && (
1228
+ <p className="mt-1 text-xs text-amber-600 dark:text-amber-400/80">
1229
+ Currently managed by CLI or Helm: {effectiveCurrency || 'Auto'}. Saved changes apply
1230
+ after Radar starts without that override.
1231
+ </p>
1232
+ )}
1233
+ </div>
1234
+ )
1235
+ }
1236
+
1111
1237
  // -- MCP Section --------------------------------------------------------------
1112
1238
 
1113
1239
  function MCPSection({
@@ -1440,6 +1566,7 @@ function ArgoCDConfigField({
1440
1566
  envManaged,
1441
1567
  envError,
1442
1568
  cliSession,
1569
+ statusReason,
1443
1570
  onChangeUrl,
1444
1571
  onChangeInsecureTls,
1445
1572
  onApplied,
@@ -1450,6 +1577,7 @@ function ArgoCDConfigField({
1450
1577
  envManaged?: boolean
1451
1578
  envError?: string
1452
1579
  cliSession?: { server: string; user: string; insecure?: boolean }
1580
+ statusReason?: string
1453
1581
  onChangeUrl: (value: string) => void
1454
1582
  onChangeInsecureTls: (value: boolean) => void
1455
1583
  onApplied?: (v: { url: string; insecureTls: boolean; tokenSet: boolean }) => void
@@ -1463,6 +1591,7 @@ function ArgoCDConfigField({
1463
1591
  insecureTls={insecureTls}
1464
1592
  tokenSet={tokenSet}
1465
1593
  cliSession={cliSession}
1594
+ statusReason={statusReason}
1466
1595
  onChangeUrl={onChangeUrl}
1467
1596
  onChangeInsecureTls={onChangeInsecureTls}
1468
1597
  onApplied={onApplied}
@@ -1555,6 +1684,7 @@ function ArgoCDEditableField({
1555
1684
  insecureTls,
1556
1685
  tokenSet,
1557
1686
  cliSession,
1687
+ statusReason,
1558
1688
  onChangeUrl,
1559
1689
  onChangeInsecureTls,
1560
1690
  onApplied,
@@ -1563,6 +1693,7 @@ function ArgoCDEditableField({
1563
1693
  insecureTls: boolean
1564
1694
  tokenSet: boolean
1565
1695
  cliSession?: { server: string; user: string; insecure?: boolean }
1696
+ statusReason?: string
1566
1697
  onChangeUrl: (value: string) => void
1567
1698
  onChangeInsecureTls: (value: boolean) => void
1568
1699
  onApplied?: (v: { url: string; insecureTls: boolean; tokenSet: boolean }) => void
@@ -1665,6 +1796,16 @@ function ArgoCDEditableField({
1665
1796
  back to a lighter annotation-based drift that can miss fields.
1666
1797
  </p>
1667
1798
 
1799
+ {statusReason && state.status !== 'connected' && (
1800
+ <div className="mb-3 rounded-md border border-theme-border bg-theme-elevated p-3">
1801
+ <p className="flex items-center gap-1.5 text-sm font-medium text-warning-text">
1802
+ <AlertTriangle className="w-3.5 h-3.5 shrink-0" />
1803
+ Argo CD token needs attention
1804
+ </p>
1805
+ <p className="mt-1 text-xs text-theme-text-secondary">{statusReason}</p>
1806
+ </div>
1807
+ )}
1808
+
1668
1809
  <label className="block text-sm font-medium text-theme-text-primary mb-1">Server URL</label>
1669
1810
  <p className="text-xs text-theme-text-tertiary mb-1">
1670
1811
  Leave blank to auto-discover the argocd-server in this cluster, or enter its API URL.