@skyhook-io/radar-app 1.14.6 → 1.16.0

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 (128) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +65 -17
  4. package/src/RadarApp.tsx +51 -21
  5. package/src/api/apiResources.test.ts +19 -1
  6. package/src/api/apiResources.ts +18 -3
  7. package/src/api/client.admission.test.ts +34 -0
  8. package/src/api/client.capacity.test.ts +15 -0
  9. package/src/api/client.trace.test.ts +9 -1
  10. package/src/api/client.ts +269 -204
  11. package/src/api/diagnose.test.ts +102 -1
  12. package/src/api/diagnose.ts +42 -11
  13. package/src/api/drain-plan.test.ts +0 -10
  14. package/src/api/httpErrors.test.ts +95 -0
  15. package/src/api/httpErrors.ts +101 -0
  16. package/src/api/policy.ts +33 -5
  17. package/src/api/radarFeatures.render.test.tsx +190 -0
  18. package/src/api/radarFeatures.test.ts +133 -0
  19. package/src/api/radarFeatures.ts +147 -0
  20. package/src/api/timelineSource.test.ts +2 -10
  21. package/src/api/timelineSource.ts +3 -2
  22. package/src/api/traffic.ts +4 -3
  23. package/src/api/usage-data.fixtures.ts +33 -0
  24. package/src/api/usage-data.ts +165 -0
  25. package/src/components/CloudFunnelButton.test.tsx +2 -2
  26. package/src/components/CloudFunnelButton.tsx +99 -55
  27. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  28. package/src/components/NamespaceSwitcher.tsx +17 -1
  29. package/src/components/SelfManagedStart.tsx +95 -0
  30. package/src/components/applications/ApplicationsView.tsx +20 -6
  31. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  32. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  33. package/src/components/capacity/CapacityView.test.tsx +13 -4
  34. package/src/components/capacity/shared.tsx +14 -13
  35. package/src/components/cloudConnectHandoff.test.ts +1 -1
  36. package/src/components/cloudConnectHandoff.ts +1 -1
  37. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  38. package/src/components/cost/CostConnectionAction.tsx +30 -0
  39. package/src/components/cost/CostTrendChart.tsx +2 -0
  40. package/src/components/cost/CostView.tsx +4 -24
  41. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  42. package/src/components/cost/source.test.ts +30 -3
  43. package/src/components/cost/source.ts +37 -2
  44. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  45. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  46. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  47. package/src/components/dialog-names.test.ts +69 -0
  48. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  49. package/src/components/dock/TerminalTab.tsx +2 -1
  50. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  51. package/src/components/execution/BatchExecutionView.tsx +1 -1
  52. package/src/components/execution/JobSetAdmission.test.tsx +160 -37
  53. package/src/components/execution/JobSetAdmission.tsx +16 -7
  54. package/src/components/gitops/GitOpsView.tsx +12 -0
  55. package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
  56. package/src/components/home/HomeView.tsx +7 -3
  57. package/src/components/home/MCPSetupDialog.tsx +37 -37
  58. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  59. package/src/components/home/RadarVersionLine.tsx +28 -3
  60. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  61. package/src/components/nav/PrimaryNavRail.tsx +25 -3
  62. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  63. package/src/components/resource/PVCUsageBar.tsx +4 -1
  64. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  65. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  66. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  67. package/src/components/resources/PodFilePreview.tsx +4 -1
  68. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  69. package/src/components/resources/ResourcesView.tsx +2 -1
  70. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  71. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  75. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  76. package/src/components/settings/FormSaveActions.tsx +76 -0
  77. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  78. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  79. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  80. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  81. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  82. package/src/components/settings/PrivacySection.test.tsx +84 -0
  83. package/src/components/settings/PrivacySection.tsx +140 -0
  84. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  85. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  86. package/src/components/settings/SettingsDialog.tsx +393 -571
  87. package/src/components/settings/controls.tsx +55 -0
  88. package/src/components/settings/currency-options.test.ts +1 -1
  89. package/src/components/settings/currency-options.ts +1 -1
  90. package/src/components/settings/settings-state.test.ts +17 -10
  91. package/src/components/settings/settings-state.ts +21 -12
  92. package/src/components/timeline/TimelineList.test.ts +56 -0
  93. package/src/components/timeline/TimelineList.tsx +7 -1
  94. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  95. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  96. package/src/components/traffic/TrafficGraph.tsx +79 -48
  97. package/src/components/traffic/TrafficView.tsx +61 -37
  98. package/src/components/traffic/TrafficWizard.tsx +4 -2
  99. package/src/components/traffic/trafficFilters.test.ts +154 -1
  100. package/src/components/traffic/trafficFilters.ts +141 -0
  101. package/src/components/ui/ErrorBoundary.tsx +5 -0
  102. package/src/components/ui/Omnibar.tsx +17 -1
  103. package/src/components/ui/UpdateNotification.tsx +70 -21
  104. package/src/components/ui/command-items.ts +14 -0
  105. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  106. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  107. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  108. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  109. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  110. package/src/components/whats-new/WhatsNew.tsx +423 -0
  111. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  112. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  113. package/src/components/whats-new/releaseNotes.ts +121 -0
  114. package/src/components/workload/WorkloadView.tsx +47 -14
  115. package/src/components/workload/historyPaging.test.ts +72 -0
  116. package/src/components/workload/historyPaging.ts +147 -0
  117. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  118. package/src/context/ConnectionContext.tsx +2 -1
  119. package/src/context/RadarUpgrade.tsx +30 -0
  120. package/src/context/RadarUpgradeHost.tsx +23 -0
  121. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  122. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  123. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  124. package/src/index.ts +1 -0
  125. package/src/k8s-ui-exports.test.ts +73 -0
  126. package/src/utils/navigation.test.ts +17 -1
  127. package/src/utils/navigation.ts +16 -0
  128. package/src/utils/version.ts +11 -0
