@skyhook-io/radar-app 1.16.2 → 1.17.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 (93) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +60 -12
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +168 -55
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.test.ts +40 -0
  8. package/src/api/diagnose.ts +12 -1
  9. package/src/api/radarFeatures.render.test.tsx +19 -1
  10. package/src/api/radarFeatures.ts +3 -0
  11. package/src/api/timelineSource.ts +1 -1
  12. package/src/api/traffic.sources.test.ts +55 -0
  13. package/src/api/traffic.ts +173 -14
  14. package/src/components/NoClusterAccessBanner.tsx +68 -0
  15. package/src/components/applications/ApplicationsView.tsx +3 -0
  16. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  17. package/src/components/capacity/CapacityView.test.tsx +31 -1
  18. package/src/components/capacity/shared.tsx +12 -21
  19. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  20. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  21. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  22. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  23. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  24. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  25. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  26. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  27. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  28. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  29. package/src/components/cnpg/CNPGView.tsx +156 -0
  30. package/src/components/cnpg/paths.ts +13 -0
  31. package/src/components/cnpg/routes.test.ts +60 -0
  32. package/src/components/cnpg/routes.ts +112 -0
  33. package/src/components/cnpg/shared.tsx +290 -0
  34. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  35. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  36. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  37. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  38. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  39. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  40. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  41. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  42. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  43. package/src/components/diagnose/InvestigationView.tsx +39 -2
  44. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  45. package/src/components/diagnose/agentCatalog.ts +2 -2
  46. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  47. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  48. package/src/components/execution/BatchExecutionView.tsx +11 -11
  49. package/src/components/helm/ChartBrowser.tsx +9 -10
  50. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  51. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  52. package/src/components/helm/InstallWizard.tsx +5 -2
  53. package/src/components/helm/RevisionHistory.tsx +3 -2
  54. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  55. package/src/components/helm/ValuesViewer.tsx +1 -1
  56. package/src/components/home/ClusterHealthCard.tsx +7 -0
  57. package/src/components/home/mcpToolCatalog.ts +1 -1
  58. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  59. package/src/components/resources/ResourcesView.tsx +6 -38
  60. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  61. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  63. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  64. package/src/components/settings/SettingsDialog.tsx +122 -19
  65. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  66. package/src/components/timeline/TimelineList.test.ts +47 -29
  67. package/src/components/timeline/TimelineList.tsx +60 -11
  68. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  69. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  70. package/src/components/timeline/TimelineView.tsx +16 -2
  71. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  72. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  73. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  74. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  75. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  76. package/src/components/traffic/TrafficGraph.tsx +109 -26
  77. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  78. package/src/components/traffic/TrafficView.tsx +582 -149
  79. package/src/components/traffic/trafficFilters.test.ts +398 -1
  80. package/src/components/traffic/trafficFilters.ts +408 -0
  81. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  82. package/src/components/whats-new/WhatsNew.tsx +146 -86
  83. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  84. package/src/components/whats-new/releaseNotes.ts +155 -24
  85. package/src/components/workload/WorkloadView.test.ts +2 -1
  86. package/src/components/workload/WorkloadView.tsx +165 -294
  87. package/src/context/TimelineCustomization.tsx +15 -0
  88. package/src/contexts/CapabilitiesContext.tsx +9 -1
  89. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  90. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  91. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  92. package/src/hooks/useResourceCounts.ts +49 -0
  93. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -10,6 +10,8 @@ import { useAPIResources } from '../../api/apiResources'
10
10
  import { useConnection } from '../../context/ConnectionContext'
11
11
  import { initNavigationMap, getSecretStoreProviderType } from '@skyhook-io/k8s-ui'
12
12
  import { usePinnedKinds } from '../../hooks/useFavorites'
13
+ import { useResourceCounts } from '../../hooks/useResourceCounts'
14
+ import { useCNPGSidebarWorkspace } from '../cnpg/useCNPGSidebarWorkspace'
13
15
  import { useOpenLogs, useOpenWorkloadLogs } from '../dock'
14
16
  import {
15
17
  canBulkRestartKind,
@@ -26,13 +28,6 @@ import { apiVersionToGroup, kindToPluralWithGroup, type NavigateToResource } fro
26
28
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
27
29
  import { getSkeletonYaml } from '../../utils/skeleton-yaml'
28
30
 
29
- interface ResourceCountsResponse {
30
- counts: Record<string, number>
31
- forbidden?: string[]
32
- reasons?: Record<string, string>
33
- unavailable?: string[]
34
- }
35
-
36
31
  interface ResourcesViewProps {
37
32
  namespaces: string[]
38
33
  selectedResource?: SelectedResource | null
@@ -114,6 +109,8 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
114
109
  if (apiResources) initNavigationMap(apiResources)
115
110
  }, [apiResources])
116
111
 
