@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.
Files changed (59) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +14 -7
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +115 -8
  6. package/src/api/diagnose.test.ts +40 -0
  7. package/src/api/diagnose.ts +12 -1
  8. package/src/api/radarFeatures.render.test.tsx +19 -1
  9. package/src/api/radarFeatures.ts +3 -0
  10. package/src/api/traffic.sources.test.ts +55 -0
  11. package/src/api/traffic.ts +173 -14
  12. package/src/components/applications/ApplicationsView.tsx +3 -0
  13. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  14. package/src/components/capacity/CapacityView.test.tsx +31 -1
  15. package/src/components/capacity/shared.tsx +12 -21
  16. package/src/components/cnpg/CNPGProtection.tsx +2 -2
  17. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  18. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  19. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  20. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  21. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  22. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  23. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  24. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  25. package/src/components/diagnose/InvestigationView.tsx +39 -2
  26. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  27. package/src/components/diagnose/agentCatalog.ts +2 -2
  28. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  29. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  30. package/src/components/execution/BatchExecutionView.tsx +11 -11
  31. package/src/components/helm/ChartBrowser.tsx +3 -3
  32. package/src/components/helm/RevisionHistory.tsx +3 -2
  33. package/src/components/home/ClusterHealthCard.tsx +7 -0
  34. package/src/components/home/mcpToolCatalog.ts +1 -1
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  36. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  37. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  38. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  39. package/src/components/settings/SettingsDialog.tsx +122 -19
  40. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  41. package/src/components/timeline/TimelineList.tsx +3 -0
  42. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  43. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  44. package/src/components/timeline/TimelineView.tsx +7 -0
  45. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  46. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  47. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  48. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  49. package/src/components/traffic/TrafficGraph.tsx +109 -26
  50. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  51. package/src/components/traffic/TrafficView.tsx +582 -149
  52. package/src/components/traffic/trafficFilters.test.ts +398 -1
  53. package/src/components/traffic/trafficFilters.ts +408 -0
  54. package/src/components/workload/WorkloadView.test.ts +2 -1
  55. package/src/components/workload/WorkloadView.tsx +135 -290
  56. package/src/context/TimelineCustomization.tsx +15 -0
  57. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  58. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  59. 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, formatAge } from './helm-utils'
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>{formatAge(revision.updated)} ago</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={`${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
  )}
@@ -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". */}