@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.
- package/package.json +5 -5
- package/src/App.tsx +60 -12
- package/src/RadarApp.tsx +27 -17
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.ts +168 -55
- package/src/api/cnpg.ts +95 -0
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +19 -1
- package/src/api/radarFeatures.ts +3 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/NoClusterAccessBanner.tsx +68 -0
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
- package/src/components/capacity/CapacityView.test.tsx +31 -1
- package/src/components/capacity/shared.tsx +12 -21
- package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
- package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
- package/src/components/cnpg/CNPGOperator.tsx +234 -0
- package/src/components/cnpg/CNPGOverview.tsx +307 -0
- package/src/components/cnpg/CNPGPooling.tsx +116 -0
- package/src/components/cnpg/CNPGProtection.tsx +320 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
- package/src/components/cnpg/CNPGView.tsx +156 -0
- package/src/components/cnpg/paths.ts +13 -0
- package/src/components/cnpg/routes.test.ts +60 -0
- package/src/components/cnpg/routes.ts +112 -0
- package/src/components/cnpg/shared.tsx +290 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
- package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/helm/ChartBrowser.tsx +9 -10
- package/src/components/helm/HelmCompareRoute.tsx +108 -115
- package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
- package/src/components/helm/InstallWizard.tsx +5 -2
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/SettingsDialog.tsx +122 -19
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.test.ts +47 -29
- package/src/components/timeline/TimelineList.tsx +60 -11
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +16 -2
- package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -0
- package/src/components/whats-new/WhatsNew.test.ts +107 -28
- package/src/components/whats-new/WhatsNew.tsx +146 -86
- package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
- package/src/components/whats-new/releaseNotes.ts +155 -24
- package/src/components/workload/WorkloadView.test.ts +2 -1
- package/src/components/workload/WorkloadView.tsx +165 -294
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
- package/src/hooks/useResourceCounts.ts +49 -0
- 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 } =
|
|
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={
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
)
|
|
45
|
-
const
|
|
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:
|
|
51
|
+
{ enabled: shouldLoadNamespaceSlices, refetchInterval: 30000 }
|
|
50
52
|
)
|
|
51
|
-
const
|
|
52
|
-
() => (
|
|
53
|
-
[
|
|
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={
|
|
62
|
-
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
|
-
|
|
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
|
|
1421
|
-
//
|
|
1422
|
-
// this is just the
|
|
1423
|
-
//
|
|
1424
|
-
|
|
1425
|
-
|
|
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">
|
|
1428
|
-
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
1429
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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('
|
|
26
|
-
it('
|
|
27
|
-
|
|
28
|
-
expect(
|
|
29
|
-
expect(
|
|
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('
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
46
|
-
expect(
|
|
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('
|
|
50
|
-
|
|
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('
|
|
54
|
-
|
|
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
|
})
|