@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
@@ -45,6 +45,17 @@ export const DURATION_DISCLOSURE = 300
45
45
  * should feel immediate. */
46
46
  export const DURATION_MENU_IN = 140
47
47
  export const DURATION_MENU_OUT = 100
48
+ /** Hover-revealed row actions (HOVER_REVEAL_*). In the range of Radar's other
49
+ * hover effects (150–200ms) rather than the menu clock, which reads as a
50
+ * flicker on something the pointer merely passes over. The exit is shorter
51
+ * so leaving a row feels immediate. The preset class strings spell these
52
+ * values out for Tailwind's scanner; a test keeps them in step. */
53
+ export const DURATION_HOVER_REVEAL_IN = 200
54
+ export const DURATION_HOVER_REVEAL_OUT = 120
55
+ /** Hover intent before a row action comes in: a pointer sweeping down a long
56
+ * list passes each row in well under this, so actions don't ripple in behind
57
+ * it. Entrance only; leaving a row hides its action at once. */
58
+ export const HOVER_INTENT_DELAY = 60
48
59
  /** Modal dialogs and command palettes. */
49
60
  export const DURATION_DIALOG_IN = 220
50
61
  export const DURATION_DIALOG_OUT = 160
@@ -104,6 +115,29 @@ export const TRANSITION_PANEL =
104
115
  export const TRANSITION_MENU =
105
116
  `transition-[translate,scale,opacity] ${TW_EASE} motion-reduce:transition-none`
106
117
 
118
+ /** Hover-revealed row action, the resting half: the row content the action
119
+ * replaces (an age, a chip) lifts 3px and fades on the exit clock. Needs a
120
+ * `group` ancestor; keyboard reveal keys on :focus-visible inside the group,
121
+ * so a row left focused by a mouse click doesn't keep its action up. Timing
122
+ * is DURATION_HOVER_REVEAL_IN / _OUT and HOVER_INTENT_DELAY. */
123
+ export const HOVER_REVEAL_REST =
124
+ `transition-[opacity,translate] duration-200 ${TW_EASE} motion-reduce:transition-none group-hover:opacity-0 group-hover:-translate-y-[3px] group-hover:duration-120 group-hover:delay-60 group-has-[:focus-visible]:opacity-0 group-has-[:focus-visible]:-translate-y-[3px] group-has-[:focus-visible]:duration-120 group-has-[:focus-visible]:delay-60`
125
+
126
+ /** Hover-revealed row action, the action half: rises 3px into place on the
127
+ * entrance clock after the hover-intent delay, drops out on the exit clock.
128
+ * Never a hit target itself, so its padding and any backdrop leave clicks to
129
+ * the row underneath; put HOVER_REVEAL_HIT on the control inside it. Pair
130
+ * with HOVER_REVEAL_REST in one group. */
131
+ export const HOVER_REVEAL_ACTION =
132
+ `transition-[opacity,translate] duration-120 ${TW_EASE} motion-reduce:transition-none pointer-events-none opacity-0 translate-y-[3px] group-hover:opacity-100 group-hover:translate-y-0 group-hover:duration-200 group-hover:delay-60 group-has-[:focus-visible]:opacity-100 group-has-[:focus-visible]:translate-y-0 group-has-[:focus-visible]:duration-200 group-has-[:focus-visible]:delay-60`
133
+
134
+ /** Hover-revealed row action, the control inside it: takes clicks only while
135
+ * shown. pointer-events transitions as a discrete property on the same
136
+ * clock, so it flips partway through the entrance rather than at hover start
137
+ * and a click during the intent delay lands on the row. */
138
+ export const HOVER_REVEAL_HIT =
139
+ `transition-[pointer-events] transition-discrete duration-120 ${TW_EASE} motion-reduce:transition-none pointer-events-none group-hover:pointer-events-auto group-hover:duration-200 group-hover:delay-60 group-has-[:focus-visible]:pointer-events-auto group-has-[:focus-visible]:duration-200 group-has-[:focus-visible]:delay-60`
140
+
107
141
  export type OverlayKind = 'menu' | 'dialog' | 'sheet' | 'drawer'
108
142
 