@@ -36,6 +36,7 @@ import {
36
36
  type RightsizingActionTone,
37
37
  } from './presentation'
38
38
  import { useNavCustomization } from '../../context/NavCustomization'
39
+ import { previousSettingsNote, usePreviousIntegrationSettings } from '../../hooks/usePreviousIntegrationSettings'
39
40
 
40
41
  export const RIGHTSIZING_SCAN_DESCRIPTION =
41
42
  'Find CPU and memory requests to increase, reduce, or review. Radar never changes them.'
@@ -117,6 +118,8 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
117
118
  isLoading: statusLoading,
118
119
  refetch: retryPrometheus,
119
120
  } = usePrometheusStatus()
121
+ const offers = usePreviousIntegrationSettings(!!promStatus && !promStatus.connected && !promStatus.discovering && !statusLoading)
122
+ const previousNote = offers.metrics ? previousSettingsNote('metrics', offers.metrics) : undefined
120
123
  const scan = useRightsizingScan(namespaces, clusterInfo?.context)
121
124
  const result = scan.data
122
125
  const [openRow, setOpenRow] = useState<string | null>(null)
@@ -257,7 +260,7 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
257
260
  <CenteredState
258
261
  title={RIGHTSIZING_METRICS_REQUIRED_TITLE}
259
262
  body={settingsAvailable
260
- ? RIGHTSIZING_METRICS_REQUIRED_BODY
263
+ ? `${RIGHTSIZING_METRICS_REQUIRED_BODY}${previousNote ? `\n${previousNote}` : ''}`
261
264
  : RIGHTSIZING_EMBEDDED_METRICS_REQUIRED_BODY}
