@skyhook-io/radar-app 1.16.1 → 1.16.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 +1 -1
- package/src/App.tsx +5 -4
- package/src/RadarApp.tsx +10 -2
- package/src/api/client.ts +8 -2
- package/src/api/client.updateResource.test.tsx +58 -0
- package/src/api/diagnose.ts +26 -0
- package/src/api/httpErrors.ts +12 -0
- package/src/components/ConnectionErrorView.test.tsx +2 -0
- package/src/components/ConnectionErrorView.tsx +2 -1
- package/src/components/diagnose/AgentControls.tsx +1 -1
- package/src/components/diagnose/AssessmentCard.tsx +11 -1
- package/src/components/diagnose/DiagnoseContext.tsx +35 -22
- package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
- package/src/components/diagnose/Home.tsx +1 -1
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
- package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
- package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
- package/src/components/diagnose/InvestigationView.tsx +199 -65
- package/src/components/diagnose/investigationSourceFocus.ts +6 -6
- package/src/components/diagnose/investigationState.test.ts +83 -1
- package/src/components/diagnose/investigationState.ts +43 -0
- package/src/components/diagnose/launch.ts +2 -2
- package/src/components/diagnose/useDisclosureReveal.ts +1 -1
- package/src/components/dock/BottomDock.tsx +1 -1
- package/src/components/dock/DockContext.tsx +27 -2
- package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
- package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
- package/src/components/dock/LocalTerminalTab.tsx +81 -6
- package/src/components/logs/LogsViewer.tsx +3 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
- package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
- package/src/components/settings/LocalConnectionSettings.tsx +41 -10
- package/src/components/whats-new/releaseNotes.ts +1 -1
- package/src/context/DiagnoseCustomization.tsx +13 -3
- package/src/index.ts +4 -2
|
@@ -1,22 +1,97 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCallback, useRef, useState } from 'react'
|
|
2
|
+
import { AlertTriangle } from 'lucide-react'
|
|
3
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
4
|
+
import { ClusterName, LocalTerminalTab as SharedLocalTerminalTab, Tooltip, parseContextName, useDock, type LocalTerminalSessionInfo } from '@skyhook-io/k8s-ui'
|
|
2
5
|
import { getWsUrl } from '../../api/config'
|
|
6
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
7
|
+
import { useOpenLocalTerminal } from './DockContext'
|
|
3
8
|
|
|
4
9
|
interface LocalTerminalTabProps {
|
|
10
|
+
tabId: string
|
|
11
|
+
title: string
|
|
12
|
+
intendedContext?: string
|
|
5
13
|
isActive?: boolean
|
|
6
14
|
initialCommand?: string
|
|
7
15
|
}
|
|
8
16
|
|
|
9
|
-
export function LocalTerminalTab({ isActive, initialCommand }: LocalTerminalTabProps) {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
export function LocalTerminalTab({ tabId, title, intendedContext, isActive, initialCommand }: LocalTerminalTabProps) {
|
|
18
|
+
const { connection } = useConnection()
|
|
19
|
+
const queryClient = useQueryClient()
|
|
20
|
+
const { setTabTitle } = useDock()
|
|
21
|
+
const openLocalTerminal = useOpenLocalTerminal()
|
|
22
|
+
const baseTitle = useRef(title)
|
|
23
|
+
const [sessionInfo, setSessionInfo] = useState<LocalTerminalSessionInfo | null>(null)
|
|
24
|
+
const handleSessionInfo = useCallback((info: LocalTerminalSessionInfo | null) => {
|
|
25
|
+
if (!info) return
|
|
26
|
+
setSessionInfo(info)
|
|
27
|
+
const suffix = info.kubeconfigIsolated ? parseContextName(info.context).clusterName : 'context not confirmed'
|
|
28
|
+
setTabTitle(tabId, `${baseTitle.current} · ${suffix}`, info.kubeconfigIsolated ? info.context : undefined)
|
|
29
|
+
}, [tabId, setTabTitle])
|
|
30
|
+
const unconfirmed = sessionInfo !== null && !sessionInfo.kubeconfigIsolated
|
|
31
|
+
const contextNotice = unconfirmed
|
|
32
|
+
? `Requested for ${intendedContext || 'no active context'}; kubeconfig not confirmed.`
|
|
33
|
+
: `This terminal is for ${intendedContext || 'no active context'}.`
|
|
34
|
+
const context = sessionInfo?.kubeconfigIsolated ? sessionInfo.context : null
|
|
35
|
+
const mismatched = intendedContext !== undefined && !!connection.context && intendedContext !== connection.context
|
|
36
|
+
const createSession = async () => {
|
|
37
|
+
if (intendedContext === undefined) throw new Error('No context selected. Open a new terminal for the selected context.')
|
|
38
|
+
return { wsUrl: getWsUrl(`/local-terminal?expectedContext=${encodeURIComponent(intendedContext)}`) }
|
|
39
|
+
}
|
|
14
40
|
|
|
15
41
|
return (
|
|
16
42
|
<SharedLocalTerminalTab
|
|
17
43
|
isActive={isActive}
|
|
18
44
|
createSession={createSession}
|
|
19
45
|
initialCommand={initialCommand}
|
|
46
|
+
canConnect={() => intendedContext !== undefined && connection.context === intendedContext && (!!intendedContext || connection.state === 'disconnected')}
|
|
47
|
+
onConnectionError={() => { void queryClient.invalidateQueries({ queryKey: ['connection-status'] }) }}
|
|
48
|
+
onSessionInfo={handleSessionInfo}
|
|
49
|
+
toolbarExtra={
|
|
50
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
51
|
+
{context ? (
|
|
52
|
+
<Tooltip content={<>Radar supplied a temporary kubeconfig for <strong>{context}</strong>. Shell settings and commands can override it.</>} wrapperClassName="min-w-0">
|
|
53
|
+
<span className="flex items-center gap-1 min-w-0 text-xs text-theme-text-secondary">
|
|
54
|
+
<span className="shrink-0">Opened for:</span>
|
|
55
|
+
<ClusterName name={context} noBadge noTooltip />
|
|
56
|
+
</span>
|
|
57
|
+
</Tooltip>
|
|
58
|
+
) : !sessionInfo && intendedContext ? (
|
|
59
|
+
<Tooltip content={<>A new shell has been requested for <strong>{intendedContext}</strong>.</>} wrapperClassName="min-w-0">
|
|
60
|
+
<span className="flex items-center gap-1 min-w-0 text-xs text-theme-text-secondary">
|
|
61
|
+
<span className="shrink-0">Requested:</span>
|
|
62
|
+
<ClusterName name={intendedContext} noBadge noTooltip />
|
|
63
|
+
</span>
|
|
64
|
+
</Tooltip>
|
|
65
|
+
) : intendedContext === undefined ? (
|
|
66
|
+
<span className="text-xs text-warning-text whitespace-nowrap">No context selected</span>
|
|
67
|
+
) : (
|
|
68
|
+
<Tooltip content={sessionInfo
|
|
69
|
+
? 'Radar could not create a temporary kubeconfig. This shell uses the original or inherited kubeconfig.'
|
|
70
|
+
: 'The server has not reported this terminal’s kubeconfig.'}>
|
|
71
|
+
<span className="text-xs text-warning-text">Context not confirmed</span>
|
|
72
|
+
</Tooltip>
|
|
73
|
+
)}
|
|
74
|
+
{mismatched && (
|
|
75
|
+
<Tooltip content={<>Radar is showing <strong>{connection.context}</strong>. {contextNotice} Switch back to reconnect, or open a new terminal.</>} wrapperClassName="shrink-0">
|
|
76
|
+
<span
|
|
77
|
+
role="status"
|
|
78
|
+
aria-label={`Radar is showing ${connection.context}. ${contextNotice}`}
|
|
79
|
+
className="flex items-center gap-1 text-xs text-warning-text whitespace-nowrap"
|
|
80
|
+
>
|
|
81
|
+
<AlertTriangle className="w-3 h-3" />
|
|
82
|
+
{unconfirmed ? 'Requested context differs' : 'Different context'}
|
|
83
|
+
</span>
|
|
84
|
+
</Tooltip>
|
|
85
|
+
)}
|
|
86
|
+
{connection.context && (mismatched || intendedContext === undefined) && (
|
|
87
|
+
<Tooltip content={<>Open a new terminal for <strong>{connection.context}</strong>.</>} wrapperClassName="shrink-0">
|
|
88
|
+
<button className="btn-brand px-2 py-0.5 text-xs whitespace-nowrap" aria-label={`New terminal for ${connection.context}`} onClick={() => openLocalTerminal()}>
|
|
89
|
+
New terminal
|
|
90
|
+
</button>
|
|
91
|
+
</Tooltip>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
}
|
|
20
95
|
/>
|
|
21
96
|
)
|
|
22
97
|
}
|
|
@@ -43,7 +43,10 @@ export function LogsViewer({ namespace, podName, containers, initialContainer, a
|
|
|
43
43
|
fetchLogs={fetchLogs}
|
|
44
44
|
createStream={makeStream}
|
|
45
45
|
overrideDownload={desktopDownload}
|
|
46
|
+
// Light logs on a dark app are never wanted: dark theme pins the palette,
|
|
47
|
+
// light theme only sets where it starts and leaves the toggle available.
|
|
46
48
|
forceDark={theme === 'dark' ? true : undefined}
|
|
49
|
+
defaultDark={false}
|
|
47
50
|
autoStream={autoStream}
|
|
48
51
|
/>
|
|
49
52
|
)
|
|
@@ -42,7 +42,10 @@ export function WorkloadLogsViewer({ kind, namespace, name, autoStream = true, s
|
|
|
42
42
|
fetchAll={fetchAll}
|
|
43
43
|
createStream={snapshotOnly ? undefined : makeStream}
|
|
44
44
|
overrideDownload={desktopDownload}
|
|
45
|
+
// Light logs on a dark app are never wanted: dark theme pins the palette,
|
|
46
|
+
// light theme only sets where it starts and leaves the toggle available.
|
|
45
47
|
forceDark={theme === 'dark' ? true : undefined}
|
|
48
|
+
defaultDark={false}
|
|
46
49
|
autoStream={autoStream}
|
|
47
50
|
/>
|
|
48
51
|
)
|
|
@@ -73,6 +73,13 @@ export function PreviousIntegrationSettingsNotice({ profiles, onNavigate, onDism
|
|
|
73
73
|
|
|
74
74
|
export const integrationSettingsByClusterId = 'integration-settings-by-cluster'
|
|
75
75
|
|
|
76
|
+
// Switching to auto-discovery keeps an empty record, so a record alone doesn't
|
|
77
|
+
// mean anything is saved. An invalid record disables the integration instead.
|
|
78
|
+
function integrationStatus(use: StoredConnection) {
|
|
79
|
+
if (use.error || use.mode !== 'auto' || use.url || use.secretSet || use.clusterId) return 'saved'
|
|
80
|
+
return use.integration === 'cost' ? 'Automatic' : 'auto-discovery'
|
|
81
|
+
}
|
|
82
|
+
|
|
76
83
|
export function LocalConfigurationDetails({ onNavigate }: { onNavigate: (section: SettingsSectionId) => void }) {
|
|
77
84
|
return (
|
|
78
85
|
<div className="mt-5 border-t border-theme-border pt-4">
|
|
@@ -168,7 +175,7 @@ export function SavedClusterConnections({
|
|
|
168
175
|
.then(async (response) => {
|
|
169
176
|
const data = (await response.json().catch(() => ({}))) as ConnectionResponse
|
|
170
177
|
if (!response.ok)
|
|
171
|
-
throw new Error(data.error || 'Could not load
|
|
178
|
+
throw new Error(data.error || 'Could not load integration settings.')
|
|
172
179
|
if (controller.signal.aborted || getApiBase() !== base) return
|
|
173
180
|
setUses(data.connections)
|
|
174
181
|
})
|
|
@@ -215,10 +222,10 @@ export function SavedClusterConnections({
|
|
|
215
222
|
const data = (await response.json().catch(() => ({}))) as ConnectionResponse
|
|
216
223
|
if (controller.signal.aborted || getApiBase() !== base) return
|
|
217
224
|
if (!response.ok)
|
|
218
|
-
throw new Error(data.error || 'Could not remove the saved
|
|
225
|
+
throw new Error(data.error || 'Could not remove the saved settings.')
|
|
219
226
|
setUses(data.connections)
|
|
220
227
|
setMessage(
|
|
221
|
-
`Removed the saved ${integrationNames[selected.integration]}
|
|
228
|
+
`Removed the saved ${integrationNames[selected.integration]} settings for ${selected.context}.`,
|
|
222
229
|
)
|
|
223
230
|
focusSummary.current = true
|
|
224
231
|
setSelected(null)
|
|
@@ -261,12 +268,12 @@ export function SavedClusterConnections({
|
|
|
261
268
|
</p>
|
|
262
269
|
{loading && (
|
|
263
270
|
<p role="status" className="text-xs text-theme-text-tertiary">
|
|
264
|
-
Loading
|
|
271
|
+
Loading integration settings…
|
|
265
272
|
</p>
|
|
266
273
|
)}
|
|
267
274
|
{!loading && !error && clusters.size === 0 && (
|
|
268
275
|
<p className="text-xs text-theme-text-tertiary">
|
|
269
|
-
No saved
|
|
276
|
+
No integration settings saved yet.
|
|
270
277
|
</p>
|
|
271
278
|
)}
|
|
272
279
|
{!loading &&
|
|
@@ -300,17 +307,16 @@ export function SavedClusterConnections({
|
|
|
300
307
|
</p>
|
|
301
308
|
{!current && entry.availability === 'removed' && (
|
|
302
309
|
<p className="text-theme-text-secondary">
|
|
303
|
-
This kubeconfig entry is gone, but its
|
|
304
|
-
saved. To reuse one, select the new context and choose “Copy
|
|
310
|
+
This kubeconfig entry is gone, but its integration settings
|
|
311
|
+
are still saved. To reuse one, select the new context and choose “Copy
|
|
305
312
|
settings from…” in that integration’s settings.
|
|
306
313
|
</p>
|
|
307
314
|
)}
|
|
308
315
|
{(current ||
|
|
309
316
|
entries.every((use) => use.availability === 'available')) && (
|
|
310
317
|
<p className="text-theme-text-secondary">
|
|
311
|
-
Saved:{' '}
|
|
312
318
|
{entries
|
|
313
|
-
.map((use) => integrationNames[use.integration])
|
|
319
|
+
.map((use) => `${integrationNames[use.integration]}: ${integrationStatus(use)}`)
|
|
314
320
|
.join(' · ')}
|
|
315
321
|
</p>
|
|
316
322
|
)}
|
|
@@ -325,7 +331,7 @@ export function SavedClusterConnections({
|
|
|
325
331
|
type="button"
|
|
326
332
|
disabled={busy}
|
|
327
333
|
className="text-accent-text hover:underline disabled:opacity-50"
|
|
328
|
-
aria-label={`Remove saved ${integrationNames[use.integration]}
|
|
334
|
+
aria-label={`Remove saved ${integrationNames[use.integration]} settings for ${use.context}`}
|
|
329
335
|
onClick={() => {
|
|
330
336
|
setSelected(use)
|
|
331
337
|
setError('')
|
|
@@ -353,7 +359,7 @@ export function SavedClusterConnections({
|
|
|
353
359
|
className="underline"
|
|
354
360
|
onClick={() => setVersion((value) => value + 1)}
|
|
355
361
|
>
|
|
356
|
-
Reload
|
|
362
|
+
Reload integration settings
|
|
357
363
|
</button>
|
|
358
364
|
</p>
|
|
359
365
|
)}
|
|
@@ -362,13 +368,13 @@ export function SavedClusterConnections({
|
|
|
362
368
|
open={!!selected}
|
|
363
369
|
onClose={() => setSelected(null)}
|
|
364
370
|
onConfirm={() => void remove()}
|
|
365
|
-
title="Remove saved
|
|
371
|
+
title="Remove saved settings?"
|
|
366
372
|
message={
|
|
367
373
|
selected
|
|
368
|
-
? `Remove the saved ${integrationNames[selected.integration]}
|
|
374
|
+
? `Remove the saved ${integrationNames[selected.integration]} settings and credentials for ${selected.context}? Other saved settings are unchanged. Nothing is deleted from Kubernetes or the backend.${selected.availability === 'unavailable' ? ' This entry may still exist in another kubeconfig; removal also affects any CLI or Desktop using that entry.' : ''}`
|
|
369
375
|
: ''
|
|
370
376
|
}
|
|
371
|
-
confirmLabel="Remove
|
|
377
|
+
confirmLabel="Remove settings"
|
|
372
378
|
isLoading={busy}
|
|
373
379
|
confirmDisabled={!!error}
|
|
374
380
|
showWarning={false}
|
|
@@ -380,7 +386,7 @@ export function SavedClusterConnections({
|
|
|
380
386
|
role="alert"
|
|
381
387
|
className="text-sm text-warning-text"
|
|
382
388
|
>
|
|
383
|
-
{error} Cancel and reload
|
|
389
|
+
{error} Cancel and reload integration settings before trying again.
|
|
384
390
|
</p>
|
|
385
391
|
)}
|
|
386
392
|
</ConfirmDialog>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
2
|
import { Badge, Collapse, ConfirmDialog, SelectMenu } from '@skyhook-io/k8s-ui'
|
|
3
3
|
import { ArrowLeft, Info } from 'lucide-react'
|
|
4
4
|
import { Tooltip } from '../ui/Tooltip'
|
|
@@ -47,11 +47,13 @@ export interface StoredConnection {
|
|
|
47
47
|
source: string
|
|
48
48
|
inFileName: string
|
|
49
49
|
availability: 'available' | 'removed' | 'unavailable'
|
|
50
|
+
mode: string
|
|
50
51
|
url: string
|
|
51
52
|
headerKeys: string[]
|
|
52
53
|
envHeaderKeys: string[]
|
|
53
54
|
secretSet: boolean
|
|
54
55
|
insecureTls: boolean
|
|
56
|
+
clusterId: string
|
|
55
57
|
error?: string
|
|
56
58
|
}
|
|
57
59
|
export interface IntegrationProfile {
|
|
@@ -126,6 +128,11 @@ const automaticAction: Record<IntegrationKind, string> = {
|
|
|
126
128
|
argocd: 'Use auto-discovery',
|
|
127
129
|
cost: 'Reset to Automatic'
|
|
128
130
|
}
|
|
131
|
+
function sameIdentity(a?: TargetIdentity, b?: TargetIdentity) {
|
|
132
|
+
return !!a && !!b && a.server === b.server && a.user === b.user &&
|
|
133
|
+
a.tlsName === b.tlsName && a.trust === b.trust && a.proxy === b.proxy &&
|
|
134
|
+
!!a.insecureTls === !!b.insecureTls
|
|
135
|
+
}
|
|
129
136
|
const names: Record<IntegrationKind, string> = {
|
|
130
137
|
metrics: 'Metrics',
|
|
131
138
|
argocd: 'Argo CD',
|
|
@@ -180,9 +187,18 @@ export function LocalConnectionSettings({
|
|
|
180
187
|
const confirmationCopy = useRef({ title: '', message: '', label: '' })
|
|
181
188
|
const [confirmBack, setConfirmBack] = useState(false)
|
|
182
189
|
const [accepted, setAccepted] = useState<IntegrationKind[]>([])
|
|
183
|
-
|
|
190
|
+
// The review page compares against one previous identity, so it can only
|
|
191
|
+
// approve integrations that were paused from that same identity.
|
|
192
|
+
const pausedKinds = (Object.keys(snapshot) as IntegrationKind[]).filter(
|
|
184
193
|
(k) => snapshot[k].state === 'target_changed'
|
|
185
194
|
)
|
|
195
|
+
const reviewKinds = pausedKinds.filter(
|
|
196
|
+
(k) => k === kind || sameIdentity(snapshot[k].previousIdentity, profile.previousIdentity)
|
|
197
|
+
)
|
|
198
|
+
const separateReviewKinds = pausedKinds.filter((k) => !reviewKinds.includes(k))
|
|
199
|
+
const separateIdentityKnown = !!profile.previousIdentity &&
|
|
200
|
+
separateReviewKinds.every((k) => !!snapshot[k].previousIdentity)
|
|
201
|
+
const acceptedChanges = accepted.filter((k) => reviewKinds.includes(k))
|
|
186
202
|
const request = useRef<AbortController | null>(null)
|
|
187
203
|
const catalogRequest = useRef<AbortController | null>(null)
|
|
188
204
|
const heading = useRef<HTMLHeadingElement>(null)
|
|
@@ -191,6 +207,14 @@ export function LocalConnectionSettings({
|
|
|
191
207
|
const editorRegion = useRef<HTMLFieldSetElement>(null)
|
|
192
208
|
const trigger = useRef<HTMLElement | null>(null)
|
|
193
209
|
const mounted = useRef(true)
|
|
210
|
+
// Focus moves in the commit that mounts a new draft form. Deferring it to a
|
|
211
|
+
// later frame lets keystrokes typed in between land in a different field.
|
|
212
|
+
const focusAfterDraft = useRef<(() => void) | null>(null)
|
|
213
|
+
useLayoutEffect(() => {
|
|
214
|
+
const focus = focusAfterDraft.current
|
|
215
|
+
focusAfterDraft.current = null
|
|
216
|
+
focus?.()
|
|
217
|
+
})
|
|
194
218
|
const copySources = catalog.filter(c => c.integration === kind && !c.error && c.url && c.binding !== profile.target.binding)
|
|
195
219
|
const removalContext = profile.target.context
|
|
196
220
|
const dirty =
|
|
@@ -220,7 +244,7 @@ export function LocalConnectionSettings({
|
|
|
220
244
|
.then(async (response) => {
|
|
221
245
|
const data = (await response.json().catch(() => ({}))) as ConnectionResponse
|
|
222
246
|
if (!response.ok)
|
|
223
|
-
throw new Error(data.error || 'Could not load
|
|
247
|
+
throw new Error(data.error || 'Could not load other clusters’ settings.')
|
|
224
248
|
if (!controller.signal.aborted && getApiBase() === base) {
|
|
225
249
|
setCatalog(data.connections)
|
|
226
250
|
setCatalogError(false)
|
|
@@ -519,7 +543,7 @@ export function LocalConnectionSettings({
|
|
|
519
543
|
setDraftGeneration((generation) => generation + 1)
|
|
520
544
|
setError('')
|
|
521
545
|
// Cost's first control is its source picker, a listbox trigger.
|
|
522
|
-
|
|
546
|
+
focusAfterDraft.current = () => region.current?.querySelector<HTMLElement>('input:not(:disabled), button[aria-haspopup="listbox"]:not(:disabled)')?.focus()
|
|
523
547
|
}}
|
|
524
548
|
className="text-xs text-accent-text hover:underline disabled:opacity-50 shrink-0"
|
|
525
549
|
>
|
|
@@ -539,7 +563,7 @@ export function LocalConnectionSettings({
|
|
|
539
563
|
setDraftGeneration(generation => generation + 1)
|
|
540
564
|
setMessage('')
|
|
541
565
|
setError('')
|
|
542
|
-
|
|
566
|
+
focusAfterDraft.current = () => editorRegion.current?.querySelector<HTMLInputElement>('input:not(:disabled)')?.focus()
|
|
543
567
|
}
|
|
544
568
|
const copySource = (source: StoredConnection) => {
|
|
545
569
|
setSelected(source)
|
|
@@ -555,7 +579,7 @@ export function LocalConnectionSettings({
|
|
|
555
579
|
setDraftGeneration(generation => generation + 1)
|
|
556
580
|
setMessage('')
|
|
557
581
|
setError('')
|
|
558
|
-
|
|
582
|
+
focusAfterDraft.current = () => editorRegion.current?.querySelector<HTMLInputElement>('input:not(:disabled)')?.focus()
|
|
559
583
|
}
|
|
560
584
|
const reuseOptions = task === 'main' && profile.state !== 'launch' ? [
|
|
561
585
|
...(previous ? [{
|
|
@@ -836,7 +860,7 @@ export function LocalConnectionSettings({
|
|
|
836
860
|
) : null}
|
|
837
861
|
</>
|
|
838
862
|
)}
|
|
839
|
-
<fieldset ref={editorRegion} className="min-w-0">
|
|
863
|
+
<fieldset ref={editorRegion} className="min-w-0 empty:hidden">
|
|
840
864
|
{editor &&
|
|
841
865
|
(kind === 'metrics' ? (
|
|
842
866
|
<PrometheusConnectionForm
|
|
@@ -904,7 +928,7 @@ export function LocalConnectionSettings({
|
|
|
904
928
|
/>
|
|
905
929
|
))}
|
|
906
930
|
</fieldset>
|
|
907
|
-
{!editor && <Collapse open={showFeedback} className="!mt-0">
|
|
931
|
+
{!editor && task === 'main' && <Collapse open={showFeedback} className="!mt-0">
|
|
908
932
|
<p role="status" className={`pt-2 text-xs ${messageWarning ? 'text-warning-text' : 'text-theme-text-secondary'}`}>{feedback?.message}</p>
|
|
909
933
|
</Collapse>}
|
|
910
934
|
<ConfirmDialog
|
|
@@ -968,8 +992,7 @@ export function LocalConnectionSettings({
|
|
|
968
992
|
: 'Unchanged'}
|
|
969
993
|
</dd>
|
|
970
994
|
</dl>
|
|
971
|
-
{
|
|
972
|
-
.filter((k) => snapshot[k].state === 'target_changed')
|
|
995
|
+
{reviewKinds
|
|
973
996
|
.map((k) => (
|
|
974
997
|
<label key={k} className="flex items-center gap-2 text-sm">
|
|
975
998
|
<input
|
|
@@ -996,6 +1019,14 @@ export function LocalConnectionSettings({
|
|
|
996
1019
|
>
|
|
997
1020
|
Keep selected settings for this cluster
|
|
998
1021
|
</button>
|
|
1022
|
+
{separateReviewKinds.length > 0 && (
|
|
1023
|
+
<p className="text-xs text-theme-text-secondary">
|
|
1024
|
+
{separateReviewKinds.map((k) => names[k]).join(' and ')}{' '}
|
|
1025
|
+
{separateIdentityKnown
|
|
1026
|
+
? `${separateReviewKinds.length > 1 ? 'were' : 'was'} saved for a different previous cluster, so ${separateReviewKinds.length > 1 ? 'review them in their own tabs' : 'review it in its own tab'}.`
|
|
1027
|
+
: `${separateReviewKinds.length > 1 ? 'need separate reviews in their own tabs' : 'needs a separate review in its own tab'}.`}
|
|
1028
|
+
</p>
|
|
1029
|
+
)}
|
|
999
1030
|
</div>
|
|
1000
1031
|
)}
|
|
1001
1032
|
{error && !pending && (
|
|
@@ -38,7 +38,7 @@ export const RELEASE_NOTES: ReleaseNotes[] = [
|
|
|
38
38
|
id: 'ai-investigations',
|
|
39
39
|
icon: Bot,
|
|
40
40
|
title: 'AI investigations that show their work',
|
|
41
|
-
// No link: the investigations
|
|
41
|
+
// No link: the investigations page redirects home when this run
|
|
42
42
|
// mode can't host local agents.
|
|
43
43
|
description: 'A clear verdict and the story of what broke, with the charts, logs and config behind it placed where the agent cites them. Now also runs on OpenCode, including AWS Bedrock.',
|
|
44
44
|
},
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// agent-free.
|
|
12
12
|
import { createContext, useContext, useMemo } from "react";
|
|
13
13
|
import type { ReactNode } from "react";
|
|
14
|
-
import type { RunSummary } from "../api/diagnose";
|
|
14
|
+
import type { InvestigationRefusal, RunSummary } from "../api/diagnose";
|
|
15
15
|
|
|
16
16
|
/** Optional host controls beside shared run actions. Return a component element
|
|
17
17
|
* if hooks are needed: this callback is invoked conditionally. Call onRunUpdated
|
|
@@ -21,6 +21,13 @@ export type RenderInvestigationRunActions = (props: {
|
|
|
21
21
|
onRunUpdated: (run: RunSummary) => void;
|
|
22
22
|
}) => ReactNode;
|
|
23
23
|
|
|
24
|
+
/** Host action beside a refused start or follow-up, e.g. Radar Cloud's
|
|
25
|
+
* "Upgrade" when an organization has used its investigations. Called only for
|
|
26
|
+
* refusals that carry a code, reason or action; return null to show none. */
|
|
27
|
+
export type RenderInvestigationRefusalAction = (
|
|
28
|
+
refusal: InvestigationRefusal,
|
|
29
|
+
) => ReactNode;
|
|
30
|
+
|
|
24
31
|
/** Render prop for the resource-level Investigate action. */
|
|
25
32
|
export type RenderDiagnoseAction = (ctx: {
|
|
26
33
|
/** Kubernetes Kind when loaded or discovered; otherwise its API resource name. */
|
|
@@ -63,6 +70,7 @@ export type DiagnoseConsentCopy = {
|
|
|
63
70
|
export interface DiagnoseCustomization {
|
|
64
71
|
renderAction: RenderDiagnoseAction | undefined;
|
|
65
72
|
renderRunActions?: RenderInvestigationRunActions;
|
|
73
|
+
renderRefusalAction?: RenderInvestigationRefusalAction;
|
|
66
74
|
consentCopy: DiagnoseConsentCopy | undefined;
|
|
67
75
|
// undefined = default (CustomEvent → Radar's own Settings dialog);
|
|
68
76
|
// null = hide the settings affordances.
|
|
@@ -82,20 +90,22 @@ export function DiagnoseCustomizationProvider({
|
|
|
82
90
|
value,
|
|
83
91
|
consentCopy,
|
|
84
92
|
renderRunActions,
|
|
93
|
+
renderRefusalAction,
|
|
85
94
|
onOpenSettings,
|
|
86
95
|
children,
|
|
87
96
|
}: {
|
|
88
97
|
value: RenderDiagnoseAction | undefined;
|
|
89
98
|
consentCopy?: DiagnoseConsentCopy;
|
|
90
99
|
renderRunActions?: RenderInvestigationRunActions;
|
|
100
|
+
renderRefusalAction?: RenderInvestigationRefusalAction;
|
|
91
101
|
/** Where "AI settings" affordances lead. Omit for Radar's own Settings
|
|
92
102
|
* dialog; pass `null` to hide them. */
|
|
93
103
|
onOpenSettings?: (() => void) | null;
|
|
94
104
|
children: ReactNode;
|
|
95
105
|
}) {
|
|
96
106
|
const ctx = useMemo(
|
|
97
|
-
() => ({ renderAction: value, consentCopy, onOpenSettings, renderRunActions }),
|
|
98
|
-
[value, consentCopy, onOpenSettings, renderRunActions],
|
|
107
|
+
() => ({ renderAction: value, consentCopy, onOpenSettings, renderRunActions, renderRefusalAction }),
|
|
108
|
+
[value, consentCopy, onOpenSettings, renderRunActions, renderRefusalAction],
|
|
99
109
|
);
|
|
100
110
|
return (
|
|
101
111
|
<DiagnoseCustomizationContext.Provider value={ctx}>
|
package/src/index.ts
CHANGED
|
@@ -30,12 +30,14 @@ export type {
|
|
|
30
30
|
export type {
|
|
31
31
|
RenderDiagnoseAction,
|
|
32
32
|
RenderInvestigationRunActions,
|
|
33
|
+
RenderInvestigationRefusalAction,
|
|
33
34
|
DiagnoseConsentCopy,
|
|
34
35
|
} from './context/DiagnoseCustomization';
|
|
36
|
+
export type { InvestigationRefusal } from './api/diagnose';
|
|
35
37
|
|
|
36
38
|
// Standalone AI investigation surface — mount the investigation panel outside a
|
|
37
|
-
// full <RadarApp>. It uses the nearest React Router for durable drawer
|
|
38
|
-
// URLs, or supplies one when mounted standalone. The backend set via setApiBase()
|
|
39
|
+
// full <RadarApp>. It uses the nearest React Router for durable drawer and
|
|
40
|
+
// investigations-page URLs, or supplies one when mounted standalone. The backend set via setApiBase()
|
|
39
41
|
// picks the cluster, so hosts remount <DiagnoseProvider key={cluster}> to switch.
|
|
40
42
|
// Mount order: ThemeProvider > DiagnoseCustomizationProvider > DiagnoseProvider
|
|
41
43
|
// > DiagnoseSurface, under a @tanstack/react-query QueryClientProvider.
|