@skyhook-io/k8s-ui 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/package.json +9 -2
  2. package/src/components/applications/ApplicationDetail.tsx +8 -2
  3. package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
  5. package/src/components/compare/ResourceCompareView.test.tsx +54 -0
  6. package/src/components/compare/ResourceCompareView.tsx +35 -12
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
  9. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
  10. package/src/components/gitops/GitOpsTableView.tsx +26 -7
  11. package/src/components/gitops/RollbackDialog.test.tsx +80 -0
  12. package/src/components/gitops/RollbackDialog.tsx +25 -6
  13. package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
  14. package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
  15. package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
  16. package/src/components/gitops/applicationset-row.test.ts +12 -0
  17. package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
  18. package/src/components/gitops/detail-helpers.test.ts +8 -2
  19. package/src/components/gitops/detail-helpers.ts +9 -11
  20. package/src/components/gitops/index.ts +1 -1
  21. package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
  22. package/src/components/gitops/insights/GitOpsInsightViews.tsx +30 -12
  23. package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
  24. package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
  25. package/src/components/gitops/insights/insights-helpers.test.ts +37 -0
  26. package/src/components/gitops/insights/insights-helpers.ts +12 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +3 -1
  28. package/src/components/issues/diagnostic.ts +14 -1
  29. package/src/components/issues/severity.ts +14 -0
  30. package/src/components/kueue/AdmissionPath.tsx +223 -0
  31. package/src/components/kueue/QueueEvidence.tsx +148 -0
  32. package/src/components/kueue/constants.ts +1 -0
  33. package/src/components/kueue/eligibility.ts +43 -0
  34. package/src/components/kueue/flavors-tried.ts +79 -0
  35. package/src/components/kueue/helpers.test.ts +105 -0
  36. package/src/components/kueue/policy.ts +100 -0
  37. package/src/components/kueue/queue-detail.test.tsx +317 -0
  38. package/src/components/kueue/queue-status.ts +42 -0
  39. package/src/components/kueue/related-read.ts +48 -0
  40. package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
  41. package/src/components/logs/StructuredLogLine.test.tsx +22 -0
  42. package/src/components/logs/StructuredLogLine.tsx +8 -3
  43. package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
  44. package/src/components/resources/KueueAdmissionSection.tsx +51 -12
  45. package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
  46. package/src/components/resources/KueuePendingPosition.tsx +91 -0
  47. package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
  48. package/src/components/resources/ResourcesSidebar.tsx +3 -2
  49. package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
  50. package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
  51. package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
  52. package/src/components/resources/ResourcesView.tsx +50 -3
  53. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  54. package/src/components/resources/generic-status.ts +7 -0
  55. package/src/components/resources/get-pod-phase-display.test.ts +70 -0
  56. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
  57. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
  58. package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
  59. package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
  60. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
  61. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
  62. package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
  63. package/src/components/resources/renderers/JobRenderer.tsx +7 -2
  64. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
  65. package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
  66. package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
  67. package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
  68. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
  69. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
  70. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
  71. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
  72. package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
  73. package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
  74. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
  75. package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
  76. package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
  77. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
  78. package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
  79. package/src/components/resources/renderers/crd-cells.tsx +55 -0
  80. package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
  81. package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
  82. package/src/components/resources/renderers/index.ts +6 -0
  83. package/src/components/resources/renderers/kueue-cells.tsx +5 -0
  84. package/src/components/resources/resource-utils-argo.test.ts +94 -1
  85. package/src/components/resources/resource-utils-argo.ts +84 -5
  86. package/src/components/resources/resource-utils-crd.test.ts +32 -0
  87. package/src/components/resources/resource-utils-crd.ts +26 -0
  88. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
  89. package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
  90. package/src/components/resources/resource-utils-kueue.test.ts +15 -0
  91. package/src/components/resources/resource-utils-kueue.ts +137 -4
  92. package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
  93. package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
  94. package/src/components/resources/resource-utils-ray.test.ts +42 -1
  95. package/src/components/resources/resource-utils-ray.ts +38 -0
  96. package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
  97. package/src/components/resources/resource-utils-strimzi.ts +35 -0
  98. package/src/components/resources/resource-utils.ts +45 -1
  99. package/src/components/shared/ResourceActionsBar.tsx +83 -25
  100. package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
  101. package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
  102. package/src/components/topology/K8sResourceNode.test.ts +14 -0
  103. package/src/components/topology/K8sResourceNode.tsx +18 -1
  104. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  105. package/src/components/topology/TopologyGraph.tsx +96 -94
  106. package/src/components/topology/layout.ts +3 -1
  107. package/src/components/topology/pod-group-expansion.test.ts +38 -0
  108. package/src/components/ui/Badge.tsx +1 -0
  109. package/src/components/ui/RadarUpgradeNote.tsx +8 -0
  110. package/src/components/ui/drawer-components.tsx +1 -1
  111. package/src/components/ui/index.ts +1 -0
  112. package/src/components/workload/PodList.tsx +35 -9
  113. package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
  114. package/src/components/workload/WorkloadView.tsx +61 -8
  115. package/src/components/workload/workload-logs-availability.test.ts +7 -0
  116. package/src/types/core.ts +40 -0
  117. package/src/types/gitops-insights.ts +11 -2
  118. package/src/types/gitops-tree.ts +2 -2
  119. package/src/types/kueue-queues.ts +84 -0
  120. package/src/types/read-coverage.ts +2 -1
  121. package/src/types/scheduling.ts +72 -0
  122. package/src/utils/animation.test.ts +38 -0
  123. package/src/utils/animation.ts +34 -0
  124. package/src/utils/api-resources-crd.test.ts +11 -0
  125. package/src/utils/api-resources.ts +5 -1
  126. package/src/utils/applications.test.ts +15 -1
  127. package/src/utils/applications.ts +26 -1
  128. package/src/utils/crd-schema.test.ts +107 -0
  129. package/src/utils/crd-schema.ts +201 -0
  130. package/src/utils/format.test.ts +37 -1
  131. package/src/utils/format.ts +28 -80
  132. package/src/utils/gitops-owner.test.ts +28 -2
  133. package/src/utils/gitops-owner.ts +19 -0
  134. package/src/utils/kueue-queue-workloads.ts +19 -0
  135. package/src/utils/resource-icons.ts +2 -0
  136. package/src/utils/topology-neighborhood.test.ts +90 -1
  137. package/src/utils/topology-neighborhood.ts +55 -3
  138. package/src/utils/workload-history-notes.test.ts +76 -0
  139. package/src/utils/workload-history-notes.ts +93 -0
