@skyhook-io/radar-app 1.15.0 → 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 (89) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +14 -11
  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.capacity.test.ts +15 -0
  8. package/src/api/client.trace.test.ts +9 -1
  9. package/src/api/client.ts +232 -201
  10. package/src/api/diagnose.test.ts +102 -1
  11. package/src/api/diagnose.ts +42 -11
  12. package/src/api/drain-plan.test.ts +0 -10
  13. package/src/api/httpErrors.test.ts +95 -0
  14. package/src/api/httpErrors.ts +101 -0
  15. package/src/api/policy.ts +33 -5
  16. package/src/api/radarFeatures.render.test.tsx +190 -0
  17. package/src/api/radarFeatures.test.ts +133 -0
  18. package/src/api/radarFeatures.ts +147 -0
  19. package/src/api/timelineSource.test.ts +2 -10
  20. package/src/api/timelineSource.ts +3 -2
  21. package/src/api/traffic.ts +4 -3
  22. package/src/components/CloudFunnelButton.tsx +9 -9
  23. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  24. package/src/components/NamespaceSwitcher.tsx +17 -1
  25. package/src/components/SelfManagedStart.tsx +5 -6
  26. package/src/components/applications/ApplicationsView.tsx +20 -6
  27. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  28. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  29. package/src/components/capacity/CapacityView.test.tsx +13 -4
  30. package/src/components/capacity/shared.tsx +14 -13
  31. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  32. package/src/components/cost/CostConnectionAction.tsx +30 -0
  33. package/src/components/cost/CostTrendChart.tsx +2 -0
  34. package/src/components/cost/CostView.tsx +4 -24
  35. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  36. package/src/components/cost/source.test.ts +30 -3
  37. package/src/components/cost/source.ts +37 -2
  38. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  39. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  40. package/src/components/dock/TerminalTab.tsx +2 -1
  41. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  42. package/src/components/gitops/GitOpsView.tsx +12 -0
  43. package/src/components/home/HomeView.tsx +1 -1
  44. package/src/components/home/MCPSetupDialog.tsx +37 -37
  45. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  46. package/src/components/resource/PVCUsageBar.tsx +4 -1
  47. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  48. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  49. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  50. package/src/components/resources/PodFilePreview.tsx +4 -1
  51. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  52. package/src/components/resources/ResourcesView.tsx +2 -1
  53. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  54. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  55. package/src/components/settings/FormSaveActions.tsx +76 -0
  56. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  57. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  58. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  59. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  60. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  61. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  62. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  63. package/src/components/settings/SettingsDialog.tsx +382 -521
  64. package/src/components/settings/currency-options.test.ts +1 -1
  65. package/src/components/settings/currency-options.ts +1 -1
  66. package/src/components/settings/settings-state.test.ts +17 -10
  67. package/src/components/settings/settings-state.ts +20 -12
  68. package/src/components/timeline/TimelineList.test.ts +56 -0
  69. package/src/components/timeline/TimelineList.tsx +7 -1
  70. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  71. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  72. package/src/components/traffic/TrafficGraph.tsx +79 -48
  73. package/src/components/traffic/TrafficView.tsx +61 -37
  74. package/src/components/traffic/TrafficWizard.tsx +4 -2
  75. package/src/components/traffic/trafficFilters.test.ts +154 -1
  76. package/src/components/traffic/trafficFilters.ts +141 -0
  77. package/src/components/ui/Omnibar.tsx +1 -1
  78. package/src/components/ui/UpdateNotification.tsx +57 -12
  79. package/src/components/workload/WorkloadView.tsx +36 -13
  80. package/src/components/workload/historyPaging.test.ts +72 -0
  81. package/src/components/workload/historyPaging.ts +147 -0
  82. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  83. package/src/context/ConnectionContext.tsx +2 -1
  84. package/src/context/RadarUpgrade.tsx +30 -0
  85. package/src/context/RadarUpgradeHost.tsx +23 -0
  86. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  87. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  88. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  89. package/src/index.ts +1 -0
