@skyhook-io/k8s-ui 1.16.3 → 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/applications/ApplicationDetail.test.tsx +15 -0
- package/src/components/applications/ApplicationDetail.tsx +17 -15
- 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/checks/types.ts +2 -13
- 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/GitOpsTableView.tsx +9 -24
- package/src/components/gitops/detail-helpers.ts +1 -1
- 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 +29 -83
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +40 -60
- 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 +14 -3
- package/src/components/issues/issues.test.ts +50 -2
- 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.notice.test.tsx +59 -0
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +44 -10
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogStream.ts +28 -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 +309 -117
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +5 -4
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
- 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 +7 -8
- 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 +9 -17
- package/src/components/resources/renderers/CNPGShared.tsx +2 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- 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/ConfigAuditReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +62 -0
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +15 -14
- package/src/components/resources/renderers/EventRenderer.tsx +3 -3
- package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
- package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/HPARenderer.tsx +57 -17
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
- package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +36 -5
- 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/KarpenterNodeClaimRenderer.tsx +2 -14
- package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +15 -13
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
- 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/KustomizationRenderer.tsx +2 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
- package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
- package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
- package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
- package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +62 -36
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +9 -22
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
- 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 -24
- 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-keda.ts +1 -7
- package/src/components/resources/resource-utils-knative.ts +18 -0
- package/src/components/resources/resource-utils-kyverno.ts +1 -7
- package/src/components/resources/resource-utils-velero.ts +0 -12
- package/src/components/resources/resource-utils.ts +99 -39
- 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/DrainPlanDialog.tsx +1 -0
- package/src/components/shared/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
- package/src/components/shared/ResourceActionsBar.tsx +20 -24
- package/src/components/shared/ResourceRendererDispatch.tsx +13 -10
- package/src/components/shared/SetImageDialog.test.ts +75 -6
- package/src/components/shared/SetImageDialog.tsx +92 -84
- package/src/components/shared/index.ts +4 -1
- package/src/components/timeline/TimelineList.tsx +2 -15
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -11
- 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 +8 -74
- 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/ScheduleValue.test.tsx +5 -0
- package/src/components/ui/ScheduleValue.tsx +3 -2
- package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +175 -1
- package/src/components/ui/drawer-components.tsx +103 -36
- 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 +101 -0
- package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +297 -104
- 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 +21 -2
- package/src/types/core.ts +60 -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 +49 -2
- package/src/utils/format.ts +9 -1
- package/src/utils/gateway-references.test.ts +17 -0
- package/src/utils/gateway-references.ts +29 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/gitops-write-guard.test.ts +401 -0
- package/src/utils/gitops-write-guard.ts +509 -0
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/ingress-class-references.ts +22 -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 +70 -0
- package/src/utils/navigation.ts +91 -8
- package/src/utils/node-lifecycle.test.ts +36 -0
- package/src/utils/node-lifecycle.ts +77 -0
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +69 -0
- package/src/utils/resource-hierarchy.ts +41 -7
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/resource-refs.test.ts +23 -0
- package/src/utils/resource-refs.ts +30 -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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { highlightJson, tokenizeJson, unescapeJsonStrings } from './log-format'
|
|
3
|
+
|
|
4
|
+
describe('unescapeJsonStrings', () => {
|
|
5
|
+
it('expands whitespace inside strings while leaving escapes outside them untouched', () => {
|
|
6
|
+
expect(unescapeJsonStrings('{"msg":"first\\r\\nsecond\\nthird\\tend"}')).toBe('{"msg":"first\r\nsecond\nthird\tend"}')
|
|
7
|
+
expect(unescapeJsonStrings('outside\\n {"msg":"inside\\n"} outside\\t')).toBe('outside\\n {"msg":"inside\n"} outside\\t')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('keeps escaped quotes and backslashes in their original string', () => {
|
|
11
|
+
expect(unescapeJsonStrings('{"msg":"say \\"hello\\"\\nnext","path":"C:\\\\docs"}')).toBe('{"msg":"say \\"hello\\"\nnext","path":"C:\\\\docs"}')
|
|
12
|
+
expect(unescapeJsonStrings(JSON.stringify({ empty: '', nested: ['a\nb', 'c\td'], number: 42, yes: true }))).toBe('{"empty":"","nested":["a\nb","c\td"],"number":42,"yes":true}')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('stops once at an unterminated string with many escaped quotes', () => {
|
|
16
|
+
const text = '"' + '\\"'.repeat(100_000) + '\\n'
|
|
17
|
+
expect(unescapeJsonStrings(text)).toBe(text)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('JSON highlighting', () => {
|
|
22
|
+
it('keeps primitive-looking words inside strings and preserves JSON token colors', () => {
|
|
23
|
+
const tokens = tokenizeJson('{ "text" : "true null 123 \\"quoted\\"", "values": [-1.2e+3, true, false, null, ""] }')
|
|
24
|
+
expect(tokens.filter((token) => token.type === 'string').map((token) => token.value)).toEqual(['"true null 123 \\"quoted\\""', '""'])
|
|
25
|
+
expect(tokens.filter((token) => token.type === 'number').map((token) => token.value)).toEqual(['-1.2e+3'])
|
|
26
|
+
expect(highlightJson('{"a":true,"b":null,"n":12}')).toBe('{<span style="color:#7cacf8">"a"</span>:<span style="color:#c678dd">true</span>,<span style="color:#7cacf8">"b"</span>:<span style="color:#808080">null</span>,<span style="color:#7cacf8">"n"</span>:<span style="color:#e5c07b">12</span>}')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('escapes HTML in log values before inserting highlighting markup', () => {
|
|
30
|
+
const html = highlightJson(JSON.stringify({ msg: '<script>alert("x")</script>&' }))
|
|
31
|
+
expect(html).toContain('<script>')
|
|
32
|
+
expect(html).toContain('</script>&')
|
|
33
|
+
expect(html).not.toContain('<script>')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('leaves a long unterminated string as plain text without rescanning escaped quotes', () => {
|
|
37
|
+
const text = '"' + '\\"'.repeat(100_000)
|
|
38
|
+
expect(tokenizeJson(text)).toEqual([{ type: 'text', value: text }])
|
|
39
|
+
expect(highlightJson(text)).toBe(text)
|
|
40
|
+
})
|
|
41
|
+
})
|
package/src/utils/log-format.ts
CHANGED
|
@@ -363,24 +363,71 @@ export const SYNTAX_COLOR_NUMBER = '#e5c07b'
|
|
|
363
363
|
export const SYNTAX_COLOR_BOOLEAN = '#c678dd'
|
|
364
364
|
export const SYNTAX_COLOR_NULL = '#808080'
|
|
365
365
|
|
|
366
|
+
export interface JsonToken {
|
|
367
|
+
type: 'text' | 'key' | 'string' | 'number' | 'boolean' | 'null'
|
|
368
|
+
value: string
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function jsonStringEnd(text: string, start: number): number {
|
|
372
|
+
let offset = start + 1
|
|
373
|
+
while (offset < text.length) {
|
|
374
|
+
const char = text[offset++]
|
|
375
|
+
if (char === '\\') offset++
|
|
376
|
+
else if (char === '"') return offset
|
|
377
|
+
}
|
|
378
|
+
return -1
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function tokenizeJson(text: string): JsonToken[] {
|
|
382
|
+
const tokens: JsonToken[] = []
|
|
383
|
+
const primitiveRe = /(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|\b(true|false)\b|\b(null)\b/y
|
|
384
|
+
let copied = 0
|
|
385
|
+
let offset = 0
|
|
386
|
+
while (offset < text.length) {
|
|
387
|
+
const start = offset
|
|
388
|
+
let type: JsonToken['type']
|
|
389
|
+
let value: string
|
|
390
|
+
if (text[offset] === '"') {
|
|
391
|
+
const end = jsonStringEnd(text, offset)
|
|
392
|
+
if (end < 0) break
|
|
393
|
+
value = text.slice(start, end)
|
|
394
|
+
let colon = end
|
|
395
|
+
while (colon < text.length && /\s/.test(text[colon])) colon++
|
|
396
|
+
type = text[colon] === ':' ? 'key' : 'string'
|
|
397
|
+
offset = type === 'key' ? colon + 1 : end
|
|
398
|
+
} else {
|
|
399
|
+
primitiveRe.lastIndex = offset
|
|
400
|
+
const match = primitiveRe.exec(text)
|
|
401
|
+
if (!match) {
|
|
402
|
+
offset++
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
405
|
+
type = match[1] !== undefined ? 'number' : match[2] !== undefined ? 'boolean' : 'null'
|
|
406
|
+
value = match[0]
|
|
407
|
+
offset = primitiveRe.lastIndex
|
|
408
|
+
}
|
|
409
|
+
if (start > copied) tokens.push({ type: 'text', value: text.slice(copied, start) })
|
|
410
|
+
tokens.push({ type, value })
|
|
411
|
+
copied = offset
|
|
412
|
+
}
|
|
413
|
+
if (copied < text.length) tokens.push({ type: 'text', value: text.slice(copied) })
|
|
414
|
+
return tokens
|
|
415
|
+
}
|
|
416
|
+
|
|
366
417
|
/**
|
|
367
418
|
* Syntax-highlight a pretty-printed JSON string for HTML display.
|
|
368
419
|
* Wraps keys, string values, numbers, booleans, and null in colored spans.
|
|
369
420
|
* Applies HTML escaping internally — do not pre-escape the input.
|
|
370
421
|
*/
|
|
371
422
|
export function highlightJson(json: string): string {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if (
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if (bool) return `<span style="color:${SYNTAX_COLOR_BOOLEAN}">${bool}</span>`
|
|
381
|
-
if (nil) return `<span style="color:${SYNTAX_COLOR_NULL}">${nil}</span>`
|
|
382
|
-
return match
|
|
383
|
-
})
|
|
423
|
+
return tokenizeJson(escapeHtml(json)).map(({ type, value }) => {
|
|
424
|
+
if (type === 'key') return `<span style="color:${SYNTAX_COLOR_KEY}">${value}</span>:`
|
|
425
|
+
if (type === 'string') return `<span style="color:${SYNTAX_COLOR_STRING}">${value}</span>`
|
|
426
|
+
if (type === 'number') return `<span style="color:${SYNTAX_COLOR_NUMBER}">${value}</span>`
|
|
427
|
+
if (type === 'boolean') return `<span style="color:${SYNTAX_COLOR_BOOLEAN}">${value}</span>`
|
|
428
|
+
if (type === 'null') return `<span style="color:${SYNTAX_COLOR_NULL}">${value}</span>`
|
|
429
|
+
return value
|
|
430
|
+
}).join('')
|
|
384
431
|
}
|
|
385
432
|
|
|
386
433
|
/**
|
|
@@ -389,12 +436,21 @@ export function highlightJson(json: string): string {
|
|
|
389
436
|
* making stack traces and multi-line messages readable in the expanded view.
|
|
390
437
|
*/
|
|
391
438
|
export function unescapeJsonStrings(text: string): string {
|
|
392
|
-
|
|
393
|
-
|
|
439
|
+
let result = ''
|
|
440
|
+
let copied = 0
|
|
441
|
+
let offset = 0
|
|
442
|
+
while (offset < text.length) {
|
|
443
|
+
if (text[offset++] !== '"') continue
|
|
444
|
+
const start = offset - 1
|
|
445
|
+
offset = jsonStringEnd(text, start)
|
|
446
|
+
if (offset < 0) break
|
|
447
|
+
result += text.slice(copied, start) + text.slice(start, offset)
|
|
394
448
|
.replace(/\\r\\n/g, '\r\n')
|
|
395
449
|
.replace(/\\n/g, '\n')
|
|
396
450
|
.replace(/\\t/g, '\t')
|
|
397
|
-
|
|
451
|
+
copied = offset
|
|
452
|
+
}
|
|
453
|
+
return result + text.slice(copied)
|
|
398
454
|
}
|
|
399
455
|
|
|
400
456
|
// logfmt detection and parsing
|
package/src/utils/log-level.ts
CHANGED
|
@@ -49,14 +49,22 @@ export function normalizeLevel(raw: unknown): LogLevel | null {
|
|
|
49
49
|
return 'unknown'
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
// PostgreSQL
|
|
53
|
-
// anything else under `record` isn't PostgreSQL's.
|
|
52
|
+
// PostgreSQL writes every DEBUGn severity as DEBUG.
|
|
54
53
|
const POSTGRES_SEVERITIES: Record<string, LogLevel> = {
|
|
55
54
|
DEBUG: 'debug', LOG: 'info', INFO: 'info', NOTICE: 'info', WARNING: 'warn', ERROR: 'error', FATAL: 'error', PANIC: 'error',
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
const LEVEL_FIELD_KEYS = ['level', 'lvl', 'severity', 'levelname', 'log.level'] as const
|
|
59
58
|
|
|
59
|
+
export function selectPostgresRecord(obj: Record<string, unknown>): { record: Record<string, unknown>; raw: string; level: LogLevel } | null {
|
|
60
|
+
const record = obj.record
|
|
61
|
+
if (obj.logger !== 'postgres' || obj.msg !== 'record' || !record || typeof record !== 'object' || Array.isArray(record)) return null
|
|
62
|
+
const fields = record as Record<string, unknown>
|
|
63
|
+
const raw = fields.error_severity
|
|
64
|
+
const level = typeof raw === 'string' ? POSTGRES_SEVERITIES[raw.trim().toUpperCase()] : undefined
|
|
65
|
+
return typeof raw === 'string' && level ? { record: fields, raw, level } : null
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
/**
|
|
61
69
|
* Pick the level field of a structured record. Shared by detection and the
|
|
62
70
|
* structured-line badge so the badge text and the line's resolved level always
|
|
@@ -65,12 +73,8 @@ const LEVEL_FIELD_KEYS = ['level', 'lvl', 'severity', 'levelname', 'log.level']
|
|
|
65
73
|
export function selectLevelField(obj: Record<string, unknown>): { raw: unknown; level: LogLevel } | null {
|
|
66
74
|
// CloudNativePG wraps each PostgreSQL line in an instance-manager record
|
|
67
75
|
// whose own level is usually info; the database's severity is nested.
|
|
68
|
-
const pgRecord = obj
|
|
69
|
-
if (pgRecord
|
|
70
|
-
const raw = (pgRecord as Record<string, unknown>).error_severity
|
|
71
|
-
const level = typeof raw === 'string' ? POSTGRES_SEVERITIES[raw.trim().toUpperCase()] : undefined
|
|
72
|
-
if (level) return { raw, level }
|
|
73
|
-
}
|
|
76
|
+
const pgRecord = selectPostgresRecord(obj)
|
|
77
|
+
if (pgRecord) return { raw: pgRecord.raw, level: pgRecord.level }
|
|
74
78
|
for (const key of LEVEL_FIELD_KEYS) {
|
|
75
79
|
const level = normalizeLevel(obj[key])
|
|
76
80
|
if (level) return { raw: obj[key], level }
|
|
@@ -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
|
|
@@ -305,6 +362,19 @@ describe('refToSelectedResource', () => {
|
|
|
305
362
|
})
|
|
306
363
|
})
|
|
307
364
|
|
|
365
|
+
test('resolves AWS IngressClassParams without discovery', () => {
|
|
366
|
+
expect(refToSelectedResource({ kind: 'IngressClassParams', name: 'alb', group: 'elbv2.k8s.aws' })).toEqual({
|
|
367
|
+
kind: 'ingressclassparams',
|
|
368
|
+
name: 'alb',
|
|
369
|
+
namespace: '',
|
|
370
|
+
group: 'elbv2.k8s.aws',
|
|
371
|
+
})
|
|
372
|
+
expect(knownKindForPluralWithGroup('ingressclassparams', 'elbv2.k8s.aws')).toBe('IngressClassParams')
|
|
373
|
+
// Hosts without discovery fold the plural back to a Kind and re-pluralize it.
|
|
374
|
+
expect(pluralToKind('ingressclassparams')).toBe('IngressClassParams')
|
|
375
|
+
expect(kindToPlural(pluralToKind('ingressclassparams'))).toBe('ingressclassparams')
|
|
376
|
+
})
|
|
377
|
+
|
|
308
378
|
test('normalizes an omitted namespace for cluster-scoped references', () => {
|
|
309
379
|
expect(refToSelectedResource({ kind: 'NodePool', name: 'spot' })).toEqual({
|
|
310
380
|
kind: 'nodepools',
|
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.
|
|
@@ -41,6 +41,10 @@ const BUILTIN_PLURAL_TO_KIND: Record<string, string> = {
|
|
|
41
41
|
clusterrolebindings: 'ClusterRoleBinding',
|
|
42
42
|
serviceaccounts: 'ServiceAccount',
|
|
43
43
|
networkpolicies: 'NetworkPolicy',
|
|
44
|
+
// AWS Load Balancer Controller's IngressClass parameters. The Kind is
|
|
45
|
+
// singular and plural at once, so English rules cannot recover it in hosts
|
|
46
|
+
// that never load discovery.
|
|
47
|
+
ingressclassparams: 'IngressClassParams',
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
const BUILTIN_GROUP_KIND_TO_PLURAL: Record<string, string> = {
|
|
@@ -54,6 +58,7 @@ const BUILTIN_GROUP_PLURAL_TO_KIND: Record<string, string> = {
|
|
|
54
58
|
'metrics.k8s.io/pods': 'PodMetrics',
|
|
55
59
|
'metrics.k8s.io/nodes': 'NodeMetrics',
|
|
56
60
|
'scheduling.k8s.io/podgroups': 'PodGroup',
|
|
61
|
+
'elbv2.k8s.aws/ingressclassparams': 'IngressClassParams',
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
// Dynamic map built from API discovery — populated by initNavigationMap().
|
|
@@ -62,6 +67,18 @@ let discoveredPluralToKind: Record<string, string> | null = null
|
|
|
62
67
|
let discoveredKindToPlural: Record<string, string> | null = null
|
|
63
68
|
let discoveredGroupKindToPlural: Record<string, string> | null = null
|
|
64
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
|
+
}
|
|
65
82
|
|
|
66
83
|
/**
|
|
67
84
|
* Initialize navigation maps from discovered API resources.
|
|
@@ -70,17 +87,35 @@ let discoveredGroupPluralToKind: Record<string, string> | null = null
|
|
|
70
87
|
*/
|
|
71
88
|
export function initNavigationMap(resources: APIResource[]) {
|
|
72
89
|
const p2k: Record<string, string> = { ...BUILTIN_PLURAL_TO_KIND }
|
|
73
|
-
|
|
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
|
+
)
|
|
74
95
|
const gk2p: Record<string, string> = {}
|
|
75
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))
|
|
76
100
|
for (const r of resources) {
|
|
77
101
|
const plural = r.name.toLowerCase()
|
|
102
|
+
const kindLower = r.kind.toLowerCase()
|
|
103
|
+
if (r.definitionName) crdNames[`${r.group}/${r.kind}`] = r.definitionName
|
|
78
104
|
// First-wins on plurals: BUILTIN_PLURAL_TO_KIND seeds canonical core mappings
|
|
79
105
|
// (e.g. "pods" → "Pod") so a colliding API resource (metrics.k8s.io exposes
|
|
80
106
|
// "pods" with kind "PodMetrics") cannot hijack the core mapping.
|
|
81
107
|
if (!(plural in p2k)) p2k[plural] = r.kind
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
84
119
|
const groupPlural = `${r.group}/${plural}`
|
|
85
120
|
if (!(groupPlural in gp2k)) gp2k[groupPlural] = r.kind
|
|
86
121
|
}
|
|
@@ -88,14 +123,20 @@ export function initNavigationMap(resources: APIResource[]) {
|
|
|
88
123
|
discoveredKindToPlural = k2p
|
|
89
124
|
discoveredGroupKindToPlural = gk2p
|
|
90
125
|
discoveredGroupPluralToKind = gp2k
|
|
126
|
+
discoveredGroupKindNamespaced = scopes
|
|
127
|
+
discoveredCRDNames = crdNames
|
|
128
|
+
crdDefinitionListeners.forEach(listener => listener())
|
|
91
129
|
}
|
|
92
130
|
|
|
93
|
-
/** Reset navigation maps to builtin-only state
|
|
131
|
+
/** Reset navigation maps to builtin-only state on cluster changes. */
|
|
94
132
|
export function resetNavigationMap() {
|
|
95
133
|
discoveredPluralToKind = null
|
|
96
134
|
discoveredKindToPlural = null
|
|
97
135
|
discoveredGroupKindToPlural = null
|
|
98
136
|
discoveredGroupPluralToKind = null
|
|
137
|
+
discoveredGroupKindNamespaced = null
|
|
138
|
+
discoveredCRDNames = null
|
|
139
|
+
crdDefinitionListeners.forEach(listener => listener())
|
|
99
140
|
}
|
|
100
141
|
|
|
101
142
|
function getPluralToKind(): Record<string, string> {
|
|
@@ -112,6 +153,11 @@ export function kindToPlural(kind: string): string {
|
|
|
112
153
|
const kindLower = kind.toLowerCase()
|
|
113
154
|
const pluralToKindMap = getPluralToKind()
|
|
114
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
|
+
|
|
115
161
|
// Already a known plural — return as-is to prevent double-pluralization
|
|
116
162
|
if (kindLower in pluralToKindMap) return kindLower
|
|
117
163
|
|
|
@@ -151,10 +197,15 @@ export function kindToPlural(kind: string): string {
|
|
|
151
197
|
export function kindToPluralWithGroup(kind: string, group: string): string {
|
|
152
198
|
if (!group) return kindToPlural(kind)
|
|
153
199
|
const kindLower = kind.toLowerCase()
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
156
204
|
const groupKind = `${group}/${kindLower}`
|
|
157
|
-
|
|
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)
|
|
158
209
|
}
|
|
159
210
|
|
|
160
211
|
/**
|
|
@@ -191,6 +242,13 @@ export function knownKindForPluralWithGroup(plural: string, group: string): stri
|
|
|
191
242
|
?? BUILTIN_GROUP_PLURAL_TO_KIND[groupPlural]
|
|
192
243
|
}
|
|
193
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
|
+
|
|
194
252
|
/**
|
|
195
253
|
* Convert a ResourceRef (from backend relationships) to a SelectedResource (for navigation).
|
|
196
254
|
* Handles kind singular→plural conversion.
|
|
@@ -220,6 +278,31 @@ export function apiVersionToGroup(apiVersion?: string | null): string {
|
|
|
220
278
|
return i === -1 ? '' : apiVersion.slice(0, i)
|
|
221
279
|
}
|
|
222
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
|
+
|
|
223
306
|
// -----------------------------------------------------------------------------
|
|
224
307
|
// Timeline lane identity. A lane's id includes the API group so two CRDs that
|
|
225
308
|
// share a kind name across vendors (CAPI `Cluster` in cluster.x-k8s.io vs CNPG
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { getNodeLifecycle } from './node-lifecycle'
|
|
5
|
+
import { getNodeConditions, getNodeStatus } from '../components/resources/resource-utils'
|
|
6
|
+
|
|
7
|
+
const fixture = JSON.parse(readFileSync(fileURLToPath(new URL('../../../../pkg/health/testdata/node_lifecycle.json', import.meta.url)), 'utf8'))
|
|
8
|
+
|
|
9
|
+
describe('node lifecycle shared Go/TS scenarios', () => {
|
|
10
|
+
for (const vector of fixture.vectors) {
|
|
11
|
+
it(vector.name, () => {
|
|
12
|
+
expect(getNodeLifecycle(vector.node, Date.parse(fixture.now))).toEqual({
|
|
13
|
+
label: vector.label, level: vector.level, readinessFailed: vector.readinessFailed, delayed: vector.delayed,
|
|
14
|
+
removing: vector.removing, actor: vector.actor, startedAt: vector.startedAt ? Date.parse(vector.startedAt) : undefined, problems: vector.problems,
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
it('handles the actions bar loading before node data arrives', () => {
|
|
19
|
+
expect(getNodeLifecycle(undefined)).toMatchObject({ level: 'unknown', removing: false })
|
|
20
|
+
})
|
|
21
|
+
it('keeps badges and table warning icons calm for expected readiness loss', () => {
|
|
22
|
+
vi.useFakeTimers()
|
|
23
|
+
vi.setSystemTime(new Date(fixture.now))
|
|
24
|
+
try {
|
|
25
|
+
const node = fixture.vectors.find((vector: any) => vector.name === 'autoscaler-Unknown').node
|
|
26
|
+
expect(getNodeStatus(node)).toMatchObject({ text: 'Removing (cluster autoscaler)', level: 'neutral' })
|
|
27
|
+
expect(getNodeConditions(node).problems).toEqual([])
|
|
28
|
+
expect(getNodeConditions(node).readinessLabel).toBe('Readiness unknown during removal')
|
|
29
|
+
const shutdown = fixture.vectors.find((vector: any) => vector.name === 'autoscaler-False').node
|
|
30
|
+
expect(getNodeConditions(shutdown).readinessLabel).toBe('Not ready (expected during removal)')
|
|
31
|
+
expect(getNodeConditions({ status: { conditions: [] } })).toMatchObject({ healthy: false, readinessLabel: 'Readiness unknown' })
|
|
32
|
+
const failed = fixture.vectors.find((vector: any) => vector.name === 'preexisting-failure').node
|
|
33
|
+
expect(getNodeConditions(failed).problems).toContain('NotReady')
|
|
34
|
+
} finally { vi.useRealTimers() }
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { HealthLevel } from '../components/resources/resource-utils'
|
|
2
|
+
|
|
3
|
+
const REMOVAL_ACTORS: Record<string, string> = {
|
|
4
|
+
ToBeDeletedByClusterAutoscaler: 'cluster autoscaler',
|
|
5
|
+
'karpenter.sh/disrupted': 'Karpenter',
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const NETWORK_UNAVAILABLE_GRACE_MS = 2 * 60_000
|
|
9
|
+
|
|
10
|
+
export const NODE_REMOVAL_WARNING_MS = 10 * 60_000
|
|
11
|
+
export const NODE_REMOVAL_CRITICAL_MS = 30 * 60_000
|
|
12
|
+
|
|
13
|
+
export interface NodeLifecycleState {
|
|
14
|
+
label: string
|
|
15
|
+
level: HealthLevel
|
|
16
|
+
removing: boolean
|
|
17
|
+
actor?: string
|
|
18
|
+
startedAt?: number
|
|
19
|
+
readinessFailed: boolean
|
|
20
|
+
delayed: boolean
|
|
21
|
+
problems: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type NodeLifecycleObservation = Omit<NodeLifecycleState, 'startedAt' | 'problems'> & {
|
|
25
|
+
startedAt?: string
|
|
26
|
+
problems?: string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getNodeLifecycle(node: any, now = Date.now()): NodeLifecycleState {
|
|
30
|
+
const ready = node?.status?.conditions?.find((condition: any) => condition.type === 'Ready')
|
|
31
|
+
const createdAt = Date.parse(node?.metadata?.creationTimestamp)
|
|
32
|
+
const validTime = (timestamp: number) => Number.isFinite(timestamp) && timestamp > 0 && timestamp <= now && (!Number.isFinite(createdAt) || timestamp >= createdAt) ? timestamp : undefined
|
|
33
|
+
let startedAt = validTime(Date.parse(node?.metadata?.deletionTimestamp))
|
|
34
|
+
let removing = Boolean(node?.metadata?.deletionTimestamp)
|
|
35
|
+
let actor: string | undefined
|
|
36
|
+
let candidate = false
|
|
37
|
+
for (const taint of node?.spec?.taints ?? []) {
|
|
38
|
+
if (taint.key === 'DeletionCandidateOfClusterAutoscaler' && taint.effect === 'PreferNoSchedule') candidate = true
|
|
39
|
+
if (taint.effect !== 'NoSchedule') continue
|
|
40
|
+
const source = REMOVAL_ACTORS[taint.key]
|
|
41
|
+
if (!source) continue
|
|
42
|
+
removing = true
|
|
43
|
+
actor = source
|
|
44
|
+
if (taint.key === 'ToBeDeletedByClusterAutoscaler' && /^\d+$/.test(taint.value ?? '')) {
|
|
45
|
+
const markerTime = validTime(Number(taint.value) * 1000)
|
|
46
|
+
if (markerTime !== undefined && (startedAt === undefined || markerTime < startedAt)) startedAt = markerTime
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const readyTime = Date.parse(ready?.lastTransitionTime)
|
|
50
|
+
const readinessFailed = Boolean(ready && ready.status !== 'True' && !(removing && startedAt !== undefined && Number.isFinite(readyTime) && readyTime >= startedAt && readyTime <= now))
|
|
51
|
+
const delayed = removing && startedAt !== undefined && now - startedAt >= NODE_REMOVAL_WARNING_MS
|
|
52
|
+
let label = 'Unknown'
|
|
53
|
+
let level: HealthLevel = 'unknown'
|
|
54
|
+
if (removing) {
|
|
55
|
+
label = actor ? `Removing (${actor})` : 'Removing'
|
|
56
|
+
level = readinessFailed || (delayed && now - startedAt! >= NODE_REMOVAL_CRITICAL_MS) ? 'unhealthy' : delayed ? 'degraded' : 'neutral'
|
|
57
|
+
} else if (readinessFailed) {
|
|
58
|
+
label = 'NotReady'
|
|
59
|
+
level = 'unhealthy'
|
|
60
|
+
} else if (ready?.status === 'True') {
|
|
61
|
+
label = node?.spec?.unschedulable ? 'Cordoned' : candidate ? 'Scale-down candidate' : 'Ready'
|
|
62
|
+
level = node?.spec?.unschedulable ? 'degraded' : candidate ? 'neutral' : 'healthy'
|
|
63
|
+
}
|
|
64
|
+
const problems: string[] = []
|
|
65
|
+
if (removing && readinessFailed) label += ' · NotReady'
|
|
66
|
+
const problemLabels: Record<string, string> = { MemoryPressure: 'Memory pressure', DiskPressure: 'Disk pressure', PIDPressure: 'PID pressure', NetworkUnavailable: 'Network unavailable' }
|
|
67
|
+
for (const condition of node?.status?.conditions ?? []) {
|
|
68
|
+
if (condition.status !== 'True' || !problemLabels[condition.type]) continue
|
|
69
|
+
const conditionTime = Date.parse(condition.lastTransitionTime)
|
|
70
|
+
if (condition.type === 'NetworkUnavailable' && Number.isFinite(conditionTime) && conditionTime <= now && now - conditionTime < NETWORK_UNAVAILABLE_GRACE_MS) continue
|
|
71
|
+
problems.push(condition.type)
|
|
72
|
+
label += ` · ${problemLabels[condition.type]}`
|
|
73
|
+
if (condition.type !== 'NetworkUnavailable') level = 'unhealthy'
|
|
74
|
+
else if (level !== 'unhealthy') level = 'degraded'
|
|
75
|
+
}
|
|
76
|
+
return { label, level, removing, actor, startedAt, readinessFailed, delayed, problems }
|
|
77
|
+
}
|
|
@@ -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
|
+
})
|