@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,148 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Badge } from '../ui/Badge'
|
|
3
|
+
import { ResourceLink } from '../ui/drawer-components'
|
|
4
|
+
import { TimeValue } from '../ui/ScheduleValue'
|
|
5
|
+
import { FactGrid, FactRow, FactValue, type Fact } from '../facts'
|
|
6
|
+
import type { StatusBadge } from '../resources/resource-utils'
|
|
7
|
+
import { getAdmissionCheckStatus, getClusterQueueStatus, getKueueQueueCondition, isKueueConditionStale } from '../resources/resource-utils-kueue'
|
|
8
|
+
import { describeKueuePolicy } from './policy'
|
|
9
|
+
import { isDenied, unreadReason, type RelatedRead } from './related-read'
|
|
10
|
+
import { namespaceEligibility } from './eligibility'
|
|
11
|
+
import { KUEUE_GROUP } from './constants'
|
|
12
|
+
|
|
13
|
+
export type Navigate = (ref: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
14
|
+
|
|
15
|
+
/** One related object's name with what Radar's read of it found. Without a read, the name alone. */
|
|
16
|
+
export function ReadState({ read, found }: { read?: RelatedRead; found?: ReactNode }) {
|
|
17
|
+
if (!read) return null
|
|
18
|
+
if (read.state === 'found') return <>{found ?? <Badge severity="neutral" size="sm">Found</Badge>}</>
|
|
19
|
+
if (read.state === 'not_found') return <Badge severity="error" size="sm">Not found</Badge>
|
|
20
|
+
if (read.state === 'unconfirmed') return <span className="text-xs text-theme-text-tertiary">This Radar can’t confirm whether it exists</span>
|
|
21
|
+
return <span className={isDenied(read) ? 'text-xs text-theme-text-secondary' : 'text-xs text-theme-text-tertiary'}>{read.state === 'loading' ? 'Checking…' : `Not read: ${unreadReason(read)}`}</span>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function StatusPill({ status }: { status: StatusBadge }) {
|
|
25
|
+
return <Badge colorClass={status.color} size="sm">{status.text}</Badge>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ReportedActive({ data, status, children }: { data: any; status: StatusBadge; children?: ReactNode }) {
|
|
29
|
+
const condition = getKueueQueueCondition(data)
|
|
30
|
+
const generation = data?.metadata?.generation
|
|
31
|
+
if (!condition) {
|
|
32
|
+
return <div className="space-y-2 text-sm">
|
|
33
|
+
<div className="flex flex-wrap items-center gap-2"><StatusPill status={status} /></div>
|
|
34
|
+
<p className="text-theme-text-secondary">No Active condition reported.</p>
|
|
35
|
+
{children}
|
|
36
|
+
</div>
|
|
37
|
+
}
|
|
38
|
+
return <div className="space-y-2 text-sm">
|
|
39
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
40
|
+
<StatusPill status={status} />
|
|
41
|
+
{condition.reason && !status.text.startsWith(condition.reason) && <code className="break-all text-xs text-theme-text-secondary">{condition.reason}</code>}
|
|
42
|
+
</div>
|
|
43
|
+
{condition.message && <p className="whitespace-pre-wrap break-words text-theme-text-primary">{condition.message}</p>}
|
|
44
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
45
|
+
Active={condition.status}
|
|
46
|
+
{condition.lastTransitionTime && <> since <TimeValue timestamp={condition.lastTransitionTime} /></>}
|
|
47
|
+
{' · '}
|
|
48
|
+
{condition.observedGeneration ? `observed generation ${condition.observedGeneration} of ${generation ?? '?'}` : 'observed generation not reported'}
|
|
49
|
+
</p>
|
|
50
|
+
{children}
|
|
51
|
+
</div>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function count(value: unknown, source: string): Fact {
|
|
55
|
+
return typeof value === 'number'
|
|
56
|
+
? { text: String(value), tone: 'neutral', source }
|
|
57
|
+
: { text: 'Not reported', tone: 'unknown', source }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Controller-reported Workload counts. Reserving includes admitted, so they never add up. */
|
|
61
|
+
export function QueueCounts({ data, kind }: { data: any; kind: 'ClusterQueue' | 'LocalQueue' }) {
|
|
62
|
+
const source = `Reported in the ${kind}'s status`
|
|
63
|
+
return <div className="space-y-2">
|
|
64
|
+
<FactGrid>
|
|
65
|
+
<FactRow label="Pending"><FactValue fact={count(data?.status?.pendingWorkloads, source)} /></FactRow>
|
|
66
|
+
<FactRow label="Reserving quota"><FactValue fact={count(data?.status?.reservingWorkloads, source)} /></FactRow>
|
|
67
|
+
<FactRow label="Admitted"><FactValue fact={count(data?.status?.admittedWorkloads, source)} /></FactRow>
|
|
68
|
+
</FactGrid>
|
|
69
|
+
<p className="text-xs text-theme-text-tertiary">Reserving includes admitted Workloads, so these counts are not additive. Admission does not establish that Pods are running.</p>
|
|
70
|
+
</div>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function ConfiguredStop({ data }: { data: any }) {
|
|
74
|
+
const raw = data?.spec?.stopPolicy
|
|
75
|
+
const policy = describeKueuePolicy(data?.apiVersion, 'stopPolicy', raw ?? 'None')
|
|
76
|
+
return <div className="space-y-1 text-sm">
|
|
77
|
+
<div className="flex flex-wrap items-baseline gap-2">
|
|
78
|
+
<span className="text-theme-text-tertiary">Stop policy</span>
|
|
79
|
+
<code className="text-theme-text-primary">{policy.raw}</code>
|
|
80
|
+
{raw == null && <span className="text-xs text-theme-text-tertiary">(not set)</span>}
|
|
81
|
+
</div>
|
|
82
|
+
<p className="text-theme-text-secondary">{policy.meaning ?? `Radar has no description of this value for ${data?.apiVersion ?? 'this API version'}.`}</p>
|
|
83
|
+
</div>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ReportedMessage({ condition, generation }: { condition: any; generation?: number }) {
|
|
87
|
+
return <p className="whitespace-pre-wrap break-words text-xs text-theme-text-secondary">
|
|
88
|
+
{isKueueConditionStale(condition, generation) && <span className="text-theme-text-tertiary">Reported for generation {condition.observedGeneration} (now {generation}): </span>}
|
|
89
|
+
{condition.message}
|
|
90
|
+
</p>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function ClusterQueueReadSummary({ name, read, onNavigate }: { name: string; read?: RelatedRead; onNavigate?: Navigate }) {
|
|
94
|
+
const parent = read?.state === 'found' ? read.object : undefined
|
|
95
|
+
const condition = parent ? getKueueQueueCondition(parent) : undefined
|
|
96
|
+
return <div className="min-w-0 space-y-1 text-sm">
|
|
97
|
+
<div className="flex flex-wrap items-center gap-2 break-all [&_button]:text-left">
|
|
98
|
+
<ResourceLink name={name} kind="clusterqueues" group={KUEUE_GROUP} onNavigate={onNavigate} />
|
|
99
|
+
<ReadState read={read} found={parent ? <StatusPill status={getClusterQueueStatus(parent)} /> : undefined} />
|
|
100
|
+
</div>
|
|
101
|
+
{parent && <p className="text-xs text-theme-text-secondary">
|
|
102
|
+
Read from the ClusterQueue{condition?.reason && condition.status !== 'True' ? `: ${condition.reason}${isKueueConditionStale(condition, parent.metadata?.generation) ? ' (stale)' : ''}` : ''}{parent.spec?.stopPolicy && parent.spec.stopPolicy !== 'None' ? ` · stop policy ${parent.spec.stopPolicy}` : ''}
|
|
103
|
+
</p>}
|
|
104
|
+
{parent && condition?.message && condition.status !== 'True' && <ReportedMessage condition={condition} generation={parent.metadata?.generation} />}
|
|
105
|
+
</div>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether a namespace matches a ClusterQueue's namespaceSelector. The
|
|
110
|
+
* selector is only known from a ClusterQueue that was read; the namespace's
|
|
111
|
+
* labels are needed only when the selector has terms.
|
|
112
|
+
*/
|
|
113
|
+
export function namespaceEligibilityFact({ clusterQueue, parent, namespaceRead }: { clusterQueue: string; parent?: RelatedRead; namespaceRead?: RelatedRead }): Fact {
|
|
114
|
+
const source = `ClusterQueue ${clusterQueue} namespaceSelector`
|
|
115
|
+
if (parent?.state !== 'found') {
|
|
116
|
+
const why = !parent ? 'Unknown: the ClusterQueue was not read'
|
|
117
|
+
: parent.state === 'loading' ? 'Checking the ClusterQueue…'
|
|
118
|
+
: parent.state === 'not_found' ? 'Unknown: the ClusterQueue was not found'
|
|
119
|
+
: `Unknown: ClusterQueue not read (${unreadReason(parent)})`
|
|
120
|
+
return { text: why, tone: 'unknown', source }
|
|
121
|
+
}
|
|
122
|
+
const selector = parent.object?.spec?.namespaceSelector
|
|
123
|
+
const labels = namespaceRead?.state === 'found' ? (namespaceRead.object?.metadata?.labels ?? {}) : undefined
|
|
124
|
+
const result = namespaceEligibility(selector, labels)
|
|
125
|
+
switch (result.state) {
|
|
126
|
+
case 'eligible':
|
|
127
|
+
return { text: result.basis === 'all' ? 'Eligible: the selector is empty ({}), so every namespace matches' : 'Eligible: its labels match the selector', tone: 'healthy', source }
|
|
128
|
+
case 'not_eligible':
|
|
129
|
+
return { text: result.basis === 'none' ? 'Not eligible: the ClusterQueue sets no namespaceSelector, so no namespace matches' : 'Not eligible: its labels don’t match the selector', tone: 'unhealthy', source }
|
|
130
|
+
default:
|
|
131
|
+
if (result.why === 'unsupported_operator') return { text: `Unknown: the selector uses operator ${result.operator || '(none)'}, which Radar doesn't evaluate`, tone: 'unknown', source }
|
|
132
|
+
if (namespaceRead?.state === 'loading') return { text: 'Checking the namespace…', tone: 'unknown', source }
|
|
133
|
+
return { text: namespaceRead?.state === 'not_found' ? 'Unknown: the Namespace was not found' : `Unknown: its labels weren’t read (${unreadReason(namespaceRead)})`, tone: 'unknown', source }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function AdmissionCheckRow({ name, onFlavors, read, onNavigate }: { name: string; onFlavors?: string[]; read?: RelatedRead; onNavigate?: Navigate }) {
|
|
138
|
+
const check = read?.state === 'found' ? read.object : undefined
|
|
139
|
+
const condition = check ? getKueueQueueCondition(check) : undefined
|
|
140
|
+
return <div className="min-w-0 space-y-1 text-sm">
|
|
141
|
+
<div className="flex flex-wrap items-center gap-2 break-all [&_button]:text-left">
|
|
142
|
+
<ResourceLink name={name} kind="admissionchecks" group={KUEUE_GROUP} onNavigate={onNavigate} />
|
|
143
|
+
<ReadState read={read} found={check ? <StatusPill status={getAdmissionCheckStatus(check)} /> : undefined} />
|
|
144
|
+
</div>
|
|
145
|
+
<p className="break-words text-xs text-theme-text-secondary">{onFlavors?.length ? `Applies to flavors: ${onFlavors.join(', ')}` : 'Applies to all flavors'}</p>
|
|
146
|
+
{condition?.message && condition.status !== 'True' && <ReportedMessage condition={condition} generation={check.metadata?.generation} />}
|
|
147
|
+
</div>
|
|
148
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const KUEUE_GROUP = 'kueue.x-k8s.io'
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Whether a namespace may submit Workloads to a ClusterQueue, from the
|
|
2
|
+
// ClusterQueue's namespaceSelector. Kueue reads a null selector as "no
|
|
3
|
+
// namespace" and an empty one ({}) as "every namespace", so only a selector
|
|
4
|
+
// with terms needs the namespace's labels.
|
|
5
|
+
|
|
6
|
+
export type NamespaceEligibility =
|
|
7
|
+
| { state: 'eligible'; basis: 'all' | 'labels' }
|
|
8
|
+
| { state: 'not_eligible'; basis: 'none' | 'labels' }
|
|
9
|
+
/** The labels are needed and weren't read, or the selector can't be evaluated. */
|
|
10
|
+
| { state: 'unknown'; why: 'labels_unread' | 'unsupported_operator'; operator?: string }
|
|
11
|
+
|
|
12
|
+
interface Requirement { key?: string; operator?: string; values?: string[] }
|
|
13
|
+
export interface LabelSelector { matchLabels?: Record<string, string>; matchExpressions?: Requirement[] }
|
|
14
|
+
|
|
15
|
+
function selectorHasTerms(selector: LabelSelector | null | undefined): boolean {
|
|
16
|
+
return !!selector && (Object.keys(selector.matchLabels ?? {}).length > 0 || (selector.matchExpressions?.length ?? 0) > 0)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Evaluating the selector would use the namespace's labels (it has terms, all with supported operators). */
|
|
20
|
+
export function needsNamespaceLabels(selector: LabelSelector | null | undefined): boolean {
|
|
21
|
+
const result = namespaceEligibility(selector, undefined)
|
|
22
|
+
return result.state === 'unknown' && result.why === 'labels_unread'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** labels is undefined when the namespace's labels weren't read. */
|
|
26
|
+
export function namespaceEligibility(selector: LabelSelector | null | undefined, labels: Record<string, string> | undefined): NamespaceEligibility {
|
|
27
|
+
if (selector == null) return { state: 'not_eligible', basis: 'none' }
|
|
28
|
+
if (!selectorHasTerms(selector)) return { state: 'eligible', basis: 'all' }
|
|
29
|
+
const unsupported = (selector.matchExpressions ?? []).find(term => !['In', 'NotIn', 'Exists', 'DoesNotExist'].includes(term?.operator ?? ''))
|
|
30
|
+
if (unsupported) return { state: 'unknown', why: 'unsupported_operator', operator: String(unsupported?.operator ?? '') }
|
|
31
|
+
if (!labels) return { state: 'unknown', why: 'labels_unread' }
|
|
32
|
+
const has = (key: string) => Object.prototype.hasOwnProperty.call(labels, key)
|
|
33
|
+
const matches = Object.entries(selector.matchLabels ?? {}).every(([key, value]) => has(key) && labels[key] === value)
|
|
34
|
+
&& (selector.matchExpressions ?? []).every(({ key = '', operator, values = [] }) => {
|
|
35
|
+
switch (operator) {
|
|
36
|
+
case 'In': return has(key) && values.includes(labels[key])
|
|
37
|
+
case 'NotIn': return !has(key) || !values.includes(labels[key])
|
|
38
|
+
case 'Exists': return has(key)
|
|
39
|
+
default: return !has(key)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
return matches ? { state: 'eligible', basis: 'labels' } : { state: 'not_eligible', basis: 'labels' }
|
|
43
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Parses the flavor attempts out of a pending Workload's QuotaReserved
|
|
2
|
+
// message. Kueue builds it as "couldn't assign flavors to pod set <ps>: <reasons>"
|
|
3
|
+
// per failing pod set, joined by "; ", with each pod set's reasons sorted
|
|
4
|
+
// alphabetically and joined by ", " — so the order of the reasons is not the
|
|
5
|
+
// order flavors were tried. A message with any part this parser doesn't
|
|
6
|
+
// recognise yields null and is shown verbatim instead.
|
|
7
|
+
|
|
8
|
+
export type FlavorAttemptKind =
|
|
9
|
+
| 'insufficient_quota'
|
|
10
|
+
| 'insufficient_unused_quota'
|
|
11
|
+
| 'untolerated_taint'
|
|
12
|
+
| 'node_affinity'
|
|
13
|
+
| 'flavor_not_found'
|
|
14
|
+
| 'resource_unavailable'
|
|
15
|
+
| 'skipped_by_annotation'
|
|
16
|
+
| 'pinned_by_original'
|
|
17
|
+
|
|
18
|
+
export interface FlavorAttempt {
|
|
19
|
+
kind: FlavorAttemptKind
|
|
20
|
+
flavor?: string
|
|
21
|
+
resource?: string
|
|
22
|
+
/** The controller's own words for this reason. */
|
|
23
|
+
text: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PodSetAttempts {
|
|
27
|
+
podSet: string
|
|
28
|
+
attempts: FlavorAttempt[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const podSetPrefix = "couldn't assign flavors to pod set "
|
|
32
|
+
|
|
33
|
+
/** The message is Kueue's per-flavor account of why quota wasn't assigned (parsed or not). Its error form isn't. */
|
|
34
|
+
export function isFlavorAssignmentMessage(message: string | undefined): boolean {
|
|
35
|
+
return !!message && message.startsWith(podSetPrefix)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const patterns: { kind: FlavorAttemptKind; re: RegExp; flavor?: number; resource?: number }[] = [
|
|
39
|
+
{ kind: 'insufficient_unused_quota', re: /^insufficient unused quota for (\S+) in flavor (\S+), \S+ more needed$/, resource: 1, flavor: 2 },
|
|
40
|
+
{ kind: 'insufficient_quota', re: /^insufficient quota for (\S+) in flavor (\S+), (?:previously considered podsets requests \(\S+\) \+ current podset request \(\S+\) > maximum capacity \(\S+\)|request > maximum capacity \(\S+ > \S+\))$/, resource: 1, flavor: 2 },
|
|
41
|
+
{ kind: 'untolerated_taint', re: /^untolerated taint .+ in flavor (\S+)$/, flavor: 1 },
|
|
42
|
+
{ kind: 'node_affinity', re: /^flavor (\S+) doesn't match node affinity$/, flavor: 1 },
|
|
43
|
+
{ kind: 'flavor_not_found', re: /^flavor (\S+) not found$/, flavor: 1 },
|
|
44
|
+
{ kind: 'resource_unavailable', re: /^resource (\S+) unavailable in ClusterQueue$/, resource: 1 },
|
|
45
|
+
{ kind: 'skipped_by_annotation', re: /^skipping flavor (\S+) due to WorkloadAllowedResourceFlavorAnnotation annotation$/, flavor: 1 },
|
|
46
|
+
// Names the candidate flavor, then the flavor the original Workload holds.
|
|
47
|
+
{ kind: 'pinned_by_original', re: /^could not assign (\S+) flavor since the original workload is assigned: \S+$/, flavor: 1 },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
// A reason begins only at one of these; ", " inside a reason ("…, 2 more needed")
|
|
51
|
+
// is not a boundary.
|
|
52
|
+
const reasonStart = /, (?=insufficient (?:unused )?quota for |untolerated taint |flavor \S+ (?:doesn't match node affinity|not found)|resource \S+ unavailable in ClusterQueue|skipping flavor |could not assign )/
|
|
53
|
+
|
|
54
|
+
function parseReason(text: string): FlavorAttempt | null {
|
|
55
|
+
for (const { kind, re, flavor, resource } of patterns) {
|
|
56
|
+
const match = re.exec(text)
|
|
57
|
+
if (match) return { kind, text, ...(flavor ? { flavor: match[flavor] } : {}), ...(resource ? { resource: match[resource] } : {}) }
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseFlavorAttempts(message: string | undefined): PodSetAttempts[] | null {
|
|
63
|
+
if (!message?.startsWith(podSetPrefix)) return null
|
|
64
|
+
const result: PodSetAttempts[] = []
|
|
65
|
+
for (const part of message.slice(podSetPrefix.length).split(`; ${podSetPrefix}`)) {
|
|
66
|
+
const separator = part.indexOf(': ')
|
|
67
|
+
if (separator <= 0) return null
|
|
68
|
+
const podSet = part.slice(0, separator)
|
|
69
|
+
const reasons = part.slice(separator + 2).split(reasonStart)
|
|
70
|
+
const attempts: FlavorAttempt[] = []
|
|
71
|
+
for (const reason of reasons) {
|
|
72
|
+
const attempt = parseReason(reason)
|
|
73
|
+
if (!attempt) return null
|
|
74
|
+
attempts.push(attempt)
|
|
75
|
+
}
|
|
76
|
+
result.push({ podSet, attempts })
|
|
77
|
+
}
|
|
78
|
+
return result
|
|
79
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { parseFlavorAttempts } from './flavors-tried'
|
|
3
|
+
import { namespaceEligibility } from './eligibility'
|
|
4
|
+
import { describeKueuePolicy } from './policy'
|
|
5
|
+
import { isDenied, unreadReason } from './related-read'
|
|
6
|
+
|
|
7
|
+
describe('parseFlavorAttempts', () => {
|
|
8
|
+
it('parses the Kueue v0.18 message seen on a live 3-CPU Workload into both attempts', () => {
|
|
9
|
+
const message = "couldn't assign flavors to pod set main: insufficient quota for cpu in flavor gpu-night-813-small, previously considered podsets requests (0) + current podset request (3) > maximum capacity (1), insufficient quota for cpu in flavor gpu-night-813-spot, previously considered podsets requests (0) + current podset request (3) > maximum capacity (1)"
|
|
10
|
+
expect(parseFlavorAttempts(message)).toEqual([{ podSet: 'main', attempts: [
|
|
11
|
+
{ kind: 'insufficient_quota', resource: 'cpu', flavor: 'gpu-night-813-small', text: 'insufficient quota for cpu in flavor gpu-night-813-small, previously considered podsets requests (0) + current podset request (3) > maximum capacity (1)' },
|
|
12
|
+
{ kind: 'insufficient_quota', resource: 'cpu', flavor: 'gpu-night-813-spot', text: 'insufficient quota for cpu in flavor gpu-night-813-spot, previously considered podsets requests (0) + current podset request (3) > maximum capacity (1)' },
|
|
13
|
+
] }])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('splits pod sets and every reason format, keeping commas inside a reason', () => {
|
|
17
|
+
const message = "couldn't assign flavors to pod set driver: flavor a doesn't match node affinity, insufficient unused quota for nvidia.com/gpu in flavor b, 2 more needed, untolerated taint gpu=true:NoSchedule in flavor c; couldn't assign flavors to pod set workers: flavor z not found, resource example.com/fpga unavailable in ClusterQueue, skipping flavor y due to WorkloadAllowedResourceFlavorAnnotation annotation, could not assign x flavor since the original workload is assigned: w"
|
|
18
|
+
const parsed = parseFlavorAttempts(message)
|
|
19
|
+
expect(parsed?.map(p => p.podSet)).toEqual(['driver', 'workers'])
|
|
20
|
+
expect(parsed?.[0].attempts.map(a => [a.kind, a.flavor, a.resource])).toEqual([
|
|
21
|
+
['node_affinity', 'a', undefined], ['insufficient_unused_quota', 'b', 'nvidia.com/gpu'], ['untolerated_taint', 'c', undefined],
|
|
22
|
+
])
|
|
23
|
+
expect(parsed?.[1].attempts.map(a => [a.kind, a.flavor, a.resource])).toEqual([
|
|
24
|
+
['flavor_not_found', 'z', undefined], ['resource_unavailable', undefined, 'example.com/fpga'], ['skipped_by_annotation', 'y', undefined], ['pinned_by_original', 'x', undefined],
|
|
25
|
+
])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('returns null for anything it cannot account for, so the caller shows the message verbatim', () => {
|
|
29
|
+
for (const message of [
|
|
30
|
+
undefined,
|
|
31
|
+
'',
|
|
32
|
+
"LocalQueue no-such-localqueue doesn't exist",
|
|
33
|
+
"Workload namespace doesn't match ClusterQueue selector",
|
|
34
|
+
'failed to assign flavors to pod set main: boom',
|
|
35
|
+
"couldn't assign flavors to pod set main: topology \"rack\" doesn't allow to fit any of 3 pod(s)",
|
|
36
|
+
"couldn't assign flavors to pod set main: insufficient quota for cpu in flavor a, request > maximum capacity (24 > 8), something new",
|
|
37
|
+
"couldn't assign flavors to pod set main",
|
|
38
|
+
]) expect(parseFlavorAttempts(message)).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('accepts an older quota detail shape as long as the reason prefix is known', () => {
|
|
42
|
+
expect(parseFlavorAttempts("couldn't assign flavors to pod set main: insufficient quota for cpu in flavor default, request > maximum capacity (24 > 8)")?.[0].attempts[0].flavor).toBe('default')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('namespaceEligibility', () => {
|
|
47
|
+
it('reads null as no namespace and {} as every namespace without needing labels', () => {
|
|
48
|
+
expect(namespaceEligibility(null, undefined)).toEqual({ state: 'not_eligible', basis: 'none' })
|
|
49
|
+
expect(namespaceEligibility(undefined, undefined)).toEqual({ state: 'not_eligible', basis: 'none' })
|
|
50
|
+
expect(namespaceEligibility({}, undefined)).toEqual({ state: 'eligible', basis: 'all' })
|
|
51
|
+
expect(namespaceEligibility({ matchLabels: {}, matchExpressions: [] }, undefined)).toEqual({ state: 'eligible', basis: 'all' })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('needs labels for a selector with terms, and says so when they were not read', () => {
|
|
55
|
+
expect(namespaceEligibility({ matchLabels: { team: 'a' } }, undefined)).toEqual({ state: 'unknown', why: 'labels_unread' })
|
|
56
|
+
expect(namespaceEligibility({ matchLabels: { team: 'a' } }, { team: 'a' }).state).toBe('eligible')
|
|
57
|
+
expect(namespaceEligibility({ matchLabels: { team: 'a' } }, { team: 'b' }).state).toBe('not_eligible')
|
|
58
|
+
expect(namespaceEligibility({ matchLabels: { team: 'a' } }, {}).state).toBe('not_eligible')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('evaluates every selector operator as Kubernetes does', () => {
|
|
62
|
+
const sel = (operator: string, values?: string[]) => ({ matchExpressions: [{ key: 'tier', operator, values }] })
|
|
63
|
+
expect(namespaceEligibility(sel('In', ['gpu']), { tier: 'gpu' }).state).toBe('eligible')
|
|
64
|
+
expect(namespaceEligibility(sel('In', ['gpu']), {}).state).toBe('not_eligible')
|
|
65
|
+
expect(namespaceEligibility(sel('NotIn', ['gpu']), {}).state).toBe('eligible')
|
|
66
|
+
expect(namespaceEligibility(sel('NotIn', ['gpu']), { tier: 'gpu' }).state).toBe('not_eligible')
|
|
67
|
+
expect(namespaceEligibility(sel('Exists'), { tier: '' }).state).toBe('eligible')
|
|
68
|
+
expect(namespaceEligibility(sel('DoesNotExist'), { tier: '' }).state).toBe('not_eligible')
|
|
69
|
+
expect(namespaceEligibility(sel('Gt', ['1']), { tier: '2' })).toEqual({ state: 'unknown', why: 'unsupported_operator', operator: 'Gt' })
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('describeKueuePolicy', () => {
|
|
74
|
+
it('keeps a dictionary per API version and shows unknown values raw', () => {
|
|
75
|
+
expect(describeKueuePolicy('kueue.x-k8s.io/v1beta1', 'flavorFungibility.whenCanBorrow', 'Borrow').meaning).toContain('Equivalent to MayStopSearch in newer Kueue')
|
|
76
|
+
expect(describeKueuePolicy('kueue.x-k8s.io/v1beta2', 'flavorFungibility.whenCanBorrow', 'Borrow')).toEqual({ raw: 'Borrow' })
|
|
77
|
+
expect(describeKueuePolicy('kueue.x-k8s.io/v1beta2', 'admissionScope.admissionMode', 'NoAdmissionFairSharing').meaning).toBeTruthy()
|
|
78
|
+
expect(describeKueuePolicy('kueue.x-k8s.io/v1', 'stopPolicy', 'Hold')).toEqual({ raw: 'Hold' })
|
|
79
|
+
expect(describeKueuePolicy('kueue.x-k8s.io/v1beta2', 'stopPolicy', 'Hold').meaning).toContain('admitted Workloads run to completion')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('related reads', () => {
|
|
84
|
+
it('tells a denial apart from a read that failed for another reason', () => {
|
|
85
|
+
expect(isDenied({ state: 'unavailable', code: 'user_forbidden' })).toBe(true)
|
|
86
|
+
expect(isDenied({ state: 'unavailable', code: 'kind_sync_timed_out' })).toBe(false)
|
|
87
|
+
expect(isDenied({ state: 'not_found' })).toBe(false)
|
|
88
|
+
expect(unreadReason({ state: 'unavailable', code: 'user_forbidden' })).toBe('you can’t read it')
|
|
89
|
+
expect(unreadReason({ state: 'unavailable', code: 'radar_forbidden' })).toBe('Radar can’t read it')
|
|
90
|
+
expect(unreadReason({ state: 'unavailable', code: 'rbac_denied' })).toBe('you can’t read it')
|
|
91
|
+
expect(unreadReason({ state: 'loading' })).toBe('checking…')
|
|
92
|
+
expect(unreadReason({ state: 'unavailable' })).toBe('the read failed')
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('policy dictionaries against the served CRDs', () => {
|
|
97
|
+
it('describe every enum value Kueue v0.18 serves, per version', async () => {
|
|
98
|
+
const { enums } = (await import('./testdata/kueue-v0.18-policy-enums.json')).default as { enums: Record<string, Record<string, string[]>> }
|
|
99
|
+
for (const [version, fields] of Object.entries(enums)) {
|
|
100
|
+
for (const [field, values] of Object.entries(fields)) {
|
|
101
|
+
for (const value of values) expect(describeKueuePolicy(version, field as any, value).meaning, `${version} ${field}=${value}`).toBeTruthy()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Plain-language meanings for Kueue queue policy values, one dictionary per
|
|
2
|
+
// served API version. Wording follows the CRD field descriptions; a value a
|
|
3
|
+
// dictionary doesn't list is shown raw, never mapped to a nearby meaning.
|
|
4
|
+
|
|
5
|
+
export type KueuePolicyField =
|
|
6
|
+
| 'stopPolicy'
|
|
7
|
+
| 'queueingStrategy'
|
|
8
|
+
| 'preemption.withinClusterQueue'
|
|
9
|
+
| 'preemption.reclaimWithinCohort'
|
|
10
|
+
| 'preemption.borrowWithinCohort.policy'
|
|
11
|
+
| 'flavorFungibility.whenCanBorrow'
|
|
12
|
+
| 'flavorFungibility.whenCanPreempt'
|
|
13
|
+
| 'flavorFungibility.preference'
|
|
14
|
+
| 'admissionScope.admissionMode'
|
|
15
|
+
|
|
16
|
+
type Dictionary = Partial<Record<KueuePolicyField, Record<string, string>>>
|
|
17
|
+
|
|
18
|
+
const shared: Dictionary = {
|
|
19
|
+
stopPolicy: {
|
|
20
|
+
None: 'Not stopped by policy.',
|
|
21
|
+
Hold: 'Stopped: no new reservations; reservations are cancelled for Workloads not yet admitted, and admitted Workloads run to completion.',
|
|
22
|
+
HoldAndDrain: 'Stopped and draining: no new reservations; reservations are cancelled and admitted Workloads are evicted.',
|
|
23
|
+
},
|
|
24
|
+
queueingStrategy: {
|
|
25
|
+
StrictFIFO: 'By priority, then creation time; the Workload at the head that can’t be admitted blocks those behind it, even if they fit.',
|
|
26
|
+
BestEffortFIFO: 'By priority, then creation time; a Workload that can’t be admitted doesn’t block those behind it that fit.',
|
|
27
|
+
},
|
|
28
|
+
'preemption.withinClusterQueue': {
|
|
29
|
+
Never: 'A pending Workload never preempts Workloads in this ClusterQueue.',
|
|
30
|
+
LowerPriority: 'A pending Workload that doesn’t fit nominal quota may preempt lower-priority Workloads in this ClusterQueue.',
|
|
31
|
+
LowerOrNewerEqualPriority: 'A pending Workload may preempt Workloads in this ClusterQueue with lower priority, or equal priority and newer.',
|
|
32
|
+
},
|
|
33
|
+
'preemption.reclaimWithinCohort': {
|
|
34
|
+
Never: 'Never preempts Workloads of other ClusterQueues in the cohort.',
|
|
35
|
+
LowerPriority: 'May preempt lower-priority Workloads of cohort ClusterQueues that use more than their nominal quota.',
|
|
36
|
+
Any: 'May preempt Workloads of cohort ClusterQueues that use more than their nominal quota, regardless of priority.',
|
|
37
|
+
},
|
|
38
|
+
'preemption.borrowWithinCohort.policy': {
|
|
39
|
+
Never: 'A Workload that must borrow never preempts in other cohort ClusterQueues.',
|
|
40
|
+
LowerPriority: 'A Workload that must borrow may preempt lower-priority Workloads in other cohort ClusterQueues.',
|
|
41
|
+
},
|
|
42
|
+
'flavorFungibility.preference': {
|
|
43
|
+
BorrowingOverPreemption: 'When every flavor needs borrowing or preemption, prefer borrowing.',
|
|
44
|
+
PreemptionOverBorrowing: 'When every flavor needs borrowing or preemption, prefer preemption.',
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const admissionMode = {
|
|
49
|
+
UsageBasedAdmissionFairSharing: 'Admission fair sharing: Workloads are ordered by their LocalQueue’s recent resource usage.',
|
|
50
|
+
NoAdmissionFairSharing: 'Admission fair sharing is off for this ClusterQueue.',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const mayStopSearchBorrow = 'Stop at the first flavor where the Workload fits or fits by borrowing.'
|
|
54
|
+
const mayStopSearchPreempt = 'Stop at the first flavor where the Workload fits or fits by preempting.'
|
|
55
|
+
|
|
56
|
+
const v1beta1: Dictionary = {
|
|
57
|
+
...shared,
|
|
58
|
+
'admissionScope.admissionMode': admissionMode,
|
|
59
|
+
'flavorFungibility.whenCanBorrow': {
|
|
60
|
+
MayStopSearch: mayStopSearchBorrow,
|
|
61
|
+
TryNextFlavor: 'Try the next flavor before borrowing in this one.',
|
|
62
|
+
Borrow: `${mayStopSearchBorrow} (Equivalent to MayStopSearch in newer Kueue.)`,
|
|
63
|
+
},
|
|
64
|
+
'flavorFungibility.whenCanPreempt': {
|
|
65
|
+
MayStopSearch: mayStopSearchPreempt,
|
|
66
|
+
TryNextFlavor: 'Try the next flavor before preempting in this one.',
|
|
67
|
+
Preempt: `${mayStopSearchPreempt} (Equivalent to MayStopSearch in newer Kueue.)`,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const v1beta2: Dictionary = {
|
|
72
|
+
...shared,
|
|
73
|
+
'flavorFungibility.whenCanBorrow': {
|
|
74
|
+
MayStopSearch: mayStopSearchBorrow,
|
|
75
|
+
TryNextFlavor: 'Try the next flavor before borrowing in this one.',
|
|
76
|
+
},
|
|
77
|
+
'flavorFungibility.whenCanPreempt': {
|
|
78
|
+
MayStopSearch: mayStopSearchPreempt,
|
|
79
|
+
TryNextFlavor: 'Try the next flavor before preempting in this one.',
|
|
80
|
+
},
|
|
81
|
+
'admissionScope.admissionMode': admissionMode,
|
|
82
|
+
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const dictionaries: Record<string, Dictionary> = {
|
|
86
|
+
'kueue.x-k8s.io/v1beta1': v1beta1,
|
|
87
|
+
'kueue.x-k8s.io/v1beta2': v1beta2,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface KueuePolicyValue {
|
|
91
|
+
raw: string
|
|
92
|
+
/** Absent when this Radar has no meaning for the value at this API version. */
|
|
93
|
+
meaning?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function describeKueuePolicy(apiVersion: string | undefined, field: KueuePolicyField, value: unknown): KueuePolicyValue {
|
|
97
|
+
const raw = String(value)
|
|
98
|
+
const meaning = dictionaries[apiVersion ?? '']?.[field]?.[raw]
|
|
99
|
+
return meaning ? { raw, meaning } : { raw }
|
|
100
|
+
}
|