109
143
  const OVERLAY_DURATIONS: Record<OverlayKind, { in: number; out: number }> = {
@@ -0,0 +1,11 @@
1
+ import { expect, it } from 'vitest'
2
+ import { categorizeResources, resourceSearchAliases } from './api-resources'
3
+ import type { APIResource } from '../types'
4
+ const crd: APIResource = { group: 'apiextensions.k8s.io', version: 'v1', kind: 'CustomResourceDefinition', name: 'customresourcedefinitions', namespaced: false, isCrd: false, verbs: ['get', 'list'] }
5
+ it('places discovered definitions in Cluster without a fallback row', () => {
6
+ expect(categorizeResources([crd]).find(c => c.name === 'Cluster')?.resources).toContainEqual(crd)
7
+ expect(categorizeResources([]).flatMap(c => c.resources).some(r => r.kind === crd.kind)).toBe(false)
8
+ expect(categorizeResources([{ ...crd, verbs: ['get'] }]).flatMap(c => c.resources).some(r => r.kind === crd.kind)).toBe(false)
9
+ expect(resourceSearchAliases(crd)).toEqual(['crd', 'crds'])
10
+ expect(resourceSearchAliases({ group: 'other.io', kind: crd.kind })).toEqual([])
11
+ })
@@ -12,7 +12,7 @@ const NETWORKING_KINDS = ['Service', 'Ingress', 'IngressClass', 'NetworkPolicy',
12
12
  const CONFIG_KINDS = ['ConfigMap', 'Secret', 'HorizontalPodAutoscaler', 'PodDisruptionBudget', 'LimitRange', 'ResourceQuota', 'PriorityClass', 'RuntimeClass', 'Lease', 'MutatingWebhookConfiguration', 'ValidatingWebhookConfiguration']
13
13
  const STORAGE_KINDS = ['PersistentVolumeClaim', 'PersistentVolume', 'StorageClass', 'VolumeAttachment']
14
14
  const ACCESS_CONTROL_KINDS = ['ServiceAccount', 'Role', 'ClusterRole', 'RoleBinding', 'ClusterRoleBinding']
15
- const CLUSTER_KINDS = ['Node', 'Namespace', 'Event']
15
+ const CLUSTER_KINDS = ['Node', 'Namespace', 'Event', 'CustomResourceDefinition']
16
16
  const CORE_CATEGORY_NAMES = new Set(['Workloads', 'Networking', 'Configuration', 'Storage', 'Access Control', 'Cluster'])
17
17
  // Core resources that must always be present (fallback if API discovery misses them)
18
18
  export const CORE_RESOURCES: APIResource[] = [
@@ -346,3 +346,7 @@ export function getKindLabel(kind: string): string {
346
346
  export function getKindPlural(resource: APIResource): string {
347
347
  return resource.name
348
348
  }
349
+
350
+ export function resourceSearchAliases(resource: Pick<APIResource, 'group' | 'kind'>): string[] {
351
+ return resource.group === 'apiextensions.k8s.io' && resource.kind === 'CustomResourceDefinition' ? ['crd', 'crds'] : []
352
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { compareVersions, appGroupingExplainer, APP_IDENTITY_ANNOTATION, appGroupLagMessage, matchWorkloadAcrossInstances, foldAppGroups, identityEnvInferred, worstHealth, buildAppMembershipIndex, batchActivityForApp, batchRuntimeForApp, servingReadiness, resolveAppWorkloadSelection, type AppGroupFoldEntry, type AppRow, type AppWorkload } from './applications'
2
+ import { compareVersions, appGroupingExplainer, APP_IDENTITY_ANNOTATION, appGroupLagMessage, matchWorkloadAcrossInstances, foldAppGroups, identityEnvInferred, worstHealth, buildAppMembershipIndex, batchActivityForApp, batchRuntimeForApp, batchSignalForApp, servingReadiness, resolveAppWorkloadSelection, type AppGroupFoldEntry, type AppRow, type AppWorkload } from './applications'
3
3
 
4
4
  describe('application workload scope resolution', () => {
5
5
  const sole = ['Deployment/prod/api']
@@ -64,6 +64,20 @@ describe('application workload scope resolution', () => {
64
64
  })
65
65
 
66
66
  describe('batch application runtime', () => {
67
+ it('uses JobSet root health without interpreting child activity as running Pods', () => {
68
+ const workload: AppWorkload = { kind: 'JobSet', group: 'jobset.x-k8s.io', name: 'train', namespace: 'ml', health: 'unknown', ready: 0, desired: 0, restarts: 0, batch: { latestRunPhase: 'Unknown', retainedRuns: 1 } }
69
+ const app: AppRow = { key: 'ml/JobSet/train', name: 'train', health: 'unknown', workload_class: 'job', workloads: [workload] }
70
+ for (const [health, phase] of [['unknown', 'Unknown'], ['degraded', 'Restarting'], ['neutral', 'Active'], ['neutral', 'Succeeded'], ['unhealthy', 'Failed']]) {
71
+ workload.health = health
72
+ workload.batch = { latestRunPhase: phase, retainedRuns: 1, activeRuns: phase === 'Active' ? 1 : 0 }
73
+ expect(batchRuntimeForApp(app)).toMatchObject({ health, label: phase })
74
+ expect(batchSignalForApp(app)).toMatchObject({ label: `JobSet ${phase}` })
75
+ expect(batchActivityForApp(app)[0]).toMatchObject({ label: phase })
76
+ }
77
+ workload.health = 'unhealthy'
78
+ app.workloads!.push({ ...workload, kind: 'CronJob', group: 'batch', health: 'neutral', batch: { activeRuns: 1, latestRunPhase: 'Running' } })
79
+ expect(batchRuntimeForApp(app).health).toBe('unhealthy')
80
+ })
67
81
  it('uses the latest retained outcome instead of historical failure count', () => {
68
82
  const app: AppRow = {
69
83
  key: 'batch', name: 'batch', health: 'unhealthy', workload_class: 'job',
@@ -1203,7 +1203,7 @@ export const CLASS_META: Record<
1203
1203
  label: "Job",
1204
1204
  pill: CHIP_TONE.amber,
1205
1205
  tooltip:
1206
- "Finite or scheduled work (Job/CronJob/Workflow/CronWorkflow/WorkflowTemplate/ClusterWorkflowTemplate/ScaledJob).",
1206
+ "Finite or scheduled work (Job/CronJob/Workflow/CronWorkflow/WorkflowTemplate/ClusterWorkflowTemplate/ScaledJob/JobSet).",
1207
1207
  },
1208
1208
  mixed: {
1209
1209
  label: "Mixed",
@@ -1293,6 +1293,8 @@ export function batchSignalForApp(app: AppRow): BatchSignal | null {
1293
1293
  function batchSignalForWorkload(workload: AppWorkload): BatchSignal | null {
1294
1294
  const batch = workload.batch;
1295
1295
  if (!batch) return null;
1296
+ const root = jobSetBatchRuntime(workload)
1297
+ if (root) return { tone: root.health === 'unhealthy' ? 'rose' : root.health === 'degraded' ? 'amber' : 'sky', label: `JobSet ${root.label}`, detail: root.detail, workload }
1296
1298
  const latestPhase = batch.latestRunPhase || "";
1297
1299
  const name = `${workload.kind}/${workload.name}`;
1298
1300
  if (latestPhase === "Failed" || latestPhase === "Error") {
@@ -1330,7 +1332,23 @@ export interface AppBatchRuntime {
1330
1332
  detail: string;
1331
1333
  }
1332
1334
 
1335
+ function jobSetBatchRuntime(workload: AppWorkload): AppBatchRuntime | null {
1336
+ if (workload.kind !== 'JobSet' || workload.group !== 'jobset.x-k8s.io') return null
1337
+ return {
1338
+ label: workload.batch?.latestRunPhase || 'Unknown',
1339
+ health: healthOf(workload.health),
1340
+ detail: workload.batch?.message || `JobSet/${workload.name} root lifecycle.`,
1341
+ }
1342
+ }
1343
+
1333
1344
  export function batchRuntimeForApp(app: AppRow): AppBatchRuntime {
1345
+ const jobSets = (app.workloads || []).filter(w => w.kind === 'JobSet' && w.group === 'jobset.x-k8s.io')
1346
+ if (jobSets.length > 0) {
1347
+ const runtimes: AppBatchRuntime[] = jobSets.map(workload => jobSetBatchRuntime(workload)!)
1348
+ const others = (app.workloads || []).filter(w => !jobSets.includes(w))
1349
+ if (others.length > 0) runtimes.push(batchRuntimeForApp({ ...app, workloads: others }))
1350
+ return runtimes.reduce((worst, next) => HEALTH_RANK[next.health] > HEALTH_RANK[worst.health] ? next : worst)
1351
+ }
1334
1352
  const activity = batchActivityForApp(app);
1335
1353
  if (
1336
1354
  activity.some(
@@ -1504,6 +1522,13 @@ function batchActivityForWorkload(
1504
1522
  const activeRuns = batch.activeRuns ?? 0;
1505
1523
  const retainedRuns = batch.retainedRuns ?? 0;
1506
1524
  const failedRuns = batch.failedRuns ?? 0;
1525
+ const root = jobSetBatchRuntime(workload)
1526
+ if (root) return {
1527
+ workload, tone: root.health === 'unhealthy' ? 'rose' : root.health === 'degraded' ? 'amber' : 'sky',
1528
+ rank: root.health === 'unhealthy' ? 50 : root.health === 'degraded' ? 40 : 10,
1529
+ label: root.label, detail: root.detail, latestRunName: batch.latestRunName,
1530
+ latestRunPhase: latestPhase, activeRuns, retainedRuns, failedRuns,
1531
+ }
1507
1532
  if (latestPhase === "Failed" || latestPhase === "Error") {
1508
1533
  return {
1509
1534
  workload,
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { compareKubeAwareVersions, displaySchemaPath, filterCRDSchema, indexCRDSchema, preferredCRDVersion, schemaChildren, schemaCompositions, schemaConstraints, schemaTypeLabel, schemaLeafName, schemaParentPath, schemaElement, enclosingRules, type CRDSchema } from './crd-schema'
3
+
4
+ describe('CRD schemas', () => {
5
+ it('labels arrays, maps, boolean additionalProperties and Kubernetes extensions', () => {
6
+ expect(schemaTypeLabel({ type: 'array', items: { type: 'string' } })).toBe('[]string')
7
+ expect(schemaTypeLabel({ type: 'object', additionalProperties: { type: 'integer' } })).toBe('map[string]integer')
8
+ expect(schemaTypeLabel({ type: 'object', additionalProperties: true })).toBe('map[string]any')
9
+ expect(schemaTypeLabel({ type: 'object', additionalProperties: false })).toBe('object')
10
+ expect(schemaTypeLabel({ 'x-kubernetes-int-or-string': true })).toBe('int-or-string')
11
+ expect(schemaTypeLabel({ type: 'object', 'x-kubernetes-preserve-unknown-fields': true })).toBe('object')
12
+ })
13
+ it('retains requiredness, array/map traversal and dotted property identity', () => {
14
+ const nodes = indexCRDSchema({ required: ['a.b'], properties: {
15
+ 'a.b': { type: 'string' }, a: { properties: { b: { type: 'number' } } },
16
+ list: { items: { properties: { x: { type: 'boolean' } } } },
17
+ map: { additionalProperties: { properties: { y: { type: 'integer' } } } },
18
+ } })
19
+ expect(nodes[0].required).toBe(true)
20
+ expect(nodes.filter(n => displaySchemaPath(n.path) === 'a.b')).toHaveLength(1)
21
+ expect(nodes.filter(n => displaySchemaPath(n.path) === '["a.b"]')).toHaveLength(1)
22
+ expect(nodes.map(n => displaySchemaPath(n.path))).toContain('list[].x')
23
+ expect(nodes.map(n => displaySchemaPath(n.path))).toContain('map{}.y')
24
+ expect(nodes.map(n => displaySchemaPath(n.path))).not.toContain('list[]')
25
+ expect(nodes.map(n => displaySchemaPath(n.path))).not.toContain('map{}')
26
+ const deep = nodes.find(n => displaySchemaPath(n.path) === 'list[].x')!
27
+ expect(schemaLeafName(deep.path)).toBe('x')
28
+ expect(schemaParentPath(deep.path)).toBe('list[].')
29
+ expect(schemaElement({ type: 'array', items: { type: 'string', enum: ['a'] } })).toEqual({ label: 'items', schema: { type: 'string', enum: ['a'] } })
30
+ expect(schemaChildren({ additionalProperties: true })).toEqual([])
31
+ })
32
+ it('retains zero/false/default, constraints, CEL and compositions', () => {
33
+ const schema = { type: 'integer', minimum: 0, maximum: 4, default: false, enum: [0, 1], format: 'int32', pattern: '^a', minLength: 0, maxLength: 4, minItems: 0, maxItems: 2,
34
+ 'x-kubernetes-validations': [{ rule: 'self > 0', message: 'positive' }],
35
+ oneOf: [{ required: ['a'] }, { required: ['b', 'c'] }], anyOf: [{ type: 'string' }], allOf: [{ minimum: 0 }], not: { type: 'boolean' } }
36
+ expect(schemaConstraints(schema)).toEqual(expect.arrayContaining([{ label: 'minimum', value: '0' }, { label: 'default', value: 'false' }, { label: 'enum', value: '0 · 1' }]))
37
+ expect(schema['x-kubernetes-validations'][0]).toEqual({ rule: 'self > 0', message: 'positive' })
38
+ expect(schemaCompositions(schema)).toEqual([{ label: 'One of', value: 'a · b + c' }, { label: 'anyOf', value: '[{"type":"string"}]' }, { label: 'allOf', value: '[{"minimum":0}]' }, { label: 'not', value: '{"type":"boolean"}' }])
39
+ })
40
+ it('finds a field from an API server error path or a path without brackets', () => {
41
+ const index = indexCRDSchema({ properties: { spec: { properties: {
42
+ ports: { type: 'array', items: { properties: { name: { type: 'string' } } } },
43
+ labels: { type: 'object', additionalProperties: { type: 'object', properties: { tier: { type: 'string' } } } },
44
+ } } } })
45
+ const paths = (q: string) => filterCRDSchema(index, q).nodes.map(n => displaySchemaPath(n.path))
46
+ expect(paths('spec.ports[0].name')).toEqual(['spec.ports[].name'])
47
+ expect(paths('ports.name')).toEqual(['spec.ports[].name'])
48
+ expect(paths(' spec.ports[].name ')).toEqual(['spec.ports[].name'])
49
+ expect(paths('spec.labels[app].tier')).toEqual(['spec.labels{}.tier'])
50
+ expect(paths('ports.tier')).toEqual([])
51
+ })
52
+ it('lists the rules on enclosing fields, nearest first, including array item rules', () => {
53
+ const root = {
54
+ 'x-kubernetes-validations': [{ rule: 'has(self.spec)' }],
55
+ properties: { spec: {
56
+ 'x-kubernetes-validations': [{ rule: 'has(self.a) != has(self.b)', message: 'set a or b' }],
57
+ oneOf: [{ required: ['a'] }, { required: ['b'] }],
58
+ properties: { list: { type: 'array', items: { 'x-kubernetes-validations': [{ rule: 'self.x > 0' }], properties: { x: { type: 'integer' } } } } },
59
+ } },
60
+ }
61
+ const x = indexCRDSchema(root).find(n => displaySchemaPath(n.path) === 'spec.list[].x')!
62
+ expect(enclosingRules(x, root)).toEqual([
63
+ { path: 'spec.list[]', validations: [{ rule: 'self.x > 0' }], compositions: [] },
64
+ { path: 'spec', validations: [{ rule: 'has(self.a) != has(self.b)', message: 'set a or b' }], compositions: [{ label: 'One of', value: 'a · b' }] },
65
+ { path: '', validations: [{ rule: 'has(self.spec)' }], compositions: [] },
66
+ ])
67
+ expect(enclosingRules(indexCRDSchema({ properties: { a: { type: 'string' } } })[0], { properties: { a: { type: 'string' } } })).toEqual([])
68
+ })
69
+ it('reports list, map and key semantics as facts', () => {
70
+ expect(schemaConstraints({ type: 'array', 'x-kubernetes-list-type': 'map', 'x-kubernetes-list-map-keys': ['name', 'protocol'] })).toEqual([
71
+ { label: 'list type', value: 'map' }, { label: 'list keys', value: 'name, protocol' },
72
+ ])
73
+ expect(schemaConstraints({ type: 'object', 'x-kubernetes-map-type': 'atomic' })).toEqual([{ label: 'map type', value: 'atomic' }])
74
+ })
75
+ it('reports rules at every container level a search hit sits in, under the path self refers to', () => {
76
+ const root = { properties: { spec: { properties: { groups: { type: 'array',
77
+ 'x-kubernetes-validations': [{ rule: 'size(self) < 5' }],
78
+ items: { type: 'object', additionalProperties: { type: 'object',
79
+ 'x-kubernetes-validations': [{ rule: 'self.min <= self.max' }],
80
+ properties: { min: { type: 'integer' }, max: { type: 'integer' } } } } } } } } }
81
+ const min = filterCRDSchema(indexCRDSchema(root), 'spec.groups[0][a].min').nodes
82
+ expect(min.map(n => displaySchemaPath(n.path))).toEqual(['spec.groups[]{}.min'])
83
+ expect(enclosingRules(min[0], root).map(r => [r.path, r.validations.map(v => v.rule)])).toEqual([
84
+ ['spec.groups[]{}', ['self.min <= self.max']],
85
+ ['spec.groups', ['size(self) < 5']],
86
+ ])
87
+ })
88
+ it('keeps a quoted property exact and ahead of the result cap', () => {
89
+ const properties: Record<string, CRDSchema> = Object.fromEntries(Array.from({ length: 205 }, (_, i) => [`f${i}`, { type: 'string' }]))
90
+ properties['a.b'] = { type: 'string' }
91
+ const result = filterCRDSchema(indexCRDSchema({ properties: { spec: { properties } } }), 'spec["a.b"]')
92
+ expect(result.nodes.map(n => displaySchemaPath(n.path))).toEqual(['spec["a.b"]'])
93
+ expect(filterCRDSchema(indexCRDSchema({ properties: { spec: { properties } } }), 'spec.f20').nodes[0].path.at(-1)?.name).toBe('f20')
94
+ })
95
+ it('orders kube versions by stability then descending major/minor, with other names last', () => {
96
+ expect(['v2alpha2', 'foo', 'v1beta2', 'v2', 'v1', 'v3beta1', 'v1beta10', 'bar', 'v2alpha10'].sort(compareKubeAwareVersions)).toEqual(['v2', 'v1', 'v3beta1', 'v1beta10', 'v1beta2', 'v2alpha10', 'v2alpha2', 'bar', 'foo'])
97
+ expect(preferredCRDVersion([{ name: 'v3', served: false }, { name: 'v1', served: true }, { name: 'v2', served: true }])?.name).toBe('v2')
98
+ })
99
+ it('filters case-insensitive displayed paths with a 200 cap and exact total; accepts missing schema', () => {
100
+ const index = indexCRDSchema({ properties: Object.fromEntries(Array.from({ length: 245 }, (_, i) => [`field${i}`, { type: 'string' }])) })
101
+ const result = filterCRDSchema(index, 'FIELD')
102
+ expect(result.total).toBe(245)
103
+ expect(result.nodes).toHaveLength(200)
104
+ expect(indexCRDSchema(undefined)).toEqual([])
105
+ expect(preferredCRDVersion([])).toBeUndefined()
106
+ })
107
+ })
@@ -0,0 +1,201 @@
1
+ export interface CRDSchema {
2
+ type?: string
3
+ description?: string
4
+ properties?: Record<string, CRDSchema>
5
+ required?: string[]
6
+ items?: CRDSchema
7
+ additionalProperties?: CRDSchema | boolean
8
+ 'x-kubernetes-int-or-string'?: boolean
9
+ 'x-kubernetes-preserve-unknown-fields'?: boolean
10
+ 'x-kubernetes-validations'?: { rule: string; message?: string }[]
11
+ oneOf?: CRDSchema[]
12
+ anyOf?: CRDSchema[]
13
+ allOf?: CRDSchema[]
14
+ not?: CRDSchema
15
+ [key: string]: unknown
16
+ }
17
+
18
+ export interface CRDVersion {
19
+ name: string
20
+ served?: boolean
21
+ storage?: boolean
22
+ deprecated?: boolean
23
+ deprecationWarning?: string
24
+ schema?: { openAPIV3Schema?: CRDSchema }
25
+ }
26
+
27
+ export type SchemaPath = { kind: 'property' | 'items' | 'map'; name: string }[]
28
+ export interface SchemaNode {
29
+ path: SchemaPath
30
+ schema: CRDSchema
31
+ required: boolean
32
+ parent?: SchemaNode
33
+ }
34
+
35
+ export function compareKubeAwareVersions(a: string, b: string): number {
36
+ const parse = (v: string) => /^v(\d+)(?:(alpha|beta)(\d+))?$/.exec(v)
37
+ const x = parse(a), y = parse(b)
38
+ if (!x || !y) return x ? -1 : y ? 1 : a.localeCompare(b)
39
+ const rank = (stage?: string) => stage === 'alpha' ? 0 : stage === 'beta' ? 1 : 2
40
+ return rank(y[2]) - rank(x[2]) || Number(y[1]) - Number(x[1]) || Number(y[3] || 0) - Number(x[3] || 0)
41
+ }
42
+
43
+ export function preferredCRDVersion(versions: CRDVersion[]): CRDVersion | undefined {
44
+ return [...versions].filter(v => v.served).sort((a, b) => compareKubeAwareVersions(a.name, b.name))[0] ?? versions[0]
45
+ }
46
+
47
+ export function schemaTypeLabel(schema: CRDSchema): string {
48
+ if (schema['x-kubernetes-int-or-string']) return 'int-or-string'
49
+ if (schema.type === 'array') return `[]${schema.items ? schemaTypeLabel(schema.items) : 'any'}`
50
+ if (schema.additionalProperties && typeof schema.additionalProperties === 'object') return `map[string]${schemaTypeLabel(schema.additionalProperties)}`
51
+ if (schema.additionalProperties === true) return 'map[string]any'
52
+ return schema.type || 'any'
53
+ }
54
+
55
+ // Array items and map values get no row of their own, as in `kubectl explain`:
56
+ // a field's children are the fields of what it holds, reached through a `[]` or
57
+ // `{}` path segment.
58
+ export function schemaChildren(schema: CRDSchema, path: SchemaPath = [], parent?: SchemaNode): SchemaNode[] {
59
+ const children: SchemaNode[] = Object.entries(schema.properties || {}).map(([name, child]) => ({
60
+ path: [...path, { kind: 'property', name }], schema: child, required: !!schema.required?.includes(name), parent,
61
+ }))
62
+ if (schema.items) children.push(...schemaChildren(schema.items, [...path, { kind: 'items', name: '[]' }], parent))
63
+ if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
64
+ children.push(...schemaChildren(schema.additionalProperties, [...path, { kind: 'map', name: '{}' }], parent))
65
+ }
66
+ return children
67
+ }
68
+
69
+ export interface EnclosingRules {
70
+ /** Display path of the enclosing field; empty for the document root. */
71
+ path: string
72
+ validations: { rule: string; message?: string }[]
73
+ compositions: { label: string; value: string }[]
74
+ }
75
+
76
+ // A field's own rules, then those of each array item or map value it holds
77
+ // (`spec.groups`, `spec.groups[]`, `spec.groups[]{}`), each under the path its
78
+ // `self` refers to. Nearest the leaf first.
79
+ function rulesOf(schema: CRDSchema, path: string): EnclosingRules[] {
80
+ const levels: EnclosingRules[] = []
81
+ for (let level: CRDSchema | undefined = schema, at = path; level;) {
82
+ const validations = level['x-kubernetes-validations'] ?? []
83
+ const compositions = schemaCompositions(level)
84
+ if (validations.length || compositions.length) levels.push({ path: at, validations, compositions })
85
+ const element = schemaElement(level)
86
+ at += element?.label === 'items' ? '[]' : '{}'
87
+ level = element?.schema
88
+ }
89
+ return levels.reverse()
90
+ }
91
+
92
+ /** Rules declared on the fields that contain `node`, nearest first. CEL rules
93
+ * and oneOf/anyOf constraints on a parent often decide whether a field's value
94
+ * is accepted, so a field found by search has to show them too. */
95
+ export function enclosingRules(node: SchemaNode, root: CRDSchema): EnclosingRules[] {
96
+ const result: EnclosingRules[] = []
97
+ for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
98
+ result.push(...rulesOf(ancestor.schema, displaySchemaPath(ancestor.path)))
99
+ }
100
+ result.push(...rulesOf(root, ''))
101
+ return result
102
+ }
103
+
104
+ /** The schema an array or map holds, whose constraints belong to the field. */
105
+ export function schemaElement(schema: CRDSchema): { label: 'items' | 'values'; schema: CRDSchema } | undefined {
106
+ if (schema.items) return { label: 'items', schema: schema.items }
107
+ if (schema.additionalProperties && typeof schema.additionalProperties === 'object') return { label: 'values', schema: schema.additionalProperties }
108
+ return undefined
109
+ }
110
+
111
+ export function schemaLeafName(path: SchemaPath): string {
112
+ for (let i = path.length - 1; i >= 0; i--) if (path[i].kind === 'property') return path[i].name
113
+ return ''
114
+ }
115
+
116
+ /** Display path of everything above the leaf property, with its trailing dot. */
117
+ export function schemaParentPath(path: SchemaPath): string {
118
+ let leaf = path.length - 1
119
+ while (leaf >= 0 && path[leaf].kind !== 'property') leaf--
120
+ const parent = displaySchemaPath(path.slice(0, leaf))
121
+ return parent ? `${parent}.` : ''
122
+ }
123
+
124
+ // A property name that would read as path syntax is quoted, so a literal
125
+ // `a.b` key and a nested `a` → `b` never display alike.
126
+ function displayPropertyName(name: string): string {
127
+ return /^[A-Za-z0-9_$@-]+$/.test(name) ? name : `[${JSON.stringify(name)}]`
128
+ }
129
+
130
+ export function displaySchemaPath(path: SchemaPath): string {
131
+ return path.map((segment, i) => {
132
+ if (segment.kind !== 'property') return segment.name
133
+ const name = displayPropertyName(segment.name)
134
+ return name.startsWith('[') || i === 0 ? name : `.${name}`
135
+ }).join('')
136
+ }
137
+
138
+ const EXTENSION_FACTS: [key: string, label: string][] = [
139
+ ['x-kubernetes-list-type', 'list type'],
140
+ ['x-kubernetes-list-map-keys', 'list keys'],
141
+ ['x-kubernetes-map-type', 'map type'],
142
+ ]
143
+
144
+ export function schemaConstraints(schema: CRDSchema): { label: string; value: string }[] {
145
+ const extensions = EXTENSION_FACTS
146
+ .filter(([key]) => schema[key] !== undefined)
147
+ .map(([key, label]) => ({ label, value: Array.isArray(schema[key]) ? (schema[key] as unknown[]).join(', ') : String(schema[key]) }))
148
+ return [...extensions, ...['format', 'enum', 'default', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
149
+ 'minLength', 'maxLength', 'pattern', 'minItems', 'maxItems', 'uniqueItems', 'minProperties', 'maxProperties', 'nullable']
150
+ .filter(key => schema[key] !== undefined)
151
+ .map(key => ({ label: key, value: key === 'enum' && Array.isArray(schema.enum) ? schema.enum.map(v => typeof v === 'string' ? v : JSON.stringify(v)).join(' · ')
152
+ : typeof schema[key] === 'string' ? schema[key] as string : JSON.stringify(schema[key]) }))]
153
+ }
154
+
155
+ export function schemaCompositions(schema: CRDSchema): { label: string; value: string }[] {
156
+ return (['oneOf', 'anyOf', 'allOf', 'not'] as const).flatMap(key => {
157
+ const value = schema[key]
158
+ if (!value) return []
159
+ const alternatives = Array.isArray(value) ? value : [value]
160
+ const requiredOnly = alternatives.every(v => v.required?.length && Object.keys(v).every(k => k === 'required'))
161
+ return [{ label: key === 'oneOf' && requiredOnly ? 'One of' : key,
162
+ value: requiredOnly ? alternatives.map(v => v.required!.join(' + ')).join(' · ') : JSON.stringify(value) }]
163
+ })
164
+ }
165
+
166
+ export function indexCRDSchema(schema: CRDSchema | undefined): SchemaNode[] {
167
+ if (!schema) return []
168
+ const result: SchemaNode[] = []
169
+ const stack = schemaChildren(schema).reverse()
170
+ while (stack.length) {
171
+ const node = stack.pop()!
172
+ result.push(node)
173
+ stack.push(...schemaChildren(node.schema, node.path, node).reverse())
174
+ }
175
+ return result
176
+ }
177
+
178
+ // Paths in API server errors and kubectl output index into lists and maps
179
+ // (`spec.ports[0].name`, `metadata.labels[app]`), and people often leave the
180
+ // brackets out entirely. Matching ignores both so a pasted path still finds its field.
181
+ function matchablePath(path: string): string {
182
+ return path.toLowerCase().replace(/\[(?!")[^\]]*\]|\{\}/g, '').replace(/\.{2,}/g, '.')
183
+ }
184
+
185
+ export function filterCRDSchema(index: SchemaNode[], query: string, limit = 200): { nodes: SchemaNode[]; total: number } {
186
+ const term = query.trim().toLowerCase()
187
+ const looseTerm = matchablePath(term)
188
+ // The field the query names exactly leads, so the cap can never hide it.
189
+ const exact: SchemaNode[] = []
190
+ const nodes: SchemaNode[] = []
191
+ let total = 0
192
+ for (const node of index) {
193
+ const display = displaySchemaPath(node.path).toLowerCase()
194
+ const loose = matchablePath(display)
195
+ if (!display.includes(term) && !(looseTerm && loose.includes(looseTerm))) continue
196
+ total++
197
+ if (display === term || loose === looseTerm) exact.push(node)
198
+ else if (nodes.length < limit) nodes.push(node)
199
+ }
200
+ return { nodes: [...exact, ...nodes].slice(0, limit), total }
201
+ }
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
- import { formatCompactAge, formatCPUString, formatMemoryString, formatRelativeAgeTime, parseMemoryToBytes, parseQuantityToNumber } from './format'
2
+ import { formatCompactAge, formatCPUString, formatMemoryString, formatRelativeAgeTime, parseCPUToNanocores, parseMemoryToBytes, parseQuantity, parseQuantityToNumber } from './format'
3
3
 
4
4
  describe('formatCPUString', () => {
5
5
  it('renders exact zero as zero, not a fabricated near-zero', () => {
@@ -169,3 +169,39 @@ describe('relative ages', () => {
169
169
  expect(formatRelativeAgeTime('not a time')).toBe('not a time')
170
170
  })
171
171
  })
172
+
173
+ describe('parseCPUToNanocores', () => {
174
+ it.each([
175
+ ['', 0],
176
+ ['2', 2_000_000_000],
177
+ ['1.5', 1_500_000_000],
178
+ ['.5', 500_000_000],
179
+ ['1.', 1_000_000_000],
180
+ ['250m', 250_000_000],
181
+ ['98753000n', 98_753_000],
182
+ // metrics-server's shortest exact form for the same reading
183
+ ['98753u', 98_753_000],
184
+ ['2k', 2_000_000_000_000],
185
+ ['garbage', 0],
186
+ ])('%s -> %d', (input, want) => {
187
+ expect(parseCPUToNanocores(input)).toBe(want)
188
+ })
189
+ })
190
+
191
+ describe('parseQuantity', () => {
192
+ it.each([
193
+ ['+1Gi', 1024 ** 3],
194
+ ['.5Ki', 512],
195
+ ['-.5', -0.5],
196
+ ['1.e3', 1000],
197
+ ['0', 0],
198
+ ])('%s -> %d', (input, want) => {
199
+ expect(parseQuantity(input)).toBe(want)
200
+ expect(parseMemoryToBytes(input)).toBe(want)
201
+ })
202
+
203
+ it.each(['', 'abcMi', '.', '1.2.3', '5Xi', null, undefined])('cannot read %s', (input) => {
204
+ expect(parseQuantity(input)).toBeNull()
205
+ expect(parseQuantityToNumber(input)).toBe(0)
206
+ })
207
+ })