@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
@@ -307,6 +307,101 @@ export function phaseOnlyHealth(phase: string | undefined): HealthStatus {
307
307
  }
308
308
  }
309
309
 
310
+ /** Replaces a PodGroup with its pods. A pod connects to its own owners
311
+ * (`ownerIds`) or, when the server sent none, to every owner of the group;
312
+ * an empty `ownerIds` connects it to none. */
313
+ export function expandPodGroup(
314
+ topoNodes: TopologyNode[],
315
+ topoEdges: TopologyEdge[],
316
+ podGroupId: string,
317
+ ): { nodes: TopologyNode[]; edges: TopologyEdge[] } {
318
+ const podGroupNode = topoNodes.find(n => n.id === podGroupId && n.kind === 'PodGroup')
319
+ if (!podGroupNode || !podGroupNode.data.pods) {
320
+ return { nodes: topoNodes, edges: topoEdges }
321
+ }
322
+
323
+ const pods = podGroupNode.data.pods as Array<{
324
+ name: string
325
+ namespace: string
326
+ phase: string
327
+ restarts: number
328
+ containers: number
329
+ status?: HealthStatus
330
+ terminating?: boolean
331
+ // Present only when the group spans more than one owner (e.g. a
332
+ // Rollout's canary + stable ReplicaSets) — the specific edge
333
+ // source(s) that actually own this pod, from the backend's own
334
+ // per-pod owner resolution. See pkg/topology/builder.go's
335
+ // ownerKeyToSourceIDs.
336
+ ownerIds?: string[]
337
+ // The group's own trafficRole (podGroupNode.data.trafficRole) is only
338
+ // set when every pod agrees — this is each pod's OWN role, so a mixed
339
+ // canary/stable group still badges correctly once expanded.
340
+ trafficRole?: string
341
+ }>
342
+
343
+ const edgesToGroup = topoEdges.filter(e => e.target === podGroupId)
344
+ const sourceIds = edgesToGroup.map(e => e.source)
345
+ // Homogeneous per build — resources-view feeds `manages` ownership
346
+ // edges, traffic-view feeds `routes-to` Service edges — so any surviving
347
+ // edge's type applies to the whole group.
348
+ const edgeType = edgesToGroup[0]?.type ?? 'routes-to'
349
+
350
+ const newNodes = topoNodes.filter(n => n.id !== podGroupId)
351
+ const newEdges = topoEdges.filter(e => e.target !== podGroupId)
352
+
353
+ for (const pod of pods) {
354
+ const podId = `pod/${pod.namespace}/${pod.name}`
355
+ newNodes.push({
356
+ id: podId,
357
+ kind: 'Pod',
358
+ name: pod.name,
359
+ // The server computes pod health; pkg/health tracks a crash loop across
360
+ // the kubelet's Waiting->Running oscillation, which the phase alone
361
+ // hides — a crash-looping pod sits at Phase=Running. Deriving it here
362
+ // again would rebuild that logic in a second place and get it wrong.
363
+ status: pod.status ?? phaseOnlyHealth(pod.phase),
364
+ data: {
365
+ ...podGroupNode.data,
366
+ namespace: pod.namespace,
367
+ phase: pod.phase,
368
+ restarts: pod.restarts,
369
+ containers: pod.containers,
370
+ trafficRole: pod.trafficRole,
371
+ ...(podGroupNode.data.earlierJobRun ? { terminating: pod.terminating ? 1 : 0 } : {}),
372
+ expandedFromGroup: podGroupId, // Track which group this came from
373
+ },
374
+ })
375
+
376
+ // A group with a single owner has every source apply to every pod —
377
+ // the common case. A mixed-owner group (pod.ownerIds present) instead
378
+ // connects each pod only to the source(s) that are actually its own
379
+ // owner, so e.g. a canary pod doesn't end up drawn as owned by the
380
+ // stable ReplicaSet too.
381
+ const podSourceIds = pod.ownerIds?.filter(id => sourceIds.includes(id)) ?? sourceIds
382
+ // A mixed-owner group's shared sources (e.g. a Rollout, reached via
383
+ // both a canary and a stable edge) can't be told apart by source id
384
+ // alone once collapsed — both edges point at the same node id, just
385
+ // with different labels. The pod's own trafficRole is unambiguous, so
386
+ // an ownership edge derives its label from that directly rather than
387
+ // trying to match back to one specific original edge.
388
+ const label = podGroupNode.data.earlierJobRun ? edgesToGroup[0]?.label : edgeType === 'manages' && pod.trafficRole
389
+ ? pod.trafficRole[0].toUpperCase() + pod.trafficRole.slice(1)
390
+ : undefined
391
+ for (const sourceId of podSourceIds) {
392
+ newEdges.push({
393
+ id: `${sourceId}-to-${podId}`,
394
+ source: sourceId,
395
+ target: podId,
396
+ type: edgeType,
397
+ ...(label ? { label } : {}),
398
+ })
399
+ }
400
+ }
401
+
402
+ return { nodes: newNodes, edges: newEdges }
403
+ }
404
+
310
405
  export function TopologyGraph({
311
406
  topology,
312
407
  viewMode,
@@ -455,99 +550,6 @@ export function TopologyGraph({
455
550
  fitAllAfterLayoutRef.current = true
456
551
  }, [])
457
552
 
458
- // Expand PodGroup to individual pods
459
- const expandPodGroup = useCallback((
460
- topoNodes: TopologyNode[],
461
- topoEdges: TopologyEdge[],
462
- podGroupId: string
463
- ): { nodes: TopologyNode[]; edges: TopologyEdge[] } => {
464
- const podGroupNode = topoNodes.find(n => n.id === podGroupId && n.kind === 'PodGroup')
465
- if (!podGroupNode || !podGroupNode.data.pods) {
466
- return { nodes: topoNodes, edges: topoEdges }
467
- }
468
-
469
- const pods = podGroupNode.data.pods as Array<{
470
- name: string
471
- namespace: string
472
- phase: string
473
- restarts: number
474
- containers: number
475
- status?: HealthStatus
476
- // Present only when the group spans more than one owner (e.g. a
477
- // Rollout's canary + stable ReplicaSets) — the specific edge
478
- // source(s) that actually own this pod, from the backend's own
479
- // per-pod owner resolution. See pkg/topology/builder.go's
480
- // ownerKeyToSourceIDs.
481
- ownerIds?: string[]
482
- // The group's own trafficRole (podGroupNode.data.trafficRole) is only
483
- // set when every pod agrees — this is each pod's OWN role, so a mixed
484
- // canary/stable group still badges correctly once expanded.
485
- trafficRole?: string
486
- }>
487
-
488
- // Find edges pointing to this pod group
489
- const edgesToGroup = topoEdges.filter(e => e.target === podGroupId)
490
- const sourceIds = edgesToGroup.map(e => e.source)
491
- // Homogeneous per build — resources-view feeds `manages` ownership
492
- // edges, traffic-view feeds `routes-to` Service edges — so any surviving
493
- // edge's type applies to the whole group.
494
- const edgeType = edgesToGroup[0]?.type ?? 'routes-to'
495
-
496
- // Remove the PodGroup node and its edges
497
- const newNodes = topoNodes.filter(n => n.id !== podGroupId)
498
- const newEdges = topoEdges.filter(e => e.target !== podGroupId)
499
-
500
- // Add individual pod nodes
501
- for (const pod of pods) {
502
- const podId = `pod/${pod.namespace}/${pod.name}`
503
- newNodes.push({
504
- id: podId,
505
- kind: 'Pod',
506
- name: pod.name,
507
- // The server computes pod health; pkg/health tracks a crash loop across
508
- // the kubelet's Waiting->Running oscillation, which the phase alone
509
- // hides — a crash-looping pod sits at Phase=Running. Deriving it here
510
- // again would rebuild that logic in a second place and get it wrong.
511
- status: pod.status ?? phaseOnlyHealth(pod.phase),
512
- data: {
513
- ...podGroupNode.data,
514
- namespace: pod.namespace,
515
- phase: pod.phase,
516
- restarts: pod.restarts,
517
- containers: pod.containers,
518
- trafficRole: pod.trafficRole,
519
- expandedFromGroup: podGroupId, // Track which group this came from
520
- },
521
- })
522
-
523
- // A group with a single owner has every source apply to every pod —
524
- // the common case. A mixed-owner group (pod.ownerIds present) instead
525
- // connects each pod only to the source(s) that are actually its own
526
- // owner, so e.g. a canary pod doesn't end up drawn as owned by the
527
- // stable ReplicaSet too.
528
- const podSourceIds = pod.ownerIds?.filter(id => sourceIds.includes(id)) ?? sourceIds
529
- // A mixed-owner group's shared sources (e.g. a Rollout, reached via
530
- // both a canary and a stable edge) can't be told apart by source id
531
- // alone once collapsed — both edges point at the same node id, just
532
- // with different labels. The pod's own trafficRole is unambiguous, so
533
- // an ownership edge derives its label from that directly rather than
534
- // trying to match back to one specific original edge.
535
- const label = edgeType === 'manages' && pod.trafficRole
536
- ? pod.trafficRole[0].toUpperCase() + pod.trafficRole.slice(1)
537
- : undefined
538
- for (const sourceId of podSourceIds) {
539
- newEdges.push({
540
- id: `${sourceId}-to-${podId}`,
541
- source: sourceId,
542
- target: podId,
543
- type: edgeType,
544
- ...(label ? { label } : {}),
545
- })
546
- }
547
- }
548
-
549
- return { nodes: newNodes, edges: newEdges }
550
- }, [])
551
553
 
552
554
 
553
555
  // Prepare topology data with expanded pod groups
@@ -574,7 +576,7 @@ export function TopologyGraph({
574
576
  }
575
577
 
576
578
  return { workingNodes: nodes, workingEdges: edges }
577
- }, [topology, expandedPodGroups, expandPodGroup, isTrafficView, groupingMode])
579
+ }, [topology, expandedPodGroups, isTrafficView, groupingMode])
578
580
 
