@skyhook-io/radar-app 1.10.0 → 1.12.2

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 (66) hide show
  1. package/package.json +10 -10
  2. package/src/App.tsx +5 -8
  3. package/src/api/client.ts +174 -12
  4. package/src/api/policy.test.ts +38 -0
  5. package/src/api/policy.ts +166 -2
  6. package/src/components/ConnectionErrorView.test.tsx +53 -0
  7. package/src/components/ConnectionErrorView.tsx +15 -12
  8. package/src/components/ContextSwitcher.tsx +10 -13
  9. package/src/components/audit/UpgradeReadinessView.tsx +3 -3
  10. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  11. package/src/components/capacity/schedulingBar.test.ts +10 -0
  12. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  13. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  14. package/src/components/cost/CostTrendChart.tsx +20 -10
  15. package/src/components/cost/CostView.tsx +79 -28
  16. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  17. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  19. package/src/components/cost/format.test.ts +27 -8
  20. package/src/components/cost/format.ts +78 -27
  21. package/src/components/home/ClusterHealthCard.tsx +3 -0
  22. package/src/components/home/CostCard.tsx +12 -7
  23. package/src/components/home/HomeView.tsx +15 -3
  24. package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
  25. package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
  26. package/src/components/home/TopologyPreview.tsx +57 -11
  27. package/src/components/home/mcpToolCatalog.ts +27 -5
  28. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  29. package/src/components/resources/ResourcesView.tsx +15 -3
  30. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
  31. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
  32. package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
  33. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
  34. package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
  36. package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
  37. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
  38. package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
  39. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
  40. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
  41. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
  42. package/src/components/resources/renderers/index.ts +1 -0
  43. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  44. package/src/components/settings/SettingsDialog.tsx +160 -36
  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/traffic/TrafficFilterSidebar.tsx +37 -20
  48. package/src/components/traffic/TrafficFlowList.tsx +16 -2
  49. package/src/components/traffic/TrafficGraph.tsx +150 -58
  50. package/src/components/traffic/TrafficView.tsx +168 -52
  51. package/src/components/traffic/TrafficWizard.tsx +13 -1
  52. package/src/components/traffic/trafficFilters.test.ts +103 -0
  53. package/src/components/traffic/trafficFilters.ts +117 -0
  54. package/src/components/ui/DiagnosticsOverlay.test.ts +115 -0
  55. package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
  56. package/src/components/ui/command-items.ts +4 -14
  57. package/src/components/workload/WorkloadView.tsx +57 -8
  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.ts +44 -2
  62. package/src/utils/network-policy-navigation.test.ts +68 -0
  63. package/src/utils/topology-selection.test.ts +40 -0
  64. package/src/utils/topology-selection.ts +39 -0
  65. package/src/utils/wails-clipboard.test.ts +109 -0
  66. package/src/utils/wails-clipboard.ts +127 -0
