@skyhook-io/radar-app 1.11.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.
- package/package.json +7 -7
- package/src/App.tsx +3 -2
- package/src/api/client.ts +36 -6
- package/src/components/ConnectionErrorView.test.tsx +53 -0
- package/src/components/ConnectionErrorView.tsx +15 -12
- package/src/components/ContextSwitcher.tsx +10 -13
- package/src/components/audit/UpgradeReadinessView.tsx +3 -3
- package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
- package/src/components/capacity/schedulingBar.test.ts +10 -0
- package/src/components/cost/ApplicationCostTab.test.ts +6 -0
- package/src/components/cost/ApplicationCostTab.tsx +28 -16
- package/src/components/cost/CostTrendChart.tsx +20 -10
- package/src/components/cost/CostView.tsx +79 -28
- package/src/components/cost/CurrentAllocationUse.tsx +6 -4
- package/src/components/cost/WorkloadCostTab.test.ts +10 -0
- package/src/components/cost/WorkloadCostTab.tsx +24 -12
- package/src/components/cost/format.test.ts +27 -8
- package/src/components/cost/format.ts +78 -27
- package/src/components/home/ClusterHealthCard.tsx +3 -0
- package/src/components/home/CostCard.tsx +12 -7
- package/src/components/home/mcpToolCatalog.ts +12 -0
- package/src/components/nav/PrimaryNavRail.tsx +2 -2
- package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
- package/src/components/settings/SettingsDialog.tsx +160 -36
- package/src/components/settings/currency-options.test.ts +49 -0
- package/src/components/settings/currency-options.ts +38 -0
- package/src/components/ui/DiagnosticsOverlay.test.ts +40 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +18 -6
- package/src/components/ui/command-items.ts +4 -14
- package/src/components/workload/WorkloadView.tsx +2 -1
- package/src/main.tsx +4 -114
- package/src/utils/context-name.test.ts +63 -0
- package/src/utils/context-name.ts +22 -0
- package/src/utils/wails-clipboard.test.ts +109 -0
- 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
|
-
// •
|
|
76
|
-
// owner-gated footer
|
|
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
|
-
//
|
|
84
|
-
// argoCdUrl, argoCdInsecureTls) apply
|
|
85
|
-
//
|
|
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
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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.
|
|
310
|
+
// drop it on close.
|
|
281
311
|
const requestCloseRef = useRef<() => void>(() => {})
|
|
282
312
|
requestCloseRef.current = () => {
|
|
283
|
-
if (canEditConfig &&
|
|
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
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
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',
|
|
297
|
-
return () => document.removeEventListener('keydown',
|
|
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 ||
|
|
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
|
|
613
|
-
apply
|
|
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 || !
|
|
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 || !
|
|
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 :
|
|
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="
|
|
1067
|
+
label="Primary Kubeconfig"
|
|
1068
|
+
help="File path loaded before additional directories"
|
|
1000
1069
|
value={config.kubeconfig ?? ''}
|
|
1001
1070
|
effectiveValue={effectiveConfig?.kubeconfig}
|
|
1002
|
-
placeholder=
|
|
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="
|
|
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
|
+
}
|
|
@@ -73,3 +73,43 @@ describe('formatForGitHub desktop section', () => {
|
|
|
73
73
|
expect(md).toContain('Desktop: `(unset)`')
|
|
74
74
|
})
|
|
75
75
|
})
|
|
76
|
+
|
|
77
|
+
describe('formatForGitHub kubeconfig section', () => {
|
|
78
|
+
it('reports combined source counts and ignored ambient configuration', () => {
|
|
79
|
+
const md = formatForGitHub({
|
|
80
|
+
...baseSnapshot,
|
|
81
|
+
kubeconfig: {
|
|
82
|
+
mode: 'multi-source',
|
|
83
|
+
fileCount: 3,
|
|
84
|
+
directoryFileCount: 2,
|
|
85
|
+
contextCount: 7,
|
|
86
|
+
enrichedFromShell: true,
|
|
87
|
+
kubeconfigEnvIgnored: true,
|
|
88
|
+
kubeconfigEnvIgnoredReason: 'directories-only configuration',
|
|
89
|
+
currentContextUsesExec: false,
|
|
90
|
+
},
|
|
91
|
+
}, undefined, false)
|
|
92
|
+
|
|
93
|
+
expect(md).toContain('Mode: `multi-source` | Files: 3 | Directory Files: 2 | Contexts (after source resolution): 7')
|
|
94
|
+
expect(md).toContain('KUBECONFIG Captured From Shell: Yes | Ignored: Yes — directories-only configuration')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('omits directory counts for environment path lists', () => {
|
|
98
|
+
const md = formatForGitHub({
|
|
99
|
+
...baseSnapshot,
|
|
100
|
+
kubeconfig: {
|
|
101
|
+
mode: 'multi-env',
|
|
102
|
+
fileCount: 2,
|
|
103
|
+
directoryFileCount: 0,
|
|
104
|
+
contextCount: 4,
|
|
105
|
+
enrichedFromShell: false,
|
|
106
|
+
kubeconfigEnvIgnored: false,
|
|
107
|
+
kubeconfigEnvIgnoredReason: '',
|
|
108
|
+
currentContextUsesExec: true,
|
|
109
|
+
},
|
|
110
|
+
}, undefined, false)
|
|
111
|
+
|
|
112
|
+
expect(md).toContain('Mode: `multi-env` | Files: 2 | Contexts (after source resolution): 4')
|
|
113
|
+
expect(md).not.toContain('Directory Files:')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -171,10 +171,10 @@ function Section({ title, children, warn }: { title: string; children: React.Rea
|
|
|
171
171
|
|
|
172
172
|
function Row({ label, value, warn }: { label: string; value: React.ReactNode; warn?: boolean }) {
|
|
173
173
|
return (
|
|
174
|
-
<div className="flex items-
|
|
174
|
+
<div className="flex items-start justify-between gap-4 text-xs">
|
|
175
175
|
<span className="text-theme-text-secondary shrink-0">{label}</span>
|
|
176
176
|
<span className={clsx(
|
|
177
|
-
'text-right
|
|
177
|
+
'min-w-0 text-right break-words',
|
|
178
178
|
warn ? 'text-yellow-400' : 'text-theme-text-primary'
|
|
179
179
|
)}>{value}</span>
|
|
180
180
|
</div>
|
|
@@ -219,11 +219,19 @@ function KubeconfigSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
|
219
219
|
const present = k.execPluginsPresent ?? []
|
|
220
220
|
const hasMissing = missing.length > 0
|
|
221
221
|
return (
|
|
222
|
-
<Section title="Kubeconfig" warn={hasMissing}>
|
|
222
|
+
<Section title="Kubeconfig" warn={hasMissing || k.kubeconfigEnvIgnored}>
|
|
223
223
|
<Row label="Mode" value={k.mode || '(not initialized)'} />
|
|
224
224
|
<Row label="Files Loaded" value={k.fileCount} />
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
{(k.mode === 'multi-dir' || k.mode === 'multi-source') && (
|
|
226
|
+
<Row label="Directory Files Loaded" value={k.directoryFileCount} />
|
|
227
|
+
)}
|
|
228
|
+
<Row label="Contexts (after source resolution)" value={k.contextCount} />
|
|
229
|
+
<Row label="KUBECONFIG Captured From Shell" value={k.enrichedFromShell ? 'Yes' : 'No'} />
|
|
230
|
+
<Row
|
|
231
|
+
label="KUBECONFIG Ignored"
|
|
232
|
+
value={k.kubeconfigEnvIgnored ? `Yes — ${k.kubeconfigEnvIgnoredReason}` : 'No'}
|
|
233
|
+
warn={k.kubeconfigEnvIgnored}
|
|
234
|
+
/>
|
|
227
235
|
<Row
|
|
228
236
|
label="Current Context Uses Exec"
|
|
229
237
|
value={k.currentContextUsesExec ? 'Yes' : 'No'}
|
|
@@ -631,7 +639,11 @@ export function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIP
|
|
|
631
639
|
if (data.kubeconfig) {
|
|
632
640
|
const k = data.kubeconfig
|
|
633
641
|
lines.push(`### Kubeconfig`)
|
|
634
|
-
|
|
642
|
+
const directoryFiles = k.mode === 'multi-dir' || k.mode === 'multi-source'
|
|
643
|
+
? ` | Directory Files: ${k.directoryFileCount}`
|
|
644
|
+
: ''
|
|
645
|
+
lines.push(`- Mode: \`${k.mode || '(not initialized)'}\` | Files: ${k.fileCount}${directoryFiles} | Contexts (after source resolution): ${k.contextCount}`)
|
|
646
|
+
lines.push(`- KUBECONFIG Captured From Shell: ${k.enrichedFromShell ? 'Yes' : 'No'} | Ignored: ${k.kubeconfigEnvIgnored ? `Yes — ${k.kubeconfigEnvIgnoredReason}` : 'No'}`)
|
|
635
647
|
lines.push(`- Current Context Uses Exec: ${k.currentContextUsesExec ? 'Yes' : 'No'}`)
|
|
636
648
|
if (k.execPluginsPresent && k.execPluginsPresent.length > 0) {
|
|
637
649
|
lines.push(`- Exec Plugins on PATH: \`${k.execPluginsPresent.join('`, `')}\``)
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
Activity,
|
|
9
9
|
Sun,
|
|
10
10
|
Stethoscope,
|
|
11
|
-
|
|
11
|
+
Coins,
|
|
12
12
|
Gauge,
|
|
13
13
|
ShieldCheck,
|
|
14
14
|
GitBranch,
|
|
@@ -19,15 +19,7 @@ import {
|
|
|
19
19
|
import { useNamespaces, useContexts } from "../../api/client";
|
|
20
20
|
import { CORE_RESOURCES, useAPIResources } from "../../api/apiResources";
|
|
21
21
|
import { getResourceIcon } from "../../utils/resource-icons";
|
|
22
|
-
import { parseContextName } from "../../utils/context-name";
|
|
23
|
-
|
|
24
|
-
// Drop the disambiguating " (source)" suffix the context list appends, so the
|
|
25
|
-
// GKE/EKS/AKS parser sees the bare context name (mirrors the cluster picker).
|
|
26
|
-
function stripSourceSuffix(name: string, source?: string): string {
|
|
27
|
-
if (!source) return name;
|
|
28
|
-
const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29
|
-
return name.replace(new RegExp(`\\s+\\(${escaped}(?:\\s+#\\d+)?\\)$`), "");
|
|
30
|
-
}
|
|
22
|
+
import { parseContextForSwitcher, parseContextName } from "../../utils/context-name";
|
|
31
23
|
|
|
32
24
|
export type MainView =
|
|
33
25
|
| "home"
|
|
@@ -151,7 +143,7 @@ const VIEW_ENTRIES: {
|
|
|
151
143
|
{ view: "traffic", label: "Live Traffic", icon: Activity, shortcut: "g f" },
|
|
152
144
|
{ view: "checks", label: "Checks", icon: ShieldCheck, shortcut: "g u" },
|
|
153
145
|
{ view: "capacity", label: "Capacity", icon: Gauge, shortcut: "g p" },
|
|
154
|
-
{ view: "cost", label: "Cost", icon:
|
|
146
|
+
{ view: "cost", label: "Cost", icon: Coins, shortcut: "g c" },
|
|
155
147
|
];
|
|
156
148
|
|
|
157
149
|
// The static command-palette items (Views, Resource Kinds, Contexts,
|
|
@@ -204,9 +196,7 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
|
|
|
204
196
|
// it. Count display names so genuine duplicates (same cluster name from
|
|
205
197
|
// different kubeconfig sources) stay distinguishable; unique ones stay clean.
|
|
206
198
|
const parsedCtx = contexts.map((ctx) => {
|
|
207
|
-
const parsed =
|
|
208
|
-
stripSourceSuffix(ctx.name, ctx.source),
|
|
209
|
-
);
|
|
199
|
+
const parsed = parseContextForSwitcher(ctx);
|
|
210
200
|
const fromCluster = ctx.cluster ? parseContextName(ctx.cluster) : null;
|
|
211
201
|
const meta = [
|
|
212
202
|
parsed.provider ?? fromCluster?.provider,
|