579
581
  // Handle card click in card-grid view — find the topology node and open drawer
580
582
  const handleCardClick = useCallback((nodeId: string) => {
@@ -829,6 +829,8 @@ function computeWorkloadCards(
829
829
  subtitle = (d.schedule as string) || ''
830
830
  } else if (primary.kind === 'CronWorkflow') {
831
831
  subtitle = (d.schedule as string) || ''
832
+ } else if (primary.kind === 'JobSet') {
833
+ subtitle = (d.phase as string) || ''
832
834
  } else if (primary.kind === 'Job') {
833
835
  subtitle = (d.phase as string) || ''
834
836
  } else if (primary.kind === 'Workflow') {
@@ -863,7 +865,7 @@ function computeWorkloadCards(
863
865
  // Shared kind priority map — used by both pickGroupName and computeWorkloadCards
864
866
  const KIND_PRIORITY: Record<string, number> = {
865
867
  'Deployment': 1, 'Rollout': 1, 'StatefulSet': 2, 'DaemonSet': 3,
866
- 'CronJob': 4, 'CronWorkflow': 4, 'Job': 5, 'Workflow': 5, 'Service': 6, 'Gateway': 7,
868
+ 'CronJob': 4, 'CronWorkflow': 4, 'JobSet': 4, 'Job': 5, 'Workflow': 5, 'Service': 6, 'Gateway': 7,
867
869
  'HTTPRoute': 6, 'GRPCRoute': 6, 'TCPRoute': 6, 'TLSRoute': 6, 'UDPRoute': 6, 'Ingress': 7,
868
870
  'CalicoNetworkPolicy': 10, 'CalicoGlobalNetworkPolicy': 10,
869
871
  'CalicoStagedNetworkPolicy': 10, 'CalicoStagedGlobalNetworkPolicy': 10,
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { expandPodGroup } from './TopologyGraph'
3
+ import type { TopologyEdge, TopologyNode } from '../../types'
4
+
5
+ function group(pods: Record<string, unknown>[]): TopologyNode {
6
+ return { id: 'podgroup/ml/train-workers-0', kind: 'PodGroup', name: 'train-workers-0', status: 'healthy', data: { namespace: 'ml', pods } }
7
+ }
8
+
9
+ const jobEdge: TopologyEdge = { id: 'job-to-group', source: 'job/ml/train-workers-0', target: 'podgroup/ml/train-workers-0', type: 'manages' }
10
+ const pod = (name: string, extra: Record<string, unknown> = {}) => ({ name, namespace: 'ml', phase: 'Running', restarts: 0, containers: 1, ...extra })
11
+
12
+ describe('expandPodGroup', () => {
13
+ it('preserves earlier-run context and each Pod termination state when expanded', () => {
14
+ const node = group([pod('old', { status: 'neutral', terminating: true }), pod('failed', { status: 'unhealthy', terminating: false })])
15
+ node.data.earlierJobRun = 'train-workers-0'
16
+ node.data.terminating = 1
17
+ const edge: TopologyEdge = { ...jobEdge, source: 'jobset/ml/train/jobset.x-k8s.io', type: 'uses', label: 'Earlier Job run (same name)' }
18
+ const out = expandPodGroup([node], [edge], node.id)
19
+ expect(out.nodes.map(n => [n.status, n.data.earlierJobRun, n.data.terminating])).toEqual([
20
+ ['neutral', 'train-workers-0', 1], ['unhealthy', 'train-workers-0', 0],
21
+ ])
22
+ expect(out.edges.every(e => e.type === 'uses' && e.label === edge.label)).toBe(true)
23
+ })
24
+ it('connects a pod without ownerIds to every owner of the group', () => {
25
+ const out = expandPodGroup([group([pod('a')])], [jobEdge], 'podgroup/ml/train-workers-0')
26
+ expect(out.edges.map((e) => [e.source, e.target])).toEqual([['job/ml/train-workers-0', 'pod/ml/a']])
27
+ })
28
+
29
+ it('connects a pod with empty ownerIds to none of the group\'s owners', () => {
30
+ const out = expandPodGroup(
31
+ [group([pod('current', { ownerIds: ['job/ml/train-workers-0'] }), pod('earlier', { ownerIds: [] })])],
32
+ [jobEdge],
33
+ 'podgroup/ml/train-workers-0',
34
+ )
35
+ expect(out.nodes.map((n) => n.id).sort()).toEqual(['pod/ml/current', 'pod/ml/earlier'])
36
+ expect(out.edges.map((e) => [e.source, e.target])).toEqual([['job/ml/train-workers-0', 'pod/ml/current']])
37
+ })
38
+ })
@@ -95,6 +95,7 @@ const KIND: Record<string, string> = {
95
95
  // Jobs
96
96
  Job: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
97
97
  CronJob: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
98
+ JobSet: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
98
99
  Workflow: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
99
100
  CronWorkflow: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
100
101
  WorkflowTemplate: 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
@@ -81,6 +81,14 @@ function radarUpgradeNeed(requirement: RadarUpgradeRequirement): string {
81
81
  return current ? `${need} It runs ${current}.` : need
82
82
  }
83
83
 
84
+ /**
85
+ * The inline note's sentence as plain text, for the tooltip of a control the
86
+ * feature backs: "Argo CD rollbacks need Radar v1.10 or newer on this cluster."
87
+ */
88
+ export function radarUpgradeNoteText(requirement: RadarUpgradeRequirement): string {
89
+ return `${requirement.feature} ${radarUpgradeNeed(requirement)}`
90
+ }
91
+
84
92
  /** The upgrade call to action, or nothing when the host offers no way to upgrade. */
85
93
  export function RadarUpgradeAction({ requirement }: { requirement: RadarUpgradeRequirement }) {
86
94
  const { onRequestUpgrade, upgradeHref } = useContext(RadarUpgradeContext)
@@ -1016,7 +1016,7 @@ function ownershipUnanswered(coverage: OwnershipCoverage | undefined): string |
1016
1016
  }
1017
1017
 
1018
1018
  // ownerNote says why no owner is named, when there is an owner reference.
1019
- function ownerNote(coverage: OwnershipCoverage | undefined): RelationshipNote | undefined {
1019
+ export function ownerNote(coverage: OwnershipCoverage | undefined): RelationshipNote | undefined {
1020
1020
  const ref = coverage?.ownerRef
1021
1021
  const named = ref?.kind && ref.name ? `${ref.kind} ${ref.name}` : 'the owner it names'
1022
1022
  switch (coverage?.primaryOwnerState) {
@@ -82,5 +82,6 @@ export {
82
82
  RadarUpgradeContext,
83
83
  radarUpgradeDetail,
84
84
  radarUpgradeHeadline,
85
+ radarUpgradeNoteText,
85
86
  } from "./RadarUpgradeNote";
86
87
  export type { RadarUpgradeActions } from "./RadarUpgradeNote";
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import clsx from 'clsx'
3
+ import { Terminal } from 'lucide-react'
3
4
  import { Collapse } from '../ui/Collapse'
4
5
  import type { WorkloadPodInfo } from '../../types'
5
6
  import type { NavigateToResource } from '../../utils/navigation'
@@ -12,6 +13,8 @@ export function PodRow({
12
13
  healthLevel,
13
14
  detail,
14
15
  onNavigate,
16
+ onOpenLogs,
17
+ logsUnavailable,
15
18
  }: {
16
19
  name: string
17
20
  namespace: string
@@ -19,6 +22,10 @@ export function PodRow({
19
22
  healthLevel?: WorkloadPodInfo['healthLevel']
20
23
  detail: string
21
24
  onNavigate?: NavigateToResource
25
+ /** Adds a Logs button beside the row. */
26
+ onOpenLogs?: () => void
27
+ /** With `onOpenLogs`, shows this reason in place of the Logs button. */
28
+ logsUnavailable?: string
22
29
  }) {
23
30
  const statusLabel = podStatusLabel(healthLevel, ready)
24
31
  const statusClass = podStatusClass(healthLevel, ready)
@@ -35,15 +42,11 @@ export function PodRow({
35
42
  </>
36
43
  )
37
44
 
38
- if (!onNavigate) {
39
- return (
40
- <div className="flex w-full min-w-0 items-center gap-3 rounded-md border border-theme-border bg-theme-base px-3 py-2 text-left">
41
- {content}
42
- </div>
43
- )
44
- }
45
-
46
- return (
45
+ const row = !onNavigate ? (
46
+ <div className="flex w-full min-w-0 items-center gap-3 rounded-md border border-theme-border bg-theme-base px-3 py-2 text-left">
47
+ {content}
48
+ </div>
49
+ ) : (
47
50
  <button
48
51
  type="button"
49
52
  onClick={() => onNavigate({ kind: 'pods', namespace, name })}
@@ -52,6 +55,29 @@ export function PodRow({
52
55
  {content}
53
56
  </button>
54
57
  )
58
+ if (!onOpenLogs) return row
59
+
60
+ return (
61
+ <div className="flex min-w-0 items-stretch gap-2">
62
+ <div className="min-w-0 flex-1">{row}</div>
63
+ {logsUnavailable ? (
64
+ <span className="flex w-24 shrink-0 items-center justify-center px-2 text-center text-xs text-theme-text-tertiary">
65
+ <span className="sr-only">Logs for {name}: </span>
66
+ {logsUnavailable}
67
+ </span>
68
+ ) : (
69
+ <button
70
+ type="button"
71
+ onClick={onOpenLogs}
72
+ aria-label={`Open logs for ${name}`}
73
+ className="btn-secondary inline-flex min-w-24 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-xs font-medium"
74
+ >
75
+ <Terminal className="h-3.5 w-3.5" />
76
+ Logs
77
+ </button>
78
+ )}
79
+ </div>
80
+ )
55
81
  }
56
82
 
57
83
  export function PodListFrame({
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment jsdom
2
- import { act } from 'react'
2
+ import { act, useEffect } from 'react'
3
3
  import { createRoot } from 'react-dom/client'
4
4
  import { renderToStaticMarkup } from 'react-dom/server'
5
5
  import type { ReactNode } from 'react'
@@ -147,3 +147,43 @@ it.each(['Deployment', 'Pod', 'Pooler', 'ScheduledBackup'])('groups %s drawer ac
147
147
  if (kind === 'ScheduledBackup') { expect(object.querySelectorAll('button')).toHaveLength(3); expect(object.textContent).toContain('Run blocked: no backup destination') }
148
148
  act(() => root.unmount())
149
149
  })
150
+
151
+ it('does not carry a picked Pod to the next resource the view shows', () => {
152
+ const pods = (name: string) => ({ pods: [{ kind: 'Pod', namespace: 'db', name }] })
153
+ const seen: (string | null)[] = []
154
+ const Probe = ({ selectedPod, onSelectPod }: { selectedPod: string | null; onSelectPod: (pod: string | null) => void }) => {
155
+ useEffect(() => { seen.push(selectedPod) })
156
+ return <button data-pick onClick={() => onSelectPod('pg-1')}>pick</button>
157
+ }
158
+ const renderLogsTab = (tab: { selectedPod: string | null; onSelectPod: (pod: string | null) => void }) => <Probe {...tab} />
159
+ const host = document.createElement('div')
160
+ const root = createRoot(host)
161
+ act(() => root.render(<WorkloadView {...props} activeTab="logs" relationships={pods('pg-1')} renderLogsTab={renderLogsTab} />))
162
+ act(() => host.querySelector<HTMLButtonElement>('[data-pick]')!.click())
163
+ expect(seen.at(-1)).toBe('pg-1')
164
+ const before = seen.length
165
+ act(() => root.render(<WorkloadView {...props} name="other" activeTab="logs" relationships={pods('other-1')} renderLogsTab={renderLogsTab} />))
166
+ expect(seen.slice(before)).not.toContain('pg-1')
167
+ act(() => root.unmount())
168
+ })
169
+
170
+ it('does not carry a picked Pod to a recreated resource with the same name', () => {
171
+ const relationships = { pods: [{ kind: 'Pod', namespace: 'db', name: 'pg-1' }] }
172
+ const seen: (string | null)[] = []
173
+ const Probe = ({ selectedPod, onSelectPod }: { selectedPod: string | null; onSelectPod: (pod: string | null) => void }) => {
174
+ useEffect(() => { seen.push(selectedPod) })
175
+ return <button data-pick onClick={() => onSelectPod('pg-1')}>pick</button>
176
+ }
177
+ const renderLogsTab = (tab: { selectedPod: string | null; onSelectPod: (pod: string | null) => void }) => <Probe {...tab} />
178
+ const host = document.createElement('div')
179
+ const root = createRoot(host)
180
+ const withUID = (uid: string) => ({ ...resource, metadata: { ...resource.metadata, uid } })
181
+ act(() => root.render(<WorkloadView {...props} resource={withUID('a')} activeTab="logs" relationships={relationships} renderLogsTab={renderLogsTab} />))
182
+ act(() => host.querySelector<HTMLButtonElement>('[data-pick]')!.click())
183
+ act(() => root.render(<WorkloadView {...props} resource={withUID('a')} activeTab="logs" relationships={relationships} renderLogsTab={renderLogsTab} />))
184
+ expect(seen.at(-1)).toBe('pg-1')
185
+ const before = seen.length
186
+ act(() => root.render(<WorkloadView {...props} resource={withUID('b')} activeTab="logs" relationships={relationships} renderLogsTab={renderLogsTab} />))
187
+ expect(seen.slice(before)).not.toContain('pg-1')
188
+ act(() => root.unmount())
189
+ })