@skyhook-io/k8s-ui 1.17.0 → 1.17.1
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/README.md +41 -0
- package/package.json +3 -3
- package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
- package/src/components/charts/AreaChart.test.tsx +25 -0
- package/src/components/charts/AreaChart.tsx +141 -12
- package/src/components/charts/index.ts +1 -0
- package/src/components/checks/ChecksView.tsx +18 -1
- package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
- package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
- package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
- package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
- package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
- package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
- package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
- package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
- package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
- package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
- package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
- package/src/components/cnpg/backupRuns.test.ts +17 -0
- package/src/components/cnpg/backupRuns.ts +19 -0
- package/src/components/cnpg/connect.test.ts +124 -0
- package/src/components/cnpg/connect.ts +155 -0
- package/src/components/cnpg/databaseRole.test.ts +38 -0
- package/src/components/cnpg/databaseRole.ts +80 -0
- package/src/components/cnpg/ha.test.ts +446 -0
- package/src/components/cnpg/ha.ts +621 -0
- package/src/components/cnpg/index.ts +18 -0
- package/src/components/cnpg/logicalReplication.test.ts +121 -0
- package/src/components/cnpg/logicalReplication.ts +252 -0
- package/src/components/cnpg/pooler.test.ts +84 -0
- package/src/components/cnpg/pooler.ts +225 -0
- package/src/components/cnpg/primitives.tsx +21 -117
- package/src/components/cnpg/problems.ts +12 -0
- package/src/components/cnpg/relations.test.ts +63 -13
- package/src/components/cnpg/relations.ts +61 -52
- package/src/components/cnpg/schedule.ts +35 -0
- package/src/components/cnpg/workspace-disk.test.ts +97 -0
- package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
- package/src/components/cnpg/workspace-problems.test.ts +175 -0
- package/src/components/cnpg/workspace.test.ts +380 -9
- package/src/components/cnpg/workspace.ts +1137 -136
- package/src/components/dock/DockContext.test.tsx +32 -0
- package/src/components/dock/DockContext.tsx +18 -4
- package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
- package/src/components/dock/LocalTerminalTab.tsx +4 -3
- package/src/components/dock/TerminalTab.tsx +20 -8
- package/src/components/facts/certainty.tsx +36 -0
- package/src/components/facts/facts.test.tsx +24 -0
- package/src/components/facts/facts.tsx +62 -0
- package/src/components/facts/index.ts +6 -0
- package/src/components/facts/managed-by.test.tsx +17 -0
- package/src/components/facts/managed-by.tsx +36 -0
- package/src/components/facts/public-exports.test.tsx +35 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
- package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
- package/src/components/gitops/insights/DriftPanel.tsx +62 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +24 -71
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
- package/src/components/gitops/tree/layout.test.ts +111 -0
- package/src/components/gitops/tree/layout.ts +128 -0
- package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
- package/src/components/gitops/tree/tree-helpers.ts +7 -1
- package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
- package/src/components/issues/IssuesView.tsx +40 -5
- package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
- package/src/components/issues/ResourceIssuesSection.tsx +3 -0
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +12 -1
- package/src/components/issues/issues.test.ts +49 -1
- package/src/components/issues/severity.ts +21 -0
- package/src/components/issues/types.ts +23 -0
- package/src/components/logs/LogCore.theme.test.tsx +81 -3
- package/src/components/logs/LogCore.tsx +25 -17
- package/src/components/logs/LogToolbarSelects.tsx +3 -1
- package/src/components/logs/LogsViewer.test.tsx +291 -0
- package/src/components/logs/LogsViewer.tsx +72 -11
- package/src/components/logs/StructuredLogLine.test.tsx +76 -0
- package/src/components/logs/StructuredLogLine.tsx +22 -30
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogStream.ts +17 -1
- package/src/components/problems/index.ts +1 -0
- package/src/components/problems/problems.test.tsx +63 -0
- package/src/components/problems/problems.tsx +165 -0
- package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
- package/src/components/resources/ActiveFilterBar.tsx +86 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
- package/src/components/resources/ResourcesSidebar.tsx +92 -41
- package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
- package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
- package/src/components/resources/ResourcesView.tsx +302 -109
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
- package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
- package/src/components/resources/renderers/HPARenderer.tsx +56 -16
- package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
- package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
- package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
- package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
- package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +41 -29
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
- package/src/components/resources/resource-utils-cnpg.ts +51 -20
- package/src/components/resources/resource-utils-contour.test.ts +44 -0
- package/src/components/resources/resource-utils-contour.ts +16 -0
- package/src/components/resources/resource-utils-istio.ts +12 -8
- package/src/components/resources/resource-utils-knative.ts +18 -0
- package/src/components/resources/resource-utils.ts +82 -6
- package/src/components/resources/rules-column-width.test.ts +1 -1
- package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
- package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
- package/src/components/shared/ActionConfirmDialog.tsx +246 -0
- package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
- package/src/components/shared/CreateResourceDialog.tsx +126 -80
- package/src/components/shared/DetailShell.test.tsx +80 -0
- package/src/components/shared/DetailShell.tsx +85 -10
- package/src/components/shared/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/ResourceActionsBar.tsx +11 -4
- package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
- package/src/components/shared/index.ts +2 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
- package/src/components/timeline/timeline-filters.ts +1 -0
- package/src/components/topology/K8sResourceNode.test.ts +18 -1
- package/src/components/topology/K8sResourceNode.tsx +44 -2
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +3 -71
- package/src/components/topology/TopologySearch.tsx +1 -0
- package/src/components/topology/internet-groups.test.ts +35 -0
- package/src/components/topology/internet-groups.ts +55 -0
- package/src/components/topology/layout.ts +1 -1
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/FoldSection.tsx +68 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
- package/src/components/ui/RefLink.tsx +20 -0
- package/src/components/ui/RowActionMenu.tsx +8 -3
- package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +110 -1
- package/src/components/ui/drawer-components.tsx +62 -9
- package/src/components/ui/index.ts +3 -1
- package/src/components/ui/monacoEditorFeatures.ts +77 -0
- package/src/components/ui/monacoRuntime.ts +14 -19
- package/src/components/ui/status-tone.test.ts +10 -1
- package/src/components/ui/status-tone.tsx +29 -0
- package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +180 -64
- package/src/index.ts +14 -1
- package/src/monaco-deep.d.ts +19 -12
- package/src/theme/components.css +13 -0
- package/src/types/capacity.ts +11 -2
- package/src/types/core.ts +29 -2
- package/src/types/gitops-flux-status.test.ts +32 -1
- package/src/types/gitops-insights.ts +19 -2
- package/src/types/gitops-tree.ts +10 -0
- package/src/types/gitops.ts +26 -6
- package/src/utils/api-resources.ts +7 -0
- package/src/utils/application-history.ts +1 -0
- package/src/utils/cnpg-backup.test.ts +26 -0
- package/src/utils/cnpg-backup.ts +70 -0
- package/src/utils/format.test.ts +9 -0
- package/src/utils/format.ts +4 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/k8s-errors.ts +7 -7
- package/src/utils/log-format.test.ts +41 -0
- package/src/utils/log-format.ts +71 -15
- package/src/utils/log-level.ts +12 -8
- package/src/utils/navigation.test.ts +57 -0
- package/src/utils/navigation.ts +86 -8
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +37 -0
- package/src/utils/resource-hierarchy.ts +15 -7
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.test.ts +21 -0
- package/src/utils/skeleton-yaml.ts +16 -0
- package/src/utils/url-path.test.ts +35 -0
- package/src/utils/url-path.ts +21 -0
- package/src/utils/validators.test.ts +4 -13
- package/src/utils/validators.ts +5 -19
- package/src/utils/view-transition.test.ts +35 -1
- package/src/utils/view-transition.ts +16 -1
|
@@ -193,6 +193,63 @@ describe('pluralToKind', () => {
|
|
|
193
193
|
})
|
|
194
194
|
|
|
195
195
|
describe('initNavigationMap', () => {
|
|
196
|
+
const coreNode = { group: '', version: 'v1', kind: 'Node', name: 'nodes', namespaced: false, isCrd: false, verbs: ['get'] }
|
|
197
|
+
const customNode = { group: 'collision.example.io', version: 'v1', kind: 'Node', name: 'workerhosts', namespaced: false, isCrd: true, verbs: ['get'] }
|
|
198
|
+
|
|
199
|
+
test.each([
|
|
200
|
+
['core listed first', [coreNode, customNode]],
|
|
201
|
+
['custom listed first', [customNode, coreNode]],
|
|
202
|
+
])('keeps the core plural for a Kind a CRD reuses under another plural (%s)', (_, resources) => {
|
|
203
|
+
initNavigationMap(resources)
|
|
204
|
+
expect(kindToPlural('Node')).toBe('nodes')
|
|
205
|
+
expect(kindToPluralWithGroup('Node', '')).toBe('nodes')
|
|
206
|
+
expect(refToSelectedResource({ kind: 'Node', group: '', name: 'worker' }).kind).toBe('nodes')
|
|
207
|
+
expect(kindToPluralWithGroup('Node', 'collision.example.io')).toBe('workerhosts')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('keeps the core plural when partial discovery omits the core group', () => {
|
|
211
|
+
initNavigationMap([customNode])
|
|
212
|
+
expect(refToSelectedResource({ kind: 'Node', group: '', name: 'worker' }).kind).toBe('nodes')
|
|
213
|
+
expect(kindToPluralWithGroup('Node', 'collision.example.io')).toBe('workerhosts')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test.each([
|
|
217
|
+
['partial discovery', [{ ...customNode, name: 'node' }]],
|
|
218
|
+
['full discovery', [coreNode, { ...customNode, name: 'node' }]],
|
|
219
|
+
])('keeps the core plural when a CRD registers the core Kind word as its plural (%s)', (_, resources) => {
|
|
220
|
+
initNavigationMap(resources)
|
|
221
|
+
expect(refToSelectedResource({ kind: 'Node', group: '', name: 'worker' }).kind).toBe('nodes')
|
|
222
|
+
expect(kindToPlural('node')).toBe('node')
|
|
223
|
+
expect(kindToPluralWithGroup('Node', 'collision.example.io')).toBe('node')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('resolves a group-qualified Kind spelled like a core plural to its own resource', () => {
|
|
227
|
+
initNavigationMap([
|
|
228
|
+
{ group: '', version: 'v1', kind: 'Endpoints', name: 'endpoints', namespaced: true, isCrd: false, verbs: ['get'] },
|
|
229
|
+
{ group: 'custom.example.io', version: 'v1', kind: 'Endpoints', name: 'virtualendpoints', namespaced: true, isCrd: true, verbs: ['get'] },
|
|
230
|
+
])
|
|
231
|
+
expect(kindToPluralWithGroup('Endpoints', 'custom.example.io')).toBe('virtualendpoints')
|
|
232
|
+
expect(kindToPluralWithGroup('Endpoints', '')).toBe('endpoints')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('keeps a lowercase group plural rather than reading it as another Kind', () => {
|
|
236
|
+
initNavigationMap([
|
|
237
|
+
{ group: 'shapes.example.io', version: 'v1', kind: 'Widget', name: 'widgets', namespaced: true, isCrd: true, verbs: ['get'] },
|
|
238
|
+
{ group: 'shapes.example.io', version: 'v1', kind: 'Widgets', name: 'widgetsets', namespaced: true, isCrd: true, verbs: ['get'] },
|
|
239
|
+
])
|
|
240
|
+
expect(kindToPluralWithGroup('widgets', 'shapes.example.io')).toBe('widgets')
|
|
241
|
+
expect(kindToPluralWithGroup('Widgets', 'shapes.example.io')).toBe('widgetsets')
|
|
242
|
+
expect(kindToPluralWithGroup('Widget', 'shapes.example.io')).toBe('widgets')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('keeps last-wins for Kinds that only CRDs declare', () => {
|
|
246
|
+
initNavigationMap([
|
|
247
|
+
{ group: 'a.example.io', version: 'v1', kind: 'Widget', name: 'widgets', namespaced: true, isCrd: true, verbs: [] },
|
|
248
|
+
{ group: 'b.example.io', version: 'v1', kind: 'Widget', name: 'gadgets', namespaced: true, isCrd: true, verbs: [] },
|
|
249
|
+
])
|
|
250
|
+
expect(kindToPlural('Widget')).toBe('gadgets')
|
|
251
|
+
})
|
|
252
|
+
|
|
196
253
|
test('discovered API resources override heuristic pluralization', () => {
|
|
197
254
|
// Before init, an unknown CRD would hit the heuristic fallback
|
|
198
255
|
// After init, it uses the discovered plural name
|
package/src/utils/navigation.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SelectedResource, ResourceRef, APIResource } from '../types/core'
|
|
2
2
|
import { englishPlural, englishSingular, isEnglishPlural } from './pluralize'
|
|
3
|
-
import { CORE_RESOURCES } from './api-resources'
|
|
3
|
+
import { CORE_RESOURCES, isBuiltinCoreKind } from './api-resources'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Canonical callback type for navigating to a resource.
|
|
@@ -67,6 +67,18 @@ let discoveredPluralToKind: Record<string, string> | null = null
|
|
|
67
67
|
let discoveredKindToPlural: Record<string, string> | null = null
|
|
68
68
|
let discoveredGroupKindToPlural: Record<string, string> | null = null
|
|
69
69
|
let discoveredGroupPluralToKind: Record<string, string> | null = null
|
|
70
|
+
let discoveredGroupKindNamespaced: Record<string, boolean> | null = null
|
|
71
|
+
let discoveredCRDNames: Record<string, string> | null = null
|
|
72
|
+
const crdDefinitionListeners = new Set<() => void>()
|
|
73
|
+
|
|
74
|
+
export function subscribeCRDDefinitions(listener: () => void): () => void {
|
|
75
|
+
crdDefinitionListeners.add(listener)
|
|
76
|
+
return () => { crdDefinitionListeners.delete(listener) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getCRDDefinitionsSnapshot(): Readonly<Record<string, string>> | null {
|
|
80
|
+
return discoveredCRDNames
|
|
81
|
+
}
|
|
70
82
|
|
|
71
83
|
/**
|
|
72
84
|
* Initialize navigation maps from discovered API resources.
|
|
@@ -75,17 +87,35 @@ let discoveredGroupPluralToKind: Record<string, string> | null = null
|
|
|
75
87
|
*/
|
|
76
88
|
export function initNavigationMap(resources: APIResource[]) {
|
|
77
89
|
const p2k: Record<string, string> = { ...BUILTIN_PLURAL_TO_KIND }
|
|
78
|
-
|
|
90
|
+
// Seeded from the built-in table so core protection survives partial
|
|
91
|
+
// discovery (the server forwards whatever groups it could list).
|
|
92
|
+
const k2p: Record<string, string> = Object.fromEntries(
|
|
93
|
+
CORE_RESOURCES.filter(r => r.group === '').map(r => [r.kind.toLowerCase(), r.name]),
|
|
94
|
+
)
|
|
79
95
|
const gk2p: Record<string, string> = {}
|
|
80
96
|
const gp2k: Record<string, string> = { ...BUILTIN_GROUP_PLURAL_TO_KIND }
|
|
97
|
+
const scopes: Record<string, boolean> = {}
|
|
98
|
+
const crdNames: Record<string, string> = {}
|
|
99
|
+
const coreKinds = new Set(Object.keys(k2p))
|
|
81
100
|
for (const r of resources) {
|
|
82
101
|
const plural = r.name.toLowerCase()
|
|
102
|
+
const kindLower = r.kind.toLowerCase()
|
|
103
|
+
if (r.definitionName) crdNames[`${r.group}/${r.kind}`] = r.definitionName
|
|
83
104
|
// First-wins on plurals: BUILTIN_PLURAL_TO_KIND seeds canonical core mappings
|
|
84
105
|
// (e.g. "pods" → "Pod") so a colliding API resource (metrics.k8s.io exposes
|
|
85
106
|
// "pods" with kind "PodMetrics") cannot hijack the core mapping.
|
|
86
107
|
if (!(plural in p2k)) p2k[plural] = r.kind
|
|
87
|
-
|
|
88
|
-
|
|
108
|
+
// Kind-only lookups (core links carry an empty group) likewise keep the core
|
|
109
|
+
// plural: a CRD that reuses a core Kind under another plural must not win
|
|
110
|
+
// just because discovery happened to list it later.
|
|
111
|
+
if (r.group === '') {
|
|
112
|
+
k2p[kindLower] = plural
|
|
113
|
+
coreKinds.add(kindLower)
|
|
114
|
+
} else if (!coreKinds.has(kindLower)) {
|
|
115
|
+
k2p[kindLower] = plural
|
|
116
|
+
}
|
|
117
|
+
gk2p[`${r.group}/${kindLower}`] = plural
|
|
118
|
+
scopes[`${r.group}/${kindLower}`] = r.namespaced
|
|
89
119
|
const groupPlural = `${r.group}/${plural}`
|
|
90
120
|
if (!(groupPlural in gp2k)) gp2k[groupPlural] = r.kind
|
|
91
121
|
}
|
|
@@ -93,14 +123,20 @@ export function initNavigationMap(resources: APIResource[]) {
|
|
|
93
123
|
discoveredKindToPlural = k2p
|
|
94
124
|
discoveredGroupKindToPlural = gk2p
|
|
95
125
|
discoveredGroupPluralToKind = gp2k
|
|
126
|
+
discoveredGroupKindNamespaced = scopes
|
|
127
|
+
discoveredCRDNames = crdNames
|
|
128
|
+
crdDefinitionListeners.forEach(listener => listener())
|
|
96
129
|
}
|
|
97
130
|
|
|
98
|
-
/** Reset navigation maps to builtin-only state
|
|
131
|
+
/** Reset navigation maps to builtin-only state on cluster changes. */
|
|
99
132
|
export function resetNavigationMap() {
|
|
100
133
|
discoveredPluralToKind = null
|
|
101
134
|
discoveredKindToPlural = null
|
|
102
135
|
discoveredGroupKindToPlural = null
|
|
103
136
|
discoveredGroupPluralToKind = null
|
|
137
|
+
discoveredGroupKindNamespaced = null
|
|
138
|
+
discoveredCRDNames = null
|
|
139
|
+
crdDefinitionListeners.forEach(listener => listener())
|
|
104
140
|
}
|
|
105
141
|
|
|
106
142
|
function getPluralToKind(): Record<string, string> {
|
|
@@ -117,6 +153,11 @@ export function kindToPlural(kind: string): string {
|
|
|
117
153
|
const kindLower = kind.toLowerCase()
|
|
118
154
|
const pluralToKindMap = getPluralToKind()
|
|
119
155
|
|
|
156
|
+
// An exact core Kind (PascalCase, unlike plural slugs) keeps its core plural
|
|
157
|
+
// even when a CRD registered that word as its own plural.
|
|
158
|
+
const core = kind !== kindLower ? CORE_RESOURCES.find(r => r.group === '' && r.kind === kind) : undefined
|
|
159
|
+
if (core) return core.name
|
|
160
|
+
|
|
120
161
|
// Already a known plural — return as-is to prevent double-pluralization
|
|
121
162
|
if (kindLower in pluralToKindMap) return kindLower
|
|
122
163
|
|
|
@@ -156,10 +197,15 @@ export function kindToPlural(kind: string): string {
|
|
|
156
197
|
export function kindToPluralWithGroup(kind: string, group: string): string {
|
|
157
198
|
if (!group) return kindToPlural(kind)
|
|
158
199
|
const kindLower = kind.toLowerCase()
|
|
159
|
-
|
|
160
|
-
|
|
200
|
+
// The group's own plural, then its exact Kind, before the group-blind plural
|
|
201
|
+
// shortcut: a custom Kind spelled like a core plural (Endpoints) has its own
|
|
202
|
+
// resource, while a lowercase plural must not be read as another Kind.
|
|
203
|
+
if (kind === kindLower && discoveredGroupPluralToKind?.[`${group}/${kindLower}`]) return kindLower
|
|
161
204
|
const groupKind = `${group}/${kindLower}`
|
|
162
|
-
|
|
205
|
+
const exact = discoveredGroupKindToPlural?.[groupKind] ?? BUILTIN_GROUP_KIND_TO_PLURAL[groupKind]
|
|
206
|
+
if (exact) return exact
|
|
207
|
+
if (kindLower in getPluralToKind()) return kindLower
|
|
208
|
+
return kindToPlural(kind)
|
|
163
209
|
}
|
|
164
210
|
|
|
165
211
|
/**
|
|
@@ -196,6 +242,13 @@ export function knownKindForPluralWithGroup(plural: string, group: string): stri
|
|
|
196
242
|
?? BUILTIN_GROUP_PLURAL_TO_KIND[groupPlural]
|
|
197
243
|
}
|
|
198
244
|
|
|
245
|
+
/** Return a CRD definition only when exact group/kind discovery identifies a CRD. */
|
|
246
|
+
export function customResourceDefinitionRef(apiVersion: string | undefined, kind: string | undefined): ResourceRef | null {
|
|
247
|
+
if (!apiVersion || !kind) return null
|
|
248
|
+
const name = discoveredCRDNames?.[`${apiVersionToGroup(apiVersion)}/${kind}`]
|
|
249
|
+
return name ? { kind: 'CustomResourceDefinition', group: 'apiextensions.k8s.io', namespace: '', name } : null
|
|
250
|
+
}
|
|
251
|
+
|
|
199
252
|
/**
|
|
200
253
|
* Convert a ResourceRef (from backend relationships) to a SelectedResource (for navigation).
|
|
201
254
|
* Handles kind singular→plural conversion.
|
|
@@ -225,6 +278,31 @@ export function apiVersionToGroup(apiVersion?: string | null): string {
|
|
|
225
278
|
return i === -1 ? '' : apiVersion.slice(0, i)
|
|
226
279
|
}
|
|
227
280
|
|
|
281
|
+
/** A Kubernetes ObjectReference retains its own API identity and namespace.
|
|
282
|
+
* Cluster scope comes from exact group/kind discovery (or known built-ins).
|
|
283
|
+
* Core controllers omit apiVersion for core objects (the EndpointSlice
|
|
284
|
+
* controller writes Pod targets as kind/namespace/name/uid), so an omitted
|
|
285
|
+
* apiVersion means the core group only for a built-in core Kind. Any other
|
|
286
|
+
* missing identity or scope stays non-navigable rather than guessing an API
|
|
287
|
+
* or borrowing the referring object's namespace. UID is not a drawer locator.
|
|
288
|
+
*/
|
|
289
|
+
export function objectReferenceToResourceRef(ref: {
|
|
290
|
+
apiVersion?: string
|
|
291
|
+
kind?: string
|
|
292
|
+
namespace?: string
|
|
293
|
+
name?: string
|
|
294
|
+
} | null | undefined): ResourceRef | null {
|
|
295
|
+
if (!ref?.kind || !ref.name) return null
|
|
296
|
+
const kindLower = ref.kind.toLowerCase()
|
|
297
|
+
if (!ref.apiVersion && !isBuiltinCoreKind(ref.kind)) return null
|
|
298
|
+
const group = apiVersionToGroup(ref.apiVersion)
|
|
299
|
+
const key = `${group}/${kindLower}`
|
|
300
|
+
const namespaced = discoveredGroupKindNamespaced?.[key]
|
|
301
|
+
?? CORE_RESOURCES.find(r => r.group === group && r.kind.toLowerCase() === kindLower)?.namespaced
|
|
302
|
+
if (namespaced !== false && !ref.namespace) return null
|
|
303
|
+
return { kind: ref.kind, group, namespace: namespaced === false ? '' : ref.namespace!, name: ref.name }
|
|
304
|
+
}
|
|
305
|
+
|
|
228
306
|
// -----------------------------------------------------------------------------
|
|
229
307
|
// Timeline lane identity. A lane's id includes the API group so two CRDs that
|
|
230
308
|
// share a kind name across vendors (CAPI `Cluster` in cluster.x-k8s.io vs CNPG
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { initNavigationMap, objectReferenceToResourceRef, resetNavigationMap } from './navigation'
|
|
3
|
+
|
|
4
|
+
afterEach(resetNavigationMap)
|
|
5
|
+
|
|
6
|
+
describe('objectReferenceToResourceRef', () => {
|
|
7
|
+
it('keeps the reference namespace and drops it for cluster-scoped core kinds', () => {
|
|
8
|
+
expect(objectReferenceToResourceRef({ apiVersion: 'v1', kind: 'Pod', namespace: 'other', name: 'backend' }))
|
|
9
|
+
.toEqual({ kind: 'Pod', group: '', namespace: 'other', name: 'backend' })
|
|
10
|
+
expect(objectReferenceToResourceRef({ apiVersion: 'v1', kind: 'Node', namespace: 'irrelevant', name: 'worker' }))
|
|
11
|
+
.toEqual({ kind: 'Node', group: '', namespace: '', name: 'worker' })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('resolves a core Kind without apiVersion, the shape the EndpointSlice controller writes', () => {
|
|
15
|
+
expect(objectReferenceToResourceRef({ kind: 'Pod', namespace: 'team', name: 'backend' }))
|
|
16
|
+
.toEqual({ kind: 'Pod', group: '', namespace: 'team', name: 'backend' })
|
|
17
|
+
expect(objectReferenceToResourceRef({ kind: 'Endpoints', namespace: 'team', name: 'backend' }))
|
|
18
|
+
.toEqual({ kind: 'Endpoints', group: '', namespace: 'team', name: 'backend' })
|
|
19
|
+
expect(objectReferenceToResourceRef({ kind: 'Deployment', namespace: 'team', name: 'backend' })).toBeNull()
|
|
20
|
+
expect(objectReferenceToResourceRef({ kind: 'Services', namespace: 'team', name: 'backend' })).toBeNull()
|
|
21
|
+
expect(objectReferenceToResourceRef({ kind: 'Nodes', namespace: 'team', name: 'worker' })).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('uses exact discovered scope for colliding API kinds', () => {
|
|
25
|
+
initNavigationMap([
|
|
26
|
+
{ group: 'cluster.example.io', version: 'v1', kind: 'Node', name: 'nodes', namespaced: false, isCrd: true, verbs: ['get'] },
|
|
27
|
+
{ group: 'team.example.io', version: 'v1', kind: 'Node', name: 'nodes', namespaced: true, isCrd: true, verbs: ['get'] },
|
|
28
|
+
])
|
|
29
|
+
expect(objectReferenceToResourceRef({ apiVersion: 'cluster.example.io/v1', kind: 'Node', name: 'worker' }))
|
|
30
|
+
.toEqual({ kind: 'Node', group: 'cluster.example.io', namespace: '', name: 'worker' })
|
|
31
|
+
expect(objectReferenceToResourceRef({ apiVersion: 'team.example.io/v1', kind: 'Node', namespace: 'team', name: 'worker' }))
|
|
32
|
+
.toEqual({ kind: 'Node', group: 'team.example.io', namespace: 'team', name: 'worker' })
|
|
33
|
+
expect(objectReferenceToResourceRef({ apiVersion: 'team.example.io/v1', kind: 'Node', name: 'worker' })).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('does not invent a custom API group, a namespace, or an unknown cluster scope', () => {
|
|
37
|
+
expect(objectReferenceToResourceRef({ kind: 'Widget', namespace: 'team', name: 'backend' })).toBeNull()
|
|
38
|
+
expect(objectReferenceToResourceRef({ apiVersion: 'v1', kind: 'Pod', name: 'backend' })).toBeNull()
|
|
39
|
+
expect(objectReferenceToResourceRef({ apiVersion: 'unknown.example.io/v1', kind: 'Node', name: 'worker' })).toBeNull()
|
|
40
|
+
expect(objectReferenceToResourceRef({ apiVersion: 'unknown.example.io/v1', kind: 'Widget', namespace: 'team', name: 'backend' }))
|
|
41
|
+
.toEqual({ kind: 'Widget', group: 'unknown.example.io', namespace: 'team', name: 'backend' })
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -1560,3 +1560,40 @@ describe('uses edges in the timeline hierarchy', () => {
|
|
|
1560
1560
|
expect(deployment.children?.map(child => child.kind)).toEqual(['HorizontalPodAutoscaler'])
|
|
1561
1561
|
})
|
|
1562
1562
|
})
|
|
1563
|
+
|
|
1564
|
+
describe('buildResourceHierarchy Flux sources', () => {
|
|
1565
|
+
it('keeps a shared Flux source as its own lane instead of nesting it under one consumer', () => {
|
|
1566
|
+
const event = (kind: string, name: string, apiVersion: string): TimelineEvent => ({
|
|
1567
|
+
id: `${kind}/flux-system/${name}`,
|
|
1568
|
+
timestamp: '2024-01-01T00:00:00.000Z',
|
|
1569
|
+
source: 'informer',
|
|
1570
|
+
kind,
|
|
1571
|
+
apiVersion,
|
|
1572
|
+
namespace: 'flux-system',
|
|
1573
|
+
name,
|
|
1574
|
+
eventType: 'update',
|
|
1575
|
+
} as TimelineEvent)
|
|
1576
|
+
const events = [
|
|
1577
|
+
event('GitRepository', 'podinfo', 'source.toolkit.fluxcd.io/v1'),
|
|
1578
|
+
event('Kustomization', 'base', 'kustomize.toolkit.fluxcd.io/v1'),
|
|
1579
|
+
event('Kustomization', 'overlay', 'kustomize.toolkit.fluxcd.io/v1'),
|
|
1580
|
+
]
|
|
1581
|
+
const topology = {
|
|
1582
|
+
nodes: [
|
|
1583
|
+
{ id: 'gitrepository/flux-system/podinfo', kind: 'GitRepository', name: 'podinfo', status: 'healthy', data: { namespace: 'flux-system', apiVersion: 'source.toolkit.fluxcd.io/v1' } },
|
|
1584
|
+
{ id: 'kustomization/flux-system/base', kind: 'Kustomization', name: 'base', status: 'healthy', data: { namespace: 'flux-system', apiVersion: 'kustomize.toolkit.fluxcd.io/v1' } },
|
|
1585
|
+
{ id: 'kustomization/flux-system/overlay', kind: 'Kustomization', name: 'overlay', status: 'healthy', data: { namespace: 'flux-system', apiVersion: 'kustomize.toolkit.fluxcd.io/v1' } },
|
|
1586
|
+
],
|
|
1587
|
+
edges: [
|
|
1588
|
+
{ id: 'e1', source: 'gitrepository/flux-system/podinfo', target: 'kustomization/flux-system/base', type: 'configures' },
|
|
1589
|
+
{ id: 'e2', source: 'gitrepository/flux-system/podinfo', target: 'kustomization/flux-system/overlay', type: 'configures' },
|
|
1590
|
+
],
|
|
1591
|
+
} as unknown as Topology
|
|
1592
|
+
const lanes = buildResourceHierarchy({ events, topology, grouping: 'owner' })
|
|
1593
|
+
expect(lanes.map((lane) => `${lane.kind}/${lane.name}`).sort()).toEqual([
|
|
1594
|
+
'GitRepository/podinfo',
|
|
1595
|
+
'Kustomization/base',
|
|
1596
|
+
'Kustomization/overlay',
|
|
1597
|
+
])
|
|
1598
|
+
})
|
|
1599
|
+
})
|
|
@@ -466,7 +466,7 @@ function sortEventsForRendering(events: TimelineEvent[]): TimelineEvent[] {
|
|
|
466
466
|
// then workloads, then their generated children (ReplicaSet/Pod), config last.
|
|
467
467
|
// Applied at EVERY depth so a Job's Pods sort the same way a root's children do.
|
|
468
468
|
const CHILD_SORT_KIND_PRIORITY: Record<string, number> = {
|
|
469
|
-
Service: 1, Gateway: 1, HTTPRoute: 2, GRPCRoute: 2, TCPRoute: 2, TLSRoute: 2,
|
|
469
|
+
Service: 1, Gateway: 1, HTTPRoute: 2, GRPCRoute: 2, TCPRoute: 2, TLSRoute: 2, UDPRoute: 2,
|
|
470
470
|
Deployment: 2, Rollout: 2, StatefulSet: 2, DaemonSet: 2,
|
|
471
471
|
Job: 3, CronJob: 3,
|
|
472
472
|
ReplicaSet: 3, Pod: 4, ConfigMap: 5, Secret: 5,
|
|
@@ -761,11 +761,11 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
761
761
|
const targetKind = parseLaneId(targetId)!.kind
|
|
762
762
|
|
|
763
763
|
// Gateway→Route: Gateway is parent of Route
|
|
764
|
-
if (sourceKind === 'Gateway' && (targetKind === 'HTTPRoute' || targetKind === 'GRPCRoute' || targetKind === 'TCPRoute' || targetKind === 'TLSRoute')) {
|
|
764
|
+
if (sourceKind === 'Gateway' && (targetKind === 'HTTPRoute' || targetKind === 'GRPCRoute' || targetKind === 'TCPRoute' || targetKind === 'TLSRoute' || targetKind === 'UDPRoute')) {
|
|
765
765
|
link(targetId, sourceId, targetExists)
|
|
766
766
|
}
|
|
767
767
|
// Route→Service: reverse (Service is representative, like Ingress)
|
|
768
|
-
else if ((sourceKind === 'HTTPRoute' || sourceKind === 'GRPCRoute' || sourceKind === 'TCPRoute' || sourceKind === 'TLSRoute') && targetKind === 'Service') {
|
|
768
|
+
else if ((sourceKind === 'HTTPRoute' || sourceKind === 'GRPCRoute' || sourceKind === 'TCPRoute' || sourceKind === 'TLSRoute' || sourceKind === 'UDPRoute') && targetKind === 'Service') {
|
|
769
769
|
link(sourceId, targetId, sourceExists)
|
|
770
770
|
}
|
|
771
771
|
// Ingress→Service: reverse relationship (Service is representative)
|
|
@@ -778,8 +778,10 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
778
778
|
}
|
|
779
779
|
}
|
|
780
780
|
|
|
781
|
-
// configures/protects: ConfigMap→Deployment, PDB→Deployment (target is parent)
|
|
782
|
-
|
|
781
|
+
// configures/protects: ConfigMap→Deployment, PDB→Deployment (target is parent).
|
|
782
|
+
// A Flux source feeds many reconcilers and its timeline is its own, so it
|
|
783
|
+
// never nests under one of them.
|
|
784
|
+
if ((edge.type === 'configures' && !isFluxSource(topologyNodeById.get(edge.source))) || edge.type === 'protects') {
|
|
783
785
|
link(sourceId, targetId, sourceExists)
|
|
784
786
|
}
|
|
785
787
|
// uses: only an autoscaler or a mounted claim belongs under the workload it
|
|
@@ -813,7 +815,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
813
815
|
const appLabelEligibleKinds = new Set([
|
|
814
816
|
'Service', 'Deployment', 'Rollout', 'StatefulSet', 'DaemonSet',
|
|
815
817
|
'Job', 'CronJob', 'Ingress', 'Gateway', 'HTTPRoute', 'GRPCRoute',
|
|
816
|
-
'TCPRoute', 'TLSRoute', 'ConfigMap', 'Secret',
|
|
818
|
+
'TCPRoute', 'TLSRoute', 'UDPRoute', 'ConfigMap', 'Secret',
|
|
817
819
|
'Application', 'Kustomization', 'HelmRelease', 'GitRepository',
|
|
818
820
|
'Workflow', 'CronWorkflow',
|
|
819
821
|
'KnativeService', 'KnativeConfiguration', 'KnativeRevision', 'KnativeRoute',
|
|
@@ -844,7 +846,7 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
844
846
|
|
|
845
847
|
const kindPriority: Record<string, number> = {
|
|
846
848
|
Service: 1, Ingress: 2, Gateway: 2,
|
|
847
|
-
HTTPRoute: 2, GRPCRoute: 2, TCPRoute: 2, TLSRoute: 2,
|
|
849
|
+
HTTPRoute: 2, GRPCRoute: 2, TCPRoute: 2, TLSRoute: 2, UDPRoute: 2,
|
|
848
850
|
Deployment: 3, Rollout: 3, StatefulSet: 3, DaemonSet: 3,
|
|
849
851
|
Job: 4, CronJob: 4, Workflow: 4, CronWorkflow: 4,
|
|
850
852
|
ConfigMap: 5, Secret: 5,
|
|
@@ -1487,6 +1489,12 @@ const ATTACHED_USES_SOURCES = new Set([
|
|
|
1487
1489
|
'/PersistentVolumeClaim',
|
|
1488
1490
|
])
|
|
1489
1491
|
|
|
1492
|
+
function isFluxSource(node: Topology['nodes'][number] | undefined): boolean {
|
|
1493
|
+
if (!node) return false
|
|
1494
|
+
const apiVersion = typeof node.data?.apiVersion === 'string' ? node.data.apiVersion : ''
|
|
1495
|
+
return apiVersionToGroup(apiVersion) === 'source.toolkit.fluxcd.io'
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1490
1498
|
function attachesToTarget(node: Topology['nodes'][number] | undefined): boolean {
|
|
1491
1499
|
if (!node) return false
|
|
1492
1500
|
const kind = topologyNodeResourceKind(node)
|
|
@@ -111,6 +111,7 @@ const KIND_ICON_MAP: Record<string, LucideIcon> = {
|
|
|
111
111
|
httproute: Globe,
|
|
112
112
|
grpcroute: Globe,
|
|
113
113
|
tcproute: Globe,
|
|
114
|
+
udproute: Globe,
|
|
114
115
|
tlsroute: Globe,
|
|
115
116
|
|
|
116
117
|
// Config
|
|
@@ -219,6 +220,7 @@ const KIND_ICON_MAP: Record<string, LucideIcon> = {
|
|
|
219
220
|
// pseudo-kinds (cnpgcluster/…) belong here only once pkg/topology's
|
|
220
221
|
// KindForGVK emits them — it has no CNPG case today.
|
|
221
222
|
pooler: Waypoints,
|
|
223
|
+
databaserole: UserCog,
|
|
222
224
|
|
|
223
225
|
// Cluster API
|
|
224
226
|
capicluster: Server,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getSkeletonYaml } from './skeleton-yaml'
|
|
3
|
+
|
|
4
|
+
describe('getSkeletonYaml', () => {
|
|
5
|
+
it('starts a CloudNativePG Cluster from a spec the operator accepts', () => {
|
|
6
|
+
const yaml = getSkeletonYaml('Cluster', 'postgresql.cnpg.io')
|
|
7
|
+
expect(yaml).toContain('apiVersion: postgresql.cnpg.io/v1')
|
|
8
|
+
expect(yaml).toContain('instances: 3')
|
|
9
|
+
expect(yaml).toContain('size: 1Gi')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('leaves a Cluster API Cluster on the generic template', () => {
|
|
13
|
+
const yaml = getSkeletonYaml('Cluster', 'cluster.x-k8s.io')
|
|
14
|
+
expect(yaml).toContain('apiVersion: cluster.x-k8s.io/v1')
|
|
15
|
+
expect(yaml).not.toContain('instances')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('keeps core kinds on their own skeleton', () => {
|
|
19
|
+
expect(getSkeletonYaml('Pod')).toContain('containers:')
|
|
20
|
+
})
|
|
21
|
+
})
|
|
@@ -198,8 +198,24 @@ spec:
|
|
|
198
198
|
image: nginx:latest`,
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
// Keyed by group as well as kind: CloudNativePG and Cluster API both name a
|
|
202
|
+
// kind Cluster.
|
|
203
|
+
const groupSkeletons: Record<string, string> = {
|
|
204
|
+
'postgresql.cnpg.io/Cluster': `apiVersion: postgresql.cnpg.io/v1
|
|
205
|
+
kind: Cluster
|
|
206
|
+
metadata:
|
|
207
|
+
name: my-cluster
|
|
208
|
+
namespace: default
|
|
209
|
+
spec:
|
|
210
|
+
instances: 3
|
|
211
|
+
storage:
|
|
212
|
+
size: 1Gi`,
|
|
213
|
+
}
|
|
214
|
+
|
|
201
215
|
// Returns skeleton YAML for a known kind, or a generic template for unknown kinds.
|
|
202
216
|
export function getSkeletonYaml(kind: string, group?: string): string {
|
|
217
|
+
const grouped = group ? groupSkeletons[`${group}/${kind}`] : undefined
|
|
218
|
+
if (grouped) return grouped
|
|
203
219
|
const skeleton = skeletons[kind]
|
|
204
220
|
if (skeleton) return skeleton
|
|
205
221
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { normalizeURLForComparison, stripTrailingSlashes } from './url-path'
|
|
3
|
+
|
|
4
|
+
describe('stripTrailingSlashes', () => {
|
|
5
|
+
it.each([
|
|
6
|
+
['', ''],
|
|
7
|
+
['/', ''],
|
|
8
|
+
['////', ''],
|
|
9
|
+
['/api///', '/api'],
|
|
10
|
+
['https://store.example//bucket///', 'https://store.example//bucket'],
|
|
11
|
+
['https://store.example//bucket?prefix=/', 'https://store.example//bucket?prefix='],
|
|
12
|
+
['/api//nested', '/api//nested'],
|
|
13
|
+
])('preserves the existing path normalization for %s', (value, expected) => {
|
|
14
|
+
expect(stripTrailingSlashes(value)).toBe(expected)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('handles long slash runs without scanning each possible start', () => {
|
|
18
|
+
const slashes = '/'.repeat(100_000)
|
|
19
|
+
expect(stripTrailingSlashes(`prefix${slashes}`)).toBe('prefix')
|
|
20
|
+
expect(stripTrailingSlashes(`prefix${slashes}suffix`)).toBe(`prefix${slashes}suffix`)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('compares equivalent origins while retaining declared path semantics', () => {
|
|
25
|
+
expect(normalizeURLForComparison('https://STORE.example:443/prefix/')).toBe(normalizeURLForComparison('https://store.example/prefix'))
|
|
26
|
+
expect(normalizeURLForComparison('http://STORE.example:080/')).toBe(normalizeURLForComparison('http://store.example'))
|
|
27
|
+
expect(normalizeURLForComparison('s3://BUCKET/path/')).toBe(normalizeURLForComparison('s3://bucket/path'))
|
|
28
|
+
expect(normalizeURLForComparison('https://store.example/Prefix')).not.toBe(normalizeURLForComparison('https://store.example/prefix'))
|
|
29
|
+
expect(normalizeURLForComparison('https://store.example/a/../b')).not.toBe(normalizeURLForComparison('https://store.example/b'))
|
|
30
|
+
expect(normalizeURLForComparison('https://store.example:8443/path')).not.toBe(normalizeURLForComparison('https://store.example/path'))
|
|
31
|
+
expect(normalizeURLForComparison('')).toBe('')
|
|
32
|
+
expect(normalizeURLForComparison('invalid')).toBeNull()
|
|
33
|
+
expect(normalizeURLForComparison('https://host\\prefix')).toBeNull()
|
|
34
|
+
expect(normalizeURLForComparison('https://user:password@store.example')).toBeNull()
|
|
35
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function stripTrailingSlashes(value: string): string {
|
|
2
|
+
let end = value.length
|
|
3
|
+
while (end > 0 && value[end - 1] === '/') end--
|
|
4
|
+
return value.slice(0, end)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function normalizeURLForComparison(value: string): string | null {
|
|
8
|
+
if (!value) return ''
|
|
9
|
+
if (value.includes('\\')) return null
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(value)
|
|
12
|
+
const authorityStart = value.indexOf('://')
|
|
13
|
+
if (authorityStart < 0 || !url.hostname || url.username || url.password || value.includes('?') || value.includes('#')) return null
|
|
14
|
+
const pathStart = value.indexOf('/', authorityStart + 3)
|
|
15
|
+
const path = pathStart < 0 ? '' : value.slice(pathStart)
|
|
16
|
+
const port = url.port || (url.protocol === 'https:' ? '443' : url.protocol === 'http:' ? '80' : '')
|
|
17
|
+
return `${url.protocol}//${url.hostname.toLowerCase()}:${port}${stripTrailingSlashes(path)}`
|
|
18
|
+
} catch {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -82,25 +82,16 @@ describe('validateRFC1123Subdomain', () => {
|
|
|
82
82
|
if (!r.valid) expect(r.error).toMatch(/253/)
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
-
it('
|
|
86
|
-
|
|
87
|
-
// separated label on top of the 253-char total. Without the
|
|
88
|
-
// per-label check, a single 200-char label slipped through
|
|
89
|
-
// client-side and was rejected only server-side.
|
|
90
|
-
const huge = 'a'.repeat(200)
|
|
91
|
-
const r = validateRFC1123Subdomain(huge)
|
|
92
|
-
expect(r.valid).toBe(false)
|
|
93
|
-
if (!r.valid) expect(r.error).toMatch(/63/)
|
|
85
|
+
it('accepts long undotted names under the Kubernetes total limit', () => {
|
|
86
|
+
expect(validateRFC1123Subdomain('a'.repeat(200)).valid).toBe(true)
|
|
94
87
|
})
|
|
95
88
|
|
|
96
89
|
it('accepts a 63-char label at the boundary', () => {
|
|
97
90
|
expect(validateRFC1123Subdomain('a'.repeat(63)).valid).toBe(true)
|
|
98
91
|
})
|
|
99
92
|
|
|
100
|
-
it('
|
|
101
|
-
|
|
102
|
-
expect(r.valid).toBe(false)
|
|
103
|
-
if (!r.valid) expect(r.error).toMatch(/63/)
|
|
93
|
+
it('accepts long dot-separated parts under the Kubernetes total limit', () => {
|
|
94
|
+
expect(validateRFC1123Subdomain(`ok.${'a'.repeat(64)}.also-ok`).valid).toBe(true)
|
|
104
95
|
})
|
|
105
96
|
})
|
|
106
97
|
|
package/src/utils/validators.ts
CHANGED
|
@@ -17,7 +17,7 @@ export type ValidationResult =
|
|
|
17
17
|
| { valid: false; error: string }
|
|
18
18
|
|
|
19
19
|
const RFC1123_LABEL_MAX = 63
|
|
20
|
-
// Kubernetes namespace
|
|
20
|
+
// Kubernetes namespace names are RFC 1123 labels:
|
|
21
21
|
// lowercase alphanumeric or '-', start and end alphanumeric, ≤63 chars.
|
|
22
22
|
// Source: k8s.io/apimachinery/pkg/util/validation.IsDNS1123Label.
|
|
23
23
|
const RFC1123_LABEL_RE = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/
|
|
@@ -34,9 +34,7 @@ const HELM_RELEASE_NAME_MAX = 53
|
|
|
34
34
|
// https://helm.sh/docs/chart_best_practices/conventions/#chart-names
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
* pod / service / configmap / secret names — anything Kubernetes
|
|
39
|
-
* accepts as a single-segment identifier.
|
|
37
|
+
* Matches Kubernetes IsDNS1123Label, including namespace names.
|
|
40
38
|
*/
|
|
41
39
|
export function validateRFC1123Label(input: string): ValidationResult {
|
|
42
40
|
if (input.length === 0) {
|
|
@@ -64,9 +62,9 @@ export function validateRFC1123Label(input: string): ValidationResult {
|
|
|
64
62
|
}
|
|
65
63
|
|
|
66
64
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
65
|
+
* Matches Kubernetes IsDNS1123Subdomain: the whole name is capped at 253
|
|
66
|
+
* characters, without a separate 63-character cap on dot-separated parts.
|
|
67
|
+
* Namespace names use validateRFC1123Label instead.
|
|
70
68
|
*/
|
|
71
69
|
export function validateRFC1123Subdomain(input: string): ValidationResult {
|
|
72
70
|
if (input.length === 0) {
|
|
@@ -90,18 +88,6 @@ export function validateRFC1123Subdomain(input: string): ValidationResult {
|
|
|
90
88
|
error: 'must contain only lowercase letters, numbers, hyphens, and dots, and each segment must start/end with a letter or number',
|
|
91
89
|
}
|
|
92
90
|
}
|
|
93
|
-
// Per-label cap: K8s' IsDNS1123Subdomain enforces ≤ 63 chars
|
|
94
|
-
// PER dot-separated label on top of the 253-char total. The
|
|
95
|
-
// regex above doesn't catch that; without this check a single
|
|
96
|
-
// 200-char label passes here and only fails server-side.
|
|
97
|
-
for (const label of input.split('.')) {
|
|
98
|
-
if (label.length > RFC1123_LABEL_MAX) {
|
|
99
|
-
return {
|
|
100
|
-
valid: false,
|
|
101
|
-
error: `each dot-separated label must be at most ${RFC1123_LABEL_MAX} characters (got ${label.length} for "${label}")`,
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
91
|
return { valid: true }
|
|
106
92
|
}
|
|
107
93
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
-
import { isInvalidStateError, startViewTransitionSafe } from './view-transition'
|
|
2
|
+
import { isInvalidStateError, isWebKitGTK, startViewTransitionSafe } from './view-transition'
|
|
3
3
|
|
|
4
4
|
// SKY-833 bug 49: rapid tab / route switches surfaced
|
|
5
5
|
// "InvalidStateError: Transition was aborted because of invalid state"
|
|
@@ -79,3 +79,37 @@ describe('startViewTransitionSafe', () => {
|
|
|
79
79
|
expect(isInvalidStateError(new TypeError('y'))).toBe(false)
|
|
80
80
|
})
|
|
81
81
|
})
|
|
82
|
+
|
|
83
|
+
const WEBKITGTK_UA =
|
|
84
|
+
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15'
|
|
85
|
+
|
|
86
|
+
describe('isWebKitGTK', () => {
|
|
87
|
+
it('matches WebKitGTK user agents', () => {
|
|
88
|
+
expect(isWebKitGTK(WEBKITGTK_UA)).toBe(true)
|
|
89
|
+
expect(isWebKitGTK('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko)')).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('does not match other engines on Linux, or Safari', () => {
|
|
93
|
+
expect(isWebKitGTK('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36')).toBe(false)
|
|
94
|
+
expect(isWebKitGTK('Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0')).toBe(false)
|
|
95
|
+
expect(isWebKitGTK('Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36')).toBe(false)
|
|
96
|
+
expect(isWebKitGTK('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15')).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('startViewTransitionSafe under WebKitGTK', () => {
|
|
101
|
+
it('applies the update directly instead of starting a transition', () => {
|
|
102
|
+
const originalDoc = (globalThis as { document?: unknown }).document
|
|
103
|
+
const startSpy = vi.fn()
|
|
104
|
+
;(globalThis as { document: object }).document = { startViewTransition: startSpy }
|
|
105
|
+
const uaSpy = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(WEBKITGTK_UA)
|
|
106
|
+
|
|
107
|
+
const update = vi.fn()
|
|
108
|
+
startViewTransitionSafe(update)
|
|
109
|
+
expect(update).toHaveBeenCalledTimes(1)
|
|
110
|
+
expect(startSpy).not.toHaveBeenCalled()
|
|
111
|
+
|
|
112
|
+
uaSpy.mockRestore()
|
|
113
|
+
;(globalThis as { document?: unknown }).document = originalDoc
|
|
114
|
+
})
|
|
115
|
+
})
|