@skyhook-io/k8s-ui 1.19.0 → 1.20.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 (139) hide show
  1. package/package.json +9 -2
  2. package/src/components/applications/ApplicationDetail.tsx +8 -2
  3. package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
  5. package/src/components/compare/ResourceCompareView.test.tsx +54 -0
  6. package/src/components/compare/ResourceCompareView.tsx +35 -12
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
  9. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
  10. package/src/components/gitops/GitOpsTableView.tsx +26 -7
  11. package/src/components/gitops/RollbackDialog.test.tsx +80 -0
  12. package/src/components/gitops/RollbackDialog.tsx +25 -6
  13. package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
  14. package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
  15. package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
  16. package/src/components/gitops/applicationset-row.test.ts +12 -0
  17. package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
  18. package/src/components/gitops/detail-helpers.test.ts +8 -2
  19. package/src/components/gitops/detail-helpers.ts +9 -11
  20. package/src/components/gitops/index.ts +1 -1
  21. package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
  22. package/src/components/gitops/insights/GitOpsInsightViews.tsx +30 -12
  23. package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
  24. package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
  25. package/src/components/gitops/insights/insights-helpers.test.ts +37 -0
  26. package/src/components/gitops/insights/insights-helpers.ts +12 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +3 -1
  28. package/src/components/issues/diagnostic.ts +14 -1
  29. package/src/components/issues/severity.ts +14 -0
  30. package/src/components/kueue/AdmissionPath.tsx +223 -0
  31. package/src/components/kueue/QueueEvidence.tsx +148 -0
  32. package/src/components/kueue/constants.ts +1 -0
  33. package/src/components/kueue/eligibility.ts +43 -0
  34. package/src/components/kueue/flavors-tried.ts +79 -0
  35. package/src/components/kueue/helpers.test.ts +105 -0
  36. package/src/components/kueue/policy.ts +100 -0
  37. package/src/components/kueue/queue-detail.test.tsx +317 -0
  38. package/src/components/kueue/queue-status.ts +42 -0
  39. package/src/components/kueue/related-read.ts +48 -0
  40. package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
  41. package/src/components/logs/StructuredLogLine.test.tsx +22 -0
  42. package/src/components/logs/StructuredLogLine.tsx +8 -3
  43. package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
  44. package/src/components/resources/KueueAdmissionSection.tsx +51 -12
  45. package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
  46. package/src/components/resources/KueuePendingPosition.tsx +91 -0
  47. package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
  48. package/src/components/resources/ResourcesSidebar.tsx +3 -2
  49. package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
  50. package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
  51. package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
  52. package/src/components/resources/ResourcesView.tsx +50 -3
  53. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  54. package/src/components/resources/generic-status.ts +7 -0
  55. package/src/components/resources/get-pod-phase-display.test.ts +70 -0
  56. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
  57. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
  58. package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
  59. package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
  60. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
  61. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
  62. package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
  63. package/src/components/resources/renderers/JobRenderer.tsx +7 -2
  64. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
  65. package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
  66. package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
  67. package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
  68. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
  69. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
  70. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
  71. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
  72. package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
  73. package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
  74. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
  75. package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
  76. package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
  77. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
  78. package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
  79. package/src/components/resources/renderers/crd-cells.tsx +55 -0
  80. package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
  81. package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
  82. package/src/components/resources/renderers/index.ts +6 -0
  83. package/src/components/resources/renderers/kueue-cells.tsx +5 -0
  84. package/src/components/resources/resource-utils-argo.test.ts +94 -1
  85. package/src/components/resources/resource-utils-argo.ts +84 -5
  86. package/src/components/resources/resource-utils-crd.test.ts +32 -0
  87. package/src/components/resources/resource-utils-crd.ts +26 -0
  88. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
  89. package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
  90. package/src/components/resources/resource-utils-kueue.test.ts +15 -0
  91. package/src/components/resources/resource-utils-kueue.ts +137 -4
  92. package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
  93. package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
  94. package/src/components/resources/resource-utils-ray.test.ts +42 -1
  95. package/src/components/resources/resource-utils-ray.ts +38 -0
  96. package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
  97. package/src/components/resources/resource-utils-strimzi.ts +35 -0
  98. package/src/components/resources/resource-utils.ts +45 -1
  99. package/src/components/shared/ResourceActionsBar.tsx +83 -25
  100. package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
  101. package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
  102. package/src/components/topology/K8sResourceNode.test.ts +14 -0
  103. package/src/components/topology/K8sResourceNode.tsx +18 -1
  104. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  105. package/src/components/topology/TopologyGraph.tsx +96 -94
  106. package/src/components/topology/layout.ts +3 -1
  107. package/src/components/topology/pod-group-expansion.test.ts +38 -0
  108. package/src/components/ui/Badge.tsx +1 -0
  109. package/src/components/ui/RadarUpgradeNote.tsx +8 -0
  110. package/src/components/ui/drawer-components.tsx +1 -1
  111. package/src/components/ui/index.ts +1 -0
  112. package/src/components/workload/PodList.tsx +35 -9
  113. package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
  114. package/src/components/workload/WorkloadView.tsx +61 -8
  115. package/src/components/workload/workload-logs-availability.test.ts +7 -0
  116. package/src/types/core.ts +40 -0
  117. package/src/types/gitops-insights.ts +11 -2
  118. package/src/types/gitops-tree.ts +2 -2
  119. package/src/types/kueue-queues.ts +84 -0
  120. package/src/types/read-coverage.ts +2 -1
  121. package/src/types/scheduling.ts +72 -0
  122. package/src/utils/animation.test.ts +38 -0
  123. package/src/utils/animation.ts +34 -0
  124. package/src/utils/api-resources-crd.test.ts +11 -0
  125. package/src/utils/api-resources.ts +5 -1
  126. package/src/utils/applications.test.ts +15 -1
  127. package/src/utils/applications.ts +26 -1
  128. package/src/utils/crd-schema.test.ts +107 -0
  129. package/src/utils/crd-schema.ts +201 -0
  130. package/src/utils/format.test.ts +37 -1
  131. package/src/utils/format.ts +28 -80
  132. package/src/utils/gitops-owner.test.ts +28 -2
  133. package/src/utils/gitops-owner.ts +19 -0
  134. package/src/utils/kueue-queue-workloads.ts +19 -0
  135. package/src/utils/resource-icons.ts +2 -0
  136. package/src/utils/topology-neighborhood.test.ts +90 -1
  137. package/src/utils/topology-neighborhood.ts +55 -3
  138. package/src/utils/workload-history-notes.test.ts +76 -0
  139. package/src/utils/workload-history-notes.ts +93 -0
