@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
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Cpu } from 'lucide-react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
|
|
4
|
-
import {
|
|
4
|
+
import { objectReferenceToResourceRef } from '../../../utils/navigation'
|
|
5
|
+
import type { ResourceRef } from '../../../types'
|
|
6
|
+
import { CORE_RESOURCES } from '../../../utils/api-resources'
|
|
5
7
|
|
|
6
8
|
interface VPARendererProps {
|
|
7
9
|
data: any
|
|
8
|
-
onNavigate?: (ref:
|
|
10
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
function formatResource(value: string | undefined): string {
|
|
@@ -17,6 +19,16 @@ export function VPARenderer({ data, onNavigate }: VPARendererProps) {
|
|
|
17
19
|
const spec = data.spec || {}
|
|
18
20
|
const status = data.status || {}
|
|
19
21
|
const targetRef = spec.targetRef || {}
|
|
22
|
+
const namespace = data.metadata?.namespace
|
|
23
|
+
const builtin = !targetRef.apiVersion ? CORE_RESOURCES.find(resource => resource.kind === targetRef.kind) : undefined
|
|
24
|
+
const builtinAPIVersion = builtin
|
|
25
|
+
? (builtin.group ? `${builtin.group}/${builtin.version}` : builtin.version)
|
|
26
|
+
: targetRef.kind === 'ReplicationController' ? 'v1' : undefined
|
|
27
|
+
const target = objectReferenceToResourceRef({
|
|
28
|
+
...targetRef,
|
|
29
|
+
apiVersion: targetRef.apiVersion || builtinAPIVersion,
|
|
30
|
+
namespace,
|
|
31
|
+
})
|
|
20
32
|
const updatePolicy = spec.updatePolicy || {}
|
|
21
33
|
const resourcePolicy = spec.resourcePolicy || {}
|
|
22
34
|
const containerPolicies = resourcePolicy.containerPolicies || []
|
|
@@ -63,15 +75,9 @@ export function VPARenderer({ data, onNavigate }: VPARendererProps) {
|
|
|
63
75
|
<Section title="Configuration" icon={Cpu}>
|
|
64
76
|
<PropertyList>
|
|
65
77
|
<Property label="Target" value={
|
|
66
|
-
targetRef.name ? (
|
|
67
|
-
<ResourceLink
|
|
68
|
-
|
|
69
|
-
kind={kindToPlural(targetRef.kind || 'Deployment')}
|
|
70
|
-
namespace={data.metadata?.namespace || ''}
|
|
71
|
-
label={`${targetRef.kind}/${targetRef.name}`}
|
|
72
|
-
onNavigate={onNavigate}
|
|
73
|
-
/>
|
|
74
|
-
) : undefined
|
|
78
|
+
targetRef.name ? (target ? (
|
|
79
|
+
<ResourceLink {...target} label={`${targetRef.kind}/${targetRef.name}`} onNavigate={onNavigate} />
|
|
80
|
+
) : `${targetRef.kind || 'Unknown kind'}/${targetRef.name}`) : undefined
|
|
75
81
|
} />
|
|
76
82
|
<Property label="Update Mode" value={
|
|
77
83
|
<span className={clsx(
|
|
@@ -73,7 +73,7 @@ export function getClusterStatus(resource: any): StatusBadge {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
export function getClusterClass(resource: any): string {
|
|
76
|
-
return resource.spec?.topology?.class || '-'
|
|
76
|
+
return resource.spec?.topology?.classRef?.name || resource.spec?.topology?.class || '-'
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
export function getClusterVersion(resource: any): string {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
getCNPGWALArchivingFailure,
|
|
15
15
|
getCNPGLastBackupFailure,
|
|
16
16
|
classifyCNPGClusterPhase,
|
|
17
|
+
cnpgBlockedPhaseExplanation,
|
|
17
18
|
classifyCNPGBackupPhase,
|
|
18
19
|
CNPG_CLUSTER_PHASES_HEALTHY,
|
|
19
20
|
CNPG_CLUSTER_PHASES_TRANSIENT,
|
|
@@ -115,6 +116,26 @@ describe('getCNPGClusterCertificateExpirations', () => {
|
|
|
115
116
|
|
|
116
117
|
const cluster = (status: any = {}, spec: any = {}) => ({ spec, status })
|
|
117
118
|
|
|
119
|
+
describe('cnpgBlockedPhaseExplanation', () => {
|
|
120
|
+
it('says the operator retries a plugin phase, and quotes its reason', () => {
|
|
121
|
+
const e = cnpgBlockedPhaseExplanation(
|
|
122
|
+
'Cluster cannot proceed to reconciliation due to an unknown plugin being required',
|
|
123
|
+
"Unknown plugin: 'barman-cloud.cloudnative-pg.io'. Check the operator and plugin logs for errors",
|
|
124
|
+
)
|
|
125
|
+
expect(e.title).toBe('Reconciliation is blocked by a plugin')
|
|
126
|
+
expect(e.body).toMatch(/retries every few seconds/)
|
|
127
|
+
expect(e.body).not.toMatch(/manual intervention|does not resolve/)
|
|
128
|
+
expect(e.body).toMatch(/The operator reports: Unknown plugin/)
|
|
129
|
+
expect(e.body.endsWith('for errors.')).toBe(true)
|
|
130
|
+
expect(e.message.startsWith('Cluster cannot proceed')).toBe(true)
|
|
131
|
+
})
|
|
132
|
+
it('keeps manual intervention for the one phase upstream says needs it', () => {
|
|
133
|
+
expect(cnpgBlockedPhaseExplanation('Cluster is unrecoverable and needs manual intervention').title).toBe('Cluster is unrecoverable')
|
|
134
|
+
const objects = cnpgBlockedPhaseExplanation('Unable to create required cluster objects', ' ')
|
|
135
|
+
expect(objects.body).toBe('The operator keeps retrying, but this does not clear until its cause is fixed.')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
118
139
|
describe('classifyCNPGClusterPhase', () => {
|
|
119
140
|
it('buckets every phase constant shipped by CNPG 1.27', () => {
|
|
120
141
|
for (const p of CNPG_CLUSTER_PHASES_HEALTHY) expect(classifyCNPGClusterPhase(p)).toBe('healthy')
|
|
@@ -365,13 +386,17 @@ describe('getCNPGClusterInstancesReportedState', () => {
|
|
|
365
386
|
// ============================================================================
|
|
366
387
|
|
|
367
388
|
describe('getCNPGPoolerStatus', () => {
|
|
389
|
+
it('uses the requested count during scale-down without claiming readiness', () => {
|
|
390
|
+
expect(getCNPGPoolerStatus({ spec: { instances: 2 }, status: { instances: 3 } })).toMatchObject({ text: '2 instances requested', level: 'neutral' })
|
|
391
|
+
expect(getCNPGPoolerStatus({ spec: { instances: 1 }, status: { instances: 1 } })).toMatchObject({ text: '1 instance requested', level: 'neutral' })
|
|
392
|
+
})
|
|
368
393
|
it('does not claim readiness from status.instances, which counts scheduled pods', () => {
|
|
369
394
|
// Verified live: a Pooler whose 2 PgBouncer pods are both Pending (0/1
|
|
370
395
|
// ready) still reports status.instances=2. Calling that "Ready" renders a
|
|
371
396
|
// broken Pooler green.
|
|
372
397
|
const s = getCNPGPoolerStatus({ spec: { instances: 2 }, status: { instances: 2 } })
|
|
373
|
-
expect(s.level).toBe('
|
|
374
|
-
expect(s.text).toBe('
|
|
398
|
+
expect(s.level).toBe('neutral')
|
|
399
|
+
expect(s.text).toBe('2 instances requested')
|
|
375
400
|
expect(s.text).not.toBe('Ready')
|
|
376
401
|
})
|
|
377
402
|
|
|
@@ -595,7 +620,7 @@ describe('status column filter reads the badge, not the raw phase', () => {
|
|
|
595
620
|
)).toBe('Suspended')
|
|
596
621
|
expect(getCellFilterValue(
|
|
597
622
|
{ apiVersion: 'postgresql.cnpg.io/v1', spec: { instances: 2 }, status: { instances: 2 } }, 'status', 'poolers',
|
|
598
|
-
)).toBe('
|
|
623
|
+
)).toBe('2 instances requested')
|
|
599
624
|
})
|
|
600
625
|
|
|
601
626
|
it('leaves a foreign CRD sharing the plural on the generic path', () => {
|
|
@@ -670,7 +695,7 @@ describe('an absent count is unknown, never zero', () => {
|
|
|
670
695
|
expect(getCNPGPoolerStatus({ spec: { instances: 2 }, status: { instances: 0 } }))
|
|
671
696
|
.toMatchObject({ text: 'Not Scheduled', level: 'unhealthy' })
|
|
672
697
|
expect(getCNPGPoolerStatus({ spec: { instances: 2 }, status: { instances: 2 } }))
|
|
673
|
-
.toMatchObject({ text: '
|
|
698
|
+
.toMatchObject({ text: '2 instances requested', level: 'neutral' })
|
|
674
699
|
})
|
|
675
700
|
})
|
|
676
701
|
|
|
@@ -696,9 +721,9 @@ describe('paused Pooler', () => {
|
|
|
696
721
|
|
|
697
722
|
it('only an explicit true pauses', () => {
|
|
698
723
|
expect(getCNPGPoolerStatus({ spec: { instances: 2, pgbouncer: {} }, status: { instances: 2 } }))
|
|
699
|
-
.toMatchObject({ text: '
|
|
724
|
+
.toMatchObject({ text: '2 instances requested', level: 'neutral' })
|
|
700
725
|
expect(getCNPGPoolerStatus({ spec: { instances: 2, pgbouncer: { paused: false } }, status: { instances: 2 } }))
|
|
701
|
-
.toMatchObject({ text: '
|
|
726
|
+
.toMatchObject({ text: '2 instances requested', level: 'neutral' })
|
|
702
727
|
expect(isCNPGPoolerPaused({ spec: { pgbouncer: { paused: true } } })).toBe(true)
|
|
703
728
|
expect(isCNPGPoolerPaused({ spec: {} })).toBe(false)
|
|
704
729
|
})
|
|
@@ -752,9 +777,9 @@ describe('CNPG scheduled backup lateness', () => {
|
|
|
752
777
|
expect(badge.text).toBe('Overdue')
|
|
753
778
|
})
|
|
754
779
|
|
|
755
|
-
it('stays
|
|
780
|
+
it('stays neutral inside the operator reconcile window', () => {
|
|
756
781
|
const badge = getCNPGScheduledBackupStatus(sb(false, at(-1 * MIN), at(-24 * 60 * MIN)))
|
|
757
|
-
expect(badge.level).toBe('
|
|
782
|
+
expect(badge.level).toBe('neutral')
|
|
758
783
|
})
|
|
759
784
|
|
|
760
785
|
it('keeps suspended as a deliberate state, not a missed backup', () => {
|
|
@@ -768,7 +793,7 @@ describe('CNPG scheduled backup lateness', () => {
|
|
|
768
793
|
// The two read from one test. Before this they disagreed: the badge said
|
|
769
794
|
// Active while this field said overdue on the same row.
|
|
770
795
|
const justPastDue = sb(false, at(-1 * MIN), at(-24 * 60 * MIN))
|
|
771
|
-
expect(getCNPGScheduledBackupStatus(justPastDue).level).toBe('
|
|
796
|
+
expect(getCNPGScheduledBackupStatus(justPastDue).level).toBe('neutral')
|
|
772
797
|
expect(getCNPGScheduledBackupNextSchedule(justPastDue)).toBe('due now')
|
|
773
798
|
|
|
774
799
|
const longPastDue = sb(false, at(-200 * 24 * 60 * MIN), at(-200 * 24 * 60 * MIN))
|
|
@@ -783,7 +808,7 @@ describe('CNPG scheduled backup lateness', () => {
|
|
|
783
808
|
|
|
784
809
|
it('says nothing when the operator published no next-run time', () => {
|
|
785
810
|
expect(getCNPGScheduledBackupNextSchedule(sb(false))).toBe('-')
|
|
786
|
-
expect(getCNPGScheduledBackupStatus(sb(false, undefined, at(-24 * 60 * MIN))).level).toBe('
|
|
811
|
+
expect(getCNPGScheduledBackupStatus(sb(false, undefined, at(-24 * 60 * MIN))).level).toBe('neutral')
|
|
787
812
|
})
|
|
788
813
|
})
|
|
789
814
|
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import type { StatusBadge } from './resource-utils'
|
|
4
4
|
import { healthColors, formatAge, formatDuration } from './resource-utils'
|
|
5
|
+
import { cnpgBackupDeclaration, cnpgBarmanPlugin } from '../../utils/cnpg-backup'
|
|
6
|
+
export { CNPG_BARMAN_PLUGIN_NAME } from '../../utils/cnpg-backup'
|
|
7
|
+
|
|
5
8
|
import { parseGoTimeString } from '../../utils/parse-go-time'
|
|
6
9
|
|
|
7
10
|
// ============================================================================
|
|
@@ -10,6 +13,11 @@ import { parseGoTimeString } from '../../utils/parse-go-time'
|
|
|
10
13
|
|
|
11
14
|
export const CNPG_GROUP = 'postgresql.cnpg.io'
|
|
12
15
|
|
|
16
|
+
export function getCNPGPostgresMajor(cluster: any): number | undefined {
|
|
17
|
+
const major = cluster?.status?.pgDataImageInfo?.majorVersion ?? cluster?.spec?.imageCatalogRef?.major
|
|
18
|
+
return typeof major === 'number' && Number.isInteger(major) && major > 0 ? major : undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
/**
|
|
14
22
|
* Exact API-group match for a resource's `apiVersion` ("group/version").
|
|
15
23
|
*
|
|
@@ -56,7 +64,11 @@ export const CNPG_CLUSTER_PHASES_TRANSIENT = [
|
|
|
56
64
|
/** Losing the primary — degraded now, self-resolving only if a replica can take over. */
|
|
57
65
|
export const CNPG_CLUSTER_PHASES_FAILING = ['Failing over'] as const
|
|
58
66
|
|
|
59
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Reconciliation is blocked. The operator keeps retrying every one of these;
|
|
69
|
+
* only the plugin phases can clear without anyone changing something (a plugin
|
|
70
|
+
* Pod that comes back). See cnpgBlockedPhaseExplanation.
|
|
71
|
+
*/
|
|
60
72
|
export const CNPG_CLUSTER_PHASES_TERMINAL = [
|
|
61
73
|
'Cluster is unrecoverable and needs manual intervention',
|
|
62
74
|
// Not in 1.27 or 1.28; added upstream after 1.28 (PhaseDefinitionInvalid).
|
|
@@ -135,6 +147,34 @@ export function getCNPGClusterDisplayState(phase: string): string {
|
|
|
135
147
|
|
|
136
148
|
export type CNPGPhaseBucket = 'healthy' | 'transient' | 'failing' | 'terminal' | 'attention' | 'unknown'
|
|
137
149
|
|
|
150
|
+
const CNPG_PLUGIN_BLOCKED_PHASES = new Set<string>([
|
|
151
|
+
'Cluster cannot proceed to reconciliation due to an unknown plugin being required',
|
|
152
|
+
'Cluster cannot proceed to reconciliation due to an error while interacting with plugins',
|
|
153
|
+
])
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* What a blocked phase means for whoever is on call, with the operator's own
|
|
157
|
+
* `status.phaseReason` when it set one. The operator requeues the plugin
|
|
158
|
+
* phases every 10–15 s, so they clear once the plugin loads and answers;
|
|
159
|
+
* the others keep failing until their cause is fixed; "unrecoverable" is the
|
|
160
|
+
* one upstream itself says needs manual intervention.
|
|
161
|
+
*/
|
|
162
|
+
export function cnpgBlockedPhaseExplanation(phase: string, phaseReason?: string): { title: string; message: string; body: string } {
|
|
163
|
+
const said = typeof phaseReason === 'string' ? phaseReason.trim() : ''
|
|
164
|
+
const reason = said ? ` The operator reports: ${/[.!?]$/.test(said) ? said : `${said}.`}` : ''
|
|
165
|
+
const out = (title: string, body: string) => ({ title, body: `${body}${reason}`, message: `${phase}. ${body}${reason}` })
|
|
166
|
+
if (phase === 'Cluster is unrecoverable and needs manual intervention') {
|
|
167
|
+
return out('Cluster is unrecoverable', 'The operator cannot bring it back by itself.')
|
|
168
|
+
}
|
|
169
|
+
if (CNPG_PLUGIN_BLOCKED_PHASES.has(phase)) {
|
|
170
|
+
return out(
|
|
171
|
+
'Reconciliation is blocked by a plugin',
|
|
172
|
+
'The operator retries every few seconds and continues once the plugin is loaded and answering, so a plugin Pod that is restarting clears on its own; check the operator and plugin logs if it does not.',
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
return out('Reconciliation is blocked', 'The operator keeps retrying, but this does not clear until its cause is fixed.')
|
|
176
|
+
}
|
|
177
|
+
|
|
138
178
|
export function classifyCNPGClusterPhase(phase: string): CNPGPhaseBucket {
|
|
139
179
|
if (!phase) return 'unknown'
|
|
140
180
|
if ((CNPG_CLUSTER_PHASES_HEALTHY as readonly string[]).includes(phase)) return 'healthy'
|
|
@@ -414,7 +454,6 @@ export function getCNPGClusterUpdateStrategy(resource: any): string {
|
|
|
414
454
|
return resource.spec?.primaryUpdateStrategy || 'unsupervised'
|
|
415
455
|
}
|
|
416
456
|
|
|
417
|
-
export const CNPG_BARMAN_PLUGIN_NAME = 'barman-cloud.cloudnative-pg.io'
|
|
418
457
|
|
|
419
458
|
/** The API group ObjectStore is served under. `objectstores` is a generic
|
|
420
459
|
* plural several operators ship, so every link to one has to be qualified —
|
|
@@ -438,20 +477,10 @@ export interface CNPGBarmanPlugin {
|
|
|
438
477
|
* status.lastSuccessfulBackup / firstRecoverabilityPoint entirely.
|
|
439
478
|
*/
|
|
440
479
|
export function getCNPGClusterBarmanPlugin(resource: any): CNPGBarmanPlugin | null {
|
|
441
|
-
const
|
|
442
|
-
if (!Array.isArray(plugins)) return null
|
|
443
|
-
const plugin = plugins.find(
|
|
444
|
-
(p: any) => p?.name === CNPG_BARMAN_PLUGIN_NAME && p?.enabled !== false
|
|
445
|
-
)
|
|
480
|
+
const plugin = cnpgBarmanPlugin(cnpgBackupDeclaration(resource))
|
|
446
481
|
if (!plugin) return null
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
barmanObjectName: plugin.parameters?.barmanObjectName,
|
|
450
|
-
// ObjectStore keys the recovery window by the plugin's serverName when set,
|
|
451
|
-
// otherwise by the cluster name.
|
|
452
|
-
serverName: plugin.parameters?.serverName || resource.metadata?.name,
|
|
453
|
-
isWALArchiver: plugin.isWALArchiver === true,
|
|
454
|
-
}
|
|
482
|
+
const { name, barmanObjectName, serverName, isWALArchiver } = plugin
|
|
483
|
+
return { name, barmanObjectName, serverName, isWALArchiver }
|
|
455
484
|
}
|
|
456
485
|
|
|
457
486
|
export function getCNPGClusterBackupConfig(resource: any): {
|
|
@@ -518,7 +547,7 @@ export function getCNPGDeclarativeMessage(resource: any): string | undefined {
|
|
|
518
547
|
export function getCNPGReclaimPolicy(resource: any): { value: string; destructive: boolean } {
|
|
519
548
|
const spec = resource?.spec ?? {}
|
|
520
549
|
const raw =
|
|
521
|
-
spec.databaseReclaimPolicy ?? spec.publicationReclaimPolicy ?? spec.subscriptionReclaimPolicy ?? 'retain'
|
|
550
|
+
spec.databaseReclaimPolicy ?? spec.publicationReclaimPolicy ?? spec.subscriptionReclaimPolicy ?? spec.databaseRoleReclaimPolicy ?? 'retain'
|
|
522
551
|
return { value: raw, destructive: String(raw).toLowerCase() === 'delete' }
|
|
523
552
|
}
|
|
524
553
|
|
|
@@ -933,15 +962,15 @@ export function getCNPGScheduledBackupStatus(resource: any): StatusBadge {
|
|
|
933
962
|
|
|
934
963
|
// If we have a last schedule time, it's active
|
|
935
964
|
if (resource.status?.lastScheduleTime) {
|
|
936
|
-
return { text: '
|
|
965
|
+
return { text: 'Enabled', color: healthColors.neutral, level: 'neutral' }
|
|
937
966
|
}
|
|
938
967
|
|
|
939
968
|
// Check if immediate flag is set and no schedule has run yet
|
|
940
969
|
if (resource.spec?.immediate) {
|
|
941
|
-
return { text: 'Immediate', color: healthColors.
|
|
970
|
+
return { text: 'Immediate run requested', color: healthColors.neutral, level: 'neutral' }
|
|
942
971
|
}
|
|
943
972
|
|
|
944
|
-
return { text: '
|
|
973
|
+
return { text: 'Enabled · not run yet', color: healthColors.neutral, level: 'neutral' }
|
|
945
974
|
}
|
|
946
975
|
|
|
947
976
|
export function getCNPGScheduledBackupCluster(resource: any): string {
|
|
@@ -1030,8 +1059,10 @@ export function getCNPGPoolerStatus(resource: any): StatusBadge {
|
|
|
1030
1059
|
return { text: 'Paused', color: healthColors.degraded, level: 'degraded' }
|
|
1031
1060
|
}
|
|
1032
1061
|
|
|
1062
|
+
// status.instances counts scheduled Pods only; readiness lives on the
|
|
1063
|
+
// Pooler's Deployment, so this badge must not read as healthy.
|
|
1033
1064
|
if (desired > 0 && scheduled >= desired) {
|
|
1034
|
-
return { text: '
|
|
1065
|
+
return { text: `${desired} instance${desired === 1 ? '' : 's'} requested`, color: healthColors.neutral, level: 'neutral' }
|
|
1035
1066
|
}
|
|
1036
1067
|
|
|
1037
1068
|
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { getContourSecretReference, getHTTPProxyTLSSecret } from './resource-utils-contour'
|
|
4
|
+
|
|
5
|
+
const proxy = (secretName?: string) => ({
|
|
6
|
+
metadata: { namespace: 'shop' },
|
|
7
|
+
spec: { virtualhost: { fqdn: 'shop.example.com', tls: secretName === undefined ? {} : { secretName } } },
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
describe('getHTTPProxyTLSSecret', () => {
|
|
11
|
+
it('resolves a bare name in the proxy namespace', () => {
|
|
12
|
+
expect(getHTTPProxyTLSSecret(proxy('cert'))).toEqual({ namespace: 'shop', name: 'cert' })
|
|
13
|
+
})
|
|
14
|
+
it('resolves a delegated namespace/name', () => {
|
|
15
|
+
expect(getHTTPProxyTLSSecret(proxy('certs/wildcard'))).toEqual({ namespace: 'certs', name: 'wildcard' })
|
|
16
|
+
})
|
|
17
|
+
it('defaults an empty namespace to the proxy namespace', () => {
|
|
18
|
+
expect(getHTTPProxyTLSSecret(proxy('/cert'))).toEqual({ namespace: 'shop', name: 'cert' })
|
|
19
|
+
})
|
|
20
|
+
it('rejects names that cannot identify a Kubernetes Secret', () => {
|
|
21
|
+
expect(getHTTPProxyTLSSecret(proxy('certs/a/b'))).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
it('returns null without a secret', () => {
|
|
24
|
+
expect(getHTTPProxyTLSSecret(proxy())).toBeNull()
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
interface ReferenceCase {
|
|
29
|
+
case: string
|
|
30
|
+
raw: string
|
|
31
|
+
defaultNamespace: string
|
|
32
|
+
valid: boolean
|
|
33
|
+
namespace?: string
|
|
34
|
+
name?: string
|
|
35
|
+
}
|
|
36
|
+
const referenceCases: ReferenceCase[] = JSON.parse(readFileSync(new URL('../../../../../pkg/configrefs/testdata/contour_secret_refs.json', import.meta.url), 'utf8'))
|
|
37
|
+
|
|
38
|
+
describe('shared Go/TS Contour reference contract', () => {
|
|
39
|
+
it.each(referenceCases)('$case', (tc) => {
|
|
40
|
+
const expected = tc.valid ? { namespace: tc.namespace, name: tc.name } : undefined
|
|
41
|
+
expect(getContourSecretReference(tc.raw, tc.defaultNamespace)).toEqual(expected)
|
|
42
|
+
expect(getHTTPProxyTLSSecret({ ...proxy(tc.raw), metadata: { namespace: tc.defaultNamespace } })).toEqual(expected ?? null)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { validateRFC1123Label, validateRFC1123Subdomain } from '../../utils/validators'
|
|
2
|
+
|
|
1
3
|
// Contour HTTPProxy CRD utility functions for resource list cells and detail renderers
|
|
2
4
|
|
|
3
5
|
export function getHTTPProxyFQDN(resource: any): string {
|
|
@@ -32,3 +34,17 @@ export function getHTTPProxyIncludeCount(resource: any): number {
|
|
|
32
34
|
export function hasHTTPProxyTLS(resource: any): boolean {
|
|
33
35
|
return !!resource.spec?.virtualhost?.tls
|
|
34
36
|
}
|
|
37
|
+
|
|
38
|
+
export function getHTTPProxyTLSSecret(resource: any): { namespace: string; name: string } | null {
|
|
39
|
+
const raw: string | undefined = resource.spec?.virtualhost?.tls?.secretName
|
|
40
|
+
return raw ? getContourSecretReference(raw, resource.metadata?.namespace ?? '') ?? null : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Contour's delegated TLS secret form is namespace/name. */
|
|
44
|
+
export function getContourSecretReference(raw: string, defaultNamespace: string): { name: string; namespace: string } | undefined {
|
|
45
|
+
const parts = raw.split('/')
|
|
46
|
+
const namespace = parts.length === 1 ? defaultNamespace : parts[0] || defaultNamespace
|
|
47
|
+
const name = parts.length === 1 ? parts[0] : parts[1]
|
|
48
|
+
if (parts.length > 2 || !validateRFC1123Label(namespace).valid || !validateRFC1123Subdomain(name).valid) return undefined
|
|
49
|
+
return { name, namespace }
|
|
50
|
+
}
|
|
@@ -8,13 +8,17 @@ import { pluralize } from '../../utils/pluralize'
|
|
|
8
8
|
// SHARED HELPERS
|
|
9
9
|
// ============================================================================
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// FQDNs require the registry domain supplied by the producer. Undotted
|
|
12
|
+
// names use Istio's documented namespace-relative expansion.
|
|
13
|
+
export function resolveIstioServiceHost(host: string, defaultNamespace: string, serviceDomain = ''): { name: string; namespace: string } | undefined {
|
|
14
|
+
const label = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
|
|
13
15
|
const parts = host.split('.')
|
|
14
|
-
return {
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
if (parts.length === 1 && host.length <= 63 && label.test(host)) return { name: host, namespace: defaultNamespace }
|
|
17
|
+
if (serviceDomain && host.endsWith(`.svc.${serviceDomain}`)) {
|
|
18
|
+
const service = host.slice(0, -(`.svc.${serviceDomain}`.length)).split('.')
|
|
19
|
+
if (service.length === 2 && service.every(part => part.length <= 63 && label.test(part))) return { name: service[0], namespace: service[1] }
|
|
17
20
|
}
|
|
21
|
+
return undefined
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
// ============================================================================
|
|
@@ -103,16 +107,16 @@ export function getVirtualServiceTlsRoutes(resource: any): any[] {
|
|
|
103
107
|
return resource.spec?.tls || []
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
export function getVirtualServiceDestinations(resource: any): Array<{ host: string; namespace: string; port?: number; subset?: string; weight?: number }> {
|
|
110
|
+
export function getVirtualServiceDestinations(resource: any, serviceDomain = ''): Array<{ host: string; namespace: string; port?: number; subset?: string; weight?: number }> {
|
|
107
111
|
const destinations: Array<{ host: string; namespace: string; port?: number; subset?: string; weight?: number }> = []
|
|
108
112
|
const httpRoutes = resource.spec?.http || []
|
|
109
113
|
for (const route of httpRoutes) {
|
|
110
114
|
for (const dest of (route.route || [])) {
|
|
111
115
|
if (dest.destination?.host) {
|
|
112
|
-
const parsed =
|
|
116
|
+
const parsed = resolveIstioServiceHost(dest.destination.host, resource.metadata?.namespace || '', serviceDomain)
|
|
113
117
|
destinations.push({
|
|
114
118
|
host: dest.destination.host,
|
|
115
|
-
namespace: parsed
|
|
119
|
+
namespace: parsed?.namespace || '',
|
|
116
120
|
port: dest.destination.port?.number,
|
|
117
121
|
subset: dest.destination.subset,
|
|
118
122
|
weight: dest.weight,
|
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
import { apiVersionToGroup, kindToPluralWithGroup } from '../../utils/navigation'
|
|
2
|
+
import type { ResourceRef } from '../../types/core'
|
|
3
|
+
|
|
4
|
+
/** KReference records apiVersion or an explicit nonempty group. */
|
|
5
|
+
export function getKnativeReference(reference: any, defaultNamespace: string): ResourceRef | undefined {
|
|
6
|
+
const version = reference?.apiVersion
|
|
7
|
+
let group = reference?.group
|
|
8
|
+
if (typeof version === 'string' && version) {
|
|
9
|
+
const parts = version.split('/')
|
|
10
|
+
if (parts.length > 2 || parts.some(part => !part)) return undefined
|
|
11
|
+
const observed = apiVersionToGroup(version)
|
|
12
|
+
if (group && group !== observed) return undefined
|
|
13
|
+
group = observed
|
|
14
|
+
} else if (typeof group !== 'string' || !group) return undefined
|
|
15
|
+
if (typeof reference?.kind !== 'string' || !reference.kind || typeof reference?.name !== 'string' || !reference.name) return undefined
|
|
16
|
+
return { group, kind: kindToPluralWithGroup(reference.kind, group), name: reference.name, namespace: reference.namespace || defaultNamespace }
|
|
17
|
+
}
|
|
18
|
+
|
|
1
19
|
// KNative CRD utility functions
|
|
2
20
|
|
|
3
21
|
import type { StatusBadge } from './resource-utils'
|
|
@@ -86,12 +86,57 @@ export interface PodProblem {
|
|
|
86
86
|
* misses. Presentation-only; the backend `scheduling` issue source does the
|
|
87
87
|
* structured decomposition + node-label resolution (e.g. naming arm64).
|
|
88
88
|
*/
|
|
89
|
-
export function summarizeSchedulerMessage(message?: string): string {
|
|
89
|
+
export function summarizeSchedulerMessage(message?: string, options: { plain?: boolean } = {}): string {
|
|
90
90
|
if (!message) return ''
|
|
91
91
|
let m = message.split('. preemption:')[0].split(' preemption:')[0].trim()
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
if (options.plain) {
|
|
93
|
+
m = m.replace(/^(?:Pod cannot be scheduled:\s*)?(?:Unschedulable:\s*)?(?:0\/\d+ nodes are available:\s*)?/i, '')
|
|
94
|
+
// Schedulers with dynamic resource allocation append this after the node reasons.
|
|
95
|
+
m = m.replace(/no new claims to deallocate,?/i, '').trim()
|
|
96
|
+
} else {
|
|
97
|
+
const colon = m.indexOf(':')
|
|
98
|
+
if (colon >= 0) m = m.slice(colon + 1).trim()
|
|
99
|
+
}
|
|
100
|
+
m = m.replace(/\.\s*$/, '').trim()
|
|
101
|
+
if (!options.plain) return m
|
|
102
|
+
const total = /0\/(\d+) nodes (?:are )?available/.exec(message)?.[1]
|
|
103
|
+
const causes: [RegExp, string][] = [
|
|
104
|
+
[/^(\d+) (?:Too many pods|(?:node\(s\) )?insufficient pods)(?: \(0\/\d+ nodes available\))?$/i, 'have reached their Pod limit'],
|
|
105
|
+
[/^(\d+) (?:node\(s\) )?Insufficient cpu(?: \(0\/\d+ nodes available\))?$/i, 'do not have enough CPU'],
|
|
106
|
+
[/^(\d+) (?:node\(s\) )?Insufficient memory(?: \(0\/\d+ nodes available\))?$/i, 'do not have enough memory'],
|
|
107
|
+
[/^(\d+) node\(s\) (?:didn.t match Pod.s node affinity\/selector|node affinity\/selector mismatch)(?: \(0\/\d+ nodes available\))?$/i, 'do not match the Pod’s node affinity or selector'],
|
|
108
|
+
[/^(\d+) node\(s\) (?:had )?untolerated taint(?:\(s\))?(?: \{[^}]*\}| [^\s]+)?(?: \(0\/\d+ nodes available\))?$/i, 'have taints the Pod does not tolerate'],
|
|
109
|
+
[/^(\d+) node\(s\) (?:had )?volume node[- ]affinity conflict(?: \(0\/\d+ nodes available\))?$/i, 'do not match the volume’s node affinity'],
|
|
110
|
+
]
|
|
111
|
+
return splitSchedulerClauses(m).filter((part) => part.trim()).map((part) => {
|
|
112
|
+
const clause = part.trim()
|
|
113
|
+
for (const [pattern, cause] of causes) {
|
|
114
|
+
const count = pattern.exec(clause)?.[1]
|
|
115
|
+
if (count) return `${count === '1' ? '1 node' : count === total && count === '2' ? 'both nodes' : count === total ? 'all nodes' : `${count} nodes`} ${count === '1' ? cause.replace(/^have /, 'has ').replace(/^do /, 'does ') : cause}`
|
|
116
|
+
}
|
|
117
|
+
if (/^(?:pod has )?unbound (?:immediate )?PersistentVolumeClaims?(?:\s*\(0\/\d+ nodes available\))?$/i.test(clause)) return 'a required volume claim is not bound'
|
|
118
|
+
return clause
|
|
119
|
+
}).join('; ')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Splits on commas and semicolons, except where the next bracket ahead closes:
|
|
123
|
+
// scheduler taints and resolved node labels can contain comma-separated values.
|
|
124
|
+
// One pass from the end keeps long messages linear.
|
|
125
|
+
function splitSchedulerClauses(m: string): string[] {
|
|
126
|
+
const parts: string[] = []
|
|
127
|
+
let end = m.length
|
|
128
|
+
let insideBrackets = false
|
|
129
|
+
for (let i = m.length - 1; i >= 0; i--) {
|
|
130
|
+
const c = m[i]
|
|
131
|
+
if (c === ']' || c === '}') insideBrackets = true
|
|
132
|
+
else if (c === '[' || c === '{') insideBrackets = false
|
|
133
|
+
else if ((c === ',' || c === ';') && !insideBrackets) {
|
|
134
|
+
parts.push(m.slice(i + 1, end))
|
|
135
|
+
end = i
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
parts.push(m.slice(0, end))
|
|
139
|
+
return parts.reverse()
|
|
95
140
|
}
|
|
96
141
|
|
|
97
142
|
/** Tailwind classes for severity dot indicators (used in tooltips and alert banners) */
|
|
@@ -1854,7 +1899,7 @@ export function getGatewayClassDescription(gc: any): string {
|
|
|
1854
1899
|
}
|
|
1855
1900
|
|
|
1856
1901
|
// ============================================================================
|
|
1857
|
-
// GATEWAY API ROUTE UTILITIES (shared by HTTPRoute, GRPCRoute, TCPRoute, TLSRoute)
|
|
1902
|
+
// GATEWAY API ROUTE UTILITIES (shared by HTTPRoute, GRPCRoute, TCPRoute, TLSRoute, UDPRoute)
|
|
1858
1903
|
// ============================================================================
|
|
1859
1904
|
|
|
1860
1905
|
const GATEWAY_API_GROUP = 'gateway.networking.k8s.io'
|
|
@@ -2264,6 +2309,37 @@ export function parseColumnFilterExcludes(filtersParam: string | null): Record<s
|
|
|
2264
2309
|
return excludes
|
|
2265
2310
|
}
|
|
2266
2311
|
|
|
2312
|
+
/** The Resources list's own filter params in the URL (the kind is in the path, `apiGroup` beside it). */
|
|
2313
|
+
export const RESOURCE_LIST_FILTER_PARAMS = ['search', 'regex', 'filters', 'problems', 'labels', 'ownerKind', 'ownerName', 'showInactive'] as const
|
|
2314
|
+
|
|
2315
|
+
/** The per-kind filters in a query string: what a kind switch resets or restores. */
|
|
2316
|
+
export function kindFiltersFromSearch(search: string | undefined) {
|
|
2317
|
+
const params = new URLSearchParams(search ?? '')
|
|
2318
|
+
const filtersParam = params.get('filters')
|
|
2319
|
+
return {
|
|
2320
|
+
columnFilters: parseColumnFilters(filtersParam),
|
|
2321
|
+
columnFilterExcludes: parseColumnFilterExcludes(filtersParam),
|
|
2322
|
+
problemFilters: params.get('problems')?.split(',').filter(Boolean) || [],
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
/** The `filters` param value. Drops an exclude flag on a column with no active values. */
|
|
2327
|
+
export function columnFiltersParam(colFilters: Record<string, string[]>, colExcludes: Record<string, boolean>): string {
|
|
2328
|
+
const activeExcludes = Object.fromEntries(
|
|
2329
|
+
Object.entries(colExcludes).filter(([k, on]) => on && (colFilters[k]?.length ?? 0) > 0)
|
|
2330
|
+
)
|
|
2331
|
+
return serializeColumnFilters(colFilters, activeExcludes)
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
/** Inverse of kindFiltersFromSearch: the `filters` + `problems` query, '' when none. */
|
|
2335
|
+
export function kindFiltersToSearch(f: ReturnType<typeof kindFiltersFromSearch>): string {
|
|
2336
|
+
const params = new URLSearchParams()
|
|
2337
|
+
const filters = columnFiltersParam(f.columnFilters, f.columnFilterExcludes)
|
|
2338
|
+
if (filters) params.set('filters', filters)
|
|
2339
|
+
if (f.problemFilters.length > 0) params.set('problems', f.problemFilters.join(','))
|
|
2340
|
+
return params.toString()
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2267
2343
|
// Split a single "col[:operator]:values" pair into its decoded key, values, and
|
|
2268
2344
|
// whether the explicit exclude operator was present. The first literal colon
|
|
2269
2345
|
// delimits key from the rest (keys are encoded, so their own colons don't
|
|
@@ -2342,7 +2418,7 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
|
|
|
2342
2418
|
if (kindLower === 'hpas' || kindLower === 'horizontalpodautoscalers') return getHPAStatus(resource).text
|
|
2343
2419
|
if (kindLower === 'gateways') return getGatewayStatus(resource).text
|
|
2344
2420
|
if (kindLower === 'gatewayclasses') return getGatewayClassStatus(resource).text
|
|
2345
|
-
if (['httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes'].includes(kindLower)) return getRouteStatus(resource).text
|
|
2421
|
+
if (['httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes', 'udproutes'].includes(kindLower)) return getRouteStatus(resource).text
|
|
2346
2422
|
if (kindLower === 'sealedsecrets') return getSealedSecretStatus(resource).text
|
|
2347
2423
|
if (kindLower === 'poddisruptionbudgets') return getPDBStatus(resource).text
|
|
2348
2424
|
if (kindLower === 'applications') return getArgoApplicationStatus(resource).text
|
|
@@ -27,7 +27,7 @@ function columnSetBody(key: string): string {
|
|
|
27
27
|
// in the sortable-key list, so neither affordance ever renders on it. w-20
|
|
28
28
|
// (80px) leaves 47px.
|
|
29
29
|
describe('Rules column width', () => {
|
|
30
|
-
const kinds = ['kyvernopolicies', 'clusterpolicies', 'httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes']
|
|
30
|
+
const kinds = ['kyvernopolicies', 'clusterpolicies', 'httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes', 'udproutes']
|
|
31
31
|
|
|
32
32
|
it.each(kinds)('%s Rules column is at least w-20', (kind) => {
|
|
33
33
|
const rules = columnSetBody(kind).match(/\{ key: 'rules'.*\}/)
|
|
@@ -28,3 +28,33 @@ describe('summarizeSchedulerMessage', () => {
|
|
|
28
28
|
expect(summarizeSchedulerMessage(undefined)).toBe('')
|
|
29
29
|
})
|
|
30
30
|
})
|
|
31
|
+
|
|
32
|
+
it.each([
|
|
33
|
+
['0/2 nodes are available: 2 Too many pods.', 'both nodes have reached their Pod limit'],
|
|
34
|
+
['Unschedulable: 0/2 nodes are available: 2 Too many pods.', 'both nodes have reached their Pod limit'],
|
|
35
|
+
['2 node(s) insufficient pods (0/2 nodes available)', 'both nodes have reached their Pod limit'],
|
|
36
|
+
['0/1 nodes are available: 1 Insufficient cpu.', '1 node does not have enough CPU'],
|
|
37
|
+
['0/3 nodes are available: 3 Insufficient cpu.', 'all nodes do not have enough CPU'],
|
|
38
|
+
['0/2 nodes are available: 2 Insufficient memory, 2 Too many pods.', 'both nodes do not have enough memory; both nodes have reached their Pod limit'],
|
|
39
|
+
["0/2 nodes are available: 2 node(s) didn’t match Pod’s node affinity/selector.", 'both nodes do not match the Pod’s node affinity or selector'],
|
|
40
|
+
['1 node(s) had untolerated taint {dedicated: db}.', '1 node has taints the Pod does not tolerate'],
|
|
41
|
+
['0/2 nodes are available: 1 node(s) had untolerated taint {dedicated: db}.', '1 node has taints the Pod does not tolerate'],
|
|
42
|
+
['0/2 nodes are available: pod has unbound immediate PersistentVolumeClaims.', 'a required volume claim is not bound'],
|
|
43
|
+
['1 node(s) untolerated taint dedicated=db, 1 node(s) insufficient cpu (0/2 nodes available)', '1 node has taints the Pod does not tolerate; 1 node does not have enough CPU'],
|
|
44
|
+
['2 node(s) node affinity/selector mismatch (0/2 nodes available)', 'both nodes do not match the Pod’s node affinity or selector'],
|
|
45
|
+
['unbound PersistentVolumeClaim, 1 node(s) volume node-affinity conflict (0/2 nodes available)', 'a required volume claim is not bound; 1 node does not match the volume’s node affinity'],
|
|
46
|
+
['0/2 nodes are available: 1 node(s) had volume node affinity conflict, 1 Insufficient memory.', '1 node does not match the volume’s node affinity; 1 node does not have enough memory'],
|
|
47
|
+
['no node has kubernetes.io/arch=arm64 — 2 node(s) carry kubernetes.io/arch: [amd64]; 1 node(s) insufficient cpu (0/3 nodes available)', 'no node has kubernetes.io/arch=arm64 — 2 node(s) carry kubernetes.io/arch: [amd64]; 1 node does not have enough CPU'],
|
|
48
|
+
['custom scheduler says 2 Insufficient cpu, 1 Insufficient memory', 'custom scheduler says 2 Insufficient cpu; 1 node does not have enough memory'],
|
|
49
|
+
['1 node(s) pod anti-affinity conflict, 1 node(s) insufficient memory, no node has zone=west — 2 node(s) carry zone: [east, north]', '1 node(s) pod anti-affinity conflict; 1 node does not have enough memory; no node has zone=west — 2 node(s) carry zone: [east, north]'],
|
|
50
|
+
["0/14 nodes are available: 6 node(s) had untolerated taint(s), 8 node(s) didn't match Pod's node affinity/selector. no new claims to deallocate, preemption: 0/14 nodes are available: 14 Preemption is not helpful for scheduling.", '6 nodes have taints the Pod does not tolerate; 8 nodes do not match the Pod’s node affinity or selector'],
|
|
51
|
+
])('offers a plain summary additively for %s', (raw, expected) => {
|
|
52
|
+
expect(summarizeSchedulerMessage(raw, { plain: true })).toBe(expected)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('summarizes a very long, separator-heavy message in linear time', () => {
|
|
56
|
+
const message = '0/2 nodes are available: ' + '1 node(s) custom reason, '.repeat(10_000) + '2 Insufficient cpu.'
|
|
57
|
+
const summary = summarizeSchedulerMessage(message, { plain: true })
|
|
58
|
+
expect(summary.split('; ')).toHaveLength(10_001)
|
|
59
|
+
expect(summary.endsWith('both nodes do not have enough CPU')).toBe(true)
|
|
60
|
+
})
|