@@ -3,10 +3,11 @@ 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'
@@ -14,11 +15,12 @@ import {
14
15
  useCloudRole, useVersionCheck, useClusterInfo, usePrometheusStatus, useArgoStatus,
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'
22
24
 
23
25
  // The loopback URL an MCP client is told to connect to. Shared by the overview
24
26
  // row and the MCP section: both must carry the base path, or the URL they
@@ -39,6 +41,7 @@ interface Config {
39
41
  timelineDbPath?: string
40
42
  historyLimit?: number
41
43
  prometheusUrl?: string
44
+ opencostCurrency?: string
42
45
  argoCdUrl?: string
43
46
  argoCdInsecureTls?: boolean
44
47
  mcp?: boolean | null
@@ -48,6 +51,7 @@ interface ConfigResponse {
48
51
  file: Config
49
52
  effective: Config
50
53
  isDesktop: boolean
54
+ openCostCurrencyManaged?: boolean
51
55
  prometheusHeaderKeys?: string[]
52
56
  // True when an Argo CD auth token is stored. The token itself is never
53
57
  // returned — the card shows a "configured" placeholder and omits the token
@@ -72,17 +76,19 @@ interface SettingsDialogProps {
72
76
  }
73
77
 
74
78
  // 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.
79
+ // • Persisted config (kubeconfig, server, timeline, MCP, cost currency) —
80
+ // saved by the owner-gated footer. Currency applies live unless a startup
81
+ // flag owns it; the rest restart.
77
82
  // • Live integrations (Prometheus, Argo CD) — their own Apply/Connect endpoints
78
83
  // re-point the running server; effect immediately, NOT part of footer dirty.
79
84
  // • AI diagnose — client-side prefs, self-saving, editable by everyone.
80
85
  export type SettingsSectionId =
81
- | 'overview' | 'perms' | 'connection' | 'prometheus' | 'argocd' | 'ai' | 'advanced'
86
+ | 'overview' | 'perms' | 'connection' | 'prometheus' | 'cost' | 'argocd' | 'ai' | 'advanced'
82
87
 
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.
88
+ // Persisted footer fields include startup settings plus the live currency override.
89
+ // Integration fields (prometheusUrl, argoCdUrl, argoCdInsecureTls) apply through
90
+ // their own controls and are excluded here. Every field is normalized so
91
+ // unset≡default doesn't read as a change.
86
92
  function normalizeStartup(c: Config) {
87
93
  return {
88
94
  kubeconfig: c.kubeconfig ?? '',
@@ -95,6 +101,7 @@ function normalizeStartup(c: Config) {
95
101
  timelineDbPath: c.timelineDbPath ?? '',
96
102
  historyLimit: c.historyLimit ?? null,
97
103
  mcp: c.mcp ?? true,
104
+ opencostCurrency: c.opencostCurrency?.trim().toUpperCase() ?? '',
98
105
  }
99
106
  }
100
107
 
@@ -103,6 +110,7 @@ export function SettingsDialog({
103
110
  onClose,
104
111
  initialSection = 'overview',
105
112
  }: SettingsDialogProps) {
113
+ const queryClient = useQueryClient()
106
114
  const dialogRef = useRef<HTMLDivElement>(null)
107
115
  const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
108
116
  const { data: versionInfo } = useVersionCheck()
@@ -122,6 +130,9 @@ export function SettingsDialog({
122
130
  const [loadError, setLoadError] = useState<string | null>(null)
123
131
  const [section, setSection] = useState<SettingsSectionId>('overview')
124
132
  const [confirmingClose, setConfirmingClose] = useState(false)
133
+ const { data: argoSectionStatus, refetch: refetchArgoSectionStatus } = useArgoStatus(
134
+ open && section === 'argocd'
135
+ )
125
136
 
126
137
  // AI Diagnosis prefs are client-side (localStorage) and now SELF-SAVING: the
127
138
  // section has its own Save that commits the draft to DiagnoseContext, so it's
@@ -156,10 +167,11 @@ export function SettingsDialog({
156
167
  edN.timelineStorage !== svN.timelineStorage ||
157
168
  edN.timelineDbPath !== svN.timelineDbPath ||
158
169
  edN.historyLimit !== svN.historyLimit
170
+ const costDirty = edN.opencostCurrency !== svN.opencostCurrency
159
171
  // Merged-pane dirty for the flat nav (Connection = cluster+server, Advanced = mcp+timeline).
160
172
  const connectionDirty = clusterDirty || serverDirty
161
173
  const advancedDirty = mcpDirty || timelineDirty
162
- const startupDirty = configData != null && (connectionDirty || advancedDirty)
174
+ const configDirty = configData != null && (connectionDirty || costDirty || advancedDirty)
163
175
 
164
176
  // Load config on open + snapshot AI prefs + pick a default section that's
165
177
  // actually accessible to the current identity.
@@ -237,9 +249,27 @@ export function SettingsDialog({
237
249
  setSaveMessage(`Error: ${data?.error || res.statusText}`)
238
250
  return false
239
251
  }
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.')
252
+ const saved = await res.json() as Config
253
+ const committed = { ...body, opencostCurrency: saved.opencostCurrency }
254
+ setEditedConfig((prev) => ({ ...prev, opencostCurrency: saved.opencostCurrency }))
255
+ setConfigData((prev) => (prev ? { ...prev, file: committed } : prev))
256
+ if (costDirty && !configData.openCostCurrencyManaged) {
257
+ void queryClient.invalidateQueries({
258
+ predicate: (query) =>
259
+ typeof query.queryKey[0] === 'string' && query.queryKey[0].startsWith('opencost-'),
260
+ })
261
+ }
262
+ if (costDirty && configData.openCostCurrencyManaged) {
263
+ setSaveMessage(connectionDirty || advancedDirty
264
+ ? 'Saved. CLI/Helm currency remains active; restart without that override to apply it. Restart Radar for other changes.'
265
+ : 'Saved. CLI/Helm currency remains active; restart without that override to apply this setting.')
266
+ } else {
267
+ setSaveMessage(connectionDirty || advancedDirty
268
+ ? costDirty
269
+ ? 'Saved. Currency applied immediately; restart Radar for other changes.'
270
+ : 'Saved. Restart Radar to apply.'
271
+ : 'Saved. Applied immediately.')
272
+ }
243
273
  return true
244
274
  } catch (err) {
245
275
  setSaveMessage(`Error: ${err}`)
@@ -247,7 +277,7 @@ export function SettingsDialog({
247
277
  } finally {
248
278
  setSaving(false)
249
279
  }
250
- }, [editedConfig, configData])
280
+ }, [editedConfig, configData, costDirty, connectionDirty, advancedDirty, queryClient])
251
281
 
252
282
  // AI prefs are client-side (localStorage) — commit the staged draft now.
253
283
  // setSelectedAgent clears model/effort (they're agent-specific), so set the
@@ -277,24 +307,26 @@ export function SettingsDialog({
277
307
 
278
308
  // Close guard: a pending startup edit prompts an inline confirm rather than
279
309
  // 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.
310
+ // drop it on close.
281
311
  const requestCloseRef = useRef<() => void>(() => {})
282
312
  requestCloseRef.current = () => {
283
- if (canEditConfig && startupDirty) setConfirmingClose(true)
313
+ if (canEditConfig && configDirty) setConfirmingClose(true)
284
314
  else onClose()
285
315
  }
286
316
 
287
- // ESC key
288
317
  useEffect(() => {
289
318
  if (!open) return
290
- const handleKeyDown = (e: KeyboardEvent) => {
291
- if (e.key === 'Escape') {
292
- e.stopPropagation()
293
- requestCloseRef.current()
294
- }
319
+ const handleDocumentKeyDown = (event: KeyboardEvent) => {
320
+ if (event.key !== 'Escape') return
321
+ const modalDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]'))
322
+ const topDialog = modalDialogs[modalDialogs.length - 1]
323
+ if (topDialog !== dialogRef.current || dialogRef.current?.contains(event.target as Node)) return
324
+ event.preventDefault()
325
+ event.stopPropagation()
326
+ requestCloseRef.current()
295
327
  }
296
- document.addEventListener('keydown', handleKeyDown, true)
297
- return () => document.removeEventListener('keydown', handleKeyDown, true)
328
+ document.addEventListener('keydown', handleDocumentKeyDown, true)
329
+ return () => document.removeEventListener('keydown', handleDocumentKeyDown, true)
298
330
  }, [open])
299
331
 
300
332
  // Post-save feedback ("Saved. Restart Radar to apply.") is scoped to the
@@ -326,12 +358,13 @@ export function SettingsDialog({
326
358
  { id: 'perms', label: 'My permissions', icon: Shield, ownerOnly: false, dirty: false },
327
359
  { id: 'connection', label: 'Connection', icon: Boxes, ownerOnly: true, dirty: connectionDirty },
328
360
  { id: 'prometheus', label: 'Prometheus', icon: Activity, ownerOnly: true, dirty: false },
361
+ { id: 'cost', label: 'Cost', icon: Coins, ownerOnly: true, dirty: costDirty },
329
362
  { id: 'argocd', label: 'Argo CD', icon: GitBranch, ownerOnly: true, dirty: false },
330
363
  { id: 'ai', label: 'AI diagnose', icon: Sparkles, ownerOnly: false, dirty: aiDirty },
331
364
  { id: 'advanced', label: 'Advanced', icon: SlidersHorizontal, ownerOnly: true, dirty: advancedDirty },
332
365
  ]
333
366
 
334
- const showFooter = canEditConfig && (confirmingClose || startupDirty || !!saveMessage)
367
+ const showFooter = canEditConfig && (confirmingClose || configDirty || !!saveMessage)
335
368
 
336
369
  return createPortal(
337
370
  <div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -348,7 +381,16 @@ export function SettingsDialog({
348
381
  {/* Dialog */}
349
382
  <div
350
383
  ref={dialogRef}
384
+ role="dialog"
385
+ aria-modal="true"
386
+ aria-labelledby="settings-dialog-title"
351
387
  tabIndex={-1}
388
+ onKeyDown={(event) => {
389
+ if (event.key !== 'Escape') return
390
+ event.preventDefault()
391
+ event.stopPropagation()
392
+ requestCloseRef.current()
393
+ }}
352
394
  className={clsx(
353
395
  'relative bg-theme-surface border border-theme-border shadow-theme-lg w-full outline-none flex flex-col',
354
396
  'max-sm:inset-0 max-sm:absolute max-sm:rounded-none max-sm:max-h-full max-sm:border-0',
@@ -365,7 +407,7 @@ export function SettingsDialog({
365
407
  <div className="flex items-center gap-2">
366
408
  <Settings className="w-5 h-5 text-theme-text-secondary" />
367
409
  <div className="flex items-baseline gap-2">
368
- <h2 className="text-lg font-semibold text-theme-text-primary">Settings</h2>
410
+ <h2 id="settings-dialog-title" className="text-lg font-semibold text-theme-text-primary">Settings</h2>
369
411
  <span className="text-[11px] text-theme-text-tertiary">
370
412
  Radar{versionInfo?.currentVersion ? ` v${versionInfo.currentVersion}` : ''}
371
413
  <span className="text-theme-text-disabled"> · by Skyhook</span>
@@ -498,6 +540,24 @@ export function SettingsDialog({
498
540
  />
499
541
  </SectionPane>
500
542
 
543
+ <SectionPane
544
+ id="cost"
545
+ active={section}
546
+ title="Cost"
547
+ caption={configData?.openCostCurrencyManaged
548
+ ? 'Saved to config. A CLI or Helm override is currently active.'
549
+ : 'Saved to config and applied immediately.'}
550
+ live={!configData?.openCostCurrencyManaged}
551
+ locked={!canEditConfig}
552
+ >
553
+ <CostSection
554
+ currency={editedConfig.opencostCurrency ?? ''}
555
+ managed={configData?.openCostCurrencyManaged ?? false}
556
+ effectiveCurrency={configData?.effective.opencostCurrency ?? ''}
557
+ onChange={(value) => updateConfigField('opencostCurrency', value || undefined)}
558
+ />
559
+ </SectionPane>
560
+
501
561
  {/* Argo CD — live */}
502
562
  <SectionPane
503
563
  id="argocd"
@@ -514,9 +574,14 @@ export function SettingsDialog({
514
574
  envManaged={configData?.argoCdEnvManaged ?? false}
515
575
  envError={configData?.argoCdEnvError}
516
576
  cliSession={configData?.argoCdCliSession}
577
+ statusReason={
578
+ argoSectionStatus?.configured && !argoSectionStatus.connected
579
+ ? argoSectionStatus.reason
580
+ : undefined
581
+ }
517
582
  onChangeUrl={(v) => updateConfigField('argoCdUrl', v || undefined)}
518
583
  onChangeInsecureTls={(v) => updateConfigField('argoCdInsecureTls', v || undefined)}
519
- onApplied={({ url, insecureTls, tokenSet }) =>
584
+ onApplied={({ url, insecureTls, tokenSet }) => {
520
585
  setConfigData((prev) =>
521
586
  prev
522
587
  ? {
@@ -526,7 +591,8 @@ export function SettingsDialog({
526
591
  }
527
592
  : prev
528
593
  )
529
- }
594
+ void refetchArgoSectionStatus()
595
+ }}
530
596
  />
531
597
  </SectionPane>
532
598
 
@@ -609,8 +675,8 @@ export function SettingsDialog({
609
675
  </div>
610
676
  </div>
611
677
 
612
- {/* Footer — owner-gated. Startup config only: AI self-saves, integrations
613
- apply live. Shown whenever a startup edit is pending (any section),
678
+ {/* Footer — owner-gated persisted config. AI self-saves and integrations
679
+ apply separately. Shown whenever an edit is pending (any section),
614
680
  while confirming a close, or briefly after a save. */}
615
681
  <div
616
682
  className={clsx(
@@ -653,7 +719,7 @@ export function SettingsDialog({
653
719
  <Tooltip content="Discard unsaved changes and revert to the last saved values">
654
720
  <button
655
721
  onClick={discardChanges}
656
- disabled={saving || !startupDirty}
722
+ disabled={saving || !configDirty}
657
723
  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
724
  >
659
725
  <RotateCcw className="w-3.5 h-3.5" />
@@ -668,7 +734,7 @@ export function SettingsDialog({
668
734
  </div>
669
735
  <button
670
736
  onClick={saveConfig}
671
- disabled={saving || !startupDirty}
737
+ disabled={saving || !configDirty}
672
738
  className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
673
739
  >
674
740
  {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
@@ -872,7 +938,7 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
872
938
  // token, not a transient reconnect — "Not reachable" matches Prometheus and
873
939
  // doesn't imply it will recover on its own.
874
940
  value: argo?.connected ? 'Connected' : argo?.configured ? 'Not reachable' : 'Not connected',
875
- detail: argo?.connected ? argo.address : undefined,
941
+ detail: argo?.connected ? argo.address : argo?.reason,
876
942
  },
877
943
  {
878
944
  id: 'advanced', icon: Zap, label: 'MCP',
@@ -992,19 +1058,22 @@ function ClusterSection({
992
1058
  effectiveConfig?: Config
993
1059
  onChange: <K extends keyof Config>(field: K, value: Config[K]) => void
994
1060
  }) {
1061
+ const kubeconfigDirs = effectiveConfig ? (effectiveConfig.kubeconfigDirs ?? []) : config.kubeconfigDirs
1062
+ const hasKubeconfigDirs = (kubeconfigDirs?.length ?? 0) > 0
1063
+
995
1064
  return (
996
1065
  <>
997
1066
  <ConfigField
998
- label="Kubeconfig"
999
- help="Path to kubeconfig file"
1067
+ label="Primary Kubeconfig"
1068
+ help="File path loaded before additional directories"
1000
1069
  value={config.kubeconfig ?? ''}
1001
1070
  effectiveValue={effectiveConfig?.kubeconfig}
1002
- placeholder="~/.kube/config"
1071
+ placeholder={hasKubeconfigDirs ? 'No primary file configured' : '~/.kube/config'}
1003
1072
  onChange={(v) => onChange('kubeconfig', v || undefined)}
1004
1073
  />
1005
1074
  <ConfigArrayField
1006
1075
  label="Kubeconfig Directories"
1007
- help="Comma-separated directories containing kubeconfig files"
1076
+ help="Additional kubeconfig directories. Without a primary file, they replace KUBECONFIG"
1008
1077
  value={config.kubeconfigDirs}
1009
1078
  effectiveValue={effectiveConfig?.kubeconfigDirs}
1010
1079
  placeholder="/path/to/dir1, /path/to/dir2"
@@ -1108,6 +1177,46 @@ function TimelineSection({
1108
1177
  )
1109
1178
  }
1110
1179
 
1180
+ function CostSection({
1181
+ currency,
1182
+ managed,
1183
+ effectiveCurrency,
1184
+ onChange,
1185
+ }: {
1186
+ currency: string
1187
+ managed: boolean
1188
+ effectiveCurrency: string
1189
+ onChange: (value: string) => void
1190
+ }) {
1191
+ return (
1192
+ <div>
1193
+ <label className="mb-1 block text-sm font-medium text-theme-text-primary">
1194
+ Currency override
1195
+ </label>
1196
+ <p className="mb-1 text-xs text-theme-text-tertiary">
1197
+ Choose a currency, or use Auto to read <code>currencyCode</code> or{' '}
1198
+ <code>DISPLAY_CURRENCY</code> from an active OpenCost/Kubecost installation, then fall back
1199
+ to USD. A custom Prometheus URL disables detection. Radar labels values but does not convert
1200
+ them.
1201
+ </p>
1202
+ <SelectMenu
1203
+ value={currency}
1204
+ options={currencyOptionsForValue(currency)}
1205
+ onChange={onChange}
1206
+ ariaLabel="Currency override"
1207
+ searchPlaceholder="Search currencies by name or code"
1208
+ className="w-full"
1209
+ />
1210
+ {managed && (
1211
+ <p className="mt-1 text-xs text-amber-600 dark:text-amber-400/80">
1212
+ Currently managed by CLI or Helm: {effectiveCurrency || 'Auto'}. Saved changes apply
1213
+ after Radar starts without that override.
1214
+ </p>
1215
+ )}
1216
+ </div>
1217
+ )
1218
+ }
1219
+
1111
1220
  // -- MCP Section --------------------------------------------------------------
1112
1221
 
1113
1222
  function MCPSection({
@@ -1440,6 +1549,7 @@ function ArgoCDConfigField({
1440
1549
  envManaged,
1441
1550
  envError,
1442
1551
  cliSession,
1552
+ statusReason,
1443
1553
  onChangeUrl,
1444
1554
  onChangeInsecureTls,
1445
1555
  onApplied,
@@ -1450,6 +1560,7 @@ function ArgoCDConfigField({
1450
1560
  envManaged?: boolean
1451
1561
  envError?: string
1452
1562
  cliSession?: { server: string; user: string; insecure?: boolean }
1563
+ statusReason?: string
1453
1564
  onChangeUrl: (value: string) => void
1454
1565
  onChangeInsecureTls: (value: boolean) => void
1455
1566
  onApplied?: (v: { url: string; insecureTls: boolean; tokenSet: boolean }) => void
@@ -1463,6 +1574,7 @@ function ArgoCDConfigField({
1463
1574
  insecureTls={insecureTls}
1464
1575
  tokenSet={tokenSet}
1465
1576
  cliSession={cliSession}
1577
+ statusReason={statusReason}
1466
1578
  onChangeUrl={onChangeUrl}
1467
1579
  onChangeInsecureTls={onChangeInsecureTls}
1468
1580
  onApplied={onApplied}
@@ -1555,6 +1667,7 @@ function ArgoCDEditableField({
1555
1667
  insecureTls,
1556
1668
  tokenSet,
1557
1669
  cliSession,
1670
+ statusReason,
1558
1671
  onChangeUrl,
1559
1672
  onChangeInsecureTls,
1560
1673
  onApplied,
@@ -1563,6 +1676,7 @@ function ArgoCDEditableField({
1563
1676
  insecureTls: boolean
1564
1677
  tokenSet: boolean
1565
1678
  cliSession?: { server: string; user: string; insecure?: boolean }
1679
+ statusReason?: string
1566
1680
  onChangeUrl: (value: string) => void
1567
1681
  onChangeInsecureTls: (value: boolean) => void
1568
1682
  onApplied?: (v: { url: string; insecureTls: boolean; tokenSet: boolean }) => void
@@ -1665,6 +1779,16 @@ function ArgoCDEditableField({
1665
1779
  back to a lighter annotation-based drift that can miss fields.
1666
1780
  </p>
1667
1781
 
1782
+ {statusReason && state.status !== 'connected' && (
1783
+ <div className="mb-3 rounded-md border border-theme-border bg-theme-elevated p-3">
1784
+ <p className="flex items-center gap-1.5 text-sm font-medium text-warning-text">
1785
+ <AlertTriangle className="w-3.5 h-3.5 shrink-0" />
1786
+ Argo CD token needs attention
1787
+ </p>
1788
+ <p className="mt-1 text-xs text-theme-text-secondary">{statusReason}</p>
1789
+ </div>
1790
+ )}
1791
+
1668
1792
  <label className="block text-sm font-medium text-theme-text-primary mb-1">Server URL</label>
1669
1793
  <p className="text-xs text-theme-text-tertiary mb-1">
1670
1794
  Leave blank to auto-discover the argocd-server in this cluster, or enter its API URL.
@@ -0,0 +1,49 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { CURRENCY_OPTIONS, currencyOptionsForValue } from './currency-options'
3
+
4
+ afterEach(() => {
5
+ vi.restoreAllMocks()
6
+ })
7
+
8
+ describe('currency options', () => {
9
+ it('offers Auto followed by named ISO 4217 currencies', () => {
10
+ expect(CURRENCY_OPTIONS[0]).toEqual({
11
+ value: '',
12
+ label: 'Auto (detect from OpenCost/Kubecost)',
13
+ })
14
+ expect(CURRENCY_OPTIONS).toContainEqual({
15
+ value: 'EUR',
16
+ label: 'Euro (EUR)',
17
+ })
18
+ expect(CURRENCY_OPTIONS).toContainEqual({
19
+ value: 'USD',
20
+ label: 'US Dollar (USD)',
21
+ })
22
+ for (const code of ['MRU', 'SLE', 'UYW', 'VED', 'VES', 'XAD', 'XCG', 'ZWG']) {
23
+ expect(CURRENCY_OPTIONS.some((option) => option.value === code)).toBe(true)
24
+ }
25
+ })
26
+
27
+ it('contains unique uppercase three-letter currency codes', () => {
28
+ const codes = CURRENCY_OPTIONS.slice(1).map((option) => option.value)
29
+ expect(new Set(codes).size).toBe(codes.length)
30
+ expect(codes.every((code) => /^[A-Z]{3}$/.test(code))).toBe(true)
31
+ expect(codes).not.toContain('XTS')
32
+ expect(codes).not.toContain('XXX')
33
+ })
34
+
35
+ it('supplements current codes when browser currency data lags', async () => {
36
+ vi.spyOn(Intl, 'supportedValuesOf').mockReturnValue(['EUR', 'USD'])
37
+ vi.resetModules()
38
+ const { CURRENCY_OPTIONS: options } = await import('./currency-options')
39
+
40
+ for (const code of ['MRU', 'SLE', 'UYW', 'VED', 'VES', 'XAD', 'XCG', 'ZWG']) {
41
+ expect(options.some((option) => option.value === code)).toBe(true)
42
+ }
43
+ })
44
+
45
+ it('shows an existing saved code instead of misrepresenting it as Auto', () => {
46
+ expect(currencyOptionsForValue('EUR')).toBe(CURRENCY_OPTIONS)
47
+ expect(currencyOptionsForValue('ADP')).toContainEqual({ value: 'ADP', label: 'ADP' })
48
+ })
49
+ })
@@ -0,0 +1,38 @@
1
+ import type { SelectMenuOption } from '@skyhook-io/k8s-ui'
2
+
3
+ const currencyNames = new Intl.DisplayNames(['en'], { type: 'currency' })
4
+ const currentISO4217CurrenciesAddedSinceCLDR32: Record<string, string> = {
5
+ MRU: 'Mauritanian Ouguiya',
6
+ SLE: 'Sierra Leonean Leone',
7
+ UYW: 'Uruguayan Nominal Wage Index Unit',
8
+ VED: 'Bolívar Soberano',
9
+ VES: 'Venezuelan Bolívar',
10
+ XAD: 'Arab Accounting Dinar',
11
+ XCG: 'Caribbean Guilder',
12
+ ZWG: 'Zimbabwean Gold',
13
+ }
14
+ const currencyCodes = [
15
+ ...new Set([
16
+ ...Intl.supportedValuesOf('currency'),
17
+ ...Object.keys(currentISO4217CurrenciesAddedSinceCLDR32),
18
+ ]),
19
+ ]
20
+
21
+ export const CURRENCY_OPTIONS: SelectMenuOption[] = [
22
+ { value: '', label: 'Auto (detect from OpenCost/Kubecost)' },
23
+ ...currencyCodes
24
+ .filter((code) => code !== 'XTS' && code !== 'XXX')
25
+ .map((code) => {
26
+ const name = currentISO4217CurrenciesAddedSinceCLDR32[code] ?? currencyNames.of(code)
27
+ return {
28
+ value: code,
29
+ label: name && name !== code ? `${name} (${code})` : code,
30
+ }
31
+ })
32
+ .sort((a, b) => a.label.localeCompare(b.label, 'en')),
33
+ ]
34
+
35
+ export function currencyOptionsForValue(value: string): SelectMenuOption[] {
36
+ if (!value || CURRENCY_OPTIONS.some((option) => option.value === value)) return CURRENCY_OPTIONS
37
+ return [...CURRENCY_OPTIONS, { value, label: value }]
38
+ }