@@ -0,0 +1,189 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { KueueQueueWorkloads, emptyMessage, listedFact, membershipText, showingText, uniqueRows, unreadWorkloadsText } from './KueueQueueWorkloads'
4
+ import { kueueQueueRetryable } from '../../../utils/kueue-queue-workloads'
5
+ import type { KueueQueueMembers, KueueQueueWorkload, KueueQueueWorkloadsResponse } from '../../../types/kueue-queues'
6
+ import type { ResourceReadCoverage } from '../../../types/resource-coverage'
7
+
8
+ const complete: ResourceReadCoverage = { status: 'complete', scope: { type: 'all_namespaces' }, read: { type: 'all_namespaces' }, gaps: [] }
9
+ const yours: ResourceReadCoverage = {
10
+ status: 'complete', scope: { type: 'namespaces', namespaces: { count: 2, names: ['team-a', 'team-c'] } }, narrowedBy: ['user_access'],
11
+ read: { type: 'namespaces', namespaces: { count: 2, names: ['team-a', 'team-c'] } }, gaps: [],
12
+ }
13
+ const partial: ResourceReadCoverage = {
14
+ status: 'partial', scope: { type: 'namespaces', namespaces: { count: 3, names: ['team-a', 'team-b', 'team-c'] } }, narrowedBy: ['user_access'],
15
+ read: { type: 'namespaces', namespaces: { count: 2, names: ['team-a', 'team-c'] } },
16
+ gaps: [{ code: 'user_forbidden', namespaces: { count: 1, names: ['team-b'] } }],
17
+ }
18
+
19
+ const row = (name: string, extra: Partial<KueueQueueWorkload> = {}): KueueQueueWorkload => ({
20
+ namespace: 'team-a', name, uid: `uid-${name}`, apiVersion: 'kueue.x-k8s.io/v1beta2', createdAt: null, deleting: false,
21
+ membership: ['submission'], queueName: 'lq', submission: { localQueue: 'lq', localQueueUID: 'lq-uid', clusterQueue: 'cq' }, ...extra,
22
+ })
23
+
24
+ const members = (items: KueueQueueWorkload[], coverage: ResourceReadCoverage, extra: Partial<KueueQueueMembers> = {}): KueueQueueMembers => ({
25
+ items, matching: items.length, counts: { members: items.length, submission: items.length, reservation: 0, states: {} }, coverage, ...extra,
26
+ })
27
+
28
+ const response = (workloads: KueueQueueMembers | undefined, extra: Partial<KueueQueueWorkloadsResponse> = {}): KueueQueueWorkloadsResponse => ({
29
+ queue: { kind: 'ClusterQueue', name: 'cq', uid: 'cq-uid' }, workloads, ...extra,
30
+ })
31
+
32
+ const render = (props: Partial<Parameters<typeof KueueQueueWorkloads>[0]>) =>
33
+ renderToStaticMarkup(<KueueQueueWorkloads kind="ClusterQueue" queue={{ metadata: { name: 'cq' } }} {...props} />)
34
+
35
+ describe('the count Radar lists', () => {
36
+ it('is exact when the read was complete and says whose namespaces it covers', () => {
37
+ expect(listedFact(members([row('a')], complete), false)).toMatchObject({ text: '1' })
38
+ expect(listedFact(members([row('a')], yours), false).source).toContain('namespaces Radar shows you')
39
+ expect(listedFact(members([row('a')], complete), false).source).toContain("Not the controller's count")
40
+ })
41
+
42
+ it('is a lower bound when the read was partial, and an unread zero is not a count', () => {
43
+ expect(listedFact(members([row('a')], partial), false).text).toBe('≥1')
44
+ expect(listedFact(members([], partial), false).text).toBe('None in what Radar read')
45
+ })
46
+
47
+ it('is a lower bound when LocalQueues went unread somewhere, though the Workload read was complete', () => {
48
+ const unresolved = members([row('a')], complete, { submissionUnresolved: { count: 1, names: ['team-b'] } })
49
+ expect(listedFact(unresolved, false).text).toBe('≥1')
50
+ const html = render({ pages: [response(unresolved)], onMembershipChange: () => {} })
51
+ expect(html).toContain('All (≥1)')
52
+ expect(html).toContain('Showing 1 of ≥1')
53
+ expect(html).toContain("Radar couldn&#x27;t read LocalQueues in team-b")
54
+ expect(emptyMessage(members([], complete, { submissionUnresolved: { count: 1, names: ['team-b'] } }), false, false)).toBe('No Workloads found where Radar could read.')
55
+ expect(render({ pages: [response(members([], complete, { submissionUnresolved: { extent: 'unknown' } }))] })).not.toContain('every namespace')
56
+ expect(render({ pages: [response(members([row('a')], complete, { submissionUnresolved: { extent: 'all' } }))] })).toContain('any namespace it read Workloads in')
57
+ })
58
+
59
+ it('bounds the state pills too, and never shows an exact count for a lower bound', () => {
60
+ const html = render({ pages: [response(members([row('a')], partial, { counts: { members: 1, submission: 1, reservation: 0, states: { insufficient_quota: 3, admitted: 2 } } }))], onStateChange: () => {} })
61
+ expect(html).toContain('Waiting for quota (≥3)')
62
+ expect(html).toContain('Admitted (≥2)')
63
+ })
64
+ })
65
+
66
+ describe('empty answers', () => {
67
+ it('keeps confirmed empty apart from empty where Radar or you could read', () => {
68
+ expect(emptyMessage(members([], complete), false, false)).toBe('No Workloads belong to this queue.')
69
+ expect(emptyMessage(members([], complete), true, false)).toBe('No Workloads are submitted to this queue.')
70
+ expect(emptyMessage(members([], yours), false, false)).toBe('No Workloads in the namespaces Radar shows you.')
71
+ expect(emptyMessage(members([], partial), false, false)).toBe('No Workloads found where Radar could read.')
72
+ expect(emptyMessage(members([], complete, { counts: { members: 3, submission: 3, reservation: 0, states: {} } }), false, true)).toBe('No Workloads match this filter.')
73
+ })
74
+
75
+ it('renders scenario 7 as confirmed empty only when nothing narrowed the read', () => {
76
+ expect(render({ pages: [response(members([], complete))] })).toContain('Radar read Workloads and LocalQueues in every namespace.')
77
+ expect(render({ pages: [response(members([], yours))] })).not.toContain('every namespace')
78
+ })
79
+ })
80
+
81
+ describe('membership in words', () => {
82
+ it('names both queues when the reservation and the LocalQueue disagree', () => {
83
+ const stale = row('w', { membership: ['reservation'], reservation: { clusterQueue: 'cq' }, submission: { localQueue: 'lq', localQueueUID: 'lq-uid', clusterQueue: 'new-cq' } })
84
+ expect(membershipText(stale, false, undefined)).toBe('Reserved here · its LocalQueue lq now points to new-cq')
85
+ expect(membershipText(row('w', { reservation: { clusterQueue: 'old-cq' } }), true, 'cq')).toBe('Reserved in old-cq · this LocalQueue now points to cq')
86
+ })
87
+
88
+ it('says when a LocalQueue was not read rather than calling it missing', () => {
89
+ const reserved = row('w', { membership: ['reservation'], reservation: { clusterQueue: 'cq' }, submission: undefined })
90
+ expect(membershipText({ ...reserved, localQueueUnread: true }, false, undefined)).toBe('Reserved here · LocalQueue lq not read')
91
+ expect(membershipText(reserved, false, undefined)).toBe('Reserved here · LocalQueue lq not found')
92
+ expect(membershipText(row('w', { membership: ['submission', 'reservation'], reservation: { clusterQueue: 'cq' } }), false, undefined)).toBe('Submitted through lq · reserved here')
93
+ })
94
+
95
+ it('does not call a finished Workload reserved, nor claim it was admitted', () => {
96
+ const done = row('w', { membership: ['reservation'], reservation: { clusterQueue: 'cq' }, admission: { state: 'finished', attention: 'none' }, submission: { localQueue: 'lq', localQueueUID: 'u', clusterQueue: 'cq' } })
97
+ expect(membershipText(done, false, undefined)).toBe('Reservation recorded here · its LocalQueue lq now points to cq')
98
+ expect(membershipText(done, true, 'cq')).toBe('Reservation recorded in cq')
99
+ })
100
+ })
101
+
102
+ describe('scenarios', () => {
103
+ it('scenario 2: names the namespace you cannot list and shows its LocalQueue counts as the controller reports them', () => {
104
+ const html = render({ pages: [response(members([row('a')], partial), {
105
+ localQueues: { total: 2, coverage: complete, items: [
106
+ { namespace: 'team-a', name: 'lq', reported: { pending: 1, reserving: null, admitted: 0 }, workloadsRead: true },
107
+ { namespace: 'team-b', name: 'lq', reported: { pending: 3, reserving: 1, admitted: 1 }, workloadsRead: false, workloadsGap: 'user_forbidden' },
108
+ ] },
109
+ })] })
110
+ expect(html).toContain('your permissions exclude Workloads in team-b')
111
+ expect(html).toContain('≥1')
112
+ expect(html).toContain('Controller reports 3 pending · 1 reserving · 1 admitted')
113
+ expect(html).toContain("You can&#x27;t list Workloads in team-b, so its Workloads aren&#x27;t listed above.")
114
+ expect(html).toContain('reserving not reported')
115
+ expect(html.replace(/<[^>]+>/g, ' ')).not.toMatch(/hidden/i)
116
+ })
117
+
118
+ it('scenario 6: a LocalQueue whose Workloads you cannot list shows its own reported counts, not a list', () => {
119
+ const html = renderToStaticMarkup(<KueueQueueWorkloads kind="LocalQueue" queue={{ metadata: { namespace: 'team-b', name: 'lq' }, status: { pendingWorkloads: 3, admittedWorkloads: 1 } }} pages={[{
120
+ queue: { kind: 'LocalQueue', namespace: 'team-b', name: 'lq', uid: 'lq-uid', clusterQueue: 'cq' },
121
+ workloadsError: { error: 'insufficient permissions to list workloads', error_code: 'user_forbidden' },
122
+ }]} />)
123
+ expect(html).toContain('You can&#x27;t list Workloads in team-b.')
124
+ expect(html).toContain('The controller reports 3 pending · reserving not reported · 1 admitted for this LocalQueue.')
125
+ })
126
+
127
+ it('never implies admission order and says how many of the matching rows are shown', () => {
128
+ const html = render({ pages: [response(members([row('a'), row('b')], complete, { matching: 5, next: 'x' }))], hasMore: true, onLoadMore: () => {} })
129
+ expect(html).toContain('Showing 2 of 5 · sorted by namespace and name, finished last; not admission order')
130
+ expect(html).toContain('Load more')
131
+ })
132
+
133
+ it('shows an older Radar the upgrade note instead of an error', () => {
134
+ const html = render({ upgrade: { feature: 'Workload lists on Kueue queues' } })
135
+ expect(html).toContain('Workload lists on Kueue queues')
136
+ })
137
+ })
138
+
139
+ describe('paging', () => {
140
+ it('takes counts from the first page and never shows more rows than it says there are', () => {
141
+ const first = response(members([row('a')], complete, { matching: 1, next: 'x', counts: { members: 1, submission: 1, reservation: 0, states: {} } }))
142
+ const second = response(members([row('b')], complete, { matching: 9, counts: { members: 9, submission: 9, reservation: 0, states: {} } }))
143
+ const html = render({ pages: [first, second], onRetry: () => {} })
144
+ expect(html).toContain('Showing 2; the list changed while paging')
145
+ expect(html).toContain('Refresh')
146
+ expect(showingText(2, 5, false)).toBe('Showing 2 of 5')
147
+ })
148
+
149
+ it('stops paging when the next-page request itself failed', () => {
150
+ const html = render({ pages: [response(members([row('a')], complete, { next: 'x' }))], hasMore: true, onLoadMore: () => {}, onRetry: () => {}, error: 'the cursor belongs to another list', nextPageError: 'the cursor belongs to another list' })
151
+ expect(html).toContain('Could not read the next page: the cursor belongs to another list')
152
+ expect(html).not.toContain('Load more')
153
+ expect(html).not.toContain('Could not refresh')
154
+ expect(html).toContain('Refresh')
155
+ })
156
+
157
+ it('dims the previous filter\'s rows and offers no Load more while the new filter loads', () => {
158
+ const html = render({ pages: [response(members([row('a')], complete, { next: 'x' }))], hasMore: true, onLoadMore: () => {}, stale: true })
159
+ expect(html).toContain('aria-busy="true"')
160
+ expect(html).not.toContain('Load more')
161
+ })
162
+
163
+
164
+ it('polls sooner only while a retry can complete the answer', () => {
165
+ expect(kueueQueueRetryable(response(members([], { ...partial, gaps: [{ code: 'kind_sync_pending', namespaces: { count: 1, names: ['team-b'] } }] })))).toBe(true)
166
+ expect(kueueQueueRetryable(response(members([], partial)))).toBe(false)
167
+ expect(kueueQueueRetryable(response(undefined, { workloadsError: { error: 'x', error_code: 'kind_sync_pending' } }))).toBe(true)
168
+ // An overdue load won't clear in seconds.
169
+ expect(kueueQueueRetryable(response(undefined, { workloadsError: { error: 'x', error_code: 'kind_sync_timed_out' } }))).toBe(false)
170
+ // Each poll would spend more permission checks: wait for the ordinary refresh.
171
+ expect(kueueQueueRetryable(response(undefined, { workloadsError: { error: 'x', error_code: 'user_permission_check_failed' } }))).toBe(false)
172
+ })
173
+ })
174
+
175
+ it('says why a LocalQueue\'s Workloads are missing by who can fix it', () => {
176
+ const lq = { namespace: 'team-b', name: 'lq', reported: { pending: 1, reserving: null, admitted: null }, workloadsRead: false }
177
+ expect(unreadWorkloadsText({ ...lq, workloadsGap: 'user_forbidden' })).toContain("You can't list Workloads in team-b")
178
+ expect(unreadWorkloadsText({ ...lq, workloadsGap: 'radar_forbidden' })).toContain("Radar's own access")
179
+ expect(unreadWorkloadsText({ ...lq, workloadsGap: 'kind_sync_pending' })).toBe('Workloads in team-b are still loading.')
180
+ expect(unreadWorkloadsText({ ...lq, workloadsGap: 'user_permission_check_failed' })).toContain('refresh to check again')
181
+ })
182
+
183
+ it('lists a row once when it moved between pages', () => {
184
+ const first = response(members([row('a'), row('b')], complete))
185
+ const second = response(members([row('b'), row('c')], complete))
186
+ expect(uniqueRows([first, second]).map(r => r.name)).toEqual(['a', 'b', 'c'])
187
+ const finishedLater = response(members([row('a', { admission: { state: 'finished', attention: 'none' } })], complete))
188
+ expect(uniqueRows([first, finishedLater]).map(r => [r.name, r.admission?.state])).toEqual([['a', 'finished'], ['b', undefined]])
189
+ })
@@ -0,0 +1,460 @@
1
+ import type { ReactNode } from 'react'
2
+ import { ListChecks } from 'lucide-react'
3
+ import { Badge } from '../../ui/Badge'
4
+ import { kueueAdmissionStateLabel, kueueAdmissionStatePresentation } from '../resource-utils-kueue'
5
+ import { ResourceLink, Section } from '../../ui/drawer-components'
6
+ import { RadarUpgradeNote } from '../../ui/RadarUpgradeNote'
7
+ import { FilterPill } from '../../ui/FilterPill'
8
+ import { FactGrid, FactRow, FactSource, FactValue, type Fact } from '../../facts'
9
+ import type { RadarUpgradeRequirement } from '../../../types/fetch-error'
10
+ import type { ResourceReadCoverage } from '../../../types/resource-coverage'
11
+ import type {
12
+ KueueQueueLocalQueue,
13
+ KueueQueueMembers,
14
+ KueueQueueMembership,
15
+ KueueQueueWorkload,
16
+ KueueQueueWorkloadsResponse,
17
+ } from '../../../types/kueue-queues'
18
+ import { boundedCountLabel, isLowerBound, resourceCoverageNotice, resourceNarrowedNotice } from '../../../utils/resource-coverage'
19
+ import { pluralize } from '../../../utils/pluralize'
20
+ import { formatRelativeAgeTime } from '../../../utils/format'
21
+
22
+ const KUEUE_GROUP = 'kueue.x-k8s.io'
23
+
24
+ type NavigateRef = { kind: string; namespace: string; name: string; group?: string }
25
+
26
+ export interface KueueQueueWorkloadsProps {
27
+ kind: 'ClusterQueue' | 'LocalQueue'
28
+ /** The queue object itself: its own reported counts are the evidence shown when its Workloads can't be listed. */
29
+ queue: any
30
+ /**
31
+ * Pages read so far, first page first. The first page's counts, coverage and
32
+ * LocalQueues describe the list; later pages only add rows.
33
+ */
34
+ pages?: KueueQueueWorkloadsResponse[]
35
+ loading?: boolean
36
+ error?: string | null
37
+ upgrade?: RadarUpgradeRequirement | null
38
+ membership?: KueueQueueMembership
39
+ onMembershipChange?: (membership: KueueQueueMembership | undefined) => void
40
+ /** An admission state code to filter by. */
41
+ state?: string
42
+ onStateChange?: (state: string | undefined) => void
43
+ hasMore?: boolean
44
+ loadingMore?: boolean
45
+ onLoadMore?: () => void
46
+ /** Why the next page could not be read, when the request itself failed. */
47
+ nextPageError?: string | null
48
+ /** The pages shown belong to the previous filter while the new one loads. */
49
+ stale?: boolean
50
+ /** Shown above the list, e.g. why it restarted from the first page. */
51
+ notice?: string | null
52
+ onRetry?: () => void
53
+ onNavigate?: (ref: NavigateRef) => void
54
+ }
55
+
56
+ /**
57
+ * The Workloads Radar read that belong to a ClusterQueue or LocalQueue, with
58
+ * what the read covered. Its numbers are Radar's: they sit apart from the
59
+ * counts Kueue reports for the queue, which count other things (Kueue leaves
60
+ * finished Workloads out, Radar lists them) and can lag either way.
61
+ */
62
+ export function KueueQueueWorkloads(props: KueueQueueWorkloadsProps) {
63
+ return (
64
+ <Section title="Workloads" icon={ListChecks} defaultExpanded={true}>
65
+ <KueueQueueWorkloadsBody {...props} />
66
+ </Section>
67
+ )
68
+ }
69
+
70
+ function KueueQueueWorkloadsBody({
71
+ kind, queue, pages, loading, error, upgrade, membership, onMembershipChange, state, onStateChange, hasMore, loadingMore, onLoadMore, nextPageError, stale, notice, onRetry, onNavigate,
72
+ }: KueueQueueWorkloadsProps) {
73
+ if (upgrade) return <RadarUpgradeNote requirement={upgrade} />
74
+ const first = pages?.[0]
75
+ if (!first) {
76
+ if (error) return <Retryable message={`Could not read this queue's Workloads: ${error}`} onRetry={onRetry} />
77
+ return <p className="text-sm text-theme-text-tertiary">{loading ? 'Reading Workloads…' : 'Workloads not read yet.'}</p>
78
+ }
79
+ const local = kind === 'LocalQueue'
80
+ return (
81
+ <div className="space-y-3">
82
+ {notice && <p className="text-xs text-theme-text-tertiary">{notice}</p>}
83
+ {first.workloads ? (
84
+ <MemberList
85
+ local={local}
86
+ clusterQueue={first.queue.clusterQueue}
87
+ members={first.workloads}
88
+ rows={uniqueRows(pages ?? [])}
89
+ paged={(pages?.length ?? 0) > 1}
90
+ laterPageError={nextPageError ?? undefined}
91
+ onRefresh={onRetry}
92
+ membership={membership}
93
+ onMembershipChange={onMembershipChange}
94
+ state={state}
95
+ onStateChange={onStateChange}
96
+ hasMore={hasMore && !stale}
97
+ loadingMore={loadingMore}
98
+ stale={stale}
99
+ onLoadMore={onLoadMore}
100
+ onNavigate={onNavigate}
101
+ />
102
+ ) : (
103
+ <WorkloadsUnread local={local} queue={queue} code={first.workloadsError?.error_code} message={first.workloadsError?.error} unenumerated={first.workloadsError?.coverage?.gaps.some(gap => gap.namespaces?.extent === 'unknown')} />
104
+ )}
105
+ {error && !nextPageError && <Retryable message={`Could not refresh: ${error}`} onRetry={onRetry} />}
106
+ {!local && <LocalQueueList response={first} onNavigate={onNavigate} />}
107
+ </div>
108
+ )
109
+ }
110
+
111
+ function Retryable({ message, onRetry }: { message: string; onRetry?: () => void }) {
112
+ return (
113
+ <div className="flex flex-wrap items-baseline gap-2 text-sm text-warning-text">
114
+ <span>{message}</span>
115
+ {onRetry && <button type="button" className="text-xs text-theme-text-secondary underline hover:text-theme-text-primary" onClick={onRetry}>Retry</button>}
116
+ </div>
117
+ )
118
+ }
119
+
120
+
121
+ /**
122
+ * Rows across pages, once each, in first-seen position with the latest data:
123
+ * a member that finishes between pages is returned again among the finished.
124
+ */
125
+ export function uniqueRows(pages: KueueQueueWorkloadsResponse[]): KueueQueueWorkload[] {
126
+ const rows = new Map<string, KueueQueueWorkload>()
127
+ for (const page of pages) {
128
+ for (const row of page.workloads?.items ?? []) rows.set(row.uid, row)
129
+ }
130
+ return [...rows.values()]
131
+ }
132
+
133
+ /**
134
+ * Whether the member counts are lower bounds: the Workload read was partial
135
+ * or narrowed by Radar's collection, or LocalQueues weren't read somewhere,
136
+ * so Workloads submitted there can't be placed. Reservation counts depend on
137
+ * the Workload read alone.
138
+ */
139
+ export function membersLowerBound(members: KueueQueueMembers, membership?: KueueQueueMembership): boolean {
140
+ return isLowerBound(members.coverage) || (membership !== 'reservation' && members.submissionUnresolved != null)
141
+ }
142
+
143
+ /** The headline count, as a sourced fact: "≥N" when it is a lower bound. */
144
+ export function listedFact(members: KueueQueueMembers, local: boolean): Fact {
145
+ const coverage = members.coverage
146
+ const narrowedToYou = coverage.narrowedBy?.includes('user_access') ?? false
147
+ const text = boundedCountLabel(members.counts.members, membersLowerBound(members))
148
+ return {
149
+ text: text === '–' ? 'None in what Radar read' : text,
150
+ tone: 'neutral',
151
+ source: local
152
+ ? "Radar's read of this namespace's Workloads, finished ones included. Not the controller's count above."
153
+ : `Radar's read of Workloads${narrowedToYou ? ' in the namespaces Radar shows you' : ''}, finished ones included. Not the controller's count above.`,
154
+ }
155
+ }
156
+
157
+ /** Why the list is empty, from what the read covered. */
158
+ export function emptyMessage(members: KueueQueueMembers, local: boolean, filtered: boolean): string {
159
+ if (filtered && members.counts.members > 0) {
160
+ return membersLowerBound(members) ? 'No Workloads match this filter in what Radar could read.' : 'No Workloads match this filter.'
161
+ }
162
+ const { status, narrowedBy } = members.coverage
163
+ if (status === 'partial' || members.submissionUnresolved) return 'No Workloads found where Radar could read.'
164
+ if (narrowedBy?.includes('namespace_collection')) return 'No Workloads in the namespaces Radar collects.'
165
+ if (narrowedBy?.includes('namespace_pin')) return 'No Workloads in the namespace Radar is pinned to.'
166
+ if (narrowedBy?.includes('user_access')) return 'No Workloads in the namespaces Radar shows you.'
167
+ return local ? 'No Workloads are submitted to this queue.' : 'No Workloads belong to this queue.'
168
+ }
169
+
170
+ function confirmedEmptyNote(members: KueueQueueMembers, local: boolean): string | undefined {
171
+ const coverage = members.coverage
172
+ if (coverage.status !== 'complete' || coverage.narrowedBy?.length || members.submissionUnresolved) return undefined
173
+ return local ? 'Radar read every Workload in this namespace.' : 'Radar read Workloads and LocalQueues in every namespace.'
174
+ }
175
+
176
+ /** Why the read covers fewer namespaces than the cluster has, when nothing else on screen says so. */
177
+ function scopeNotice(coverage: ResourceReadCoverage): string | undefined {
178
+ if (coverage.narrowedBy?.includes('namespace_pin')) return "Radar is pinned to one namespace (--namespace-scope); Workloads elsewhere aren't read."
179
+ return undefined
180
+ }
181
+
182
+ function MemberList({
183
+ local, clusterQueue, members, rows, paged, laterPageError, onRefresh, membership, onMembershipChange, state, onStateChange, hasMore, loadingMore, onLoadMore, stale, onNavigate,
184
+ }: {
185
+ local: boolean
186
+ clusterQueue?: string
187
+ members: KueueQueueMembers
188
+ rows: KueueQueueWorkload[]
189
+ paged?: boolean
190
+ laterPageError?: string
191
+ onRefresh?: () => void
192
+ membership?: KueueQueueMembership
193
+ onMembershipChange?: (membership: KueueQueueMembership | undefined) => void
194
+ state?: string
195
+ onStateChange?: (state: string | undefined) => void
196
+ hasMore?: boolean
197
+ loadingMore?: boolean
198
+ onLoadMore?: () => void
199
+ stale?: boolean
200
+ onNavigate?: (ref: NavigateRef) => void
201
+ }) {
202
+ const filtered = !!membership || !!state
203
+ const fact = listedFact(members, local)
204
+ const notices = [
205
+ resourceCoverageNotice(members.coverage, 'Workloads'),
206
+ resourceNarrowedNotice(members.coverage),
207
+ scopeNotice(members.coverage),
208
+ unresolvedNotice(members),
209
+ ].filter(Boolean) as string[]
210
+ const lowerBound = membersLowerBound(members, membership)
211
+ const chip = (count: number, bounded: boolean) => (bounded ? undefined : count)
212
+ return (
213
+ <div className="space-y-3">
214
+ <FactGrid>
215
+ <FactRow label="Listed by Radar">
216
+ <FactValue fact={fact} />
217
+ <FactSource fact={fact} />
218
+ </FactRow>
219
+ </FactGrid>
220
+ {notices.map(notice => <p key={notice} className="text-xs text-warning-text">{notice}</p>)}
221
+ {!local && onMembershipChange && members.counts.members > 0 && (
222
+ <div className="flex flex-wrap gap-1.5" role="group" aria-label="Filter by membership">
223
+ <FilterPill label={chipLabel('All', members.counts.members, membersLowerBound(members))} count={chip(members.counts.members, membersLowerBound(members))} active={!membership} onClick={() => onMembershipChange(undefined)} tooltip="Every Workload submitted here or whose admission names this queue" />
224
+ <FilterPill label={chipLabel('Submitted here', members.counts.submission, membersLowerBound(members, 'submission'))} count={chip(members.counts.submission, membersLowerBound(members, 'submission'))} active={membership === 'submission'} onClick={() => onMembershipChange('submission')} tooltip="Workloads whose LocalQueue points at this ClusterQueue now" />
225
+ <FilterPill label={chipLabel('Reserved here', members.counts.reservation, isLowerBound(members.coverage))} count={chip(members.counts.reservation, isLowerBound(members.coverage))} active={membership === 'reservation'} onClick={() => onMembershipChange('reservation')} tooltip="Workloads whose quota reservation is recorded in this ClusterQueue, admitted or not, finished ones included" />
226
+ </div>
227
+ )}
228
+ {onStateChange && <StateFilter counts={members.counts.states} bounded={lowerBound} state={state} onStateChange={onStateChange} />}
229
+ {rows.length === 0 ? (
230
+ <div>
231
+ <p className="text-sm text-theme-text-secondary">{emptyMessage(members, local, filtered)}</p>
232
+ {!filtered && confirmedEmptyNote(members, local) && <p className="mt-1 text-xs text-theme-text-tertiary">{confirmedEmptyNote(members, local)}</p>}
233
+ </div>
234
+ ) : (
235
+ <ul className={stale ? 'divide-y divide-theme-border opacity-60' : 'divide-y divide-theme-border'} aria-busy={stale || undefined}>
236
+ {rows.map(row => (
237
+ <WorkloadRow key={row.uid} row={row} local={local} clusterQueue={clusterQueue} onNavigate={onNavigate} />
238
+ ))}
239
+ </ul>
240
+ )}
241
+ {rows.length > 0 && (
242
+ <div className="flex flex-wrap items-center justify-between gap-2 text-xs text-theme-text-tertiary">
243
+ <span>
244
+ {showingText(rows.length, members.matching, lowerBound)} · sorted by {local ? 'name' : 'namespace and name'}, finished last; not admission order
245
+ </span>
246
+ <span className="flex gap-2">
247
+ {(paged || laterPageError) && onRefresh && (
248
+ <button type="button" className="btn-secondary px-2 py-1 text-xs" onClick={onRefresh} title="Pages are read at different moments; reread the list from the start">
249
+ Refresh
250
+ </button>
251
+ )}
252
+ {hasMore && onLoadMore && !laterPageError && (
253
+ <button type="button" className="btn-secondary px-2 py-1 text-xs" disabled={loadingMore} onClick={onLoadMore}>
254
+ {loadingMore ? 'Loading…' : 'Load more'}
255
+ </button>
256
+ )}
257
+ </span>
258
+ </div>
259
+ )}
260
+ {laterPageError && <p className="text-xs text-warning-text">Could not read the next page: {laterPageError}. Refresh to start over.</p>}
261
+ </div>
262
+ )
263
+ }
264
+
265
+ function chipLabel(label: string, count: number, bounded: boolean): string {
266
+ return bounded && count > 0 ? `${label} (≥${count.toLocaleString()})` : label
267
+ }
268
+
269
+ /** "Showing X of N", where N is as of the first page: rows loaded later can outnumber it when the list grew. */
270
+ export function showingText(shown: number, matching: number, lowerBound: boolean): string {
271
+ if (shown > matching) return `Showing ${shown.toLocaleString()}; the list changed while paging`
272
+ return `Showing ${shown.toLocaleString()} of ${lowerBound ? '≥' : ''}${matching.toLocaleString()}`
273
+ }
274
+
275
+ function unresolvedNotice(members: KueueQueueMembers): string | undefined {
276
+ const set = members.submissionUnresolved
277
+ if (!set) return undefined
278
+ const names = set.names ?? []
279
+ const where = set.extent === 'all'
280
+ ? 'any namespace it read Workloads in'
281
+ : names.length > 0 && names.length <= 3 && names.length === set.count ? names.join(', ') : 'some namespaces'
282
+ return `Radar couldn't read LocalQueues in ${where}, so Workloads there are listed only when their reservation names this queue.`
283
+ }
284
+
285
+ /**
286
+ * Members grouped by the state the shared classification gives them, largest
287
+ * first. Counts are Radar's, over every member it read for you; each row's
288
+ * badge carries how much attention its state needs.
289
+ */
290
+ function StateFilter({ counts, bounded, state, onStateChange }: { counts: Record<string, number>; bounded: boolean; state?: string; onStateChange: (state: string | undefined) => void }) {
291
+ const entries = Object.entries(counts ?? {}).filter(([, n]) => n > 0)
292
+ if (state && !entries.some(([code]) => code === state)) entries.push([state, 0])
293
+ if (entries.length < 2 && !state) return null
294
+ entries.sort(([a, x], [b, y]) => y - x || a.localeCompare(b))
295
+ return (
296
+ <div className="flex flex-wrap gap-1.5" role="group" aria-label="Filter by admission state">
297
+ {entries.map(([code, n]) => (
298
+ <FilterPill key={code} label={chipLabel(kueueAdmissionStateLabel(code), n, bounded)} count={bounded ? undefined : n} active={state === code} onClick={() => onStateChange(state === code ? undefined : code)} />
299
+ ))}
300
+ </div>
301
+ )
302
+ }
303
+
304
+ function StateBadge({ row }: { row: KueueQueueWorkload }) {
305
+ if (!row.admission?.state) return null
306
+ const { label, severity } = kueueAdmissionStatePresentation(row.admission)
307
+ const fact = row.admission?.blocker ?? row.admission?.inactive ?? row.admission?.disruption
308
+ return (
309
+ <span title={fact?.message || fact?.reason || undefined}>
310
+ <Badge severity={severity} size="sm">{label}</Badge>
311
+ </span>
312
+ )
313
+ }
314
+
315
+ /** How this row belongs to the queue, in words, naming both queues when they disagree. */
316
+ export function membershipText(row: KueueQueueWorkload, local: boolean, clusterQueue: string | undefined): string {
317
+ const reservedIn = row.reservation?.clusterQueue
318
+ // A finished Workload's admission record says where quota was reserved,
319
+ // not that it was admitted, nor that the quota is still held.
320
+ const finished = row.admission?.state === 'finished' || row.phase === 'finished'
321
+ const reservedWord = finished ? 'reservation recorded' : 'reserved'
322
+ if (local) {
323
+ if (!reservedIn) return 'No reservation'
324
+ const cap = finished ? 'Reservation recorded' : 'Reserved'
325
+ if (clusterQueue && reservedIn !== clusterQueue) return `${cap} in ${reservedIn} · this LocalQueue now points to ${clusterQueue}`
326
+ return `${cap} in ${reservedIn}`
327
+ }
328
+ const parts: string[] = []
329
+ const submitted = row.membership.includes('submission')
330
+ const reserved = row.membership.includes('reservation')
331
+ if (submitted) parts.push(`Submitted through ${row.queueName}`)
332
+ if (reserved) parts.push(`${reservedWord} here`)
333
+ else if (reservedIn) parts.push(`${reservedWord} in ${reservedIn}`)
334
+ else parts.push('no reservation')
335
+ if (!submitted && row.queueName) {
336
+ if (row.submission) parts.push(`its LocalQueue ${row.queueName} now points to ${row.submission.clusterQueue}`)
337
+ else if (row.localQueueUnread) parts.push(`LocalQueue ${row.queueName} not read`)
338
+ else parts.push(`LocalQueue ${row.queueName} not found`)
339
+ }
340
+ const text = parts.join(' · ')
341
+ return text.charAt(0).toUpperCase() + text.slice(1)
342
+ }
343
+
344
+ function WorkloadRow({ row, local, clusterQueue, onNavigate }: {
345
+ row: KueueQueueWorkload
346
+ local: boolean
347
+ clusterQueue?: string
348
+ onNavigate?: (ref: NavigateRef) => void
349
+ }) {
350
+ return (
351
+ <li className="py-2 first:pt-0">
352
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm [&_button]:text-left">
353
+ <span className="min-w-0 break-all font-medium">
354
+ <ResourceLink name={row.name} kind="workloads" namespace={row.namespace} group={KUEUE_GROUP} onNavigate={onNavigate} />
355
+ </span>
356
+ {!local && <span className="break-all text-xs text-theme-text-tertiary">{row.namespace}</span>}
357
+ <StateBadge row={row} />
358
+ {row.deleting && <Badge severity="neutral" size="sm">Deleting</Badge>}
359
+ </div>
360
+ <p className="mt-0.5 break-words text-xs text-theme-text-secondary">
361
+ {membershipText(row, local, clusterQueue)}
362
+ {row.createdAt && <span className="text-theme-text-tertiary"> · created {formatRelativeAgeTime(row.createdAt)}</span>}
363
+ </p>
364
+ </li>
365
+ )
366
+ }
367
+
368
+ /** Why a LocalQueue's Workloads aren't in the list, by the code of the gap that left them out. */
369
+ export function unreadWorkloadsText(lq: KueueQueueLocalQueue): string {
370
+ const where = `in ${lq.namespace}`
371
+ switch (lq.workloadsGap) {
372
+ case 'user_forbidden': return `You can't list Workloads ${where}, so its Workloads aren't listed above.`
373
+ case 'user_permission_check_failed': return `Radar hasn't confirmed you may list Workloads ${where} yet; refresh to check again.`
374
+ case 'kind_sync_pending': return `Workloads ${where} are still loading.`
375
+ case 'kind_sync_timed_out': return `Workloads ${where} haven't finished loading.`
376
+ case 'radar_forbidden':
377
+ case 'radar_credentials_rejected':
378
+ case 'radar_namespace_not_collected': return `Radar's own access doesn't cover Workloads ${where}, so its Workloads aren't listed above.`
379
+ default: return `Radar didn't read Workloads ${where}, so its Workloads aren't listed above.`
380
+ }
381
+ }
382
+
383
+ function reportedText(reported: KueueQueueLocalQueue['reported']): string {
384
+ const part = (value: number | null, label: string) => (value == null ? `${label} not reported` : `${value.toLocaleString()} ${label}`)
385
+ return [part(reported.pending, 'pending'), part(reported.reserving, 'reserving'), part(reported.admitted, 'admitted')].join(' · ')
386
+ }
387
+
388
+ function queueReported(queue: any): KueueQueueLocalQueue['reported'] {
389
+ const value = (v: unknown) => (typeof v === 'number' ? v : null)
390
+ return { pending: value(queue?.status?.pendingWorkloads), reserving: value(queue?.status?.reservingWorkloads), admitted: value(queue?.status?.admittedWorkloads) }
391
+ }
392
+
393
+ function WorkloadsUnread({ local, queue, code, message, unenumerated }: { local: boolean; queue: any; code?: string; message?: string; unenumerated?: boolean }) {
394
+ const namespace = queue?.metadata?.namespace
395
+ let headline: ReactNode = message ?? 'Radar could not read Workloads for this queue.'
396
+ if (code === 'user_forbidden') {
397
+ headline = local
398
+ ? `You can't list Workloads in ${namespace}.`
399
+ : unenumerated ? "You can't list Workloads in any namespace Radar checked; Radar couldn't list every namespace." : "You can't list Workloads in any namespace Radar shows you."
400
+ } else if (code === 'user_no_namespace_access') {
401
+ headline = "You don't have access to any namespace, so Radar can't list this queue's Workloads for you."
402
+ }
403
+ const reported = local ? queueReported(queue) : null
404
+ return (
405
+ <div className="space-y-1">
406
+ <p className="text-sm text-theme-text-secondary">{headline}</p>
407
+ {reported && Object.values(reported).some(v => v != null) && (
408
+ <p className="text-xs text-theme-text-tertiary">
409
+ The controller reports {reportedText(reported)} for this LocalQueue. That is Kueue's own count, not a list Radar can show you.
410
+ </p>
411
+ )}
412
+ </div>
413
+ )
414
+ }
415
+
416
+ function LocalQueueList({ response, onNavigate }: { response: KueueQueueWorkloadsResponse; onNavigate?: (ref: NavigateRef) => void }) {
417
+ if (response.localQueuesError) {
418
+ return (
419
+ <div className="border-t border-theme-border pt-3">
420
+ <h4 className="mb-1 text-xs font-medium uppercase tracking-wider text-theme-text-tertiary">LocalQueues pointing here</h4>
421
+ <p className="text-sm text-theme-text-secondary">
422
+ {response.localQueuesError.error_code !== 'user_forbidden'
423
+ ? response.localQueuesError.error
424
+ : response.localQueuesError.coverage?.gaps.some(gap => gap.namespaces?.extent === 'unknown')
425
+ ? "You can't list LocalQueues in any namespace Radar checked; Radar couldn't list every namespace."
426
+ : "You can't list LocalQueues in any namespace Radar shows you."}
427
+ </p>
428
+ </div>
429
+ )
430
+ }
431
+ const lqs = response.localQueues
432
+ if (!lqs) return null
433
+ const notice = resourceCoverageNotice(lqs.coverage, 'LocalQueues')
434
+ return (
435
+ <div className="border-t border-theme-border pt-3">
436
+ <h4 className="mb-1 text-xs font-medium uppercase tracking-wider text-theme-text-tertiary">
437
+ LocalQueues pointing here ({boundedCountLabel(lqs.total, isLowerBound(lqs.coverage))})
438
+ </h4>
439
+ {notice && <p className="mb-1 text-xs text-warning-text">{notice}</p>}
440
+ {lqs.items.length === 0 ? (
441
+ <p className="text-sm text-theme-text-secondary">
442
+ {lqs.coverage.status === 'complete' && !lqs.coverage.narrowedBy?.length ? 'No LocalQueue points at this ClusterQueue.' : 'None found in the namespaces Radar could read.'}
443
+ </p>
444
+ ) : (
445
+ <ul className="space-y-2">
446
+ {lqs.items.map(lq => (
447
+ <li key={`${lq.namespace}/${lq.name}`} className="text-sm [&_button]:text-left">
448
+ <span className="break-all">
449
+ <ResourceLink name={lq.name} label={`${lq.namespace}/${lq.name}`} kind="localqueues" namespace={lq.namespace} group={KUEUE_GROUP} onNavigate={onNavigate} />
450
+ </span>
451
+ <p className="text-xs text-theme-text-secondary">Controller reports {reportedText(lq.reported)}</p>
452
+ {!lq.workloadsRead && <p className="text-xs text-theme-text-tertiary">{unreadWorkloadsText(lq)}</p>}
453
+ </li>
454
+ ))}
455
+ </ul>
456
+ )}
457
+ {lqs.items.length < lqs.total && <p className="mt-1 text-xs text-theme-text-tertiary">Showing {lqs.items.length} of {pluralize(lqs.total, 'LocalQueue')}.</p>}
458
+ </div>
459
+ )
460
+ }