@@ -0,0 +1,50 @@
1
+ import { useOpenCostSummary, usePrometheusStatus, type useArgoStatus } from '../../api/client'
2
+ import { costIntegrationUnavailableMessage, costSourceLabel } from '../cost/source'
3
+ import type { IntegrationKind, IntegrationProfile } from './LocalConnectionSettings'
4
+ import { Badge, Collapse } from '@skyhook-io/k8s-ui'
5
+ import { useRef } from 'react'
6
+
7
+ export function LocalIntegrationStatus({ kind, profile, argo, busy, changedAt }: {
8
+ kind: IntegrationKind
9
+ profile: IntegrationProfile
10
+ argo: ReturnType<typeof useArgoStatus>
11
+ busy: boolean
12
+ changedAt: number
13
+ }) {
14
+ const prom = usePrometheusStatus(kind === 'metrics')
15
+ const cost = useOpenCostSummary(kind === 'cost')
16
+ const query = kind === 'metrics' ? prom : kind === 'cost' ? cost : argo
17
+ // Background polls keep the last status; only the first load, or a result
18
+ // older than the latest saved integration change, reads as "Checking…".
19
+ const checking = busy || query.isPlaceholderData || (query.isFetching && !query.data) ||
20
+ Math.max(query.dataUpdatedAt, query.errorUpdatedAt) < changedAt
21
+ let status = 'Checking…'
22
+ let detail: string | undefined
23
+ if (!checking) {
24
+ if (query.isError) status = 'Status unavailable'
25
+ else if (kind === 'metrics' && prom.data) {
26
+ status = prom.data.connected ? 'Connected' : prom.data.discovering ? 'Discovering…' : 'Not connected'
27
+ detail = prom.data.connected ? !profile.url ? `Discovered: ${prom.data.address}` : undefined : prom.data.error
28
+ } else if (kind === 'argocd' && argo.data) {
29
+ status = argo.data.connected ? argo.data.anonymous ? 'Connected · no token needed' : 'Connected' : 'Not connected'
30
+ detail = argo.data.connected ? !profile.url ? `Discovered: ${argo.data.address}` : undefined : argo.data.reason
31
+ } else if (kind === 'cost' && cost.data) {
32
+ status = cost.data.available ? `Available · ${costSourceLabel(cost.data.source)}`
33
+ : ['no_prometheus', 'no_cost_source', 'no_metrics'].includes(cost.data.reason ?? '') ? 'No cost data' : 'Unavailable'
34
+ detail = cost.data.reason ? costIntegrationUnavailableMessage(cost.data.reason)
35
+ ?? (cost.data.reason === 'query_error' ? 'The active cost source query failed.' : undefined) : undefined
36
+ }
37
+ }
38
+ const lastDetail = useRef({ text: detail, visible: !!detail })
39
+ if (!checking) lastDetail.current = { text: detail ?? lastDetail.current.text, visible: !!detail }
40
+ const configured = !!(profile.url || profile.secretSet || profile.headerKeys.length || profile.clusterId || profile.mode === 'prometheus')
41
+ const failed = !checking && (query.isError || configured && ['Not connected', 'Unavailable', 'No cost data'].includes(status))
42
+ return (
43
+ <div role="group" className="contents text-xs text-theme-text-secondary" aria-label="Current connection">
44
+ <Badge severity={status.startsWith('Connected') || status.startsWith('Available') ? 'success' : failed ? 'warning' : 'neutral'}>{status}</Badge>
45
+ <Collapse open={lastDetail.current.visible} className="basis-full min-w-0">
46
+ <p className={`break-words text-theme-text-tertiary ${checking ? 'invisible' : ''}`}>{lastDetail.current.text}</p>
47
+ </Collapse>
48
+ </div>
49
+ )
50
+ }
@@ -0,0 +1,30 @@
1
+ import { useEffect, useRef, type ComponentProps } from 'react'
2
+ import { apiUrl, getApiBase, getAuthHeaders, getCredentialsMode } from '../../api/config'
3
+ import { PrometheusConnectionForm, type PrometheusApplyResult } from './PrometheusConnectionForm'
4
+
5
+ export function PrometheusConfigField({ onBusyChange, ...props }: Omit<ComponentProps<typeof PrometheusConnectionForm>, 'onApply'> & { onBusyChange?: (busy: boolean) => void }) {
6
+ const request = useRef<AbortController | null>(null)
7
+ useEffect(() => () => request.current?.abort(), [])
8
+ useEffect(() => () => onBusyChange?.(false), [onBusyChange])
9
+ const apply = async (url: string, headers?: Record<string, string>): Promise<PrometheusApplyResult> => {
10
+ if (request.current) throw new Error('A connection update is already in progress.')
11
+ const controller = new AbortController(); request.current = controller
12
+ const base = getApiBase()
13
+ onBusyChange?.(true)
14
+ try {
15
+ const response = await fetch(apiUrl('/integrations/prometheus'), {
16
+ method: 'PUT', signal: controller.signal, credentials: getCredentialsMode(),
17
+ headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
18
+ body: JSON.stringify({ prometheusUrl: url, ...(headers !== undefined ? { headers } : {}) }),
19
+ })
20
+ const data = await response.json().catch(() => ({})) as PrometheusApplyResult
21
+ if (controller.signal.aborted || base !== getApiBase()) throw new Error('Cluster changed; reload Settings.')
22
+ if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`)
23
+ return data
24
+ } finally {
25
+ if (request.current === controller) request.current = null
26
+ if (!controller.signal.aborted) onBusyChange?.(false)
27
+ }
28
+ }
29
+ return <PrometheusConnectionForm {...props} onApply={apply} />
30
+ }
@@ -0,0 +1,295 @@
1
+ import { useEffect, useRef, useState, useId, type ReactNode } from 'react'
2
+ import { Check, Info, Loader2, Plug, Plus, X } from 'lucide-react'
3
+ import { Collapse, Input } from '@skyhook-io/k8s-ui'
4
+ import { Tooltip } from '../ui/Tooltip'
5
+ import { prometheusHeadersFromRows } from './settings-state'
6
+ import { ConnectionHeadersEditor, type HeaderOperation } from './ConnectionHeadersEditor'
7
+ import { FormSaveActions, type FormFeedback } from './FormSaveActions'
8
+
9
+ export interface PrometheusApplyResult { connected: boolean; address?: string; error?: string }
10
+
11
+ type ApplyState =
12
+ | { status: 'idle' }
13
+ | { status: 'applying' }
14
+ | { status: 'connected'; address: string }
15
+ | { status: 'unreachable'; error: string } // persisted, but the probe failed
16
+ | { status: 'failed'; error: string } // request itself failed — nothing saved
17
+
18
+ type HeaderRow = { key: string; value: string }
19
+
20
+ export function PrometheusConnectionForm({
21
+ local,
22
+ value,
23
+ onChange,
24
+ configuredHeaderKeys,
25
+ serverManaged,
26
+ headersManaged,
27
+ urlFromFlag,
28
+ onApplied,
29
+ onApply,
30
+ onDirtyChange,
31
+ onApplyOperations,
32
+ environmentHeaderKeys = [],
33
+ dirty = false,
34
+ onDiscard,
35
+ connectionAction,
36
+ feedback,
37
+ }: {
38
+ local: boolean
39
+ value: string
40
+ onChange: (value: string) => void
41
+ configuredHeaderKeys: string[]
42
+ serverManaged: boolean
43
+ headersManaged: boolean
44
+ urlFromFlag: boolean
45
+ onApplied?: (url: string) => void
46
+ onApply: (url: string, headers?: Record<string, string>) => Promise<PrometheusApplyResult>
47
+ onDirtyChange?: (dirty: boolean) => void
48
+ onApplyOperations?: (url: string, operations: HeaderOperation[]) => Promise<PrometheusApplyResult | null>
49
+ environmentHeaderKeys?: string[]
50
+ dirty?: boolean
51
+ onDiscard?: () => void
52
+ connectionAction?: ReactNode
53
+ feedback?: FormFeedback
54
+ }) {
55
+ const mounted = useRef(true)
56
+ const urlId = useId()
57
+ useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
58
+ const [apply, setApply] = useState<ApplyState>({ status: 'idle' })
59
+ // null = not editing headers (preserve what's stored). A non-null array means
60
+ // the user opened the editor; see handleApply for how rows become a
61
+ // replacement (values are write-only, so the server never sends them back).
62
+ const [headerRows, setHeaderRows] = useState<HeaderRow[] | null>(null)
63
+ // Show the server's configured header keys, but let a successful apply override
64
+ // optimistically (config isn't refetched). Derived from the prop — not a
65
+ // mount-time snapshot — so it stays correct as config loads asynchronously.
66
+ const [appliedKeys, setAppliedKeys] = useState<string[] | null>(null)
67
+ const [operations, setOperations] = useState<HeaderOperation[]>([])
68
+ const storedKeys = appliedKeys ?? configuredHeaderKeys
69
+ useEffect(() => { onDirtyChange?.(headerRows !== null || operations.some(operation => operation.action !== 'keep')) }, [headerRows, operations, onDirtyChange])
70
+
71
+ const clearStatus = () => {
72
+ if (apply.status !== 'applying') setApply({ status: 'idle' })
73
+ }
74
+
75
+ // Footer Reset (and any external edit) clears the URL field without a keystroke;
76
+ // drop a stale "Connected"/"Saved" status so it doesn't describe an emptied field.
77
+ useEffect(() => {
78
+ setApply((s) => (s.status === 'idle' || s.status === 'applying' ? s : { status: 'idle' }))
79
+ }, [value])
80
+
81
+ const handleApply = async () => {
82
+ setApply({ status: 'applying' })
83
+ // Decide what to do with headers. undefined = leave them untouched. Only send
84
+ // a replacement when the editor has real content, or {} when the user emptied
85
+ // every row (explicit clear) — blank in-progress rows must NOT wipe stored
86
+ // secrets just because the editor happens to be open for a URL-only change.
87
+ try {
88
+ const editedHeaders = prometheusHeadersFromRows(headerRows)
89
+ const editedOperations = operations.filter(operation => operation.key.trim() || operation.value)
90
+ if (onApplyOperations) {
91
+ const names = new Set<string>()
92
+ for (const operation of editedOperations) {
93
+ const name = operation.key.trim().toLowerCase()
94
+ if (!name) throw new Error('Enter a name for each header with a value.')
95
+ if (names.has(name)) throw new Error(`Header names must be unique: ${operation.key.trim()}.`)
96
+ if (operation.action === 'set' && !operation.value) throw new Error(`Enter a value for ${operation.key.trim()}.`)
97
+ names.add(name)
98
+ }
99
+ }
100
+ const data = onApplyOperations ? await onApplyOperations(value.trim(), editedOperations) : await onApply(value.trim(), editedHeaders)
101
+ if (!mounted.current) return
102
+ if (!data) { setApply({ status: 'idle' }); return }
103
+ onApplied?.(value.trim())
104
+ if (editedHeaders !== undefined) {
105
+ setAppliedKeys(Object.keys(editedHeaders).sort())
106
+ }
107
+ if (headerRows !== null) {
108
+ setHeaderRows(null)
109
+ }
110
+ setOperations([])
111
+ if (data.connected) {
112
+ setApply({ status: 'connected', address: data.address || value.trim() })
113
+ } else {
114
+ setApply({ status: 'unreachable', error: data.error || 'not reachable' })
115
+ }
116
+ } catch (err) {
117
+ if (mounted.current) setApply({ status: 'failed', error: err instanceof Error ? err.message : String(err) })
118
+ }
119
+ }
120
+
121
+ return (
122
+ <div>
123
+ <div className="mb-4 space-y-1">
124
+ <p className="text-sm text-theme-text-secondary">
125
+ Connect a Prometheus-compatible backend, such as Prometheus, VictoriaMetrics, Thanos or Grafana Mimir.
126
+ </p>
127
+ </div>
128
+ <div className="flex flex-wrap items-center gap-1 mb-1">
129
+ <label htmlFor={urlId} className="text-sm font-medium text-theme-text-primary">Metrics backend URL</label>
130
+ <Tooltip content="Use a base URL reachable from Radar, not your browser. Include the backend path prefix, but not /api/v1/query. Available charts depend on the metrics collected.">
131
+ <button type="button" aria-label="Metrics URL help" className="text-theme-text-tertiary"><Info className="w-3.5 h-3.5" /></button>
132
+ </Tooltip>
133
+ </div>
134
+ <p className="text-xs text-theme-text-tertiary mb-1">
135
+ Leave empty to discover a backend in this cluster.
136
+ </p>
137
+ <div className="flex items-center gap-2">
138
+ <Input
139
+ id={urlId}
140
+ value={value}
141
+ disabled={apply.status === 'applying'}
142
+ onChange={(e) => onChange(e.target.value)}
143
+ placeholder="http://prometheus-server.monitoring:9090"
144
+ className="flex-1 min-w-0 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"
145
+ />
146
+ </div>
147
+ {connectionAction}
148
+ {!onApplyOperations && <p className="mt-2 text-xs text-theme-text-tertiary">
149
+ {local ? 'Applies only to this cluster.' : 'Changes affect this Radar installation. Use deployment settings for configuration that survives Pod replacement.'}
150
+ </p>}
151
+ {value.startsWith('http://') && (configuredHeaderKeys.length > 0 || operations.some(operation => operation.action === 'set')) && (
152
+ <p className="mt-2 text-xs text-warning-text">Headers will travel over unencrypted HTTP. Prefer HTTPS outside a trusted private network.</p>
153
+ )}
154
+ {serverManaged && (
155
+ <p className="mt-2 text-xs text-theme-text-secondary">
156
+ Server controlled by startup configuration. To set or change the server,
157
+ {local ? ' update the startup flags or environment references and restart Radar.' : ' update the deployment configuration (such as Helm values) and restart Radar.'}
158
+ </p>
159
+ )}
160
+ {urlFromFlag && (
161
+ <p className="mt-1 text-xs text-theme-text-secondary">
162
+ Path edits apply until restart; the URL supplied at launch will then be restored.
163
+ </p>
164
+ )}
165
+
166
+ {onApplyOperations ? <ConnectionHeadersEditor keys={configuredHeaderKeys} environmentKeys={environmentHeaderKeys} value={operations} onChange={setOperations} /> : <section className="mt-5 border-t border-theme-border pt-4">
167
+ <h4 className="text-sm font-medium text-theme-text-primary mb-2">Authentication headers</h4>
168
+ <p className="text-xs text-theme-text-tertiary mb-3">
169
+ Optional authentication or tenant headers, such as Authorization or X-Scope-OrgID. Requires a backend URL.
170
+ {!headersManaged && storedKeys.length > 0 && ' Changing servers requires replacing or clearing the saved headers.'}
171
+ </p>
172
+ {headerRows === null ? (
173
+ <div className="flex flex-wrap items-center justify-between gap-2">
174
+ <span className="min-w-0 break-words text-xs text-theme-text-tertiary">
175
+ {storedKeys.length > 0
176
+ ? <><span className="text-theme-text-secondary">{storedKeys.join(', ')}</span> (values hidden)</>
177
+ : 'No headers configured'}
178
+ </span>
179
+ {!headersManaged && <button
180
+ onClick={() => { setHeaderRows([{ key: '', value: '' }]); clearStatus() }}
181
+ disabled={apply.status === 'applying'}
182
+ className="shrink-0 text-xs font-medium text-accent-text hover:underline"
183
+ >
184
+ {storedKeys.length > 0 ? 'Edit headers' : 'Add auth headers'}
185
+ </button>}
186
+ </div>
187
+ ) : (
188
+ <div className="rounded-md border border-theme-border bg-theme-elevated/40 p-2.5 space-y-2">
189
+ {headerRows.map((row, i) => (
190
+ <div key={i} className="flex items-center gap-2">
191
+ <Input
192
+ value={row.key}
193
+ disabled={apply.status === 'applying'}
194
+ onChange={(e) => {
195
+ setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, key: e.target.value } : r))
196
+ clearStatus()
197
+ }}
198
+ placeholder="Header (e.g. Authorization)"
199
+ className="flex-1 min-w-0 px-2.5 py-1.5 text-xs bg-theme-base border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
200
+ />
201
+ <input
202
+ type="password"
203
+ value={row.value}
204
+ disabled={apply.status === 'applying'}
205
+ onChange={(e) => {
206
+ setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, value: e.target.value } : r))
207
+ clearStatus()
208
+ }}
209
+ placeholder="Value (e.g. Bearer …)"
210
+ className="flex-1 min-w-0 px-2.5 py-1.5 text-xs bg-theme-base border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
211
+ />
212
+ <Tooltip content="Remove header" wrapperClassName="shrink-0">
213
+ <button
214
+ onClick={() => setHeaderRows((rows) => rows!.filter((_, j) => j !== i))}
215
+ disabled={apply.status === 'applying'}
216
+ className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
217
+ >
218
+ <X className="w-3.5 h-3.5" />
219
+ </button>
220
+ </Tooltip>
221
+ </div>
222
+ ))}
223
+ <div className="flex items-center justify-between gap-2">
224
+ <button
225
+ onClick={() => setHeaderRows((rows) => [...rows!, { key: '', value: '' }])}
226
+ disabled={apply.status === 'applying'}
227
+ className="flex items-center gap-1 text-xs font-medium text-accent-text hover:underline"
228
+ >
229
+ <Plus className="w-3 h-3" /> Add header
230
+ </button>
231
+ <button
232
+ onClick={() => { setHeaderRows(null); clearStatus() }}
233
+ disabled={apply.status === 'applying'}
234
+ className="text-xs text-theme-text-tertiary hover:text-theme-text-primary"
235
+ >
236
+ Cancel
237
+ </button>
238
+ </div>
239
+ <p className="text-xs text-theme-text-tertiary">
240
+ Saved when you click Apply now. Entered headers replace all stored
241
+ ones — values are hidden, so re-enter any you want to keep. Leave
242
+ all rows blank to keep existing headers unchanged. To remove all saved
243
+ headers, remove every row and click Apply now.
244
+ </p>
245
+ </div>
246
+ )}
247
+ {headersManaged ? (
248
+ <p className="mt-2 text-xs text-theme-text-secondary">
249
+ Headers are controlled by startup configuration. Change them at their source and restart Radar.
250
+ </p>
251
+ ) : storedKeys.length > 0 && (
252
+ <button
253
+ onClick={() => { setHeaderRows([]); clearStatus() }}
254
+ disabled={apply.status === 'applying'}
255
+ className="mt-2 text-xs text-theme-text-secondary hover:underline"
256
+ >
257
+ Remove saved headers
258
+ </button>
259
+ )}
260
+ {headerRows?.length === 0 && (
261
+ <p className="mt-1 text-xs text-warning-text">Headers will be removed when you click Apply now.</p>
262
+ )}
263
+ </section>}
264
+ <div className="mt-4 space-y-1">
265
+ {onDiscard ? <FormSaveActions dirty={dirty} busy={apply.status === 'applying'} onSave={() => void handleApply()} onDiscard={onDiscard} feedback={feedback} error={apply.status === 'failed' ? apply.error : undefined} /> : <Tooltip content="Save and apply this connection, then check reachability. Applying clears any workload scope override and resumes automatic identity matching.">
266
+ <button
267
+ type="button"
268
+ onClick={handleApply}
269
+ disabled={apply.status === 'applying'}
270
+ className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium btn-brand rounded-md disabled:opacity-50"
271
+ >
272
+ {apply.status === 'applying'
273
+ ? <Loader2 className="w-3.5 h-3.5 animate-spin" />
274
+ : <Plug className="w-3.5 h-3.5" />}
275
+ Apply now
276
+ </button>
277
+ </Tooltip>}
278
+ {!onDiscard && <Collapse open={apply.status === 'failed' || (!onApplyOperations && apply.status !== 'applying')}>
279
+ {onApplyOperations && (apply.status === 'connected' || apply.status === 'unreachable') ? null : apply.status === 'connected' ? (
280
+ <p role="status" className="flex items-center gap-1 text-xs text-theme-text-secondary">
281
+ <Check className="w-3 h-3 shrink-0 text-[var(--color-success-dark)] dark:text-[var(--color-success-light)]" />
282
+ Connected to {apply.address} — applied, no restart needed
283
+ </p>
284
+ ) : apply.status === 'unreachable' ? (
285
+ <p role="status" className="text-xs text-warning-text">Saved, but not reachable: {apply.error}</p>
286
+ ) : apply.status === 'failed' ? (
287
+ <p role="alert" className="text-xs text-semantic-error">Couldn't apply: {apply.error}</p>
288
+ ) : !onApplyOperations ? (
289
+ <p className="text-xs text-theme-text-tertiary">Saves and applies before checking the connection.</p>
290
+ ) : null}
291
+ </Collapse>}
292
+ </div>
293
+ </div>
294
+ )
295
+ }