@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.
- package/README.md +2 -0
- package/package.json +6 -5
- package/src/App.tsx +14 -11
- package/src/RadarApp.tsx +51 -21
- package/src/api/apiResources.test.ts +19 -1
- package/src/api/apiResources.ts +18 -3
- package/src/api/client.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +232 -201
- package/src/api/diagnose.test.ts +102 -1
- package/src/api/diagnose.ts +42 -11
- package/src/api/drain-plan.test.ts +0 -10
- package/src/api/httpErrors.test.ts +95 -0
- package/src/api/httpErrors.ts +101 -0
- package/src/api/policy.ts +33 -5
- package/src/api/radarFeatures.render.test.tsx +190 -0
- package/src/api/radarFeatures.test.ts +133 -0
- package/src/api/radarFeatures.ts +147 -0
- package/src/api/timelineSource.test.ts +2 -10
- package/src/api/timelineSource.ts +3 -2
- package/src/api/traffic.ts +4 -3
- package/src/components/CloudFunnelButton.tsx +9 -9
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +5 -6
- package/src/components/applications/ApplicationsView.tsx +20 -6
- package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
- package/src/components/audit/UpgradeReadinessView.tsx +9 -13
- package/src/components/capacity/CapacityView.test.tsx +13 -4
- package/src/components/capacity/shared.tsx +14 -13
- package/src/components/cost/ApplicationCostTab.tsx +9 -1
- package/src/components/cost/CostConnectionAction.tsx +30 -0
- package/src/components/cost/CostTrendChart.tsx +2 -0
- package/src/components/cost/CostView.tsx +4 -24
- package/src/components/cost/WorkloadCostTab.tsx +7 -1
- package/src/components/cost/source.test.ts +30 -3
- package/src/components/cost/source.ts +37 -2
- package/src/components/curl/ServiceCurlButton.tsx +7 -3
- package/src/components/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/home/HomeView.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
- package/src/components/resource/PVCUsageBar.tsx +4 -1
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
- package/src/components/resources/ImageFilesystemModal.tsx +2 -1
- package/src/components/resources/PodFilePreview.tsx +4 -1
- package/src/components/resources/PodFilesystemModal.tsx +3 -2
- package/src/components/resources/ResourcesView.tsx +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
- package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
- package/src/components/settings/FormSaveActions.tsx +76 -0
- package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
- package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
- package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
- package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
- package/src/components/settings/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +382 -521
- package/src/components/settings/currency-options.test.ts +1 -1
- package/src/components/settings/currency-options.ts +1 -1
- package/src/components/settings/settings-state.test.ts +17 -10
- package/src/components/settings/settings-state.ts +20 -12
- package/src/components/timeline/TimelineList.test.ts +56 -0
- package/src/components/timeline/TimelineList.tsx +7 -1
- package/src/components/traffic/TrafficFlowList.tsx +5 -21
- package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
- package/src/components/traffic/TrafficGraph.tsx +79 -48
- package/src/components/traffic/TrafficView.tsx +61 -37
- package/src/components/traffic/TrafficWizard.tsx +4 -2
- package/src/components/traffic/trafficFilters.test.ts +154 -1
- package/src/components/traffic/trafficFilters.ts +141 -0
- package/src/components/ui/Omnibar.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +57 -12
- package/src/components/workload/WorkloadView.tsx +36 -13
- package/src/components/workload/historyPaging.test.ts +72 -0
- package/src/components/workload/historyPaging.ts +147 -0
- package/src/components/workload/useHistoryPaging.test.tsx +81 -0
- package/src/context/ConnectionContext.tsx +2 -1
- package/src/context/RadarUpgrade.tsx +30 -0
- package/src/context/RadarUpgradeHost.tsx +23 -0
- package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
- package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
- package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
- 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
|
+
}
|