@@ -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
+ })
@@ -46,32 +46,14 @@ export function formatCPUNanocores(nanocores: number): string {
46
46
 
47
47
  /**
48
48
  * Parse a Kubernetes CPU string (e.g., "500m", "2", "100n") to nanocores.
49
+ * metrics-server writes each value in its shortest exact form, so the same
50
+ * reading can arrive as "98753000n" or "98753u".
49
51
  *
50
- * @param cpuString - K8s CPU string like "500m", "2", "100n"
52
+ * @param cpuString - K8s CPU string like "500m", "2", "100n", "98753u"
51
53
  * @returns CPU in nanocores
52
54
  */
53
55
  export function parseCPUToNanocores(cpuString: string): number {
54
- if (!cpuString) return 0
55
-
56
- const str = cpuString.trim()
57
-
58
- // Nanocores suffix
59
- if (str.endsWith('n')) {
60
- return parseInt(str.slice(0, -1), 10) || 0
61
- }
62
-
63
- // Millicores suffix
64
- if (str.endsWith('m')) {
65
- return (parseInt(str.slice(0, -1), 10) || 0) * 1_000_000
66
- }
67
-
68
- // Plain number = cores
69
- const cores = parseFloat(str)
70
- if (!isNaN(cores)) {
71
- return cores * 1_000_000_000
72
- }
73
-
74
- return 0
56
+ return Math.round(parseQuantityToNumber(cpuString) * 1e9)
75
57
  }
76
58
 
77
59
  /**
@@ -117,55 +99,7 @@ export function formatMemoryBytes(bytes: number): string {
117
99
  * @returns Memory in bytes
118
100
  */
119
101
  export function parseMemoryToBytes(memString: string): number {
120
- if (!memString) return 0
121
-
122
- const str = memString.trim()
123
- // Decimal-exponent form ("1e9") is decided before the suffix table, as in
124
- // parseQuantityToNumber, so "1E3" reads as 1000 and not as exa.
125
- if (/^[+-]?\d+(?:\.\d+)?[eE][+-]?\d+$/.test(str)) return Number(str)
126
-
127
- const match = str.match(/^(-?\d+(?:\.\d+)?)\s*([A-Za-z]*)$/)
128
- if (!match) return 0
129
-
130
- const num = parseFloat(match[1])
131
- const suffix = match[2]
132
-
133
- // Binary suffixes (powers of 1024). Pi/Ei are real inputs at cluster scale —
134
- // an aggregate of a thousand 1TiB nodes serializes as "1Pi".
135
- const binarySuffixes: Record<string, number> = {
136
- 'Ki': 1024,
137
- 'Mi': 1024 ** 2,
138
- 'Gi': 1024 ** 3,
139
- 'Ti': 1024 ** 4,
140
- 'Pi': 1024 ** 5,
141
- 'Ei': 1024 ** 6,
142
- }
143
-
144
- // Decimal suffixes (powers of 1000). The sub-unit ones are real inputs too:
145
- // a request written as "1288490188800m" is valid, and a sum that includes one
146
- // keeps the milli scale, so a whole cluster's total serializes with "m".
147
- const decimalSuffixes: Record<string, number> = {
148
- 'n': 1000 ** -3,
149
- 'u': 1000 ** -2,
150
- 'm': 1000 ** -1,
151
- 'k': 1000,
152
- 'K': 1000,
153
- 'M': 1000 ** 2,
154
- 'G': 1000 ** 3,
155
- 'T': 1000 ** 4,
156
- 'P': 1000 ** 5,
157
- 'E': 1000 ** 6,
158
- }
159
-
160
- if (suffix in binarySuffixes) {
161
- return num * binarySuffixes[suffix]
162
- }
163
- if (suffix in decimalSuffixes) {
164
- return num * decimalSuffixes[suffix]
165
- }
166
-
167
- // No suffix = bytes
168
- return num
102
+ return parseQuantityToNumber(memString)
169
103
  }
170
104
 
171
105
  /**
@@ -224,32 +158,46 @@ const QUANTITY_SUFFIXES: Record<string, number> = {
224
158
  * @returns The value as a number, or 0 when it cannot be parsed
225
159
  */
226
160
  export function parseQuantityToNumber(quantity: string | number | null | undefined): number {
227
- if (typeof quantity === 'number') return Number.isFinite(quantity) ? quantity : 0
228
- if (!quantity) return 0
161
+ return parseQuantity(quantity) ?? 0
162
+ }
163
+
164
+ // A Quantity's number: optional sign, then digits with an optional fraction,
165
+ // where either side of the point may be empty ("1.", ".5") but not both.
166
+ const QUANTITY_NUMBER = String.raw`[+-]?(?:\d+(?:\.\d*)?|\.\d+)`
167
+ const QUANTITY_EXPONENT_RE = new RegExp(`^(${QUANTITY_NUMBER})[eE]([+-]?\\d+)$`)
168
+ const QUANTITY_SUFFIX_RE = new RegExp(`^(${QUANTITY_NUMBER})\\s*([A-Za-z]*)$`)
169
+
170
+ /**
171
+ * Like parseQuantityToNumber, but null when the input is missing or cannot be
172
+ * parsed, for callers that must tell garbage apart from a real 0.
173
+ */
174
+ export function parseQuantity(quantity: string | number | null | undefined): number | null {
175
+ if (typeof quantity === 'number') return Number.isFinite(quantity) ? quantity : null
176
+ if (quantity == null) return null
229
177
 
230
178
  const str = String(quantity).trim()
231
- if (!str) return 0
179
+ if (!str) return null
232
180
 
233
181
  // Decimal-exponent form. A Quantity's suffix is either an SI suffix or an
234
182
  // exponent, never both, so this is decided before the suffix table — which
235
183
  // also keeps "1E3" (1000) from being read as exa (1e18).
236
- const exponent = str.match(/^([+-]?\d+(?:\.\d+)?)[eE]([+-]?\d+)$/)
184
+ const exponent = str.match(QUANTITY_EXPONENT_RE)
237
185
  if (exponent) {
238
186
  const value = Number(`${exponent[1]}e${exponent[2]}`)
239
- return Number.isFinite(value) ? value : 0
187
+ return Number.isFinite(value) ? value : null
240
188
  }
241
189
 
242
- const match = str.match(/^([+-]?\d+(?:\.\d+)?)\s*([A-Za-z]*)$/)
243
- if (!match) return 0
190
+ const match = str.match(QUANTITY_SUFFIX_RE)
191
+ if (!match) return null
244
192
 
245
193
  const num = parseFloat(match[1])
246
- if (!Number.isFinite(num)) return 0
194
+ if (!Number.isFinite(num)) return null
247
195
 
248
196
  const suffix = match[2]
249
197
  if (!suffix) return num
250
198
 
251
199
  const multiplier = QUANTITY_SUFFIXES[suffix]
252
- return multiplier === undefined ? 0 : num * multiplier
200
+ return multiplier === undefined ? null : num * multiplier
253
201
  }
254
202
 
255
203
  // =============================================================================