@skyhook-io/radar-app 1.14.5 → 1.14.6

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 (83) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +91 -328
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.jobset.test.ts +18 -0
  6. package/src/api/client.resource-identity.test.ts +44 -0
  7. package/src/api/client.rightsizing.test.ts +7 -3
  8. package/src/api/client.ts +233 -78
  9. package/src/api/preferences.test.ts +54 -0
  10. package/src/api/preferences.ts +38 -0
  11. package/src/components/CloudFunnelButton.test.tsx +155 -0
  12. package/src/components/CloudFunnelButton.tsx +14 -8
  13. package/src/components/ContextSwitcher.tsx +13 -132
  14. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  15. package/src/components/applications/ApplicationsView.tsx +5 -3
  16. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  17. package/src/components/diagnose/AgentControls.tsx +26 -3
  18. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  19. package/src/components/diagnose/agentCatalog.ts +6 -0
  20. package/src/components/diagnose/launch.test.ts +23 -0
  21. package/src/components/diagnose/launch.ts +4 -0
  22. package/src/components/diagnose/parts.test.tsx +18 -0
  23. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  24. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  25. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  26. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  27. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  28. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  29. package/src/components/execution/BatchExecutionView.tsx +324 -65
  30. package/src/components/execution/JobSetAdmission.test.tsx +42 -0
  31. package/src/components/execution/JobSetAdmission.tsx +17 -0
  32. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  33. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  34. package/src/components/execution/batch-run-actions.test.ts +1 -0
  35. package/src/components/execution/batch-timeline.test.ts +3 -0
  36. package/src/components/execution/execution-definition.test.ts +15 -1
  37. package/src/components/execution/execution-definition.ts +4 -0
  38. package/src/components/execution/member-collection.render.test.tsx +142 -0
  39. package/src/components/gitops/GitOpsView.tsx +33 -2
  40. package/src/components/gitops/destination-toast.test.ts +33 -0
  41. package/src/components/gitops/destination-toast.ts +46 -0
  42. package/src/components/gitops/useDestinationCluster.ts +30 -0
  43. package/src/components/helm/TrackChartSourceDialog.tsx +20 -11
  44. package/src/components/home/MCPSetupDialog.tsx +10 -0
  45. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  46. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  47. package/src/components/nav/PrimaryNavRail.tsx +2 -5
  48. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  49. package/src/components/resource/PVCUsageBar.tsx +62 -16
  50. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  51. package/src/components/resources/ResourcesView.tsx +3 -3
  52. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  53. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  54. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  55. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  56. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  57. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  58. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  59. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  60. package/src/components/rightsizing/copy.test.ts +7 -0
  61. package/src/components/rightsizing/model.test.ts +2 -1
  62. package/src/components/rightsizing/notices.test.tsx +4 -3
  63. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  64. package/src/components/settings/SettingsDialog.tsx +157 -42
  65. package/src/components/settings/settings-state.test.ts +18 -0
  66. package/src/components/settings/settings-state.ts +18 -0
  67. package/src/components/timeline/TimelineView.tsx +9 -3
  68. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  69. package/src/components/ui/command-items.ts +0 -3
  70. package/src/components/useContextSwitchFlow.tsx +147 -0
  71. package/src/components/workload/WorkloadView.test.ts +17 -1
  72. package/src/components/workload/WorkloadView.tsx +36 -18
  73. package/src/context/ContextSwitchContext.tsx +18 -2
  74. package/src/context/NavCustomization.tsx +4 -58
  75. package/src/context/ThemeContext.tsx +3 -11
  76. package/src/hooks/useClusterLoadState.ts +2 -2
  77. package/src/hooks/useFavorites.ts +3 -5
  78. package/src/utils/auditBadges.ts +1 -1
  79. package/src/utils/navigation.test.ts +18 -1
  80. package/src/utils/navigation.ts +12 -5
  81. package/src/utils/topology-selection.test.ts +56 -0
  82. package/src/utils/topology-selection.ts +9 -9
  83. package/src/components/ui/CommandPalette.tsx +0 -261
@@ -179,14 +179,14 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
179
179
 
180
180
  // Cluster Audit findings for the selected kind, keyed by "namespace/name" for
181
181
  // the resource list. The list shows ONE kind at a time, so ns/name is enough;
182
- // we still match the finding's group (built-ins → real group, CRDs → "") so a
182
+ // we still match the finding's group for every resource so a
183
183
  // kind shared across groups doesn't bleed findings across the two lists. Only
184
184
  // "badge-worthy" findings count (reference-integrity / lifecycle) — posture
185
185
  // and best-practice nags fire near-universally and would just be noise.
186
186
  const audit = useAudit(namespaces)
