@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.
Files changed (117) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +142 -334
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.admission.test.ts +34 -0
  6. package/src/api/client.jobset.test.ts +18 -0
  7. package/src/api/client.resource-identity.test.ts +44 -0
  8. package/src/api/client.rightsizing.test.ts +7 -3
  9. package/src/api/client.ts +267 -78
  10. package/src/api/preferences.test.ts +54 -0
  11. package/src/api/preferences.ts +38 -0
  12. package/src/api/usage-data.fixtures.ts +33 -0
  13. package/src/api/usage-data.ts +165 -0
  14. package/src/components/CloudFunnelButton.test.tsx +155 -0
  15. package/src/components/CloudFunnelButton.tsx +113 -63
  16. package/src/components/ContextSwitcher.tsx +13 -132
  17. package/src/components/SelfManagedStart.tsx +96 -0
  18. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  19. package/src/components/applications/ApplicationsView.tsx +5 -3
  20. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  21. package/src/components/cloudConnectHandoff.test.ts +1 -1
  22. package/src/components/cloudConnectHandoff.ts +1 -1
  23. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  24. package/src/components/diagnose/AgentControls.tsx +26 -3
  25. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  26. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  27. package/src/components/diagnose/agentCatalog.ts +6 -0
  28. package/src/components/diagnose/launch.test.ts +23 -0
  29. package/src/components/diagnose/launch.ts +4 -0
  30. package/src/components/diagnose/parts.test.tsx +18 -0
  31. package/src/components/dialog-names.test.ts +69 -0
  32. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  33. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  34. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  35. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  36. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  37. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  38. package/src/components/execution/BatchExecutionView.tsx +324 -65
  39. package/src/components/execution/JobSetAdmission.test.tsx +165 -0
  40. package/src/components/execution/JobSetAdmission.tsx +26 -0
  41. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  42. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  43. package/src/components/execution/batch-run-actions.test.ts +1 -0
  44. package/src/components/execution/batch-timeline.test.ts +3 -0
  45. package/src/components/execution/execution-definition.test.ts +15 -1
  46. package/src/components/execution/execution-definition.ts +4 -0
  47. package/src/components/execution/member-collection.render.test.tsx +142 -0
  48. package/src/components/gitops/GitOpsView.tsx +33 -2
  49. package/src/components/gitops/destination-toast.test.ts +33 -0
  50. package/src/components/gitops/destination-toast.ts +46 -0
  51. package/src/components/gitops/useDestinationCluster.ts +30 -0
  52. package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
  53. package/src/components/home/HomeView.tsx +6 -2
  54. package/src/components/home/MCPSetupDialog.tsx +10 -0
  55. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  56. package/src/components/home/RadarVersionLine.tsx +28 -3
  57. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  58. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  59. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  60. package/src/components/nav/PrimaryNavRail.tsx +27 -8
  61. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  62. package/src/components/resource/PVCUsageBar.tsx +62 -16
  63. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  64. package/src/components/resources/ResourcesView.tsx +3 -3
  65. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  66. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  67. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  68. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  69. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  70. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  71. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  75. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  76. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  77. package/src/components/rightsizing/copy.test.ts +7 -0
  78. package/src/components/rightsizing/model.test.ts +2 -1
  79. package/src/components/rightsizing/notices.test.tsx +4 -3
  80. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  81. package/src/components/settings/PrivacySection.test.tsx +84 -0
  82. package/src/components/settings/PrivacySection.tsx +140 -0
  83. package/src/components/settings/SettingsDialog.tsx +168 -92
  84. package/src/components/settings/controls.tsx +55 -0
  85. package/src/components/settings/settings-state.test.ts +18 -0
  86. package/src/components/settings/settings-state.ts +19 -0
  87. package/src/components/timeline/TimelineView.tsx +9 -3
  88. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  89. package/src/components/ui/ErrorBoundary.tsx +5 -0
  90. package/src/components/ui/Omnibar.tsx +16 -0
  91. package/src/components/ui/UpdateNotification.tsx +13 -9
  92. package/src/components/ui/command-items.ts +14 -3
  93. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  94. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  95. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  96. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  97. package/src/components/useContextSwitchFlow.tsx +147 -0
  98. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  99. package/src/components/whats-new/WhatsNew.tsx +423 -0
  100. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  101. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  102. package/src/components/whats-new/releaseNotes.ts +121 -0
  103. package/src/components/workload/WorkloadView.test.ts +17 -1
  104. package/src/components/workload/WorkloadView.tsx +47 -19
  105. package/src/context/ContextSwitchContext.tsx +18 -2
  106. package/src/context/NavCustomization.tsx +4 -58
  107. package/src/context/ThemeContext.tsx +3 -11
  108. package/src/hooks/useClusterLoadState.ts +2 -2
  109. package/src/hooks/useFavorites.ts +3 -5
  110. package/src/k8s-ui-exports.test.ts +73 -0
  111. package/src/utils/auditBadges.ts +1 -1
  112. package/src/utils/navigation.test.ts +34 -1
  113. package/src/utils/navigation.ts +28 -5
  114. package/src/utils/topology-selection.test.ts +56 -0
  115. package/src/utils/topology-selection.ts +9 -9
  116. package/src/utils/version.ts +11 -0
  117. package/src/components/ui/CommandPalette.tsx +0 -261