112
+ const cnpgSidebarWorkspace = useCNPGSidebarWorkspace({ apiResources, namespaces })
113
+
117
114
  // Track the selected kind from the k8s-ui component
118
115
  const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(null)
119
116
  const workloadWrites = namespaces.length === 0
@@ -126,37 +123,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
126
123
 
127
124
  // Lightweight resource counts for sidebar badges (~2KB instead of ~608MB)
128
125
  const namespacesParam = namespaces.join(',')
129
- const { data: countsData, isError: countsIsError } = useQuery({
130
- queryKey: ['resource-counts', namespacesParam],
131
- queryFn: async () => {
132
- const params = new URLSearchParams()
133
- if (namespaces.length > 0) params.set('namespaces', namespacesParam)
134
- const startedAt = performance.now()
135
- debugNamespaceLog('resources:counts-fetch-start', { namespaces, params: params.toString() })
136
- try {
137
- return await fetchJSON<ResourceCountsResponse>(`/resource-counts?${params}`)
138
- } finally {
139
- debugNamespaceLog('resources:counts-fetch-end', {
140
- namespaces,
141
- params: params.toString(),
142
- durationMs: Math.round(performance.now() - startedAt),
143
- })
144
- }
145
- },
146
- staleTime: 10000,
147
- // SSE invalidation isn't running while connecting, and mid-sync counts
148
- // are what unlatch guarded kinds as their informers finish — poll fast
149
- // during the shell, settle to the safety net once connected.
150
- refetchInterval: connection.state === 'connecting' ? 3000 : 60000,
151
- // During the first seconds of the progressive shell the endpoint 503s
152
- // (cluster_connecting) until the mid-sync cache handle exists; keep the
153
- // query pending rather than parking it in error state, which would
154
- // unlatch the large-list guard at the connected flip.
155
- retry: (failureCount: number, error: Error) =>
156
- isStillLoadingError(error) ? true : failureCount < 3,
157
- retryDelay: (failureCount: number, error: Error) =>
158
- isStillLoadingError(error) ? 2000 : Math.min(1000 * 2 ** failureCount, 30000),
159
- })
126
+ const { data: countsData, isError: countsIsError } = useResourceCounts(namespaces)
160
127
 
161
128
  // Determine if selected kind is a CRD (only CRDs should send ?group= to backend)
162
129
  const isSelectedCrd = useMemo(() => {
@@ -440,6 +407,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
440
407
  connectionState={connection.state === 'connecting' && connection.syncStatus ? 'syncing' : connection.state}
441
408
  largeListGuard={largeListGuard}
442
409
  onSelectedKindChange={setSelectedKind}
410
+ sidebarCategoryWorkspaces={cnpgSidebarWorkspace}
443
411
  topPodMetrics={topPodMetrics}
444
412
  topNodeMetrics={topNodeMetrics}
445
413
  certExpiry={certExpiry}
@@ -7,6 +7,7 @@ import { formatAge } from '@skyhook-io/k8s-ui/components/resources/resource-util
7
7
  import { isForbiddenError } from '../../../api/client'
8
8
  import { isKyvernoMutateExistingEnabled } from '@skyhook-io/k8s-ui/components/resources/resource-utils-kyverno-modern'
9
9
  import { usePolicyQueued } from '../../../api/policy'
10
+ import { TimeValue } from '@skyhook-io/k8s-ui/components/ui/ScheduleValue'
10
11
 
11
12
  /** A request sitting this long is not mid-flight. Kyverno retries on every
12
13
  * reconcile, so past this point a backlog grows rather than drains. */
@@ -160,7 +161,7 @@ export function KyvernoPolicyQueued({ data }: { data: any }) {
160
161
  />
161
162
  ))}
162
163
  {oldestPending && (
163
- <Property label="Oldest Pending" value={`${formatAge(oldestPending)} ago`} />
164
+ <Property label="Oldest Pending" value={<TimeValue timestamp={oldestPending} />} />
164
165
  )}
165
166
  </PropertyList>
