@skyhook-io/k8s-ui 1.18.2 → 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 (152) hide show
  1. package/package.json +9 -2
  2. package/src/components/applications/ApplicationDetail.tsx +8 -2
  3. package/src/components/charts/AreaChart.test.tsx +39 -1
  4. package/src/components/charts/AreaChart.tsx +32 -13
  5. package/src/components/charts/axis.test.ts +17 -1
  6. package/src/components/charts/axis.ts +13 -0
  7. package/src/components/charts/format.test.ts +159 -2
  8. package/src/components/charts/format.ts +118 -0
  9. package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
  11. package/src/components/compare/ResourceCompareView.test.tsx +54 -0
  12. package/src/components/compare/ResourceCompareView.tsx +35 -12
  13. package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
  14. package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
  15. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
  16. package/src/components/gitops/GitOpsTableView.tsx +26 -7
  17. package/src/components/gitops/RollbackDialog.test.tsx +80 -0
  18. package/src/components/gitops/RollbackDialog.tsx +25 -6
  19. package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
  20. package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
  21. package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
  22. package/src/components/gitops/applicationset-row.test.ts +12 -0
  23. package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
  24. package/src/components/gitops/detail-helpers.test.ts +8 -2
  25. package/src/components/gitops/detail-helpers.ts +9 -11
  26. package/src/components/gitops/index.ts +1 -1
  27. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  28. package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
  29. package/src/components/gitops/insights/GitOpsInsightViews.tsx +94 -46
  30. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  31. package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
  32. package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
  33. package/src/components/gitops/insights/insights-helpers.test.ts +65 -0
  34. package/src/components/gitops/insights/insights-helpers.ts +17 -3
  35. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  36. package/src/components/issues/IssueGuidance.tsx +48 -0
  37. package/src/components/issues/IssuesView.tsx +15 -3
  38. package/src/components/issues/ResourceIssuesSection.tsx +3 -1
  39. package/src/components/issues/diagnostic.ts +14 -1
  40. package/src/components/issues/index.ts +1 -0
  41. package/src/components/issues/severity.ts +15 -0
  42. package/src/components/issues/types.ts +10 -0
  43. package/src/components/kueue/AdmissionPath.tsx +223 -0
  44. package/src/components/kueue/QueueEvidence.tsx +148 -0
  45. package/src/components/kueue/constants.ts +1 -0
  46. package/src/components/kueue/eligibility.ts +43 -0
  47. package/src/components/kueue/flavors-tried.ts +79 -0
  48. package/src/components/kueue/helpers.test.ts +105 -0
  49. package/src/components/kueue/policy.ts +100 -0
  50. package/src/components/kueue/queue-detail.test.tsx +317 -0
  51. package/src/components/kueue/queue-status.ts +42 -0
  52. package/src/components/kueue/related-read.ts +48 -0
  53. package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
  54. package/src/components/logs/StructuredLogLine.test.tsx +22 -0
  55. package/src/components/logs/StructuredLogLine.tsx +8 -3
  56. package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
  57. package/src/components/resources/KueueAdmissionSection.tsx +51 -12
  58. package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
  59. package/src/components/resources/KueuePendingPosition.tsx +91 -0
  60. package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
  61. package/src/components/resources/ResourcesSidebar.tsx +3 -2
  62. package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
  63. package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
  64. package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
  65. package/src/components/resources/ResourcesView.tsx +50 -3
  66. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  67. package/src/components/resources/generic-status.ts +7 -0
  68. package/src/components/resources/get-pod-phase-display.test.ts +70 -0
  69. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
  70. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
  71. package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
  72. package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
  73. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
  74. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
  75. package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
  76. package/src/components/resources/renderers/JobRenderer.tsx +7 -2
  77. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
  78. package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
  79. package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
  80. package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
  81. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
  82. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
  83. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
  84. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
  85. package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
  86. package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
  87. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
  88. package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
  89. package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
  90. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
  91. package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
  92. package/src/components/resources/renderers/crd-cells.tsx +55 -0
  93. package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
  94. package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
  95. package/src/components/resources/renderers/index.ts +6 -0
  96. package/src/components/resources/renderers/kueue-cells.tsx +5 -0
  97. package/src/components/resources/resource-utils-argo.test.ts +94 -1
  98. package/src/components/resources/resource-utils-argo.ts +84 -5
  99. package/src/components/resources/resource-utils-crd.test.ts +32 -0
  100. package/src/components/resources/resource-utils-crd.ts +26 -0
  101. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
  102. package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
  103. package/src/components/resources/resource-utils-kueue.test.ts +15 -0
  104. package/src/components/resources/resource-utils-kueue.ts +137 -4
  105. package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
  106. package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
  107. package/src/components/resources/resource-utils-ray.test.ts +42 -1
  108. package/src/components/resources/resource-utils-ray.ts +38 -0
  109. package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
  110. package/src/components/resources/resource-utils-strimzi.ts +35 -0
  111. package/src/components/resources/resource-utils.ts +45 -1
  112. package/src/components/shared/ResourceActionsBar.tsx +83 -25
  113. package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
  114. package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
  115. package/src/components/topology/K8sResourceNode.test.ts +14 -0
  116. package/src/components/topology/K8sResourceNode.tsx +18 -1
  117. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  118. package/src/components/topology/TopologyGraph.tsx +96 -94
  119. package/src/components/topology/layout.ts +3 -1
  120. package/src/components/topology/pod-group-expansion.test.ts +38 -0
  121. package/src/components/ui/Badge.tsx +1 -0
  122. package/src/components/ui/RadarUpgradeNote.tsx +8 -0
  123. package/src/components/ui/drawer-components.tsx +1 -1
  124. package/src/components/ui/index.ts +1 -0
  125. package/src/components/workload/PodList.tsx +35 -9
  126. package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
  127. package/src/components/workload/WorkloadView.tsx +61 -8
  128. package/src/components/workload/workload-logs-availability.test.ts +7 -0
  129. package/src/types/core.ts +41 -0
  130. package/src/types/gitops-insights.ts +19 -2
  131. package/src/types/gitops-tree.ts +2 -2
  132. package/src/types/kueue-queues.ts +84 -0
  133. package/src/types/read-coverage.ts +2 -1
  134. package/src/types/scheduling.ts +72 -0
  135. package/src/utils/animation.test.ts +38 -0
  136. package/src/utils/animation.ts +34 -0
  137. package/src/utils/api-resources-crd.test.ts +11 -0
  138. package/src/utils/api-resources.ts +5 -1
  139. package/src/utils/applications.test.ts +15 -1
  140. package/src/utils/applications.ts +26 -1
  141. package/src/utils/crd-schema.test.ts +107 -0
  142. package/src/utils/crd-schema.ts +201 -0
  143. package/src/utils/format.test.ts +37 -1
  144. package/src/utils/format.ts +28 -80
  145. package/src/utils/gitops-owner.test.ts +28 -2
  146. package/src/utils/gitops-owner.ts +19 -0
  147. package/src/utils/kueue-queue-workloads.ts +19 -0
  148. package/src/utils/resource-icons.ts +2 -0
  149. package/src/utils/topology-neighborhood.test.ts +90 -1
  150. package/src/utils/topology-neighborhood.ts +55 -3
  151. package/src/utils/workload-history-notes.test.ts +76 -0
  152. package/src/utils/workload-history-notes.ts +93 -0
