@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.
- package/package.json +9 -2
- package/src/components/applications/ApplicationDetail.tsx +8 -2
- package/src/components/charts/AreaChart.test.tsx +39 -1
- package/src/components/charts/AreaChart.tsx +32 -13
- package/src/components/charts/axis.test.ts +17 -1
- package/src/components/charts/axis.ts +13 -0
- package/src/components/charts/format.test.ts +159 -2
- package/src/components/charts/format.ts +118 -0
- 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/ArgoResourceDiff.test.tsx +32 -0
- package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +94 -46
- package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
- 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 +65 -0
- package/src/components/gitops/insights/insights-helpers.ts +17 -3
- package/src/components/issues/IssueGuidance.test.tsx +190 -0
- package/src/components/issues/IssueGuidance.tsx +48 -0
- package/src/components/issues/IssuesView.tsx +15 -3
- package/src/components/issues/ResourceIssuesSection.tsx +3 -1
- package/src/components/issues/diagnostic.ts +14 -1
- package/src/components/issues/index.ts +1 -0
- package/src/components/issues/severity.ts +15 -0
- package/src/components/issues/types.ts +10 -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 +41 -0
- package/src/types/gitops-insights.ts +19 -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
package/src/utils/animation.ts
CHANGED
|
@@ -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
|
+
}
|
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
|
+
})
|