@@ -14,6 +14,7 @@ import {
14
14
  Gauge,
15
15
  ShieldCheck,
16
16
  Settings,
17
+ Megaphone,
17
18
  PanelLeftClose,
18
19
  PanelLeftOpen,
19
20
  } from "lucide-react";
@@ -49,9 +50,7 @@ type NavRailView =
49
50
  // content under the cursor).
50
51
  //
51
52
  // In embedded mode (Radar Hub) this rail is not rendered at all — the host
52
- // owns the left chrome via its own fleet LeftRail, and Radar falls back to
53
- // its top-bar pill nav. That keeps the @skyhook-io/radar-app surface
54
- // non-breaking and avoids triple-stacked left chrome.
53
+ // owns primary view navigation via its own fleet LeftRail.
55
54
 
56
55
  interface NavItemDef {
57
56
  view: NavRailView;
@@ -66,8 +65,7 @@ interface NavItemDef {
66
65
  // sits there — next to Cost, its spend-lever sibling. It renders on every
67
66
  // cluster (like Issues/Topology): the view itself reads the cluster's capacity
68
67
  // posture across all node managers, not just Karpenter. The rail's vertical
69
- // room lets us surface the views the 8-slot pill bar dropped (Issues,
70
- // Applications, Cost).
68
+ // room lets us surface every supported standalone destination.
71
69
  const NAV_ITEMS: NavItemDef[] = [
72
70
  { view: "home", icon: Home, label: "Home" },
73
71
  { view: "resources", icon: List, label: "Resources" },
@@ -100,6 +98,8 @@ interface PrimaryNavRailProps {
100
98
  // which self-nulls without auth so the row vanishes in no-auth OSS).
101
99
  onOpenSettings?: () => void;
102
100
  accountSlot?: ReactNode;
101
+ // Present only when release notes exist for this version; unread adds a dot.
102
+ whatsNew?: { unread: boolean; onOpen: () => void };
103
103
  }
104
104
 
105
105
  export function PrimaryNavRail({
@@ -110,6 +110,7 @@ export function PrimaryNavRail({
110
110
  showPinToggle = true,
111
111
  onOpenSettings,
112
112
  accountSlot,
113
+ whatsNew,
113
114
  }: PrimaryNavRailProps) {
114
115
  return (
115
116
  <aside
@@ -160,6 +161,15 @@ export function PrimaryNavRail({
160
161
  accountSlot self-nulls without auth, so no-auth OSS shows only Settings. */}
161
162
  <nav className="flex flex-col gap-0.5 px-2 pt-1 border-t border-theme-border/50">
162
163
  {accountSlot}
164
+ {whatsNew && (
165
+ <RailActionRow
166
+ icon={Megaphone}
167
+ label="What's new"
168
+ pinned={pinned}
169
+ onClick={whatsNew.onOpen}
170
+ unread={whatsNew.unread}
171
+ />
172
+ )}
163
173
  {onOpenSettings && (
164
174
  <RailActionRow
165
175
  icon={Settings}
@@ -340,11 +350,13 @@ export function RailActionRow({
340
350
  label,
341
351
  pinned,
342
352
  onClick,
353
+ unread = false,
343
354
  }: {
344
355
  icon: ComponentType<{ className?: string }>;
345
356
  label: string;
346
357
  pinned: boolean;
347
358
  onClick: () => void;
359
+ unread?: boolean;
348
360
  }) {
349
361
  return (
350
362
  <div className={clsx("group/item relative", !pinned && "w-10")}>
@@ -356,11 +368,18 @@ export function RailActionRow({
356
368
  !pinned && "max-w-10 overflow-hidden",
357
369
  )}
358
370
  >
359
- <span className="flex w-10 shrink-0 items-center justify-center">
360
- <Icon className="w-[18px] h-[18px] text-theme-text-tertiary group-hover/item:text-theme-text-secondary" />
371
+ <span className="relative flex w-10 shrink-0 items-center justify-center">
372
+ <Icon className={clsx(
373
+ "w-[18px] h-[18px] group-hover/item:text-theme-text-secondary",
374
+ unread ? "text-accent" : "text-theme-text-tertiary",
375
+ )} />
376
+ {unread && (
377
+ <span aria-hidden className="absolute top-1.5 right-2 h-2 w-2 rounded-full bg-accent ring-2 ring-theme-sidebar" />
378
+ )}
361
379
  </span>
362
- <span className={clsx("pr-3 truncate", !pinned && "opacity-0")}>
380
+ <span className={clsx("pr-3 truncate", !pinned && "opacity-0", unread && "text-theme-text-primary")}>
363
381
  {label}
382
+ {unread && <span className="sr-only"> (unread)</span>}
364
383
  </span>
365
384
  </button>
366
385
  {!pinned && (
@@ -0,0 +1,168 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { NavCustomizationProvider } from '../../context/NavCustomization'
4
+ import { ApiError, type PrometheusPVCUsage } from '../../api/client'
5
+
6
+ let canConfigure = true
7
+ let roleLoading = false
8
+ let statusResult: { data?: { connected: boolean; discovering?: boolean }; error?: Error }
9
+ let usageResult: { data?: PrometheusPVCUsage; error?: Error }
10
+ vi.mock('../../api/client', async (importActual) => ({
11
+ ...(await importActual<typeof import('../../api/client')>()),
12
+ usePrometheusPVCUsage: () => usageResult,
13
+ usePrometheusStatus: () => statusResult,
14
+ useAutoPromConnect: () => undefined,
15
+ useCloudRole: () => ({ canAtLeast: () => canConfigure, isLoading: roleLoading }),
16
+ }))
17
+ const { PVCUsageBar } = await import('./PVCUsageBar')
18
+ const measured: PrometheusPVCUsage = { namespace: 'demo', name: 'disk', used: 0, capacity: 1024, ratio: 0, hasData: true, status: 'available' }
19
+ const render = () => renderToString(<PVCUsageBar namespace="demo" name="disk" />)
20
+
21
+ describe('PVC usage availability', () => {
22
+ beforeEach(() => {
23
+ canConfigure = true
24
+ roleLoading = false
25
+ statusResult = { data: { connected: true } }
26
+ usageResult = {}
27
+ })
28
+ it.each([
29
+ ['no_series', 'No usage measurements reported for this volume'],
30
+ ['invalid_data', 'Volume usage measurements are invalid'],
31
+ ['query_failed', 'The usage query failed'],
32
+ ] as const)('explains %s without a gauge', (status, message) => {
33
+ usageResult = { data: { ...measured, hasData: false, status } }
34
+ expect(render()).toContain(message)
35
+ expect(render()).not.toContain('width:')
36
+ })
37
+ it('reports discovery as in progress', () => {
38
+ statusResult = { data: { connected: false, discovering: true } }
39
+ expect(render()).toContain('Discovering Prometheus')
40
+ expect(render()).not.toContain('not connected')
41
+ })
42
+ it('does not attribute status denial to this PVC', () => {
43
+ statusResult = { error: new ApiError('forbidden', 403) }
44
+ expect(render()).toContain('Metrics access denied')
45
+ })
46
+ it('keeps older agent unavailable responses from becoming zero gauges', () => {
47
+ usageResult = { data: { ...measured, status: undefined, hasData: false, capacity: 0 } }
48
+ expect(render()).toContain('Usage measurements are unavailable')
49
+ expect(render()).not.toContain('width:')
50
+ })
51
+ it('retains older agent valid measurements', () => {
52
+ usageResult = { data: { ...measured, status: undefined } }
53
+ expect(render()).toContain('width:0%')
54
+ })
55
+ it('renders valid zero usage as a measurement', () => {
56
+ usageResult = { data: measured }
57
+ expect(render().replaceAll('<!-- -->', '')).toContain('(0%)')
58
+ expect(render()).toContain('width:0%')
59
+ })
60
+ it('shows loading instead of an empty row', () => {
61
+ expect(render()).toContain('Loading usage measurements')
62
+ })
63
+ it('does not present stale usage after a refresh failure', () => {
64
+ usageResult = { data: measured, error: new ApiError('failed', 500) }
65
+ expect(render()).toContain('The usage query failed')
66
+ expect(render()).not.toContain('width:')
67
+ })
68
+ it('does not present stale usage when disconnected', () => {
69
+ statusResult = { data: { connected: false } }
70
+ usageResult = { data: measured }
71
+ expect(render()).toContain('Prometheus is not connected')
72
+ expect(render()).not.toContain('width:')
73
+ })
74
+ it('distinguishes denied access from unavailable measurements', () => {
75
+ usageResult = { data: measured, error: new ApiError('forbidden', 403) }
76
+ expect(render()).toContain('access to usage metrics for this PVC')
77
+ expect(render()).not.toContain('width:')
78
+ })
79
+ it('does not reuse metrics if connection status cannot be checked', () => {
80
+ statusResult = { data: { connected: true }, error: new Error('failed') }
81
+ usageResult = { data: measured }
82
+ expect(render()).toContain('Could not check the metrics connection')
83
+ expect(render()).not.toContain('width:')
84
+ })
85
+ })
86
+
87
+
88
+ describe('PVC metrics next step', () => {
89
+ beforeEach(() => {
90
+ canConfigure = true
91
+ roleLoading = false
92
+ statusResult = { data: { connected: true } }
93
+ usageResult = { data: { ...measured, hasData: false, status: 'no_series' } }
94
+ })
95
+ it.each(['no_series', 'invalid_data', 'query_failed'] as const)('offers the existing settings flow for %s', status => {
96
+ usageResult = { data: { ...measured, hasData: false, status } }
97
+ expect(render()).toContain('Configure metrics')
98
+ expect(render()).toContain('type="button"')
99
+ })
100
+ it('offers configuration when disconnected', () => {
101
+ statusResult = { data: { connected: false } }
102
+ expect(render()).toContain('Configure metrics')
103
+ })
104
+ it('offers configuration after a status query failure', () => {
105
+ statusResult = { error: new Error('status failed') }
106
+ expect(render()).toContain('Configure metrics')
107
+ })
108
+ it('offers configuration for older-agent unavailable measurements', () => {
109
+ usageResult = { data: { ...measured, status: undefined, hasData: false } }
110
+ expect(render()).toContain('Configure metrics')
111
+ })
112
+ it('gives a non-owner factual guidance without a locked-settings link', () => {
113
+ canConfigure = false
114
+ expect(render()).not.toContain('Configure metrics')
115
+ expect(render()).toContain('check metrics availability for this volume')
116
+ expect(render()).not.toContain('check the metrics connection')
117
+ })
118
+ it('gives a non-owner connection guidance only when disconnected', () => {
119
+ canConfigure = false
120
+ statusResult = { data: { connected: false } }
121
+ expect(render()).toContain('check the metrics connection for this cluster')
122
+ expect(render()).not.toContain('Configure metrics')
123
+ })
124
+ it('does not offer inaccessible standalone settings in an embedded host', () => {
125
+ const text = renderToString(<NavCustomizationProvider value={{ embedded: true }}><PVCUsageBar namespace="demo" name="disk" /></NavCustomizationProvider>)
126
+ expect(text).not.toContain('Configure metrics')
127
+ expect(text).toContain('check metrics availability')
128
+ })
129
+ it('does not flash an action while permissions load', () => {
130
+ roleLoading = true
131
+ expect(render()).not.toContain('Configure metrics')
132
+ expect(render()).not.toContain('Ask your operator')
133
+ })
134
+ it.each(['usage', 'status'])('guides %s denial toward access rather than connection settings', source => {
135
+ if (source === 'usage') usageResult = { error: new ApiError('forbidden', 403) }
136
+ else statusResult = { error: new ApiError('forbidden', 403) }
137
+ expect(render()).not.toContain('Configure metrics')
138
+ expect(render()).toContain('review your metrics access')
139
+ })
140
+ it.each([true, false])('keeps discovery quiet with a retained usage error (owner=%s)', owner => {
141
+ canConfigure = owner
142
+ statusResult = { data: { connected: false, discovering: true } }
143
+ usageResult = { error: new ApiError('retained query failure', 500) }
144
+ expect(render()).toContain('Discovering Prometheus')
145
+ expect(render()).not.toContain('Configure metrics')
146
+ expect(render()).not.toContain('Ask your operator')
147
+ })
148
+ it('keeps status loading quiet with a retained usage error', () => {
149
+ statusResult = {}
150
+ usageResult = { error: new ApiError('retained query failure', 500) }
151
+ expect(render()).toContain('Checking metrics availability')
152
+ expect(render()).not.toContain('Configure metrics')
153
+ })
154
+ it('retains access guidance when denied during discovery', () => {
155
+ statusResult = { data: { connected: false, discovering: true } }
156
+ usageResult = { error: new ApiError('forbidden', 403) }
157
+ expect(render()).toContain('review your metrics access')
158
+ expect(render()).not.toContain('Configure metrics')
159
+ })
160
+ it.each(['status', 'discovery', 'usage', 'available'])('does not add an action during %s', state => {
161
+ if (state === 'status') statusResult = {}
162
+ if (state === 'discovery') statusResult = { data: { connected: false, discovering: true } }
163
+ if (state === 'usage') usageResult = {}
164
+ if (state === 'available') usageResult = { data: measured }
165
+ expect(render()).not.toContain('Configure metrics')
166
+ expect(render()).not.toContain('Ask your operator')
167
+ })
168
+ })
@@ -1,26 +1,72 @@
1
1
  import { formatMemoryBytes } from '@skyhook-io/k8s-ui/utils/format'
2
- import { useAutoPromConnect, usePrometheusPVCUsage, usePrometheusStatus } from '../../api/client'
3
-
4
- /**
5
- * PVCUsageBar — single-line capacity gauge derived from kubelet_volume_stats_*.
6
- *
7
- * Hidden silently when:
8
- * - Prometheus isn't connected
9
- * - The CSI driver doesn't implement NodeGetVolumeStats
10
- * - Prometheus isn't scraping kubelet endpoints (notably GMP default config)
11
- *
12
- * Operators get nothing rather than a "no data" message that'd look like Radar
13
- * is broken — the absence is information enough.
14
- */
2
+ import { isForbiddenError, useAutoPromConnect, useCloudRole, usePrometheusPVCUsage, usePrometheusStatus } from '../../api/client'
3
+ import { useNavCustomization } from '../../context/NavCustomization'
4
+
15
5
  export function PVCUsageBar({ namespace, name }: { namespace: string; name: string }) {
16
6
  // PVC detail can be the first Prometheus-backed surface a user opens; without
17
7
  // this, the gauge silently stays hidden until they open a workload metrics tab.
18
8
  useAutoPromConnect()
19
- const { data: status } = usePrometheusStatus()
9
+ const { canAtLeast, isLoading: roleLoading } = useCloudRole()
10
+ const settingsAvailable = !useNavCustomization().embedded
11
+ const canConfigure = settingsAvailable && !roleLoading && canAtLeast('owner')
12
+ const { data: status, error: statusError } = usePrometheusStatus()
20
13
  const isConnected = status?.connected === true
21
- const { data: usage } = usePrometheusPVCUsage(namespace, name, isConnected)
14
+ const { data: usage, error } = usePrometheusPVCUsage(namespace, name, isConnected)
15
+
16
+ let unavailable: string | undefined
17
+ if (isForbiddenError(error)) {
18
+ unavailable = "You don't have access to usage metrics for this PVC."
19
+ } else if (isForbiddenError(statusError)) {
20
+ unavailable = 'Metrics access denied.'
21
+ } else if (statusError) {
22
+ unavailable = 'Could not check the metrics connection.'
23
+ } else if (!status) {
24
+ unavailable = 'Checking metrics availability…'
25
+ } else if (status.discovering) {
26
+ unavailable = 'Discovering Prometheus…'
27
+ } else if (!isConnected) {
28
+ unavailable = 'Prometheus is not connected. Used space is unknown.'
29
+ } else if (error || usage?.status === 'query_failed') {
30
+ unavailable = 'The usage query failed. Used space is unknown.'
31
+ } else if (!usage) {
32
+ unavailable = 'Loading usage measurements…'
33
+ } else if (usage.status === 'no_series') {
34
+ unavailable = 'No usage measurements reported for this volume.'
35
+ } else if (usage.status === 'invalid_data') {
36
+ unavailable = 'Volume usage measurements are invalid. Used space is unknown.'
37
+ } else if (!usage.hasData) {
38
+ unavailable = 'Usage measurements are unavailable.'
39
+ }
40
+
41
+ const denied = isForbiddenError(error) || isForbiddenError(statusError)
42
+ const waiting = !denied && !statusError && (!status || status.discovering || (isConnected && !usage && !error))
43
+ const guidance = denied
44
+ ? 'Ask your operator to review your metrics access.'
45
+ : !canConfigure && !roleLoading
46
+ ? !isConnected || statusError
47
+ ? 'Ask your operator to check the metrics connection for this cluster.'
48
+ : 'Ask your operator to check metrics availability for this volume.'
49
+ : undefined
22
50
 
23
- if (!usage || !usage.hasData) return null
51
+ if (unavailable || !usage) {
52
+ return (
53
+ <section aria-label="PVC usage" className="rounded-lg border border-theme-border bg-theme-surface/30 p-3">
54
+ <div className="text-xs font-medium text-theme-text-secondary uppercase tracking-wide mb-1">Usage</div>
55
+ <p className="text-sm text-theme-text-tertiary">{unavailable}</p>
56
+ {!waiting && (
57
+ canConfigure && !denied ? (
58
+ <button
59
+ type="button"
60
+ className="mt-2 text-xs text-accent hover:underline"
61
+ onClick={() => window.dispatchEvent(new CustomEvent('radar:open-settings', { detail: { section: 'prometheus' } }))}
62
+ >Configure metrics</button>
63
+ ) : guidance ? (
64
+ <p className="mt-2 text-xs text-theme-text-tertiary">{guidance}</p>
65
+ ) : null
66
+ )}
67
+ </section>
68
+ )
69
+ }
24
70
 
25
71
  const pct = Math.max(0, Math.min(1, usage.ratio))
26
72
  const usedLabel = formatMemoryBytes(usage.used)
@@ -30,7 +30,7 @@ vi.mock('@skyhook-io/k8s-ui/components/charts', async (importActual) => ({
30
30
  const { PrometheusChartsGrid } = await import('./PrometheusChartsGrid')
31
31
  const resource = { spec: { template: { spec: { containers: [{ resources: { requests: { cpu: '100m' }, limits: { cpu: '200m' } } }] } } } }
32
32
  const render = (url = '/workloads/Deployment/demo/web?tab=metrics', embedded = false) => renderToString(
33
- <MemoryRouter initialEntries={[url]}><NavCustomizationProvider value={embedded ? { embedded: true, rightExtras: null } : {}}>
33
+ <MemoryRouter initialEntries={[url]}><NavCustomizationProvider value={embedded ? { embedded: true } : {}}>
34
34
  <PrometheusChartsGrid kind="Deployment" namespace="demo" name="web" resource={resource} />
35
35
  </NavCustomizationProvider></MemoryRouter>,
36
36
  )
@@ -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,18 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { JobRenderer, JobSetRenderer } from './JobAdmissionRenderers'
4
+ vi.mock('../../execution/JobSetAdmission', () => ({ KueueAdmission: ({ presentation }: { presentation?: string }) => <div data-presentation={presentation}>admission evidence</div> }))
5
+ const job = { apiVersion: 'batch/v1', kind: 'Job', metadata: { name: 'training', namespace: 'ml' }, status: { conditions: [{ type: 'Failed', status: 'True', reason: 'BackoffLimitExceeded' }] } }
6
+ describe('admission renderer composition', () => {
7
+ it('keeps Job failure above admission evidence', () => {
8
+ const html = renderToStaticMarkup(<JobRenderer data={job} />)
9
+ expect(html).toContain('data-presentation="drawer"')
10
+ expect(html.indexOf('Job Issues')).toBeLessThan(html.indexOf('admission evidence'))
11
+ })
12
+ it('does not mount batch admission for a colliding Job kind', () => {
13
+ expect(renderToStaticMarkup(<JobRenderer data={{...job, apiVersion:'batch.volcano.sh/v1alpha1'}} />)).not.toContain('admission evidence')
14
+ })
15
+ it('mounts admission in the JobSet drawer', () => {
16
+ expect(renderToStaticMarkup(<JobSetRenderer data={{...job, apiVersion:'jobset.x-k8s.io/v1alpha2',kind:'JobSet'}} />)).toContain('admission evidence')
17
+ })
18
+ })
@@ -0,0 +1,18 @@
1
+ import { JobRenderer as BaseJobRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/JobRenderer'
2
+ import { JobSetRenderer as BaseJobSetRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/JobSetRenderer'
3
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
4
+ import { KueueAdmission } from '../../execution/JobSetAdmission'
5
+
6
+ type Props = { data: any; onNavigate?: (ref: ResourceRef) => void }
7
+
8
+ export function JobRenderer({ data, onNavigate }: Props) {
9
+ return <BaseJobRenderer data={data} admissionContent={
10
+ data.apiVersion === 'batch/v1' && data.kind === 'Job' && <KueueAdmission presentation="drawer" resource={data} namespace={data.metadata.namespace} name={data.metadata.name} onNavigate={onNavigate} />
11
+ } />
12
+ }
13
+
14
+ export function JobSetRenderer({ data, onNavigate }: Props) {
15
+ return <BaseJobSetRenderer data={data} admissionContent={
16
+ <KueueAdmission presentation="drawer" resource={data} namespace={data.metadata.namespace} name={data.metadata.name} onNavigate={onNavigate} />
17
+ } />
18
+ }
@@ -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,40 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { RayJobRenderer } from './RayJobRenderer'
4
+ import { ApiError } from '../../../api/client'
5
+ const mock = vi.hoisted(() => ({ query: {} as any, reads: [] as any[] }))
6
+ vi.mock('../../../api/client', () => ({ ApiError: class extends Error { constructor(message: string, public status: number) { super(message) } }, useResource: (...args: any[]) => { mock.reads.push(args); return mock.query } }))
7
+ vi.mock('../../execution/JobSetAdmission', () => ({ KueueAdmission: () => null }))
8
+ const root = { apiVersion: 'ray.io/v1', kind: 'RayJob', metadata: { name: 'train', namespace: 'ml', uid: 'root' }, spec: {} }
9
+ const child = { apiVersion: 'batch/v1', kind: 'Job', metadata: { name: 'train', namespace: 'ml', ownerReferences: [{ apiVersion: 'ray.io/v1', kind: 'RayJob', name: 'train', uid: 'root', controller: true }] }, spec: {}, status: {} }
10
+ const render = (data: any = root) => renderToStaticMarkup(<RayJobRenderer data={data} onNavigate={() => {}} />)
11
+ describe('RayJob child reads', () => {
12
+ beforeEach(() => { mock.query = {}; mock.reads = [] })
13
+ it('reads the exact submitter candidate and accepts only the controller incarnation', () => {
14
+ mock.query = { data: child }
15
+ expect(render()).toContain('Open this Job for submitter Pods')
16
+ expect(mock.reads).toEqual([['jobs', 'ml', 'train', 'batch']])
17
+ expect(render({ ...root, metadata: { ...root.metadata, uid: 'recreated' } })).toContain('ownership mismatch')
18
+ })
19
+ it.each([
20
+ { ...child, apiVersion: 'batch.volcano.sh/v1alpha1' },
21
+ { ...child, metadata: { ...child.metadata, namespace: 'other' } },
22
+ { ...child, metadata: { ...child.metadata, ownerReferences: [{ ...child.metadata.ownerReferences[0], controller: false }] } },
23
+ ])('rejects wrong identity', data => { mock.query = { data }; expect(render()).toContain('ownership mismatch') })
24
+ it.each(['HTTPMode', 'SidecarMode', 'InteractiveMode', 'FutureMode'])('does not read a submitter in %s', submissionMode => { render({ ...root, spec: { submissionMode } }); expect(mock.reads).toEqual([]) })
25
+ it('does not read remote children, including selected clusters', () => { render({ ...root, spec: { managedBy: 'remote', clusterSelector: { 'ray.io/cluster': 'shared' } } }); expect(mock.reads).toEqual([]) })
26
+ it('keeps missing and denied observations distinct, even with cached data', () => {
27
+ mock.query = { data: child, error: new ApiError('not found', 404) }
28
+ expect(render()).toContain('No submitter Job observed')
29
+ mock.query.error = new ApiError('denied', 403)
30
+ expect(render()).toContain('Submitter evidence unavailable')
31
+ expect(render()).not.toContain('Open this Job')
32
+ })
33
+ it('keeps deleting submitter logs reachable without declaring a current attempt', () => {
34
+ mock.query = { data: { ...child, metadata: { ...child.metadata, deletionTimestamp: '2026-09-27T00:00:00Z' } } }
35
+ const html = render({ ...root, status: { jobDeploymentStatus: 'Retrying' } })
36
+ expect(html).toContain('Deleting')
37
+ expect(html).toContain('attempt being cleaned up')
38
+ expect(html).toContain('Open this Job')
39
+ })
40
+ })