187
187
  const auditBadges = useMemo(() => {
188
188
  if (!selectedKind || !audit.data?.findings) return undefined
189
- const wantGroup = isSelectedCrd ? '' : selectedKind.group
189
+ const wantGroup = selectedKind.group
190
190
  const map: Record<string, { danger: number; warning: number; messages: AuditBadgeMessage[] }> = {}
191
191
  for (const f of audit.data.findings) {
192
192
  if (f.kind !== selectedKindCanonical || (f.group ?? '') !== wantGroup) continue
@@ -202,7 +202,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
202
202
  cur.messages.sort((a, b) => (a.severity === 'danger' ? 0 : 1) - (b.severity === 'danger' ? 0 : 1))
203
203
  }
204
204
  return map
205
- }, [audit.data?.findings, audit.data?.checks, selectedKind, selectedKindCanonical, isSelectedCrd])
205
+ }, [audit.data?.findings, audit.data?.checks, selectedKind, selectedKindCanonical])
206
206
 
207
207
  const selectedCountKey = selectedKind ? resourceCountKey(selectedKind) : ''
208
208
  const selectedCount = selectedCountKey ? countsData?.counts[selectedCountKey] : undefined
@@ -0,0 +1,8 @@
1
+ import { CAPIClusterRenderer as BaseCAPIClusterRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/CAPIClusterRenderer'
2
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
3
+ import { useCapabilities } from '../../../api/client'
4
+
5
+ export function CAPIClusterRenderer({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
6
+ const { data: capabilities } = useCapabilities()
7
+ return <BaseCAPIClusterRenderer data={data} onNavigate={onNavigate} canConnect={capabilities != null && capabilities.configManagement !== 'operator'} />
8
+ }
@@ -0,0 +1,39 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { KueueWorkloadRenderer } from './KueueWorkloadRenderer'
4
+
5
+ const mock = vi.hoisted(() => ({ query: {} as any }))
6
+ vi.mock('../../../api/client', () => ({ useKueueProvisioning: () => mock.query }))
7
+ const data = { metadata: { name: 'work', namespace: 'ml', uid: 'new' }, status: { admissionChecks: [{ name: 'capacity', state: 'Rejected', message: 'Capacity unavailable' }] } }
8
+ const render = () => renderToStaticMarkup(<KueueWorkloadRenderer data={data} />)
9
+
10
+ describe('Workload provisioning observation', () => {
11
+ beforeEach(() => { mock.query = {} })
12
+ it('never reports failed or old-generation lookups as an empty collection', () => {
13
+ mock.query = { error: new Error('no access to list ProvisioningRequests'), data: { uid: 'new', requests: [] } }
14
+ expect(render()).toContain('Could not observe')
15
+ expect(render()).not.toContain('No retained')
16
+ mock.query = { data: { uid: 'old', installed: true, requests: [{ metadata: { name: 'old-request' } }] } }
17
+ expect(render()).toContain('Looking for')
18
+ expect(render()).not.toContain('old-request')
19
+ })
20
+ it('keeps the rejected explanation when requests have already been cleaned up', () => {
21
+ mock.query = { data: { uid: 'new', installed: true, requests: [], total: 0 } }
22
+ expect(render()).toContain('Capacity unavailable')
23
+ expect(render()).toContain('may have been cleaned up')
24
+ mock.query.data.installed = false
25
+ expect(render()).toContain('API is not served')
26
+ expect(render()).not.toContain('No retained')
27
+ })
28
+ it('shows retained request state before PodSets without inferring check membership', () => {
29
+ mock.query = { data: { uid: 'new', installed: true, total: 21, truncated: true, requests: [{
30
+ metadata: { name: 'request-1', namespace: 'ml', uid: 'request-id' }, spec: { provisioningClassName: 'test.invalid' },
31
+ status: { conditions: [{ type: 'Failed', status: 'True', message: 'No capacity' }] },
32
+ }] } }
33
+ const html = render()
34
+ expect(html).toContain('request-1')
35
+ expect(html).toContain('Showing 1 of 21')
36
+ expect(html.indexOf('Provisioning Requests')).toBeLessThan(html.indexOf('Pod Sets'))
37
+ expect(html).toContain('not a retry history')
38
+ })
39
+ })
@@ -0,0 +1,37 @@
1
+ import { Boxes } from 'lucide-react'
2
+ import { KueueWorkloadRenderer as BaseRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/KueueWorkloadRenderer'
3
+ import { getProvisioningRequestStatus, getProvisioningRequestMessage } from '@skyhook-io/k8s-ui/components/resources/resource-utils-kueue'
4
+ import { Badge, ResourceLink, Section, type ResourceRef } from '@skyhook-io/k8s-ui'
5
+ import { useKueueProvisioning } from '../../../api/client'
6
+
7
+ export function KueueWorkloadRenderer({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
8
+ const namespace = data.metadata?.namespace ?? ''
9
+ const uid = data.metadata?.uid
10
+ const hasChecks = (data.status?.admissionChecks?.length ?? 0) > 0
11
+ const requests = useKueueProvisioning(namespace, data.metadata?.name ?? '', uid, hasChecks)
12
+ const current = requests.data?.uid === uid ? requests.data : undefined
13
+ const provisioning = hasChecks ? <Section title="Provisioning Requests" icon={Boxes} defaultExpanded>
14
+ {requests.error ? <p className="text-sm text-warning-text">Could not observe provisioning requests: {requests.error.message}</p>
15
+ : !current ? <p className="text-sm text-theme-text-tertiary">Looking for provisioning requests…</p>
16
+ : !current.installed ? <p className="text-sm text-theme-text-tertiary">The ProvisioningRequest API is not served by this cluster.</p>
17
+ : current.requests.length === 0 ? <p className="text-sm text-theme-text-tertiary">No retained provisioning requests found on this cluster. A request may not be needed or may have been cleaned up. Check results above retain the controller’s explanation; requests on a MultiKueue worker cluster are not included.</p>
18
+ : <div className="space-y-2">
19
+ <p className="text-xs text-theme-text-tertiary">Currently retained requests owned by this Workload, not a retry history. Requests may be removed after completion or eviction.</p>
20
+ {current.requests.map(request => {
21
+ const status = getProvisioningRequestStatus(request)
22
+ const message = getProvisioningRequestMessage(request)
23
+ return <div key={request.metadata.uid} className="card-inner">
24
+ <div className="flex flex-wrap items-center gap-2 break-all [&_button]:text-left">
25
+ <ResourceLink kind="provisioningrequests" group="autoscaling.x-k8s.io" namespace={namespace} name={request.metadata.name} onNavigate={onNavigate} />
26
+ <Badge colorClass={status.color} size="sm">{status.text}</Badge>
27
+ {request.metadata.deletionTimestamp && <Badge severity="neutral" size="sm">Deleting</Badge>}
28
+ </div>
29
+ <p className="mt-1 break-all text-xs text-theme-text-tertiary">{request.spec.provisioningClassName}</p>
30
+ {message && <p className="mt-1 whitespace-pre-wrap break-words text-sm text-theme-text-secondary">{message}</p>}
31
+ </div>
32
+ })}
33
+ {current.truncated && <p className="text-xs text-theme-text-tertiary">Showing {current.requests.length} of {current.total} retained requests, newest first.</p>}
34
+ </div>}
35
+ </Section> : undefined
36
+ return <BaseRenderer data={data} onNavigate={onNavigate} provisioning={provisioning} />
37
+ }
@@ -0,0 +1,18 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { RayClusterRenderer } from './RayClusterRenderer'
4
+ const mock = vi.hoisted(() => ({ result: {} as any, calls: [] as any[] }))
5
+ vi.mock('../../../api/client', () => ({ useWorkloadPods: (...args: any[]) => { mock.calls.push(args); return mock.result } }))
6
+ const root = { apiVersion: 'ray.io/v1', kind: 'RayCluster', metadata: { name: 'cluster', namespace: 'ml', uid: 'current' } }
7
+ describe('RayCluster host evidence', () => {
8
+ beforeEach(() => { mock.result = {}; mock.calls = [] })
9
+ it('makes one bounded UID-bound query with polling', () => {
10
+ renderToStaticMarkup(<RayClusterRenderer data={root} />)
11
+ expect(mock.calls).toEqual([['rayclusters', 'ml', 'cluster', { ownerUID: 'current', limit: 200, refetchInterval: 5000 }]])
12
+ })
13
+ it.each(['Forbidden', 'Cache warming', 'RayCluster was recreated'])('surfaces %s without claiming an empty cluster', message => {
14
+ mock.result = { error: new Error(message) }
15
+ const html = renderToStaticMarkup(<RayClusterRenderer data={root} />)
16
+ expect(html).toContain(message); expect(html).not.toContain('No directly owned Pods')
17
+ })
18
+ })
@@ -0,0 +1,17 @@
1
+ import { useState } from 'react'
2
+ import { RayClusterRenderer as BaseRenderer, type RayClusterPodSelection } from '@skyhook-io/k8s-ui/components/resources/renderers/RayClusterRenderer'
3
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
4
+ import { useWorkloadPods } from '../../../api/client'
5
+
6
+ export function RayClusterRenderer(props: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
7
+ return <RayClusterEvidence key={props.data.metadata.uid} {...props} />
8
+ }
9
+
10
+ function RayClusterEvidence({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
11
+ const [requestedSelection, setSelection] = useState<RayClusterPodSelection>({})
12
+ const selection = requestedSelection.workerGroup != null && !(data.spec?.workerGroupSpecs ?? []).some((group: any) => group.groupName === requestedSelection.workerGroup) ? {} : requestedSelection
13
+ const query = useWorkloadPods('rayclusters', data.metadata.namespace, data.metadata.uid ? data.metadata.name : '', {
14
+ ownerUID: data.metadata.uid, ...selection, limit: 200, refetchInterval: 5000,
15
+ })
16
+ return <BaseRenderer data={data} onNavigate={onNavigate} selection={selection} onSelectPods={setSelection} podEvidence={query.data} podsLoading={query.isLoading} podsError={query.error?.message} />
17
+ }
@@ -0,0 +1,69 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { RayServiceRenderer } from './RayServiceRenderer'
4
+ import { ApiError } from '../../../api/client'
5
+
6
+ const mock = vi.hoisted(() => ({ queries: {} as Record<string, any>, reads: [] as any[] }))
7
+ vi.mock('../../../api/client', () => ({
8
+ ApiError: class extends Error { constructor(message: string, public status: number) { super(message) } },
9
+ useResource: (...args: any[]) => { mock.reads.push(args); return mock.queries[args[2]] ?? {} },
10
+ }))
11
+ const root = { apiVersion: 'ray.io/v1', kind: 'RayService', metadata: { namespace: 'ml', name: 'serve', uid: 'root' }, status: { activeServiceStatus: { rayClusterName: 'active' }, pendingServiceStatus: { rayClusterName: 'pending' } } }
12
+ const cluster = (name: string, extra = {}) => ({ apiVersion: 'ray.io/v1', kind: 'RayCluster', metadata: { namespace: 'ml', name, generation: 1, ownerReferences: [{ controller: true, apiVersion: 'ray.io/v1', kind: 'RayService', name: 'serve', uid: 'root' }], ...extra }, status: { conditions: [{ type: 'HeadPodReady', status: 'False', reason: 'CrashLoopBackOff', message: 'Head container failed' }] } })
13
+ const render = (data = root) => renderToStaticMarkup(<RayServiceRenderer data={data} />)
14
+
15
+ describe('RayService runtime observation', () => {
16
+ beforeEach(() => { mock.queries = {}; mock.reads = [] })
17
+ it('makes only two exact named reads and reports direct pending failures', () => {
18
+ mock.queries.pending = { data: cluster('pending') }
19
+ const html = render()
20
+ expect(mock.reads).toEqual([['rayclusters', 'ml', 'active', 'ray.io'], ['rayclusters', 'ml', 'pending', 'ray.io']])
21
+ expect(html).toContain('Reading RayCluster status')
22
+ expect(html).toContain('Head container failed')
23
+ })
24
+ it.each([403, 500, 503])('shows %s read failure instead of empty/healthy runtime', status => {
25
+ mock.queries.pending = { error: new ApiError('Access unavailable', status), data: cluster('pending') }
26
+ const html = render()
27
+ expect(html).toContain('Could not read RayCluster: Access unavailable')
28
+ expect(html).not.toContain('Head container failed')
29
+ })
30
+ it('uses root freshness rather than unstamped RayCluster conditions', () => {
31
+ const data = cluster('pending')
32
+ mock.queries.pending = { data: { ...data, status: { observedGeneration: 1, conditions: [{ type: 'HeadPodReady', status: 'True', observedGeneration: 0 }] } } }
33
+ expect(render()).not.toContain('RayCluster status describes an earlier generation')
34
+ mock.queries.pending.data.metadata.generation = 2
35
+ expect(render()).toContain('RayCluster status describes an earlier generation')
36
+ })
37
+ it('treats missing and conditions-unreported as distinct observations', () => {
38
+ mock.queries.active = { error: new ApiError('Missing', 404) }
39
+ mock.queries.pending = { data: { ...cluster('pending'), status: {} } }
40
+ const html = render()
41
+ expect(html).toContain('may have been cleaned up')
42
+ expect(html).toContain('Runtime conditions are not reported')
43
+ })
44
+ it('does not attribute foreign, recreated or incorrectly owned children', () => {
45
+ for (const data of [
46
+ cluster('wrong-name'), cluster('pending', { namespace: 'other' }),
47
+ { ...cluster('pending'), apiVersion: 'foreign.io/v1' },
48
+ cluster('pending', { ownerReferences: [{ controller: true, apiVersion: 'ray.io/v1', kind: 'RayService', name: 'serve', uid: 'old-root' }] }),
49
+ cluster('pending', { ownerReferences: [{ controller: false, apiVersion: 'ray.io/v1', kind: 'RayService', name: 'serve', uid: 'root' }] }),
50
+ ]) {
51
+ mock.queries.pending = { data }
52
+ expect(render()).toContain('Runtime status is not attributed')
53
+ expect(render()).not.toContain('Head container failed')
54
+ }
55
+ })
56
+ it('accepts the same controller identity across served owner versions', () => {
57
+ const data = cluster('pending')
58
+ data.metadata.ownerReferences[0].apiVersion = 'ray.io/v1alpha1'
59
+ mock.queries.pending = { data }
60
+ expect(render()).toContain('Head container failed')
61
+ })
62
+ it('follows promotion and root recreation using current slot and owner identity', () => {
63
+ mock.queries.pending = { data: cluster('pending') }
64
+ const promoted = { ...root, status: { activeServiceStatus: { rayClusterName: 'pending' }, pendingServiceStatus: { rayClusterName: '' } } }
65
+ expect(render(promoted)).not.toContain('Pending Revision')
66
+ expect(mock.reads).toEqual([['rayclusters', 'ml', 'pending', 'ray.io']])
67
+ expect(render({ ...promoted, metadata: { ...root.metadata, uid: 'new-root' } })).toContain('Runtime status is not attributed')
68
+ })
69
+ })
@@ -0,0 +1,38 @@
1
+ import { apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
2
+ import { RayServiceRenderer as BaseRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/RayServiceRenderer'
3
+ import { isRayObservationStale } from '@skyhook-io/k8s-ui/components/resources/resource-utils-ray'
4
+ import { AlertBanner, ConditionsSection, type ConditionTone } from '@skyhook-io/k8s-ui/components/ui/drawer-components'
5
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
6
+ import { ApiError, useResource } from '../../../api/client'
7
+
8
+ function runtimeConditionTone(condition: any): ConditionTone {
9
+ if (!['True', 'False'].includes(condition.status)) return 'unknown'
10
+ if (condition.type === 'RayClusterReplicaFailure') return condition.status === 'True' ? 'fail' : 'unknown'
11
+ if (['HeadPodReady', 'RayClusterProvisioned'].includes(condition.type)) return condition.status === 'True' ? 'ok' : 'warning'
12
+ return 'unknown'
13
+ }
14
+
15
+ function RuntimeEvidence({ root, name }: { root: any; name: string }) {
16
+ const query = useResource<any>('rayclusters', root.metadata.namespace, name, 'ray.io')
17
+ if (query.error) return <AlertBanner variant="warning" title="Runtime evidence unavailable" message={query.error instanceof ApiError && query.error.status === 404 ? 'The named RayCluster was not found; it may have been cleaned up.' : `Could not read RayCluster: ${query.error.message}`} />
18
+ if (!query.data) return <p className="mt-2 text-sm text-theme-text-tertiary">Reading RayCluster status…</p>
19
+ const cluster = query.data
20
+ const owned = root.metadata.uid && cluster.apiVersion === 'ray.io/v1' && cluster.kind === 'RayCluster' && cluster.metadata?.namespace === root.metadata.namespace && cluster.metadata?.name === name && cluster.metadata.ownerReferences?.some((owner: any) => owner.controller === true && apiVersionToGroup(owner.apiVersion) === 'ray.io' && owner.kind === 'RayService' && owner.name === root.metadata.name && owner.uid === root.metadata.uid)
21
+ if (!owned) return <AlertBanner variant="warning" title="Runtime ownership mismatch" message="The named RayCluster does not report this RayService as its controller. Runtime status is not attributed to this revision." />
22
+ const conditions = (cluster.status?.conditions ?? []).filter((condition: any) => ['HeadPodReady', 'RayClusterProvisioned', 'RayClusterReplicaFailure', 'RayClusterSuspending', 'RayClusterSuspended'].includes(condition.type))
23
+ const stale = isRayObservationStale(cluster.metadata.generation, cluster.status?.observedGeneration)
24
+ return <div className="mt-2">
25
+ <p className="text-xs text-theme-text-tertiary">Directly observed RayCluster conditions, separate from the RayService application snapshot.</p>
26
+ {stale && <p className="mt-2 text-sm text-theme-text-secondary">RayCluster status describes an earlier generation.</p>}
27
+ {conditions.length === 0 ? <p className="mt-2 text-sm text-theme-text-secondary">Runtime conditions are not reported by this RayCluster.</p> : <ConditionsSection conditions={conditions} getConditionTone={condition => stale ? 'unknown' : runtimeConditionTone(condition)} defaultExpanded />}
28
+ </div>
29
+ }
30
+
31
+ export function RayServiceRenderer({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
32
+ const active = data.status?.activeServiceStatus?.rayClusterName
33
+ const pending = data.status?.pendingServiceStatus?.rayClusterName
34
+ return <BaseRenderer data={data} onNavigate={onNavigate} runtimeEvidence={{
35
+ active: active ? <RuntimeEvidence root={data} name={active} /> : undefined,
36
+ pending: pending ? <RuntimeEvidence root={data} name={pending} /> : undefined,
37
+ }} />
38
+ }
@@ -48,6 +48,7 @@ export const RIGHTSIZING_EMBEDDED_METRICS_REQUIRED_BODY =
48
48
  `${RIGHTSIZING_METRICS_REQUIRED_BODY}\nConfigure metrics for this cluster in the host application or Radar deployment.`
49
49
 
50
50
  export type RightsizingScanSurfaceState =
51
+ | 'loading_scan'
51
52
  | 'discovering'
52
53
  | 'prometheus_required'
53
54
  | 'first_run'
@@ -57,6 +58,7 @@ export type RightsizingScanSurfaceState =
57
58
  | 'results'
58
59
 
59
60
  export function getRightsizingScanSurfaceState(input: {
61
+ scanLoading?: boolean
60
62
  statusLoading: boolean
61
63
  hasStatus: boolean
62
64
  connected: boolean
@@ -67,6 +69,7 @@ export function getRightsizingScanSurfaceState(input: {
67
69
  }): RightsizingScanSurfaceState {
68
70
  if (input.statusLoading && !input.hasStatus) return 'discovering'
69
71
  if (!input.connected) return 'prometheus_required'
72
+ if (input.scanLoading && !input.hasResult) return 'loading_scan'
70
73
  if (input.pending && !input.hasResult) return 'scanning'
71
74
  if (input.hasError && !input.hasResult) return 'fatal_error'
72
75
  if (!input.hasResult) return 'first_run'
@@ -185,6 +188,7 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
185
188
  const onlySystemRowsAreHidden = !includeSystem && rows.length > 0 && scopeRows.length === 0
186
189
  const surfaceState = getRightsizingScanSurfaceState({
187
190
  statusLoading,
191
+ scanLoading: scan.isLoading,
188
192
  hasStatus: Boolean(promStatus),
189
193
  connected: promStatus?.connected === true,
190
194
  pending: scan.isPending,
@@ -232,12 +236,12 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
232
236
  {result && (
233
237
  <button
234
238
  type="button"
235
- onClick={runScan}
236
- disabled={scan.isPending}
239
+ onClick={scan.isPending ? scan.stop : runScan}
240
+ disabled={scan.isStarting || scan.isStopping}
237
241
  className="btn-brand inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium"
238
242
  >
239
243
  <RefreshCw className={`h-3.5 w-3.5 ${scan.isPending ? 'animate-spin' : ''}`} />
240
- {scan.isPending ? 'Scanning…' : 'Run again'}
244
+ {scan.isStarting ? 'Starting…' : scan.isStopping ? 'Stopping…' : scan.isPending ? 'Stop scan' : 'Run again'}
241
245
  </button>
242
246
  )}
243
247
  </div>
@@ -280,25 +284,27 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
280
284
  </div>
281
285
  }
282
286
  />
287
+ ) : surfaceState === 'loading_scan' ? (
288
+ <CenteredState loading title="Loading scan…" body="Checking for an existing scan in this cluster and namespace scope." />
283
289
  ) : surfaceState === 'first_run' ? (
284
290
  <FirstRunState namespaces={namespaces} onRun={runScan} />
285
291
  ) : surfaceState === 'scanning' ? (
286
292
  <CenteredState
287
293
  loading
288
294
  title="Analyzing CPU and memory requests…"
289
- body="Comparing 7 days of CPU and memory usage with configured requests. Larger clusters can take up to a minute."
295
+ body="Comparing 7 days of CPU and memory usage with configured requests. Results appear as batches finish; scans run for up to three minutes."
290
296
  />
291
297
  ) : surfaceState === 'fatal_error' ? (
292
298
  <CenteredState
293
- title="Rightsizing scan failed"
299
+ title={scan.statusError ? "Could not retrieve scan status" : "Rightsizing scan failed"}
294
300
  body={errorMessage(scan.error)}
295
301
  action={
296
302
  <button
297
303
  type="button"
298
- onClick={runScan}
304
+ onClick={scan.statusError ? () => void scan.retryStatus() : runScan}
299
305
  className="btn-brand px-3 py-1.5 text-xs font-medium"
300
306
  >
301
- Try again
307
+ {scan.statusError ? 'Retry status' : 'Try again'}
302
308
  </button>
303
309
  }
304
310
  />
@@ -310,7 +316,6 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
310
316
  <button
311
317
  type="button"
312
318
  onClick={runScan}
313
- disabled={scan.isPending}
314
319
  className="btn-brand px-3 py-1.5 text-xs font-medium"
315
320
  >
316
321
  Try again
@@ -319,16 +324,16 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
319
324
  />
320
325
  ) : result ? (
321
326
  <div
322
- className={`flex flex-col gap-4 transition-opacity ${scan.isPending ? 'opacity-70' : ''}`}
327
+ className="flex flex-col gap-4"
323
328
  >
324
329
  {scan.error && (
325
- <Notice
326
- tone="warning"
327
- text={`Refresh failed; showing results from ${formatScanTime(result.scannedAt)}. ${errorMessage(scan.error)}`}
328
- />
330
+ <div className="flex items-center gap-3">
331
+ <Notice tone="warning" text={`${scan.statusError ? 'Could not retrieve scan status. The scan may still be running.' : 'Could not start or stop the scan.'} ${errorMessage(scan.error)}`} />
332
+ <button type="button" className="btn-brand-muted shrink-0 px-3 py-1.5 text-xs" onClick={() => void scan.retryStatus()}>Retry status</button>
333
+ </div>
329
334
  )}
330
335
  {scan.isPending && (
331
- <Notice text="Scanning the current scope. Previous results remain visible until the scan completes." />
336
+ <Notice loading text={`${scan.progress?.coverage.workloadsDiscovered ? `Scanning — ${scan.progress.coverage.workloadsEvaluated} of ${scan.progress.coverage.workloadsDiscovered} workloads attempted.` : 'Preparing scan…'} Results and rankings update as batches finish. You can leave this page and return.${scan.showingPrevious ? ' Showing previous results until the first batch is ready.' : ''}`} />
332
337
  )}
333
338
  <ScanSummary
334
339
  namespaces={namespaces}
@@ -337,8 +342,10 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
337
342
  selected={classFilter}
338
343
  onSelect={(value) => setFilter('rfClass', value === 'actions' ? undefined : value)}
339
344
  />
340
- <ScanNotices key={result.scannedAt} result={result} />
341
- {result.reason === 'only_daemonsets_without_nodes' ? (
345
+ {!scan.isPending && <ScanNotices key={result.scannedAt} result={result} />}
346
+ {scan.isPending && rows.length === 0 ? (
347
+ <CenteredState loading title="Preparing recommendations…" body="The scan is running. Recommendations appear after each batch finishes." />
348
+ ) : result.reason === 'only_daemonsets_without_nodes' ? (
342
349
  <EmptyState
343
350
  variant="card"
344
351
  headline="Only DaemonSets with no nodes in this scope"
@@ -369,7 +376,9 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
369
376
  onClear={clearFilters}
370
377
  active={activeFilters}
371
378
  />
372
- {filteredRows.length === 0 ? (
379
+ {filteredRows.length === 0 && scan.isPending ? (
380
+ <EmptyState variant="card" headline="No matching results yet" body="The scan is still running. More workloads may match these filters as batches finish." />
381
+ ) : filteredRows.length === 0 ? (
373
382
  <ScanEmptyState
374
383
  counts={counts}
375
384
  classFilter={classFilter}
@@ -571,7 +580,7 @@ export function ScanSummary({
571
580
  <div className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-theme-text-tertiary">
572
581
  <span>
573
582
  {result.coverage.workloadsEvaluated} of {result.coverage.workloadsDiscovered} workloads
574
- attempted
583
+ attempted{result.scanStatus === 'running' ? ' so far' : ''}
575
584
  {' · '}
576
585
  {result.coverage.workloadsWithData} with usage data · {result.window} window
577
586
  </span>
@@ -624,7 +633,7 @@ export function ScanNotices({ result }: { result: ScanResult }) {
624
633
  `${pluralize(daemonSetsWithoutNodes, 'DaemonSet')} ${daemonSetsWithoutNodes === 1 ? 'runs' : 'run'} on no node right now and ${daemonSetsWithoutNodes === 1 ? 'is' : 'are'} not listed. Open one from Resources to see recommendations from its retained history.`,
625
634
  )
626
635
  const warnings = result.warnings ?? []
627
- const deadlineExceeded = warnings.some((warning) => warning.code === 'scan_deadline_exceeded')
636
+ const deadlineExceeded = result.scanStatus === 'timed_out' || warnings.some((warning) => warning.code === 'scan_deadline_exceeded')
628
637
  for (const warning of warnings) {
629
638
  if (warning.code !== 'scan_deadline_exceeded') notices.push(warningMessage(warning.code))
630
639
  }
@@ -633,7 +642,7 @@ export function ScanNotices({ result }: { result: ScanResult }) {
633
642
  const details = [...new Set(notices)]
634
643
  const singleNote = details.length === 1 && !deadlineExceeded
635
644
  const hasDetails = details.length > 0 && !singleNote
636
- const summary = singleNote
645
+ const summary = result.scanStatus === 'cancelled' ? 'Scan stopped. Available recommendations are shown. Run again to start a new scan.' : singleNote
637
646
  ? details[0]
638
647
  : deadlineExceeded
639
648
  ? 'The scan reached its time limit. Available recommendations are shown. For a narrower scan, select namespaces in the top bar and run again.'
@@ -677,12 +686,12 @@ export function ScanNotices({ result }: { result: ScanResult }) {
677
686
  )
678
687
  }
679
688
 
680
- function Notice({ text, tone }: { text: string; tone?: 'warning' }) {
689
+ function Notice({ text, tone, loading }: { text: string; tone?: 'warning'; loading?: boolean }) {
681
690
  return (
682
691
  <div
683
692
  className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-xs ${tone === 'warning' ? 'status-degraded' : 'border-theme-border bg-theme-surface text-theme-text-secondary'}`}
684
693
  >
685
- <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
694
+ {loading ? <Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" /> : <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />}
686
695
  {text}
687
696
  </div>
688
697
  )
@@ -1089,6 +1098,9 @@ function unavailableMessage(reason?: string): string {
1089
1098
  return 'Deployment history cannot be mapped because kube_replicaset_owner is unavailable from kube-state-metrics.'
1090
1099
  if (reason === 'owner_metrics_query_failed')
1091
1100
  return 'Radar found Prometheus, but could not query kube-state-metrics ownership data.'
1101
+ if (reason === 'scan_capacity_exceeded') return 'This scope is too large for one scan. Select fewer namespaces in the top bar and run again.'
1102
+ if (reason === 'scan_cancelled') return 'The scan was stopped before any workloads were evaluated. Run again to start a new scan.'
1103
+ if (reason === 'scan_deadline_exceeded') return 'The scan reached its three-minute time limit. Select fewer namespaces in the top bar and run again.'
1092
1104
  if (reason === 'scan_incomplete')
1093
1105
  return 'The scan could not evaluate any workloads in the current scope.'
1094
1106
  return 'Prometheus or kube-state-metrics does not expose the metrics required for this scan.'
@@ -60,6 +60,13 @@ describe('rightsizing scan copy', () => {
60
60
  ).toBe('fatal_error')
61
61
  })
62
62
 
63
+ it('waits for the retained scan lookup before offering a first run', () => {
64
+ expect(getRightsizingScanSurfaceState({
65
+ statusLoading: false, hasStatus: true, connected: true, scanLoading: true,
66
+ pending: false, hasResult: false, hasError: false,
67
+ })).toBe('loading_scan')
68
+ })
69
+
63
70
  it('does not auto-scan a connected first visit', () => {
64
71
  expect(
65
72
  getRightsizingScanSurfaceState({
@@ -31,6 +31,7 @@ function response(
31
31
  scaledToZero = false,
32
32
  ): RightsizingScanResponse {
33
33
  return {
34
+ scanId: 'rs_test', scanStatus: 'finished', deadlineAt: '2026-09-19T10:03:00Z',
34
35
  state: 'complete',
35
36
  scannedAt: '2026-07-12T10:00:00Z',
36
37
  window: '7d',
@@ -40,7 +41,7 @@ function response(
40
41
  workloadsEvaluated: 1,
41
42
  workloadsWithData: 1,
42
43
  batches: 1,
43
- completedBatches: 1,
44
+ attemptedBatches: 1, completedBatches: 1,
44
45
  },
45
46
  workloads: [
46
47
  {
@@ -4,7 +4,8 @@ import type { RightsizingRow, RightsizingScanResponse } from '../../api/client'
4
4
  import { FitWhy, ScanNotices, ScanSummary } from './RightsizingScanView'
5
5
 
6
6
  const scan: RightsizingScanResponse = {
7
- state: 'complete',
7
+ scanId: 'rs_test', scanStatus: 'finished', deadlineAt: '2026-09-19T10:03:00Z',
8
+ state: 'complete',
8
9
  scannedAt: '2026-09-19T06:52:54Z',
9
10
  window: '7d',
10
11
  source: 'radar',
@@ -13,7 +14,7 @@ const scan: RightsizingScanResponse = {
13
14
  workloadsEvaluated: 120,
14
15
  workloadsWithData: 47,
15
16
  batches: 3,
16
- completedBatches: 3,
17
+ attemptedBatches: 3, completedBatches: 3,
17
18
  },
18
19
  workloads: [],
19
20
  }
@@ -30,7 +31,7 @@ describe('rightsizing scan notices', () => {
30
31
  coverage: {
31
32
  ...scan.coverage,
32
33
  workloadsEvaluated: 100,
33
- completedBatches: 0,
34
+ attemptedBatches: 1, completedBatches: 0,
34
35
  daemonSetsWithoutNodes: 27,
35
36
  },
36
37
  warnings: ['cpu_query_failed', 'memory_query_failed', 'scan_deadline_exceeded'].map(
@@ -0,0 +1,17 @@
1
+ import { Lock } from 'lucide-react'
2
+
3
+ export function OperatorManagedNotice({ helmValue }: { helmValue?: string }) {
4
+ return (
5
+ <div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated/50 p-3 text-sm">
6
+ <Lock className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
7
+ <div className="min-w-0">
8
+ <p className="font-medium text-theme-text-primary">Managed by your operator</p>
9
+ <p className="mt-1 text-xs text-theme-text-secondary">
10
+ To change these settings, ask your operator to update {helmValue ? <><code className="font-mono">{helmValue}</code> in </> : null}
11
+ Radar’s Helm values or startup configuration.
12
+ {' '}<a className="text-accent hover:underline" href="https://github.com/skyhook-io/radar/blob/main/docs/in-cluster.md#installation-settings" target="_blank" rel="noreferrer">Configuration guide</a>
13
+ </p>
14
+ </div>
15
+ </div>
16
+ )
17
+ }