166
167
  {messages.length > 0 && (
@@ -1,6 +1,7 @@
1
1
  import { NodeRenderer as BaseNodeRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/NodeRenderer'
2
2
  import { useNavigate } from 'react-router-dom'
3
- import { getVisibleLiveMetrics, isLiveMetricsUnavailable, shouldFetchLiveMetrics, useNodeMetrics, useNodeMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
3
+ import { getVisibleLiveMetrics, isLiveMetricsUnavailable, shouldFetchLiveMetrics, useNodeRemovalPlan, useNodeMetrics, useNodeMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
4
+ import { getNodeLifecycle } from '@skyhook-io/k8s-ui/utils/node-lifecycle'
4
5
  import { serializeColumnFilters } from '../resource-utils'
5
6
 
6
7
  interface NodeRendererProps {
@@ -11,6 +12,8 @@ interface NodeRendererProps {
11
12
  export function NodeRenderer({ data, relationships }: NodeRendererProps) {
12
13
  const navigate = useNavigate()
13
14
  const nodeName = data.metadata?.name
15
+ const lifecycle = getNodeLifecycle(data)
16
+ const removalPlan = useNodeRemovalPlan(nodeName, lifecycle.removing)
14
17
 
15
18
  // Fetch node metrics
16
19
  const metricsHistoryQuery = useNodeMetricsHistory(nodeName)
@@ -35,6 +38,9 @@ export function NodeRenderer({ data, relationships }: NodeRendererProps) {
35
38
  return (
36
39
  <BaseNodeRenderer
37
40
  data={data}
41
+ removalPlan={removalPlan.data}
42
+ removalPlanLoading={removalPlan.isLoading}
43
+ removalPlanError={removalPlan.error?.message}
38
44
  relationships={relationships}
39
45
  onViewPods={nodeName ? () => {
40
46
  const params = new URLSearchParams()
@@ -0,0 +1,81 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { beforeEach, expect, it, vi } from 'vitest'
3
+ import { RadarFeatureUnsupportedError } from '../../../api/radarFeatures'
4
+ import { ServiceRenderer } from './ServiceRenderer'
5
+
6
+ const mock = vi.hoisted(() => ({
7
+ serviceQuery: {} as any,
8
+ namespaceQuery: {} as any,
9
+ serviceReads: [] as any[],
10
+ namespaceReads: [] as any[],
11
+ }))
12
+ vi.mock('../../../api/client', () => ({
13
+ useServiceEndpointSlices: (...args: any[]) => { mock.serviceReads.push(args); return mock.serviceQuery },
14
+ useResources: (...args: any[]) => { mock.namespaceReads.push(args); return mock.namespaceQuery },
15
+ }))
16
+ vi.mock('../../../contexts/CapabilitiesContext', () => ({ useNamespacedCapabilities: () => ({ canPortForward: false }), useIsLocalDeployment: () => true }))
17
+ vi.mock('../../portforward/PortForwardButton', () => ({ PortForwardInlineButton: () => null }))
18
+ vi.mock('../../curl/ServiceCurlButton', () => ({ CurlButton: () => null, CurlPanel: () => null, isHttpishPort: () => false, defaultScheme: () => '', defaultPathForPort: () => '' }))
19
+
20
+ const service = { apiVersion: 'v1', kind: 'Service', metadata: { name: 'web', namespace: 'team' }, spec: { selector: { app: 'web' } } }
21
+ const selectorless = { ...service, spec: {} }
22
+ const slice = (name: string, labels?: Record<string, string>) => ({ apiVersion: 'discovery.k8s.io/v1', kind: 'EndpointSlice', metadata: { namespace: 'team', name, labels } })
23
+ const render = (data: any = service) => renderToStaticMarkup(<ServiceRenderer data={data} onCopy={() => {}} copied={null} />)
24
+ const unsupported = () => new RadarFeatureUnsupportedError('serviceEndpointSlices', { currentVersion: 'v1.7.0' })
25
+
26
+ beforeEach(() => {
27
+ mock.serviceQuery = {}
28
+ mock.namespaceQuery = {}
29
+ mock.serviceReads = []
30
+ mock.namespaceReads = []
31
+ })
32
+
33
+ it('renders the server-selected slices for a selector Service without listing the namespace', () => {
34
+ mock.serviceQuery = { data: { items: [slice('web-abc', { 'kubernetes.io/service-name': 'web' })], truncated: false } }
35
+ const html = render()
36
+ expect(mock.serviceReads[0]).toEqual(['team', 'web', true])
37
+ expect(mock.namespaceReads[0][3].enabled).toBe(false)
38
+ expect(html).toContain('EndpointSlices')
39
+ expect(html).toContain('web-abc')
40
+ expect(html).not.toContain('Showing the first')
41
+ })
42
+
43
+ it('says when the server cut the inventory off', () => {
44
+ mock.serviceQuery = { data: { items: [slice('web-abc')], truncated: true } }
45
+ expect(render()).toContain('Showing the first 1 EndpointSlices.')
46
+ })
47
+
48
+ it('surfaces a failed lookup instead of an empty inventory', () => {
49
+ mock.serviceQuery = { error: new Error('insufficient permissions to list endpointslices') }
50
+ const html = render()
51
+ expect(html).toContain('EndpointSlice inventory unavailable')
52
+ expect(html).toContain('insufficient permissions to list endpointslices')
53
+ expect(html).not.toContain('No EndpointSlices found')
54
+ })
55
+
56
+ it('does not query slices for ExternalName Services', () => {
57
+ const html = render({ ...service, spec: { type: 'ExternalName', externalName: 'example.org' } })
58
+ expect(mock.serviceReads[0][2]).toBe(false)
59
+ expect(mock.namespaceReads[0][3].enabled).toBe(false)
60
+ expect(html).not.toContain('EndpointSlices')
61
+ })
62
+
63
+ it('falls back to the label-filtered namespace list for selector-less Services on an older Radar', () => {
64
+ mock.serviceQuery = { error: unsupported() }
65
+ mock.namespaceQuery = { data: [
66
+ slice('manual-abc', { 'kubernetes.io/service-name': 'web' }),
67
+ slice('other-abc', { 'kubernetes.io/service-name': 'other' }),
68
+ ] }
69
+ const html = render(selectorless)
70
+ expect(mock.namespaceReads[0]).toEqual(['endpointslices', 'team', 'discovery.k8s.io', { enabled: true, refetchInterval: 30000 }])
71
+ expect(html).toContain('manual-abc')
72
+ expect(html).not.toContain('other-abc')
73
+ expect(html).not.toContain('EndpointSlice inventory unavailable')
74
+ })
75
+
76
+ it('keeps selector Services free of slice lookups on an older Radar', () => {
77
+ mock.serviceQuery = { error: unsupported() }
78
+ const html = render()
79
+ expect(mock.namespaceReads[0][3].enabled).toBe(false)
80
+ expect(html).not.toContain('EndpointSlices')
81
+ })
@@ -2,7 +2,8 @@ import { useMemo, useState, useCallback } from 'react'
2
2
  import { ServiceRenderer as BaseServiceRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
3
3
  import { PortForwardInlineButton } from '../../portforward/PortForwardButton'
4
4
  import { CurlButton, CurlPanel, isHttpishPort, defaultScheme, defaultPathForPort } from '../../curl/ServiceCurlButton'
5
- import { useResources } from '../../../api/client'
5
+ import { useResources, useServiceEndpointSlices } from '../../../api/client'
6
+ import { isRadarFeatureUnsupported } from '../../../api/radarFeatures'
6
7
  import { useNamespacedCapabilities, useIsLocalDeployment } from '../../../contexts/CapabilitiesContext'
7
8
  import type { ResourceRef } from '../../../types'
8
9
  import { DURATION_DISCLOSURE } from '@skyhook-io/k8s-ui/utils/animation'
@@ -36,21 +37,22 @@ export function ServiceRenderer({ data, onCopy, copied, onNavigate }: ServiceRen
36
37
  window.setTimeout(() => setCurl((p) => (p?.closing ? null : p)), DURATION_DISCLOSURE + 20)
37
38
  }, [])
38
39
  const spec = data.spec || {}
39
- const shouldLoadEndpointSlices = Boolean(
40
- namespace &&
41
- serviceName &&
42
- spec.type !== 'ExternalName' &&
43
- (!spec.selector || Object.keys(spec.selector).length === 0)
44
- )
45
- const { data: endpointSlices, isLoading: endpointSlicesLoading } = useResources<any>(
40
+ const hasNoSelector = !spec.selector || Object.keys(spec.selector).length === 0
41
+ const canListEndpointSlices = Boolean(namespace && serviceName && spec.type !== 'ExternalName')
42
+ const serviceSlices = useServiceEndpointSlices(namespace, serviceName, canListEndpointSlices)
43
+ // A Radar without the per-Service endpoint keeps its earlier behavior: only
44
+ // selector-less Services list the namespace's slices and filter them here.
45
+ const legacy = isRadarFeatureUnsupported(serviceSlices.error, 'serviceEndpointSlices')
46
+ const shouldLoadNamespaceSlices = canListEndpointSlices && hasNoSelector && legacy
47
+ const { data: namespaceSlices, isLoading: namespaceSlicesLoading, error: namespaceSlicesError } = useResources<any>(
46
48
  'endpointslices',
47
49
  namespace,
48
50
  'discovery.k8s.io',
49
- { enabled: shouldLoadEndpointSlices, refetchInterval: 30000 }
51
+ { enabled: shouldLoadNamespaceSlices, refetchInterval: 30000 }
50
52
  )
51
- const matchingEndpointSlices = useMemo(
52
- () => (endpointSlices || []).filter((slice: any) => slice.metadata?.labels?.['kubernetes.io/service-name'] === serviceName),
53
- [endpointSlices, serviceName]
53
+ const matchingNamespaceSlices = useMemo(
54
+ () => (namespaceSlices || []).filter((slice: any) => slice.metadata?.labels?.['kubernetes.io/service-name'] === serviceName),
55
+ [namespaceSlices, serviceName]
54
56
  )
55
57
 
56
58
  return (
@@ -58,8 +60,11 @@ export function ServiceRenderer({ data, onCopy, copied, onNavigate }: ServiceRen
58
60
  data={data}
59
61
  onCopy={onCopy}
60
62
  copied={copied}
61
- endpointSlices={matchingEndpointSlices}
62
- endpointSlicesLoading={endpointSlicesLoading}
63
+ endpointSlices={legacy ? matchingNamespaceSlices : serviceSlices.data?.items}
64
+ endpointSlicesLoading={legacy ? namespaceSlicesLoading : serviceSlices.isLoading}
65
+ endpointSlicesEnabled={!legacy && canListEndpointSlices}
66
+ endpointSlicesError={(legacy ? namespaceSlicesError : serviceSlices.error)?.message}
67
+ endpointSlicesTruncated={!legacy && serviceSlices.data?.truncated}
63
68
  onNavigate={onNavigate}
64
69
  renderPortAction={({ port, name, appProtocol, protocol }) => (
65
70
  <>
@@ -23,7 +23,7 @@ import { AISettingsSection, type AIDraft } from '../diagnose/AISettings'
23
23
  import { MyPermissionsContent } from './MyPermissionsDialog'
24
24
  import { PrivacySection } from './PrivacySection'
25
25
  import { ConfigToggle, SubHeading } from './controls'
26
- import { useDiagnose } from '../diagnose/DiagnoseContext'
26
+ import { useDiagnose, type DiagnoseSetup } from '../diagnose/DiagnoseContext'
27
27
  import { currencyOptionsForValue } from './currency-options'
28
28
  import { UpdateNotification } from '../ui/UpdateNotification'
29
29
  import {
@@ -924,7 +924,13 @@ export function SettingsDialog({
924
924
  )}
925
925
  </div>
926
926
  ) : (
927
- <AIUnavailableNotice />
927
+ <AIUnavailableNotice
928
+ setupState={diag.setupState}
929
+ cliOverride={diag.cliOverride}
930
+ checkingAgents={diag.checkingAgents}
931
+ agentsCheckFailed={diag.agentsCheckFailed}
932
+ recheckAgents={diag.recheckAgents}
933
+ />
928
934
  )}
929
935
  </div>
930
936
 
@@ -1354,8 +1360,7 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
1354
1360
  },
1355
1361
  {
1356
1362
  id: 'ai', icon: Sparkles, label: 'AI investigations',
1357
- tone: aiAvailable ? 'ok' : 'off',
1358
- value: aiAvailable ? 'Ready' : 'No agent CLI',
1363
+ ...aiOverviewStatus(aiAvailable, diag.setupState, diag.cliOverride, diag.agentsCheckFailed),
1359
1364
  detail: aiAvailable ? agentLabel : undefined,
1360
1365
  },
1361
1366
  ]
@@ -1408,6 +1413,33 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
1408
1413
  )
1409
1414
  }
1410
1415
 
1416
+ // aiOverviewStatus says which reason applies, for the same reason
1417
+ // AIUnavailableNotice does: "No agent CLI" is a claim about the user's machine,
1418
+ // and it is false when RADAR_AI_CLI_BIN is what's broken, when an agent is
1419
+ // reported but off, when this deployment can't run investigations at all, or
1420
+ // when the agents probe hasn't answered.
1421
+ function aiOverviewStatus(
1422
+ aiAvailable: boolean,
1423
+ setupState: DiagnoseSetup,
1424
+ cliOverride: boolean,
1425
+ checkFailed: boolean,
1426
+ ): Pick<OverviewRow, 'tone' | 'value'> {
1427
+ if (aiAvailable) return { tone: 'ok', value: 'Ready' }
1428
+ if (cliOverride && (setupState === 'needs-install' || setupState === 'needs-restart')) {
1429
+ return { tone: 'warn', value: "RADAR_AI_CLI_BIN can't be run" }
1430
+ }
1431
+ switch (setupState) {
1432
+ case 'unknown':
1433
+ return checkFailed ? { tone: 'warn', value: "Couldn't check" } : { tone: 'unknown', value: 'Checking…' }
1434
+ case 'needs-restart':
1435
+ return { tone: 'warn', value: 'Not available right now' }
1436
+ case 'off':
1437
+ return { tone: 'off', value: 'Not available in this deployment' }
1438
+ default:
1439
+ return { tone: 'off', value: 'No agent CLI' }
1440
+ }
1441
+ }
1442
+
1411
1443
  function OverviewStatus({ tone }: { tone: OverviewTone }) {
1412
1444
  const cls =
1413
1445
  tone === 'ok' ? 'bg-green-500'
@@ -1417,24 +1449,95 @@ function OverviewStatus({ tone }: { tone: OverviewTone }) {
1417
1449
  return <span className={clsx('w-2 h-2 rounded-full shrink-0', cls)} />
1418
1450
  }
1419
1451
 
1420
- // AIUnavailableNotice is the body of the AI investigations tab when no supported agent
1421
- // CLI is installed — the heading/description are provided by the tab itself, so
1422
- // this is just the enable explainer (keeping the feature discoverable to whoever
1423
- // would set it up).
1424
- function AIUnavailableNotice() {
1425
- return (
1452
+ // AIUnavailableNotice is the body of the AI investigations tab when the agent,
1453
+ // model and effort controls have nothing to configure. The tab supplies the
1454
+ // heading, so this is just the explainer.
1455
+ //
1456
+ // It must say WHICH of the reasons applies. "No supported agent CLI found" is a
1457
+ // claim about the user's machine, and when a CLI was found but investigations
1458
+ // didn't start it is false.
1459
+ function AIUnavailableNotice({
1460
+ setupState,
1461
+ cliOverride,
1462
+ checkingAgents,
1463
+ agentsCheckFailed,
1464
+ recheckAgents,
1465
+ }: {
1466
+ setupState: DiagnoseSetup
1467
+ cliOverride: boolean
1468
+ checkingAgents: boolean
1469
+ agentsCheckFailed: boolean
1470
+ recheckAgents: () => Promise<void>
1471
+ }) {
1472
+ // The state the tab was in when a check started. "Still…" is only true when
1473
+ // the finished check left it there.
1474
+ const [checkedState, setCheckedState] = useState<DiagnoseSetup | null>(null)
1475
+ const unchanged = checkedState === setupState && !checkingAgents && !agentsCheckFailed
1476
+ const checkAgain = (
1477
+ <button
1478
+ type="button"
1479
+ disabled={checkingAgents}
1480
+ onClick={() => void recheckAgents().then(() => setCheckedState(setupState))}
1481
+ className="mt-3 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium btn-brand rounded-md disabled:opacity-50"
1482
+ >
1483
+ <RotateCw className={clsx('w-3.5 h-3.5', checkingAgents && 'animate-spin')} />
1484
+ {checkingAgents ? 'Checking…' : 'Check again'}
1485
+ </button>
1486
+ )
1487
+ const panel = (title: string, body?: ReactNode, action?: ReactNode) => (
1426
1488
  <div className="rounded-md border border-theme-border bg-theme-elevated/50 p-3">
1427
- <p className="text-sm font-medium text-theme-text-primary">No supported agent CLI found</p>
1428
- <p className="mt-1 text-xs text-theme-text-tertiary">
1429
- Install <span className="text-theme-text-secondary">Claude Code</span>,{' '}
1430
- <span className="text-theme-text-secondary">Codex</span>,{' '}
1431
- <span className="text-theme-text-secondary">Cursor</span> (
1432
- <span className="font-mono">cursor-agent</span>), or{' '}
1433
- <span className="text-theme-text-secondary">OpenCode</span>, then restart Radar — this tab
1434
- will show the agent, model, and effort controls.
1435
- </p>
1489
+ <p className="text-sm font-medium text-theme-text-primary">{title}</p>
1490
+ {body && <p className="mt-1 text-xs text-theme-text-tertiary">{body}</p>}
1491
+ {action}
1436
1492
  </div>
1437
1493
  )
1494
+ if (setupState === 'unknown') {
1495
+ // The agent probe hasn't answered. Saying anything about the CLI or the
1496
+ // deployment here would be a guess.
1497
+ return agentsCheckFailed && !checkingAgents
1498
+ ? panel("Couldn't check for agent CLIs", 'Try again.', checkAgain)
1499
+ : panel('Checking…')
1500
+ }
1501
+ if (setupState === 'off') {
1502
+ return panel(
1503
+ 'Not available in this deployment',
1504
+ 'They need Radar running on your own computer, with MCP on and sign-in off.',
1505
+ )
1506
+ }
1507
+ if (cliOverride) {
1508
+ return panel(
1509
+ "Radar can't run your RADAR_AI_CLI_BIN",
1510
+ <>
1511
+ Fix the path, or remove it and restart Radar.
1512
+ {unchanged && " Still can't run it."}
1513
+ </>,
1514
+ checkAgain,
1515
+ )
1516
+ }
1517
+ if (setupState === 'needs-restart') {
1518
+ return panel(
1519
+ "AI investigations aren't available right now",
1520
+ <>
1521
+ {agentsCheckFailed && !checkingAgents ? "Couldn't check. Try again." : 'Try again in a moment.'}
1522
+ {unchanged && ' Still not available.'}
1523
+ </>,
1524
+ checkAgain,
1525
+ )
1526
+ }
1527
+ return panel(
1528
+ 'No supported agent CLI found',
1529
+ <>
1530
+ Install <span className="text-theme-text-secondary">Claude Code</span>,{' '}
1531
+ <span className="text-theme-text-secondary">Codex</span>,{' '}
1532
+ <span className="text-theme-text-secondary">Cursor</span> (
1533
+ <span className="font-mono">cursor-agent</span>), or{' '}
1534
+ <span className="text-theme-text-secondary">OpenCode</span>, and this tab will show the
1535
+ agent, model, and effort controls.
1536
+ {agentsCheckFailed && !checkingAgents && " Couldn't check. Try again."}
1537
+ {unchanged && ' Still not found.'}
1538
+ </>,
1539
+ checkAgain,
1540
+ )
1438
1541
  }
1439
1542
 
1440
1543
  // -- Startup section bodies ---------------------------------------------------
@@ -1,10 +1,14 @@
1
- import { useState, useRef, useEffect, useMemo } from 'react'
1
+ import { useState, useRef, useEffect, useMemo, type ReactNode } from 'react'
2
2
  import { Search } from 'lucide-react'
3
3
  import { Input } from '@skyhook-io/k8s-ui'
4
4
 
5
- export function LargeClusterNamespacePicker({ namespaces, onSelect }: {
5
+ export function LargeClusterNamespacePicker({ namespaces, onSelect, ranked = false, renderMeta }: {
6
6
  namespaces: { name: string }[] | undefined
7
7
  onSelect: (ns: string) => void
8
+ /** Keep the given order (already ranked) instead of sorting by name. */
9
+ ranked?: boolean
10
+ /** Extra detail shown at the end of a namespace's row. */
11
+ renderMeta?: (name: string) => ReactNode
8
12
  }) {
9
13
  const [search, setSearch] = useState('')
10
14
  const inputRef = useRef<HTMLInputElement>(null)
@@ -15,8 +19,9 @@ export function LargeClusterNamespacePicker({ namespaces, onSelect }: {
15
19
 
16
20
  const sorted = useMemo(() => {
17
21
  if (!namespaces) return []
22
+ if (ranked) return namespaces
18
23
  return [...namespaces].sort((a, b) => a.name.localeCompare(b.name))
19
- }, [namespaces])
24
+ }, [namespaces, ranked])
20
25
 
21
26
  const filtered = useMemo(() => {
22
27
  if (!search.trim()) return sorted
@@ -51,9 +56,10 @@ export function LargeClusterNamespacePicker({ namespaces, onSelect }: {
51
56
  key={ns.name}
52
57
  type="button"
53
58
  onClick={() => onSelect(ns.name)}
54
- className="w-full text-left px-3 py-2 text-sm text-theme-text-primary hover:bg-theme-hover transition-colors border-b border-theme-border last:border-b-0"
59
+ className="w-full flex items-center justify-between gap-3 text-left px-3 py-2 text-sm text-theme-text-primary hover:bg-theme-hover transition-colors border-b border-theme-border last:border-b-0"
55
60
  >
56
- {ns.name}
61
+ <span className="truncate">{ns.name}</span>
62
+ {renderMeta?.(ns.name)}
57
63
  </button>
58
64
  ))
59
65
  )}
@@ -1,11 +1,13 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { applyClientFilters } from '../../api/timelineSource'
3
3
  import type { TimelineEvent } from '../../types'
4
- import { listIncludesManaged } from './TimelineList'
4
+ import { splitRoutineActivity } from './TimelineList'
5
5
 
6
+ let clock = Date.parse('2024-01-01T00:00:00.000Z')
6
7
  function ev(over: Partial<TimelineEvent> & { id: string }): TimelineEvent {
8
+ clock += 1000
7
9
  return {
8
- timestamp: '2024-01-01T00:00:00.000Z',
10
+ timestamp: new Date(clock).toISOString(),
9
11
  source: 'informer',
10
12
  kind: 'Deployment',
11
13
  namespace: 'ns-a',
@@ -15,42 +17,58 @@ function ev(over: Partial<TimelineEvent> & { id: string }): TimelineEvent {
15
17
  }
16
18
  }
17
19
 
18
- // Mirrors the host: the source filter runs first, then the UI keeps the selected kinds.
19
- function listRows(events: TimelineEvent[], kinds: string[]): string[] {
20
- return applyClientFilters(events, { includeManaged: listIncludesManaged(false, kinds) })
21
- .filter((e) => kinds.length === 0 || kinds.includes(e.kind))
22
- .map((e) => e.id)
20
+ const deploy = ev({ id: 'deploy', kind: 'Deployment' })
21
+ const podStarted = ev({ id: 'pod-started', kind: 'Pod', source: 'k8s_event', eventType: 'Normal', reason: 'Started' })
22
+ const podOom = ev({ id: 'pod-oom', kind: 'Pod', source: 'k8s_event', eventType: 'Warning', reason: 'OOMKilled' })
23
+ const rsScaled = ev({ id: 'rs-scaled', kind: 'ReplicaSet' })
24
+ const cronJobOk = ev({ id: 'cron-job-ok', kind: 'Job', owner: { kind: 'CronJob', name: 'nightly' }, healthState: 'healthy' })
25
+ const cronJobFailed = ev({ id: 'cron-job-failed', kind: 'Job', owner: { kind: 'CronJob', name: 'nightly' }, healthState: 'unhealthy' })
26
+ const standaloneJob = ev({ id: 'standalone-job', kind: 'Job' })
27
+ const ALL = [deploy, podStarted, podOom, rsScaled, cronJobOk, cronJobFailed, standaloneJob]
28
+
29
+ // Mirrors the host: one uncapped query through the source's client filters.
30
+ function split(events: TimelineEvent[], kinds: string[], limit = 2000) {
31
+ return splitRoutineActivity(applyClientFilters(events, { kinds, includeManaged: true }), limit)
23
32
  }
33
+ const ids = (rows: TimelineEvent[]) => rows.map((e) => e.id).sort()
34
+ const oom = (id: string) => ev({ id, kind: 'Pod', source: 'k8s_event', eventType: 'Warning', reason: 'OOMKilled' })
35
+ const started = (id: string) => ev({ id, kind: 'Pod', source: 'k8s_event', eventType: 'Normal', reason: 'Started' })
24
36
 
25
- describe('listIncludesManaged', () => {
26
- it('includes managed rows when app-scoped or when any kind is selected', () => {
27
- expect(listIncludesManaged(true, [])).toBe(true)
28
- expect(listIncludesManaged(false, ['Pod'])).toBe(true)
29
- expect(listIncludesManaged(false, ['Job'])).toBe(true)
37
+ describe('splitRoutineActivity', () => {
38
+ it('keeps owner rows and child problems, holds routine child rows apart', () => {
39
+ const { withoutRoutine, routine } = split(ALL, [])
40
+ expect(ids(withoutRoutine)).toEqual(['cron-job-failed', 'deploy', 'pod-oom', 'standalone-job'])
41
+ expect(ids(routine)).toEqual(['cron-job-ok', 'pod-started', 'rs-scaled'])
30
42
  })
31
43
 
32
- it('keeps hiding managed rows for the unfiltered list', () => {
33
- expect(listIncludesManaged(false, [])).toBe(false)
34
- })
35
- })
44
+ it('shows a picked kind\'s problems and holds its routine rows apart', () => {
45
+ const pods = split(ALL, ['Pod'])
46
+ expect(ids(pods.withoutRoutine)).toEqual(['pod-oom'])
47
+ expect(ids(pods.routine)).toEqual(['pod-started'])
36
48
 
37
- describe('timeline list rows', () => {
38
- const events = [
39
- ev({ id: 'dep', kind: 'Deployment' }),
40
- ev({ id: 'pod', kind: 'Pod' }),
41
- ev({ id: 'job', kind: 'Job' }),
42
- ev({ id: 'cron-job', kind: 'Job', owner: { kind: 'CronJob', name: 'nightly' } }),
43
- ]
49
+ const jobs = split(ALL, ['Job'])
50
+ expect(ids(jobs.withoutRoutine)).toEqual(['cron-job-failed', 'standalone-job'])
51
+ expect(ids(jobs.routine)).toEqual(['cron-job-ok'])
52
+ })
44
53
 
45
- it('lists pod rows for a Pod selection', () => {
46
- expect(listRows(events, ['Pod'])).toEqual(['pod'])
54
+ it('lists rows newest first', () => {
55
+ expect(split(ALL, []).withoutRoutine.map((e) => e.id)).toEqual(['standalone-job', 'cron-job-failed', 'pod-oom', 'deploy'])
47
56
  })
48
57
 
49
- it('lists owned rows of an unmanaged kind for that kind', () => {
50
- expect(listRows(events, ['Job']).sort()).toEqual(['cron-job', 'job'])
58
+ it('never lets owner rows crowd out child problems', () => {
59
+ const owners = [1, 2, 3].map((n) => ev({ id: `deploy-${n}`, kind: 'Deployment' }))
60
+ const { withoutRoutine, truncated } = split([oom('old-oom'), ...owners], [], 2)
61
+ expect(ids(withoutRoutine)).toEqual(['deploy-2', 'deploy-3', 'old-oom'])
62
+ expect(truncated).toBe(true)
51
63
  })
52
64
 
53
- it('hides pod and owned rows when no kind is selected', () => {
54
- expect(listRows(events, []).sort()).toEqual(['dep', 'job'])
65
+ it('only adds rows when routine activity is shown', () => {
66
+ const olderDeploy = ev({ id: 'older-deploy', kind: 'Deployment' })
67
+ const burst = [1, 2, 3].map((n) => started(`started-${n}`))
68
+ const { withoutRoutine, withRoutine, routine, routineTruncated } = split([olderDeploy, ...burst], [], 2)
69
+ expect(ids(withoutRoutine)).toEqual(['older-deploy'])
70
+ expect(ids(withRoutine)).toEqual(['older-deploy', 'started-2', 'started-3'])
71
+ expect(ids(routine)).toEqual(['started-2', 'started-3'])
72
+ expect(routineTruncated).toBe(true)
55
73
  })
56
74
  })