@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
@@ -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
+ }