@@ -46,32 +46,14 @@ export function formatCPUNanocores(nanocores: number): string {
46
46
 
47
47
  /**
48
48
  * Parse a Kubernetes CPU string (e.g., "500m", "2", "100n") to nanocores.
49
+ * metrics-server writes each value in its shortest exact form, so the same
50
+ * reading can arrive as "98753000n" or "98753u".
49
51
  *
50
- * @param cpuString - K8s CPU string like "500m", "2", "100n"
52
+ * @param cpuString - K8s CPU string like "500m", "2", "100n", "98753u"
51
53
  * @returns CPU in nanocores
52
54
  */
53
55
  export function parseCPUToNanocores(cpuString: string): number {
54
- if (!cpuString) return 0
55
-
56
- const str = cpuString.trim()
57
-
58
- // Nanocores suffix
59
- if (str.endsWith('n')) {
60
- return parseInt(str.slice(0, -1), 10) || 0
61
- }
62
-
63
- // Millicores suffix
64
- if (str.endsWith('m')) {
65
- return (parseInt(str.slice(0, -1), 10) || 0) * 1_000_000
66
- }
67
-
68
- // Plain number = cores
69
- const cores = parseFloat(str)
70
- if (!isNaN(cores)) {
71
- return cores * 1_000_000_000
72
- }
73
-
74
- return 0
56
+ return Math.round(parseQuantityToNumber(cpuString) * 1e9)
75
57
  }
76
58
 
77
59
  /**
@@ -117,55 +99,7 @@ export function formatMemoryBytes(bytes: number): string {
117
99
  * @returns Memory in bytes
118
100
  */
119
101
  export function parseMemoryToBytes(memString: string): number {
120
- if (!memString) return 0
121
-
122
- const str = memString.trim()
123
- // Decimal-exponent form ("1e9") is decided before the suffix table, as in
124
- // parseQuantityToNumber, so "1E3" reads as 1000 and not as exa.
125
- if (/^[+-]?\d+(?:\.\d+)?[eE][+-]?\d+$/.test(str)) return Number(str)
126
-
127
- const match = str.match(/^(-?\d+(?:\.\d+)?)\s*([A-Za-z]*)$/)
128
- if (!match) return 0
129
-
130
- const num = parseFloat(match[1])
131
- const suffix = match[2]
132
-
133
- // Binary suffixes (powers of 1024). Pi/Ei are real inputs at cluster scale —
134
- // an aggregate of a thousand 1TiB nodes serializes as "1Pi".
135
- const binarySuffixes: Record<string, number> = {
136
- 'Ki': 1024,
137
- 'Mi': 1024 ** 2,
138
- 'Gi': 1024 ** 3,
139
- 'Ti': 1024 ** 4,
140
- 'Pi': 1024 ** 5,
141
- 'Ei': 1024 ** 6,
142
- }
143
-
144
- // Decimal suffixes (powers of 1000). The sub-unit ones are real inputs too:
145
- // a request written as "1288490188800m" is valid, and a sum that includes one
146
- // keeps the milli scale, so a whole cluster's total serializes with "m".
147
- const decimalSuffixes: Record<string, number> = {
148
- 'n': 1000 ** -3,
149
- 'u': 1000 ** -2,
150
- 'm': 1000 ** -1,
151
- 'k': 1000,
152
- 'K': 1000,
153
- 'M': 1000 ** 2,
154
- 'G': 1000 ** 3,
155
- 'T': 1000 ** 4,
156
- 'P': 1000 ** 5,
157
- 'E': 1000 ** 6,
158
- }
159
-
160
- if (suffix in binarySuffixes) {
161
- return num * binarySuffixes[suffix]
162
- }
163
- if (suffix in decimalSuffixes) {
164
- return num * decimalSuffixes[suffix]
165
- }
166
-
167
- // No suffix = bytes
168
- return num
102
+ return parseQuantityToNumber(memString)
169
103
  }
170
104
 
171
105
  /**
@@ -224,32 +158,46 @@ const QUANTITY_SUFFIXES: Record<string, number> = {
224
158
  * @returns The value as a number, or 0 when it cannot be parsed
225
159
  */
226
160
  export function parseQuantityToNumber(quantity: string | number | null | undefined): number {
227
- if (typeof quantity === 'number') return Number.isFinite(quantity) ? quantity : 0
228
- if (!quantity) return 0
161
+ return parseQuantity(quantity) ?? 0
162
+ }
163
+
164
+ // A Quantity's number: optional sign, then digits with an optional fraction,
165
+ // where either side of the point may be empty ("1.", ".5") but not both.
166
+ const QUANTITY_NUMBER = String.raw`[+-]?(?:\d+(?:\.\d*)?|\.\d+)`
167
+ const QUANTITY_EXPONENT_RE = new RegExp(`^(${QUANTITY_NUMBER})[eE]([+-]?\\d+)$`)
168
+ const QUANTITY_SUFFIX_RE = new RegExp(`^(${QUANTITY_NUMBER})\\s*([A-Za-z]*)$`)
169
+
170
+ /**
171
+ * Like parseQuantityToNumber, but null when the input is missing or cannot be
172
+ * parsed, for callers that must tell garbage apart from a real 0.
173
+ */
174
+ export function parseQuantity(quantity: string | number | null | undefined): number | null {
175
+ if (typeof quantity === 'number') return Number.isFinite(quantity) ? quantity : null
176
+ if (quantity == null) return null
229
177
 
230
178
  const str = String(quantity).trim()
231
- if (!str) return 0
179
+ if (!str) return null
232
180
 
233
181
  // Decimal-exponent form. A Quantity's suffix is either an SI suffix or an
234
182
  // exponent, never both, so this is decided before the suffix table — which
235
183
  // also keeps "1E3" (1000) from being read as exa (1e18).
236
- const exponent = str.match(/^([+-]?\d+(?:\.\d+)?)[eE]([+-]?\d+)$/)
184
+ const exponent = str.match(QUANTITY_EXPONENT_RE)
237
185
  if (exponent) {
238
186
  const value = Number(`${exponent[1]}e${exponent[2]}`)
239
- return Number.isFinite(value) ? value : 0
187
+ return Number.isFinite(value) ? value : null
240
188
  }
241
189
 
242
- const match = str.match(/^([+-]?\d+(?:\.\d+)?)\s*([A-Za-z]*)$/)
243
- if (!match) return 0
190
+ const match = str.match(QUANTITY_SUFFIX_RE)
191
+ if (!match) return null
244
192
 
245
193
  const num = parseFloat(match[1])
246
- if (!Number.isFinite(num)) return 0
194
+ if (!Number.isFinite(num)) return null
247
195
 
248
196
  const suffix = match[2]
249
197
  if (!suffix) return num
250
198
 
251
199
  const multiplier = QUANTITY_SUFFIXES[suffix]
252
- return multiplier === undefined ? 0 : num * multiplier
200
+ return multiplier === undefined ? null : num * multiplier
253
201
  }
254
202
 
255
203
  // =============================================================================
@@ -1,8 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
 
3
- import type { Relationships } from '../types/core'
3
+ import type { ManagerAnswer, Relationships } from '../types/core'
4
4
 
5
- import { gitOpsOwnerFromRelationships, managerListing } from './gitops-owner'
5
+ import { argoAutoSyncController, gitOpsOwnerFromRelationships, managerListing } from './gitops-owner'
6
6
 
7
7
  describe('gitOpsOwnerFromRelationships', () => {
8
8
  it('returns null when relationships is undefined', () => {
@@ -134,3 +134,29 @@ describe('managerListing', () => {
134
134
  expect(managerListing(owner, [app('hub')], true, false)).toBeUndefined()
135
135
  })
136
136
  })
137
+
138
+ describe('argoAutoSyncController', () => {
139
+ const self = { namespace: 'argocd', name: 'guestbook' }
140
+ const parent = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'apps' }
141
+ const managed = (state: ManagerAnswer['state'], lead = parent): Relationships => ({ ownership: { manager: { state, lead } } } as unknown as Relationships)
142
+
143
+ it('names the owning ApplicationSet first', () => {
144
+ const rel: Relationships = { ...managed('listed'), owner: { kind: 'ApplicationSet', group: 'argoproj.io', namespace: 'argocd', name: 'clusters' } }
145
+ expect(argoAutoSyncController(rel, self)).toEqual({ kind: 'ApplicationSet', name: 'clusters' })
146
+ })
147
+
148
+ it.each(['agree', 'listed'] as const)('names a parent Application whose resources list it (%s)', (state) => {
149
+ expect(argoAutoSyncController(managed(state), self)).toEqual({ kind: 'Application', name: 'apps' })
150
+ })
151
+
152
+ it.each(['claimed', 'shared', 'disagree', 'unknown', 'none'] as const)('names no parent on a %s answer', (state) => {
153
+ expect(argoAutoSyncController(managed(state), self)).toBeNull()
154
+ })
155
+
156
+ it('names nothing without an answer, for a non-Argo owner, or for itself', () => {
157
+ expect(argoAutoSyncController(undefined, self)).toBeNull()
158
+ expect(argoAutoSyncController({ managedBy: [parent] }, self)).toBeNull()
159
+ expect(argoAutoSyncController({ owner: { kind: 'ApplicationSet', group: 'example.com', namespace: 'argocd', name: 'x' } }, self)).toBeNull()
160
+ expect(argoAutoSyncController(managed('listed', { ...parent, name: 'guestbook' }), self)).toBeNull()
161
+ })
162
+ })
@@ -39,6 +39,25 @@ export function gitOpsOwnerFromRelationships(
39
39
  return gitOpsOwnerFromRef(refs[0])
40
40
  }
41
41
 
42
+ /**
43
+ * The Argo CD object that rewrites an Application's spec, and so can turn its
44
+ * auto-sync back on after a suspend: the ApplicationSet that owns it, else the
45
+ * Application whose recorded resources list it. A tracking claim alone can
46
+ * outlive a detached parent, so it names nothing.
47
+ */
48
+ export function argoAutoSyncController(
49
+ rel: Relationships | undefined | null,
50
+ self: { namespace: string; name: string },
51
+ ): { kind: 'ApplicationSet' | 'Application'; name: string } | null {
52
+ const appSet = [rel?.owner, ...(rel?.owners ?? [])].find(ref => ref?.kind === 'ApplicationSet' && ref.group === ARGO_APPLICATION_GROUP)
53
+ if (appSet) return { kind: 'ApplicationSet', name: appSet.name }
54
+ const answer = rel?.ownership?.manager
55
+ const lead = answer?.state === 'agree' || answer?.state === 'listed' ? answer.lead : undefined
56
+ return lead?.kind === 'Application' && lead.group === ARGO_APPLICATION_GROUP && !(lead.namespace === self.namespace && lead.name === self.name)
57
+ ? { kind: 'Application', name: lead.name }
58
+ : null
59
+ }
60
+
42
61
  /** A manager ref (relationships.managedBy, a workspace's managedBy) as a GitOps owner, or null when the manager is not a GitOps controller. */
43
62
  export function gitOpsOwnerFromRef(ref: ResourceRef): GitOpsOwnerRef | null {
44
63
  switch (true) {
@@ -0,0 +1,19 @@
1
+ import type { KueueQueueWorkloadsResponse } from '../types/kueue-queues'
2
+ import { readActionFor } from './read-actions'
3
+
4
+ /**
5
+ * A response part a quick retry can complete: a nested read error or gap
6
+ * whose remedy is to retry (still loading, a failed read). A permission check
7
+ * that didn't run also clears on its own, but each poll spends more checks,
8
+ * and a load that is overdue (`wait`) won't clear in seconds: both wait for
9
+ * the ordinary refresh.
10
+ */
11
+ export function kueueQueueRetryable(page: KueueQueueWorkloadsResponse | undefined): boolean {
12
+ if (!page) return false
13
+ const codes = [
14
+ page.workloadsError?.error_code, page.localQueuesError?.error_code,
15
+ ...(page.workloads?.coverage.gaps ?? []).map(gap => gap.code),
16
+ ...(page.localQueues?.coverage.gaps ?? []).map(gap => gap.code),
17
+ ]
18
+ return codes.some(code => code !== 'user_permission_check_failed' && readActionFor(code) === 'retry')
19
+ }
@@ -52,6 +52,7 @@ import {
52
52
  // GitOps
53
53
  GitBranch,
54
54
  Layers,
55
+ ListVideo,
55
56
  Anchor,
56
57
  FolderGit2,
57
58
 
@@ -91,6 +92,7 @@ const KIND_ICON_MAP: Record<string, LucideIcon> = {
91
92
  replicaset: Copy,
92
93
  job: Play,
93
94
  cronjob: Timer,
95
+ jobset: ListVideo,
94
96
  rollout: Rocket,
95
97
 
96
98
  // Networking
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { batchRunParentNodes, neighborhoodFor, tagWorkloadOwnership, workloadKey } from './topology-neighborhood'
2
+ import { batchRunParentNodes, jobSetMemberRunKey, neighborhoodFor, tagWorkloadOwnership, workloadKey } from './topology-neighborhood'
3
3
  import type { Topology, NodeKind, EdgeType } from '../types/core'
4
4
 
5
5
  function node(id: string, kind: string, ns: string, name: string): Topology['nodes'][number] {
@@ -455,3 +455,92 @@ describe('tagWorkloadOwnership', () => {
455
455
  expect(dataOf(topology, 'cert').ownerWorkloadId).toBe('Deployment/app/web')
456
456
  })
457
457
  })
458
+
459
+ describe('JobSet ownership in the neighborhood', () => {
460
+ const jobSetID = 'jobset/ml/train/jobset.x-k8s.io'
461
+ const topo: Topology = {
462
+ nodes: [
463
+ crdNode(jobSetID, 'JobSet', 'ml', 'train', 'jobset.x-k8s.io/v1alpha2'),
464
+ crdNode('job/ml/train-workers-0', 'Job', 'ml', 'train-workers-0', 'batch/v1'),
465
+ crdNode('job/ml/train-workers-1', 'Job', 'ml', 'train-workers-1', 'batch/v1'),
466
+ node('pod/ml/train-workers-0-a', 'Pod', 'ml', 'train-workers-0-a'),
467
+ node('pod/ml/train-workers-1-a', 'Pod', 'ml', 'train-workers-1-a'),
468
+ crdNode('job/ml/standalone', 'Job', 'ml', 'standalone', 'batch/v1'),
469
+ crdNode('job/ml/train-workers-0/batch.volcano.sh', 'Job', 'ml', 'train-workers-0', 'batch.volcano.sh/v1alpha1'),
470
+ ],
471
+ edges: [
472
+ edge(jobSetID, 'job/ml/train-workers-0', 'manages'),
473
+ edge(jobSetID, 'job/ml/train-workers-1', 'manages'),
474
+ edge('job/ml/train-workers-0', 'pod/ml/train-workers-0-a', 'manages'),
475
+ edge('job/ml/train-workers-1', 'pod/ml/train-workers-1-a', 'manages'),
476
+ { ...edge(jobSetID, 'pod/ml/train-workers-0-a', 'manages'), id: 'shortcut-0', skipIfKindVisible: 'Job' },
477
+ { ...edge(jobSetID, 'job/ml/train-workers-0/batch.volcano.sh', 'manages'), id: 'foreign' },
478
+ ],
479
+ }
480
+
481
+ it('walks a JobSet down to every member Job and Pod, not capped as retained runs', () => {
482
+ const out = neighborhoodFor(topo, [{ kind: 'JobSet', group: 'jobset.x-k8s.io', namespace: 'ml', name: 'train' }])
483
+ const ids = new Set(out.nodes.map((n) => n.id))
484
+ for (const id of ['job/ml/train-workers-0', 'job/ml/train-workers-1', 'pod/ml/train-workers-0-a', 'pod/ml/train-workers-1-a']) {
485
+ expect(ids.has(id)).toBe(true)
486
+ }
487
+ expect(ids.has('job/ml/standalone')).toBe(false)
488
+ // Jobs are present, so the collapsed shortcut is dropped.
489
+ expect(out.edges.some((e) => e.id === 'shortcut-0')).toBe(false)
490
+ expect(out.warnings ?? []).toEqual([])
491
+ })
492
+
493
+ it('shows the JobSet as context from a member Job without pulling in sibling members', () => {
494
+ const out = neighborhoodFor(topo, [{ kind: 'Job', group: 'batch', namespace: 'ml', name: 'train-workers-1' }])
495
+ const ids = new Set(out.nodes.map((n) => n.id))
496
+ expect(ids.has(jobSetID)).toBe(true)
497
+ expect(ids.has('job/ml/train-workers-0')).toBe(false)
498
+ })
499
+
500
+ it('keys only directly controlled batch/v1 member Jobs for in-place selection', () => {
501
+ const byId = new Map(topo.nodes.map((n) => [n.id, n]))
502
+ expect(jobSetMemberRunKey(topo, jobSetID, byId.get('job/ml/train-workers-0')!)).toBe('jobs/ml/train-workers-0')
503
+ expect(batchRunParentNodes(topo, byId.get('job/ml/train-workers-0')!)).toEqual([byId.get(jobSetID)])
504
+ expect(batchRunParentNodes(topo, byId.get('job/ml/train-workers-0/batch.volcano.sh')!)).toEqual([])
505
+ expect(jobSetMemberRunKey(topo, jobSetID, byId.get('job/ml/standalone')!)).toBeUndefined()
506
+ expect(jobSetMemberRunKey(topo, jobSetID, byId.get('job/ml/train-workers-0/batch.volcano.sh')!)).toBeUndefined()
507
+ expect(jobSetMemberRunKey(topo, jobSetID, byId.get('pod/ml/train-workers-0-a')!)).toBeUndefined()
508
+ // Only a JobSet of the exact group selects members.
509
+ expect(jobSetMemberRunKey(topo, 'job/ml/train-workers-0', byId.get('pod/ml/train-workers-0-a')!)).toBeUndefined()
510
+ })
511
+ })
512
+
513
+ describe('JobSet member cap', () => {
514
+ it('keeps failed and active members first and says how many it left out', () => {
515
+ const jobSetID = 'jobset/ml/big/jobset.x-k8s.io'
516
+ const nodes: Topology['nodes'] = [crdNode(jobSetID, 'JobSet', 'ml', 'big', 'jobset.x-k8s.io/v1alpha2')]
517
+ const edges: Topology['edges'] = []
518
+ for (let i = 0; i < 40; i++) {
519
+ const id = `job/ml/big-w-${String(i).padStart(2, '0')}`
520
+ const job = { ...crdNode(id, 'Job', 'ml', `big-w-${String(i).padStart(2, '0')}`, 'batch/v1'), status: 'neutral' as const }
521
+ if (i === 39) job.status = 'unhealthy' as never
522
+ if (i === 38) job.data = { ...job.data, active: 1 }
523
+ if (i !== 38) job.data = { ...job.data, completionTime: '2026-11-01T00:00:00Z', succeeded: 1 }
524
+ nodes.push(job)
525
+ edges.push(edge(jobSetID, id, 'manages'))
526
+ const pod = `pod/ml/big-w-${i}-a`
527
+ nodes.push(node(pod, 'Pod', 'ml', `big-w-${i}-a`))
528
+ edges.push(edge(id, pod, 'manages'))
529
+ edges.push({ ...edge(jobSetID, pod, 'manages'), id: `shortcut-${i}`, skipIfKindVisible: 'Job' })
530
+ }
531
+ nodes.push(crdNode('workload/ml/big', 'Workload', 'ml', 'big', 'kueue.x-k8s.io/v1beta2'))
532
+ edges.push(edge(jobSetID, 'workload/ml/big', 'manages'))
533
+
534
+ const out = neighborhoodFor({ nodes, edges }, [{ kind: 'JobSet', group: 'jobset.x-k8s.io', namespace: 'ml', name: 'big' }])
535
+ const ids = new Set(out.nodes.map((n) => n.id))
536
+ const jobs = out.nodes.filter((n) => n.kind === 'Job')
537
+ expect(jobs).toHaveLength(24)
538
+ expect(ids.has('job/ml/big-w-39')).toBe(true) // failed first
539
+ expect(ids.has('job/ml/big-w-38')).toBe(true) // active next
540
+ // Pods only of the shown members: shortcuts don't bring back the rest.
541
+ expect(out.nodes.filter((n) => n.kind === 'Pod')).toHaveLength(24)
542
+ // Other things the JobSet owns aren't capped as members.
543
+ expect(ids.has('workload/ml/big')).toBe(true)
544
+ expect(out.warnings).toEqual(['Topology view: showing 24 of 40 member Jobs of JobSet/big, failed and active first. Find any member in the Overview\'s member list.'])
545
+ })
546
+ })
@@ -40,6 +40,9 @@ export interface NeighborhoodSeed {
40
40
  const IDENTITY_EDGES = new Set<EdgeType>(['manages'])
41
41
  const ROUTING_EDGES = new Set<EdgeType>(['exposes', 'routes-to'])
42
42
  const BATCH_RUN_FANOUT_LIMIT = 8
43
+ // A JobSet's member Jobs run side by side, so they aren't thinned like
44
+ // retained runs; past this many, the failed and active ones are kept first.
45
+ const JOBSET_MEMBER_LIMIT = 24
43
46
  // context: 'configures' | 'uses' | 'protects' — everything else is leaf-attached.
44
47
 
45
48
  // GitOps managers: included as context ("managed by"), never expanded through.
@@ -92,9 +95,36 @@ function isBatchRunFanoutEdge(edge: TopologyEdge, nodeById: Map<string, Topology
92
95
  return isTemplateToRunEdge(edge, nodeById)
93
96
  }
94
97
 
98
+ /** The member key (`jobs/<ns>/<name>`, the JobSet member list's selection key)
99
+ * when `node` is a batch/v1 Job the JobSet node `jobSetNodeId` controls, by
100
+ * its direct manages edge — the server draws that edge only for exact
101
+ * controller-owned members. Undefined for anything else. */
102
+ export function jobSetMemberRunKey(topology: Topology, jobSetNodeId: string, node: TopologyNode): string | undefined {
103
+ const root = topology.nodes.find((candidate) => candidate.id === jobSetNodeId)
104
+ if (!root || root.kind !== 'JobSet' || nodeGroup(root) !== 'jobset.x-k8s.io') return undefined
105
+ if (node.kind !== 'Job' || canonicalResourceGroup('Job', nodeGroup(node)) !== 'batch') return undefined
106
+ const member = topology.edges.some((edge) =>
107
+ edge.type === 'manages' && edge.source === root.id && edge.target === node.id && !edge.skipIfKindVisible,
108
+ )
109
+ return member ? `jobs/${nodeNamespace(node)}/${node.name}` : undefined
110
+ }
111
+
112
+ function isJobSetMemberEdge(edge: TopologyEdge, nodeById: Map<string, TopologyNode>): boolean {
113
+ const source = nodeById.get(edge.source)
114
+ const target = nodeById.get(edge.target)
115
+ return edge.type === 'manages' && !edge.skipIfKindVisible
116
+ && source?.kind === 'JobSet' && !!source && nodeGroup(source) === 'jobset.x-k8s.io'
117
+ && target?.kind === 'Job' && canonicalResourceGroup('Job', nodeGroup(target)) === 'batch'
118
+ }
119
+
120
+ function memberRank(node: TopologyNode): number {
121
+ if (node.status === 'unhealthy') return 0
122
+ return runDisposition(node) === 'active' ? 1 : 2
123
+ }
124
+
95
125
  export function batchRunParentNodes(topology: Topology, run: TopologyNode): TopologyNode[] {
96
126
  const nodeById = new Map(topology.nodes.map((node) => [node.id, node]))
97
- const parentEdges = topology.edges.filter((edge) => edge.target === run.id && isBatchRunFanoutEdge(edge, nodeById))
127
+ const parentEdges = topology.edges.filter((edge) => edge.target === run.id && (isBatchRunFanoutEdge(edge, nodeById) || isJobSetMemberEdge(edge, nodeById)))
98
128
  parentEdges.sort((left, right) => Number(right.type === 'manages') - Number(left.type === 'manages'))
99
129
  return parentEdges.flatMap((edge) => {
100
130
  const parent = nodeById.get(edge.source)
@@ -207,8 +237,24 @@ export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]):
207
237
  }
208
238
  }
209
239
 
210
- const limitedFanouts = new Map<string, { allowed: Set<string>; total: number; kind: string; activeTotal: number; activeShown: number }>()
240
+ const limitedFanouts = new Map<string, { allowed: Set<string>; total: number; kind: string; activeTotal: number; activeShown: number; members?: boolean }>()
211
241
  for (const [sourceId, edges] of adjacency) {
242
+ const memberEdges = edges.filter((edge) => edge.source === sourceId && isJobSetMemberEdge(edge, nodeById))
243
+ if (memberEdges.length > JOBSET_MEMBER_LIMIT) {
244
+ const members = memberEdges
245
+ .map((edge) => nodeById.get(edge.target))
246
+ .filter((node): node is TopologyNode => !!node)
247
+ .sort((a, b) => memberRank(a) - memberRank(b) || a.name.localeCompare(b.name))
248
+ limitedFanouts.set(sourceId, {
249
+ allowed: new Set(members.slice(0, JOBSET_MEMBER_LIMIT).map((node) => node.id)),
250
+ total: members.length,
251
+ kind: 'Job',
252
+ activeTotal: 0,
253
+ activeShown: 0,
254
+ members: true,
255
+ })
256
+ continue
257
+ }
212
258
  const runEdges = edges.filter((edge) => edge.source === sourceId && batchFanoutLimit(edge, nodeById) !== null)
213
259
  if (runEdges.length <= BATCH_RUN_FANOUT_LIMIT) continue
214
260
  const targets = runEdges
@@ -240,7 +286,10 @@ export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]):
240
286
  const nextNode = nodeById.get(nextId)
241
287
  if (!nextNode) continue
242
288
  const fanout = e.source === id ? limitedFanouts.get(id) : undefined
243
- if (fanout && !fanout.allowed.has(nextId)) {
289
+ // A capped JobSet's shortcuts to Pods would bring back the Pods of the
290
+ // members left out; the shown members still reach theirs.
291
+ if (fanout?.members && e.skipIfKindVisible) continue
292
+ if (fanout && (!fanout.members || isJobSetMemberEdge(e, nodeById)) && !fanout.allowed.has(nextId)) {
244
293
  cappedSources.add(id)
245
294
  continue
246
295
  }
@@ -308,6 +357,9 @@ export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]):
308
357
  ...Array.from(cappedSources).map((sourceId) => {
309
358
  const source = nodeById.get(sourceId)
310
359
  const fanout = limitedFanouts.get(sourceId)
360
+ if (fanout?.members) {
361
+ return `Topology view: showing ${JOBSET_MEMBER_LIMIT} of ${fanout.total} member Jobs of ${source?.kind ?? 'JobSet'}/${source?.name ?? sourceId}, failed and active first. Find any member in the Overview's member list.`
362
+ }
311
363
  const activeNote = fanout && fanout.activeTotal > fanout.activeShown ? ` showing the ${fanout.activeShown - 1} newest and oldest of ${fanout.activeTotal} active runs plus representative completed runs` : ' showing active and representative completed runs'
312
364
  return `Topology view:${activeNote} from ${fanout?.total ?? BATCH_RUN_FANOUT_LIMIT} retained ${fanout?.kind ?? 'batch'} runs for ${source?.kind ?? 'workload'}/${source?.name ?? sourceId}. Use Run history for the full retained set.`
313
365
  }),
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { workloadHistoryNotes } from './workload-history-notes'
3
+
4
+ describe('workloadHistoryNotes', () => {
5
+ it('says nothing when the history is complete and nothing is withheld', () => {
6
+ expect(workloadHistoryNotes(undefined, undefined)).toEqual([])
7
+ expect(workloadHistoryNotes({ incomplete: false, reasons: [] }, [])).toEqual([])
8
+ })
9
+
10
+ it('dates the start of complete history when the viewer may see the time', () => {
11
+ const since = '2026-11-03T05:00:00Z'
12
+ const [note] = workloadHistoryNotes({ incomplete: true, complete_since: since, reasons: ['observation_started_late'] })
13
+ expect(note).toBe(`Changes before ${new Date(since).toLocaleString()} may be missing: Radar began recording part of this history later.`)
14
+ })
15
+
16
+ it('names both reasons, and no time when the server withheld it', () => {
17
+ expect(workloadHistoryNotes({ incomplete: true, reasons: ['observation_started_late', 'events_evicted'] })).toEqual([
18
+ 'Earlier changes may be missing: Radar began recording part of this history later and older history was deleted to stay within its limits.',
19
+ ])
20
+ expect(workloadHistoryNotes({ incomplete: true, reasons: ['something_new'] })).toEqual(['Earlier changes may be missing.'])
21
+ })
22
+
23
+ it('names the Kueue kinds a viewer may not list', () => {
24
+ expect(
25
+ workloadHistoryNotes(null, [
26
+ {
27
+ code: 'user_forbidden',
28
+ kinds: { count: 2, names: [{ kind: 'Workload', group: 'kueue.x-k8s.io' }, { kind: 'ProvisioningRequest', group: 'autoscaling.x-k8s.io' }] },
29
+ namespaces: { count: 1, names: ['ml'] },
30
+ },
31
+ ]),
32
+ ).toEqual(["Changes to Kueue Workloads and ProvisioningRequests aren't shown: you don't have access to list them in this namespace."])
33
+ })
34
+
35
+ it('separates what Radar does not collect from what is still loading', () => {
36
+ expect(
37
+ workloadHistoryNotes(null, [
38
+ { code: 'radar_forbidden', kinds: { count: 1, names: [{ kind: 'Workload', group: 'kueue.x-k8s.io' }] } },
39
+ { code: 'kind_sync_pending', kinds: { count: 1, names: [{ kind: 'ProvisioningRequest', group: 'autoscaling.x-k8s.io' }] } },
40
+ { code: 'some_future_code' },
41
+ ]),
42
+ ).toEqual([
43
+ "Changes to Kueue Workloads aren't shown: Radar's Kubernetes identity may not list them.",
44
+ "Changes to ProvisioningRequests aren't shown: still loading.",
45
+ "Some related changes aren't shown: the read failed.",
46
+ ])
47
+ })
48
+ })
49
+
50
+
51
+ describe('history gap remedies', () => {
52
+ it('preserves known Radar access and collection reasons separately', () => {
53
+ expect(workloadHistoryNotes(null, [
54
+ { code: 'radar_credentials_rejected', action: 'radar_config' },
55
+ { code: 'radar_namespace_not_collected', action: 'radar_config' },
56
+ { code: 'cluster_disconnected', action: 'radar_config' },
57
+ ])).toEqual([
58
+ "Some related changes aren't shown: Kubernetes rejected Radar's credentials.",
59
+ "Some related changes aren't shown: Radar doesn't collect them here.",
60
+ "Some related changes aren't shown: Radar lost its connection.",
61
+ ])
62
+ })
63
+
64
+ it.each([
65
+ ['user_access', "you don't have access to list them in this namespace"],
66
+ ['radar_config', "Radar's connection or collection settings need attention"],
67
+ ['retry', 'retry the read'],
68
+ ['wait', 'wait and retry the read'],
69
+ ['check_target', 'check the requested target and retry'],
70
+ ['change_request', 'change the read request'],
71
+ ])('uses the supplied %s action for an unknown code', (action, clause) => {
72
+ expect(workloadHistoryNotes(null, [{ code: 'new_server_code', action }])).toEqual([
73
+ `Some related changes aren't shown: ${clause}.`,
74
+ ])
75
+ })
76
+ })
@@ -0,0 +1,93 @@
1
+ import type { ReadGap, ReadKindRef } from '../types/read-coverage'
2
+ import { canonicalReadCode, gapReasonText, READ_ACTIONS, readActionFor } from './read-actions'
3
+
4
+ /**
5
+ * Whether Radar holds every change since a workload existed, as Radar's
6
+ * issuesapi.HistoryCoverage serializes it. Present only when incomplete;
7
+ * complete_since is sent only to callers who can read every namespace.
8
+ * An absent coverage value means complete history or an older Radar; it
9
+ * does not establish completeness on its own.
10
+ */
11
+ export interface WorkloadHistoryCoverage {
12
+ incomplete: boolean
13
+ complete_since?: string
14
+ reasons: string[]
15
+ }
16
+
17
+ // Kinds a history gap names, as a reader knows them.
18
+ const KIND_LABELS: Record<string, string> = {
19
+ 'kueue.x-k8s.io/Workload': 'Kueue Workloads',
20
+ 'autoscaling.x-k8s.io/ProvisioningRequest': 'ProvisioningRequests',
21
+ }
22
+
23
+ function kindLabel(ref: ReadKindRef): string {
24
+ return KIND_LABELS[`${ref.group ?? ''}/${ref.kind}`] ?? `${ref.kind} resources`
25
+ }
26
+
27
+ function joinWords(words: string[]): string {
28
+ if (words.length <= 1) return words[0] ?? ''
29
+ return `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`
30
+ }
31
+
32
+ function gapClause(gap: ReadGap): string {
33
+ const code = canonicalReadCode(gap.code)
34
+ switch (code) {
35
+ case 'radar_forbidden':
36
+ return "Radar's Kubernetes identity may not list them"
37
+ case 'radar_credentials_rejected':
38
+ return "Kubernetes rejected Radar's credentials"
39
+ case 'radar_kind_not_collected':
40
+ case 'radar_namespace_not_collected':
41
+ return "Radar doesn't collect them here"
42
+ }
43
+ if (typeof code === 'string' && Object.hasOwn(READ_ACTIONS, code)) {
44
+ return readActionFor(code) === 'user_access'
45
+ ? "you don't have access to list them in this namespace"
46
+ : gapReasonText(code)
47
+ }
48
+ switch (gap.action) {
49
+ case 'user_access':
50
+ return "you don't have access to list them in this namespace"
51
+ case 'radar_config':
52
+ return "Radar's connection or collection settings need attention"
53
+ case 'retry':
54
+ return 'retry the read'
55
+ case 'wait':
56
+ return 'wait and retry the read'
57
+ case 'check_target':
58
+ return 'check the requested target and retry'
59
+ case 'change_request':
60
+ return 'change the read request'
61
+ default:
62
+ return gapReasonText(gap.code)
63
+ }
64
+ }
65
+
66
+ function formatSince(iso: string): string {
67
+ const date = new Date(iso)
68
+ return Number.isNaN(date.getTime()) ? iso : date.toLocaleString()
69
+ }
70
+
71
+ /**
72
+ * Sentences for a workload Timeline's note line: since when Radar's history
73
+ * is complete, and which related kinds it can't include for this viewer.
74
+ * Unknown reasons and codes from a newer Radar still produce a sentence.
75
+ */
76
+ export function workloadHistoryNotes(coverage?: WorkloadHistoryCoverage | null, gaps?: ReadGap[] | null): string[] {
77
+ const notes: string[] = []
78
+ if (coverage?.incomplete) {
79
+ const why: string[] = []
80
+ if (coverage.reasons.includes('observation_started_late')) why.push('Radar began recording part of this history later')
81
+ if (coverage.reasons.includes('events_evicted')) why.push('older history was deleted to stay within its limits')
82
+ const head = coverage.complete_since
83
+ ? `Changes before ${formatSince(coverage.complete_since)} may be missing`
84
+ : 'Earlier changes may be missing'
85
+ notes.push(why.length > 0 ? `${head}: ${joinWords(why)}.` : `${head}.`)
86
+ }
87
+ for (const gap of gaps ?? []) {
88
+ const kinds = gap.kinds?.names?.map(kindLabel) ?? []
89
+ const what = kinds.length > 0 ? `Changes to ${joinWords(kinds)}` : 'Some related changes'
90
+ notes.push(`${what} aren't shown: ${gapClause(gap)}.`)
91
+ }
92
+ return notes
93
+ }