@skyhook-io/radar-app 1.16.0 → 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.
Files changed (39) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +5 -4
  3. package/src/RadarApp.tsx +10 -2
  4. package/src/api/client.ts +27 -6
  5. package/src/api/client.updateResource.test.tsx +58 -0
  6. package/src/api/diagnose.ts +26 -0
  7. package/src/api/httpErrors.ts +12 -0
  8. package/src/api/radarFeatures.render.test.tsx +34 -1
  9. package/src/api/radarFeatures.test.ts +9 -0
  10. package/src/api/radarFeatures.ts +1 -0
  11. package/src/components/ConnectionErrorView.test.tsx +2 -0
  12. package/src/components/ConnectionErrorView.tsx +2 -1
  13. package/src/components/diagnose/AgentControls.tsx +1 -1
  14. package/src/components/diagnose/AssessmentCard.tsx +11 -1
  15. package/src/components/diagnose/DiagnoseContext.tsx +35 -22
  16. package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
  17. package/src/components/diagnose/Home.tsx +1 -1
  18. package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
  19. package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
  20. package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
  21. package/src/components/diagnose/InvestigationView.tsx +199 -65
  22. package/src/components/diagnose/investigationSourceFocus.ts +6 -6
  23. package/src/components/diagnose/investigationState.test.ts +83 -1
  24. package/src/components/diagnose/investigationState.ts +43 -0
  25. package/src/components/diagnose/launch.ts +2 -2
  26. package/src/components/diagnose/useDisclosureReveal.ts +1 -1
  27. package/src/components/dock/BottomDock.tsx +1 -1
  28. package/src/components/dock/DockContext.tsx +27 -2
  29. package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
  30. package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
  31. package/src/components/dock/LocalTerminalTab.tsx +81 -6
  32. package/src/components/logs/LogsViewer.tsx +3 -0
  33. package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
  34. package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
  35. package/src/components/settings/LocalConnectionSettings.tsx +41 -10
  36. package/src/components/whats-new/releaseNotes.ts +1 -1
  37. package/src/components/workload/WorkloadView.tsx +26 -11
  38. package/src/context/DiagnoseCustomization.tsx +13 -3
  39. package/src/index.ts +4 -2
@@ -1,22 +1,97 @@
1
- import { LocalTerminalTab as SharedLocalTerminalTab } from '@skyhook-io/k8s-ui'
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 createSession = () =>
11
- Promise.resolve({
12
- wsUrl: getWsUrl('/local-terminal'),
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 saved connections.')
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 connection.')
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]} connection for ${selected.context}.`,
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 saved connections…
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 connections yet.
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 connections are still
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]} connection for ${use.context}`}
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 saved connections
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 connection?"
371
+ title="Remove saved settings?"
366
372
  message={
367
373
  selected
368
- ? `Remove the saved ${integrationNames[selected.integration]} connection and credentials for ${selected.context}? Other saved connections 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.' : ''}`
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 connection"
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 saved connections before trying again.
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
- const acceptedChanges = accepted.filter(
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 saved connections.')
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
- requestAnimationFrame(() => region.current?.querySelector<HTMLElement>('input:not(:disabled), button[aria-haspopup="listbox"]:not(:disabled)')?.focus())
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
- requestAnimationFrame(() => editorRegion.current?.querySelector<HTMLInputElement>('input:not(:disabled)')?.focus())
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
- requestAnimationFrame(() => editorRegion.current?.querySelector<HTMLInputElement>('input:not(:disabled)')?.focus())
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
- {(Object.keys(snapshot) as IntegrationKind[])
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 workspace redirects home when this run
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
  },
@@ -49,6 +49,7 @@ import {
49
49
  import {
50
50
  useWorkloadHistory,
51
51
  fetchWorkloadHistoryPage,
52
+ useChanges,
52
53
  useResourceWithRelationships,
53
54
  usePodLogs,
54
55
  useTopology,
@@ -803,18 +804,32 @@ export function WorkloadView({
803
804
  // colliding kind) under the wrong group and refetch once it settles.
804
805
  const historyGroupSettled = Boolean(rest.group) || resource !== undefined || resourceError != null
805
806
  const historyQuery = useWorkloadHistory(apiKind, namespace, name, effectiveGroup, expanded && historyGroupSettled)
807
+ // A Radar without the scoped history endpoint gets the namespace's newest
808
+ // changes instead, which the timeline filters to this workload.
809
+ const historyFallback = isRadarFeatureUnsupported(historyQuery.error, 'workloadHistory')
810
+ const namespaceChanges = useChanges({
811
+ namespaces: [namespace],
812
+ timeRange: 'all',
813
+ includeK8sEvents: true,
814
+ includeManaged: true,
815
+ limit: 10000,
816
+ enabled: expanded && historyFallback,
817
+ })
806
818
  const {
807
- events: allEvents,
819
+ events: scopedEvents,
808
820
  truncated: historyTruncated,
809
821
  loadOlder: loadOlderHistory,
810
822
  loadingOlder: loadingOlderHistory,
811
823
  olderError: olderHistoryError,
812
824
  } = useHistoryPaging(
813
825
  `${effectiveGroup ?? ''}/${apiKind}/${namespace}/${name}`,
814
- historyQuery.data,
826
+ historyFallback ? undefined : historyQuery.data,
815
827
  (beforeSeq) => fetchWorkloadHistoryPage(apiKind, namespace, name, effectiveGroup, beforeSeq),
816
828
  )
817
- const eventsLoading = historyQuery.isLoading || (expanded && !historyGroupSettled)
829
+ const allEvents = historyFallback ? namespaceChanges.data : scopedEvents
830
+ const eventsLoading = historyFallback
831
+ ? namespaceChanges.isLoading
832
+ : historyQuery.isLoading || (expanded && !historyGroupSettled)
818
833
 
819
834
  // RBAC
820
835
  const canUpdateSecrets = useCanUpdateSecrets()
@@ -1211,14 +1226,14 @@ export function WorkloadView({
1211
1226
  refetch={refetchResourceAndRuns}
1212
1227
  // Timeline
1213
1228
  allEvents={allEvents}
1214
- historyTruncated={historyTruncated}
1215
- onLoadOlderHistory={loadOlderHistory}
1216
- loadingOlderHistory={loadingOlderHistory}
1217
- olderHistoryError={olderHistoryError}
1218
- historyScoped
1219
- historyIncomplete={Boolean(historyQuery.data?.incomplete)}
1220
- historyError={historyQuery.error as Error | null}
1221
- onRetryHistory={() => void historyQuery.refetch()}
1229
+ historyTruncated={!historyFallback && historyTruncated}
1230
+ onLoadOlderHistory={historyFallback ? undefined : loadOlderHistory}
1231
+ loadingOlderHistory={!historyFallback && loadingOlderHistory}
1232
+ olderHistoryError={historyFallback ? null : olderHistoryError}
1233
+ historyScoped={!historyFallback}
1234
+ historyIncomplete={!historyFallback && Boolean(historyQuery.data?.incomplete)}
1235
+ historyError={(historyFallback ? namespaceChanges.error : historyQuery.error) as Error | null}
1236
+ onRetryHistory={() => void (historyFallback ? namespaceChanges.refetch() : historyQuery.refetch())}
1222
1237
  relatedTimelineEvents={relatedTimelineEvents}
1223
1238
  eventsLoading={eventsLoading || (batchExecution && batchKind !== 'JobSet' && batchRunsQuery.isLoading)}
1224
1239
  topology={topology}
@@ -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/workspace
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.