@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
|
@@ -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
|
+
}
|
package/src/utils/format.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/utils/format.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
|
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(
|
|
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 :
|
|
187
|
+
return Number.isFinite(value) ? value : null
|
|
240
188
|
}
|
|
241
189
|
|
|
242
|
-
const match = str.match(
|
|
243
|
-
if (!match) return
|
|
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
|
|
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 ?
|
|
200
|
+
return multiplier === undefined ? null : num * multiplier
|
|
253
201
|
}
|
|
254
202
|
|
|
255
203
|
// =============================================================================
|