262
265
  action={
263
266
  <div className="flex flex-wrap items-center justify-center gap-3">
@@ -0,0 +1,109 @@
1
+ import { Input } from '@skyhook-io/k8s-ui'
2
+
3
+ export interface HeaderOperation {
4
+ key: string
5
+ action: 'keep' | 'set' | 'clear'
6
+ value?: string
7
+ }
8
+
9
+ export function ConnectionHeadersEditor({
10
+ keys,
11
+ environmentKeys,
12
+ value,
13
+ onChange
14
+ }: {
15
+ keys: string[]
16
+ environmentKeys: string[]
17
+ value: HeaderOperation[]
18
+ onChange: (value: HeaderOperation[]) => void
19
+ }) {
20
+ const rows = value.length
21
+ ? value
22
+ : keys.map((key): HeaderOperation => ({ key, action: 'keep' }))
23
+ const update = (index: number, patch: Partial<HeaderOperation>) =>
24
+ onChange(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)))
25
+ return (
26
+ <section className="mt-4 space-y-2">
27
+ <h4 className="text-sm font-medium text-theme-text-primary">
28
+ Authentication headers
29
+ </h4>
30
+ <p className="text-xs text-theme-text-tertiary">
31
+ Optional. Saved values stay unchanged unless you replace or remove them.
32
+ </p>
33
+ {rows.map((row, index) => {
34
+ const saved = index < keys.length
35
+ const environment = saved && environmentKeys.some(
36
+ (key) => key.toLowerCase() === row.key.toLowerCase()
37
+ )
38
+ return (
39
+ <div key={index} className="space-y-1">
40
+ <div className="flex items-center gap-2">
41
+ <Input
42
+ aria-label={`Header ${index + 1} name`}
43
+ value={row.key}
44
+ disabled={saved}
45
+ onChange={(e) => update(index, { key: e.target.value })}
46
+ placeholder="Header name"
47
+ className="min-w-0 flex-1 px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
48
+ />
49
+ <Input
50
+ aria-label={`${row.key || 'New header'} value`}
51
+ type="password"
52
+ autoComplete="new-password"
53
+ disabled={environment || row.action === 'clear'}
54
+ value={row.value ?? ''}
55
+ placeholder={
56
+ row.action === 'clear'
57
+ ? 'Will be removed'
58
+ : environment
59
+ ? 'From environment'
60
+ : saved
61
+ ? 'Saved value'
62
+ : 'Value'
63
+ }
64
+ onChange={(e) =>
65
+ update(index, {
66
+ action: saved && !e.target.value ? 'keep' : 'set',
67
+ value: e.target.value || undefined
68
+ })
69
+ }
70
+ className="min-w-0 flex-1 px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
71
+ />
72
+ {!environment && (
73
+ <button
74
+ type="button"
75
+ className="text-xs text-accent-text hover:underline shrink-0"
76
+ aria-label={`${row.action === 'clear' ? 'Undo removal of' : 'Remove'} ${row.key || 'new header'}`}
77
+ onClick={() =>
78
+ saved
79
+ ? update(index, {
80
+ action: row.action === 'clear' ? 'keep' : 'clear',
81
+ value: undefined
82
+ })
83
+ : onChange(rows.filter((_, i) => i !== index))
84
+ }
85
+ >
86
+ {row.action === 'clear' ? 'Undo' : 'Remove'}
87
+ </button>
88
+ )}
89
+ </div>
90
+ {environment && (
91
+ <p className="text-xs text-theme-text-tertiary">
92
+ Environment reference · edit this header in clusters.json.
93
+ </p>
94
+ )}
95
+ </div>
96
+ )
97
+ })}
98
+ <button
99
+ type="button"
100
+ className="text-xs text-accent-text hover:underline"
101
+ onClick={() =>
102
+ onChange([...rows, { key: '', action: 'set', value: '' }])
103
+ }
104
+ >
105
+ Add header
106
+ </button>
107
+ </section>
108
+ )
109
+ }
@@ -0,0 +1,76 @@
1
+ import { Collapse } from "@skyhook-io/k8s-ui";
2
+ import { Check } from "lucide-react";
3
+
4
+ export interface FormFeedback {
5
+ message: string;
6
+ tone: "success" | "warning" | "info";
7
+ }
8
+
9
+ export function FormSaveActions({
10
+ dirty,
11
+ busy,
12
+ onSave,
13
+ onDiscard,
14
+ feedback,
15
+ error,
16
+ }: {
17
+ dirty: boolean;
18
+ busy: boolean;
19
+ onSave: () => void;
20
+ onDiscard: () => void;
21
+ feedback?: FormFeedback;
22
+ error?: string;
23
+ }) {
24
+ const success = !dirty && !busy && !error && feedback?.tone === "success";
25
+ const detail =
26
+ !busy && (error || (feedback?.tone !== "success" && feedback?.message));
27
+ return (
28
+ <div className="mt-4 border-t border-theme-border pt-4">
29
+ <div className="flex min-h-8 flex-wrap items-center gap-3">
30
+ <button
31
+ type="button"
32
+ disabled={!dirty || busy}
33
+ onClick={onSave}
34
+ className="btn-brand min-w-28 px-3 py-2 text-xs"
35
+ >
36
+ {busy ? "Saving…" : "Save changes"}
37
+ </button>
38
+ {success && (
39
+ <span
40
+ role="status"
41
+ className="flex items-center gap-1.5 text-xs text-theme-text-secondary"
42
+ >
43
+ <Check
44
+ aria-hidden="true"
45
+ className="h-3.5 w-3.5 text-[var(--color-success-dark)] dark:text-[var(--color-success-light)]"
46
+ />
47
+ {feedback.message}
48
+ </span>
49
+ )}
50
+ {dirty && (
51
+ <>
52
+ <button
53
+ type="button"
54
+ disabled={busy}
55
+ onClick={onDiscard}
56
+ className="text-xs text-theme-text-secondary hover:underline"
57
+ >
58
+ Discard
59
+ </button>
60
+ <span className="text-xs text-theme-text-tertiary">
61
+ Unsaved changes
62
+ </span>
63
+ </>
64
+ )}
65
+ </div>
66
+ <Collapse open={!!detail}>
67
+ <p
68
+ role={error ? "alert" : "status"}
69
+ className={`pt-2 text-xs ${error ? "text-semantic-error" : feedback?.tone === "warning" ? "text-warning-text" : "text-theme-text-secondary"}`}
70
+ >
71
+ {detail}
72
+ </p>
73
+ </Collapse>
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,391 @@
1
+ import { useEffect, useId, useState, type ReactNode } from 'react'
2
+ import { Input, Disclosure, SelectMenu } from '@skyhook-io/k8s-ui'
3
+ import { FormSaveActions, type FormFeedback } from './FormSaveActions'
4
+ import { COST_SOURCE_OPTIONS } from '../cost/source'
5
+
6
+ export interface SecretEdit {
7
+ action: 'keep' | 'set' | 'clear'
8
+ value?: string
9
+ }
10
+ export interface ArgoConnectionDraft {
11
+ url: string
12
+ insecureTls: boolean
13
+ secret?: SecretEdit
14
+ useCliToken?: boolean
15
+ }
16
+ export interface CostConnectionDraft {
17
+ url: string
18
+ mode: 'auto' | 'prometheus' | 'kubecost'
19
+ clusterId: string
20
+ secret?: SecretEdit
21
+ }
22
+
23
+ function CredentialField({
24
+ label,
25
+ saved,
26
+ value,
27
+ onChange
28
+ }: {
29
+ label: string
30
+ saved: boolean
31
+ value: SecretEdit
32
+ onChange: (value: SecretEdit) => void
33
+ }) {
34
+ const id = useId()
35
+ return (
36
+ <section className="space-y-2 border-t border-theme-border pt-4">
37
+ <label
38
+ htmlFor={id}
39
+ className="block text-sm font-medium text-theme-text-primary"
40
+ >
41
+ {label}
42
+ </label>
43
+ <div className="flex items-center gap-2">
44
+ <Input
45
+ id={id}
46
+ type="password"
47
+ autoComplete="new-password"
48
+ spellCheck={false}
49
+ value={value.value ?? ''}
50
+ onChange={(e) =>
51
+ onChange({
52
+ action: e.target.value ? 'set' : 'keep',
53
+ value: e.target.value || undefined
54
+ })
55
+ }
56
+ placeholder={
57
+ value.action === 'clear'
58
+ ? 'Will be removed'
59
+ : saved
60
+ ? 'Saved value'
61
+ : 'Optional'
62
+ }
63
+ className="block w-full min-w-0 px-3 py-2 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500 flex-1"
64
+ />
65
+ {saved && (
66
+ <button
67
+ type="button"
68
+ className="text-xs text-accent-text hover:underline"
69
+ onClick={() =>
70
+ onChange(
71
+ value.action === 'keep'
72
+ ? { action: 'clear' }
73
+ : { action: 'keep' }
74
+ )
75
+ }
76
+ >
77
+ {value.action === 'keep' ? 'Remove' : 'Undo'}
78
+ </button>
79
+ )}
80
+ </div>
81
+ <p className="text-xs text-theme-text-tertiary">
82
+ {value.action === 'clear'
83
+ ? 'Will be removed when you save.'
84
+ : 'Leave unchanged to keep the saved value.'}
85
+ </p>
86
+ </section>
87
+ )
88
+ }
89
+
90
+ export function ArgoCDConnectionForm({
91
+ value,
92
+ secretSet,
93
+ cliSession,
94
+ onChange,
95
+ onApply,
96
+ dirty,
97
+ onDiscard,
98
+ connectionAction,
99
+ feedback,
100
+ onDirtyChange
101
+ }: {
102
+ value: ArgoConnectionDraft
103
+ secretSet: boolean
104
+ cliSession?: { server: string; user: string; insecure?: boolean }
105
+ onChange: (value: ArgoConnectionDraft) => void
106
+ onApply: (value: ArgoConnectionDraft) => Promise<void | boolean>
107
+ dirty: boolean
108
+ onDiscard: () => void
109
+ connectionAction?: ReactNode
110
+ feedback?: FormFeedback
111
+ onDirtyChange?: (dirty: boolean) => void
112
+ }) {
113
+ const id = useId()
114
+ const [secret, setSecret] = useState<SecretEdit>({ action: 'keep' })
115
+ const [busy, setBusy] = useState(false)
116
+ const [error, setError] = useState('')
117
+ useEffect(
118
+ () => onDirtyChange?.(secret.action !== 'keep'),
119
+ [secret, onDirtyChange]
120
+ )
121
+ const apply = async (useCliToken = false) => {
122
+ setBusy(true)
123
+ setError('')
124
+ try {
125
+ const applied = await onApply({
126
+ ...value,
127
+ ...(useCliToken ? { useCliToken: true } : { secret })
128
+ })
129
+ if (applied === false) return
130
+ setSecret({ action: 'keep' })
131
+ } catch (error) {
132
+ setError(error instanceof Error ? error.message : String(error))
133
+ } finally {
134
+ setBusy(false)
135
+ }
136
+ }
137
+ return (
138
+ <fieldset disabled={busy} className="min-w-0 space-y-4">
139
+ <p className="text-sm text-theme-text-secondary">
140
+ Compare Git configuration with live resources and show Argo CD health.
141
+ Auto-discovery works without setup where anonymous reads are allowed.
142
+ </p>
143
+ <div className="space-y-1">
144
+ <div className="flex flex-wrap items-center justify-between gap-2">
145
+ <label
146
+ htmlFor={id}
147
+ className="block text-sm font-medium text-theme-text-primary"
148
+ >
149
+ Argo CD server URL
150
+ </label>
151
+ </div>
152
+ <p className="text-xs text-theme-text-tertiary">
153
+ Reachable from Radar. Leave empty to discover the server in this
154
+ cluster.
155
+ </p>
156
+ <Input
157
+ className="block w-full min-w-0 px-3 py-2 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
158
+ id={id}
159
+ value={value.url}
160
+ onChange={(e) => onChange({ ...value, url: e.target.value })}
161
+ placeholder="Auto-discover, or https://argocd.example.com"
162
+ />
163
+ {connectionAction}
164
+ </div>
165
+ <CredentialField
166
+ label="API token"
167
+ saved={secretSet}
168
+ value={secret}
169
+ onChange={setSecret}
170
+ />
171
+ {cliSession && (
172
+ <div className="text-xs text-theme-text-secondary space-y-1">
173
+ <p>
174
+ CLI session available: {cliSession.user} at {cliSession.server}
175
+ </p>
176
+ <button
177
+ type="button"
178
+ disabled={!value.url.trim()}
179
+ onClick={() => void apply(true)}
180
+ className="text-accent-text hover:underline disabled:opacity-50"
181
+ >
182
+ Connect using CLI token
183
+ </button>
184
+ </div>
185
+ )}
186
+ <label className="flex items-center gap-2 text-xs text-theme-text-secondary">
187
+ <input
188
+ type="checkbox"
189
+ checked={value.insecureTls}
190
+ onChange={(e) =>
191
+ onChange({ ...value, insecureTls: e.target.checked })
192
+ }
193
+ />
194
+ Skip TLS verification (self-signed server)
195
+ </label>
196
+ {value.insecureTls && (
197
+ <p className="text-xs text-warning-text">
198
+ Server identity will not be verified. Use only for a trusted server.
199
+ </p>
200
+ )}
201
+ {value.url.startsWith('http://') &&
202
+ (secretSet || secret.action === 'set') && (
203
+ <p className="text-xs text-warning-text">
204
+ Credentials will travel over unencrypted HTTP. Prefer HTTPS outside
205
+ a trusted private network.
206
+ </p>
207
+ )}
208
+ <FormSaveActions
209
+ dirty={dirty}
210
+ busy={busy}
211
+ onSave={() => void apply()}
212
+ onDiscard={onDiscard}
213
+ feedback={feedback}
214
+ error={error}
215
+ />
216
+ </fieldset>
217
+ )
218
+ }
219
+
220
+ export function CostConnectionForm({
221
+ value,
222
+ secretSet,
223
+ onChange,
224
+ onApply,
225
+ dirty,
226
+ onDiscard,
227
+ connectionAction,
228
+ feedback,
229
+ onDirtyChange
230
+ }: {
231
+ value: CostConnectionDraft
232
+ secretSet: boolean
233
+ onChange: (value: CostConnectionDraft) => void
234
+ onApply: (value: CostConnectionDraft) => Promise<void | boolean>
235
+ dirty: boolean
236
+ onDiscard: () => void
237
+ connectionAction?: ReactNode
238
+ feedback?: FormFeedback
239
+ onDirtyChange?: (dirty: boolean) => void
240
+ }) {
241
+ const id = useId()
242
+ const [secret, setSecret] = useState<SecretEdit>({ action: 'keep' })
243
+ const [busy, setBusy] = useState(false)
244
+ const [error, setError] = useState('')
245
+ const [overridesOpen, setOverridesOpen] = useState(
246
+ !!value.url || !!value.clusterId || secretSet
247
+ )
248
+ const automatic = value.mode === 'auto'
249
+ useEffect(
250
+ () => onDirtyChange?.(secret.action !== 'keep'),
251
+ [secret, onDirtyChange]
252
+ )
253
+ const apply = async () => {
254
+ setBusy(true)
255
+ setError('')
256
+ try {
257
+ const applied = await onApply({ ...value, secret })
258
+ if (applied === false) return
259
+ setSecret({ action: 'keep' })
260
+ } catch (error) {
261
+ setError(error instanceof Error ? error.message : String(error))
262
+ setOverridesOpen(true)
263
+ } finally {
264
+ setBusy(false)
265
+ }
266
+ }
267
+ return (
268
+ <fieldset disabled={busy} className="min-w-0 space-y-4">
269
+ <p className="text-sm text-theme-text-secondary">
270
+ Use OpenCost metrics from this cluster's metrics connection, or connect
271
+ to a Kubecost Aggregator.
272
+ </p>
273
+ <div className="space-y-1">
274
+ <div className="flex flex-wrap items-center justify-between gap-2">
275
+ <label
276
+ htmlFor={`${id}-source`}
277
+ className="text-sm font-medium text-theme-text-primary"
278
+ >
279
+ Cost source
280
+ </label>
281
+ </div>
282
+ <SelectMenu
283
+ id={`${id}-source`}
284
+ ariaLabel="Cost source"
285
+ value={value.mode}
286
+ options={COST_SOURCE_OPTIONS}
287
+ onChange={(mode) => {
288
+ if (
289
+ mode === 'auto' &&
290
+ (value.url ||
291
+ value.clusterId ||
292
+ secretSet ||
293
+ secret.action !== 'keep')
294
+ )
295
+ setOverridesOpen(true)
296
+ onChange({
297
+ ...value,
298
+ mode: mode as CostConnectionDraft['mode']
299
+ })
300
+ }}
301
+ className="w-full"
302
+ />
303
+ {(value.mode === 'prometheus' || (automatic && !overridesOpen)) && connectionAction}
304
+ </div>
305
+ {value.mode !== 'prometheus' && (
306
+ <Disclosure
307
+ summary={
308
+ value.url ||
309
+ value.clusterId ||
310
+ secretSet ||
311
+ secret.action !== 'keep'
312
+ ? 'Kubecost connection overrides · configured'
313
+ : 'Kubecost connection overrides (optional)'
314
+ }
315
+ summaryClassName={
316
+ automatic
317
+ ? 'text-sm font-medium text-theme-text-secondary'
318
+ : 'hidden'
319
+ }
320
+ open={!automatic || overridesOpen}
321
+ onOpenChange={setOverridesOpen}
322
+ >
323
+ <div className={automatic ? 'space-y-4 pt-3' : 'space-y-4'}>
324
+ <div className="space-y-1">
325
+ <label
326
+ htmlFor={id}
327
+ className="block text-sm font-medium text-theme-text-primary"
328
+ >
329
+ Kubecost Aggregator URL
330
+ </label>
331
+ <p className="text-xs text-theme-text-tertiary">
332
+ Leave empty for discovery in this cluster. Use the central
333
+ Aggregator URL for a federated setup.
334
+ </p>
335
+ <Input
336
+ className="block w-full min-w-0 px-3 py-2 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
337
+ id={id}
338
+ value={value.url}
339
+ onChange={(e) => onChange({ ...value, url: e.target.value })}
340
+ placeholder="Auto-discover, or https://kubecost.example.com"
341
+ />
342
+ {(!automatic || overridesOpen) && connectionAction}
343
+ </div>
344
+ <CredentialField
345
+ label="API key"
346
+ saved={secretSet}
347
+ value={secret}
348
+ onChange={setSecret}
349
+ />
350
+ {value.url.startsWith('http://') &&
351
+ (secretSet || secret.action === 'set') && (
352
+ <p className="text-xs text-warning-text">
353
+ The API key will travel over unencrypted HTTP. Prefer HTTPS
354
+ outside a trusted private network.
355
+ </p>
356
+ )}
357
+ <section className="space-y-1 border-t border-theme-border pt-4">
358
+ <label
359
+ htmlFor={`${id}-cluster`}
360
+ className="block text-sm font-medium text-theme-text-primary"
361
+ >
362
+ Kubecost cluster ID
363
+ </label>
364
+ <p className="text-xs text-theme-text-tertiary">
365
+ The FinOps Agent's CLUSTER_ID, not the kubeconfig context
366
+ name. Leave empty to detect it. Applies only to this cluster.
367
+ </p>
368
+ <Input
369
+ className="block w-full min-w-0 px-3 py-2 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
370
+ id={`${id}-cluster`}
371
+ value={value.clusterId}
372
+ onChange={(e) =>
373
+ onChange({ ...value, clusterId: e.target.value })
374
+ }
375
+ placeholder="Detected automatically"
376
+ />
377
+ </section>
378
+ </div>
379
+ </Disclosure>
380
+ )}
381
+ <FormSaveActions
382
+ dirty={dirty}
383
+ busy={busy}
384
+ onSave={() => void apply()}
385
+ onDiscard={onDiscard}
386
+ feedback={feedback}
387
+ error={error}
388
+ />
389
+ </fieldset>
390
+ )
391
+ }