@skyhook-io/radar-app 1.16.3 → 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 +2 -2
- package/src/App.tsx +14 -7
- package/src/RadarApp.tsx +27 -17
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.ts +115 -8
- 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/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- 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/CNPGProtection.tsx +2 -2
- 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 +3 -3
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/mcpToolCatalog.ts +1 -1
- 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.tsx +3 -0
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +7 -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/workload/WorkloadView.test.ts +2 -1
- package/src/components/workload/WorkloadView.tsx +135 -290
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
|
@@ -2,9 +2,10 @@ import { useState } from 'react'
|
|
|
2
2
|
import { History, Eye, GitCompare, Check, RotateCcw } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import type { HelmOperation, HelmRevision } from '../../types'
|
|
5
|
-
import { getStatusColor, formatDate
|
|
5
|
+
import { getStatusColor, formatDate } from './helm-utils'
|
|
6
6
|
import { SEVERITY_BADGE } from '../../utils/badge-colors'
|
|
7
7
|
import { Tooltip } from '../ui/Tooltip'
|
|
8
|
+
import { formatRelativeAgeTime } from '@skyhook-io/k8s-ui/utils/format'
|
|
8
9
|
|
|
9
10
|
interface RevisionHistoryProps {
|
|
10
11
|
history: HelmRevision[]
|
|
@@ -117,7 +118,7 @@ export function RevisionHistory({ history, currentRevision, operations = [], onV
|
|
|
117
118
|
|
|
118
119
|
<div className="flex items-center gap-4 mt-1 text-xs text-theme-text-tertiary">
|
|
119
120
|
<Tooltip content={revision.updated}><span>{formatDate(revision.updated)}</span></Tooltip>
|
|
120
|
-
<span>{
|
|
121
|
+
<span>{formatRelativeAgeTime(revision.updated)}</span>
|
|
121
122
|
</div>
|
|
122
123
|
|
|
123
124
|
{revision.description && (
|
|
@@ -177,8 +177,12 @@ export function ClusterHealthCard({
|
|
|
177
177
|
|
|
178
178
|
// Nodes ring segments
|
|
179
179
|
const cordonedCount = counts.nodes.cordoned ?? 0
|
|
180
|
+
const removingCount = counts.nodes.removing ?? 0
|
|
181
|
+
const removingUnhealthyCount = counts.nodes.removingUnhealthy ?? 0
|
|
180
182
|
const nodesRingSegments = [
|
|
181
183
|
{ value: counts.nodes.ready, color: '#22c55e' },
|
|
184
|
+
{ value: removingCount - removingUnhealthyCount, color: 'var(--text-secondary)' },
|
|
185
|
+
{ value: removingUnhealthyCount, color: 'var(--color-error)' },
|
|
182
186
|
{ value: cordonedCount, color: '#eab308' }, // amber for cordoned
|
|
183
187
|
{ value: counts.nodes.notReady, color: '#ef4444' },
|
|
184
188
|
]
|
|
@@ -394,6 +398,9 @@ export function ClusterHealthCard({
|
|
|
394
398
|
{cordonedCount > 0 && (
|
|
395
399
|
<span className="text-yellow-500">{cordonedCount} cordoned</span>
|
|
396
400
|
)}
|
|
401
|
+
{removingCount > 0 && (
|
|
402
|
+
<span className="text-theme-text-secondary">{removingCount} removing{removingUnhealthyCount > 0 ? ` (${removingUnhealthyCount} unhealthy)` : ''}</span>
|
|
403
|
+
)}
|
|
397
404
|
{counts.nodes.notReady > 0 && (
|
|
398
405
|
<span className="text-red-500">{counts.nodes.notReady} not ready</span>
|
|
399
406
|
)}
|
|
@@ -58,7 +58,7 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
58
58
|
{ arg: 'name', required: true, desc: 'resource name' },
|
|
59
59
|
{ arg: 'namespace', desc: 'omit for cluster-scoped kinds (Node, ClusterRole, IngressClass, etc.)' },
|
|
60
60
|
{ arg: 'group', desc: 'API group when the kind is ambiguous (e.g. serving.knative.dev for Knative Service vs core Service)' },
|
|
61
|
-
{ arg: 'include', desc: 'events, metrics, changes, revisions (rollback targets for Deployment/StatefulSet/DaemonSet/Rollout)' },
|
|
61
|
+
{ arg: 'include', desc: 'events, metrics, changes, issues, drain-plan (Node pod/PDB estimate), revisions (rollback targets for Deployment/StatefulSet/DaemonSet/Rollout)' },
|
|
62
62
|
{ arg: 'context', desc: 'resourceContext tier: basic (default) or none' },
|
|
63
63
|
],
|
|
64
64
|
},
|
|
@@ -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
|
)}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import type { TimelineEvent } from '../../types'
|
|
14
14
|
import { useTimelineSource } from '../../context/TimelineSource'
|
|
15
15
|
import { isManagedTimelineEvent } from '../../api/timelineSource'
|
|
16
|
+
import { TimelineRetentionBanner } from './TimelineRetentionNote'
|
|
16
17
|
import { useHasLimitedAccess } from '../../contexts/CapabilitiesContext'
|
|
17
18
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
18
19
|
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
|
@@ -175,6 +176,8 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
|
|
|
175
176
|
|
|
176
177
|
return (
|
|
177
178
|
<div className="flex h-full min-h-0 flex-1 flex-col">
|
|
179
|
+
{/* Under a scrubber TimelineView already shows the note above it. */}
|
|
180
|
+
{!selectionWindow && <TimelineRetentionBanner />}
|
|
178
181
|
{staleNote}
|
|
179
182
|
<div className="min-h-0 flex-1">
|
|
180
183
|
<TimelineListUI
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import type { TimelineEventsResult, TimelineSource } from '../../api/timelineSource'
|
|
4
|
+
|
|
5
|
+
// The list reads its source from context; hand it a fixed empty result so the
|
|
6
|
+
// test is about the note row alone.
|
|
7
|
+
const eventsResult: TimelineEventsResult = { data: [], isLoading: false, isFetching: false, isError: false, refetch: () => {} }
|
|
8
|
+
const fakeSource: TimelineSource = {
|
|
9
|
+
capabilities: { mode: 'local', ringLimit: 10_000 },
|
|
10
|
+
useEvents: () => eventsResult,
|
|
11
|
+
}
|
|
12
|
+
vi.mock('../../context/TimelineSource', () => ({ useTimelineSource: () => fakeSource }))
|
|
13
|
+
|
|
14
|
+
import { TimelineRetentionNoteProvider } from '../../context/TimelineCustomization'
|
|
15
|
+
import { TimelineList } from './TimelineList'
|
|
16
|
+
|
|
17
|
+
describe('TimelineList history-limit row', () => {
|
|
18
|
+
const props = {
|
|
19
|
+
namespaces: [],
|
|
20
|
+
showDeleted: false,
|
|
21
|
+
onShowDeletedChange: () => {},
|
|
22
|
+
showRoutine: false,
|
|
23
|
+
onShowRoutineChange: () => {},
|
|
24
|
+
search: '',
|
|
25
|
+
onSearchChange: () => {},
|
|
26
|
+
activityFilter: [],
|
|
27
|
+
onActivityFilterChange: () => {},
|
|
28
|
+
kindFilter: [],
|
|
29
|
+
onKindFilterChange: () => {},
|
|
30
|
+
}
|
|
31
|
+
const withNote = (node: React.ReactNode) => (
|
|
32
|
+
<TimelineRetentionNoteProvider value={<a href="/plans">Host note</a>}>{node}</TimelineRetentionNoteProvider>
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
it('shows the host note in its own row', () => {
|
|
36
|
+
const html = renderToStaticMarkup(withNote(<TimelineList {...props} />))
|
|
37
|
+
expect(html).toContain('data-testid="timeline-retention-note"')
|
|
38
|
+
expect(html).toContain('<a href="/plans">Host note</a>')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('renders no row without a host note (standalone Radar)', () => {
|
|
42
|
+
expect(renderToStaticMarkup(<TimelineList {...props} />)).not.toContain('timeline-retention-note')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('leaves the note to the view when a scrubber owns the range', () => {
|
|
46
|
+
const html = renderToStaticMarkup(withNote(<TimelineList {...props} selectionWindow={{ fromMs: 0, toMs: 1 }} />))
|
|
47
|
+
expect(html).not.toContain('timeline-retention-note')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Clock } from 'lucide-react'
|
|
2
|
+
import { useTimelineRetentionNote } from '../../context/TimelineCustomization'
|
|
3
|
+
|
|
4
|
+
// The host's history-limit note as a full-width row above the timeline,
|
|
5
|
+
// beside the other history-completeness rows (truncation, stale live
|
|
6
|
+
// updates). Informational, not a warning: nothing is broken, the server
|
|
7
|
+
// serves this much. Renders nothing when the host has no note.
|
|
8
|
+
export function TimelineRetentionBanner() {
|
|
9
|
+
const note = useTimelineRetentionNote()
|
|
10
|
+
if (!note) return null
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
className="flex flex-wrap items-center gap-x-1.5 gap-y-1 border-b border-theme-border px-4 py-1.5 text-xs text-theme-text-tertiary"
|
|
14
|
+
data-testid="timeline-retention-note"
|
|
15
|
+
>
|
|
16
|
+
<Clock className="h-3.5 w-3.5 shrink-0" />
|
|
17
|
+
{note}
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -25,6 +25,7 @@ import type { ActivityFilterKey } from './TimelineList'
|
|
|
25
25
|
import { TimelineSwimlanes } from './TimelineSwimlanes'
|
|
26
26
|
import { RetainedTimelineScrubber, extendSelection, type ScrubberDomainInfo } from './RetainedTimelineScrubber'
|
|
27
27
|
import { LocalTimelineScrubber } from './LocalTimelineScrubber'
|
|
28
|
+
import { TimelineRetentionBanner } from './TimelineRetentionNote'
|
|
28
29
|
import { useTopology, useApplications } from '../../api/client'
|
|
29
30
|
import { useTimelineSource } from '../../context/TimelineSource'
|
|
30
31
|
import type { Topology } from '../../types'
|
|
@@ -829,11 +830,16 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
829
830
|
// The scrubber sits above whichever view is active, sharing one selection
|
|
830
831
|
// across list + swimlane. Retained draws its server-overview strip; local
|
|
831
832
|
// derives the strip client-side from the loaded ring and omits the gap band.
|
|
833
|
+
// The host's note about how far back history goes: without it the stretch
|
|
834
|
+
// before the limit reads as "nothing happened". Without a scrubber the list
|
|
835
|
+
// view shows it itself, above its own range selector.
|
|
836
|
+
const retentionBanner = showScrubber || viewMode === 'swimlane' ? <TimelineRetentionBanner /> : null
|
|
832
837
|
const wrap = (node: ReactNode): ReactNode => {
|
|
833
838
|
if (!showScrubber) {
|
|
834
839
|
return (
|
|
835
840
|
<div className="flex-1 flex flex-col min-h-0">
|
|
836
841
|
{appScopeBar}
|
|
842
|
+
{retentionBanner}
|
|
837
843
|
<div className="flex-1 flex flex-col min-h-0">{node}</div>
|
|
838
844
|
</div>
|
|
839
845
|
)
|
|
@@ -850,6 +856,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
|
|
|
850
856
|
return (
|
|
851
857
|
<div className="flex-1 flex flex-col min-h-0">
|
|
852
858
|
{appScopeBar}
|
|
859
|
+
{retentionBanner}
|
|
853
860
|
{/* A row-capped ring load means the OLDEST part of the retention
|
|
854
861
|
window is not loaded — say so, or an old selection reads as
|
|
855
862
|
"nothing happened back then". */}
|