@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.
- package/package.json +9 -2
- package/src/components/applications/ApplicationDetail.tsx +8 -2
- package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
- package/src/components/compare/ResourceCompareView.test.tsx +54 -0
- package/src/components/compare/ResourceCompareView.tsx +35 -12
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
- package/src/components/gitops/GitOpsTableView.tsx +26 -7
- package/src/components/gitops/RollbackDialog.test.tsx +80 -0
- package/src/components/gitops/RollbackDialog.tsx +25 -6
- package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
- package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
- package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
- package/src/components/gitops/applicationset-row.test.ts +12 -0
- package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
- package/src/components/gitops/detail-helpers.test.ts +8 -2
- package/src/components/gitops/detail-helpers.ts +9 -11
- package/src/components/gitops/index.ts +1 -1
- package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +30 -12
- package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
- package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +37 -0
- package/src/components/gitops/insights/insights-helpers.ts +12 -0
- package/src/components/issues/ResourceIssuesSection.tsx +3 -1
- package/src/components/issues/diagnostic.ts +14 -1
- package/src/components/issues/severity.ts +14 -0
- package/src/components/kueue/AdmissionPath.tsx +223 -0
- package/src/components/kueue/QueueEvidence.tsx +148 -0
- package/src/components/kueue/constants.ts +1 -0
- package/src/components/kueue/eligibility.ts +43 -0
- package/src/components/kueue/flavors-tried.ts +79 -0
- package/src/components/kueue/helpers.test.ts +105 -0
- package/src/components/kueue/policy.ts +100 -0
- package/src/components/kueue/queue-detail.test.tsx +317 -0
- package/src/components/kueue/queue-status.ts +42 -0
- package/src/components/kueue/related-read.ts +48 -0
- package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
- package/src/components/logs/StructuredLogLine.test.tsx +22 -0
- package/src/components/logs/StructuredLogLine.tsx +8 -3
- package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
- package/src/components/resources/KueueAdmissionSection.tsx +51 -12
- package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
- package/src/components/resources/KueuePendingPosition.tsx +91 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
- package/src/components/resources/ResourcesSidebar.tsx +3 -2
- package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
- package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
- package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
- package/src/components/resources/ResourcesView.tsx +50 -3
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +7 -0
- package/src/components/resources/get-pod-phase-display.test.ts +70 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
- package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
- package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
- package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/JobRenderer.tsx +7 -2
- package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
- package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
- package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
- package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
- package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
- package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
- package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
- package/src/components/resources/renderers/crd-cells.tsx +55 -0
- package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
- package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
- package/src/components/resources/renderers/index.ts +6 -0
- package/src/components/resources/renderers/kueue-cells.tsx +5 -0
- package/src/components/resources/resource-utils-argo.test.ts +94 -1
- package/src/components/resources/resource-utils-argo.ts +84 -5
- package/src/components/resources/resource-utils-crd.test.ts +32 -0
- package/src/components/resources/resource-utils-crd.ts +26 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
- package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
- package/src/components/resources/resource-utils-kueue.test.ts +15 -0
- package/src/components/resources/resource-utils-kueue.ts +137 -4
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
- package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
- package/src/components/resources/resource-utils-ray.test.ts +42 -1
- package/src/components/resources/resource-utils-ray.ts +38 -0
- package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
- package/src/components/resources/resource-utils-strimzi.ts +35 -0
- package/src/components/resources/resource-utils.ts +45 -1
- package/src/components/shared/ResourceActionsBar.tsx +83 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
- package/src/components/topology/K8sResourceNode.test.ts +14 -0
- package/src/components/topology/K8sResourceNode.tsx +18 -1
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +96 -94
- package/src/components/topology/layout.ts +3 -1
- package/src/components/topology/pod-group-expansion.test.ts +38 -0
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/RadarUpgradeNote.tsx +8 -0
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/PodList.tsx +35 -9
- package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
- package/src/components/workload/WorkloadView.tsx +61 -8
- package/src/components/workload/workload-logs-availability.test.ts +7 -0
- package/src/types/core.ts +40 -0
- package/src/types/gitops-insights.ts +11 -2
- package/src/types/gitops-tree.ts +2 -2
- package/src/types/kueue-queues.ts +84 -0
- package/src/types/read-coverage.ts +2 -1
- package/src/types/scheduling.ts +72 -0
- package/src/utils/animation.test.ts +38 -0
- package/src/utils/animation.ts +34 -0
- package/src/utils/api-resources-crd.test.ts +11 -0
- package/src/utils/api-resources.ts +5 -1
- package/src/utils/applications.test.ts +15 -1
- package/src/utils/applications.ts +26 -1
- package/src/utils/crd-schema.test.ts +107 -0
- package/src/utils/crd-schema.ts +201 -0
- package/src/utils/format.test.ts +37 -1
- package/src/utils/format.ts +28 -80
- package/src/utils/gitops-owner.test.ts +28 -2
- package/src/utils/gitops-owner.ts +19 -0
- package/src/utils/kueue-queue-workloads.ts +19 -0
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +90 -1
- package/src/utils/topology-neighborhood.ts +55 -3
- package/src/utils/workload-history-notes.test.ts +76 -0
- 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
|
-
|
|
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
|
+
}
|