@skyhook-io/radar-app 1.14.5 → 1.15.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/README.md +2 -9
- package/package.json +6 -6
- package/src/App.tsx +142 -334
- package/src/RadarApp.tsx +3 -8
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.jobset.test.ts +18 -0
- package/src/api/client.resource-identity.test.ts +44 -0
- package/src/api/client.rightsizing.test.ts +7 -3
- package/src/api/client.ts +267 -78
- package/src/api/preferences.test.ts +54 -0
- package/src/api/preferences.ts +38 -0
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +155 -0
- package/src/components/CloudFunnelButton.tsx +113 -63
- package/src/components/ContextSwitcher.tsx +13 -132
- package/src/components/SelfManagedStart.tsx +96 -0
- package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
- package/src/components/applications/ApplicationsView.tsx +5 -3
- package/src/components/audit/AuditSettingsDialog.tsx +15 -11
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/AgentControls.tsx +26 -3
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- package/src/components/diagnose/agentCatalog.ts +6 -0
- package/src/components/diagnose/launch.test.ts +23 -0
- package/src/components/diagnose/launch.ts +4 -0
- package/src/components/diagnose/parts.test.tsx +18 -0
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
- package/src/components/dock/NodeTerminalTab.tsx +5 -3
- package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
- package/src/components/dock/WorkloadLogsTab.tsx +2 -1
- package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
- package/src/components/execution/BatchExecutionView.test.ts +52 -1
- package/src/components/execution/BatchExecutionView.tsx +324 -65
- package/src/components/execution/JobSetAdmission.test.tsx +165 -0
- package/src/components/execution/JobSetAdmission.tsx +26 -0
- package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
- package/src/components/execution/JobSetMemberComparison.tsx +88 -0
- package/src/components/execution/batch-run-actions.test.ts +1 -0
- package/src/components/execution/batch-timeline.test.ts +3 -0
- package/src/components/execution/execution-definition.test.ts +15 -1
- package/src/components/execution/execution-definition.ts +4 -0
- package/src/components/execution/member-collection.render.test.tsx +142 -0
- package/src/components/gitops/GitOpsView.tsx +33 -2
- package/src/components/gitops/destination-toast.test.ts +33 -0
- package/src/components/gitops/destination-toast.ts +46 -0
- package/src/components/gitops/useDestinationCluster.ts +30 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
- package/src/components/home/HomeView.tsx +6 -2
- package/src/components/home/MCPSetupDialog.tsx +10 -0
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +27 -8
- package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
- package/src/components/resource/PVCUsageBar.tsx +62 -16
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +3 -3
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
- package/src/components/rightsizing/copy.test.ts +7 -0
- package/src/components/rightsizing/model.test.ts +2 -1
- package/src/components/rightsizing/notices.test.tsx +4 -3
- package/src/components/settings/OperatorManagedNotice.tsx +17 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/SettingsDialog.tsx +168 -92
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/settings-state.test.ts +18 -0
- package/src/components/settings/settings-state.ts +19 -0
- package/src/components/timeline/TimelineView.tsx +9 -3
- package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +16 -0
- package/src/components/ui/UpdateNotification.tsx +13 -9
- package/src/components/ui/command-items.ts +14 -3
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/useContextSwitchFlow.tsx +147 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.test.ts +17 -1
- package/src/components/workload/WorkloadView.tsx +47 -19
- package/src/context/ContextSwitchContext.tsx +18 -2
- package/src/context/NavCustomization.tsx +4 -58
- package/src/context/ThemeContext.tsx +3 -11
- package/src/hooks/useClusterLoadState.ts +2 -2
- package/src/hooks/useFavorites.ts +3 -5
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/auditBadges.ts +1 -1
- package/src/utils/navigation.test.ts +34 -1
- package/src/utils/navigation.ts +28 -5
- package/src/utils/topology-selection.test.ts +56 -0
- package/src/utils/topology-selection.ts +9 -9
- package/src/utils/version.ts +11 -0
- package/src/components/ui/CommandPalette.tsx +0 -261
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Tooltip } from '@skyhook-io/k8s-ui/components/ui/Tooltip'
|
|
2
|
+
import { Badge } from '@skyhook-io/k8s-ui/components/ui/Badge'
|
|
3
|
+
import type { ResourceRef } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { RayJobRenderer as BaseRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/RayJobRenderer'
|
|
5
|
+
import { rayJobIsLocal, rayJobSubmissionMode } from '@skyhook-io/k8s-ui/components/resources/resource-utils-ray'
|
|
6
|
+
import { getJobStatus } from '@skyhook-io/k8s-ui/components/resources/resource-utils'
|
|
7
|
+
import { apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
8
|
+
import { formatRelativeAgeTime } from '@skyhook-io/k8s-ui/utils/format'
|
|
9
|
+
import { AlertBanner, ResourceLink } from '@skyhook-io/k8s-ui/components/ui/drawer-components'
|
|
10
|
+
import { ApiError, useResource } from '../../../api/client'
|
|
11
|
+
import { KueueAdmission } from '../../execution/JobSetAdmission'
|
|
12
|
+
|
|
13
|
+
function Submitter({ root, onNavigate }: { root: any; onNavigate?: (ref: ResourceRef) => void }) {
|
|
14
|
+
const query = useResource<any>('jobs', root.metadata.namespace, root.metadata.name, 'batch')
|
|
15
|
+
if (query.error) return query.error instanceof ApiError && query.error.status === 404
|
|
16
|
+
? <p className="text-theme-text-secondary">No submitter Job observed. It may not have been created yet or may have been cleaned up.</p>
|
|
17
|
+
: <AlertBanner variant="warning" title="Submitter evidence unavailable" message={query.error.message} />
|
|
18
|
+
if (!query.data) return <p className="text-theme-text-secondary">Reading submitter Job…</p>
|
|
19
|
+
const job = query.data
|
|
20
|
+
const owned = job.apiVersion === 'batch/v1' && job.kind === 'Job' && job.metadata?.name === root.metadata.name && job.metadata?.namespace === root.metadata.namespace && job.metadata.ownerReferences?.some((owner: any) => owner.controller === true && apiVersionToGroup(owner.apiVersion) === 'ray.io' && owner.kind === 'RayJob' && owner.name === root.metadata.name && owner.uid === root.metadata.uid)
|
|
21
|
+
if (!owned) return <AlertBanner variant="warning" title="Submitter ownership mismatch" message="The same-name Job does not report this RayJob incarnation as its controller. Its status is not attributed to this RayJob." />
|
|
22
|
+
const status = getJobStatus(job)
|
|
23
|
+
return <div className="space-y-2">
|
|
24
|
+
<div className="flex flex-wrap items-center gap-2"><ResourceLink kind="jobs" group="batch" namespace={job.metadata.namespace} name={job.metadata.name} onNavigate={onNavigate} />{job.metadata.deletionTimestamp ? <Badge severity="alert">Deleting</Badge> : <span className={`badge ${status.color}`}>{status.text}</span>}</div>
|
|
25
|
+
{job.metadata.creationTimestamp && <p className="text-xs text-theme-text-secondary">Created <Tooltip content={job.metadata.creationTimestamp}><time dateTime={job.metadata.creationTimestamp}>{formatRelativeAgeTime(job.metadata.creationTimestamp)}</time></Tooltip></p>}
|
|
26
|
+
<p className="text-xs text-theme-text-secondary">Open this Job for submitter Pods and logs. Ownership identifies the RayJob, not a particular attempt.</p>
|
|
27
|
+
{(job.metadata.deletionTimestamp || ['Retrying', 'Suspending'].includes(root.status?.jobDeploymentStatus)) && <p className="text-xs text-theme-text-secondary">The submitter may be part of the attempt being cleaned up.</p>}
|
|
28
|
+
</div>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function SelectedCluster({ namespace, name }: { namespace: string; name: string }) {
|
|
32
|
+
const query = useResource<any>('rayclusters', namespace, name, 'ray.io')
|
|
33
|
+
if (query.error) return <AlertBanner variant="warning" title="Selected cluster unavailable" message={query.error instanceof ApiError && query.error.status === 404 ? 'The selected existing RayCluster was not found. RayJob initialization requires that cluster.' : query.error.message} />
|
|
34
|
+
if (!query.data) return <p className="text-xs text-theme-text-secondary">Checking selected RayCluster…</p>
|
|
35
|
+
return <p className="text-xs text-theme-text-secondary">Selected RayCluster is present. It may be shared; its health is separate from this application.</p>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function RayJobRenderer({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
|
|
39
|
+
const local = rayJobIsLocal(data)
|
|
40
|
+
const selected = data.spec?.clusterSelector?.['ray.io/cluster']
|
|
41
|
+
return <BaseRenderer data={data} onNavigate={onNavigate}
|
|
42
|
+
admissionContent={<KueueAdmission resource={data} namespace={data.metadata.namespace} name={data.metadata.name} onNavigate={onNavigate} presentation="card" />}
|
|
43
|
+
submitterEvidence={local && rayJobSubmissionMode(data) === 'K8sJobMode' && data.metadata.uid ? <Submitter root={data} onNavigate={onNavigate} /> : undefined}
|
|
44
|
+
selectedClusterEvidence={local && selected ? <SelectedCluster namespace={data.metadata.namespace} name={selected} /> : undefined} />
|
|
45
|
+
}
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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=
|
|
327
|
+
className="flex flex-col gap-4"
|
|
323
328
|
>
|
|
324
329
|
{scan.error && (
|
|
325
|
-
<
|
|
326
|
-
tone="warning"
|
|
327
|
-
|
|
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=
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import type { UsageDataStatus } from '../../api/usage-data'
|
|
5
|
+
import { exampleReport, exampleStatus } from '../../api/usage-data.fixtures'
|
|
6
|
+
|
|
7
|
+
let current: UsageDataStatus
|
|
8
|
+
|
|
9
|
+
vi.mock('../../api/usage-data', async (orig) => ({
|
|
10
|
+
...(await orig<typeof import('../../api/usage-data')>()),
|
|
11
|
+
useUsageData: () => ({ data: current, isLoading: false, error: null }),
|
|
12
|
+
useSetUsageData: () => ({ mutate: vi.fn(), isPending: false }),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
import { PrivacySection } from './PrivacySection'
|
|
16
|
+
|
|
17
|
+
function make(patch: Partial<UsageDataStatus>): UsageDataStatus {
|
|
18
|
+
return exampleStatus({ preview: exampleReport({ views: { topology: 3 } }), ...patch })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function render(status: UsageDataStatus) {
|
|
22
|
+
current = status
|
|
23
|
+
return renderToString(
|
|
24
|
+
<QueryClientProvider client={new QueryClient()}>
|
|
25
|
+
<PrivacySection active />
|
|
26
|
+
</QueryClientProvider>,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('PrivacySection', () => {
|
|
31
|
+
it('shows an example report and promises nothing is sent while off', () => {
|
|
32
|
+
const html = render(make({}))
|
|
33
|
+
expect(html).toContain('See an example report')
|
|
34
|
+
expect(html).toContain('What isn')
|
|
35
|
+
expect(html).not.toContain('usage-report.json')
|
|
36
|
+
expect(html).toContain('aria-checked="false"')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('shows the pending report and where it lives once on', () => {
|
|
40
|
+
const html = render(make({ state: 'on', source: 'user', nextReportAt: '2026-09-30T12:00:00Z' }))
|
|
41
|
+
expect(html).toContain('See the next report')
|
|
42
|
+
expect(html).toContain('usage-report.json')
|
|
43
|
+
expect(html).toContain('"topology": 3')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('names the control that fixed the choice', () => {
|
|
47
|
+
expect(render(make({ state: 'off', source: 'deployment', canChange: false }))).toContain('Radar Cloud manages')
|
|
48
|
+
const helm = make({ state: 'on', source: 'env', canChange: false, shared: true })
|
|
49
|
+
expect(render({ ...helm, preview: { ...helm.preview, mode: 'in-cluster' } })).toContain('Helm value usageReporting.enabled')
|
|
50
|
+
expect(render(make({ state: 'off', source: 'env', canChange: false }))).toContain('RADAR_USAGE_REPORTING=off')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('PrivacySection intro', () => {
|
|
55
|
+
it('does not claim data stays on this machine for an in-cluster install', () => {
|
|
56
|
+
const base = make({ state: 'off', source: 'deployment', canChange: false })
|
|
57
|
+
const html = render({ ...base, preview: { ...base.preview, mode: 'in-cluster' } })
|
|
58
|
+
expect(html).toContain('from inside it')
|
|
59
|
+
expect(html).not.toContain('from this machine')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('PrivacySection on a shared Radar', () => {
|
|
64
|
+
it('points at the Helm value while nothing set it', () => {
|
|
65
|
+
const base = make({ shared: true, canChange: false })
|
|
66
|
+
const html = render({ ...base, preview: { ...base.preview, mode: 'in-cluster' } })
|
|
67
|
+
expect(html).toContain('Applies to everyone using this Radar.')
|
|
68
|
+
expect(html).toContain('usageReporting.enabled')
|
|
69
|
+
expect(html).toContain('aria-checked="false"')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('uses the environment variable outside a cluster', () => {
|
|
73
|
+
expect(render(make({ shared: true, canChange: false }))).toContain('RADAR_USAGE_REPORTING=on')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('PrivacySection storage and log wording', () => {
|
|
78
|
+
it('says logs, not sends, for development builds', () => {
|
|
79
|
+
const html = render(make({ state: 'on', source: 'user', developmentBuild: true, nextReportAt: '2026-09-25T12:00:00Z' }))
|
|
80
|
+
expect(html).toContain('logs')
|
|
81
|
+
expect(html).toContain('reports go to the log, never sent')
|
|
82
|
+
expect(render(make({ state: 'on', source: 'user', nextReportAt: '2026-09-25T12:00:00Z' }))).toContain('sends')
|
|
83
|
+
})
|
|
84
|
+
})
|