From f594bf03f386807f3ddb8123e53b935c4e1ab8e2 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 17 Mar 2022 16:54:05 -0400 Subject: [PATCH] Turn on strict mode in tsconfig.json - Add route, clusterRoute, and payloadValidatedClusterRoute helper functions to improve types with backend routes - Turn on the following new lints: - react/jsx-first-prop-new-line - react/jsx-wrap-multilines - react/jsx-one-expression-per-line - react/jsx-max-props-per-line - react/jsx-indent - react/jsx-indent-props Signed-off-by: Sebastian Malton --- .eslintrc.js | 22 + build/build_theme_vars.ts | 4 +- .../kube-object-event-status/package.json | 2 +- .../metrics-cluster-feature/package.json | 2 +- .../src/metrics-settings.tsx | 20 +- extensions/node-menu/package.json | 2 +- extensions/node-menu/src/node-menu.tsx | 28 +- extensions/pod-menu/package.json | 2 +- extensions/pod-menu/src/attach-menu.tsx | 12 +- extensions/pod-menu/src/logs-menu.tsx | 12 +- extensions/pod-menu/src/shell-menu.tsx | 12 +- integration/helpers/utils.ts | 4 + package.json | 6 +- src/common/__tests__/base-store.test.ts | 7 +- src/common/__tests__/cluster-store.test.ts | 12 +- src/common/__tests__/event-bus.test.ts | 7 +- src/common/__tests__/event-emitter.test.ts | 2 +- src/common/__tests__/hotbar-store.test.ts | 10 +- src/common/__tests__/system-ca.test.ts | 6 +- src/common/__tests__/user-store.test.ts | 2 +- src/common/catalog-entities/general.ts | 7 +- .../catalog-entities/kubernetes-cluster.ts | 13 +- src/common/catalog-entities/web-link.ts | 9 +- src/common/catalog/catalog-entity.ts | 79 +- .../allowed-resources.injectable.ts | 4 +- src/common/cluster-store/cluster-store.ts | 8 +- src/common/cluster/cluster.ts | 103 ++- .../cluster/create-cluster-injection-token.ts | 7 +- .../cluster/list-namespaces.injectable.ts | 5 +- src/common/hotbar-store.ts | 24 +- src/common/hotbar-types.ts | 2 +- .../create-channel/create-channel.ts | 2 +- .../ipc/__tests__/type-enforced-ipc.test.ts | 2 +- src/common/item.store.ts | 10 +- .../k8s-api/__tests__/api-manager.test.ts | 16 +- src/common/k8s-api/__tests__/crd.test.ts | 29 + .../k8s-api/__tests__/deployment.api.test.ts | 8 +- .../k8s-api/__tests__/endpoint.api.test.ts | 32 +- .../k8s-api/__tests__/helm-charts.api.test.ts | 72 +- src/common/k8s-api/__tests__/ingress.api.ts | 9 + .../k8s-api/__tests__/kube-api-parse.test.ts | 4 +- src/common/k8s-api/__tests__/kube-api.test.ts | 104 +-- src/common/k8s-api/__tests__/nodes.test.ts | 7 + src/common/k8s-api/__tests__/pods.api.test.ts | 2 + src/common/k8s-api/__tests__/pods.test.ts | 72 +- .../__tests__/stateful-set.api.test.ts | 3 +- src/common/k8s-api/api-kube.ts | 2 +- src/common/k8s-api/api-manager.ts | 65 +- .../endpoints/cluster-role-binding.api.ts | 63 +- .../k8s-api/endpoints/cluster-role.api.ts | 48 +- src/common/k8s-api/endpoints/cluster.api.ts | 31 +- src/common/k8s-api/endpoints/configmap.api.ts | 46 +- src/common/k8s-api/endpoints/crd.api.ts | 132 +-- src/common/k8s-api/endpoints/cron-job.api.ts | 96 +-- .../k8s-api/endpoints/daemon-set.api.ts | 145 ++-- .../k8s-api/endpoints/deployment.api.ts | 216 ++--- src/common/k8s-api/endpoints/endpoint.api.ts | 195 ++--- src/common/k8s-api/endpoints/events.api.ts | 133 ++- .../k8s-api/endpoints/helm-charts.api.ts | 50 +- .../k8s-api/endpoints/helm-releases.api.ts | 37 +- src/common/k8s-api/endpoints/hpa.api.ts | 299 ++++--- src/common/k8s-api/endpoints/ingress.api.ts | 160 ++-- src/common/k8s-api/endpoints/job.api.ts | 157 ++-- .../k8s-api/endpoints/limit-range.api.ts | 41 +- src/common/k8s-api/endpoints/metrics.api.ts | 37 +- .../k8s-api/endpoints/namespaces.api.ts | 30 +- .../k8s-api/endpoints/network-policy.api.ts | 35 +- src/common/k8s-api/endpoints/nodes.api.ts | 266 +++--- .../endpoints/persistent-volume-claims.api.ts | 111 +-- .../endpoints/persistent-volume.api.ts | 118 +-- .../k8s-api/endpoints/pod-metrics.api.ts | 66 +- .../endpoints/poddisruptionbudget.api.ts | 65 +- src/common/k8s-api/endpoints/pods.api.ts | 334 ++++---- .../endpoints/podsecuritypolicy.api.ts | 156 ++-- .../k8s-api/endpoints/replica-set.api.ts | 121 +-- .../k8s-api/endpoints/resource-applier.api.ts | 4 +- .../k8s-api/endpoints/resource-quota.api.ts | 66 +- .../k8s-api/endpoints/role-binding.api.ts | 59 +- src/common/k8s-api/endpoints/role.api.ts | 46 +- src/common/k8s-api/endpoints/secret.api.ts | 49 +- .../endpoints/selfsubjectrulesreviews.api.ts | 2 +- .../k8s-api/endpoints/service-accounts.api.ts | 58 +- src/common/k8s-api/endpoints/service.api.ts | 91 +- .../k8s-api/endpoints/stateful-set.api.ts | 141 ++-- .../k8s-api/endpoints/storage-class.api.ts | 82 +- .../endpoints/types/aggregation-rule.ts} | 6 +- .../endpoints/types/job-template-spec.ts | 12 + .../persistent-volume-claim-template-spec.ts | 12 + .../endpoints/types/pod-template-spec.ts | 12 + .../k8s-api/endpoints/types/policy-rule.ts | 12 + .../endpoints/types/resource-requirements.ts | 25 + .../k8s-api/endpoints/types/role-ref.ts} | 10 +- src/common/k8s-api/endpoints/types/subject.ts | 13 + src/common/k8s-api/json-api.ts | 43 +- src/common/k8s-api/kube-api-parse.ts | 45 +- src/common/k8s-api/kube-api.ts | 274 +++--- src/common/k8s-api/kube-json-api.ts | 30 +- src/common/k8s-api/kube-object.store.ts | 172 ++-- src/common/k8s-api/kube-object.ts | 433 ++++++++-- src/common/k8s-api/kube-watch-event.ts | 11 +- src/common/k8s-api/workload-kube-object.ts | 89 -- src/common/k8s/resource-stack.ts | 4 +- src/common/kube-helpers.ts | 77 +- src/common/protocol-handler/router.ts | 14 +- src/common/system-ca.ts | 4 +- src/common/user-store/preferences-helpers.ts | 79 +- src/common/user-store/user-store.ts | 73 +- src/common/utils/autobind.ts | 5 +- src/common/utils/buildUrl.ts | 8 +- src/common/utils/camelCase.ts | 28 +- src/common/utils/cluster-id-url-parsing.ts | 17 +- src/common/utils/collection-functions.ts | 26 +- src/common/utils/disposer.ts | 2 +- src/common/utils/downloadFile.ts | 6 +- src/common/utils/index.ts | 10 +- .../utils/is-allowed-resource.injectable.ts | 4 +- src/common/utils/iter.ts | 89 +- src/common/utils/json.ts | 10 + src/common/utils/lazy-initialized.ts | 3 +- src/common/utils/objects.ts | 13 +- src/common/utils/readableStream.ts | 2 +- src/common/utils/readonly.ts | 10 + src/common/utils/singleton.ts | 2 + src/common/utils/sort-compare.ts | 8 +- src/common/utils/string.ts | 21 + src/common/utils/tar.ts | 24 +- src/common/utils/tuple.ts | 3 + src/common/utils/type-narrowing.ts | 116 ++- src/common/utils/types.ts | 21 + src/common/utils/wait.ts | 61 ++ src/common/vars.ts | 4 +- .../__tests__/lens-extension.test.ts | 2 +- ...obal-singleton-object-for-extension-api.ts | 26 +- .../legacy-global-di-for-extension-api.ts | 10 +- .../extension-discovery.test.ts | 21 +- .../extension-discovery.ts | 20 +- .../extension-installer.ts | 2 +- .../file-system-provisioner-store.ts | 12 +- .../extension-loader/extension-loader.ts | 39 +- src/extensions/extension-store.ts | 5 +- .../extensions-store/extensions-store.ts | 2 +- src/extensions/lens-extension.ts | 18 +- src/extensions/lens-renderer-extension.ts | 64 +- src/extensions/main-api/catalog.ts | 16 +- src/extensions/main-api/k8s-api.ts | 20 +- src/extensions/registries/base-registry.ts | 5 +- .../catalog-entity-detail-registry.ts | 5 + .../registries/kube-object-detail-registry.ts | 11 +- src/extensions/registries/protocol-handler.ts | 4 +- src/extensions/renderer-api/components.ts | 41 +- src/extensions/renderer-api/k8s-api.ts | 36 +- src/main/__test__/cluster.test.ts | 21 +- src/main/__test__/context-handler.test.ts | 22 +- src/main/__test__/kube-auth-proxy.test.ts | 66 +- src/main/__test__/kubeconfig-manager.test.ts | 21 +- src/main/__test__/static-file-route.test.ts | 8 +- .../get-electron-app-path.injectable.ts | 5 +- .../get-electron-app-path.test.ts | 4 +- .../get-electron-app-path.ts | 22 +- src/main/app-updater.ts | 2 +- .../__test__/kubeconfig-sync.test.ts | 2 +- .../kubeconfig-sync-manager.ts | 8 +- .../__tests__/catalog-entity-registry.test.ts | 6 +- src/main/catalog/catalog-entity-registry.ts | 14 +- src/main/child-process/spawn.injectable.ts | 6 +- .../base-cluster-detector.ts | 11 +- .../cluster-detectors/detector-registry.ts | 8 +- .../distribution-detector.ts | 77 +- .../cluster-detectors/version-detector.ts | 1 - src/main/cluster-manager.ts | 22 +- src/main/context-handler/context-handler.ts | 60 +- .../create-context-handler.injectable.ts | 18 +- src/main/getDiForUnitTesting.ts | 3 +- src/main/helm/exec.ts | 16 +- src/main/helm/helm-chart-manager.ts | 37 +- src/main/helm/helm-release-manager.ts | 28 +- src/main/helm/helm-repo-manager.ts | 41 +- src/main/helm/helm-service.ts | 35 +- src/main/index.ts | 7 +- src/main/ipc/dialog.ts | 6 +- .../create-kube-auth-proxy.injectable.ts | 2 + .../get-kube-auth-proxy-certificate.ts | 50 +- src/main/kube-auth-proxy/kube-auth-proxy.ts | 39 +- .../kubeconfig-manager/kubeconfig-manager.ts | 18 +- src/main/kubectl/kubectl.ts | 22 +- src/main/lens-proxy.ts | 30 +- .../menu/application-menu-items.injectable.ts | 41 +- src/main/menu/menu.ts | 4 +- .../navigate-to-url.injectable.ts | 2 +- src/main/prometheus/provider-registry.ts | 18 +- .../protocol-handler/__test__/router.test.ts | 14 +- .../lens-protocol-router-main.ts | 6 +- .../kube-api-upgrade-request.ts | 13 +- .../shell-api-request/shell-api-request.ts | 4 +- .../shell-request-authenticator.ts | 4 +- src/main/proxy-functions/types.ts | 3 +- src/main/resource-applier.ts | 25 +- src/main/router/route.ts | 147 ++++ src/main/router/router-content-types.ts | 2 +- src/main/router/router.injectable.ts | 20 +- src/main/router/router.test.ts | 25 +- src/main/router/router.ts | 65 +- .../helm/charts/get-chart-route.injectable.ts | 31 +- .../get-chart-values-route.injectable.ts | 30 +- .../charts/list-charts-route.injectable.ts | 19 +- .../delete-release-route.injectable.ts | 31 +- .../get-release-history-route.injectable.ts | 31 +- .../releases/get-release-route.injectable.ts | 31 +- .../get-release-values-route.injectable.ts | 34 +- .../install-chart-route.injectable.ts | 50 +- .../list-releases-route.injectable.ts | 23 +- .../rollback-release-route.injectable.ts | 31 +- .../update-release-route.injectable.ts | 47 +- .../get-service-account-route.injectable.ts | 85 +- .../metrics/add-metrics-route.injectable.ts | 127 +-- .../get-metric-providers-route.injectable.ts | 32 +- .../functionality/port-forward.ts | 7 +- ...t-current-port-forward-route.injectable.ts | 41 +- .../start-port-forward-route.injectable.ts | 141 ++-- ...p-current-port-forward-route.injectable.ts | 62 +- .../apply-resource-route.injectable.ts | 19 +- .../patch-resource-route.injectable.ts | 60 +- .../routes/static-file-route.injectable.ts | 42 +- .../versions/get-version-route.injectable.ts | 19 +- .../local-shell-session.ts | 9 +- .../node-shell-session/node-shell-session.ts | 57 +- .../terminal-shell-env-modifiers.ts | 13 +- src/main/shell-session/shell-session.ts | 38 +- src/main/tray/tray.ts | 6 +- src/main/utils/get-port.ts | 20 +- src/main/utils/update-channel.ts | 2 +- src/main/window-manager.ts | 96 ++- src/migrations/cluster-store/3.6.0-beta.1.ts | 8 +- src/migrations/cluster-store/5.0.0-beta.10.ts | 12 +- src/migrations/cluster-store/5.0.0-beta.13.ts | 14 +- src/migrations/hotbar-store/5.0.0-alpha.2.ts | 6 +- src/migrations/hotbar-store/5.0.0-beta.10.ts | 17 +- src/migrations/hotbar-store/5.0.0-beta.5.ts | 9 +- src/migrations/user-store/5.0.3-beta.1.ts | 4 +- .../user-store/file-name-migration.ts | 8 +- .../__tests__/catalog-entity-registry.test.ts | 6 +- src/renderer/api/catalog-entity-registry.ts | 10 +- .../api/create-terminal-api.injectable.ts | 28 + .../api/helpers/general-active-sync.ts | 7 +- src/renderer/api/terminal-api.ts | 33 +- src/renderer/api/websocket-api.ts | 28 +- src/renderer/bootstrap.tsx | 9 +- .../cluster-frame-context.injectable.ts | 13 +- .../cluster/hosted-cluster-id.injectable.ts | 14 + .../cluster}/hosted-cluster.injectable.ts | 9 +- .../components/+add-cluster/add-cluster.tsx | 18 +- .../+catalog/__tests__/custom-columns.test.ts | 25 +- .../+catalog/__tests__/custom-views.test.ts | 12 +- .../+catalog/catalog-add-button.tsx | 26 +- .../+catalog/catalog-entity-details.tsx | 2 +- .../+catalog/catalog-entity-drawer-menu.tsx | 42 +- .../catalog-entity-store.injectable.ts | 4 +- .../catalog-entity.store.tsx | 91 +- .../components/+catalog/catalog-menu.tsx | 11 +- .../components/+catalog/catalog.test.tsx | 56 +- src/renderer/components/+catalog/catalog.tsx | 106 +-- .../+catalog/hotbar-toggle-menu-item.tsx | 20 +- .../name-category-column.injectable.tsx | 1 - .../components/+cluster/cluster-issues.tsx | 23 +- .../+cluster/cluster-metric-switchers.tsx | 4 +- .../components/+cluster/cluster-metrics.tsx | 24 +- .../cluster-overview-store.injectable.ts | 3 +- .../cluster-overview-store.ts | 16 +- .../components/+cluster/cluster-overview.tsx | 38 +- .../+config-autoscalers/hpa-details.tsx | 90 +- .../+config-autoscalers/hpa.store.ts | 17 +- .../components/+config-autoscalers/hpa.tsx | 8 +- .../limit-range-details.tsx | 42 +- .../limit-ranges.store.ts | 15 +- .../+config-maps/config-map-details.tsx | 19 +- .../+config-maps/config-maps.store.ts | 15 +- .../components/+config-maps/config-maps.tsx | 4 +- .../pod-disruption-budgets-details.tsx | 4 +- .../pod-disruption-budgets.store.ts | 17 +- .../add-quota-dialog.tsx | 99 ++- .../resource-quota-details.tsx | 22 +- .../resource-quotas.store.ts | 15 +- .../__tests__/secret-details.test.tsx | 2 + .../+config-secrets/add-secret-dialog.tsx | 127 +-- .../+config-secrets/secret-details.tsx | 21 +- .../+config-secrets/secrets.store.ts | 17 +- .../components/+config-secrets/secrets.tsx | 13 +- .../+custom-resources/crd-details.tsx | 19 +- .../components/+custom-resources/crd-list.tsx | 111 +-- .../crd-resource-details.tsx | 29 +- .../+custom-resources/crd-resource.store.ts | 2 +- .../+custom-resources/crd-resources.tsx | 14 +- .../components/+custom-resources/crd.store.ts | 8 +- ...stom-resource-sidebar-items.injectable.tsx | 4 +- .../+entity-settings/entity-settings.tsx | 9 +- .../components/+events/duration-absolute.tsx | 27 + .../components/+events/event-details.tsx | 11 +- .../components/+events/event.store.ts | 25 +- src/renderer/components/+events/events.tsx | 56 +- .../components/+events/kube-event-details.tsx | 13 +- .../+extensions/__tests__/extensions.test.tsx | 18 +- .../attempt-install-by-info.tsx | 28 +- .../attempt-install/attempt-install.tsx | 19 +- .../create-temp-files-and-validate.tsx | 7 +- .../unpack-extension/unpack-extension.tsx | 8 +- .../validate-package/validate-package.tsx | 18 +- .../confirm-uninstall-extension.tsx | 4 +- .../components/+extensions/extensions.tsx | 26 +- .../get-base-registry-url.tsx | 19 +- .../get-message-from-error.ts | 11 +- .../install-from-input/install-from-input.tsx | 76 +- .../components/+extensions/install.tsx | 17 +- .../+extensions/installed-extensions.tsx | 12 +- .../uninstall-extension.tsx | 84 +- .../+helm-charts/helm-chart-details.tsx | 132 +-- .../+helm-charts/helm-chart.store.ts | 26 +- .../components/+helm-charts/helm-charts.tsx | 11 +- .../create-release.injectable.ts | 5 +- .../dialog.scss} | 0 .../+helm-releases/dialog/dialog.tsx | 141 ++++ .../+helm-releases/dialog/open.injectable.ts | 21 + .../+helm-releases/dialog/state.injectable.ts | 14 + .../release-details.injectable.ts | 13 +- .../release-details/release-details.tsx | 130 +-- .../+helm-releases/release-menu.tsx | 46 +- ...elease-rollback-dialog-model.injectable.ts | 13 - .../release-rollback-dialog-model.ts | 31 - .../release-rollback-dialog.tsx | 131 --- .../+helm-releases/releases.injectable.ts | 2 +- .../components/+helm-releases/releases.tsx | 60 +- .../update-release.injectable.ts | 5 +- .../+namespaces/add-namespace-dialog.tsx | 10 +- .../+namespaces/namespace-details.tsx | 20 +- .../namespace-select-filter-model.ts | 95 --- .../namespace-select-filter-model.tsx | 155 ++++ .../+namespaces/namespace-select-filter.tsx | 94 +-- .../+namespaces/namespace-select.tsx | 138 ++- .../namespace-store.injectable.ts | 3 +- .../namespace-store/namespace.store.ts | 15 +- src/renderer/components/+namespaces/route.tsx | 12 +- .../+network-endpoints/endpoint-details.tsx | 14 +- .../endpoint-subset-list.tsx | 54 +- .../+network-endpoints/endpoints.store.ts | 9 +- .../+network-endpoints/endpoints.tsx | 3 +- .../+network-ingresses/ingress-charts.tsx | 52 +- .../+network-ingresses/ingress-details.tsx | 50 +- .../+network-ingresses/ingress.store.ts | 15 +- .../+network-ingresses/ingresses.tsx | 12 +- .../__tests__/network-policy-details.test.tsx | 54 +- .../network-policy-details.tsx | 19 +- .../+network-policies/network-policy.store.ts | 7 +- .../port-forward-details.tsx | 12 +- .../port-forward-menu.tsx | 28 +- .../+network-port-forwards/port-forwards.tsx | 14 +- .../service-details-endpoint.tsx | 25 +- .../+network-services/service-details.tsx | 32 +- .../service-port-component.tsx | 26 +- .../+network-services/services.store.ts | 7 +- .../components/+network-services/services.tsx | 3 +- .../components/+nodes/details-resources.tsx | 82 +- src/renderer/components/+nodes/details.tsx | 138 +-- .../components/+nodes/node-charts.tsx | 68 +- src/renderer/components/+nodes/nodes.store.ts | 10 +- src/renderer/components/+nodes/route.tsx | 34 +- .../pod-security-policies.store.ts | 11 +- .../pod-security-policies.tsx | 4 +- .../pod-security-policy-details.tsx | 38 +- .../+preferences/add-helm-repo-dialog.tsx | 55 +- .../components/+preferences/application.tsx | 74 +- .../components/+preferences/editor.tsx | 30 +- ...-preference-item-registrator.injectable.ts | 3 +- .../+preferences/extension-settings.tsx | 6 +- ...-preference-item-registrator.injectable.ts | 3 +- .../components/+preferences/helm-charts.tsx | 61 +- .../+preferences/kubectl-binaries.tsx | 140 ++-- .../components/+preferences/proxy.tsx | 12 +- .../components/+preferences/telemetry.tsx | 82 +- .../components/+preferences/terminal.tsx | 16 +- .../storage-class-details.tsx | 9 +- .../+storage-classes/storage-class.store.ts | 14 +- .../volume-claim-details.tsx | 18 +- .../volume-claim-disk-chart.tsx | 12 +- .../volume-claim.store.ts | 11 +- .../+storage-volume-claims/volume-claims.tsx | 16 +- .../+storage-volumes/volume-details-list.tsx | 4 + .../+storage-volumes/volume-details.tsx | 8 +- .../+storage-volumes/volumes.store.ts | 14 +- .../components/+storage-volumes/volumes.tsx | 10 +- .../__tests__/dialog.test.tsx | 21 +- .../+cluster-role-bindings/details.tsx | 18 +- .../+cluster-role-bindings/dialog.tsx | 107 ++- .../+cluster-role-bindings/hashers.ts | 15 - .../+cluster-role-bindings/store.ts | 31 +- .../+cluster-role-bindings/view.tsx | 4 +- .../+cluster-roles/add-dialog.tsx | 7 +- .../+user-management/+cluster-roles/store.ts | 21 +- .../+role-bindings/__tests__/dialog.test.tsx | 21 +- .../+role-bindings/details.tsx | 18 +- .../+role-bindings/dialog.tsx | 116 +-- .../+user-management/+role-bindings/store.ts | 29 +- .../+user-management/+role-bindings/view.tsx | 4 +- .../+user-management/+roles/add-dialog.tsx | 7 +- .../+user-management/+roles/store.ts | 25 +- .../+service-accounts/create-dialog.tsx | 7 +- .../+service-accounts/details.tsx | 112 ++- .../+service-accounts/secret.tsx | 74 +- .../service-account-menu.tsx | 6 +- .../+service-accounts/store.ts | 24 +- .../+service-accounts/view.tsx | 3 +- .../{+role-bindings => }/hashers.ts | 4 +- .../welcome-banner-items.injectable.ts | 8 +- .../welcome-banner-registration.ts | 2 +- src/renderer/components/+welcome/welcome.tsx | 41 +- .../+workloads-cronjobs/cron-job-menu.tsx | 98 ++- .../+workloads-cronjobs/cronjob-details.tsx | 21 +- .../cronjob-trigger-dialog.tsx | 135 ++- .../+workloads-cronjobs/cronjob.store.ts | 24 +- .../daemonset-details.tsx | 59 +- .../+workloads-daemonsets/daemonsets.store.ts | 41 +- .../+workloads-daemonsets/daemonsets.tsx | 8 +- .../deployment-details.tsx | 66 +- .../deployment-menu.tsx | 54 +- .../deployment-replicasets.tsx | 14 +- .../deployment-scale-dialog.test.tsx | 78 +- .../deployment-scale-dialog.tsx | 128 ++- .../deployments.store.ts | 33 +- .../+workloads-deployments/deployments.tsx | 13 +- .../+workloads-jobs/job-details.tsx | 87 +- .../components/+workloads-jobs/job.store.ts | 41 +- .../components/+workloads-jobs/jobs.tsx | 7 +- .../+workloads-overview/overview-statuses.tsx | 2 +- .../overview-workload-status.tsx | 2 +- .../+workloads-overview/overview.tsx | 7 +- .../__tests__/pod-tolerations.test.tsx | 22 +- .../+workloads-pods/container-charts.tsx | 35 +- .../details/volumes/variant-helpers.tsx | 10 +- .../details/volumes/variant.tsx | 184 +++- .../details/volumes/variants/ceph-fs.tsx | 4 +- .../variants/container-storage-interface.tsx | 10 +- .../details/volumes/variants/ephemeral.tsx | 8 +- .../details/volumes/variants/flex-volume.tsx | 6 +- .../variants/persistent-volume-claim.tsx | 4 +- .../details/volumes/variants/projected.tsx | 8 +- .../volumes/variants/rados-block-device.tsx | 4 +- .../details/volumes/variants/scale-io.tsx | 4 +- .../details/volumes/variants/secret.tsx | 6 +- .../details/volumes/variants/storage-os.tsx | 4 +- .../components/+workloads-pods/pod-charts.tsx | 43 +- .../+workloads-pods/pod-container-env.tsx | 41 +- .../+workloads-pods/pod-container-port.tsx | 32 +- .../+workloads-pods/pod-details-container.tsx | 232 ++--- .../+workloads-pods/pod-details-list.tsx | 43 +- .../+workloads-pods/pod-details-secrets.tsx | 10 +- .../+workloads-pods/pod-details-statuses.tsx | 11 +- .../pod-details-tolerations.tsx | 31 +- .../+workloads-pods/pod-details.tsx | 40 +- .../+workloads-pods/pod-tolerations.tsx | 6 +- .../components/+workloads-pods/pods.store.ts | 49 +- .../components/+workloads-pods/pods.tsx | 91 +- .../replica-set-menu.tsx | 6 +- .../replicaset-details.tsx | 66 +- .../replicaset-scale-dialog.test.tsx | 47 +- .../replicaset-scale-dialog.tsx | 126 ++- .../replicasets.store.ts | 41 +- .../+workloads-replicasets/replicasets.tsx | 5 +- .../stateful-set-menu.tsx | 6 +- .../statefulset-details.tsx | 64 +- .../statefulset-scale-dialog.test.tsx | 44 +- .../statefulset-scale-dialog.tsx | 128 ++- .../statefulset.store.ts | 41 +- .../+workloads-statefulsets/statefulsets.tsx | 7 +- .../__tests__/cronjob.store.test.ts | 14 +- .../__tests__/daemonset.store.test.ts | 75 +- .../__tests__/deployments.store.test.ts | 174 ++-- .../components/__tests__/job.store.test.ts | 96 ++- .../components/__tests__/pods.store.test.ts | 102 +-- .../__tests__/replicaset.store.test.ts | 75 +- .../__tests__/statefulset.store.test.ts | 75 +- .../activate-entity-command.tsx | 18 +- .../add-remove-buttons/add-remove-buttons.tsx | 8 +- src/renderer/components/animate/animate.tsx | 9 +- src/renderer/components/avatar/avatar.tsx | 12 +- src/renderer/components/badge/badge.tsx | 21 +- .../catalog-entities/weblink-add-command.tsx | 6 +- src/renderer/components/chart/bar-chart.tsx | 299 +++---- src/renderer/components/chart/chart.tsx | 96 ++- src/renderer/components/chart/options.ts | 150 ++++ src/renderer/components/chart/pie-chart.tsx | 134 +-- .../components/chart/zebra-stripes.plugin.ts | 67 +- src/renderer/components/checkbox/checkbox.tsx | 65 +- .../cluster-manager/cluster-manager.tsx | 27 +- .../cluster-manager/cluster-status.tsx | 7 +- .../cluster-manager/cluster-view.tsx | 50 +- .../cluster-manager/lens-views.injectable.ts | 13 + .../components/cluster-manager/lens-views.ts | 51 +- .../cluster-local-terminal-settings.test.tsx | 22 +- .../cluster-accessible-namespaces.tsx | 2 +- .../components/cluster-icon-settings.tsx | 10 +- .../cluster-local-terminal-settings.tsx | 18 +- .../components/cluster-metrics-setting.tsx | 15 +- .../components/cluster-prometheus-setting.tsx | 76 +- .../components/cluster-show-metrics.tsx | 6 +- .../command-palette/command-container.tsx | 5 +- .../command-palette/command-dialog.tsx | 51 +- .../registered-commands/commands.ts | 2 +- .../confirm-dialog/confirm-dialog.tsx | 60 +- .../__tests__/delete-cluster-dialog.test.tsx | 26 +- .../delete-cluster-dialog-model.injectable.ts | 13 - .../delete-cluster-dialog-model.ts | 33 - .../delete-cluster-dialog.tsx | 252 ------ .../components/delete-cluster-dialog/index.ts | 2 +- .../delete-cluster-dialog/open.injectable.ts | 20 + .../delete-cluster-dialog/state.injectable.ts | 20 + ...er-dialog.module.scss => view.module.scss} | 0 .../components/delete-cluster-dialog/view.tsx | 266 ++++++ src/renderer/components/dialog/dialog.tsx | 18 +- .../components/dialog/logs-dialog.tsx | 71 +- .../dock/__test__/dock-tabs.test.tsx | 18 +- .../create-resource-templates.injectable.ts | 6 +- .../user-templates.injectable.ts | 4 +- .../components/dock/create-resource/view.tsx | 40 +- .../dock/dock-tab-store/dock-tab.store.ts | 8 +- src/renderer/components/dock/dock-tab.tsx | 105 ++- src/renderer/components/dock/dock-tabs.tsx | 18 +- src/renderer/components/dock/dock.tsx | 28 +- .../dock/dock/dock-storage.injectable.ts | 1 + src/renderer/components/dock/dock/store.ts | 14 +- .../components/dock/edit-resource/store.ts | 18 +- .../components/dock/edit-resource/view.tsx | 118 +-- src/renderer/components/dock/editor-panel.tsx | 2 +- src/renderer/components/dock/info-panel.tsx | 29 +- .../dock/install-chart/store.injectable.ts | 4 +- .../components/dock/install-chart/store.ts | 20 +- .../components/dock/install-chart/view.tsx | 134 +-- .../__test__/log-resource-selector.test.tsx | 29 +- .../dock/logs/__test__/log-search.test.tsx | 3 +- .../components/dock/logs/__test__/pod.mock.ts | 24 +- .../dock/logs/call-for-logs.injectable.ts | 4 +- .../components/dock/logs/controls.tsx | 8 +- .../create-workload-logs-tab.injectable.ts | 4 +- .../dock/logs/get-log-tab-data.injectable.ts | 2 +- src/renderer/components/dock/logs/list.tsx | 41 +- .../dock/logs/load-logs.injectable.ts | 2 +- .../components/dock/logs/logs-view-model.ts | 16 +- .../dock/logs/reload-logs.injectable.ts | 2 +- .../dock/logs/resource-selector.tsx | 49 +- src/renderer/components/dock/logs/search.tsx | 2 +- src/renderer/components/dock/logs/store.ts | 42 +- src/renderer/components/dock/logs/view.tsx | 7 +- .../terminal/create-terminal.injectable.ts | 4 +- .../components/dock/terminal/dock-tab.tsx | 32 +- .../terminal/get-terminal-api.injectable.ts | 3 +- .../dock/terminal/send-command.injectable.ts | 15 +- .../dock/terminal/store.injectable.ts | 2 + .../components/dock/terminal/store.ts | 9 +- .../components/dock/terminal/terminal.ts | 27 +- .../components/dock/terminal/view.tsx | 21 +- .../components/dock/upgrade-chart/store.ts | 9 +- .../components/dock/upgrade-chart/view.tsx | 52 +- .../components/drawer/drawer-item-labels.tsx | 10 +- .../components/drawer/drawer-item.tsx | 6 +- .../components/drawer/drawer-title.tsx | 10 +- src/renderer/components/drawer/drawer.tsx | 64 +- .../editable-list/editable-list.tsx | 13 +- .../error-boundary/error-boundary.tsx | 33 +- .../components/file-picker/file-picker.tsx | 50 +- .../__tests__/hotbar-remove-command.test.tsx | 8 +- .../components/hotbar/hotbar-add-command.tsx | 4 +- .../components/hotbar/hotbar-cell.tsx | 2 +- .../components/hotbar/hotbar-entity-icon.tsx | 6 +- .../components/hotbar/hotbar-icon.tsx | 13 +- .../components/hotbar/hotbar-menu.tsx | 22 +- .../hotbar/hotbar-remove-command.tsx | 88 +- .../hotbar/hotbar-rename-command.tsx | 26 +- .../components/hotbar/hotbar-selector.tsx | 15 +- .../hotbar/hotbar-switch-command.tsx | 106 +-- src/renderer/components/icon/icon.tsx | 14 +- .../input/__tests__/input_validators.test.ts | 6 +- src/renderer/components/input/file-input.tsx | 8 +- src/renderer/components/input/input.tsx | 102 ++- .../components/input/input_validators.ts | 104 ++- .../components/input/search-input.tsx | 12 +- .../unique-hotbar-name.injectable.ts | 6 +- .../components/item-object-list/content.tsx | 117 ++- .../item-object-list/filter-icon.tsx | 16 +- .../components/item-object-list/header.tsx | 23 +- .../item-object-list/list-layout.tsx | 115 ++- .../item-object-list/page-filters-list.tsx | 2 +- .../item-object-list/page-filters.store.ts | 2 +- .../components/kube-detail-params/params.ts | 2 +- .../kube-object-details.tsx | 62 +- .../kube-object-list-layout.tsx | 65 +- .../get-kube-object-menu-items.ts | 18 +- .../static-kube-object-menu-items.ts | 3 +- .../kube-object-menu.test.tsx | 9 +- .../kube-object-menu/kube-object-menu.tsx | 75 +- .../kube-object-meta/kube-object-meta.tsx | 40 +- .../kube-object-status-icon.test.tsx | 1 + .../kube-object-status-icon.tsx | 28 +- src/renderer/components/kube-object/age.tsx | 16 +- .../kubeconfig-dialog/kubeconfig-dialog.tsx | 100 +-- .../components/layout/close-button.tsx | 6 +- ...on-sidebar-item-registrator.injectable.tsx | 99 ++- .../components/layout/setting-layout.tsx | 2 +- .../layout/siblings-in-tab-layout.tsx | 3 +- .../components/layout/sidebar-cluster.tsx | 4 +- .../layout/sidebar-items.injectable.ts | 8 +- src/renderer/components/layout/sidebar.tsx | 57 +- src/renderer/components/layout/sub-title.tsx | 4 +- src/renderer/components/layout/tab-layout.tsx | 26 +- .../components/layout/top-bar/top-bar.tsx | 64 +- .../line-progress/line-progress.tsx | 42 +- src/renderer/components/list/list.tsx | 11 +- .../markdown-viewer/markdown-viewer.tsx | 2 +- src/renderer/components/menu/menu-actions.tsx | 22 +- src/renderer/components/menu/menu.tsx | 74 +- .../monaco-editor/monaco-editor.tsx | 48 +- .../notifications/notifications.store.tsx | 26 +- .../notifications/notifications.tsx | 26 +- src/renderer/components/radio/radio.tsx | 157 ++-- .../resizing-anchor/resizing-anchor.tsx | 36 +- .../resource-metrics/no-metrics.tsx | 3 +- .../resource-metrics-text.tsx | 5 +- .../resource-metrics/resource-metrics.tsx | 50 +- .../scroll-spy/__tests__/scroll-spy.test.tsx | 242 +++--- .../components/scroll-spy/scroll-spy.tsx | 26 +- .../components/select/select.test.tsx | 72 +- src/renderer/components/select/select.tsx | 145 +--- src/renderer/components/slider/slider.tsx | 9 +- src/renderer/components/switch/switch.tsx | 8 +- src/renderer/components/table/react-table.tsx | 60 +- src/renderer/components/table/sorting.ts | 3 +- src/renderer/components/table/table-cell.tsx | 51 +- src/renderer/components/table/table-row.tsx | 11 +- src/renderer/components/table/table.tsx | 56 +- src/renderer/components/tabs/tabs.tsx | 6 +- .../test-utils/get-application-builder.tsx | 33 +- .../test-utils/get-renderer-extension-fake.ts | 3 +- src/renderer/components/tooltip/tooltip.tsx | 54 +- .../components/virtual-list/virtual-list.tsx | 196 ++--- src/renderer/components/wizard/wizard.tsx | 61 +- .../frames/cluster-frame/cluster-frame.tsx | 97 +-- .../init-cluster-frame.injectable.ts | 17 +- .../init-cluster-frame/init-cluster-frame.ts | 102 +-- .../cluster-frame/start-url.injectable.ts | 2 +- src/renderer/frames/root-frame/root-frame.tsx | 7 - src/renderer/getDiForUnitTesting.tsx | 2 +- src/renderer/hooks/useInterval.ts | 2 +- src/renderer/hooks/useMutationObserver.ts | 2 +- src/renderer/hooks/useResizeObserver.ts | 2 +- .../add-sync-entries.injectable.tsx | 16 +- .../catalog-entity-detail-registry.tsx | 3 + src/renderer/initializers/catalog.tsx | 22 +- src/renderer/initializers/ipc.ts | 4 +- .../kube-object-detail-registry.tsx | 791 +++++++++--------- ...amespaces-forbidden-handler.injectable.tsx | 7 +- src/renderer/ipc/register-listeners.tsx | 34 +- src/renderer/kube-watch-api/kube-watch-api.ts | 51 +- src/renderer/mui-base-theme.tsx | 2 +- src/renderer/navigation/page-param.ts | 6 +- .../about-port-forwarding.injectable.ts | 14 +- ...notify-error-port-forwarding.injectable.ts | 14 +- .../port-forward-dialog-model.ts | 52 +- .../port-forward/port-forward-dialog.tsx | 107 +-- .../port-forward/port-forward-notify.tsx | 19 +- .../port-forward-store/port-forward-store.ts | 21 +- .../port-forward/port-forward-utils.ts | 2 +- .../bind-protocol-add-route-handlers.tsx | 30 +- .../lens-protocol-router-renderer.tsx | 42 +- .../current-route-component.injectable.ts | 4 +- .../extension-page-parameters.injectable.ts | 10 +- ...extension-route-registrator.injectable.tsx | 18 +- ...tension-route-path.ts => for-extension.ts} | 14 +- src/renderer/routes/get-extension-route-id.ts | 9 - ...oute-specific-component-injection-token.ts | 2 +- .../routes/sibling-tabs.injectable.ts | 16 +- src/renderer/theme.store.ts | 51 +- .../themes/{lens-dark.json => lens-dark.ts} | 17 +- .../themes/{lens-light.json => lens-light.ts} | 16 +- .../utils/__tests__/storageHelper.test.ts | 2 +- .../create-storage.injectable.ts | 9 +- .../utils/create-storage/create-storage.ts | 15 +- src/renderer/utils/cssNames.ts | 4 +- src/renderer/utils/display-booleans.ts | 2 +- src/renderer/utils/storageHelper.ts | 5 +- src/test-utils/override-fs-with-fakes.ts | 2 +- src/test-utils/override-ipc-bridge.ts | 27 +- tsconfig.json | 2 +- yarn.lock | 91 +- 689 files changed, 14532 insertions(+), 11305 deletions(-) rename src/common/{utils/cloneJson.ts => k8s-api/endpoints/types/aggregation-rule.ts} (53%) create mode 100644 src/common/k8s-api/endpoints/types/job-template-spec.ts create mode 100644 src/common/k8s-api/endpoints/types/persistent-volume-claim-template-spec.ts create mode 100644 src/common/k8s-api/endpoints/types/pod-template-spec.ts create mode 100644 src/common/k8s-api/endpoints/types/policy-rule.ts create mode 100644 src/common/k8s-api/endpoints/types/resource-requirements.ts rename src/{preload.ts => common/k8s-api/endpoints/types/role-ref.ts} (64%) create mode 100644 src/common/k8s-api/endpoints/types/subject.ts delete mode 100644 src/common/k8s-api/workload-kube-object.ts create mode 100644 src/common/utils/json.ts create mode 100644 src/common/utils/readonly.ts create mode 100644 src/common/utils/string.ts create mode 100644 src/common/utils/types.ts create mode 100644 src/common/utils/wait.ts create mode 100644 src/main/router/route.ts create mode 100644 src/renderer/api/create-terminal-api.injectable.ts create mode 100644 src/renderer/cluster/hosted-cluster-id.injectable.ts rename src/{common/cluster-store => renderer/cluster}/hosted-cluster.injectable.ts (52%) create mode 100644 src/renderer/components/+events/duration-absolute.tsx rename src/renderer/components/+helm-releases/{release-rollback-dialog.scss => dialog/dialog.scss} (100%) create mode 100644 src/renderer/components/+helm-releases/dialog/dialog.tsx create mode 100644 src/renderer/components/+helm-releases/dialog/open.injectable.ts create mode 100644 src/renderer/components/+helm-releases/dialog/state.injectable.ts delete mode 100644 src/renderer/components/+helm-releases/release-rollback-dialog-model/release-rollback-dialog-model.injectable.ts delete mode 100644 src/renderer/components/+helm-releases/release-rollback-dialog-model/release-rollback-dialog-model.ts delete mode 100644 src/renderer/components/+helm-releases/release-rollback-dialog.tsx delete mode 100644 src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.ts create mode 100644 src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx delete mode 100644 src/renderer/components/+user-management/+cluster-role-bindings/hashers.ts rename src/renderer/components/+user-management/{+role-bindings => }/hashers.ts (69%) create mode 100644 src/renderer/components/chart/options.ts create mode 100644 src/renderer/components/cluster-manager/lens-views.injectable.ts delete mode 100644 src/renderer/components/delete-cluster-dialog/delete-cluster-dialog-model/delete-cluster-dialog-model.injectable.ts delete mode 100644 src/renderer/components/delete-cluster-dialog/delete-cluster-dialog-model/delete-cluster-dialog-model.ts delete mode 100644 src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx create mode 100644 src/renderer/components/delete-cluster-dialog/open.injectable.ts create mode 100644 src/renderer/components/delete-cluster-dialog/state.injectable.ts rename src/renderer/components/delete-cluster-dialog/{delete-cluster-dialog.module.scss => view.module.scss} (100%) create mode 100644 src/renderer/components/delete-cluster-dialog/view.tsx rename src/renderer/routes/{get-extension-route-path.ts => for-extension.ts} (60%) delete mode 100644 src/renderer/routes/get-extension-route-id.ts rename src/renderer/themes/{lens-dark.json => lens-dark.ts} (94%) rename src/renderer/themes/{lens-light.json => lens-light.ts} (94%) diff --git a/.eslintrc.js b/.eslintrc.js index 8cd6fd26fa..3a3b6f7030 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -139,6 +139,28 @@ module.exports = { "requireLast": false, }, }], + "react/jsx-max-props-per-line": ["error", { + "maximum": { + "single": 2, + "multi": 1, + }, + }], + "react/jsx-first-prop-new-line": ["error", "multiline"], + "react/jsx-one-expression-per-line": ["error", { + "allow": "single-child", + }], + "react/jsx-indent": ["error", 2], + "react/jsx-indent-props": ["error", 2], + "react/jsx-closing-tag-location": "error", + "react/jsx-wrap-multilines": ["error", { + "declaration": "parens-new-line", + "assignment": "parens-new-line", + "return": "parens-new-line", + "arrow": "parens-new-line", + "condition": "parens-new-line", + "logical": "parens-new-line", + "prop": "parens-new-line", + }], "react/display-name": "off", "space-before-function-paren": "off", "@typescript-eslint/space-before-function-paren": ["error", { diff --git a/build/build_theme_vars.ts b/build/build_theme_vars.ts index 44376712a7..dbade45847 100644 --- a/build/build_theme_vars.ts +++ b/build/build_theme_vars.ts @@ -5,11 +5,11 @@ import fs from "fs-extra"; import path from "path"; -import defaultBaseLensTheme from "../src/renderer/themes/lens-dark.json"; +import defaultBaseLensTheme from "../src/renderer/themes/lens-dark"; const outputCssFile = path.resolve("src/renderer/themes/theme-vars.css"); -const banner = `/* +const banner = `/* Generated Lens theme CSS-variables, don't edit manually. To refresh file run $: yarn run ts-node build/${path.basename(__filename)} */`; diff --git a/extensions/kube-object-event-status/package.json b/extensions/kube-object-event-status/package.json index a453097288..6b489eb07e 100644 --- a/extensions/kube-object-event-status/package.json +++ b/extensions/kube-object-event-status/package.json @@ -1,6 +1,6 @@ { "name": "kube-object-event-status", - "version": "0.0.1", + "version": "5.5.0-alpha.0.1649256751918", "description": "Adds kube object status from events", "renderer": "dist/renderer.js", "lens": { diff --git a/extensions/metrics-cluster-feature/package.json b/extensions/metrics-cluster-feature/package.json index 708d644b28..ea1d7dc928 100644 --- a/extensions/metrics-cluster-feature/package.json +++ b/extensions/metrics-cluster-feature/package.json @@ -1,6 +1,6 @@ { "name": "lens-metrics-cluster-feature", - "version": "0.0.1", + "version": "5.5.0-alpha.0.1649256751918", "description": "Lens metrics cluster feature", "renderer": "dist/renderer.js", "lens": { diff --git a/extensions/metrics-cluster-feature/src/metrics-settings.tsx b/extensions/metrics-cluster-feature/src/metrics-settings.tsx index dfd943797e..3f20a80df1 100644 --- a/extensions/metrics-cluster-feature/src/metrics-settings.tsx +++ b/extensions/metrics-cluster-feature/src/metrics-settings.tsx @@ -208,14 +208,14 @@ export class MetricsSettings extends React.Component {
this.togglePrometheus(v.target.checked)} name="prometheus" /> - } + )} label="Enable bundled Prometheus metrics stack" /> @@ -226,14 +226,14 @@ export class MetricsSettings extends React.Component {
this.toggleKubeStateMetrics(v.target.checked)} name="node-exporter" /> - } + )} label="Enable bundled kube-state-metrics stack" /> @@ -245,14 +245,14 @@ export class MetricsSettings extends React.Component {
this.toggleNodeExporter(v.target.checked)} name="node-exporter" /> - } + )} label="Enable bundled node-exporter stack" /> @@ -271,9 +271,11 @@ export class MetricsSettings extends React.Component { className="w-60 h-14" /> - {this.canUpgrade && ( - An update is available for enabled metrics components. - )} + {this.canUpgrade && ( + + An update is available for enabled metrics components. + + )}
); diff --git a/extensions/node-menu/package.json b/extensions/node-menu/package.json index 4ec9fb5688..f3053fb6ff 100644 --- a/extensions/node-menu/package.json +++ b/extensions/node-menu/package.json @@ -1,6 +1,6 @@ { "name": "lens-node-menu", - "version": "0.0.1", + "version": "5.5.0-alpha.0.1649256751918", "description": "Lens node menu", "renderer": "dist/renderer.js", "lens": { diff --git a/extensions/node-menu/src/node-menu.tsx b/extensions/node-menu/src/node-menu.tsx index 9666ad36e0..adc7576206 100644 --- a/extensions/node-menu/src/node-menu.tsx +++ b/extensions/node-menu/src/node-menu.tsx @@ -68,7 +68,9 @@ export function NodeMenu(props: NodeMenuProps) { labelOk: `Drain Node`, message: (

- Are you sure you want to drain {nodeName}? + {"Are you sure you want to drain "} + {nodeName} + ?

), }); @@ -77,26 +79,42 @@ export function NodeMenu(props: NodeMenuProps) { return ( <> - + Shell { node.isUnschedulable() ? ( - + Uncordon ) : ( - + Cordon ) } - + Drain diff --git a/extensions/pod-menu/package.json b/extensions/pod-menu/package.json index c55c9bdd75..91c8101a0c 100644 --- a/extensions/pod-menu/package.json +++ b/extensions/pod-menu/package.json @@ -1,6 +1,6 @@ { "name": "lens-pod-menu", - "version": "0.0.1", + "version": "5.5.0-alpha.0.1649256751918", "description": "Lens pod menu", "renderer": "dist/renderer.js", "lens": { diff --git a/extensions/pod-menu/src/attach-menu.tsx b/extensions/pod-menu/src/attach-menu.tsx index de8035ed94..fd6250bd30 100644 --- a/extensions/pod-menu/src/attach-menu.tsx +++ b/extensions/pod-menu/src/attach-menu.tsx @@ -71,7 +71,11 @@ export class PodAttachMenu extends React.Component { return ( this.attachToPod(containers[0].name))}> - + Attach Pod {containers.length > 1 && ( <> @@ -82,7 +86,11 @@ export class PodAttachMenu extends React.Component { const { name } = container; return ( - this.attachToPod(name))} className="flex align-center"> + this.attachToPod(name))} + className="flex align-center" + > {name} diff --git a/extensions/pod-menu/src/logs-menu.tsx b/extensions/pod-menu/src/logs-menu.tsx index b7cc0851aa..e5c8496d3d 100644 --- a/extensions/pod-menu/src/logs-menu.tsx +++ b/extensions/pod-menu/src/logs-menu.tsx @@ -46,7 +46,11 @@ export class PodLogsMenu extends React.Component { return ( this.showLogs(containers[0]))}> - + Logs {containers.length > 1 && ( <> @@ -63,7 +67,11 @@ export class PodLogsMenu extends React.Component { ) : null; return ( - this.showLogs(container))} className="flex align-center"> + this.showLogs(container))} + className="flex align-center" + > {brick} {name} diff --git a/extensions/pod-menu/src/shell-menu.tsx b/extensions/pod-menu/src/shell-menu.tsx index 4e239d6fdf..36be7c470b 100644 --- a/extensions/pod-menu/src/shell-menu.tsx +++ b/extensions/pod-menu/src/shell-menu.tsx @@ -79,7 +79,11 @@ export class PodShellMenu extends React.Component { return ( this.execShell(containers[0].name))}> - + Shell {containers.length > 1 && ( <> @@ -90,7 +94,11 @@ export class PodShellMenu extends React.Component { const { name } = container; return ( - this.execShell(name))} className="flex align-center"> + this.execShell(name))} + className="flex align-center" + > {name} diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index 33dabede04..f8eca46c26 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -108,6 +108,10 @@ export async function lauchMinikubeClusterFromCatalog(window: Page): Promise { - @observable a: string; - @observable b: string; - @observable c: string; + @observable a = ""; + @observable b = ""; + @observable c = ""; constructor() { super({ @@ -90,7 +90,6 @@ describe("BaseStore", () => { await mainDi.runSetups(); - store = undefined; TestStore.resetInstance(); const mockOpts = { diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index ccc2d897b9..263cb0dd5d 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -14,16 +14,13 @@ import { stdout, stderr } from "process"; import getCustomKubeConfigDirectoryInjectable from "../app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable"; import clusterStoreInjectable from "../cluster-store/cluster-store.injectable"; import type { ClusterModel } from "../cluster-types"; -import type { - DiContainer, -} from "@ogre-tools/injectable"; - +import type { DiContainer } from "@ogre-tools/injectable"; import { createClusterInjectionToken } from "../cluster/create-cluster-injection-token"; - import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; import getConfigurationFileModelInjectable from "../get-configuration-file-model/get-configuration-file-model.injectable"; import appVersionInjectable from "../get-configuration-file-model/app-version/app-version.injectable"; +import assert from "assert"; console = new Console(stdout, stderr); @@ -148,6 +145,8 @@ describe("cluster-store", () => { it("adds new cluster to store", async () => { const storedCluster = clusterStore.getById("foo"); + assert(storedCluster); + expect(storedCluster.id).toBe("foo"); expect(storedCluster.preferences.terminalCWD).toBe("/some-directory-for-user-data"); expect(storedCluster.preferences.icon).toBe( @@ -249,6 +248,8 @@ describe("cluster-store", () => { it("allows to retrieve a cluster", () => { const storedCluster = clusterStore.getById("cluster1"); + assert(storedCluster); + expect(storedCluster.id).toBe("cluster1"); expect(storedCluster.preferences.terminalCWD).toBe("/foo"); }); @@ -379,6 +380,7 @@ users: it("migrates to modern format with icon not in file", async () => { const { icon } = clusterStore.clustersList[0].preferences; + assert(icon); expect(icon.startsWith("data:;base64,")).toBe(true); }); }); diff --git a/src/common/__tests__/event-bus.test.ts b/src/common/__tests__/event-bus.test.ts index e7165aac92..f90381eb44 100644 --- a/src/common/__tests__/event-bus.test.ts +++ b/src/common/__tests__/event-bus.test.ts @@ -5,7 +5,7 @@ import type { AppEvent } from "../app-event-bus/event-bus"; import { appEventBus } from "../app-event-bus/event-bus"; -import { Console } from "console"; +import { assert, Console } from "console"; import { stdout, stderr } from "process"; console = new Console(stdout, stderr); @@ -13,14 +13,15 @@ console = new Console(stdout, stderr); describe("event bus tests", () => { describe("emit", () => { it("emits an event", () => { - let event: AppEvent = null; + let event: AppEvent | undefined; appEventBus.addListener((data) => { event = data; }); appEventBus.emit({ name: "foo", action: "bar" }); - expect(event.name).toBe("foo"); + assert(event); + expect(event?.name).toBe("foo"); }); }); }); diff --git a/src/common/__tests__/event-emitter.test.ts b/src/common/__tests__/event-emitter.test.ts index 5a0e49aad0..cc71922182 100644 --- a/src/common/__tests__/event-emitter.test.ts +++ b/src/common/__tests__/event-emitter.test.ts @@ -21,7 +21,7 @@ describe("EventEmitter", () => { let called = false; const e = new EventEmitter<[]>(); - e.addListener(() => 0 as any, {}); + e.addListener(() => 0 as never, {}); e.addListener(() => { called = true; }, {}); e.emit(); diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index 9bc12d103d..31827dd761 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -174,7 +174,7 @@ describe("HotbarStore", () => { }); it("initially adds catalog entity as first item", () => { - expect(hotbarStore.getActive().items[0].entity.name).toEqual("Catalog"); + expect(hotbarStore.getActive().items[0]?.entity.name).toEqual("Catalog"); }); it("adds items", () => { @@ -211,7 +211,7 @@ describe("HotbarStore", () => { hotbarStore.restackItems(1, 5); expect(hotbarStore.getActive().items[5]).toBeTruthy(); - expect(hotbarStore.getActive().items[5].entity.uid).toEqual("test"); + expect(hotbarStore.getActive().items[5]?.entity.uid).toEqual("test"); }); it("moves items down", () => { @@ -275,7 +275,7 @@ describe("HotbarStore", () => { hotbarStore.addToHotbar(testCluster); hotbarStore.restackItems(1, 1); - expect(hotbarStore.getActive().items[1].entity.uid).toEqual("test"); + expect(hotbarStore.getActive().items[1]?.entity.uid).toEqual("test"); }); it("new items takes first empty cell", () => { @@ -284,7 +284,7 @@ describe("HotbarStore", () => { hotbarStore.restackItems(0, 3); hotbarStore.addToHotbar(minikubeCluster); - expect(hotbarStore.getActive().items[0].entity.uid).toEqual("minikube"); + expect(hotbarStore.getActive().items[0]?.entity.uid).toEqual("minikube"); }); it("throws if invalid arguments provided", () => { @@ -389,7 +389,7 @@ describe("HotbarStore", () => { it("allows to retrieve a hotbar", () => { const hotbar = hotbarStore.getById("3caac17f-aec2-4723-9694-ad204465d935"); - expect(hotbar.id).toBe("3caac17f-aec2-4723-9694-ad204465d935"); + expect(hotbar?.id).toBe("3caac17f-aec2-4723-9694-ad204465d935"); }); it("clears cells without entity", () => { diff --git a/src/common/__tests__/system-ca.test.ts b/src/common/__tests__/system-ca.test.ts index 0d962ee368..473fe6ed57 100644 --- a/src/common/__tests__/system-ca.test.ts +++ b/src/common/__tests__/system-ca.test.ts @@ -6,13 +6,14 @@ import https from "https"; import os from "os"; import { getMacRootCA, getWinRootCA, injectCAs, DSTRootCAX3 } from "../system-ca"; import { dependencies, devDependencies } from "../../../package.json"; +import assert from "assert"; const deps = { ...dependencies, ...devDependencies }; // Skip the test if mac-ca is not installed, or os is not darwin (deps["mac-ca"] && os.platform().includes("darwin") ? describe: describe.skip)("inject CA for Mac", () => { // for reset https.globalAgent.options.ca after testing - let _ca: string | Buffer | (string | Buffer)[]; + let _ca: string | Buffer | (string | Buffer)[] | undefined; beforeEach(() => { _ca = https.globalAgent.options.ca; @@ -44,6 +45,7 @@ const deps = { ...dependencies, ...devDependencies }; injectCAs(osxCAs); const injected = https.globalAgent.options.ca; + assert(injected); expect(injected.includes(DSTRootCAX3)).toBeFalsy(); }); }); @@ -51,7 +53,7 @@ const deps = { ...dependencies, ...devDependencies }; // Skip the test if win-ca is not installed, or os is not win32 (deps["win-ca"] && os.platform().includes("win32") ? describe: describe.skip)("inject CA for Windows", () => { // for reset https.globalAgent.options.ca after testing - let _ca: string | Buffer | (string | Buffer)[]; + let _ca: string | Buffer | (string | Buffer)[] | undefined; beforeEach(() => { _ca = https.globalAgent.options.ca; diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index 9fb3713be1..3e9e3c9b9a 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -49,7 +49,7 @@ describe("user store tests", () => { mockFs(); - di.override(writeFileInjectable, () => () => undefined); + di.override(writeFileInjectable, () => () => Promise.resolve()); di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); di.override(userStoreInjectable, () => UserStore.createInstance()); diff --git a/src/common/catalog-entities/general.ts b/src/common/catalog-entities/general.ts index 9c33499ec8..975b24e10f 100644 --- a/src/common/catalog-entities/general.ts +++ b/src/common/catalog-entities/general.ts @@ -5,7 +5,7 @@ import { navigate } from "../../renderer/navigation"; import type { CatalogEntityMetadata, CatalogEntitySpec, CatalogEntityStatus } from "../catalog"; -import { CatalogCategory, CatalogEntity } from "../catalog"; +import { CatalogCategory, CatalogEntity, categoryVersion } from "../catalog"; import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; interface GeneralEntitySpec extends CatalogEntitySpec { @@ -47,10 +47,7 @@ export class GeneralCategory extends CatalogCategory { public spec = { group: "entity.k8slens.dev", versions: [ - { - name: "v1alpha1", - entityClass: GeneralEntity, - }, + categoryVersion("v1alpha1", GeneralEntity), ], names: { kind: "General", diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 83a596fcc8..83bc9f3e1e 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -5,11 +5,11 @@ import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; import type { CatalogEntityActionContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus, CatalogCategorySpec } from "../catalog"; -import { CatalogEntity, CatalogCategory } from "../catalog"; +import { CatalogEntity, CatalogCategory, categoryVersion } from "../catalog"; import { ClusterStore } from "../cluster-store/cluster-store"; import { broadcastMessage } from "../ipc"; import { app } from "electron"; -import type { CatalogEntitySpec } from "../catalog/catalog-entity"; +import type { CatalogEntityConstructor, CatalogEntitySpec } from "../catalog/catalog-entity"; import { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; import { requestClusterActivation, requestClusterDisconnection } from "../../renderer/ipc"; import KubeClusterCategoryIcon from "./icons/kubernetes.svg"; @@ -60,6 +60,10 @@ export type KubernetesClusterStatusPhase = "connected" | "connecting" | "disconn export interface KubernetesClusterStatus extends CatalogEntityStatus { } +export function isKubernetesCluster(item: unknown): item is KubernetesCluster { + return item instanceof KubernetesCluster; +} + export class KubernetesCluster< Metadata extends KubernetesClusterMetadata = KubernetesClusterMetadata, Status extends KubernetesClusterStatus = KubernetesClusterStatus, @@ -145,10 +149,7 @@ class KubernetesClusterCategory extends CatalogCategory { public spec: CatalogCategorySpec = { group: "entity.k8slens.dev", versions: [ - { - name: "v1alpha1", - entityClass: KubernetesCluster, - }, + categoryVersion("v1alpha1", KubernetesCluster as CatalogEntityConstructor), ], names: { kind: "KubernetesCluster", diff --git a/src/common/catalog-entities/web-link.ts b/src/common/catalog-entities/web-link.ts index 15331cc0fc..8429df7ab1 100644 --- a/src/common/catalog-entities/web-link.ts +++ b/src/common/catalog-entities/web-link.ts @@ -4,7 +4,7 @@ */ import type { CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; -import { CatalogCategory, CatalogEntity } from "../catalog"; +import { CatalogCategory, CatalogEntity, categoryVersion } from "../catalog"; import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; import { productName } from "../vars"; import { WeblinkStore } from "../weblink-store"; @@ -47,7 +47,7 @@ export class WebLink extends CatalogEntity(this) + .getCategoryForEntity(this) ?.emit("contextMenuOpen", this, context); } } @@ -62,10 +62,7 @@ export class WebLinkCategory extends CatalogCategory { public spec = { group: "entity.k8slens.dev", versions: [ - { - name: "v1alpha1", - entityClass: WebLink, - }, + categoryVersion("v1alpha1", WebLink), ], names: { kind: "WebLink", diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index a031e2766a..7087164291 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -11,19 +11,19 @@ import type { Disposer } from "../utils"; import { iter } from "../utils"; import type { CategoryColumnRegistration } from "../../renderer/components/+catalog/custom-category-columns"; -type ExtractEntityMetadataType = Entity extends CatalogEntity ? Metadata : never; -type ExtractEntityStatusType = Entity extends CatalogEntity ? Status : never; -type ExtractEntitySpecType = Entity extends CatalogEntity ? Spec : never; +export type CatalogEntityDataFor = Entity extends CatalogEntity + ? CatalogEntityData + : never; + +export type CatalogEntityInstanceFrom = Constructor extends CatalogEntityConstructor + ? Entity + : never; export type CatalogEntityConstructor = ( - (new (data: CatalogEntityData< - ExtractEntityMetadataType, - ExtractEntityStatusType, - ExtractEntitySpecType - >) => Entity) + new (data: CatalogEntityDataFor) => Entity ); -export interface CatalogCategoryVersion { +export interface CatalogCategoryVersion { /** * The specific version that the associated constructor is for. This MUST be * a DNS label and SHOULD be of the form `vN`, `vNalphaY`, or `vNbetaY` where @@ -35,19 +35,19 @@ export interface CatalogCategoryVersion { * - `v1alpha2` * - `v3beta2` */ - name: string; + readonly name: string; /** * The constructor for the entities. */ - entityClass: CatalogEntityConstructor; + readonly entityClass: CatalogEntityConstructor; } export interface CatalogCategorySpec { /** * The grouping for for the category. This MUST be a DNS label. */ - group: string; + readonly group: string; /** * The specific versions of the constructors. @@ -56,18 +56,18 @@ export interface CatalogCategorySpec { * For example, if `group = "entity.k8slens.dev"` and there is an entry in `.versions` with * `name = "v1alpha1"` then the resulting `.apiVersion` MUST be `entity.k8slens.dev/v1alpha1` */ - versions: CatalogCategoryVersion[]; + readonly versions: CatalogCategoryVersion[]; /** * This is the concerning the category */ - names: { + readonly names: { /** * The kind of entity that this category is for. This value MUST be a DNS * label and MUST be equal to the `kind` fields that are produced by the * `.versions.[] | .entityClass` fields. */ - kind: string; + readonly kind: string; }; /** @@ -81,7 +81,7 @@ export interface CatalogCategorySpec { * * These columns will not be used in the "Browse" view. */ - displayColumns?: CategoryColumnRegistration[]; + readonly displayColumns?: CategoryColumnRegistration[]; } /** @@ -109,6 +109,30 @@ export interface CatalogCategoryEvents { contextMenuOpen: (entity: CatalogEntity, context: CatalogEntityContextMenuContext) => void; } +export interface CatalogCategoryMetadata { + /** + * The name of your category. The category can be searched for by this + * value. This will also be used for the catalog menu. + */ + readonly name: string; + /** + * Either an `` or the name of an icon from {@link IconProps} + */ + readonly icon: string; +} + +export function categoryVersion< + T extends CatalogEntity, + Metadata extends CatalogEntityMetadata, + Status extends CatalogEntityStatus, + Spec extends CatalogEntitySpec, +>(name: string, entityClass: new (data: CatalogEntityData) => T): CatalogCategoryVersion { + return { + name, + entityClass: entityClass as CatalogEntityConstructor, + }; +} + export abstract class CatalogCategory extends (EventEmitter as new () => TypedEmitter) { /** * The version of category that you are wanting to declare. @@ -131,28 +155,17 @@ export abstract class CatalogCategory extends (EventEmitter as new () => TypedEm /** * The data about the category itself */ - abstract readonly metadata: { - /** - * The name of your category. The category can be searched for by this - * value. This will also be used for the catalog menu. - */ - name: string; - - /** - * Either an `` or the name of an icon from {@link IconProps} - */ - icon: string; - }; + abstract readonly metadata: CatalogCategoryMetadata; /** * The most important part of a category, as it is where entity versions are declared. */ - abstract spec: CatalogCategorySpec; + abstract readonly spec: CatalogCategorySpec; /** * @internal */ - protected filters = observable.set([], { + protected readonly filters = observable.set([], { deep: false, }); @@ -217,14 +230,16 @@ export abstract class CatalogCategory extends (EventEmitter as new () => TypedEm } } -export interface CatalogEntityMetadata { +export type EntityMetadataObject = { [Key in string]?: EntityMetadataValue }; +export type EntityMetadataValue = string | number | boolean | EntityMetadataObject | undefined; + +export interface CatalogEntityMetadata extends EntityMetadataObject { uid: string; name: string; shortName?: string; description?: string; source?: string; labels: Record; - [key: string]: string | object; } export interface CatalogEntityStatus { diff --git a/src/common/cluster-store/allowed-resources.injectable.ts b/src/common/cluster-store/allowed-resources.injectable.ts index 2277befbd6..3bb71e0af0 100644 --- a/src/common/cluster-store/allowed-resources.injectable.ts +++ b/src/common/cluster-store/allowed-resources.injectable.ts @@ -4,7 +4,7 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { comparer, computed } from "mobx"; -import hostedClusterInjectable from "./hosted-cluster.injectable"; +import hostedClusterInjectable from "../../renderer/cluster/hosted-cluster.injectable"; const allowedResourcesInjectable = getInjectable({ id: "allowed-resources", @@ -12,7 +12,7 @@ const allowedResourcesInjectable = getInjectable({ instantiate: (di) => { const cluster = di.inject(hostedClusterInjectable); - return computed(() => new Set(cluster.allowedResources), { + return computed(() => new Set(cluster?.allowedResources), { // This needs to be here so that during refresh changes are only propogated when necessary equals: (cur, prev) => comparer.structural(cur, prev), }); diff --git a/src/common/cluster-store/cluster-store.ts b/src/common/cluster-store/cluster-store.ts index 901f340bda..55ebf6aad1 100644 --- a/src/common/cluster-store/cluster-store.ts +++ b/src/common/cluster-store/cluster-store.ts @@ -103,8 +103,12 @@ export class ClusterStore extends BaseStore { return this.clusters.size > 0; } - getById(id: ClusterId): Cluster | null { - return this.clusters.get(id) ?? null; + getById(id: ClusterId | undefined): Cluster | undefined { + if (id) { + return this.clusters.get(id); + } + + return undefined; } addCluster(clusterOrModel: ClusterModel | Cluster): Cluster { diff --git a/src/common/cluster/cluster.ts b/src/common/cluster/cluster.ts index fbe7880f3c..0720b579a3 100644 --- a/src/common/cluster/cluster.ts +++ b/src/common/cluster/cluster.ts @@ -6,7 +6,7 @@ import { ipcMain } from "electron"; import { action, comparer, computed, makeObservable, observable, reaction, when } from "mobx"; import { broadcastMessage } from "../ipc"; -import type { ContextHandler } from "../../main/context-handler/context-handler"; +import type { ClusterContextHandler } from "../../main/context-handler/context-handler"; import type { KubeConfig } from "@kubernetes/client-node"; import { HttpError } from "@kubernetes/client-node"; import type { Kubectl } from "../../main/kubectl/kubectl"; @@ -20,16 +20,17 @@ import { DetectorRegistry } from "../../main/cluster-detectors/detector-registry import plimit from "p-limit"; import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate } from "../cluster-types"; import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../cluster-types"; -import { disposer, toJS } from "../utils"; +import { disposer, isDefined, isRequestError, toJS } from "../utils"; import type { Response } from "request"; import { clusterListNamespaceForbiddenChannel } from "../ipc/cluster"; import type { CanI } from "./authorization-review.injectable"; import type { ListNamespaces } from "./list-namespaces.injectable"; +import assert from "assert"; export interface ClusterDependencies { readonly directoryForKubeConfigs: string; createKubeconfigManager: (cluster: Cluster) => KubeconfigManager; - createContextHandler: (cluster: Cluster) => ContextHandler; + createContextHandler: (cluster: Cluster) => ClusterContextHandler; createKubectl: (clusterVersion: string) => Kubectl; createAuthorizationReview: (config: KubeConfig) => CanI; createListNamespaces: (config: KubeConfig) => ListNamespaces; @@ -43,17 +44,31 @@ export interface ClusterDependencies { export class Cluster implements ClusterModel, ClusterState { /** Unique id for a cluster */ public readonly id: ClusterId; - private kubeCtl: Kubectl; + private kubeCtl: Kubectl | undefined; /** * Context handler * * @internal */ - public contextHandler: ContextHandler; - protected proxyKubeconfigManager: KubeconfigManager; - protected eventsDisposer = disposer(); + protected readonly _contextHandler: ClusterContextHandler | undefined; + protected readonly _proxyKubeconfigManager: KubeconfigManager | undefined; + protected readonly eventsDisposer = disposer(); protected activated = false; - private resourceAccessStatuses: Map = new Map(); + private readonly resourceAccessStatuses = new Map(); + + public get contextHandler() { + // TODO: remove these once main/renderer are seperate classes + assert(this._contextHandler); + + return this._contextHandler; + } + + protected get proxyKubeconfigManager() { + // TODO: remove these once main/renderer are seperate classes + assert(this._proxyKubeconfigManager); + + return this._proxyKubeconfigManager; + } get whenReady() { return when(() => this.ready); @@ -64,21 +79,21 @@ export class Cluster implements ClusterModel, ClusterState { * * @observable */ - @observable contextName: string; + @observable contextName!: string; /** * Path to kubeconfig * * @observable */ - @observable kubeConfigPath: string; + @observable kubeConfigPath!: string; /** * @deprecated */ - @observable workspace: string; + @observable workspace?: string; /** * @deprecated */ - @observable workspaces: string[]; + @observable workspaces?: string[]; /** * Kubernetes API server URL * @@ -215,7 +230,7 @@ export class Cluster implements ClusterModel, ClusterState { * @computed * @internal */ - @computed get defaultNamespace(): string { + @computed get defaultNamespace(): string | undefined { return this.preferences.defaultNamespace; } @@ -231,12 +246,20 @@ export class Cluster implements ClusterModel, ClusterState { throw validationError; } - this.apiUrl = config.getCluster(config.getContextObject(this.contextName).cluster).server; + const context = config.getContextObject(this.contextName); + + assert(context); + + const cluster = config.getCluster(context.cluster); + + assert(cluster); + + this.apiUrl = cluster.server; if (ipcMain) { // for the time being, until renderer gets its own cluster type - this.contextHandler = this.dependencies.createContextHandler(this); - this.proxyKubeconfigManager = this.dependencies.createKubeconfigManager(this); + this._contextHandler = this.dependencies.createContextHandler(this); + this._proxyKubeconfigManager = this.dependencies.createKubeconfigManager(this); logger.debug(`[CLUSTER]: Cluster init success`, { id: this.id, @@ -255,6 +278,7 @@ export class Cluster implements ClusterModel, ClusterState { // Note: do not assign ID as that should never be updated this.kubeConfigPath = model.kubeConfigPath; + this.contextName = model.contextName; if (model.workspace) { this.workspace = model.workspace; @@ -264,10 +288,6 @@ export class Cluster implements ClusterModel, ClusterState { this.workspaces = model.workspaces; } - if (model.contextName) { - this.contextName = model.contextName; - } - if (model.preferences) { this.preferences = model.preferences; } @@ -373,8 +393,7 @@ export class Cluster implements ClusterModel, ClusterState { @action async reconnect() { logger.info(`[CLUSTER]: reconnect`, this.getMeta()); - this.contextHandler?.stopServer(); - await this.contextHandler?.ensureServer(); + await this.contextHandler?.restartServer(); this.disconnected = false; } @@ -497,32 +516,36 @@ export class Cluster implements ClusterModel, ClusterState { } catch (error) { logger.error(`[CLUSTER]: Failed to connect to "${this.contextName}": ${error}`); - if (error.statusCode) { - if (error.statusCode >= 400 && error.statusCode < 500) { - this.broadcastConnectUpdate("Invalid credentials", true); + if (isRequestError(error)) { + if (error.statusCode) { + if (error.statusCode >= 400 && error.statusCode < 500) { + this.broadcastConnectUpdate("Invalid credentials", true); - return ClusterStatus.AccessDenied; - } + return ClusterStatus.AccessDenied; + } - this.broadcastConnectUpdate(error.error || error.message, true); - - return ClusterStatus.Offline; - } - - if (error.failed === true) { - if (error.timedOut === true) { - this.broadcastConnectUpdate("Connection timed out", true); + this.broadcastConnectUpdate(error.error || error.message, true); return ClusterStatus.Offline; } - this.broadcastConnectUpdate("Failed to fetch credentials", true); + if (error.failed === true) { + if (error.timedOut === true) { + this.broadcastConnectUpdate("Connection timed out", true); - return ClusterStatus.AccessDenied; + return ClusterStatus.Offline; + } + + this.broadcastConnectUpdate("Failed to fetch credentials", true); + + return ClusterStatus.AccessDenied; + } + + this.broadcastConnectUpdate(error.message, true); + } else { + this.broadcastConnectUpdate("Unknown error has occurred", true); } - this.broadcastConnectUpdate(error.message, true); - return ClusterStatus.Offline; } } @@ -609,7 +632,7 @@ export class Cluster implements ClusterModel, ClusterState { return await listNamespaces(); } catch (error) { const ctx = proxyConfig.getContextObject(this.contextName); - const namespaceList = [ctx.namespace].filter(Boolean); + const namespaceList = [ctx?.namespace].filter(isDefined); if (namespaceList.length === 0 && error instanceof HttpError && error.statusCode === 403) { const { response } = error as HttpError & { response: Response }; diff --git a/src/common/cluster/create-cluster-injection-token.ts b/src/common/cluster/create-cluster-injection-token.ts index 0508e7b325..f1e8ef9757 100644 --- a/src/common/cluster/create-cluster-injection-token.ts +++ b/src/common/cluster/create-cluster-injection-token.ts @@ -6,5 +6,8 @@ import { getInjectionToken } from "@ogre-tools/injectable"; import type { ClusterModel } from "../cluster-types"; import type { Cluster } from "./cluster"; -export const createClusterInjectionToken = - getInjectionToken<(model: ClusterModel) => Cluster>({ id: "create-cluster-token" }); +export type CreateCluster = (model: ClusterModel) => Cluster; + +export const createClusterInjectionToken = getInjectionToken({ + id: "create-cluster-token", +}); diff --git a/src/common/cluster/list-namespaces.injectable.ts b/src/common/cluster/list-namespaces.injectable.ts index 200326c705..468ff3ac2e 100644 --- a/src/common/cluster/list-namespaces.injectable.ts +++ b/src/common/cluster/list-namespaces.injectable.ts @@ -5,6 +5,7 @@ import type { KubeConfig } from "@kubernetes/client-node"; import { CoreV1Api } from "@kubernetes/client-node"; import { getInjectable } from "@ogre-tools/injectable"; +import { isDefined } from "../utils"; export type ListNamespaces = () => Promise; @@ -14,7 +15,9 @@ export function listNamespaces(config: KubeConfig): ListNamespaces { return async () => { const { body: { items }} = await coreApi.listNamespace(); - return items.map(ns => ns.metadata.name); + return items + .map(ns => ns.metadata?.name) + .filter(isDefined); }; } diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts index abbae0e1b0..3c6a0bf810 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { action, comparer, observable, makeObservable, computed } from "mobx"; +import { action, comparer, observable, makeObservable, computed, runInAction } from "mobx"; import { BaseStore } from "./base-store"; import migrations from "../migrations/hotbar-store"; import { toJS } from "./utils"; @@ -33,7 +33,7 @@ interface Dependencies { export class HotbarStore extends BaseStore { readonly displayName = "HotbarStore"; @observable hotbars: Hotbar[] = []; - @observable private _activeHotbarId: string; + @observable private _activeHotbarId!: string; constructor(private dependencies: Dependencies) { super({ @@ -120,8 +120,22 @@ export class HotbarStore extends BaseStore { return toJS(model); } - getActive() { - return this.getById(this.activeHotbarId); + getActive(): Hotbar { + const hotbar = this.getById(this.activeHotbarId); + + if (hotbar) { + return hotbar; + } + + runInAction(() => { + if (this.hotbars.length === 0) { + this.hotbars.push(getEmptyHotbar("Default")); + } + + this._activeHotbarId = this.hotbars[0].id; + }); + + return this.hotbars[0]; } getByName(name: string) { @@ -147,7 +161,7 @@ export class HotbarStore extends BaseStore { }, ); - setHotbarName = action((id: string, name: string) => { + setHotbarName = action((id: string, name: string): void => { const index = this.hotbars.findIndex((hotbar) => hotbar.id === id); if (index < 0) { diff --git a/src/common/hotbar-types.ts b/src/common/hotbar-types.ts index 34ccfaa74e..13f6e8360d 100644 --- a/src/common/hotbar-types.ts +++ b/src/common/hotbar-types.ts @@ -10,7 +10,7 @@ import { tuple } from "./utils"; export interface HotbarItem { entity: { uid: string; - name?: string; + name: string; source?: string; }; params?: { diff --git a/src/common/ipc-channel/create-channel/create-channel.ts b/src/common/ipc-channel/create-channel/create-channel.ts index 2a18e3c145..6b9fe1b0d9 100644 --- a/src/common/ipc-channel/create-channel/create-channel.ts +++ b/src/common/ipc-channel/create-channel/create-channel.ts @@ -6,5 +6,5 @@ import type { Channel } from "../channel"; export const createChannel = (name: string): Channel => ({ name, - _template: null, + _template: null as never, }); diff --git a/src/common/ipc/__tests__/type-enforced-ipc.test.ts b/src/common/ipc/__tests__/type-enforced-ipc.test.ts index d2820ec231..bd456257cb 100644 --- a/src/common/ipc/__tests__/type-enforced-ipc.test.ts +++ b/src/common/ipc/__tests__/type-enforced-ipc.test.ts @@ -53,7 +53,7 @@ describe("type enforced ipc tests", () => { const source = new EventEmitter(); const listener = () => called += 1; const results = [true, false, true]; - const verifier = (args: unknown[]): args is [] => results.pop(); + const verifier = (args: unknown[]): args is [] => results.pop() ?? false; const channel = "foobar"; onCorrect({ source, listener, verifier, channel }); diff --git a/src/common/item.store.ts b/src/common/item.store.ts index 68ef6813b9..805137e95c 100644 --- a/src/common/item.store.ts +++ b/src/common/item.store.ts @@ -13,8 +13,6 @@ export interface ItemObject { } export abstract class ItemStore { - abstract loadAll(...args: any[]): Promise; - protected defaultSorting = (item: Item) => item.getName(); @observable failedLoading = false; @@ -44,8 +42,7 @@ export abstract class ItemStore { return this.items.length; } - getByName(name: string, ...args: any[]): Item; - getByName(name: string): Item { + getByName(name: string): Item | undefined { return this.items.find(item => item.getName() === name); } @@ -115,7 +112,6 @@ export abstract class ItemStore { } } - protected async loadItem(...args: any[]): Promise; @action protected async loadItem(request: () => Promise, sortItems = true) { const item = await Promise.resolve(request()).catch(() => null); @@ -133,9 +129,9 @@ export abstract class ItemStore { if (sortItems) items = this.sortItems(items); this.items.replace(items); } - - return item; } + + return item; } @action diff --git a/src/common/k8s-api/__tests__/api-manager.test.ts b/src/common/k8s-api/__tests__/api-manager.test.ts index 25d613ee1c..c78e7779b7 100644 --- a/src/common/k8s-api/__tests__/api-manager.test.ts +++ b/src/common/k8s-api/__tests__/api-manager.test.ts @@ -3,18 +3,21 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { ingressStore } from "../../../renderer/components/+network-ingresses/ingress.store"; import { apiManager } from "../api-manager"; import { KubeApi } from "../kube-api"; import { KubeObject } from "../kube-object"; +import { KubeObjectStore } from "../kube-object.store"; class TestApi extends KubeApi { - protected async checkPreferredVersion() { return; } } +class TestStore extends KubeObjectStore { + +} + describe("ApiManager", () => { describe("registerApi", () => { it("re-register store if apiBase changed", async () => { @@ -26,22 +29,23 @@ describe("ApiManager", () => { fallbackApiBases: [fallbackApiBase], checkPreferredVersion: true, }); + const kubeStore = new TestStore(kubeApi); apiManager.registerApi(apiBase, kubeApi); // Define to use test api for ingress store - Object.defineProperty(ingressStore, "api", { value: kubeApi }); - apiManager.registerStore(ingressStore, [kubeApi]); + Object.defineProperty(kubeStore, "api", { value: kubeApi }); + apiManager.registerStore(kubeStore, [kubeApi]); // Test that store is returned with original apiBase - expect(apiManager.getStore(kubeApi)).toBe(ingressStore); + expect(apiManager.getStore(kubeApi)).toBe(kubeStore); // Change apiBase similar as checkPreferredVersion does Object.defineProperty(kubeApi, "apiBase", { value: fallbackApiBase }); apiManager.registerApi(fallbackApiBase, kubeApi); // Test that store is returned with new apiBase - expect(apiManager.getStore(kubeApi)).toBe(ingressStore); + expect(apiManager.getStore(kubeApi)).toBe(kubeStore); }); }); }); diff --git a/src/common/k8s-api/__tests__/crd.test.ts b/src/common/k8s-api/__tests__/crd.test.ts index 5d9e51afe1..e1538dfbe6 100644 --- a/src/common/k8s-api/__tests__/crd.test.ts +++ b/src/common/k8s-api/__tests__/crd.test.ts @@ -16,8 +16,15 @@ describe("Crds", () => { name: "foo", resourceVersion: "12345", uid: "12345", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/foo", }, spec: { + group: "foo.bar", + names: { + kind: "Foo", + plural: "foos", + }, + scope: "Namespaced", versions: [ { name: "123", @@ -44,8 +51,15 @@ describe("Crds", () => { name: "foo", resourceVersion: "12345", uid: "12345", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/foo", }, spec: { + group: "foo.bar", + names: { + kind: "Foo", + plural: "foos", + }, + scope: "Namespaced", versions: [ { name: "123", @@ -72,8 +86,15 @@ describe("Crds", () => { name: "foo", resourceVersion: "12345", uid: "12345", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/foo", }, spec: { + group: "foo.bar", + names: { + kind: "Foo", + plural: "foos", + }, + scope: "Namespaced", versions: [ { name: "123", @@ -100,8 +121,15 @@ describe("Crds", () => { name: "foo", resourceVersion: "12345", uid: "12345", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/foo", }, spec: { + group: "foo.bar", + names: { + kind: "Foo", + plural: "foos", + }, + scope: "Namespaced", version: "abc", versions: [ { @@ -129,6 +157,7 @@ describe("Crds", () => { name: "foo", resourceVersion: "12345", uid: "12345", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/foo", }, spec: { version: "abc", diff --git a/src/common/k8s-api/__tests__/deployment.api.test.ts b/src/common/k8s-api/__tests__/deployment.api.test.ts index 827e2c33bd..05856274d9 100644 --- a/src/common/k8s-api/__tests__/deployment.api.test.ts +++ b/src/common/k8s-api/__tests__/deployment.api.test.ts @@ -3,11 +3,13 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { Deployment, DeploymentApi } from "../endpoints/deployment.api"; +import { DeploymentApi } from "../endpoints/deployment.api"; import type { KubeJsonApi } from "../kube-json-api"; class DeploymentApiTest extends DeploymentApi { - public setRequest(request: any) { + declare protected request: KubeJsonApi; + + public setRequest(request: KubeJsonApi) { this.request = request; } } @@ -18,7 +20,7 @@ describe("DeploymentApi", () => { patch: () => ({}), } as unknown as KubeJsonApi; - const sub = new DeploymentApiTest({ objectConstructor: Deployment }); + const sub = new DeploymentApiTest(); sub.setRequest(requestMock); diff --git a/src/common/k8s-api/__tests__/endpoint.api.test.ts b/src/common/k8s-api/__tests__/endpoint.api.test.ts index 58105b0387..666c5df63a 100644 --- a/src/common/k8s-api/__tests__/endpoint.api.test.ts +++ b/src/common/k8s-api/__tests__/endpoint.api.test.ts @@ -3,42 +3,26 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { EndpointSubset } from "../endpoints"; +import { formatEndpointSubset } from "../endpoints"; describe("endpoint tests", () => { describe("EndpointSubset", () => { - it.each([ - 4, - false, - null, - {}, - [], - "ahe", - /a/, - ])("should always initialize fields when given %j", (data: any) => { - const sub = new EndpointSubset(data); - - expect(sub.addresses).toStrictEqual([]); - expect(sub.notReadyAddresses).toStrictEqual([]); - expect(sub.ports).toStrictEqual([]); - }); - - it("toString should be addresses X ports", () => { - const sub = new EndpointSubset({ + it("formatEndpointSubset should be addresses X ports", () => { + const formatted = formatEndpointSubset({ addresses: [{ ip: "1.1.1.1", }, { ip: "1.1.1.2", - }] as any, + }], notReadyAddresses: [], ports: [{ - port: "81", + port: 81, }, { - port: "82", - }] as any, + port: 82, + }], }); - expect(sub.toString()).toBe("1.1.1.1:81, 1.1.1.1:82, 1.1.1.2:81, 1.1.1.2:82"); + expect(formatted).toBe("1.1.1.1:81, 1.1.1.1:82, 1.1.1.2:81, 1.1.1.2:82"); }); }); }); diff --git a/src/common/k8s-api/__tests__/helm-charts.api.test.ts b/src/common/k8s-api/__tests__/helm-charts.api.test.ts index 283c5168f5..e7a4cb9439 100644 --- a/src/common/k8s-api/__tests__/helm-charts.api.test.ts +++ b/src/common/k8s-api/__tests__/helm-charts.api.test.ts @@ -9,33 +9,33 @@ import { HelmChart } from "../endpoints/helm-charts.api"; describe("HelmChart tests", () => { describe("HelmChart.create() tests", () => { it("should throw on non-object input", () => { - expect(() => HelmChart.create("" as any)).toThrowError('"value" must be of type object'); - expect(() => HelmChart.create(1 as any)).toThrowError('"value" must be of type object'); - expect(() => HelmChart.create(false as any)).toThrowError('"value" must be of type object'); - expect(() => HelmChart.create([] as any)).toThrowError('"value" must be of type object'); - expect(() => HelmChart.create(Symbol() as any)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create("" as never)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create(1 as never)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create(false as never)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create([] as never)).toThrowError('"value" must be of type object'); + expect(() => HelmChart.create(Symbol() as never)).toThrowError('"value" must be of type object'); }); it("should throw on missing fields", () => { - expect(() => HelmChart.create({} as any)).toThrowError('"apiVersion" is required'); + expect(() => HelmChart.create({} as never)).toThrowError('"apiVersion" is required'); expect(() => HelmChart.create({ apiVersion: "!", - } as any)).toThrowError('"name" is required'); + } as never)).toThrowError('"name" is required'); expect(() => HelmChart.create({ apiVersion: "!", name: "!", - } as any)).toThrowError('"version" is required'); + } as never)).toThrowError('"version" is required'); expect(() => HelmChart.create({ apiVersion: "!", name: "!", version: "!", - } as any)).toThrowError('"repo" is required'); + } as never)).toThrowError('"repo" is required'); expect(() => HelmChart.create({ apiVersion: "!", name: "!", version: "!", repo: "!", - } as any)).toThrowError('"created" is required'); + } as never)).toThrowError('"created" is required'); }); it("should throw on fields being wrong type", () => { @@ -46,7 +46,7 @@ describe("HelmChart tests", () => { repo: "!", created: "!", digest: "!", - } as any)).toThrowError('"apiVersion" must be a string'); + } as never)).toThrowError('"apiVersion" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: 1, @@ -54,7 +54,7 @@ describe("HelmChart tests", () => { repo: "!", created: "!", digest: "!", - } as any)).toThrowError('"name" must be a string'); + } as never)).toThrowError('"name" must be a string'); expect(() => HelmChart.create({ apiVersion: "!", name: "!", @@ -62,7 +62,7 @@ describe("HelmChart tests", () => { repo: "!", created: "!", digest: 1, - } as any)).toThrowError('"digest" must be a string'); + } as never)).toThrowError('"digest" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "", @@ -70,7 +70,7 @@ describe("HelmChart tests", () => { repo: "!", created: "!", digest: "!", - } as any)).toThrowError('"version" must be a string'); + } as never)).toThrowError('"version" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -78,7 +78,7 @@ describe("HelmChart tests", () => { repo: 1, created: "!", digest: "!", - } as any)).toThrowError('"repo" must be a string'); + } as never)).toThrowError('"repo" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -86,7 +86,7 @@ describe("HelmChart tests", () => { repo: "1", created: 1, digest: "a", - } as any)).toThrowError('"created" must be a string'); + } as never)).toThrowError('"created" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -94,7 +94,7 @@ describe("HelmChart tests", () => { repo: "1", created: "!", digest: 1, - } as any)).toThrowError('"digest" must be a string'); + } as never)).toThrowError('"digest" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -103,7 +103,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", kubeVersion: 1, - } as any)).toThrowError('"kubeVersion" must be a string'); + } as never)).toThrowError('"kubeVersion" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -112,7 +112,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", description: 1, - } as any)).toThrowError('"description" must be a string'); + } as never)).toThrowError('"description" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -121,7 +121,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", home: 1, - } as any)).toThrowError('"home" must be a string'); + } as never)).toThrowError('"home" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -130,7 +130,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", engine: 1, - } as any)).toThrowError('"engine" must be a string'); + } as never)).toThrowError('"engine" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -139,7 +139,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", icon: 1, - } as any)).toThrowError('"icon" must be a string'); + } as never)).toThrowError('"icon" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -148,7 +148,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", appVersion: 1, - } as any)).toThrowError('"appVersion" must be a string'); + } as never)).toThrowError('"appVersion" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -157,7 +157,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", tillerVersion: 1, - } as any)).toThrowError('"tillerVersion" must be a string'); + } as never)).toThrowError('"tillerVersion" must be a string'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -166,7 +166,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", deprecated: 1, - } as any)).toThrowError('"deprecated" must be a boolean'); + } as never)).toThrowError('"deprecated" must be a boolean'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -175,7 +175,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", keywords: 1, - } as any)).toThrowError('"keywords" must be an array'); + } as never)).toThrowError('"keywords" must be an array'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -184,7 +184,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", sources: 1, - } as any)).toThrowError('"sources" must be an array'); + } as never)).toThrowError('"sources" must be an array'); expect(() => HelmChart.create({ apiVersion: "1", name: "1", @@ -193,7 +193,7 @@ describe("HelmChart tests", () => { digest: "1", created: "!", maintainers: 1, - } as any)).toThrowError('"maintainers" must be an array'); + } as never)).toThrowError('"maintainers" must be an array'); }); it("should filter non-string keywords", () => { @@ -204,10 +204,10 @@ describe("HelmChart tests", () => { repo: "1", digest: "1", created: "!", - keywords: [1, "a", false, {}, "b"] as any, + keywords: [1, "a", false, {}, "b"] as never, }); - expect(chart.keywords).toStrictEqual(["a", "b"]); + expect(chart?.keywords).toStrictEqual(["a", "b"]); }); it("should filter non-string sources", () => { @@ -218,10 +218,10 @@ describe("HelmChart tests", () => { repo: "1", digest: "1", created: "!", - sources: [1, "a", false, {}, "b"] as any, + sources: [1, "a", false, {}, "b"] as never, }); - expect(chart.sources).toStrictEqual(["a", "b"]); + expect(chart?.sources).toStrictEqual(["a", "b"]); }); it("should filter invalid maintainers", () => { @@ -236,10 +236,10 @@ describe("HelmChart tests", () => { name: "a", email: "b", url: "c", - }] as any, + }] as never, }); - expect(chart.maintainers).toStrictEqual([{ + expect(chart?.maintainers).toStrictEqual([{ name: "a", email: "b", url: "c", @@ -261,9 +261,9 @@ describe("HelmChart tests", () => { name: "a", email: "b", url: "c", - }] as any, + }] as never, "asdjhajksdhadjks": 1, - } as any); + } as never); expect(warnFn).toHaveBeenCalledWith("HelmChart data has unexpected fields", { original: anyObject(), diff --git a/src/common/k8s-api/__tests__/ingress.api.ts b/src/common/k8s-api/__tests__/ingress.api.ts index 3b84c37038..d5e4d323ad 100644 --- a/src/common/k8s-api/__tests__/ingress.api.ts +++ b/src/common/k8s-api/__tests__/ingress.api.ts @@ -14,6 +14,8 @@ describe("computeRuleDeclarations", () => { name: "foo", resourceVersion: "1", uid: "bar", + namespace: "default", + selfLink: "/apis/networking.k8s.io/v1/ingresses/default/foo", }, }); @@ -21,6 +23,7 @@ describe("computeRuleDeclarations", () => { host: "foo.bar", http: { paths: [{ + pathType: "Exact", backend: { service: { name: "my-service", @@ -44,6 +47,8 @@ describe("computeRuleDeclarations", () => { name: "foo", resourceVersion: "1", uid: "bar", + namespace: "default", + selfLink: "/apis/networking.k8s.io/v1/ingresses/default/foo", }, }); @@ -55,6 +60,7 @@ describe("computeRuleDeclarations", () => { host: "foo.bar", http: { paths: [{ + pathType: "Exact", backend: { service: { name: "my-service", @@ -78,6 +84,8 @@ describe("computeRuleDeclarations", () => { name: "foo", resourceVersion: "1", uid: "bar", + namespace: "default", + selfLink: "/apis/networking.k8s.io/v1/ingresses/default/foo", }, }); @@ -91,6 +99,7 @@ describe("computeRuleDeclarations", () => { host: "foo.bar", http: { paths: [{ + pathType: "Exact", backend: { service: { name: "my-service", diff --git a/src/common/k8s-api/__tests__/kube-api-parse.test.ts b/src/common/k8s-api/__tests__/kube-api-parse.test.ts index 8801837256..547f78adef 100644 --- a/src/common/k8s-api/__tests__/kube-api-parse.test.ts +++ b/src/common/k8s-api/__tests__/kube-api-parse.test.ts @@ -19,7 +19,7 @@ import { parseKubeApi } from "../kube-api-parse"; /** * [, ] */ -type KubeApiParseTestData = [string, Required]; +type KubeApiParseTestData = [string, IKubeApiParsed]; const tests: KubeApiParseTestData[] = [ ["/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/prometheuses.monitoring.coreos.com", { @@ -126,6 +126,6 @@ describe("parseApi unit tests", () => { }); it.each(throwtests)("testing %j should throw", (url) => { - expect(() => parseKubeApi(url)).toThrowError("invalid apiPath"); + expect(() => parseKubeApi(url as never)).toThrowError("invalid apiPath"); }); }); diff --git a/src/common/k8s-api/__tests__/kube-api.test.ts b/src/common/k8s-api/__tests__/kube-api.test.ts index b5b4094bf7..a8cafcaeda 100644 --- a/src/common/k8s-api/__tests__/kube-api.test.ts +++ b/src/common/k8s-api/__tests__/kube-api.test.ts @@ -3,7 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { Request } from "node-fetch"; import { forRemoteCluster, KubeApi } from "../kube-api"; import { KubeJsonApi } from "../kube-json-api"; import { KubeObject } from "../kube-object"; @@ -12,11 +11,12 @@ import { delay } from "../../utils/delay"; import { PassThrough } from "stream"; import type { ApiManager } from "../api-manager"; import { apiManager } from "../api-manager"; -import { Ingress, Pod } from "../endpoints"; +import type { FetchMock } from "jest-fetch-mock/types"; jest.mock("../api-manager"); const mockApiManager = apiManager as jest.Mocked; +const mockFetch = fetch as FetchMock; class TestKubeObject extends KubeObject { static kind = "Pod"; @@ -67,7 +67,7 @@ describe("forRemoteCluster", () => { }, }, TestKubeObject); - (fetch as any).mockResponse(async (request: any) => { + mockFetch.mockResponse(async (request: any) => { expect(request.url).toEqual("https://127.0.0.1:6443/api/v1/pods"); return { @@ -92,13 +92,13 @@ describe("KubeApi", () => { }); it("uses url from apiBase if apiBase contains the resource", async () => { - (fetch as any).mockResponse(async (request: any) => { + mockFetch.mockResponse(async (request: any) => { if (request.url === "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1") { return { body: JSON.stringify({ resources: [{ name: "ingresses", - }] as any[], + }], }), }; } else if (request.url === "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1") { @@ -107,13 +107,13 @@ describe("KubeApi", () => { body: JSON.stringify({ resources: [{ name: "ingresses", - }] as any[], + }], }), }; } else { return { body: JSON.stringify({ - resources: [] as any[], + resources: [], }), }; } @@ -138,11 +138,11 @@ describe("KubeApi", () => { }); it("uses url from fallbackApiBases if apiBase lacks the resource", async () => { - (fetch as any).mockResponse(async (request: any) => { + mockFetch.mockResponse(async (request: any) => { if (request.url === "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1") { return { body: JSON.stringify({ - resources: [] as any[], + resources: [], }), }; } else if (request.url === "http://127.0.0.1:9999/api-kube/apis/extensions/v1beta1") { @@ -150,13 +150,13 @@ describe("KubeApi", () => { body: JSON.stringify({ resources: [{ name: "ingresses", - }] as any[], + }], }), }; } else { return { body: JSON.stringify({ - resources: [] as any[], + resources: [], }), }; } @@ -184,7 +184,7 @@ describe("KubeApi", () => { expect.hasAssertions(); const api = new TestKubeApi({ - objectConstructor: Ingress, + objectConstructor: TestKubeObject, checkPreferredVersion: true, fallbackApiBases: ["/apis/extensions/v1beta1/ingresses"], request: { @@ -214,7 +214,7 @@ describe("KubeApi", () => { }, }; }), - } as any, + } as Partial as KubeJsonApi, }); await api.checkPreferredVersion(); @@ -227,7 +227,7 @@ describe("KubeApi", () => { expect.hasAssertions(); const api = new TestKubeApi({ - objectConstructor: Pod, + objectConstructor: TestKubeObject, checkPreferredVersion: true, fallbackApiBases: ["/api/v1beta1/pods"], request: { @@ -257,7 +257,7 @@ describe("KubeApi", () => { }, }; }), - } as any, + } as Partial as KubeJsonApi, }); await api.checkPreferredVersion(); @@ -280,10 +280,10 @@ describe("KubeApi", () => { it("sends strategic patch by default", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("PATCH"); expect(request.headers.get("content-type")).toMatch("strategic-merge-patch"); - expect(request.body.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); + expect(request.body?.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); return {}; }); @@ -296,10 +296,10 @@ describe("KubeApi", () => { it("allows to use merge patch", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("PATCH"); expect(request.headers.get("content-type")).toMatch("merge-patch"); - expect(request.body.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); + expect(request.body?.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); return {}; }); @@ -312,10 +312,10 @@ describe("KubeApi", () => { it("allows to use json patch", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("PATCH"); expect(request.headers.get("content-type")).toMatch("json-patch"); - expect(request.body.toString()).toEqual(JSON.stringify([{ op: "replace", path: "/spec/replicas", value: 2 }])); + expect(request.body?.toString()).toEqual(JSON.stringify([{ op: "replace", path: "/spec/replicas", value: 2 }])); return {}; }); @@ -328,10 +328,10 @@ describe("KubeApi", () => { it("allows deep partial patch", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("PATCH"); expect(request.headers.get("content-type")).toMatch("merge-patch"); - expect(request.body.toString()).toEqual(JSON.stringify({ metadata: { annotations: { provisioned: "true" }}})); + expect(request.body?.toString()).toEqual(JSON.stringify({ metadata: { annotations: { provisioned: "true" }}})); return {}; }); @@ -356,7 +356,7 @@ describe("KubeApi", () => { it("sends correct request with empty namespace", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("DELETE"); expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/pods/foo?propagationPolicy=Background"); @@ -368,7 +368,7 @@ describe("KubeApi", () => { it("sends correct request without namespace", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("DELETE"); expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background"); @@ -380,7 +380,7 @@ describe("KubeApi", () => { it("sends correct request with namespace", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("DELETE"); expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods/foo?propagationPolicy=Background"); @@ -392,7 +392,7 @@ describe("KubeApi", () => { it("allows to change propagationPolicy", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("DELETE"); expect(request.url).toMatch("propagationPolicy=Orphan"); @@ -423,9 +423,10 @@ describe("KubeApi", () => { it("sends a valid watch request", () => { const spy = jest.spyOn(request, "getResponse"); - (fetch as any).mockResponse(async () => { + mockFetch.mockResponse(async () => { return { - body: stream, + // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 + body: stream as unknown as string, }; }); @@ -436,9 +437,10 @@ describe("KubeApi", () => { it("sends timeout as a query parameter", async () => { const spy = jest.spyOn(request, "getResponse"); - (fetch as any).mockResponse(async () => { + mockFetch.mockResponse(async () => { return { - body: stream, + // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 + body: stream as unknown as string, }; }); @@ -449,13 +451,14 @@ describe("KubeApi", () => { it("aborts watch using abortController", async (done) => { const spy = jest.spyOn(request, "getResponse"); - (fetch as any).mockResponse(async (request: Request) => { - (request as any).signal.addEventListener("abort", () => { + mockFetch.mockResponse(async request => { + request.signal.addEventListener("abort", () => { done(); }); return { - body: stream, + // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 + body: stream as unknown as string, }; }); @@ -478,9 +481,9 @@ describe("KubeApi", () => { it("if request ended", (done) => { const spy = jest.spyOn(request, "getResponse"); - jest.spyOn(stream, "on").mockImplementation((eventName: string, callback: Function) => { + jest.spyOn(stream, "on").mockImplementation((event: string | symbol, callback: Function) => { // End the request in 100ms. - if (eventName === "end") { + if (event === "end") { setTimeout(() => { callback(); }, 100); @@ -493,8 +496,8 @@ describe("KubeApi", () => { jest.spyOn(global, "fetch").mockImplementation(async () => { return { ok: true, - body: stream, - } as any; + body: stream as never, + } as Partial as Response; }); api.watch({ @@ -512,9 +515,10 @@ describe("KubeApi", () => { it("if request not closed after timeout", (done) => { const spy = jest.spyOn(request, "getResponse"); - (fetch as any).mockResponse(async () => { + mockFetch.mockResponse(async () => { return { - body: stream, + // needed for https://github.com/jefflau/jest-fetch-mock/issues/218 + body: stream as unknown as string, }; }); @@ -536,9 +540,9 @@ describe("KubeApi", () => { it("retries only once if request ends and timeout is set", (done) => { const spy = jest.spyOn(request, "getResponse"); - jest.spyOn(stream, "on").mockImplementation((eventName: string, callback: Function) => { + jest.spyOn(stream, "on").mockImplementation((event: string | symbol, callback: Function) => { // End the request in 100ms. - if (eventName === "end") { + if (event === "end") { setTimeout(() => { callback(); }, 100); @@ -551,8 +555,8 @@ describe("KubeApi", () => { jest.spyOn(global, "fetch").mockImplementation(async () => { return { ok: true, - body: stream, - } as any; + body: stream as never, + } as Partial as Response; }); const timeoutSeconds = 0.5; @@ -589,9 +593,9 @@ describe("KubeApi", () => { it("should add kind and apiVersion", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("POST"); - expect(JSON.parse(request.body.toString())).toEqual({ + expect(JSON.parse(String(request.body))).toEqual({ kind: "Pod", apiVersion: "v1", metadata: { @@ -643,9 +647,9 @@ describe("KubeApi", () => { it("doesn't override metadata.labels", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("POST"); - expect(JSON.parse(request.body.toString())).toEqual({ + expect(JSON.parse(String(request.body))).toEqual({ kind: "Pod", apiVersion: "v1", metadata: { @@ -686,9 +690,9 @@ describe("KubeApi", () => { it("doesn't override metadata.labels", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + mockFetch.mockResponse(async request => { expect(request.method).toEqual("PUT"); - expect(JSON.parse(request.body.toString())).toEqual({ + expect(JSON.parse(String(request.body))).toEqual({ metadata: { name: "foobar", namespace: "default", diff --git a/src/common/k8s-api/__tests__/nodes.test.ts b/src/common/k8s-api/__tests__/nodes.test.ts index 4b109a8f96..e2527cd9c6 100644 --- a/src/common/k8s-api/__tests__/nodes.test.ts +++ b/src/common/k8s-api/__tests__/nodes.test.ts @@ -18,6 +18,7 @@ describe("Nodes tests", () => { name: "bar", resourceVersion: "1", uid: "bat", + selfLink: "/api/v1/nodes/bar", }, }); @@ -33,6 +34,7 @@ describe("Nodes tests", () => { resourceVersion: "1", uid: "bat", labels: {}, + selfLink: "/api/v1/nodes/bar", }, }); @@ -51,6 +53,7 @@ describe("Nodes tests", () => { "node-role.kubernetes.io/foobar": "bat", "hellonode-role.kubernetes.io/foobar1": "bat", }, + selfLink: "/api/v1/nodes/bar", }, }); @@ -69,6 +72,7 @@ describe("Nodes tests", () => { "node-role.kubernetes.io/foobar": "bat", "hellonode-role.kubernetes.io//////foobar1": "bat", }, + selfLink: "/api/v1/nodes/bar", }, }); @@ -86,6 +90,7 @@ describe("Nodes tests", () => { labels: { "kubernetes.io/role": "master", }, + selfLink: "/api/v1/nodes/bar", }, }); @@ -103,6 +108,7 @@ describe("Nodes tests", () => { labels: { "node.kubernetes.io/role": "master", }, + selfLink: "/api/v1/nodes/bar", }, }); @@ -122,6 +128,7 @@ describe("Nodes tests", () => { "kubernetes.io/role": "master", "node.kubernetes.io/role": "master-v2-max", }, + selfLink: "/api/v1/nodes/bar", }, }); diff --git a/src/common/k8s-api/__tests__/pods.api.test.ts b/src/common/k8s-api/__tests__/pods.api.test.ts index 16b57b1b48..9b01dff73b 100644 --- a/src/common/k8s-api/__tests__/pods.api.test.ts +++ b/src/common/k8s-api/__tests__/pods.api.test.ts @@ -14,6 +14,8 @@ describe("Pod tests", () => { name: "foobar", resourceVersion: "foobar", uid: "foobar", + namespace: "default", + selfLink: "/api/v1/pods/default/foobar", }, }); diff --git a/src/common/k8s-api/__tests__/pods.test.ts b/src/common/k8s-api/__tests__/pods.test.ts index a7faee1e45..f90cb62a24 100644 --- a/src/common/k8s-api/__tests__/pods.test.ts +++ b/src/common/k8s-api/__tests__/pods.test.ts @@ -3,6 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import assert from "assert"; import { Pod } from "../endpoints"; interface GetDummyPodOptions { @@ -12,16 +13,15 @@ interface GetDummyPodOptions { initDead?: number; } -function getDummyPodDefaultOptions(): Required { - return { +function getDummyPod(rawOpts?: GetDummyPodOptions): Pod { + const opts = { + ...(rawOpts ?? {}), running: 0, dead: 0, initDead: 0, initRunning: 0, }; -} -function getDummyPod(opts: GetDummyPodOptions = getDummyPodDefaultOptions()): Pod { const pod = new Pod({ apiVersion: "v1", kind: "Pod", @@ -29,36 +29,35 @@ function getDummyPod(opts: GetDummyPodOptions = getDummyPodDefaultOptions()): Po uid: "1", name: "test", resourceVersion: "v1", - selfLink: "http", + namespace: "default", + selfLink: "/api/v1/pods/default/test", + }, + spec: { + containers: [], + initContainers: [], + serviceAccount: "dummy", + serviceAccountName: "dummy", + }, + status: { + phase: "Running", + conditions: [], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", + containerStatuses: [], + initContainerStatuses: [], }, }); - pod.spec = { - containers: [], - initContainers: [], - serviceAccount: "dummy", - serviceAccountName: "dummy", - }; - - pod.status = { - phase: "Running", - conditions: [], - hostIP: "10.0.0.1", - podIP: "10.0.0.1", - startTime: "now", - containerStatuses: [], - initContainerStatuses: [], - }; - for (let i = 0; i < opts.running; i += 1) { const name = `container_r_${i}`; - pod.spec.containers.push({ + pod.spec.containers?.push({ image: "dummy", imagePullPolicy: "dummy", name, }); - pod.status.containerStatuses.push({ + pod.status?.containerStatuses?.push({ image: "dummy", imageID: "dummy", name, @@ -75,12 +74,12 @@ function getDummyPod(opts: GetDummyPodOptions = getDummyPodDefaultOptions()): Po for (let i = 0; i < opts.dead; i += 1) { const name = `container_d_${i}`; - pod.spec.containers.push({ + pod.spec.containers?.push({ image: "dummy", imagePullPolicy: "dummy", name, }); - pod.status.containerStatuses.push({ + pod.status?.containerStatuses?.push({ image: "dummy", imageID: "dummy", name, @@ -100,12 +99,12 @@ function getDummyPod(opts: GetDummyPodOptions = getDummyPodDefaultOptions()): Po for (let i = 0; i < opts.initRunning; i += 1) { const name = `container_ir_${i}`; - pod.spec.initContainers.push({ + pod.spec.initContainers?.push({ image: "dummy", imagePullPolicy: "dummy", name, }); - pod.status.initContainerStatuses.push({ + pod.status?.initContainerStatuses?.push({ image: "dummy", imageID: "dummy", name, @@ -122,12 +121,12 @@ function getDummyPod(opts: GetDummyPodOptions = getDummyPodDefaultOptions()): Po for (let i = 0; i < opts.initDead; i += 1) { const name = `container_id_${i}`; - pod.spec.initContainers.push({ + pod.spec.initContainers?.push({ image: "dummy", imagePullPolicy: "dummy", name, }); - pod.status.initContainerStatuses.push({ + pod.status?.initContainerStatuses?.push({ image: "dummy", imageID: "dummy", name, @@ -253,7 +252,7 @@ describe("Pods", () => { it("should return true if a condition isn't ready", () => { const pod = getDummyPod({ running: 1 }); - pod.status.conditions.push({ + pod.status?.conditions.push({ type: "Ready", status: "foobar", lastProbeTime: 1, @@ -266,7 +265,7 @@ describe("Pods", () => { it("should return false if a condition is non-ready", () => { const pod = getDummyPod({ running: 1 }); - pod.status.conditions.push({ + pod.status?.conditions.push({ type: "dummy", status: "foobar", lastProbeTime: 1, @@ -278,8 +277,11 @@ describe("Pods", () => { it("should return true if a current container is in a crash loop back off", () => { const pod = getDummyPod({ running: 1 }); + const firstStatus = pod.status?.containerStatuses?.[0]; - pod.status.containerStatuses[0].state = { + assert(firstStatus); + + firstStatus.state = { waiting: { reason: "CrashLookBackOff", message: "too much foobar", @@ -292,6 +294,8 @@ describe("Pods", () => { it("should return true if a current phase isn't running", () => { const pod = getDummyPod({ running: 1 }); + assert(pod.status); + pod.status.phase = "not running"; expect(pod.hasIssues()).toStrictEqual(true); @@ -300,6 +304,8 @@ describe("Pods", () => { it("should return false if a current phase is running", () => { const pod = getDummyPod({ running: 1 }); + assert(pod.status); + pod.status.phase = "Running"; expect(pod.hasIssues()).toStrictEqual(false); diff --git a/src/common/k8s-api/__tests__/stateful-set.api.test.ts b/src/common/k8s-api/__tests__/stateful-set.api.test.ts index 624a852c9d..c1fe90d1f5 100644 --- a/src/common/k8s-api/__tests__/stateful-set.api.test.ts +++ b/src/common/k8s-api/__tests__/stateful-set.api.test.ts @@ -7,7 +7,8 @@ import { StatefulSet, StatefulSetApi } from "../endpoints/stateful-set.api"; import type { KubeJsonApi } from "../kube-json-api"; class StatefulSetApiTest extends StatefulSetApi { - public setRequest(request: any) { + declare protected request: KubeJsonApi; + public setRequest(request: KubeJsonApi) { this.request = request; } } diff --git a/src/common/k8s-api/api-kube.ts b/src/common/k8s-api/api-kube.ts index 5ff8478694..24369dce63 100644 --- a/src/common/k8s-api/api-kube.ts +++ b/src/common/k8s-api/api-kube.ts @@ -17,4 +17,4 @@ export const apiKube = isClusterPageContext() "Host": window.location.host, }, }) - : undefined; + : undefined as never; diff --git a/src/common/k8s-api/api-manager.ts b/src/common/k8s-api/api-manager.ts index b2d5021337..329fb00070 100644 --- a/src/common/k8s-api/api-manager.ts +++ b/src/common/k8s-api/api-manager.ts @@ -6,15 +6,24 @@ import type { KubeObjectStore } from "./kube-object.store"; import { action, observable, makeObservable } from "mobx"; -import { autoBind, iter } from "../utils"; +import { autoBind, isDefined, iter } from "../utils"; import type { KubeApi } from "./kube-api"; -import type { KubeObject } from "./kube-object"; -import type { IKubeObjectRef } from "./kube-api-parse"; +import type { KubeJsonApiDataFor, KubeObject, ObjectReference } from "./kube-object"; import { parseKubeApi, createKubeApiURL } from "./kube-api-parse"; +export type RegisterableStore = S extends KubeObjectStore + ? S + : never; +export type RegisterableApi = A extends KubeApi + ? A + : never; +export type KubeObjectStoreFrom = A extends KubeApi + ? KubeObjectStore + : never; + export class ApiManager { - private apis = observable.map>(); - private stores = observable.map>(); + private readonly apis = observable.map(); + private readonly stores = observable.map(); constructor() { makeObservable(this); @@ -33,27 +42,27 @@ export class ApiManager { return iter.find(this.apis.values(), api => api.kind === kind && api.apiVersionWithGroup === apiVersion); } - registerApi(apiBase: string, api: KubeApi) { + registerApi(apiBase: string, api: RegisterableApi) { if (!api.apiBase) return; if (!this.apis.has(apiBase)) { this.stores.forEach((store) => { - if (store.api === api) { + if (store.api as never === api) { this.stores.set(apiBase, store); } }); - this.apis.set(apiBase, api); + this.apis.set(apiBase, api as never); } } - protected resolveApi(api?: string | KubeApi): KubeApi | undefined { + protected resolveApi(api: undefined | string | KubeApi): KubeApi | undefined { if (!api) { return undefined; } if (typeof api === "string") { - return this.getApi(api) as KubeApi; + return this.getApi(api); } return api; @@ -69,20 +78,40 @@ export class ApiManager { } } + registerStore(store: RegisterableStore): void; + /** + * @deprecated KubeObjectStore's should only every be about a single KubeApi type + */ + registerStore(store: KubeObjectStore, KubeJsonApiDataFor>, apis: KubeApi[]): void; + @action - registerStore(store: KubeObjectStore, apis: KubeApi[] = [store.api]) { - apis.filter(Boolean).forEach(api => { - if (api.apiBase) this.stores.set(api.apiBase, store); - }); + registerStore(store: KubeObjectStore, KubeJsonApiDataFor>, apis: KubeApi[] = [store.api]): void { + for (const api of apis.filter(isDefined)) { + if (api.apiBase) { + this.stores.set(api.apiBase, store as never); + } + } } - getStore>(api: string | KubeApi): S | undefined { - return this.stores.get(this.resolveApi(api)?.apiBase) as S; + getStore(api: string | undefined): KubeObjectStore | undefined; + getStore(api: RegisterableApi): KubeObjectStoreFrom | undefined; + /** + * @deprecated use an actual cast instead of hiding it with this unused type param + */ + getStore(api: string | KubeApi): S | undefined ; + getStore(api: string | KubeApi | undefined): KubeObjectStore | undefined { + const { apiBase } = this.resolveApi(api) ?? {}; + + if (apiBase) { + return this.stores.get(apiBase); + } + + return undefined; } - lookupApiLink(ref: IKubeObjectRef, parentObject?: KubeObject): string { + lookupApiLink(ref: ObjectReference, parentObject?: KubeObject): string { const { - kind, apiVersion, name, + kind, apiVersion = "v1", name, namespace = parentObject?.getNs(), } = ref; diff --git a/src/common/k8s-api/endpoints/cluster-role-binding.api.ts b/src/common/k8s-api/endpoints/cluster-role-binding.api.ts index b6e6a517ae..487439cc20 100644 --- a/src/common/k8s-api/endpoints/cluster-role-binding.api.ts +++ b/src/common/k8s-api/endpoints/cluster-role-binding.api.ts @@ -3,34 +3,39 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; +import type { KubeObjectMetadata } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { RoleRef } from "./types/role-ref"; +import type { Subject } from "./types/subject"; -export type ClusterRoleBindingSubjectKind = "Group" | "ServiceAccount" | "User"; - -export interface ClusterRoleBindingSubject { - kind: ClusterRoleBindingSubjectKind; - name: string; - apiGroup?: string; - namespace?: string; +export interface ClusterRoleBindingData extends KubeJsonApiData, void, void> { + subjects?: Subject[]; + roleRef: RoleRef; } -export interface ClusterRoleBinding { - subjects?: ClusterRoleBindingSubject[]; - roleRef: { - kind: string; - name: string; - apiGroup?: string; - }; -} - -export class ClusterRoleBinding extends KubeObject { +export class ClusterRoleBinding extends KubeObject { static kind = "ClusterRoleBinding"; static namespaced = false; static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings"; + subjects?: Subject[]; + roleRef: RoleRef; + + constructor({ + subjects, + roleRef, + ...rest + }: ClusterRoleBindingData) { + super(rest); + this.subjects = subjects; + this.roleRef = roleRef; + } + getSubjects() { - return this.subjects || []; + return this.subjects ?? []; } getSubjectNames(): string { @@ -38,17 +43,15 @@ export class ClusterRoleBinding extends KubeObject { } } -/** - * Only available within kubernetes cluster pages - */ -let clusterRoleBindingApi: KubeApi; - -if (isClusterPageContext()) { - clusterRoleBindingApi = new KubeApi({ - objectConstructor: ClusterRoleBinding, - }); +export class ClusterRoleBindingApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: ClusterRoleBinding, + }); + } } -export { - clusterRoleBindingApi, -}; +export const clusterRoleBindingApi = isClusterPageContext() + ? new ClusterRoleBindingApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/cluster-role.api.ts b/src/common/k8s-api/endpoints/cluster-role.api.ts index 70a5ef05e8..03707bbd9f 100644 --- a/src/common/k8s-api/endpoints/cluster-role.api.ts +++ b/src/common/k8s-api/endpoints/cluster-role.api.ts @@ -4,39 +4,47 @@ */ import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; +import type { KubeJsonApiData } from "../kube-json-api"; +import type { KubeObjectMetadata } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { AggregationRule } from "./types/aggregation-rule"; +import type { PolicyRule } from "./types/policy-rule"; -export interface ClusterRole { - rules: { - verbs: string[]; - apiGroups: string[]; - resources: string[]; - resourceNames?: string[]; - }[]; +export interface ClusterRoleData extends KubeJsonApiData, void, void> { + rules?: PolicyRule[]; + aggregationRule?: AggregationRule; } -export class ClusterRole extends KubeObject { +export class ClusterRole extends KubeObject { static kind = "ClusterRole"; static namespaced = false; static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterroles"; + rules?: PolicyRule[]; + aggregationRule?: AggregationRule; + + constructor({ rules, aggregationRule, ...rest }: ClusterRoleData) { + super(rest); + this.rules = rules; + this.aggregationRule = aggregationRule; + } + getRules() { return this.rules || []; } } -/** - * Only available within kubernetes cluster pages - */ -let clusterRoleApi: KubeApi; - -if (isClusterPageContext()) { // initialize automatically only when within a cluster iframe/context - clusterRoleApi = new KubeApi({ - objectConstructor: ClusterRole, - }); +export class ClusterRoleApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: ClusterRole, + }); + } } -export { - clusterRoleApi, -}; +export const clusterRoleApi = isClusterPageContext() + ? new ClusterRoleApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/cluster.api.ts b/src/common/k8s-api/endpoints/cluster.api.ts index 377fb47207..be7166533e 100644 --- a/src/common/k8s-api/endpoints/cluster.api.ts +++ b/src/common/k8s-api/endpoints/cluster.api.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { IMetrics, IMetricsReqParams } from "./metrics.api"; +import type { MetricData, IMetricsReqParams } from "./metrics.api"; import { metricsApi } from "./metrics.api"; import { KubeObject } from "../kube-object"; import { KubeApi } from "../kube-api"; @@ -14,7 +14,7 @@ export class ClusterApi extends KubeApi { static namespaced = true; } -export function getMetricsByNodeNames(nodeNames: string[], params?: IMetricsReqParams): Promise { +export function getMetricsByNodeNames(nodeNames: string[], params?: IMetricsReqParams): Promise { const nodes = nodeNames.join("|"); const opts = { category: "cluster", nodes }; @@ -45,20 +45,19 @@ export enum ClusterStatus { ERROR = "Error", } -export interface IClusterMetrics { - [metric: string]: T; - memoryUsage: T; - memoryRequests: T; - memoryLimits: T; - memoryCapacity: T; - cpuUsage: T; - cpuRequests: T; - cpuLimits: T; - cpuCapacity: T; - podUsage: T; - podCapacity: T; - fsSize: T; - fsUsage: T; +export interface ClusterMetricData extends Partial> { + memoryUsage: MetricData; + memoryRequests: MetricData; + memoryLimits: MetricData; + memoryCapacity: MetricData; + cpuUsage: MetricData; + cpuRequests: MetricData; + cpuLimits: MetricData; + cpuCapacity: MetricData; + podUsage: MetricData; + podCapacity: MetricData; + fsSize: MetricData; + fsUsage: MetricData; } export interface Cluster { diff --git a/src/common/k8s-api/endpoints/configmap.api.ts b/src/common/k8s-api/endpoints/configmap.api.ts index 82bbc47bf7..b732f7669d 100644 --- a/src/common/k8s-api/endpoints/configmap.api.ts +++ b/src/common/k8s-api/endpoints/configmap.api.ts @@ -3,28 +3,36 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { KubeObjectMetadata } from "../kube-object"; import { KubeObject } from "../kube-object"; import type { KubeJsonApiData } from "../kube-json-api"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { autoBind } from "../../utils"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -export interface ConfigMap { - data: { - [param: string]: string; - }; +export interface ConfigMapData extends KubeJsonApiData, void, void> { + data?: Partial>; + binaryData?: Partial>; + immutable?: boolean; } -export class ConfigMap extends KubeObject { +export class ConfigMap extends KubeObject { static kind = "ConfigMap"; static namespaced = true; static apiBase = "/api/v1/configmaps"; - constructor(data: KubeJsonApiData) { - super(data); + data: Partial>; + binaryData: Partial>; + immutable?: boolean; + + constructor({ data, binaryData, immutable, ...rest }: ConfigMapData) { + super(rest); autoBind(this); - this.data ??= {}; + this.data = data ?? {}; + this.binaryData = binaryData ?? {}; + this.immutable = immutable; } getKeys(): string[] { @@ -32,17 +40,15 @@ export class ConfigMap extends KubeObject { } } -/** - * Only available within kubernetes cluster pages - */ -let configMapApi: KubeApi; - -if (isClusterPageContext()) { - configMapApi = new KubeApi({ - objectConstructor: ConfigMap, - }); +export class ConfigMapApi extends KubeApi { + constructor(opts?: DerivedKubeApiOptions) { + super({ + objectConstructor: ConfigMap, + ...opts ?? {}, + }); + } } -export { - configMapApi, -}; +export const configMapApi = isClusterPageContext() + ? new ConfigMapApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/crd.api.ts b/src/common/k8s-api/endpoints/crd.api.ts index f668c819f3..7c8e898dce 100644 --- a/src/common/k8s-api/endpoints/crd.api.ts +++ b/src/common/k8s-api/endpoints/crd.api.ts @@ -3,13 +3,14 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { KubeCreationError, KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -import type { KubeJsonApiData } from "../kube-json-api"; import { getLegacyGlobalDiForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import customResourcesRouteInjectable from "../../front-end-routing/routes/cluster/custom-resources/custom-resources/custom-resources-route.injectable"; import { buildURL } from "../../utils/buildUrl"; +import type { BaseKubeObjectCondition } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; +import { KubeApi } from "../kube-api"; +import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; interface AdditionalPrinterColumnsCommon { name: string; @@ -26,7 +27,7 @@ type AdditionalPrinterColumnsV1Beta = AdditionalPrinterColumnsCommon & { JSONPath: string; }; -export interface CRDVersion { +export interface CustomResourceDefinitionVersion { name: string; served: boolean; storage: boolean; @@ -34,72 +35,78 @@ export interface CRDVersion { additionalPrinterColumns?: AdditionalPrinterColumnsV1[]; } +export interface CustomResourceDefinitionNames { + categories?: string[]; + kind: string; + listKind?: string; + plural: string; + shortNames?: string[]; + singular?: string; +} + +export interface CustomResourceConversion { + strategy?: string; + webhook?: WebhookConversion; +} + +export interface WebhookConversion { + clientConfig?: WebhookClientConfig[]; + conversionReviewVersions: string[]; +} + +export interface WebhookClientConfig { + caBundle?: string; + url?: string; + service?: ServiceReference; +} + +export interface ServiceReference { + name: string; + namespace: string; + path?: string; + port?: number; +} + export interface CustomResourceDefinitionSpec { group: string; /** * @deprecated for apiextensions.k8s.io/v1 but used in v1beta1 */ version?: string; - names: { - plural: string; - singular: string; - kind: string; - listKind: string; - }; + names: CustomResourceDefinitionNames; scope: "Namespaced" | "Cluster"; /** * @deprecated for apiextensions.k8s.io/v1 but used in v1beta1 */ validation?: object; - versions?: CRDVersion[]; - conversion: { - strategy?: string; - webhook?: any; - }; + versions?: CustomResourceDefinitionVersion[]; + conversion?: CustomResourceConversion; /** * @deprecated for apiextensions.k8s.io/v1 but used in v1beta1 */ additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[]; + preserveUnknownFields?: boolean; } -export interface CustomResourceDefinition { - spec: CustomResourceDefinitionSpec; - status: { - conditions: { - lastTransitionTime: string; - message: string; - reason: string; - status: string; - type?: string; - }[]; - acceptedNames: { - plural: string; - singular: string; - kind: string; - shortNames: string[]; - listKind: string; - }; - storedVersions: string[]; - }; +export interface CustomResourceDefinitionConditionAcceptedNames { + plural: string; + singular: string; + kind: string; + shortNames: string[]; + listKind: string; } -export interface CRDApiData extends KubeJsonApiData { - spec: object; // TODO: make better +export interface CustomResourceDefinitionStatus { + conditions?: BaseKubeObjectCondition[]; + acceptedNames: CustomResourceDefinitionConditionAcceptedNames; + storedVersions: string[]; } -export class CustomResourceDefinition extends KubeObject { +export class CustomResourceDefinition extends KubeObject { static kind = "CustomResourceDefinition"; static namespaced = false; static apiBase = "/apis/apiextensions.k8s.io/v1/customresourcedefinitions"; - constructor(data: CRDApiData) { - super(data); - - if (!data.spec || typeof data.spec !== "object") { - throw new KubeCreationError("Cannot create a CustomResourceDefinition from an object without spec", data); - } - } - getResourceUrl() { const di = getLegacyGlobalDiForExtensionApi(); @@ -141,12 +148,12 @@ export class CustomResourceDefinition extends KubeObject { return this.spec.scope; } - getPreferedVersion(): CRDVersion { + getPreferedVersion(): CustomResourceDefinitionVersion { const { apiVersion } = this; switch (apiVersion) { case "apiextensions.k8s.io/v1": - for (const version of this.spec.versions) { + for (const version of this.spec.versions ?? []) { if (version.storage) { return version; } @@ -158,7 +165,8 @@ export class CustomResourceDefinition extends KubeObject { const additionalPrinterColumns = apc?.map(({ JSONPath, ...apc }) => ({ ...apc, jsonPath: JSONPath })); return { - name: this.spec.version, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + name: this.spec.version!, served: true, storage: true, schema: this.spec.validation, @@ -179,7 +187,7 @@ export class CustomResourceDefinition extends KubeObject { } getStoredVersions() { - return this.status.storedVersions.join(", "); + return this.status?.storedVersions.join(", ") ?? ""; } getNames() { @@ -216,18 +224,16 @@ export class CustomResourceDefinition extends KubeObject { } } -/** - * Only available within kubernetes cluster pages - */ -let crdApi: KubeApi; - -if (isClusterPageContext()) { - crdApi = new KubeApi({ - objectConstructor: CustomResourceDefinition, - checkPreferredVersion: true, - }); +export class CustomResourceDefinitionApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + objectConstructor: CustomResourceDefinition, + checkPreferredVersion: true, + ...opts, + }); + } } -export { - crdApi, -}; +export const crdApi = isClusterPageContext() + ? new CustomResourceDefinitionApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/cron-job.api.ts b/src/common/k8s-api/endpoints/cron-job.api.ts index cf50780b0f..2097b3d098 100644 --- a/src/common/k8s-api/endpoints/cron-job.api.ts +++ b/src/common/k8s-api/endpoints/cron-job.api.ts @@ -4,15 +4,22 @@ */ import moment from "moment"; +import type { ObjectReference } from "../kube-object"; import { KubeObject } from "../kube-object"; -import type { IPodContainer } from "./pods.api"; import { formatDuration } from "../../utils/formatDuration"; -import { autoBind } from "../../utils"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { JobTemplateSpec } from "./types/job-template-spec"; export class CronJobApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: CronJob, + }); + } + suspend(params: { namespace: string; name: string }) { return this.request.patch(this.getUrl(params), { data: { @@ -44,61 +51,33 @@ export class CronJobApi extends KubeApi { } } -export interface CronJob { - spec: { - schedule: string; - concurrencyPolicy: string; - suspend: boolean; - jobTemplate: { - metadata: { - creationTimestamp?: string; - labels?: { - [key: string]: string; - }; - annotations?: { - [key: string]: string; - }; - }; - spec: { - template: { - metadata: { - creationTimestamp?: string; - }; - spec: { - containers: IPodContainer[]; - restartPolicy: string; - terminationGracePeriodSeconds: number; - dnsPolicy: string; - hostPID: boolean; - schedulerName: string; - }; - }; - }; - }; - successfulJobsHistoryLimit: number; - failedJobsHistoryLimit: number; - }; - status: { - lastScheduleTime?: string; - }; +export interface CronJobSpec { + concurrencyPolicy?: string; + failedJobsHistoryLimit?: number; + jobTemplate?: JobTemplateSpec; + schedule: string; + startingDeadlineSeconds?: number; + successfulJobsHistoryLimit?: number; + suspend?: boolean; } -export class CronJob extends KubeObject { - static kind = "CronJob"; - static namespaced = true; - static apiBase = "/apis/batch/v1beta1/cronjobs"; +export interface CronJobStatus { + lastScheduleTime?: string; + lastSuccessfulTime?: string; + active?: ObjectReference[]; +} - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } +export class CronJob extends KubeObject { + static readonly kind = "CronJob"; + static readonly namespaced = true; + static readonly apiBase = "/apis/batch/v1beta1/cronjobs"; getSuspendFlag() { - return this.spec.suspend.toString(); + return (this.spec.suspend ?? false).toString(); } getLastScheduleTime() { - if (!this.status.lastScheduleTime) return "-"; + if (!this.status?.lastScheduleTime) return "-"; const diff = moment().diff(this.status.lastScheduleTime); return formatDuration(diff, true); @@ -125,17 +104,6 @@ export class CronJob extends KubeObject { } } -/** - * Only available within kubernetes cluster pages - */ -let cronJobApi: CronJobApi; - -if (isClusterPageContext()) { - cronJobApi = new CronJobApi({ - objectConstructor: CronJob, - }); -} - -export { - cronJobApi, -}; +export const cronJobApi = isClusterPageContext() + ? new CronJobApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/daemon-set.api.ts b/src/common/k8s-api/endpoints/daemon-set.api.ts index ad12b44931..5928d79835 100644 --- a/src/common/k8s-api/endpoints/daemon-set.api.ts +++ b/src/common/k8s-api/endpoints/daemon-set.api.ts @@ -3,88 +3,92 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import get from "lodash/get"; -import type { IAffinity } from "../workload-kube-object"; -import { WorkloadKubeObject } from "../workload-kube-object"; -import { autoBind } from "../../utils"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { metricsApi } from "./metrics.api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import type { IPodContainer, IPodMetrics } from "./pods.api"; +import type { PodMetricData } from "./pods.api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -import type { LabelSelector } from "../kube-object"; +import type { KubeObjectStatus, LabelSelector } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { PodTemplateSpec } from "./types/pod-template-spec"; -export class DaemonSet extends WorkloadKubeObject { +export interface RollingUpdateDaemonSet { + maxUnavailable?: number | string; + maxSurge?: number | string; +} + +export interface DaemonSetUpdateStrategy { + type: string; + rollingUpdate: RollingUpdateDaemonSet; +} + +export interface DaemonSetSpec { + selector: LabelSelector; + template: PodTemplateSpec; + updateStrategy: DaemonSetUpdateStrategy; + minReadySeconds?: number; + revisionHistoryLimit?: number; +} + +export interface DaemonSetStatus extends KubeObjectStatus { + collisionCount?: number; + currentNumberScheduled: number; + desiredNumberScheduled: number; + numberAvailable?: number; + numberMisscheduled: number; + numberReady: number; + numberUnavailable?: number; + observedGeneration?: number; + updatedNumberScheduled?: number; +} + +export class DaemonSet extends KubeObject { static kind = "DaemonSet"; static namespaced = true; static apiBase = "/apis/apps/v1/daemonsets"; - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); + getSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.selector.matchLabels); } - declare spec: { - selector: LabelSelector; - template: { - metadata: { - creationTimestamp?: string; - labels: { - name: string; - }; - }; - spec: { - containers: IPodContainer[]; - initContainers?: IPodContainer[]; - restartPolicy: string; - terminationGracePeriodSeconds: number; - dnsPolicy: string; - hostPID: boolean; - affinity?: IAffinity; - nodeSelector?: { - [selector: string]: string; - }; - securityContext: {}; - schedulerName: string; - tolerations: { - key: string; - operator: string; - effect: string; - tolerationSeconds: number; - }[]; - }; - }; - updateStrategy: { - type: string; - rollingUpdate: { - maxUnavailable: number; - }; - }; - revisionHistoryLimit: number; - }; - declare status: { - currentNumberScheduled: number; - numberMisscheduled: number; - desiredNumberScheduled: number; - numberReady: number; - observedGeneration: number; - updatedNumberScheduled: number; - numberAvailable: number; - numberUnavailable: number; - }; + getNodeSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.template.spec?.nodeSelector); + } + + getTemplateLabels(): string[] { + return KubeObject.stringifyLabels(this.spec.template.metadata?.labels); + } + + getTolerations() { + return this.spec.template.spec?.tolerations ?? []; + } + + getAffinity() { + return this.spec.template.spec?.affinity; + } + + getAffinityNumber() { + return Object.keys(this.getAffinity() ?? {}).length; + } getImages() { - const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); - const initContainers: IPodContainer[] = get(this, "spec.template.spec.initContainers", []); + const containers = this.spec.template?.spec?.containers ?? []; + const initContainers = this.spec.template?.spec?.initContainers ?? []; return [...containers, ...initContainers].map(container => container.image); } } export class DaemonSetApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: DaemonSet, + }); + } } -export function getMetricsForDaemonSets(daemonsets: DaemonSet[], namespace: string, selector = ""): Promise { +export function getMetricsForDaemonSets(daemonsets: DaemonSet[], namespace: string, selector = ""): Promise { const podSelector = daemonsets.map(daemonset => `${daemonset.getName()}-[[:alnum:]]{5}`).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -101,17 +105,6 @@ export function getMetricsForDaemonSets(daemonsets: DaemonSet[], namespace: stri }); } -/** - * Only available within kubernetes cluster pages - */ -let daemonSetApi: DaemonSetApi; - -if (isClusterPageContext()) { - daemonSetApi = new DaemonSetApi({ - objectConstructor: DaemonSet, - }); -} - -export { - daemonSetApi, -}; +export const daemonSetApi = isClusterPageContext() + ? new DaemonSetApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/deployment.api.ts b/src/common/k8s-api/endpoints/deployment.api.ts index 324310fa74..dab49e587e 100644 --- a/src/common/k8s-api/endpoints/deployment.api.ts +++ b/src/common/k8s-api/endpoints/deployment.api.ts @@ -5,25 +5,35 @@ import moment from "moment"; -import type { IAffinity } from "../workload-kube-object"; -import { WorkloadKubeObject } from "../workload-kube-object"; -import { autoBind } from "../../utils"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { metricsApi } from "./metrics.api"; -import type { IPodMetrics } from "./pods.api"; -import type { KubeJsonApiData } from "../kube-json-api"; +import type { PodMetricData, PodSpec } from "./pods.api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -import type { LabelSelector } from "../kube-object"; +import type { KubeObjectStatus, LabelSelector } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import { hasTypedProperty, isNumber, isObject } from "../../utils"; export class DeploymentApi extends KubeApi { + constructor(opts?: DerivedKubeApiOptions) { + super({ + objectConstructor: Deployment, + ...opts ?? {}, + }); + } + protected getScaleApiUrl(params: { namespace: string; name: string }) { return `${this.getUrl(params)}/scale`; } - getReplicas(params: { namespace: string; name: string }): Promise { - return this.request - .get(this.getScaleApiUrl(params)) - .then(({ status }: any) => status?.replicas); + async getReplicas(params: { namespace: string; name: string }): Promise { + const { status } = await this.request.get(this.getScaleApiUrl(params)); + + if (isObject(status) && hasTypedProperty(status, "replicas", isNumber)) { + return status.replicas; + } + + return 0; } scale(params: { namespace: string; name: string }, replicas: number) { @@ -61,7 +71,7 @@ export class DeploymentApi extends KubeApi { } } -export function getMetricsForDeployments(deployments: Deployment[], namespace: string, selector = ""): Promise { +export function getMetricsForDeployments(deployments: Deployment[], namespace: string, selector = ""): Promise { const podSelector = deployments.map(deployment => `${deployment.getName()}-[[:alnum:]]{9,}-[[:alnum:]]{5}`).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -78,136 +88,66 @@ export function getMetricsForDeployments(deployments: Deployment[], namespace: s }); } -interface IContainerProbe { - httpGet?: { - path?: string; - port: number; - scheme: string; - host?: string; +export interface DeploymentSpec { + replicas: number; + selector: LabelSelector; + template: { + metadata: { + creationTimestamp?: string; + labels: Record; + annotations?: Record; + }; + spec: PodSpec; }; - exec?: { - command: string[]; + strategy: { + type: string; + rollingUpdate: { + maxUnavailable: number; + maxSurge: number; + }; }; - tcpSocket?: { - port: number; - }; - initialDelaySeconds?: number; - timeoutSeconds?: number; - periodSeconds?: number; - successThreshold?: number; - failureThreshold?: number; } -export class Deployment extends WorkloadKubeObject { +export interface DeploymentStatus extends KubeObjectStatus { + observedGeneration: number; + replicas: number; + updatedReplicas: number; + readyReplicas: number; + availableReplicas?: number; + unavailableReplicas?: number; +} + +export class Deployment extends KubeObject { static kind = "Deployment"; static namespaced = true; static apiBase = "/apis/apps/v1/deployments"; - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); + getSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.selector.matchLabels); } - declare spec: { - replicas: number; - selector: LabelSelector; - template: { - metadata: { - creationTimestamp?: string; - labels: { [app: string]: string }; - annotations?: { [app: string]: string }; - }; - spec: { - containers: { - name: string; - image: string; - args?: string[]; - ports?: { - name: string; - containerPort: number; - protocol: string; - }[]; - env?: { - name: string; - value: string; - }[]; - resources: { - limits?: { - cpu: string; - memory: string; - }; - requests: { - cpu: string; - memory: string; - }; - }; - volumeMounts?: { - name: string; - mountPath: string; - }[]; - livenessProbe?: IContainerProbe; - readinessProbe?: IContainerProbe; - startupProbe?: IContainerProbe; - terminationMessagePath: string; - terminationMessagePolicy: string; - imagePullPolicy: string; - }[]; - restartPolicy: string; - terminationGracePeriodSeconds: number; - dnsPolicy: string; - affinity?: IAffinity; - nodeSelector?: { - [selector: string]: string; - }; - serviceAccountName: string; - serviceAccount: string; - securityContext: {}; - schedulerName: string; - tolerations?: { - key: string; - operator: string; - effect: string; - tolerationSeconds: number; - }[]; - volumes?: { - name: string; - configMap: { - name: string; - defaultMode: number; - optional: boolean; - }; - }[]; - }; - }; - strategy: { - type: string; - rollingUpdate: { - maxUnavailable: number; - maxSurge: number; - }; - }; - }; - declare status: { - observedGeneration: number; - replicas: number; - updatedReplicas: number; - readyReplicas: number; - availableReplicas?: number; - unavailableReplicas?: number; - conditions: { - type: string; - status: string; - lastUpdateTime: string; - lastTransitionTime: string; - reason: string; - message: string; - }[]; - }; + getNodeSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.template.spec.nodeSelector); + } + + getTemplateLabels(): string[] { + return KubeObject.stringifyLabels(this.spec.template.metadata.labels); + } + + getTolerations() { + return this.spec.template.spec.tolerations ?? []; + } + + getAffinity() { + return this.spec.template.spec.affinity; + } + + getAffinityNumber() { + return Object.keys(this.getAffinity() ?? {}).length; + } getConditions(activeOnly = false) { - const { conditions } = this.status; - - if (!conditions) return []; + const { conditions = [] } = this.status ?? {}; if (activeOnly) { return conditions.filter(c => c.status === "True"); @@ -217,7 +157,9 @@ export class Deployment extends WorkloadKubeObject { } getConditionsText(activeOnly = true) { - return this.getConditions(activeOnly).map(({ type }) => type).join(" "); + return this.getConditions(activeOnly) + .map(({ type }) => type) + .join(" "); } getReplicas() { @@ -225,14 +167,6 @@ export class Deployment extends WorkloadKubeObject { } } -let deploymentApi: DeploymentApi; - -if (isClusterPageContext()) { - deploymentApi = new DeploymentApi({ - objectConstructor: Deployment, - }); -} - -export { - deploymentApi, -}; +export const deploymentApi = isClusterPageContext() + ? new DeploymentApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/endpoint.api.ts b/src/common/k8s-api/endpoints/endpoint.api.ts index 9e025eb158..799b894d66 100644 --- a/src/common/k8s-api/endpoints/endpoint.api.ts +++ b/src/common/k8s-api/endpoints/endpoint.api.ts @@ -4,144 +4,119 @@ */ import { autoBind } from "../../utils"; +import type { KubeObjectMetadata, ObjectReference } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { get } from "lodash"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -export interface IEndpointPort { +export function formatEndpointSubset(subset: EndpointSubset): string { + const { addresses, ports } = subset; + + if (!addresses || !ports) { + return ""; + } + + return addresses + .map(address => ( + ports + .map(port => `${address.ip}:${port.port}`) + .join(", ") + )) + .join(", "); + +} + +export interface ForZone { + name: string; +} + +export interface EndpointHints { + forZones?: ForZone[]; +} + +export interface EndpointConditions { + ready?: boolean; + serving?: boolean; + terminating?: boolean; +} + +export interface EndpointData { + addresses: string[]; + conditions?: EndpointConditions; + hints?: EndpointHints; + hostname?: string; + nodeName?: string; + targetRef?: ObjectReference; + zone?: string; +} + +export interface EndpointPort { + appProtocol?: string; name?: string; - protocol: string; + protocol?: string; port: number; } -export interface IEndpointAddress { - hostname: string; +export interface EndpointAddress { + hostname?: string; ip: string; - nodeName: string; + nodeName?: string; + targetRef?: ObjectReference; } -export interface IEndpointSubset { - addresses: IEndpointAddress[]; - notReadyAddresses: IEndpointAddress[]; - ports: IEndpointPort[]; +export interface EndpointSubset { + addresses?: EndpointAddress[]; + notReadyAddresses?: EndpointAddress[]; + ports?: EndpointPort[]; } -interface ITargetRef { - kind: string; - namespace: string; - name: string; - uid: string; - resourceVersion: string; - apiVersion: string; +export interface EndpointsData extends KubeJsonApiData, void, void> { + subsets?: EndpointSubset[]; } -export class EndpointAddress implements IEndpointAddress { - hostname: string; - ip: string; - nodeName: string; - targetRef?: { - kind: string; - namespace: string; - name: string; - uid: string; - resourceVersion: string; - }; - - static create(data: IEndpointAddress): EndpointAddress { - return new EndpointAddress(data); - } - - constructor(data: IEndpointAddress) { - Object.assign(this, data); - } - - getId() { - return this.ip; - } - - getName() { - return this.hostname; - } - - getTargetRef(): ITargetRef { - if (this.targetRef) { - return Object.assign(this.targetRef, { apiVersion: "v1" }); - } else { - return null; - } - } -} - -export class EndpointSubset implements IEndpointSubset { - addresses: IEndpointAddress[]; - notReadyAddresses: IEndpointAddress[]; - ports: IEndpointPort[]; - - constructor(data: IEndpointSubset) { - this.addresses = get(data, "addresses", []); - this.notReadyAddresses = get(data, "notReadyAddresses", []); - this.ports = get(data, "ports", []); - } - - getAddresses(): EndpointAddress[] { - return this.addresses.map(EndpointAddress.create); - } - - getNotReadyAddresses(): EndpointAddress[] { - return this.notReadyAddresses.map(EndpointAddress.create); - } - - toString(): string { - return this.addresses - .map(address => ( - this.ports - .map(port => `${address.ip}:${port.port}`) - .join(", ") - )) - .join(", "); - } -} - -export interface Endpoint { - subsets: IEndpointSubset[]; -} - -export class Endpoint extends KubeObject { +export class Endpoints extends KubeObject { static kind = "Endpoints"; static namespaced = true; static apiBase = "/api/v1/endpoints"; - constructor(data: KubeJsonApiData) { - super(data); + subsets?: EndpointSubset[]; + + constructor({ subsets, ...rest }: EndpointsData) { + super(rest); autoBind(this); + this.subsets = subsets; } - getEndpointSubsets(): EndpointSubset[] { - const subsets = this.subsets || []; - - return subsets.map(s => new EndpointSubset(s)); + getEndpointSubsets(): Required[] { + return this.subsets?.map(({ + addresses = [], + notReadyAddresses = [], + ports = [], + }) => ({ + addresses, + notReadyAddresses, + ports, + })) ?? []; } toString(): string { - if(this.subsets) { - return this.getEndpointSubsets().map(es => es.toString()).join(", "); - } else { - return ""; - } + return this.getEndpointSubsets() + .map(formatEndpointSubset) + .join(", ") || ""; } - } -let endpointApi: KubeApi; - -if (isClusterPageContext()) { - endpointApi = new KubeApi({ - objectConstructor: Endpoint, - }); +export class EndpointsApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + objectConstructor: Endpoints, + ...opts, + }); + } } -export { - endpointApi, -}; +export const endpointsApi = isClusterPageContext() + ? new EndpointsApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/events.api.ts b/src/common/k8s-api/endpoints/events.api.ts index f2f73ce383..56e6036066 100644 --- a/src/common/k8s-api/endpoints/events.api.ts +++ b/src/common/k8s-api/endpoints/events.api.ts @@ -4,34 +4,39 @@ */ import moment from "moment"; +import type { KubeObjectMetadata, ObjectReference } from "../kube-object"; import { KubeObject } from "../kube-object"; import { formatDuration } from "../../utils/formatDuration"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { KubeJsonApiData } from "../kube-json-api"; -export interface KubeEvent { - involvedObject: { - kind: string; - namespace: string; - name: string; - uid: string; - apiVersion: string; - resourceVersion: string; - fieldPath: string; - }; - reason: string; - message: string; - source: { - component: string; - host: string; - }; - firstTimestamp: string; - lastTimestamp: string; - count: number; - type: "Normal" | "Warning" | string; - eventTime: null; - reportingComponent: string; - reportingInstance: string; +export interface EventSeries { + count?: number; + lastObservedTime?: string; +} + +export interface EventSource { + component?: string; + host?: string; +} + +export interface KubeEventData extends KubeJsonApiData { + action?: string; + count?: number; + eventTime?: string; + firstTimestamp?: string; + involvedObject: Required; + lastTimestamp?: string; + message?: string; + reason?: string; + related?: ObjectReference; + reportingComponent?: string; + reportingInstance?: string; + series?: EventSeries; + source?: EventSource; + type?: string; } export class KubeEvent extends KubeObject { @@ -39,14 +44,73 @@ export class KubeEvent extends KubeObject { static namespaced = true; static apiBase = "/api/v1/events"; + action?: string; + count?: number; + eventTime?: string; + firstTimestamp?: string; + involvedObject: Required; + lastTimestamp?: string; + message?: string; + reason?: string; + related?: ObjectReference; + reportingComponent?: string; + reportingInstance?: string; + series?: EventSeries; + source?: EventSource; + + /** + * Current supported values are: + * - "Normal" + * - "Warning" + */ + type?: string; + + constructor({ + action, + count, + eventTime, + firstTimestamp, + involvedObject, + lastTimestamp, + message, + reason, + related, + reportingComponent, + reportingInstance, + series, + source, + type, + ...rest + }: KubeEventData) { + super(rest); + this.action = action; + this.count = count; + this.eventTime = eventTime; + this.firstTimestamp = firstTimestamp; + this.involvedObject = involvedObject; + this.lastTimestamp = lastTimestamp; + this.message = message; + this.reason = reason; + this.related = related; + this.reportingComponent = reportingComponent; + this.reportingInstance = reportingInstance; + this.series = series; + this.source = source; + this.type = type; + } + isWarning() { return this.type === "Warning"; } getSource() { - const { component, host } = this.source; + if (!this.source?.component) { + return ""; + } - return `${component} ${host || ""}`; + const { component, host = "" } = this.source; + + return `${component} ${host}`; } /** @@ -68,14 +132,15 @@ export class KubeEvent extends KubeObject { } } -let eventApi: KubeApi; - -if (isClusterPageContext()) { - eventApi = new KubeApi({ - objectConstructor: KubeEvent, - }); +export class KubeEventApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + objectConstructor: KubeEvent, + ...opts, + }); + } } -export { - eventApi, -}; +export const eventApi = isClusterPageContext() + ? new KubeEventApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/helm-charts.api.ts b/src/common/k8s-api/endpoints/helm-charts.api.ts index 163f90ef36..2690e95afb 100644 --- a/src/common/k8s-api/endpoints/helm-charts.api.ts +++ b/src/common/k8s-api/endpoints/helm-charts.api.ts @@ -7,7 +7,7 @@ import { compile } from "path-to-regexp"; import { apiBase } from "../index"; import { stringify } from "querystring"; import type { RequestInit } from "node-fetch"; -import { autoBind, bifurcateArray } from "../../utils"; +import { autoBind, bifurcateArray, isDefined } from "../../utils"; import Joi from "joi"; export type RepoHelmChartList = Record; @@ -30,9 +30,9 @@ export async function listCharts(): Promise { return Object .values(data) - .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), []) + .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), new Array()) .map(([chart]) => HelmChart.create(chart, { onError: "log" })) - .filter(Boolean); + .filter(isDefined); } export interface GetChartDetailsOptions { @@ -51,7 +51,7 @@ export async function getChartDetails(repo: string, name: string, { version, req const path = endpoint({ repo, name }); const { readme, ...data } = await apiBase.get(`${path}?${stringify({ version })}`, undefined, reqInit); - const versions = data.versions.map(version => HelmChart.create(version, { onError: "log" })).filter(Boolean); + const versions = data.versions.map(version => HelmChart.create(version, { onError: "log" })).filter(isDefined); return { readme, @@ -242,7 +242,7 @@ export interface RawHelmChartDependency { export type HelmChartDependency = Required> & Pick; -export interface HelmChart { +export interface HelmChartData { apiVersion: string; name: string; version: string; @@ -266,8 +266,30 @@ export interface HelmChart { tillerVersion?: string; } -export class HelmChart { - private constructor(value: HelmChart) { +export class HelmChart implements HelmChartData { + apiVersion: string; + name: string; + version: string; + repo: string; + created: string; + description: string; + keywords: string[]; + sources: string[]; + urls: string[]; + annotations: Record; + dependencies: HelmChartDependency[]; + maintainers: HelmChartMaintainer[]; + deprecated: boolean; + kubeVersion?: string; + digest?: string; + home?: string; + engine?: string; + icon?: string; + appVersion?: string; + type?: string; + tillerVersion?: string; + + private constructor(value: HelmChart | HelmChartData) { this.apiVersion = value.apiVersion; this.name = value.name; this.version = value.version; @@ -294,25 +316,25 @@ export class HelmChart { } static create(data: RawHelmChart, { onError = "throw" }: HelmChartCreateOpts = {}): HelmChart | undefined { - const { value, error } = helmChartValidator.validate(data, { + const result = helmChartValidator.validate(data, { abortEarly: false, }); - if (!error) { - return new HelmChart(value); + if (!result.error) { + return new HelmChart(result.value); } - const [actualErrors, unknownDetails] = bifurcateArray(error.details, ({ type }) => type === "object.unknown"); + const [actualErrors, unknownDetails] = bifurcateArray(result.error.details, ({ type }) => type === "object.unknown"); if (unknownDetails.length > 0) { console.warn("HelmChart data has unexpected fields", { original: data, unknownFields: unknownDetails.flatMap(d => d.path) }); } if (actualErrors.length === 0) { - return new HelmChart(value); + return new HelmChart(result.value as unknown as HelmChartData); } - const validationError = new Joi.ValidationError(actualErrors.map(er => er.message).join(". "), actualErrors, error._original); + const validationError = new Joi.ValidationError(actualErrors.map(er => er.message).join(". "), actualErrors, result.error._original); if (onError === "throw") { throw validationError; @@ -347,7 +369,7 @@ export class HelmChart { return this.icon; } - getHome(): string { + getHome(): string | undefined { return this.home; } diff --git a/src/common/k8s-api/endpoints/helm-releases.api.ts b/src/common/k8s-api/endpoints/helm-releases.api.ts index 141f884c60..b3922276bd 100644 --- a/src/common/k8s-api/endpoints/helm-releases.api.ts +++ b/src/common/k8s-api/endpoints/helm-releases.api.ts @@ -9,12 +9,12 @@ import capitalize from "lodash/capitalize"; import { apiBase } from "../index"; import { helmChartStore } from "../../../renderer/components/+helm-charts/helm-chart.store"; import type { ItemObject } from "../../item.store"; -import { KubeObject } from "../kube-object"; import type { JsonApiData } from "../json-api"; import { buildURLPositional } from "../../utils/buildUrl"; import type { KubeJsonApiData } from "../kube-json-api"; -interface IReleasePayload { +export interface HelmReleaseDetails { + resources: KubeJsonApiData[]; name: string; namespace: string; version: string; @@ -30,15 +30,7 @@ interface IReleasePayload { }; } -interface IReleaseRawDetails extends IReleasePayload { - resources: KubeJsonApiData[]; -} - -export interface IReleaseDetails extends IReleasePayload { - resources: KubeObject[]; -} - -export interface IReleaseCreatePayload { +export interface HelmReleaseCreatePayload { name?: string; repo: string; chart: string; @@ -47,19 +39,19 @@ export interface IReleaseCreatePayload { values: string; } -export interface IReleaseUpdatePayload { +export interface HelmReleaseUpdatePayload { repo: string; chart: string; version: string; values: string; } -export interface IReleaseUpdateDetails { +export interface HelmReleaseUpdateDetails { log: string; - release: IReleaseDetails; + release: HelmReleaseDetails; } -export interface IReleaseRevision { +export interface HelmReleaseRevision { revision: number; updated: string; status: string; @@ -85,18 +77,13 @@ export async function listReleases(namespace?: string): Promise { return releases.map(toHelmRelease); } -export async function getRelease(name: string, namespace: string): Promise { +export async function getRelease(name: string, namespace: string): Promise { const path = endpoint({ name, namespace }); - const { resources: rawResources, ...details } = await apiBase.get(path); - const resources = rawResources.map(KubeObject.create); - return { - ...details, - resources, - }; + return apiBase.get(path); } -export async function createRelease(payload: IReleaseCreatePayload): Promise { +export async function createRelease(payload: HelmReleaseCreatePayload): Promise { const { repo, chart: rawChart, values: rawValues, ...data } = payload; const chart = `${repo}/${rawChart}`; const values = yaml.load(rawValues); @@ -110,7 +97,7 @@ export async function createRelease(payload: IReleaseCreatePayload): Promise { +export async function updateRelease(name: string, namespace: string, payload: HelmReleaseUpdatePayload): Promise { const { repo, chart: rawChart, values: rawValues, ...data } = payload; const chart = `${repo}/${rawChart}`; const values = yaml.load(rawValues); @@ -137,7 +124,7 @@ export async function getReleaseValues(name: string, namespace: string, all?: bo return apiBase.get(path); } -export async function getReleaseHistory(name: string, namespace: string): Promise { +export async function getReleaseHistory(name: string, namespace: string): Promise { const route = "history"; const path = endpoint({ name, namespace, route }); diff --git a/src/common/k8s-api/endpoints/hpa.api.ts b/src/common/k8s-api/endpoints/hpa.api.ts index e284f3a3e8..75b81a48dc 100644 --- a/src/common/k8s-api/endpoints/hpa.api.ts +++ b/src/common/k8s-api/endpoints/hpa.api.ts @@ -3,93 +3,124 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { BaseKubeObjectCondition, LabelSelector } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { OptionVarient } from "../../utils"; export enum HpaMetricType { Resource = "Resource", Pods = "Pods", Object = "Object", External = "External", + ContainerResource = "ContainerResource", } -export type IHpaMetricData = T & { - target?: { - kind: string; - name: string; - apiVersion: string; - }; - name?: string; - metricName?: string; - currentAverageUtilization?: number; - currentAverageValue?: string; +export interface HorizontalPodAutoscalerMetricTarget { + kind: string; + name: string; + apiVersion: string; +} + +export interface ContainerResourceMetricSource { + container: string; + name: string; targetAverageUtilization?: number; targetAverageValue?: string; -}; - -export interface IHpaMetric { - [kind: string]: IHpaMetricData; - - type: HpaMetricType; - resource?: IHpaMetricData<{ name: string }>; - pods?: IHpaMetricData; - external?: IHpaMetricData; - object?: IHpaMetricData<{ - describedObject: { - apiVersion: string; - kind: string; - name: string; - }; - }>; } -export interface HorizontalPodAutoscaler { - spec: { - scaleTargetRef: { - kind: string; - name: string; - apiVersion: string; - }; - minReplicas: number; - maxReplicas: number; - metrics: IHpaMetric[]; - }; - status: { - currentReplicas: number; - desiredReplicas: number; - currentMetrics: IHpaMetric[]; - conditions: { - lastTransitionTime: string; - message: string; - reason: string; - status: string; - type: string; - }[]; - }; +export interface ExternalMetricSource { + metricName: string; + metricSelector?: LabelSelector; + targetAverageValue?: string; + targetValue?: string; } -export class HorizontalPodAutoscaler extends KubeObject { - static kind = "HorizontalPodAutoscaler"; - static namespaced = true; - static apiBase = "/apis/autoscaling/v2beta1/horizontalpodautoscalers"; +export interface ObjectMetricSource { + averageValue?: string; + metricName: string; + selector?: LabelSelector; + target: CrossVersionObjectReference; + targetValue: string; +} + +export interface PodsMetricSource { + metricName: string; + selector?: LabelSelector; + targetAverageValue: string; +} + +export interface ResourceMetricSource { + name: string; + targetAverageUtilization?: number; + targetAverageValue?: string; +} + +export interface BaseHorizontalPodAutoscalerMetricSpec { + resource: ResourceMetricSource; + object: ObjectMetricSource; + external: ExternalMetricSource; + pods: PodsMetricSource; + containerResource: ContainerResourceMetricSource; +} + +export type HorizontalPodAutoscalerMetricSpec = + | OptionVarient + | OptionVarient + | OptionVarient + | OptionVarient + | OptionVarient; + +export interface CrossVersionObjectReference { + kind: string; + name: string; + apiVersion: string; +} + +export interface HorizontalPodAutoscalerSpec { + scaleTargetRef: CrossVersionObjectReference; + minReplicas?: number; + maxReplicas: number; + metrics?: HorizontalPodAutoscalerMetricSpec[]; +} + +export interface HorizontalPodAutoscalerStatus { + conditions?: BaseKubeObjectCondition[]; + currentReplicas: number; + desiredReplicas: number; + currentMetrics: HorizontalPodAutoscalerMetricSpec[]; +} + +interface MetricCurrentTarget { + current?: string | undefined; + target?: string | undefined; +} + +export class HorizontalPodAutoscaler extends KubeObject { + static readonly kind = "HorizontalPodAutoscaler"; + static readonly namespaced = true; + static readonly apiBase = "/apis/autoscaling/v2beta1/horizontalpodautoscalers"; getMaxPods() { - return this.spec.maxReplicas || 0; + return this.spec.maxReplicas ?? 0; } getMinPods() { - return this.spec.minReplicas || 0; + return this.spec.minReplicas ?? 0; } getReplicas() { - return this.status.currentReplicas; + return this.status?.currentReplicas ?? 0; + } + + getReadyConditions() { + return this.getConditions().filter(({ isReady }) => isReady); } getConditions() { - if (!this.status.conditions) return []; - - return this.status.conditions.map(condition => { + return this.status?.conditions?.map(condition => { const { message, reason, lastTransitionTime, status } = condition; return { @@ -97,65 +128,139 @@ export class HorizontalPodAutoscaler extends KubeObject { isReady: status === "True", tooltip: `${message || reason} (${lastTransitionTime})`, }; - }); + }) ?? []; } getMetrics() { - return this.spec.metrics || []; + return this.spec.metrics ?? []; } getCurrentMetrics() { - return this.status.currentMetrics || []; + return this.status?.currentMetrics ?? []; } - protected getMetricName(metric: IHpaMetric): string { - const { type, resource, pods, object, external } = metric; - - switch (type) { + protected getMetricName(metric: HorizontalPodAutoscalerMetricSpec): string { + switch (metric.type) { case HpaMetricType.Resource: - return resource.name; + return metric.resource.name; case HpaMetricType.Pods: - return pods.metricName; + return metric.pods.metricName; case HpaMetricType.Object: - return object.metricName; + return metric.object.metricName; case HpaMetricType.External: - return external.metricName; + return metric.external.metricName; + case HpaMetricType.ContainerResource: + return metric.containerResource.name; + default: + return ``; } } - // todo: refactor - getMetricValues(metric: IHpaMetric): string { - const metricType = metric.type.toLowerCase(); - const currentMetric = this.getCurrentMetrics().find(current => - metric.type == current.type && this.getMetricName(metric) == this.getMetricName(current), - ); - const current = currentMetric ? currentMetric[metricType] : null; - const target = metric[metricType]; - let currentValue = "unknown"; - let targetValue = "unknown"; + protected getResourceMetricValue(currentMetric: ResourceMetricSource | undefined, targetMetric: ResourceMetricSource): MetricCurrentTarget { + return { + current: ( + currentMetric?.targetAverageUtilization + ? `${currentMetric.targetAverageUtilization}%` + : currentMetric?.targetAverageValue + ), + target: ( + targetMetric?.targetAverageUtilization + ? `${targetMetric.targetAverageUtilization}%` + : targetMetric?.targetAverageValue + ), + }; + } - if (current) { - currentValue = current.currentAverageUtilization || current.currentAverageValue || current.currentValue; - if (current.currentAverageUtilization) currentValue += "%"; + protected getPodsMetricValue(currentMetric: PodsMetricSource | undefined, targetMetric: PodsMetricSource): MetricCurrentTarget { + return { + current: currentMetric?.targetAverageValue, + target: targetMetric?.targetAverageValue, + }; + } + + protected getObjectMetricValue(currentMetric: ObjectMetricSource | undefined, targetMetric: ObjectMetricSource): MetricCurrentTarget { + return { + current: ( + currentMetric?.targetValue + ?? currentMetric?.averageValue + ), + target: ( + targetMetric?.targetValue + ?? targetMetric?.averageValue + ), + }; + } + + protected getExternalMetricValue(currentMetric: ExternalMetricSource | undefined, targetMetric: ExternalMetricSource): MetricCurrentTarget { + return { + current: ( + currentMetric?.targetValue + ?? currentMetric?.targetAverageValue + ), + target: ( + targetMetric?.targetValue + ?? targetMetric?.targetAverageValue + ), + }; + } + + protected getContainerResourceMetricValue(currentMetric: ContainerResourceMetricSource | undefined, targetMetric: ContainerResourceMetricSource): MetricCurrentTarget { + return { + current: ( + currentMetric?.targetAverageUtilization + ? `${currentMetric.targetAverageUtilization}%` + : currentMetric?.targetAverageValue + ), + target: ( + targetMetric?.targetAverageUtilization + ? `${targetMetric.targetAverageUtilization}%` + : targetMetric?.targetAverageValue + ), + }; + } + + protected getMetricCurrentTarget(metric: HorizontalPodAutoscalerMetricSpec): MetricCurrentTarget { + const currentMetric = this.getMetrics() + .find(m => ( + m.type === metric.type + && this.getMetricName(m) === this.getMetricName(metric) + )); + + switch (metric.type) { + case HpaMetricType.Resource: + return this.getResourceMetricValue(currentMetric?.resource, metric.resource); + case HpaMetricType.Pods: + return this.getPodsMetricValue(currentMetric?.pods, metric.pods); + case HpaMetricType.Object: + return this.getObjectMetricValue(currentMetric?.object, metric.object); + case HpaMetricType.External: + return this.getExternalMetricValue(currentMetric?.external, metric.external); + case HpaMetricType.ContainerResource: + return this.getContainerResourceMetricValue(currentMetric?.containerResource, metric.containerResource); + default: + return {}; } + } - if (target) { - targetValue = target.targetAverageUtilization || target.targetAverageValue || target.targetValue; - if (target.targetAverageUtilization) targetValue += "%"; - } + getMetricValues(metric: HorizontalPodAutoscalerMetricSpec): string { + const { + current = "unknown", + target = "unknown", + } = this.getMetricCurrentTarget(metric); - return `${currentValue} / ${targetValue}`; + return `${current} / ${target}`; } } -let hpaApi: KubeApi; - -if (isClusterPageContext()) { - hpaApi = new KubeApi({ - objectConstructor: HorizontalPodAutoscaler, - }); +export class HorizontalPodAutoscalerApi extends KubeApi { + constructor(opts?: DerivedKubeApiOptions) { + super({ + objectConstructor: HorizontalPodAutoscaler, + ...opts ?? {}, + }); + } } -export { - hpaApi, -}; +export const horizontalPodAutoscalerApi = isClusterPageContext() + ? new HorizontalPodAutoscalerApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/ingress.api.ts b/src/common/k8s-api/endpoints/ingress.api.ts index f4ec20af1a..e362b5d2b0 100644 --- a/src/common/k8s-api/endpoints/ingress.api.ts +++ b/src/common/k8s-api/endpoints/ingress.api.ts @@ -5,18 +5,27 @@ import type { TypedLocalObjectReference } from "../kube-object"; import { KubeObject } from "../kube-object"; -import { autoBind, hasTypedProperty, isString, iter } from "../../utils"; -import type { IMetrics } from "./metrics.api"; +import { hasTypedProperty, isString, iter } from "../../utils"; +import type { MetricData } from "./metrics.api"; import { metricsApi } from "./metrics.api"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; import type { RequireExactlyOne } from "type-fest"; export class IngressApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: Ingress, + // Add fallback for Kubernetes <1.19 + checkPreferredVersion: true, + fallbackApiBases: ["/apis/extensions/v1beta1/ingresses"], + }); + } } -export function getMetricsForIngress(ingress: string, namespace: string): Promise { +export function getMetricsForIngress(ingress: string, namespace: string): Promise { const opts = { category: "ingress", ingress, namespace }; return metricsApi.getMetrics({ @@ -29,12 +38,11 @@ export function getMetricsForIngress(ingress: string, namespace: string): Promis }); } -export interface IIngressMetrics { - [metric: string]: T; - bytesSentSuccess: T; - bytesSentFailure: T; - requestDurationSeconds: T; - responseDurationSeconds: T; +export interface IngressMetricData extends Partial> { + bytesSentSuccess: MetricData; + bytesSentFailure: MetricData; + requestDurationSeconds: MetricData; + responseDurationSeconds: MetricData; } export interface ILoadBalancerIngress { @@ -73,7 +81,11 @@ function isExtensionsBackend(backend: IngressBackend): backend is ExtensionsBack * Format an ingress backend into the name of the service and port * @param backend The ingress target */ -export function getBackendServiceNamePort(backend: IngressBackend): string { +export function getBackendServiceNamePort(backend: IngressBackend | undefined): string { + if (!backend) { + return ""; + } + if (isExtensionsBackend(backend)) { return `${backend.serviceName}:${backend.servicePort}`; } @@ -87,60 +99,52 @@ export function getBackendServiceNamePort(backend: IngressBackend): string { return ""; } +export interface HTTPIngressPath { + pathType: "Exact" | "Prefix" | "ImplementationSpecific"; + path?: string; + backend?: IngressBackend; +} + +export interface HTTPIngressRuleValue { + paths: HTTPIngressPath[]; +} + export interface IngressRule { host?: string; - http?: { - paths: { - path?: string; - backend: IngressBackend; - }[]; - }; + http?: HTTPIngressRuleValue; } -export interface Ingress { - spec?: { - tls?: { - secretName: string; - }[]; - rules?: IngressRule[]; - // extensions/v1beta1 - backend?: ExtensionsBackend; - /** - * The default backend which is exactly on of: - * - service - * - resource - */ - defaultBackend?: RequireExactlyOne; - }; - status: { - loadBalancer: { - ingress: ILoadBalancerIngress[]; +export interface IngressSpec { + tls: { + secretName: string; + }[]; + rules?: IngressRule[]; + // extensions/v1beta1 + backend?: ExtensionsBackend; + /** + * The default backend which is exactly on of: + * - service + * - resource + */ + defaultBackend?: RequireExactlyOne; +} + +export interface IngressStatus { + loadBalancer: { + ingress: ILoadBalancerIngress[]; }; } -export interface ComputedIngressRoute { - displayAsLink: boolean; - pathname: string; - url: string; - service: string; -} - -export class Ingress extends KubeObject { - static kind = "Ingress"; - static namespaced = true; - static apiBase = "/apis/networking.k8s.io/v1/ingresses"; - - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } +export class Ingress extends KubeObject { + static readonly kind = "Ingress"; + static readonly namespaced = true; + static readonly apiBase = "/apis/networking.k8s.io/v1/ingresses"; getRules() { return this.spec.rules ?? []; @@ -150,12 +154,16 @@ export class Ingress extends KubeObject { return computeRouteDeclarations(this).map(({ url, service }) => `${url} ⇢ ${service}`); } - getServiceNamePort(): ExtensionsBackend { + getServiceNamePort(): ExtensionsBackend | undefined { const { spec: { backend, defaultBackend } = {}} = this; const serviceName = defaultBackend?.service?.name ?? backend?.serviceName; const servicePort = defaultBackend?.service?.port.number ?? defaultBackend?.service?.port.name ?? backend?.servicePort; + if (!serviceName || !servicePort) { + return undefined; + } + return { serviceName, servicePort, @@ -170,14 +178,14 @@ export class Ingress extends KubeObject { getPorts() { const ports: number[] = []; - const { spec: { tls, rules, backend, defaultBackend }} = this; + const { spec: { tls, rules = [], backend, defaultBackend }} = this; const httpPort = 80; const tlsPort = 443; // Note: not using the port name (string) const servicePort = defaultBackend?.service?.port.number ?? backend?.servicePort; - if (rules && rules.length > 0) { - if (rules.some(rule => Object.prototype.hasOwnProperty.call(rule, "http"))) { + if (rules.length > 0) { + if (rules.some(rule => rule.http)) { ports.push(httpPort); } } else if (servicePort !== undefined) { @@ -192,14 +200,19 @@ export class Ingress extends KubeObject { } getLoadBalancers() { - const { status: { loadBalancer = { ingress: [] }}} = this; - - return (loadBalancer.ingress ?? []).map(address => ( + return this.status?.loadBalancer.ingress.map(address => ( address.hostname || address.ip - )); + )) ?? []; } } +export interface ComputedIngressRoute { + displayAsLink: boolean; + pathname: string; + url: string; + service: string; +} + export function computeRuleDeclarations(ingress: Ingress, rule: IngressRule): ComputedIngressRoute[] { const { host = "*", http: { paths } = { paths: [] }} = rule; const protocol = (ingress.spec?.tls?.length ?? 0) === 0 @@ -218,17 +231,6 @@ export function computeRouteDeclarations(ingress: Ingress): ComputedIngressRoute return ingress.getRules().flatMap(rule => computeRuleDeclarations(ingress, rule)); } -let ingressApi: IngressApi; - -if (isClusterPageContext()) { - ingressApi = new IngressApi({ - objectConstructor: Ingress, - // Add fallback for Kubernetes <1.19 - checkPreferredVersion: true, - fallbackApiBases: ["/apis/extensions/v1beta1/ingresses"], - }); -} - -export { - ingressApi, -}; +export const ingressApi = isClusterPageContext() + ? new IngressApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/job.api.ts b/src/common/k8s-api/endpoints/job.api.ts index d59d49f921..55c528f903 100644 --- a/src/common/k8s-api/endpoints/job.api.ts +++ b/src/common/k8s-api/endpoints/job.api.ts @@ -3,88 +3,77 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import get from "lodash/get"; -import { autoBind } from "../../utils"; -import type { IAffinity } from "../workload-kube-object"; -import { WorkloadKubeObject } from "../workload-kube-object"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { metricsApi } from "./metrics.api"; -import type { KubeJsonApiData } from "../kube-json-api"; -import type { IPodContainer, IPodMetrics } from "./pods.api"; +import type { IPodContainer, PodMetricData, PodSpec } from "./pods.api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -import type { LabelSelector } from "../kube-object"; +import type { KubeObjectStatus, LabelSelector } from "../kube-object"; +import { KubeObject } from "../kube-object"; -export class Job extends WorkloadKubeObject { - static kind = "Job"; - static namespaced = true; - static apiBase = "/apis/batch/v1/jobs"; +export interface JobSpec { + parallelism?: number; + completions?: number; + backoffLimit?: number; + selector?: LabelSelector; + template: { + metadata: { + creationTimestamp?: string; + labels?: Partial>; + annotations?: Partial>; + }; + spec: PodSpec; + }; + containers?: IPodContainer[]; + restartPolicy?: string; + terminationGracePeriodSeconds?: number; + dnsPolicy?: string; + serviceAccountName?: string; + serviceAccount?: string; + schedulerName?: string; +} - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); +export interface JobStatus extends KubeObjectStatus { + startTime: string; + completionTime: string; + succeeded: number; +} + +export class Job extends KubeObject { + static readonly kind = "Job"; + static readonly namespaced = true; + static readonly apiBase = "/apis/batch/v1/jobs"; + + getSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.selector?.matchLabels); } - declare spec: { - parallelism?: number; - completions?: number; - backoffLimit?: number; - selector?: LabelSelector; - template: { - metadata: { - creationTimestamp?: string; - labels?: { - [name: string]: string; - }; - annotations?: { - [name: string]: string; - }; - }; - spec: { - containers: IPodContainer[]; - restartPolicy: string; - terminationGracePeriodSeconds: number; - dnsPolicy: string; - hostPID: boolean; - affinity?: IAffinity; - nodeSelector?: { - [selector: string]: string; - }; - tolerations?: { - key: string; - operator: string; - effect: string; - tolerationSeconds: number; - }[]; - schedulerName: string; - }; - }; - containers?: IPodContainer[]; - restartPolicy?: string; - terminationGracePeriodSeconds?: number; - dnsPolicy?: string; - serviceAccountName?: string; - serviceAccount?: string; - schedulerName?: string; - }; - declare status: { - conditions: { - type: string; - status: string; - lastProbeTime: string; - lastTransitionTime: string; - message?: string; - }[]; - startTime: string; - completionTime: string; - succeeded: number; - }; + getNodeSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.template.spec.nodeSelector); + } + + getTemplateLabels(): string[] { + return KubeObject.stringifyLabels(this.spec.template.metadata.labels); + } + + getTolerations() { + return this.spec.template.spec.tolerations ?? []; + } + + getAffinity() { + return this.spec.template.spec.affinity; + } + + getAffinityNumber() { + return Object.keys(this.getAffinity() ?? {}).length; + } getDesiredCompletions() { - return this.spec.completions || 0; + return this.spec.completions ?? 0; } getCompletions() { - return this.status.succeeded || 0; + return this.status?.succeeded ?? 0; } getParallelism() { @@ -94,20 +83,24 @@ export class Job extends WorkloadKubeObject { getCondition() { // Type of Job condition could be only Complete or Failed // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.14/#jobcondition-v1-batch - return this.status.conditions?.find(({ status }) => status === "True"); + return this.status?.conditions?.find(({ status }) => status === "True"); } getImages() { - const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); - - return [...containers].map(container => container.image); + return this.spec.template.spec.containers?.map(container => container.image) ?? []; } } export class JobApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: Job, + }); + } } -export function getMetricsForJobs(jobs: Job[], namespace: string, selector = ""): Promise { +export function getMetricsForJobs(jobs: Job[], namespace: string, selector = ""): Promise { const podSelector = jobs.map(job => `${job.getName()}-[[:alnum:]]{5}`).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -124,14 +117,6 @@ export function getMetricsForJobs(jobs: Job[], namespace: string, selector = "") }); } -let jobApi: JobApi; - -if (isClusterPageContext()) { - jobApi = new JobApi({ - objectConstructor: Job, - }); -} - -export { - jobApi, -}; +export const jobApi = isClusterPageContext() + ? new JobApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/limit-range.api.ts b/src/common/k8s-api/endpoints/limit-range.api.ts index 07840e7dfc..3f2095aded 100644 --- a/src/common/k8s-api/endpoints/limit-range.api.ts +++ b/src/common/k8s-api/endpoints/limit-range.api.ts @@ -4,9 +4,8 @@ */ import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import { autoBind } from "../../utils"; -import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export enum LimitType { @@ -36,21 +35,14 @@ export interface LimitRangeItem extends LimitRangeParts { type: string; } -export interface LimitRange { - spec: { - limits: LimitRangeItem[]; - }; +export interface LimitRangeSpec { + limits: LimitRangeItem[]; } -export class LimitRange extends KubeObject { - static kind = "LimitRange"; - static namespaced = true; - static apiBase = "/api/v1/limitranges"; - - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } +export class LimitRange extends KubeObject { + static readonly kind = "LimitRange"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/limitranges"; getContainerLimits() { return this.spec.limits.filter(limit => limit.type === LimitType.CONTAINER); @@ -65,14 +57,15 @@ export class LimitRange extends KubeObject { } } -let limitRangeApi: KubeApi; - -if (isClusterPageContext()) { - limitRangeApi = new KubeApi({ - objectConstructor: LimitRange, - }); +export class LimitRangeApi extends KubeApi { + constructor(opts?: DerivedKubeApiOptions) { + super({ + objectConstructor: LimitRange, + ...opts ?? {}, + }); + } } -export { - limitRangeApi, -}; +export const limitRangeApi = isClusterPageContext() + ? new LimitRangeApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/metrics.api.ts b/src/common/k8s-api/endpoints/metrics.api.ts index ad4044c5d3..7b0028b061 100644 --- a/src/common/k8s-api/endpoints/metrics.api.ts +++ b/src/common/k8s-api/endpoints/metrics.api.ts @@ -9,18 +9,18 @@ import moment from "moment"; import { apiBase } from "../index"; import type { IMetricsQuery } from "../../../main/routes/metrics/metrics-query"; -export interface IMetrics { +export interface MetricData { status: string; data: { resultType: string; - result: IMetricsResult[]; + result: MetricResult[]; }; } -export interface IMetricsResult { +export interface MetricResult { metric: { - [name: string]: string; - instance: string; + [name: string]: string | undefined; + instance?: string; node?: string; pod?: string; kubernetes?: string; @@ -44,7 +44,7 @@ export interface IMetricsReqParams { namespace?: string; // rbac-proxy validation param } -export interface IResourceMetrics { +export interface IResourceMetrics { [metric: string]: T; cpuUsage: T; memoryUsage: T; @@ -56,7 +56,7 @@ export interface IResourceMetrics { } export const metricsApi = { - async getMetrics(query: T, reqParams: IMetricsReqParams = {}): Promise { + async getMetrics(query: T, reqParams: IMetricsReqParams = {}): Promise { const { range = 3600, step = 60, namespace } = reqParams; let { start, end } = reqParams; @@ -82,7 +82,7 @@ export const metricsApi = { }, }; -export function normalizeMetrics(metrics: IMetrics, frames = 60): IMetrics { +export function normalizeMetrics(metrics: MetricData | undefined | null, frames = 60): MetricData { if (!metrics?.data?.result) { return { data: { @@ -90,7 +90,7 @@ export function normalizeMetrics(metrics: IMetrics, frames = 60): IMetrics { result: [{ metric: {}, values: [], - } as IMetricsResult], + }], }, status: "", }; @@ -131,17 +131,17 @@ export function normalizeMetrics(metrics: IMetrics, frames = 60): IMetrics { result.push({ metric: {}, values: [], - } as IMetricsResult); + } as MetricResult); } return metrics; } -export function isMetricsEmpty(metrics: Record) { +export function isMetricsEmpty(metrics: Partial>) { return Object.values(metrics).every(metric => !metric?.data?.result?.length); } -export function getItemMetrics(metrics: Record, itemName: string): Record | undefined { +export function getItemMetrics(metrics: Partial> | null | undefined, itemName: string): Partial> | undefined { if (!metrics) { return undefined; } @@ -152,23 +152,24 @@ export function getItemMetrics(metrics: Record, itemName: stri if (!metrics[metric]?.data?.result) { continue; } - const results = metrics[metric].data.result; - const result = results.find(res => Object.values(res.metric)[0] == itemName); + const results = metrics[metric]?.data.result; + const result = results?.find(res => Object.values(res.metric)[0] == itemName); - itemMetrics[metric].data.result = result ? [result] : []; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + itemMetrics[metric]!.data.result = result ? [result] : []; } return itemMetrics; } -export function getMetricLastPoints(metrics: Record) { +export function getMetricLastPoints>>(metrics: T): Record { const result: Partial<{ [metric: string]: number }> = {}; Object.keys(metrics).forEach(metricName => { try { const metric = metrics[metricName]; - if (metric.data.result.length) { + if (metric?.data.result.length) { result[metricName] = +metric.data.result[0].values.slice(-1)[0][1]; } } catch { @@ -178,5 +179,5 @@ export function getMetricLastPoints(metrics: Record) { return result; }, {}); - return result; + return result as Record; } diff --git a/src/common/k8s-api/endpoints/namespaces.api.ts b/src/common/k8s-api/endpoints/namespaces.api.ts index 07b51f701d..f46aa8782c 100644 --- a/src/common/k8s-api/endpoints/namespaces.api.ts +++ b/src/common/k8s-api/endpoints/namespaces.api.ts @@ -4,33 +4,29 @@ */ import { KubeApi } from "../kube-api"; +import type { KubeObjectStatus } from "../kube-object"; import { KubeObject } from "../kube-object"; -import { autoBind } from "../../../renderer/utils"; import { metricsApi } from "./metrics.api"; -import type { IPodMetrics } from "./pods.api"; -import type { KubeJsonApiData } from "../kube-json-api"; +import type { PodMetricData } from "./pods.api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -export enum NamespaceStatus { +export enum NamespaceStatusKind { ACTIVE = "Active", TERMINATING = "Terminating", } -export interface Namespace { - status?: { - phase: string; - }; +export interface NamespaceSpec { + finalizers?: string[]; } -export class Namespace extends KubeObject { - static kind = "Namespace"; - static namespaced = false; - static apiBase = "/api/v1/namespaces"; +export interface NamespaceStatus extends KubeObjectStatus { + phase?: string; +} - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } +export class Namespace extends KubeObject { + static readonly kind = "Namespace"; + static readonly namespaced = false; + static readonly apiBase = "/api/v1/namespaces"; getStatus() { return this.status?.phase ?? "-"; @@ -40,7 +36,7 @@ export class Namespace extends KubeObject { export class NamespaceApi extends KubeApi { } -export function getMetricsForNamespace(namespace: string, selector = ""): Promise { +export function getMetricsForNamespace(namespace: string, selector = ""): Promise { const opts = { category: "pods", pods: ".*", namespace, selector }; return metricsApi.getMetrics({ diff --git a/src/common/k8s-api/endpoints/network-policy.api.ts b/src/common/k8s-api/endpoints/network-policy.api.ts index 64bdc563b0..3dad0ce3a1 100644 --- a/src/common/k8s-api/endpoints/network-policy.api.ts +++ b/src/common/k8s-api/endpoints/network-policy.api.ts @@ -5,9 +5,8 @@ import type { LabelSelector } from "../kube-object"; import { KubeObject } from "../kube-object"; -import { autoBind } from "../../utils"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export interface IPolicyIpBlock { @@ -105,15 +104,10 @@ export interface NetworkPolicy { spec: NetworkPolicySpec; } -export class NetworkPolicy extends KubeObject { - static kind = "NetworkPolicy"; - static namespaced = true; - static apiBase = "/apis/networking.k8s.io/v1/networkpolicies"; - - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } +export class NetworkPolicy extends KubeObject { + static readonly kind = "NetworkPolicy"; + static readonly namespaced = true; + static readonly apiBase = "/apis/networking.k8s.io/v1/networkpolicies"; getMatchLabels(): string[] { if (!this.spec.podSelector || !this.spec.podSelector.matchLabels) return []; @@ -130,14 +124,15 @@ export class NetworkPolicy extends KubeObject { } } -let networkPolicyApi: KubeApi; - -if (isClusterPageContext()) { - networkPolicyApi = new KubeApi({ - objectConstructor: NetworkPolicy, - }); +export class NetworkPolicyApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + objectConstructor: NetworkPolicy, + ...opts, + }); + } } -export { - networkPolicyApi, -}; +export const networkPolicyApi = isClusterPageContext() + ? new NetworkPolicyApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/nodes.api.ts b/src/common/k8s-api/endpoints/nodes.api.ts index 62edc090e4..04697c6bb3 100644 --- a/src/common/k8s-api/endpoints/nodes.api.ts +++ b/src/common/k8s-api/endpoints/nodes.api.ts @@ -3,18 +3,26 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { BaseKubeObjectCondition } from "../kube-object"; import { KubeObject } from "../kube-object"; -import { autoBind, cpuUnitsToNumber, iter, unitsToBytes } from "../../../renderer/utils"; -import type { IMetrics } from "./metrics.api"; +import { cpuUnitsToNumber, unitsToBytes } from "../../../renderer/utils"; +import type { MetricData } from "./metrics.api"; import { metricsApi } from "./metrics.api"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import { TypedRegEx } from "typed-regex"; -export class NodesApi extends KubeApi { +export class NodeApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: Node, + }); + } } -export function getMetricsForAllNodes(): Promise { +export function getMetricsForAllNodes(): Promise { const opts = { category: "nodes" }; return metricsApi.getMetrics({ @@ -29,16 +37,15 @@ export function getMetricsForAllNodes(): Promise { }); } -export interface INodeMetrics { - [metric: string]: T; - memoryUsage: T; - workloadMemoryUsage: T; - memoryCapacity: T; - memoryAllocatableCapacity: T; - cpuUsage: T; - cpuCapacity: T; - fsUsage: T; - fsSize: T; +export interface NodeMetricData extends Partial> { + memoryUsage: MetricData; + workloadMemoryUsage: MetricData; + memoryCapacity: MetricData; + memoryAllocatableCapacity: MetricData; + cpuUsage: MetricData; + cpuCapacity: MetricData; + fsUsage: MetricData; + fsSize: MetricData; } export interface NodeTaint { @@ -56,116 +63,127 @@ export function formatNodeTaint(taint: NodeTaint): string { return `${taint.key}:${taint.effect}`; } -export interface NodeCondition { - type: string; - status: string; +export interface NodeCondition extends BaseKubeObjectCondition { + /** + * Last time we got an update on a given condition. + */ lastHeartbeatTime?: string; - lastTransitionTime?: string; - reason?: string; - message?: string; -} - -export interface Node { - spec: { - podCIDR?: string; - podCIDRs?: string[]; - providerID?: string; - /** - * @deprecated see https://issues.k8s.io/61966 - */ - externalID?: string; - taints?: NodeTaint[]; - unschedulable?: boolean; - }; - status: { - capacity?: { - cpu: string; - "ephemeral-storage": string; - "hugepages-1Gi": string; - "hugepages-2Mi": string; - memory: string; - pods: string; - }; - allocatable?: { - cpu: string; - "ephemeral-storage": string; - "hugepages-1Gi": string; - "hugepages-2Mi": string; - memory: string; - pods: string; - }; - conditions?: NodeCondition[]; - addresses?: { - type: string; - address: string; - }[]; - daemonEndpoints?: { - kubeletEndpoint: { - Port: number; //it must be uppercase for backwards compatibility - }; - }; - nodeInfo?: { - machineID: string; - systemUUID: string; - bootID: string; - kernelVersion: string; - osImage: string; - containerRuntimeVersion: string; - kubeletVersion: string; - kubeProxyVersion: string; - operatingSystem: string; - architecture: string; - }; - images?: { - names: string[]; - sizeBytes?: number; - }[]; - volumesInUse?: string[]; - volumesAttached?: { - name: string; - devicePath: string; - }[]; - }; -} - -/** - * Iterate over `conditions` yielding the `type` field if the `status` field is - * the string `"True"` - * @param conditions An iterator of some conditions - */ -function* getTrueConditionTypes(conditions: IterableIterator | Iterable): IterableIterator { - for (const { status, type } of conditions) { - if (status === "True") { - yield type; - } - } } /** * This regex is used in the `getRoleLabels()` method bellow, but placed here * as factoring out regexes is best practice. */ -const nodeRoleLabelKeyMatcher = /^.*node-role.kubernetes.io\/+(?.+)$/; +const nodeRoleLabelKeyMatcher = TypedRegEx("^.*node-role.kubernetes.io/+(?.+)$"); -export class Node extends KubeObject { - static kind = "Node"; - static namespaced = false; - static apiBase = "/api/v1/nodes"; +export interface NodeSpec { + podCIDR?: string; + podCIDRs?: string[]; + providerID?: string; + /** + * @deprecated see https://issues.k8s.io/61966 + */ + externalID?: string; + taints?: NodeTaint[]; + unschedulable?: boolean; +} - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } +export interface NodeAddress { + type: "Hostname" | "ExternalIP" | "InternalIP"; + address: string; +} + +export interface NodeStatusResources extends Partial> { + cpu?: string; + "ephemeral-storage"?: string; + "hugepages-1Gi"?: string; + "hugepages-2Mi"?: string; + memory?: string; + pods?: string; +} + +export interface ConfigMapNodeConfigSource { + kubeletConfigKey: string; + name: string; + namespace: string; + resourceVersion?: string; + uid?: string; +} + +export interface NodeConfigSource { + configMap?: ConfigMapNodeConfigSource; +} + +export interface NodeConfigStatus { + active?: NodeConfigSource; + assigned?: NodeConfigSource; + lastKnownGood?: NodeConfigSource; + error?: string; +} + +export interface DaemonEndpoint { + Port: number; //it must be uppercase for backwards compatibility +} + +export interface NodeDaemonEndpoints { + kubeletEndpoint?: DaemonEndpoint; +} + +export interface ContainerImage { + names?: string[]; + sizeBytes?: number; +} + +export interface NodeSystemInfo { + architecture: string; + bootID: string; + containerRuntimeVersion: string; + kernelVersion: string; + kubeProxyVersion: string; + kubeletVersion: string; + machineID: string; + operatingSystem: string; + osImage: string; + systemUUID: string; +} + +export interface AttachedVolume { + name: string; + devicePath: string; +} + +export interface NodeStatus { + capacity?: NodeStatusResources; + allocatable?: NodeStatusResources; + conditions?: NodeCondition[]; + addresses?: NodeAddress[]; + config?: NodeConfigStatus; + daemonEndpoints?: NodeDaemonEndpoints; + images?: ContainerImage[]; + nodeInfo?: NodeSystemInfo; + phase?: string; + volumesInUse?: string[]; + volumesAttached?: AttachedVolume[]; +} + +export class Node extends KubeObject { + static readonly kind = "Node"; + static readonly namespaced = false; + static readonly apiBase = "/api/v1/nodes"; /** * Returns the concatination of all current condition types which have a status * of `"True"` */ getNodeConditionText(): string { - return iter.join( - getTrueConditionTypes(this.status?.conditions ?? []), - " ", - ); + if (!this.status?.conditions) { + return ""; + } + + return this.status.conditions + .filter(condition => condition.status === "True") + .map(condition => condition.type) + .join(" "); } getTaints() { @@ -182,9 +200,9 @@ export class Node extends KubeObject { const roleLabels: string[] = []; for (const labelKey of Object.keys(labels)) { - const match = nodeRoleLabelKeyMatcher.exec(labelKey); + const match = nodeRoleLabelKeyMatcher.match(labelKey); - if (match) { + if (match?.groups) { roleLabels.push(match.groups.role); } } @@ -201,19 +219,19 @@ export class Node extends KubeObject { } getCpuCapacity() { - if (!this.status.capacity || !this.status.capacity.cpu) return 0; + if (!this.status?.capacity || !this.status.capacity.cpu) return 0; return cpuUnitsToNumber(this.status.capacity.cpu); } getMemoryCapacity() { - if (!this.status.capacity || !this.status.capacity.memory) return 0; + if (!this.status?.capacity || !this.status.capacity.memory) return 0; return unitsToBytes(this.status.capacity.memory); } - getConditions() { - const conditions = this.status.conditions || []; + getConditions(): NodeCondition[] { + const conditions = this.status?.conditions || []; if (this.isUnschedulable()) { return [{ type: "SchedulingDisabled", status: "True" }, ...conditions]; @@ -235,7 +253,7 @@ export class Node extends KubeObject { } getKubeletVersion() { - return this.status.nodeInfo.kubeletVersion; + return this.status?.nodeInfo?.kubeletVersion ?? ""; } getOperatingSystem(): string { @@ -250,14 +268,6 @@ export class Node extends KubeObject { } } -let nodesApi: NodesApi; - -if (isClusterPageContext()) { - nodesApi = new NodesApi({ - objectConstructor: Node, - }); -} - -export { - nodesApi, -}; +export const nodeApi = isClusterPageContext() + ? new NodeApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/persistent-volume-claims.api.ts b/src/common/k8s-api/endpoints/persistent-volume-claims.api.ts index d013845d3c..7c56afc651 100644 --- a/src/common/k8s-api/endpoints/persistent-volume-claims.api.ts +++ b/src/common/k8s-api/endpoints/persistent-volume-claims.api.ts @@ -3,20 +3,27 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { LabelSelector } from "../kube-object"; +import type { LabelSelector, TypedLocalObjectReference } from "../kube-object"; import { KubeObject } from "../kube-object"; -import { autoBind } from "../../utils"; -import type { IMetrics } from "./metrics.api"; +import type { MetricData } from "./metrics.api"; import { metricsApi } from "./metrics.api"; import type { Pod } from "./pods.api"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import { object } from "../../utils"; +import type { ResourceRequirements } from "./types/resource-requirements"; -export class PersistentVolumeClaimsApi extends KubeApi { +export class PersistentVolumeClaimApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: PersistentVolumeClaim, + }); + } } -export function getMetricsForPvc(pvc: PersistentVolumeClaim): Promise { +export function getMetricsForPvc(pvc: PersistentVolumeClaim): Promise { const opts = { category: "pvc", pvc: pvc.getName(), namespace: pvc.getNs() }; return metricsApi.getMetrics({ @@ -27,91 +34,59 @@ export function getMetricsForPvc(pvc: PersistentVolumeClaim): Promise { - [key: string]: T; - diskUsage: T; - diskCapacity: T; +export interface PersistentVolumeClaimMetricData extends Partial> { + diskUsage: MetricData; + diskCapacity: MetricData; } export interface PersistentVolumeClaimSpec { - accessModes: string[]; - selector: LabelSelector; - resources: { - requests?: Record; - limits?: Record; - }; - volumeName?: string; + accessModes?: string[]; + dataSource?: TypedLocalObjectReference; + dataSourceRef?: TypedLocalObjectReference; + resources?: ResourceRequirements; + selector?: LabelSelector; storageClassName?: string; volumeMode?: string; - dataSource?: { - apiGroup: string; - kind: string; - name: string; - }; + volumeName?: string; } -export interface PersistentVolumeClaim { - spec: PersistentVolumeClaimSpec; - status: { - phase: string; // Pending - }; +export interface PersistentVolumeClaimStatus { + phase: string; // Pending } -export class PersistentVolumeClaim extends KubeObject { - static kind = "PersistentVolumeClaim"; - static namespaced = true; - static apiBase = "/api/v1/persistentvolumeclaims"; +export class PersistentVolumeClaim extends KubeObject { + static readonly kind = "PersistentVolumeClaim"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/persistentvolumeclaims"; - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } - - getPods(allPods: Pod[]): Pod[] { - const pods = allPods.filter(pod => pod.getNs() === this.getNs()); - - return pods.filter(pod => { - return pod.getVolumes().filter(volume => - volume.persistentVolumeClaim && - volume.persistentVolumeClaim.claimName === this.getName(), - ).length > 0; - }); + getPods(pods: Pod[]): Pod[] { + return pods + .filter(pod => pod.getNs() === this.getNs()) + .filter(pod => ( + pod.getVolumes() + .filter(volume => volume.persistentVolumeClaim?.claimName === this.getName()) + .length > 0 + )); } getStorage(): string { - if (!this.spec.resources || !this.spec.resources.requests) return "-"; - - return this.spec.resources.requests.storage; + return this.spec.resources?.requests?.storage ?? "-"; } getMatchLabels(): string[] { - if (!this.spec.selector || !this.spec.selector.matchLabels) return []; - - return Object.entries(this.spec.selector.matchLabels) + return object.entries(this.spec.selector?.matchLabels) .map(([name, val]) => `${name}:${val}`); } getMatchExpressions() { - if (!this.spec.selector || !this.spec.selector.matchExpressions) return []; - - return this.spec.selector.matchExpressions; + return this.spec.selector?.matchExpressions ?? []; } getStatus(): string { - if (this.status) return this.status.phase; - - return "-"; + return this.status?.phase ?? "-"; } } -let pvcApi: PersistentVolumeClaimsApi; - -if (isClusterPageContext()) { - pvcApi = new PersistentVolumeClaimsApi({ - objectConstructor: PersistentVolumeClaim, - }); -} - -export { - pvcApi, -}; +export const persistentVolumeClaimApi = isClusterPageContext() + ? new PersistentVolumeClaimApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/persistent-volume.api.ts b/src/common/k8s-api/endpoints/persistent-volume.api.ts index cf225dace8..8734d3fd10 100644 --- a/src/common/k8s-api/endpoints/persistent-volume.api.ts +++ b/src/common/k8s-api/endpoints/persistent-volume.api.ts @@ -3,64 +3,77 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { LabelSelector, ObjectReference, TypedLocalObjectReference } from "../kube-object"; import { KubeObject } from "../kube-object"; -import { autoBind, unitsToBytes } from "../../utils"; +import { unitsToBytes } from "../../utils"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { ResourceRequirements } from "./types/resource-requirements"; -export interface PersistentVolume { - spec: { - capacity: { - storage: string; // 8Gi - }; - flexVolume: { - driver: string; // ceph.rook.io/rook-ceph-system, - options: { - clusterNamespace: string; // rook-ceph, - image: string; // pvc-c5d7c485-9f1b-11e8-b0ea-9600000e54fb, - pool: string; // replicapool, - storageClass: string; // rook-ceph-block - }; - }; - mountOptions?: string[]; - accessModes: string[]; // [ReadWriteOnce] - claimRef: { - kind: string; // PersistentVolumeClaim, - namespace: string; // storage, - name: string; // nfs-provisioner, - uid: string; // c5d7c485-9f1b-11e8-b0ea-9600000e54fb, - apiVersion: string; // v1, - resourceVersion: string; // 292180 - }; - persistentVolumeReclaimPolicy: string; // Delete, - storageClassName: string; // rook-ceph-block - nfs?: { - path: string; - server: string; +export interface PersistentVolumeSpec { + /** + * AccessModes contains the desired access modes the volume should have. + * + * More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + */ + accessModes?: string[]; + dataSource?: TypedLocalObjectReference; + dataSourceRef?: TypedLocalObjectReference; + resources?: ResourceRequirements; + selector?: LabelSelector; + + /** + * Name of the StorageClass required by the claim. + * + * More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 + */ + storageClassName?: string; + + /** + * Defines what type of volume is required by the claim. Value of Filesystem is implied when not + * included in claim spec. + */ + volumeMode?: string; + + /** + * A description of the persistent volume\'s resources and capacity. + * + * More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#capacity + */ + capacity?: Partial>; + flexVolume?: { + driver: string; // ceph.rook.io/rook-ceph-system, + options: { + clusterNamespace: string; // rook-ceph, + image: string; // pvc-c5d7c485-9f1b-11e8-b0ea-9600000e54fb, + pool: string; // replicapool, + storageClass: string; // rook-ceph-block }; }; - - status?: { - phase: string; - reason?: string; + mountOptions?: string[]; + claimRef?: ObjectReference; + persistentVolumeReclaimPolicy?: string; // Delete, + nfs?: { + path: string; + server: string; }; } -export class PersistentVolume extends KubeObject { +export interface PersistentVolumeStatus { + phase: string; + reason?: string; +} + +export class PersistentVolume extends KubeObject { static kind = "PersistentVolume"; static namespaced = false; static apiBase = "/api/v1/persistentvolumes"; - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } - getCapacity(inBytes = false) { const capacity = this.spec.capacity; - if (capacity) { + if (capacity?.storage) { if (inBytes) return unitsToBytes(capacity.storage); return capacity.storage; @@ -74,7 +87,7 @@ export class PersistentVolume extends KubeObject { } getStorageClass(): string { - return this.spec.storageClassName; + return this.spec.storageClassName ?? ""; } getClaimRefName(): string { @@ -86,14 +99,15 @@ export class PersistentVolume extends KubeObject { } } -let persistentVolumeApi: KubeApi; - -if (isClusterPageContext()) { - persistentVolumeApi = new KubeApi({ - objectConstructor: PersistentVolume, - }); +export class PersistentVolumeApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: PersistentVolume, + }); + } } -export { - persistentVolumeApi, -}; +export const persistentVolumeApi = isClusterPageContext() + ? new PersistentVolumeApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/pod-metrics.api.ts b/src/common/k8s-api/endpoints/pod-metrics.api.ts index f92e6a6403..42cd022c33 100644 --- a/src/common/k8s-api/endpoints/pod-metrics.api.ts +++ b/src/common/k8s-api/endpoints/pod-metrics.api.ts @@ -3,36 +3,60 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { KubeObjectMetadata } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { KubeJsonApiData } from "../kube-json-api"; -export interface PodMetrics { +export interface PodMetricsData extends KubeJsonApiData, void, void> { timestamp: string; window: string; - containers: { - name: string; - usage: { - cpu: string; - memory: string; - }; - }[]; + containers: PodMetricsContainer[]; } -export class PodMetrics extends KubeObject { - static kind = "PodMetrics"; - static namespaced = true; - static apiBase = "/apis/metrics.k8s.io/v1beta1/pods"; +export interface PodMetricsContainerUsage { + cpu: string; + memory: string; } -let podMetricsApi: KubeApi; - -if (isClusterPageContext()) { - podMetricsApi = new KubeApi({ - objectConstructor: PodMetrics, - }); +export interface PodMetricsContainer { + name: string; + usage: PodMetricsContainerUsage; } -export { - podMetricsApi, -}; +export class PodMetrics extends KubeObject { + static readonly kind = "PodMetrics"; + static readonly namespaced = true; + static readonly apiBase = "/apis/metrics.k8s.io/v1beta1/pods"; + + timestamp: string; + window: string; + containers: PodMetricsContainer[]; + + constructor({ + timestamp, + window, + containers, + ...rest + }: PodMetricsData) { + super(rest); + this.timestamp = timestamp; + this.window = window; + this.containers = containers; + } +} + +export class PodMetricsApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: PodMetrics, + }); + } +} + +export const podMetricsApi = isClusterPageContext() + ? new PodMetricsApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/poddisruptionbudget.api.ts b/src/common/k8s-api/endpoints/poddisruptionbudget.api.ts index 5797ddf877..0053aa1dff 100644 --- a/src/common/k8s-api/endpoints/poddisruptionbudget.api.ts +++ b/src/common/k8s-api/endpoints/poddisruptionbudget.api.ts @@ -3,41 +3,32 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { autoBind } from "../../utils"; import type { LabelSelector } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -export interface PodDisruptionBudget { - spec: { - minAvailable: string; - maxUnavailable: string; - selector: LabelSelector; - }; - status: { - currentHealthy: number; - desiredHealthy: number; - disruptionsAllowed: number; - expectedPods: number; - }; +export interface PodDisruptionBudgetSpec { + minAvailable: string; + maxUnavailable: string; + selector: LabelSelector; } -export class PodDisruptionBudget extends KubeObject { - static kind = "PodDisruptionBudget"; - static namespaced = true; - static apiBase = "/apis/policy/v1beta1/poddisruptionbudgets"; +export interface PodDisruptionBudgetStatus { + currentHealthy: number; + desiredHealthy: number; + disruptionsAllowed: number; + expectedPods: number; +} - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } +export class PodDisruptionBudget extends KubeObject { + static readonly kind = "PodDisruptionBudget"; + static readonly namespaced = true; + static readonly apiBase = "/apis/policy/v1beta1/poddisruptionbudgets"; getSelectors() { - const selector = this.spec.selector; - - return KubeObject.stringifyLabels(selector ? selector.matchLabels : null); + return KubeObject.stringifyLabels(this.spec.selector.matchLabels); } getMinAvailable() { @@ -49,23 +40,23 @@ export class PodDisruptionBudget extends KubeObject { } getCurrentHealthy() { - return this.status.currentHealthy; + return this.status?.currentHealthy ?? 0; } getDesiredHealthy() { - return this.status.desiredHealthy; + return this.status?.desiredHealthy ?? 0; } - } -let pdbApi: KubeApi; - -if (isClusterPageContext()) { - pdbApi = new KubeApi({ - objectConstructor: PodDisruptionBudget, - }); +export class PodDisruptionBudgetApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + objectConstructor: PodDisruptionBudget, + ...opts, + }); + } } -export { - pdbApi, -}; +export const podDisruptionBudgetApi = isClusterPageContext() + ? new PodDisruptionBudgetApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/pods.api.ts b/src/common/k8s-api/endpoints/pods.api.ts index c76a3d021c..30aeb69800 100644 --- a/src/common/k8s-api/endpoints/pods.api.ts +++ b/src/common/k8s-api/endpoints/pods.api.ts @@ -3,20 +3,26 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { IAffinity } from "../workload-kube-object"; -import { WorkloadKubeObject } from "../workload-kube-object"; -import { autoBind } from "../../utils"; -import type { IMetrics } from "./metrics.api"; +import type { MetricData } from "./metrics.api"; import { metricsApi } from "./metrics.api"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; import type { RequireExactlyOne } from "type-fest"; -import type { KubeObjectMetadata, LocalObjectReference } from "../kube-object"; +import type { KubeObjectMetadata, LocalObjectReference, Affinity, Toleration, LabelSelector } from "../kube-object"; import type { SecretReference } from "./secret.api"; import type { PersistentVolumeClaimSpec } from "./persistent-volume-claims.api"; +import { KubeObject } from "../kube-object"; +import { isDefined } from "../../utils"; + +export class PodApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: Pod, + }); + } -export class PodsApi extends KubeApi { getLogs = async (params: { namespace: string; name: string }, query?: IPodLogsQuery): Promise => { const path = `${this.getUrl(params)}/log`; @@ -24,7 +30,7 @@ export class PodsApi extends KubeApi { }; } -export function getMetricsForPods(pods: Pod[], namespace: string, selector = "pod, namespace"): Promise { +export function getMetricsForPods(pods: Pod[], namespace: string, selector = "pod, namespace"): Promise { const podSelector = pods.map(pod => pod.getName()).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -45,19 +51,18 @@ export function getMetricsForPods(pods: Pod[], namespace: string, selector = "po }); } -export interface IPodMetrics { - [metric: string]: T; - cpuUsage: T; - memoryUsage: T; - fsUsage: T; - fsWrites: T; - fsReads: T; - networkReceive: T; - networkTransmit: T; - cpuRequests?: T; - cpuLimits?: T; - memoryRequests?: T; - memoryLimits?: T; +export interface PodMetricData extends Partial> { + cpuUsage: MetricData; + memoryUsage: MetricData; + fsUsage: MetricData; + fsWrites: MetricData; + fsReads: MetricData; + networkReceive: MetricData; + networkTransmit: MetricData; + cpuRequests?: MetricData; + cpuLimits?: MetricData; + memoryRequests?: MetricData; + memoryLimits?: MetricData; } // Reference: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.19/#read-log-pod-v1-core @@ -70,7 +75,7 @@ export interface IPodLogsQuery { previous?: boolean; } -export enum PodStatus { +export enum PodStatusPhase { TERMINATED = "Terminated", FAILED = "Failed", PENDING = "Pending", @@ -79,26 +84,41 @@ export enum PodStatus { EVICTED = "Evicted", } +export interface ContainerPort { + containerPort: number; + hostIP?: string; + hostPort?: number; + name?: string; + protocol?: "UDP" | "TCP" | "SCTP"; +} + +export interface VolumeMount { + name: string; + readOnly?: boolean; + mountPath: string; + mountPropagation?: string; + subPath?: string; + subPathExpr?: string; +} + export interface IPodContainer extends Partial> { name: string; image: string; command?: string[]; args?: string[]; - ports?: { - name?: string; - containerPort: number; - protocol: string; - }[]; + ports?: ContainerPort[]; resources?: { - limits: { + limits?: { cpu: string; memory: string; }; - requests: { + requests?: { cpu: string; memory: string; }; }; + terminationMessagePath?: string; + terminationMessagePolicy?: string; env?: { name: string; value?: string; @@ -121,12 +141,8 @@ export interface IPodContainer extends Partial & { +export type PodSpecVolume = RequireExactlyOne & { name: string; }; -export class Pod extends WorkloadKubeObject { + +export interface HostAlias { + ip: string; + hostnames: string[]; +} + +export interface SELinuxOptions { + level?: string; + role?: string; + type?: string; + user?: string; +} + +export interface SeccompProfile { + localhostProfile?: string; + type: string; +} + +export interface Sysctl { + name: string; + value: string; +} + +export interface WindowsSecurityContextOptions { + labelSelector?: LabelSelector; + maxSkew: number; + topologyKey: string; + whenUnsatisfiable: string; +} + +export interface PodSecurityContext { + fsGroup?: number; + fsGroupChangePolicy?: string; + runAsGroup?: number; + runAsNonRoot?: boolean; + runAsUser?: number; + seLinuxOptions?: SELinuxOptions; + seccompProfile?: SeccompProfile; + supplementalGroups?: number[]; + sysctls?: Sysctl; + windowsOptions?: WindowsSecurityContextOptions; +} + +export interface TopologySpreadConstraint { + +} + +export interface PodSpec { + activeDeadlineSeconds?: number; + affinity?: Affinity; + automountServiceAccountToken?: boolean; + containers?: IPodContainer[]; + dnsPolicy?: string; + enableServiceLinks?: boolean; + ephemeralContainers?: unknown[]; + hostAliases?: HostAlias[]; + hostIPC?: boolean; + hostname?: string; + hostNetwork?: boolean; + hostPID?: boolean; + imagePullSecrets?: LocalObjectReference[]; + initContainers?: IPodContainer[]; + nodeName?: string; + nodeSelector?: Record; + overhead?: Record; + preemptionPolicy?: string; + priority?: number; + priorityClassName?: string; + readinessGates?: unknown[]; + restartPolicy?: string; + runtimeClassName?: string; + schedulerName?: string; + securityContext?: PodSecurityContext; + serviceAccount?: string; + serviceAccountName?: string; + setHostnameAsFQDN?: boolean; + shareProcessNamespace?: boolean; + subdomain?: string; + terminationGracePeriodSeconds?: number; + tolerations?: Toleration[]; + topologySpreadConstraints?: TopologySpreadConstraint[]; + volumes?: PodSpecVolume[]; +} + +export interface PodCondition { + lastProbeTime?: number; + lastTransitionTime?: string; + message?: string; + reason?: string; + type: string; + status: string; +} + +export interface PodStatus { + phase: string; + conditions: PodCondition[]; + hostIP: string; + podIP: string; + podIPs?: { + ip: string; + }[]; + startTime: string; + initContainerStatuses?: IPodContainerStatus[]; + containerStatuses?: IPodContainerStatus[]; + qosClass?: string; + reason?: string; +} + +export class Pod extends KubeObject { static kind = "Pod"; static namespaced = true; static apiBase = "/api/v1/pods"; - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); + getAffinityNumber() { + return Object.keys(this.getAffinity()).length; } - declare spec?: { - volumes?: PodVolume[]; - initContainers: IPodContainer[]; - containers: IPodContainer[]; - restartPolicy?: string; - terminationGracePeriodSeconds?: number; - activeDeadlineSeconds?: number; - dnsPolicy?: string; - serviceAccountName: string; - serviceAccount: string; - automountServiceAccountToken?: boolean; - priority?: number; - priorityClassName?: string; - nodeName?: string; - nodeSelector?: { - [selector: string]: string; - }; - securityContext?: {}; - imagePullSecrets?: LocalObjectReference[]; - hostNetwork?: boolean; - hostPID?: boolean; - hostIPC?: boolean; - shareProcessNamespace?: boolean; - hostname?: string; - subdomain?: string; - schedulerName?: string; - tolerations?: { - key?: string; - operator?: string; - effect?: string; - tolerationSeconds?: number; - value?: string; - }[]; - hostAliases?: { - ip: string; - hostnames: string[]; - }; - affinity?: IAffinity; - }; - declare status?: { - phase: string; - conditions: { - type: string; - status: string; - lastProbeTime: number; - lastTransitionTime: string; - }[]; - hostIP: string; - podIP: string; - podIPs?: { - ip: string; - }[]; - startTime: string; - initContainerStatuses?: IPodContainerStatus[]; - containerStatuses?: IPodContainerStatus[]; - qosClass?: string; - reason?: string; - }; - getInitContainers() { - return this.spec?.initContainers || []; + return this.spec?.initContainers ?? []; } getContainers() { - return this.spec?.containers || []; + return this.spec?.containers ?? []; } getAllContainers() { @@ -719,7 +783,7 @@ export class Pod extends WorkloadKubeObject { getRunningContainers() { const runningContainerNames = new Set( this.getContainerStatuses() - .filter(({ state }) => state.running) + .filter(({ state }) => state?.running) .map(({ name }) => name), ); @@ -752,10 +816,10 @@ export class Pod extends WorkloadKubeObject { } getPriorityClassName() { - return this.spec.priorityClassName || ""; + return this.spec?.priorityClassName || ""; } - getStatus(): PodStatus { + getStatus(): PodStatusPhase { const phase = this.getStatusPhase(); const reason = this.getReason(); const trueConditionTypes = new Set(this.getConditions() @@ -763,28 +827,28 @@ export class Pod extends WorkloadKubeObject { .map(({ type }) => type)); const isInGoodCondition = ["Initialized", "Ready"].every(condition => trueConditionTypes.has(condition)); - if (reason === PodStatus.EVICTED) { - return PodStatus.EVICTED; + if (reason === PodStatusPhase.EVICTED) { + return PodStatusPhase.EVICTED; } - if (phase === PodStatus.FAILED) { - return PodStatus.FAILED; + if (phase === PodStatusPhase.FAILED) { + return PodStatusPhase.FAILED; } - if (phase === PodStatus.SUCCEEDED) { - return PodStatus.SUCCEEDED; + if (phase === PodStatusPhase.SUCCEEDED) { + return PodStatusPhase.SUCCEEDED; } - if (phase === PodStatus.RUNNING && isInGoodCondition) { - return PodStatus.RUNNING; + if (phase === PodStatusPhase.RUNNING && isInGoodCondition) { + return PodStatusPhase.RUNNING; } - return PodStatus.PENDING; + return PodStatusPhase.PENDING; } // Returns pod phase or container error if occurred getStatusMessage(): string { - if (this.getReason() === PodStatus.EVICTED) { + if (this.getReason() === PodStatusPhase.EVICTED) { return "Evicted"; } @@ -800,31 +864,30 @@ export class Pod extends WorkloadKubeObject { } getConditions() { - return this.status?.conditions || []; + return this.status?.conditions ?? []; } getVolumes() { - return this.spec.volumes || []; + return this.spec?.volumes ?? []; } getSecrets(): string[] { return this.getVolumes() - .filter(vol => vol.secret) - .map(vol => vol.secret.secretName); + .map(vol => vol.secret?.secretName) + .filter(isDefined); } getNodeSelectors(): string[] { - const { nodeSelector = {}} = this.spec; - - return Object.entries(nodeSelector).map(values => values.join(": ")); + return Object.entries(this.spec?.nodeSelector ?? {}) + .map(values => values.join(": ")); } getTolerations() { - return this.spec.tolerations || []; + return this.spec?.tolerations ?? []; } - getAffinity(): IAffinity { - return this.spec.affinity; + getAffinity(): Affinity { + return this.spec?.affinity ?? {}; } hasIssues() { @@ -907,30 +970,21 @@ export class Pod extends WorkloadKubeObject { return probe; } - getNodeName() { - return this.spec.nodeName; + getNodeName(): string | undefined { + return this.spec?.nodeName; } getSelectedNodeOs(): string | undefined { - return this.spec.nodeSelector?.["kubernetes.io/os"] || this.spec.nodeSelector?.["beta.kubernetes.io/os"]; + return this.spec?.nodeSelector?.["kubernetes.io/os"] || this.spec?.nodeSelector?.["beta.kubernetes.io/os"]; } getIPs(): string[] { - if(!this.status.podIPs) return []; - const podIPs = this.status.podIPs; + const podIPs = this.status?.podIPs ?? []; return podIPs.map(value => value.ip); } } -let podsApi: PodsApi; - -if (isClusterPageContext()) { - podsApi = new PodsApi({ - objectConstructor: Pod, - }); -} - -export { - podsApi, -}; +export const podApi = isClusterPageContext() + ? new PodApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/podsecuritypolicy.api.ts b/src/common/k8s-api/endpoints/podsecuritypolicy.api.ts index 9220d65e77..65b1e68bfc 100644 --- a/src/common/k8s-api/endpoints/podsecuritypolicy.api.ts +++ b/src/common/k8s-api/endpoints/podsecuritypolicy.api.ts @@ -3,83 +3,87 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { autoBind } from "../../utils"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -export interface PodSecurityPolicy { - spec: { - allowPrivilegeEscalation?: boolean; - allowedCSIDrivers?: { - name: string; - }[]; - allowedCapabilities: string[]; - allowedFlexVolumes?: { - driver: string; - }[]; - allowedHostPaths?: { - pathPrefix: string; - readOnly: boolean; - }[]; - allowedProcMountTypes?: string[]; - allowedUnsafeSysctls?: string[]; - defaultAddCapabilities?: string[]; - defaultAllowPrivilegeEscalation?: boolean; - forbiddenSysctls?: string[]; - fsGroup?: { - rule: string; - ranges: { max: number; min: number }[]; - }; - hostIPC?: boolean; - hostNetwork?: boolean; - hostPID?: boolean; - hostPorts?: { +export interface PodSecurityPolicySpec { + allowPrivilegeEscalation?: boolean; + allowedCSIDrivers?: { + name: string; + }[]; + allowedCapabilities: string[]; + allowedFlexVolumes?: { + driver: string; + }[]; + allowedHostPaths?: { + pathPrefix: string; + readOnly: boolean; + }[]; + allowedProcMountTypes?: string[]; + allowedUnsafeSysctls?: string[]; + defaultAddCapabilities?: string[]; + defaultAllowPrivilegeEscalation?: boolean; + forbiddenSysctls?: string[]; + fsGroup?: { + rule: string; + ranges: { max: number; min: number; }[]; - privileged?: boolean; - readOnlyRootFilesystem?: boolean; - requiredDropCapabilities?: string[]; - runAsGroup?: { - ranges: { max: number; min: number }[]; - rule: string; - }; - runAsUser?: { - rule: string; - ranges: { max: number; min: number }[]; - }; - runtimeClass?: { - allowedRuntimeClassNames: string[]; - defaultRuntimeClassName: string; - }; - seLinux?: { - rule: string; - seLinuxOptions: { - level: string; - role: string; - type: string; - user: string; - }; - }; - supplementalGroups?: { - rule: string; - ranges: { max: number; min: number }[]; - }; - volumes?: string[]; }; + hostIPC?: boolean; + hostNetwork?: boolean; + hostPID?: boolean; + hostPorts?: { + max: number; + min: number; + }[]; + privileged?: boolean; + readOnlyRootFilesystem?: boolean; + requiredDropCapabilities?: string[]; + runAsGroup?: { + ranges: { + max: number; + min: number; + }[]; + rule: string; + }; + runAsUser?: { + rule: string; + ranges: { + max: number; + min: number; + }[]; + }; + runtimeClass?: { + allowedRuntimeClassNames: string[]; + defaultRuntimeClassName: string; + }; + seLinux?: { + rule: string; + seLinuxOptions: { + level: string; + role: string; + type: string; + user: string; + }; + }; + supplementalGroups?: { + rule: string; + ranges: { + max: number; + min: number; + }[]; + }; + volumes?: string[]; } -export class PodSecurityPolicy extends KubeObject { - static kind = "PodSecurityPolicy"; - static namespaced = false; - static apiBase = "/apis/policy/v1beta1/podsecuritypolicies"; - - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } +export class PodSecurityPolicy extends KubeObject { + static readonly kind = "PodSecurityPolicy"; + static readonly namespaced = false; + static readonly apiBase = "/apis/policy/v1beta1/podsecuritypolicies"; isPrivileged() { return !!this.spec.privileged; @@ -102,14 +106,16 @@ export class PodSecurityPolicy extends KubeObject { } } -let pspApi: KubeApi; +export class PodSecurityPolicyApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: PodSecurityPolicy, -if (isClusterPageContext()) { - pspApi = new KubeApi({ - objectConstructor: PodSecurityPolicy, - }); + }); + } } -export { - pspApi, -}; +export const podSecurityPolicyApi = isClusterPageContext() + ? new PodSecurityPolicyApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/replica-set.api.ts b/src/common/k8s-api/endpoints/replica-set.api.ts index 302ad96b17..1a11523d9b 100644 --- a/src/common/k8s-api/endpoints/replica-set.api.ts +++ b/src/common/k8s-api/endpoints/replica-set.api.ts @@ -3,25 +3,31 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import get from "lodash/get"; -import { autoBind } from "../../../renderer/utils"; -import { WorkloadKubeObject } from "../workload-kube-object"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { metricsApi } from "./metrics.api"; -import type { IPodContainer, IPodMetrics, Pod } from "./pods.api"; -import type { KubeJsonApiData } from "../kube-json-api"; +import type { PodMetricData } from "./pods.api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -import type { LabelSelector } from "../kube-object"; +import type { KubeObjectStatus, LabelSelector } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { PodTemplateSpec } from "./types/pod-template-spec"; export class ReplicaSetApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: ReplicaSet, + }); + } + protected getScaleApiUrl(params: { namespace: string; name: string }) { return `${this.getUrl(params)}/scale`; } - getReplicas(params: { namespace: string; name: string }): Promise { - return this.request - .get(this.getScaleApiUrl(params)) - .then(({ status }: any) => status?.replicas); + async getReplicas(params: { namespace: string; name: string }): Promise { + const { status } = await this.request.get(this.getScaleApiUrl(params)); + + return (status as { replicas: number })?.replicas; } scale(params: { namespace: string; name: string }, replicas: number) { @@ -36,7 +42,7 @@ export class ReplicaSetApi extends KubeApi { } } -export function getMetricsForReplicaSets(replicasets: ReplicaSet[], namespace: string, selector = ""): Promise { +export function getMetricsForReplicaSets(replicasets: ReplicaSet[], namespace: string, selector = ""): Promise { const podSelector = replicasets.map(replicaset => `${replicaset.getName()}-[[:alnum:]]{5}`).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -53,72 +59,69 @@ export function getMetricsForReplicaSets(replicasets: ReplicaSet[], namespace: s }); } -export class ReplicaSet extends WorkloadKubeObject { +export interface ReplicaSetSpec { + replicas?: number; + selector: LabelSelector; + template?: PodTemplateSpec; + minReadySeconds?: number; +} + +export interface ReplicaSetStatus extends KubeObjectStatus { + replicas: number; + fullyLabeledReplicas?: number; + readyReplicas?: number; + availableReplicas?: number; + observedGeneration?: number; +} + +export class ReplicaSet extends KubeObject { static kind = "ReplicaSet"; static namespaced = true; static apiBase = "/apis/apps/v1/replicasets"; - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); + getSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.selector.matchLabels); } - declare spec: { - replicas?: number; - selector: LabelSelector; - template?: { - metadata: { - labels: { - app: string; - }; - }; - spec?: Pod["spec"]; - }; - minReadySeconds?: number; - }; - declare status: { - replicas: number; - fullyLabeledReplicas?: number; - readyReplicas?: number; - availableReplicas?: number; - observedGeneration?: number; - conditions?: { - type: string; - status: string; - lastUpdateTime: string; - lastTransitionTime: string; - reason: string; - message: string; - }[]; - }; + getNodeSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.template?.spec?.nodeSelector); + } + + getTemplateLabels(): string[] { + return KubeObject.stringifyLabels(this.spec.template?.metadata?.labels); + } + + getTolerations() { + return this.spec.template?.spec?.tolerations ?? []; + } + + getAffinity() { + return this.spec.template?.spec?.affinity; + } + + getAffinityNumber() { + return Object.keys(this.getAffinity() ?? {}).length; + } getDesired() { - return this.spec.replicas || 0; + return this.spec.replicas ?? 0; } getCurrent() { - return this.status.availableReplicas || 0; + return this.status?.availableReplicas ?? 0; } getReady() { - return this.status.readyReplicas || 0; + return this.status?.readyReplicas ?? 0; } getImages() { - const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); + const containers = this.spec.template?.spec?.containers ?? []; - return [...containers].map(container => container.image); + return containers.map(container => container.image); } } -let replicaSetApi: ReplicaSetApi; - -if (isClusterPageContext()) { - replicaSetApi = new ReplicaSetApi({ - objectConstructor: ReplicaSet, - }); -} - -export { - replicaSetApi, -}; +export const replicaSetApi = isClusterPageContext() + ? new ReplicaSetApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/resource-applier.api.ts b/src/common/k8s-api/endpoints/resource-applier.api.ts index 0bc5d4e551..9f0c3bb6cd 100644 --- a/src/common/k8s-api/endpoints/resource-applier.api.ts +++ b/src/common/k8s-api/endpoints/resource-applier.api.ts @@ -16,7 +16,7 @@ export async function update(resource: object | string): Promise("/stack", { data: resource }); } -export async function patch(name: string, kind: string, ns: string, patch: Patch): Promise { +export async function patch(name: string, kind: string, ns: string | undefined, patch: Patch): Promise { return apiBase.patch("/stack", { data: { name, diff --git a/src/common/k8s-api/endpoints/resource-quota.api.ts b/src/common/k8s-api/endpoints/resource-quota.api.ts index eda54a7249..1b984043e9 100644 --- a/src/common/k8s-api/endpoints/resource-quota.api.ts +++ b/src/common/k8s-api/endpoints/resource-quota.api.ts @@ -4,12 +4,11 @@ */ import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -export interface IResourceQuotaValues { - [quota: string]: string; - +export type IResourceQuotaValues = Partial> & { // Compute Resource Quota "limits.cpu"?: string; "limits.memory"?: string; @@ -33,46 +32,43 @@ export interface IResourceQuotaValues { "count/jobs.batch"?: string; "count/cronjobs.batch"?: string; "count/deployments.extensions"?: string; -} +}; -export interface ResourceQuota { - spec: { - hard: IResourceQuotaValues; - scopeSelector?: { - matchExpressions: { - operator: string; - scopeName: string; - values: string[]; - }[]; - }; - }; - - status: { - hard: IResourceQuotaValues; - used: IResourceQuotaValues; +export interface ResourceQuotaSpec { + hard: IResourceQuotaValues; + scopeSelector?: { + matchExpressions: { + operator: string; + scopeName: string; + values: string[]; + }[]; }; } -export class ResourceQuota extends KubeObject { - static kind = "ResourceQuota"; - static namespaced = true; - static apiBase = "/api/v1/resourcequotas"; +export interface ResourceQuotaStatus { + hard: IResourceQuotaValues; + used: IResourceQuotaValues; +} + +export class ResourceQuota extends KubeObject { + static readonly kind = "ResourceQuota"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/resourcequotas"; getScopeSelector() { - const { matchExpressions = [] } = this.spec.scopeSelector || {}; - - return matchExpressions; + return this.spec.scopeSelector?.matchExpressions ?? []; } } -let resourceQuotaApi: KubeApi; - -if (isClusterPageContext()) { - resourceQuotaApi = new KubeApi({ - objectConstructor: ResourceQuota, - }); +export class ResourceQuotaApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + objectConstructor: ResourceQuota, + ...opts, + }); + } } -export { - resourceQuotaApi, -}; +export const resourceQuotaApi = isClusterPageContext() + ? new ResourceQuotaApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/role-binding.api.ts b/src/common/k8s-api/endpoints/role-binding.api.ts index dca2510dd3..4b2cec4c62 100644 --- a/src/common/k8s-api/endpoints/role-binding.api.ts +++ b/src/common/k8s-api/endpoints/role-binding.api.ts @@ -3,38 +3,32 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { autoBind } from "../../utils"; +import type { KubeObjectMetadata } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { RoleRef } from "./types/role-ref"; +import type { Subject } from "./types/subject"; -export type RoleBindingSubjectKind = "Group" | "ServiceAccount" | "User"; - -export interface RoleBindingSubject { - kind: RoleBindingSubjectKind; - name: string; - namespace?: string; - apiGroup?: string; +export interface RoleBindingData extends KubeJsonApiData, void, void> { + subjects?: Subject[]; + roleRef: RoleRef; } -export interface RoleBinding { - subjects?: RoleBindingSubject[]; - roleRef: { - kind: string; - name: string; - apiGroup?: string; - }; -} +export class RoleBinding extends KubeObject { + static readonly kind = "RoleBinding"; + static readonly namespaced = true; + static readonly apiBase = "/apis/rbac.authorization.k8s.io/v1/rolebindings"; -export class RoleBinding extends KubeObject { - static kind = "RoleBinding"; - static namespaced = true; - static apiBase = "/apis/rbac.authorization.k8s.io/v1/rolebindings"; + subjects?: Subject[]; + roleRef: RoleRef; - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); + constructor({ subjects, roleRef, ...rest }: RoleBindingData) { + super(rest); + this.subjects = subjects; + this.roleRef = roleRef; } getSubjects() { @@ -46,14 +40,15 @@ export class RoleBinding extends KubeObject { } } -let roleBindingApi: KubeApi; - -if (isClusterPageContext()) { - roleBindingApi = new KubeApi({ - objectConstructor: RoleBinding, - }); +export class RoleBindingApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: RoleBinding, + }); + } } -export { - roleBindingApi, -}; +export const roleBindingApi = isClusterPageContext() + ? new RoleBindingApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/role.api.ts b/src/common/k8s-api/endpoints/role.api.ts index ee960bb934..673fdaed41 100644 --- a/src/common/k8s-api/endpoints/role.api.ts +++ b/src/common/k8s-api/endpoints/role.api.ts @@ -3,37 +3,43 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { KubeObjectMetadata } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { KubeJsonApiData } from "../kube-json-api"; +import type { PolicyRule } from "./types/policy-rule"; -export interface Role { - rules: { - verbs: string[]; - apiGroups: string[]; - resources: string[]; - resourceNames?: string[]; - }[]; +export interface RoleData extends KubeJsonApiData, void, void> { + rules?: PolicyRule[]; } -export class Role extends KubeObject { - static kind = "Role"; - static namespaced = true; - static apiBase = "/apis/rbac.authorization.k8s.io/v1/roles"; +export class Role extends KubeObject { + static readonly kind = "Role"; + static readonly namespaced = true; + static readonly apiBase = "/apis/rbac.authorization.k8s.io/v1/roles"; + rules?: PolicyRule[]; + + constructor({ rules, ...rest }: RoleData) { + super(rest); + this.rules = rules; + } getRules() { return this.rules || []; } } -let roleApi: KubeApi; - -if (isClusterPageContext()) { - roleApi = new KubeApi({ - objectConstructor: Role, - }); +export class RoleApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: Role, + }); + } } -export{ - roleApi, -}; +export const roleApi = isClusterPageContext() + ? new RoleApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/secret.api.ts b/src/common/k8s-api/endpoints/secret.api.ts index 93953ebf48..437926ec15 100644 --- a/src/common/k8s-api/endpoints/secret.api.ts +++ b/src/common/k8s-api/endpoints/secret.api.ts @@ -3,9 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { KubeObjectMetadata } from "../kube-object"; import { KubeObject } from "../kube-object"; import type { KubeJsonApiData } from "../kube-json-api"; import { autoBind } from "../../utils"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; @@ -20,34 +22,30 @@ export enum SecretType { BootstrapToken = "bootstrap.kubernetes.io/token", } -export interface ISecretRef { - key?: string; - name: string; -} - export interface SecretReference { name: string; namespace?: string; } -export interface SecretData extends KubeJsonApiData { +export interface SecretData extends KubeJsonApiData, void, void> { type: SecretType; - data?: Record; + data?: Partial>; } -export class Secret extends KubeObject { - static kind = "Secret"; - static namespaced = true; - static apiBase = "/api/v1/secrets"; +export class Secret extends KubeObject { + static readonly kind = "Secret"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/secrets"; - declare type: SecretType; - declare data: Record; + type: SecretType; + data: Partial>; - constructor(data: SecretData) { - super(data); + constructor({ data = {}, type, ...rest }: SecretData) { + super(rest); autoBind(this); - this.data ??= {}; + this.data = data; + this.type = type; } getKeys(): string[] { @@ -59,14 +57,15 @@ export class Secret extends KubeObject { } } -let secretsApi: KubeApi; - -if (isClusterPageContext()) { - secretsApi = new KubeApi({ - objectConstructor: Secret, - }); +export class SecretApi extends KubeApi { + constructor(options: DerivedKubeApiOptions = {}) { + super({ + ...options, + objectConstructor: Secret, + }); + } } -export { - secretsApi, -}; +export const secretApi = isClusterPageContext() + ? new SecretApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/selfsubjectrulesreviews.api.ts b/src/common/k8s-api/endpoints/selfsubjectrulesreviews.api.ts index c83271b47a..eb7ee50735 100644 --- a/src/common/k8s-api/endpoints/selfsubjectrulesreviews.api.ts +++ b/src/common/k8s-api/endpoints/selfsubjectrulesreviews.api.ts @@ -8,7 +8,7 @@ import { KubeApi } from "../kube-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export class SelfSubjectRulesReviewApi extends KubeApi { - create({ namespace = "default" }): Promise { + create({ namespace = "default" }) { return super.create({}, { spec: { namespace, diff --git a/src/common/k8s-api/endpoints/service-accounts.api.ts b/src/common/k8s-api/endpoints/service-accounts.api.ts index 02cdd453d8..e26ce42c67 100644 --- a/src/common/k8s-api/endpoints/service-accounts.api.ts +++ b/src/common/k8s-api/endpoints/service-accounts.api.ts @@ -3,29 +3,38 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { autoBind } from "../../utils"; +import type { KubeObjectMetadata, LocalObjectReference, ObjectReference } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -export interface ServiceAccount { - secrets?: { - name: string; - }[]; - imagePullSecrets?: { - name: string; - }[]; +export interface ServiceAccountData extends KubeJsonApiData, void, void> { + automountServiceAccountToken?: boolean; + imagePullSecrets?: LocalObjectReference[]; + secrets?: ObjectReference[]; } -export class ServiceAccount extends KubeObject { - static kind = "ServiceAccount"; - static namespaced = true; - static apiBase = "/api/v1/serviceaccounts"; +export class ServiceAccount extends KubeObject { + static readonly kind = "ServiceAccount"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/serviceaccounts"; - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); + automountServiceAccountToken?: boolean; + imagePullSecrets?: LocalObjectReference[]; + secrets?: ObjectReference[]; + + constructor({ + automountServiceAccountToken, + imagePullSecrets, + secrets, + ...rest + }: ServiceAccountData) { + super(rest); + this.automountServiceAccountToken = automountServiceAccountToken; + this.imagePullSecrets = imagePullSecrets; + this.secrets = secrets; } getSecrets() { @@ -37,14 +46,15 @@ export class ServiceAccount extends KubeObject { } } -let serviceAccountsApi: KubeApi; - -if (isClusterPageContext()) { - serviceAccountsApi = new KubeApi({ - objectConstructor: ServiceAccount, - }); +export class ServiceAccountApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: ServiceAccount, + }); + } } -export { - serviceAccountsApi, -}; +export const serviceAccountApi = isClusterPageContext() + ? new ServiceAccountApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/service.api.ts b/src/common/k8s-api/endpoints/service.api.ts index 6a3eb5ba7b..2849eba554 100644 --- a/src/common/k8s-api/endpoints/service.api.ts +++ b/src/common/k8s-api/endpoints/service.api.ts @@ -3,10 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { autoBind } from "../../../renderer/utils"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; -import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export interface ServicePort { @@ -31,47 +30,40 @@ export class ServicePort { } } -export interface Service { - spec: { - type: string; - clusterIP: string; - clusterIPs?: string[]; - externalTrafficPolicy?: string; - externalName?: string; - loadBalancerIP?: string; - loadBalancerSourceRanges?: string[]; - sessionAffinity: string; - selector: { [key: string]: string }; - ports: ServicePort[]; - healthCheckNodePort?: number; - externalIPs?: string[]; // https://kubernetes.io/docs/concepts/services-networking/service/#external-ips - topologyKeys?: string[]; - ipFamilies?: string[]; - ipFamilyPolicy?: string; - allocateLoadBalancerNodePorts?: boolean; - loadBalancerClass?: string; - internalTrafficPolicy?: string; - }; +export interface ServiceSpec { + type: string; + clusterIP: string; + clusterIPs?: string[]; + externalTrafficPolicy?: string; + externalName?: string; + loadBalancerIP?: string; + loadBalancerSourceRanges?: string[]; + sessionAffinity: string; + selector: Partial>; + ports: ServicePort[]; + healthCheckNodePort?: number; + externalIPs?: string[]; // https://kubernetes.io/docs/concepts/services-networking/service/#external-ips + topologyKeys?: string[]; + ipFamilies?: string[]; + ipFamilyPolicy?: string; + allocateLoadBalancerNodePorts?: boolean; + loadBalancerClass?: string; + internalTrafficPolicy?: string; +} - status: { - loadBalancer?: { - ingress?: { - ip?: string; - hostname?: string; - }[]; - }; +export interface ServiceStatus { + loadBalancer?: { + ingress?: { + ip?: string; + hostname?: string; + }[]; }; } -export class Service extends KubeObject { - static kind = "Service"; - static namespaced = true; - static apiBase = "/api/v1/services"; - - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); - } +export class Service extends KubeObject { + static readonly kind = "Service"; + static readonly namespaced = true; + static readonly apiBase = "/api/v1/services"; getClusterIp() { return this.spec.clusterIP; @@ -112,7 +104,7 @@ export class Service extends KubeObject { } getLoadBalancer() { - return this.status.loadBalancer; + return this.status?.loadBalancer; } isActive() { @@ -132,14 +124,15 @@ export class Service extends KubeObject { } } -let serviceApi: KubeApi; - -if (isClusterPageContext()) { - serviceApi = new KubeApi({ - objectConstructor: Service, - }); +export class ServiceApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: Service, + }); + } } -export { - serviceApi, -}; +export const serviceApi = isClusterPageContext() + ? new ServiceApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/stateful-set.api.ts b/src/common/k8s-api/endpoints/stateful-set.api.ts index b388ca8d44..3d000f1c5e 100644 --- a/src/common/k8s-api/endpoints/stateful-set.api.ts +++ b/src/common/k8s-api/endpoints/stateful-set.api.ts @@ -3,17 +3,24 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { IAffinity } from "../workload-kube-object"; -import { WorkloadKubeObject } from "../workload-kube-object"; -import { autoBind } from "../../utils"; +import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import { metricsApi } from "./metrics.api"; -import type { IPodMetrics } from "./pods.api"; -import type { KubeJsonApiData } from "../kube-json-api"; +import type { PodMetricData } from "./pods.api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; import type { LabelSelector } from "../kube-object"; +import { KubeObject } from "../kube-object"; +import type { PodTemplateSpec } from "./types/pod-template-spec"; +import type { PersistentVolumeClaimTemplateSpec } from "./types/persistent-volume-claim-template-spec"; export class StatefulSetApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions & IgnoredKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: StatefulSet, + }); + } + protected getScaleApiUrl(params: { namespace: string; name: string }) { return `${this.getUrl(params)}/scale`; } @@ -40,7 +47,7 @@ export class StatefulSetApi extends KubeApi { } } -export function getMetricsForStatefulSets(statefulSets: StatefulSet[], namespace: string, selector = ""): Promise { +export function getMetricsForStatefulSets(statefulSets: StatefulSet[], namespace: string, selector = ""): Promise { const podSelector = statefulSets.map(statefulset => `${statefulset.getName()}-[[:digit:]]+`).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -57,74 +64,52 @@ export function getMetricsForStatefulSets(statefulSets: StatefulSet[], namespace }); } -export class StatefulSet extends WorkloadKubeObject { - static kind = "StatefulSet"; - static namespaced = true; - static apiBase = "/apis/apps/v1/statefulsets"; +export interface StatefulSetSpec { + serviceName: string; + replicas: number; + selector: LabelSelector; + template: PodTemplateSpec; + volumeClaimTemplates: PersistentVolumeClaimTemplateSpec[]; +} - constructor(data: KubeJsonApiData) { - super(data); - autoBind(this); +export interface StatefulSetStatus { + observedGeneration: number; + replicas: number; + currentReplicas: number; + readyReplicas: number; + currentRevision: string; + updateRevision: string; + collisionCount: number; +} + +export class StatefulSet extends KubeObject { + static readonly kind = "StatefulSet"; + static readonly namespaced = true; + static readonly apiBase = "/apis/apps/v1/statefulsets"; + + getSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.selector.matchLabels); } - declare spec: { - serviceName: string; - replicas: number; - selector: LabelSelector; - template: { - metadata: { - labels: { - app: string; - }; - }; - spec: { - containers: null | { - name: string; - image: string; - ports: { - containerPort: number; - name: string; - }[]; - volumeMounts: { - name: string; - mountPath: string; - }[]; - }[]; - affinity?: IAffinity; - nodeSelector?: { - [selector: string]: string; - }; - tolerations: { - key: string; - operator: string; - effect: string; - tolerationSeconds: number; - }[]; - }; - }; - volumeClaimTemplates: { - metadata: { - name: string; - }; - spec: { - accessModes: string[]; - resources: { - requests: { - storage: string; - }; - }; - }; - }[]; - }; - declare status: { - observedGeneration: number; - replicas: number; - currentReplicas: number; - readyReplicas: number; - currentRevision: string; - updateRevision: string; - collisionCount: number; - }; + getNodeSelectors(): string[] { + return KubeObject.stringifyLabels(this.spec.template.spec?.nodeSelector); + } + + getTemplateLabels(): string[] { + return KubeObject.stringifyLabels(this.spec.template.metadata?.labels); + } + + getTolerations() { + return this.spec.template.spec?.tolerations ?? []; + } + + getAffinity() { + return this.spec.template.spec?.affinity ?? {}; + } + + getAffinityNumber() { + return Object.keys(this.getAffinity()).length; + } getReplicas() { return this.spec.replicas || 0; @@ -137,14 +122,6 @@ export class StatefulSet extends WorkloadKubeObject { } } -let statefulSetApi: StatefulSetApi; - -if (isClusterPageContext()) { - statefulSetApi = new StatefulSetApi({ - objectConstructor: StatefulSet, - }); -} - -export { - statefulSetApi, -}; +export const statefulSetApi = isClusterPageContext() + ? new StatefulSetApi() + : undefined as never; diff --git a/src/common/k8s-api/endpoints/storage-class.api.ts b/src/common/k8s-api/endpoints/storage-class.api.ts index c01c89f5b7..46f62fb3ee 100644 --- a/src/common/k8s-api/endpoints/storage-class.api.ts +++ b/src/common/k8s-api/endpoints/storage-class.api.ts @@ -4,29 +4,64 @@ */ import { autoBind } from "../../utils"; +import type { KubeObjectMetadata } from "../kube-object"; import { KubeObject } from "../kube-object"; +import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -export interface StorageClass { - provisioner: string; // e.g. "storage.k8s.io/v1" - mountOptions?: string[]; - volumeBindingMode: string; - reclaimPolicy: string; - parameters: { - [param: string]: string; // every provisioner has own set of these parameters - }; +export interface TopologySelectorLabelRequirement { + key: string; + values: string[]; } -export class StorageClass extends KubeObject { - static kind = "StorageClass"; - static namespaced = false; - static apiBase = "/apis/storage.k8s.io/v1/storageclasses"; +export interface TopologySelectorTerm { + matchLabelExpressions?: TopologySelectorLabelRequirement[]; +} - constructor(data: KubeJsonApiData) { - super(data); +export interface StorageClassData extends KubeJsonApiData, void, void> { + allowVolumeExpansion?: boolean; + allowedTopologies?: TopologySelectorTerm[]; + mountOptions?: string[]; + parameters?: Partial>; + provisioner: string; + reclaimPolicy?: string; + volumeBindingMode?: string; +} + +export class StorageClass extends KubeObject { + static readonly kind = "StorageClass"; + static readonly namespaced = false; + static readonly apiBase = "/apis/storage.k8s.io/v1/storageclasses"; + + allowVolumeExpansion?: boolean; + allowedTopologies: TopologySelectorTerm[]; + mountOptions: string[]; + parameters: Partial>; + provisioner: string; + reclaimPolicy: string; + volumeBindingMode?: string; + + constructor({ + allowVolumeExpansion, + allowedTopologies = [], + mountOptions = [], + parameters = {}, + provisioner, + reclaimPolicy = "Delete", + volumeBindingMode, + ...rest + }: StorageClassData) { + super(rest); autoBind(this); + this.allowVolumeExpansion = allowVolumeExpansion; + this.allowedTopologies = allowedTopologies; + this.mountOptions = mountOptions; + this.parameters = parameters; + this.provisioner = provisioner; + this.reclaimPolicy = reclaimPolicy; + this.volumeBindingMode = volumeBindingMode; } isDefault() { @@ -47,14 +82,15 @@ export class StorageClass extends KubeObject { } } -let storageClassApi: KubeApi; - -if (isClusterPageContext()) { - storageClassApi = new KubeApi({ - objectConstructor: StorageClass, - }); +export class StorageClassApi extends KubeApi { + constructor(opts: DerivedKubeApiOptions = {}) { + super({ + ...opts, + objectConstructor: StorageClass, + }); + } } -export { - storageClassApi, -}; +export const storageClassApi = isClusterPageContext() + ? new StorageClassApi() + : undefined as never; diff --git a/src/common/utils/cloneJson.ts b/src/common/k8s-api/endpoints/types/aggregation-rule.ts similarity index 53% rename from src/common/utils/cloneJson.ts rename to src/common/k8s-api/endpoints/types/aggregation-rule.ts index da29062b43..57ebe32264 100644 --- a/src/common/utils/cloneJson.ts +++ b/src/common/k8s-api/endpoints/types/aggregation-rule.ts @@ -3,8 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -// Clone json-serializable object +import type { LabelSelector } from "../../kube-object"; -export function cloneJsonObject(obj: T): T { - return JSON.parse(JSON.stringify(obj)); +export interface AggregationRule { + clusterRoleSelectors?: LabelSelector; } diff --git a/src/common/k8s-api/endpoints/types/job-template-spec.ts b/src/common/k8s-api/endpoints/types/job-template-spec.ts new file mode 100644 index 0000000000..e842101a62 --- /dev/null +++ b/src/common/k8s-api/endpoints/types/job-template-spec.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeTemplateObjectMetadata } from "../../kube-object"; +import type { JobSpec } from "../job.api"; + +export interface JobTemplateSpec { + metadata?: KubeTemplateObjectMetadata<"namespace-scoped">; + spec?: JobSpec; +} diff --git a/src/common/k8s-api/endpoints/types/persistent-volume-claim-template-spec.ts b/src/common/k8s-api/endpoints/types/persistent-volume-claim-template-spec.ts new file mode 100644 index 0000000000..8b55c45574 --- /dev/null +++ b/src/common/k8s-api/endpoints/types/persistent-volume-claim-template-spec.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeTemplateObjectMetadata } from "../../kube-object"; +import type { PersistentVolumeSpec } from "../persistent-volume.api"; + +export interface PersistentVolumeClaimTemplateSpec { + metadata?: KubeTemplateObjectMetadata<"cluster-scoped">; + spec?: PersistentVolumeSpec; +} diff --git a/src/common/k8s-api/endpoints/types/pod-template-spec.ts b/src/common/k8s-api/endpoints/types/pod-template-spec.ts new file mode 100644 index 0000000000..75549c01d9 --- /dev/null +++ b/src/common/k8s-api/endpoints/types/pod-template-spec.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeTemplateObjectMetadata } from "../../kube-object"; +import type { PodSpec } from "../pods.api"; + +export interface PodTemplateSpec { + metadata?: KubeTemplateObjectMetadata<"namespace-scoped">; + spec?: PodSpec; +} diff --git a/src/common/k8s-api/endpoints/types/policy-rule.ts b/src/common/k8s-api/endpoints/types/policy-rule.ts new file mode 100644 index 0000000000..b84e556223 --- /dev/null +++ b/src/common/k8s-api/endpoints/types/policy-rule.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface PolicyRule { + verbs: string[]; + apiGroups?: string[]; + resources?: string[]; + resourceNames?: string[]; + nonResourceURLs?: string[]; +} diff --git a/src/common/k8s-api/endpoints/types/resource-requirements.ts b/src/common/k8s-api/endpoints/types/resource-requirements.ts new file mode 100644 index 0000000000..eb3b6caffa --- /dev/null +++ b/src/common/k8s-api/endpoints/types/resource-requirements.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * ResourceRequirements describes the compute resource requirements. + */ +export interface ResourceRequirements { + /** + * Limits describes the maximum amount of compute resources allowed. + * + * More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + */ + limits?: Partial>; + + /** + * Requests describes the minimum amount of compute resources required. If Requests is omitted + * for a container, it defaults to Limits if that is explicitly specified, otherwise to an + * implementation-defined value. + * + * More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + */ + requests?: Partial>; +} diff --git a/src/preload.ts b/src/common/k8s-api/endpoints/types/role-ref.ts similarity index 64% rename from src/preload.ts rename to src/common/k8s-api/endpoints/types/role-ref.ts index 64188789ea..60e9bdda77 100644 --- a/src/preload.ts +++ b/src/common/k8s-api/endpoints/types/role-ref.ts @@ -3,8 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import fetch from "node-fetch"; - -export { - fetch, -}; +export interface RoleRef { + apiGroup: string; + kind: string; + name: string; +} diff --git a/src/common/k8s-api/endpoints/types/subject.ts b/src/common/k8s-api/endpoints/types/subject.ts new file mode 100644 index 0000000000..ba4e37f1a4 --- /dev/null +++ b/src/common/k8s-api/endpoints/types/subject.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export type SubjectKind = "Group" | "ServiceAccount" | "User"; + +export interface Subject { + apiGroup?: string; + kind: SubjectKind; + name: string; + namespace?: string; +} diff --git a/src/common/k8s-api/json-api.ts b/src/common/k8s-api/json-api.ts index 41180df078..386cbe52f8 100644 --- a/src/common/k8s-api/json-api.ts +++ b/src/common/k8s-api/json-api.ts @@ -13,6 +13,8 @@ import fetch from "node-fetch"; import { stringify } from "querystring"; import { EventEmitter } from "../../common/event-emitter"; import logger from "../../common/logger"; +import type { Defaulted } from "../utils"; +import { json } from "../utils"; export interface JsonApiData {} @@ -35,38 +37,40 @@ export interface JsonApiLog { error?: any; } +export type GetRequestOptions = () => Promise; + export interface JsonApiConfig { apiBase: string; serverAddress: string; debug?: boolean; - getRequestOptions?: () => Promise; + getRequestOptions?: GetRequestOptions; } const httpAgent = new HttpAgent({ keepAlive: true }); const httpsAgent = new HttpsAgent({ keepAlive: true }); export class JsonApi { - static reqInitDefault: RequestInit = { + static readonly reqInitDefault = { headers: { "content-type": "application/json", }, }; + protected readonly reqInit: Defaulted; - static configDefault: Partial = { + static readonly configDefault: Partial = { debug: false, }; - constructor(public readonly config: JsonApiConfig, protected reqInit?: RequestInit) { + constructor(public readonly config: JsonApiConfig, reqInit?: RequestInit) { this.config = Object.assign({}, JsonApi.configDefault, config); this.reqInit = merge({}, JsonApi.reqInitDefault, reqInit); this.parseResponse = this.parseResponse.bind(this); this.getRequestOptions = config.getRequestOptions ?? (() => Promise.resolve({})); } - public onData = new EventEmitter<[D, Response]>(); - public onError = new EventEmitter<[JsonApiErrorParsed, Response]>(); - - private getRequestOptions: JsonApiConfig["getRequestOptions"]; + public readonly onData = new EventEmitter<[D, Response]>(); + public readonly onError = new EventEmitter<[JsonApiErrorParsed, Response]>(); + private readonly getRequestOptions: GetRequestOptions; get(path: string, params?: P, reqInit: RequestInit = {}) { return this.request(path, params, { ...reqInit, method: "get" }); @@ -74,22 +78,17 @@ export class JsonApi { async getResponse(path: string, params?: P, init: RequestInit = {}): Promise { let reqUrl = `${this.config.serverAddress}${this.config.apiBase}${path}`; - const reqInit: RequestInit = merge( - {}, + const reqInit = merge( + { + method: "get", + agent: reqUrl.startsWith("https:") ? httpsAgent : httpAgent, + }, this.reqInit, await this.getRequestOptions(), init, ); const { query } = params || {} as P; - if (!reqInit.method) { - reqInit.method = "get"; - } - - if (!reqInit.agent) { - reqInit.agent = reqUrl.startsWith("https:") ? httpsAgent : httpAgent; - } - if (query) { const queryString = stringify(query); @@ -115,9 +114,9 @@ export class JsonApi { return this.request(path, params, { ...reqInit, method: "delete" }); } - protected async request(path: string, params?: P, init: RequestInit = {}) { + protected async request(path: string, params: P | undefined, init: Defaulted) { let reqUrl = `${this.config.serverAddress}${this.config.apiBase}${path}`; - const reqInit: RequestInit = merge( + const reqInit = merge( {}, this.reqInit, await this.getRequestOptions(), @@ -149,10 +148,10 @@ export class JsonApi { const { status } = res; const text = await res.text(); - let data; + let data: any; try { - data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body + data = text ? json.parse(text) : ""; // DELETE-requests might not have response-body } catch (e) { data = text; } diff --git a/src/common/k8s-api/kube-api-parse.ts b/src/common/k8s-api/kube-api-parse.ts index a47f0e5a1b..509f1c28e9 100644 --- a/src/common/k8s-api/kube-api-parse.ts +++ b/src/common/k8s-api/kube-api-parse.ts @@ -6,16 +6,6 @@ // Parse kube-api path and get api-version, group, etc. import { splitArray } from "../utils"; -import { isDebugging } from "../vars"; -import logger from "../../main/logger"; -import { inspect } from "util"; - -export interface IKubeObjectRef { - kind: string; - apiVersion: string; - name: string; - namespace?: string; -} export interface IKubeApiLinkRef { apiPrefix?: string; @@ -32,35 +22,21 @@ export interface IKubeApiParsed extends IKubeApiLinkRef { } export function parseKubeApi(path: string): IKubeApiParsed { - if (!isDebugging) { - return _parseKubeApi(path); - } - - try { - const res = _parseKubeApi(path); - - logger.debug(`parseKubeApi(${inspect(path, false, null, false)}) -> ${inspect(res, false, null, false)}`); - - return res; - } catch (error) { - logger.debug(`parseKubeApi(${inspect(path, false, null, false)}) threw: ${error}`); - - throw error; - } -} - -function _parseKubeApi(path: string): IKubeApiParsed { const apiPath = new URL(path, "http://localhost").pathname; const [, prefix, ...parts] = apiPath.split("/"); const apiPrefix = `/${prefix}`; const [left, right, namespaced] = splitArray(parts, "namespaces"); - let apiGroup, apiVersion, namespace, resource, name; + let apiGroup!: string; + let apiVersion!: string; + let namespace!: string; + let resource!: string; + let name!: string; if (namespaced) { switch (right.length) { case 1: name = right[0]; - // fallthrough + // fallthrough case 0: resource = "namespaces"; // special case this due to `split` removing namespaces break; @@ -69,7 +45,8 @@ function _parseKubeApi(path: string): IKubeApiParsed { break; } - apiVersion = left.pop(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + apiVersion = left.pop()!; apiGroup = left.join("/"); } else { switch (left.length) { @@ -79,10 +56,12 @@ function _parseKubeApi(path: string): IKubeApiParsed { [apiGroup, apiVersion, resource, name] = left; break; case 2: - resource = left.pop(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + resource = left.pop()!; // fallthrough case 1: - apiVersion = left.pop(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + apiVersion = left.pop()!; apiGroup = ""; break; default: diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index 8d890a1d75..75add27797 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -12,30 +12,51 @@ import logger from "../../main/logger"; import { apiManager } from "./api-manager"; import { apiBase, apiKube } from "./index"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; -import type { KubeObjectConstructor } from "./kube-object"; -import { KubeObject, KubeStatus } from "./kube-object"; +import type { KubeObjectConstructor, KubeJsonApiDataFor, KubeObjectMetadata, KubeObjectScope } from "./kube-object"; +import { KubeObject, KubeStatus, isKubeStatusData } from "./kube-object"; import byline from "byline"; import type { IKubeWatchEvent } from "./kube-watch-event"; import type { KubeJsonApiData } from "./kube-json-api"; import { KubeJsonApi } from "./kube-json-api"; -import { noop, WrappedAbortController } from "../utils"; +import type { Disposer } from "../utils"; +import { isDefined, noop, WrappedAbortController } from "../utils"; import type { RequestInit } from "node-fetch"; import type AbortController from "abort-controller"; import type { AgentOptions } from "https"; import { Agent } from "https"; import type { Patch } from "rfc6902"; +import assert from "assert"; +import type { PartialDeep } from "type-fest"; /** * The options used for creating a `KubeApi` */ -export interface IKubeApiOptions { +export interface IKubeApiOptions, Data extends KubeJsonApiDataFor = KubeJsonApiDataFor> extends DerivedKubeApiOptions { /** * base api-path for listing all resources, e.g. "/api/v1/pods" * * If not specified then will be the one on the `objectConstructor` + * @deprecated should be specified by `objectConstructor` */ apiBase?: string; + /** + * The constructor for the kube objects returned from the API + */ + objectConstructor: KubeObjectConstructor; + + /** + * @deprecated should be specified by `objectConstructor` + */ + isNamespaced?: boolean; + + /** + * @deprecated should be specified by `objectConstructor` + */ + kind?: string; +} + +export interface DerivedKubeApiOptions { /** * If the API uses a different API endpoint (e.g. apiBase) depending on the cluster version, * fallback API bases can be listed individually. @@ -50,27 +71,34 @@ export interface IKubeApiOptions { */ checkPreferredVersion?: boolean; - /** - * The constructor for the kube objects returned from the API - */ - objectConstructor: KubeObjectConstructor; - /** * The api instance to use for making requests * * @default apiKube */ request?: KubeJsonApi; +} +/** + * @deprecated This type is only present for backwards compatable typescript support + */ +export interface IgnoredKubeApiOptions { /** - * @deprecated should be specified by `objectConstructor` + * @deprecated this option is overridden and should not be used */ - isNamespaced?: boolean; - + objectConstructor?: any; /** - * @deprecated should be specified by `objectConstructor` + * @deprecated this option is overridden and should not be used */ - kind?: string; + kind?: any; + /** + * @deprecated this option is overridden and should not be used + */ + isNamespaces?: any; + /** + * @deprecated this option is overridden and should not be used + */ + apiBase?: any; } export interface IKubeApiQueryParams { @@ -118,10 +146,6 @@ export type PropagationPolicy = undefined | "Orphan" | "Foreground" | "Backgroun */ export interface IKubeApiCluster extends ILocalKubeApiConfig { } -export type PartialKubeObject = Partial> & { - metadata?: Partial; -}; - export interface IRemoteKubeApiConfig { cluster: { server: string; @@ -135,7 +159,20 @@ export interface IRemoteKubeApiConfig { }; } -export function forCluster = KubeApi>(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: IKubeApiOptions) => Y = null): KubeApi { +export function forCluster< + T extends KubeObject, + Y extends KubeApi, + Data extends KubeJsonApiDataFor, +>(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: IKubeApiOptions) => Y): Y; +export function forCluster< + T extends KubeObject, + Data extends KubeJsonApiDataFor, +>(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass?: new (apiOpts: IKubeApiOptions) => KubeApi): KubeApi; + +export function forCluster< + T extends KubeObject, + Data extends KubeJsonApiDataFor, +>(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: (new (apiOpts: IKubeApiOptions) => KubeApi) = KubeApi): KubeApi { const url = new URL(apiBase.config.serverAddress); const request = new KubeJsonApi({ serverAddress: apiBase.config.serverAddress, @@ -147,17 +184,27 @@ export function forCluster = KubeApi< }, }); - if (!apiClass) { - apiClass = KubeApi as new (apiOpts: IKubeApiOptions) => Y; - } - return new apiClass({ - objectConstructor: kubeClass, + objectConstructor: kubeClass as KubeObjectConstructor>, request, }); } -export function forRemoteCluster = KubeApi>(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: IKubeApiOptions) => Y = null): Y { +export function forRemoteCluster< + T extends KubeObject, + Y extends KubeApi, + Data extends KubeJsonApiDataFor, +>(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: IKubeApiOptions) => Y): Y; +export function forRemoteCluster< + T extends KubeObject, + Data extends KubeJsonApiDataFor, +>(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass?: new (apiOpts: IKubeApiOptions) => KubeApi): KubeApi; + +export function forRemoteCluster< + K extends KubeObject, + Y extends KubeApi, + Data extends KubeJsonApiDataFor, +>(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: IKubeApiOptions) => KubeApi = KubeApi): KubeApi { const reqInit: RequestInit = {}; const agentOptions: AgentOptions = {}; @@ -196,38 +243,26 @@ export function forRemoteCluster = Ku }, reqInit); if (!apiClass) { - apiClass = KubeApi as new (apiOpts: IKubeApiOptions) => Y; + apiClass = KubeApi as new (apiOpts: IKubeApiOptions) => Y; } return new apiClass({ - objectConstructor: kubeClass as KubeObjectConstructor, + objectConstructor: kubeClass as KubeObjectConstructor>, request, }); } -export function ensureObjectSelfLink(api: KubeApi, object: KubeJsonApiData) { - if (!object.metadata.selfLink) { - object.metadata.selfLink = createKubeApiURL({ - apiPrefix: api.apiPrefix, - apiVersion: api.apiVersionWithGroup, - resource: api.apiResource, - namespace: api.isNamespaced ? object.metadata.namespace : undefined, - name: object.metadata.name, - }); - } -} +export type KubeApiWatchCallback = (data: IKubeWatchEvent, error: any) => void; -export type KubeApiWatchCallback = (data: IKubeWatchEvent, error: any) => void; - -export interface KubeApiWatchOptions { +export interface KubeApiWatchOptions, Data extends KubeJsonApiDataFor> { namespace: string; - callback?: KubeApiWatchCallback; - abortController?: AbortController; - watchId?: string; - retry?: boolean; + callback?: KubeApiWatchCallback | undefined; + abortController?: AbortController | undefined; + watchId?: string | undefined; + retry?: boolean | undefined; // timeout in seconds - timeout?: number; + timeout?: number | undefined; } export type KubeApiPatchType = "merge" | "json" | "strategic"; @@ -261,54 +296,74 @@ export interface DeleteResourceDescriptor extends ResourceDescriptor { propagationPolicy?: PropagationPolicy; } -export class KubeApi { +export class KubeApi< + K extends KubeObject = KubeObject, + D extends KubeJsonApiDataFor = KubeJsonApiDataFor, +> { readonly kind: string; readonly apiVersion: string; apiBase: string; apiPrefix: string; apiGroup: string; - apiVersionPreferred?: string; + apiVersionPreferred: string | undefined; readonly apiResource: string; readonly isNamespaced: boolean; - public objectConstructor: KubeObjectConstructor; - protected request: KubeJsonApi; - protected resourceVersions = new Map(); - protected watchDisposer: () => void; + public readonly objectConstructor: KubeObjectConstructor; + protected readonly request: KubeJsonApi; + protected readonly resourceVersions = new Map(); + protected readonly watchDisposer: Disposer | undefined; private watchId = 1; + protected readonly doCheckPreferredVersion: boolean; + protected readonly fullApiPathname: string; + protected readonly fallbackApiBases?: string[]; - constructor(protected options: IKubeApiOptions) { - const { objectConstructor, request, kind, isNamespaced } = options; - const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(options.apiBase || objectConstructor.apiBase); + constructor({ + objectConstructor, + request = apiKube, + kind = objectConstructor.kind, + isNamespaced, + apiBase: fullApiPathname = objectConstructor.apiBase, + checkPreferredVersion: doCheckPreferredVersion = false, + fallbackApiBases, + }: IKubeApiOptions) { + assert(fullApiPathname); + assert(request, "request MUST be provided if not in a cluster page frame context"); - this.options = options; - this.kind = kind ?? objectConstructor.kind; + const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(fullApiPathname); + + assert(kind); + assert(apiPrefix); + + this.doCheckPreferredVersion = doCheckPreferredVersion; + this.fallbackApiBases = fallbackApiBases; + this.fullApiPathname = fullApiPathname; + this.kind = kind; this.isNamespaced = isNamespaced ?? objectConstructor.namespaced ?? false; this.apiBase = apiBase; this.apiPrefix = apiPrefix; this.apiGroup = apiGroup; this.apiVersion = apiVersion; this.apiResource = resource; - this.request = request ?? apiKube; + this.request = request; this.objectConstructor = objectConstructor; - this.parseResponse = this.parseResponse.bind(this); - apiManager.registerApi(apiBase, this); + apiManager.registerApi>(apiBase, this); } get apiVersionWithGroup() { return [this.apiGroup, this.apiVersionPreferred ?? this.apiVersion] - .filter(Boolean) + .filter(isDefined) .join("/"); } /** * Returns the latest API prefix/group that contains the required resource. - * First tries options.apiBase, then urls in order from options.fallbackApiBases. + * First tries fullApiPathname, then urls in order from fallbackApiBases. */ private async getLatestApiPrefixGroup() { - // Note that this.options.apiBase is the "full" url, whereas this.apiBase is parsed - const apiBases = [this.options.apiBase, this.objectConstructor.apiBase, ...this.options.fallbackApiBases]; + // Note that this.fullApiPathname is the "full" url, whereas this.apiBase is parsed + const apiBases = [this.fullApiPathname, this.objectConstructor.apiBase, ...this.fallbackApiBases ?? []]; for (const apiUrl of apiBases) { if (!apiUrl) { @@ -338,7 +393,7 @@ export class KubeApi { * Get the apiPrefix and apiGroup to be used for fetching the preferred version. */ private async getPreferredVersionPrefixGroup() { - if (this.options.fallbackApiBases) { + if (this.fallbackApiBases) { try { return await this.getLatestApiPrefixGroup(); } catch (error) { @@ -354,25 +409,27 @@ export class KubeApi { } protected async checkPreferredVersion() { - if (this.options.fallbackApiBases && !this.options.checkPreferredVersion) { + if (this.fallbackApiBases && !this.doCheckPreferredVersion) { throw new Error("checkPreferredVersion must be enabled if fallbackApiBases is set in KubeApi"); } - if (this.options.checkPreferredVersion && this.apiVersionPreferred === undefined) { + if (this.doCheckPreferredVersion && this.apiVersionPreferred === undefined) { const { apiPrefix, apiGroup } = await this.getPreferredVersionPrefixGroup(); + assert(apiPrefix); + // The apiPrefix and apiGroup might change due to fallbackApiBases, so we must override them this.apiPrefix = apiPrefix; this.apiGroup = apiGroup; - const url = [apiPrefix, apiGroup].filter(Boolean).join("/"); + const url = [apiPrefix, apiGroup].filter(isDefined).join("/"); const res = await this.request.get(url); - this.apiVersionPreferred = res?.preferredVersion?.version ?? null; + this.apiVersionPreferred = res?.preferredVersion?.version; if (this.apiVersionPreferred) { this.apiBase = this.computeApiBase(); - apiManager.registerApi(this.apiBase, this); + apiManager.registerApi>(this.apiBase, this); } } } @@ -421,8 +478,11 @@ export class KubeApi { return query; } - protected parseResponse(data: unknown, namespace?: string): T | T[] | null { - if (!data) return null; + protected parseResponse(data: unknown, namespace?: string): K | K[] | null { + if (!data) { + return null; + } + const KubeObjectConstructor = this.objectConstructor; // process items list response, check before single item since there is overlap @@ -432,37 +492,55 @@ export class KubeApi { this.setResourceVersion(namespace, metadata.resourceVersion); this.setResourceVersion("", metadata.resourceVersion); - return items.map((item) => { - const object = new KubeObjectConstructor({ - kind: this.kind, - apiVersion, - ...item, - }); + return items + .map((item) => { + if (item.metadata) { + this.ensureMetadataSelfLink(item.metadata); + } else { + return undefined; + } - ensureObjectSelfLink(this, object); + const object = new KubeObjectConstructor({ + ...(item as D), + kind: this.kind, + apiVersion, + }); - return object; - }); + return object; + }) + .filter(isDefined); } // process a single item if (KubeObject.isJsonApiData(data)) { - const object = new KubeObjectConstructor(data); + this.ensureMetadataSelfLink(data.metadata); - ensureObjectSelfLink(this, object); - - return object; + return new KubeObjectConstructor(data as never); } // custom apis might return array for list response, e.g. users, groups, etc. if (Array.isArray(data)) { - return data.map(data => new KubeObjectConstructor(data)); + return data.map(data => { + this.ensureMetadataSelfLink(data.metadata); + + return new KubeObjectConstructor(data); + }); } return null; } - async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: IKubeApiQueryParams): Promise { + private ensureMetadataSelfLink(metadata: T): asserts metadata is T & { selfLink: string } { + metadata.selfLink ||= createKubeApiURL({ + apiPrefix: this.apiPrefix, + apiVersion: this.apiVersionWithGroup, + resource: this.apiResource, + namespace: metadata.namespace, + name: metadata.name, + }); + } + + async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); const url = this.getUrl({ namespace }); @@ -480,7 +558,7 @@ export class KubeApi { throw new Error(`GET multiple request to ${url} returned not an array: ${JSON.stringify(parsed)}`); } - async get(desc: ResourceDescriptor, query?: IKubeApiQueryParams): Promise { + async get(desc: ResourceDescriptor, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); const url = this.getUrl(desc); @@ -494,7 +572,7 @@ export class KubeApi { return parsed; } - async create({ name, namespace }: Partial, data?: PartialKubeObject): Promise { + async create({ name, namespace }: Partial, data?: PartialDeep): Promise { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace }); @@ -517,7 +595,7 @@ export class KubeApi { return parsed; } - async update({ name, namespace }: ResourceDescriptor, data: PartialKubeObject): Promise { + async update({ name, namespace }: ResourceDescriptor, data: PartialDeep): Promise { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace, name }); @@ -538,7 +616,7 @@ export class KubeApi { return parsed; } - async patch(desc: ResourceDescriptor, data?: PartialKubeObject | Patch, strategy: KubeApiPatchType = "strategic") { + async patch(desc: ResourceDescriptor, data?: PartialDeep | Patch, strategy: KubeApiPatchType = "strategic"): Promise { await this.checkPreferredVersion(); const apiUrl = this.getUrl(desc); @@ -553,7 +631,7 @@ export class KubeApi { throw new Error(`PATCH request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); } - return parsed as T | null; + return parsed; } async delete({ propagationPolicy = "Background", ...desc }: DeleteResourceDescriptor) { @@ -575,7 +653,7 @@ export class KubeApi { }); } - watch(opts: KubeApiWatchOptions = { namespace: "", retry: false }): () => void { + watch(opts: KubeApiWatchOptions = { namespace: "", retry: false }): () => void { let errorReceived = false; let timedRetry: NodeJS.Timeout; const { namespace, callback = noop, retry, timeout } = opts; @@ -653,9 +731,9 @@ export class KubeApi { byline(response.body).on("data", (line) => { try { - const event: IKubeWatchEvent = JSON.parse(line); + const event = JSON.parse(line) as IKubeWatchEvent>; - if (event.type === "ERROR" && event.object.kind === "Status") { + if (event.type === "ERROR" && isKubeStatusData(event.object)) { errorReceived = true; return callback(null, new KubeStatus(event.object)); @@ -679,15 +757,17 @@ export class KubeApi { }; } - protected modifyWatchEvent(event: IKubeWatchEvent) { + protected modifyWatchEvent(event: IKubeWatchEvent>) { if (event.type === "ERROR") { return; } - ensureObjectSelfLink(this, event.object); + this.ensureMetadataSelfLink(event.object.metadata); const { namespace, resourceVersion } = event.object.metadata; + assert(resourceVersion, "watch events failed to return resourceVersion from kube api"); + this.setResourceVersion(namespace, resourceVersion); this.setResourceVersion("", resourceVersion); } diff --git a/src/common/k8s-api/kube-json-api.ts b/src/common/k8s-api/kube-json-api.ts index 258c61af5e..0a83c67d46 100644 --- a/src/common/k8s-api/kube-json-api.ts +++ b/src/common/k8s-api/kube-json-api.ts @@ -8,6 +8,7 @@ import { JsonApi } from "./json-api"; import type { Response } from "node-fetch"; import { apiKubePrefix, isDebugging } from "../vars"; import { apiBase } from "./api-base"; +import type { KubeJsonApiObjectMetadata } from "./kube-object"; export interface KubeJsonApiListMetadata { resourceVersion: string; @@ -21,28 +22,17 @@ export interface KubeJsonApiDataList { metadata: KubeJsonApiListMetadata; } -export interface KubeJsonApiMetadata { - uid: string; - name: string; - namespace?: string; - creationTimestamp?: string; - resourceVersion: string; - continue?: string; - finalizers?: string[]; - selfLink?: string; - labels?: { - [label: string]: string; - }; - annotations?: { - [annotation: string]: string; - }; - [key: string]: any; -} - -export interface KubeJsonApiData extends JsonApiData { +export interface KubeJsonApiData< + Metadata extends KubeJsonApiObjectMetadata = KubeJsonApiObjectMetadata, + Status = unknown, + Spec = unknown, +> extends JsonApiData { kind: string; apiVersion: string; - metadata: KubeJsonApiMetadata; + metadata: Metadata; + status?: Status; + spec?: Spec; + [otherKeys: string]: unknown; } export interface KubeJsonApiError extends JsonApiError { diff --git a/src/common/k8s-api/kube-object.store.ts b/src/common/k8s-api/kube-object.store.ts index c7780585e8..02e2cb8edd 100644 --- a/src/common/k8s-api/kube-object.store.ts +++ b/src/common/k8s-api/kube-object.store.ts @@ -7,22 +7,19 @@ import type { ClusterContext } from "./cluster-context"; import { action, computed, makeObservable, observable, reaction, when } from "mobx"; import type { Disposer } from "../utils"; -import { autoBind, noop, rejectPromiseBy } from "../utils"; -import type { KubeObject } from "./kube-object"; +import { autoBind, includes, isRequestError, noop, rejectPromiseBy } from "../utils"; +import type { KubeJsonApiDataFor, KubeObject } from "./kube-object"; import { KubeStatus } from "./kube-object"; import type { IKubeWatchEvent } from "./kube-watch-event"; import { ItemStore } from "../item.store"; -import type { IKubeApiQueryParams, KubeApi } from "./kube-api"; -import { ensureObjectSelfLink } from "./kube-api"; +import type { IKubeApiQueryParams, KubeApi, KubeApiWatchCallback } from "./kube-api"; import { parseKubeApi } from "./kube-api-parse"; -import type { KubeJsonApiData } from "./kube-json-api"; import type { RequestInit } from "node-fetch"; - -// BUG: https://github.com/mysticatea/abort-controller/pull/22 -// eslint-disable-next-line import/no-named-as-default import AbortController from "abort-controller"; import type { Patch } from "rfc6902"; import logger from "../logger"; +import assert from "assert"; +import type { PartialDeep } from "type-fest"; export interface KubeObjectStoreLoadingParams { namespaces: string[]; @@ -68,13 +65,32 @@ export interface MergeItemsOptions { namespaces: string[]; } -export abstract class KubeObjectStore extends ItemStore { - static defaultContext = observable.box(); // TODO: support multiple cluster contexts +export interface StatusProvider { + getStatuses(items: K[]): Record; +} - public readonly api: KubeApi; +export interface KubeObjectStoreOptions { + limit?: number; + bufferSize?: number; +} + +export type KubeApiDataFrom = A extends KubeApi + ? D extends KubeJsonApiDataFor + ? D + : never + : never; + +export abstract class KubeObjectStore< + K extends KubeObject = KubeObject, + A extends KubeApi = KubeApi>, + D extends KubeJsonApiDataFor = KubeApiDataFrom, +> extends ItemStore { + static readonly defaultContext = observable.box(); // TODO: support multiple cluster contexts + + public readonly api: A; public readonly limit?: number; - public readonly bufferSize: number = 50000; - @observable private loadedNamespaces?: string[]; + public readonly bufferSize: number; + @observable private loadedNamespaces: string[] | undefined = undefined; get contextReady() { return when(() => Boolean(this.context)); @@ -84,9 +100,11 @@ export abstract class KubeObjectStore extends ItemStore return when(() => Boolean(this.loadedNamespaces)); } - constructor(api?: KubeApi) { + constructor(api: A, opts?: KubeObjectStoreOptions) { super(); - if (api) this.api = api; + this.api = api; + this.limit = opts?.limit; + this.bufferSize = opts?.bufferSize ?? 50_000; makeObservable(this); autoBind(this); @@ -98,7 +116,7 @@ export abstract class KubeObjectStore extends ItemStore } // TODO: Circular dependency: KubeObjectStore -> ClusterFrameContext -> NamespaceStore -> KubeObjectStore - @computed get contextItems(): T[] { + @computed get contextItems(): K[] { const namespaces = this.context?.contextNamespaces ?? []; return this.items.filter(item => { @@ -122,13 +140,11 @@ export abstract class KubeObjectStore extends ItemStore return { limit }; } - getStatuses?(items: T[]): Record; - - getAllByNs(namespace: string | string[], strict = false): T[] { - const namespaces: string[] = [].concat(namespace); + getAllByNs(namespace: string | string[], strict = false): K[] { + const namespaces = [namespace].flat(); if (namespaces.length) { - return this.items.filter(item => namespaces.includes(item.getNs())); + return this.items.filter(item => includes(namespaces, item.getNs())); } if (!strict) { @@ -138,11 +154,11 @@ export abstract class KubeObjectStore extends ItemStore return []; } - getById(id: string) { + getById(id: string | undefined): K | undefined { return this.items.find(item => item.getId() === id); } - getByName(name: string, namespace?: string): T { + getByName(name: string, namespace?: string): K | undefined { return this.items.find(item => { return item.getName() === name && ( namespace ? item.getNs() === namespace : true @@ -150,19 +166,19 @@ export abstract class KubeObjectStore extends ItemStore }); } - getByPath(path: string): T { + getByPath(path: string): K | undefined { return this.items.find(item => item.selfLink === path); } - getByLabel(labels: string[] | { [label: string]: string }): T[] { + getByLabel(labels: string[] | { [label: string]: string }): K[] { if (Array.isArray(labels)) { - return this.items.filter((item: T) => { + return this.items.filter((item: K) => { const itemLabels = item.getLabels(); return labels.every(label => itemLabels.includes(label)); }); } else { - return this.items.filter((item: T) => { + return this.items.filter((item: K) => { const itemLabels = item.metadata.labels || {}; return Object.entries(labels) @@ -171,8 +187,8 @@ export abstract class KubeObjectStore extends ItemStore } } - protected async loadItems({ namespaces, reqInit, onLoadFailure }: KubeObjectStoreLoadingParams): Promise { - if (!this.context?.cluster.isAllowedResource(this.api.kind)) { + protected async loadItems({ namespaces, reqInit, onLoadFailure }: KubeObjectStoreLoadingParams): Promise { + if (!this.context?.cluster?.isAllowedResource(this.api.kind)) { return []; } @@ -189,9 +205,13 @@ export abstract class KubeObjectStore extends ItemStore if (onLoadFailure) { try { - return await res; + return await res ?? []; } catch (error) { - onLoadFailure(error?.message || error?.toString() || "Unknown error"); + onLoadFailure(( + isRequestError(error) + ? error.message || error.toString() + : "Unknown error" + )); // reset the store because we are loading all, so that nothing is displayed this.items.clear(); @@ -201,7 +221,7 @@ export abstract class KubeObjectStore extends ItemStore } } - return res; + return await res ?? []; } this.loadedNamespaces = namespaces; @@ -209,12 +229,12 @@ export abstract class KubeObjectStore extends ItemStore const results = await Promise.allSettled( namespaces.map(namespace => this.api.list({ namespace, reqInit }, this.query)), ); - const res: T[] = []; + const res: K[] = []; for (const result of results) { switch (result.status) { case "fulfilled": - res.push(...result.value); + res.push(...result.value ?? []); break; case "rejected": @@ -230,12 +250,12 @@ export abstract class KubeObjectStore extends ItemStore return res; } - protected filterItemsOnLoad(items: T[]) { + protected filterItemsOnLoad(items: K[]) { return items; } @action - async loadAll({ namespaces, merge = true, reqInit, onLoadFailure }: KubeObjectStoreLoadAllParams = {}): Promise { + async loadAll({ namespaces, merge = true, reqInit, onLoadFailure }: KubeObjectStoreLoadAllParams = {}): Promise { await this.contextReady; namespaces ??= this.context.contextNamespaces; this.isLoading = true; @@ -261,7 +281,7 @@ export abstract class KubeObjectStore extends ItemStore } @action - async reloadAll(opts: { force?: boolean; namespaces?: string[]; merge?: boolean } = {}): Promise { + async reloadAll(opts: { force?: boolean; namespaces?: string[]; merge?: boolean } = {}): Promise { const { force = false, ...loadingOptions } = opts; if (this.isLoading || (this.isLoaded && !force)) { @@ -272,7 +292,7 @@ export abstract class KubeObjectStore extends ItemStore } @action - protected mergeItems(partialItems: T[], { merge = true, updateStore = true, sort = true, filter = true, namespaces }: MergeItemsOptions): T[] { + protected mergeItems(partialItems: K[], { merge = true, updateStore = true, sort = true, filter = true, namespaces }: MergeItemsOptions): K[] { let items = partialItems; // update existing items @@ -280,7 +300,7 @@ export abstract class KubeObjectStore extends ItemStore const ns = new Set(namespaces); items = [ - ...this.items.filter(existingItem => !ns.has(existingItem.getNs())), + ...this.items.filter(item => !ns.has(item.getNs() as string)), ...partialItems, ]; } @@ -296,17 +316,18 @@ export abstract class KubeObjectStore extends ItemStore if (error) this.reset(); } - protected async loadItem(params: { name: string; namespace?: string }): Promise { + protected async loadItem(params: { name: string; namespace?: string }): Promise { return this.api.get(params); } @action - async load(params: { name: string; namespace?: string }): Promise { + async load(params: { name: string; namespace?: string }): Promise { const { name, namespace } = params; - let item = this.getByName(name, namespace); + let item: K | null | undefined = this.getByName(name, namespace); if (!item) { item = await this.loadItem(params); + assert(item, "Failed to load item from kube"); const newItems = this.sortItems([...this.items, item]); this.items.replace(newItems); @@ -319,15 +340,19 @@ export abstract class KubeObjectStore extends ItemStore async loadFromPath(resourcePath: string) { const { namespace, name } = parseKubeApi(resourcePath); + assert(name && namespace, "Both name and namesapce must be part of resourcePath"); + return this.load({ name, namespace }); } - protected async createItem(params: { name: string; namespace?: string }, data?: Partial): Promise { + protected async createItem(params: { name: string; namespace?: string }, data?: PartialDeep): Promise { return this.api.create(params, data); } - async create(params: { name: string; namespace?: string }, data?: Partial): Promise { + async create(params: { name: string; namespace?: string }, data?: PartialDeep): Promise { const newItem = await this.createItem(params, data); + + assert(newItem, "Failed to create item from kube"); const items = this.sortItems([...this.items, newItem]); this.items.replace(items); @@ -335,12 +360,9 @@ export abstract class KubeObjectStore extends ItemStore return newItem; } - private postUpdate(rawItem: KubeJsonApiData): T { - const newItem = new this.api.objectConstructor(rawItem); + private postUpdate(newItem: K): K { const index = this.items.findIndex(item => item.getId() === newItem.getId()); - ensureObjectSelfLink(this.api, newItem); - if (index < 0) { this.items.push(newItem); } else { @@ -350,45 +372,49 @@ export abstract class KubeObjectStore extends ItemStore return newItem; } - async patch(item: T, patch: Patch): Promise { - return this.postUpdate( - await this.api.patch( - { - name: item.getName(), namespace: item.getNs(), - }, - patch, - "json", - ), + async patch(item: K, patch: Patch): Promise { + const rawItem = await this.api.patch( + { + name: item.getName(), namespace: item.getNs(), + }, + patch, + "json", ); + + assert(rawItem, `Failed to patch ${item.getDescriptor()} of ${item.kind} ${item.apiVersion}`); + + return this.postUpdate(rawItem); } - async update(item: T, data: Partial): Promise { - return this.postUpdate( - await this.api.update( - { - name: item.getName(), - namespace: item.getNs(), - }, - data, - ), + async update(item: K, data: PartialDeep): Promise { + const rawItem = await this.api.update( + { + name: item.getName(), + namespace: item.getNs(), + }, + data, ); + + assert(rawItem, `Failed to update ${item.getDescriptor()} of ${item.kind} ${item.apiVersion}`); + + return this.postUpdate(rawItem); } - async remove(item: T) { + async remove(item: K) { await this.api.delete({ name: item.getName(), namespace: item.getNs() }); this.selectedItemsIds.delete(item.getId()); } async removeSelectedItems() { - return Promise.all(this.selectedItems.map(this.remove)); + await Promise.all(this.selectedItems.map(this.remove)); } - async removeItems(items: T[]) { + async removeItems(items: K[]) { await Promise.all(items.map(this.remove)); } // collect items from watch-api events to avoid UI blowing up with huge streams of data - protected eventsBuffer = observable.array>([], { deep: false }); + protected eventsBuffer = observable.array>([], { deep: false }); protected bindWatchEventsUpdater(delay = 1000) { reaction(() => this.eventsBuffer.length, this.updateFromEventsBuffer, { @@ -400,7 +426,9 @@ export abstract class KubeObjectStore extends ItemStore if (this.api.isNamespaced) { Promise.race([rejectPromiseBy(abortController.signal), Promise.all([this.contextReady, this.namespacesReady])]) .then(() => { - if (this.context.cluster.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) { + assert(this.loadedNamespaces); + + if (this.context.cluster?.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) { return this.watchNamespace("", abortController, { onLoadFailure }); } @@ -430,7 +458,7 @@ export abstract class KubeObjectStore extends ItemStore const { signal } = abortController; - const callback = (data: IKubeWatchEvent, error: any) => { + const callback: KubeApiWatchCallback = (data, error) => { if (!this.isLoaded || error?.type === "aborted") return; if (error instanceof Response) { diff --git a/src/common/k8s-api/kube-object.ts b/src/common/k8s-api/kube-object.ts index 05bb769b41..079340cdf3 100644 --- a/src/common/k8s-api/kube-object.ts +++ b/src/common/k8s-api/kube-object.ts @@ -6,53 +6,210 @@ // Base class for all kubernetes objects import moment from "moment"; -import type { KubeJsonApiData, KubeJsonApiDataList, KubeJsonApiListMetadata, KubeJsonApiMetadata } from "./kube-json-api"; -import { autoBind, formatDuration } from "../utils"; +import type { KubeJsonApiData, KubeJsonApiDataList, KubeJsonApiListMetadata } from "./kube-json-api"; +import { autoBind, formatDuration, hasOptionalTypedProperty, hasTypedProperty, isObject, isString, isNumber, bindPredicate, isTypedArray, isRecord, json } from "../utils"; import type { ItemObject } from "../item.store"; import { apiKube } from "./index"; import type { JsonApiParams } from "./json-api"; import * as resourceApplierApi from "./endpoints/resource-applier.api"; -import { hasOptionalProperty, hasTypedProperty, isObject, isString, bindPredicate, isTypedArray, isRecord } from "../../common/utils/type-narrowing"; import type { Patch } from "rfc6902"; +import assert from "assert"; +import type { JsonObject } from "type-fest"; -export type KubeObjectConstructor = (new (data: KubeJsonApiData | any) => K) & { - kind?: string; - namespaced?: boolean; - apiBase?: string; +export type KubeJsonApiDataFor = K extends KubeObject + ? KubeJsonApiData, Status, Spec> + : never; + +export interface KubeObjectConstructorData { + readonly kind?: string; + readonly namespaced?: boolean; + readonly apiBase?: string; +} + +export type KubeObjectConstructor, Data> = (new (data: Data) => K) & KubeObjectConstructorData; + +export interface OwnerReference { + apiVersion: string; + kind: string; + name: string; + uid: string; + controller?: boolean; + blockOwnerDeletion?: boolean; +} + +export type KubeTemplateObjectMetadata = Pick, "annotations" | "finalizers" | "generateName" | "labels" | "ownerReferences"> & { + name?: string; + namespace?: ScopedNamespace; }; -/** - * A reference to an object in the same namespace - */ -export interface LocalObjectReference { - name: string; +interface BaseKubeJsonApiObjectMetadata { + /** + * Annotations is an unstructured key value map stored with a resource that may be set by + * external tools to store and retrieve arbitrary metadata. They are not queryable and should be + * preserved when modifying objects. + * + * More info: http://kubernetes.io/docs/user-guide/annotations + */ + annotations?: Partial>; + + /** + * The name of the cluster which the object belongs to. This is used to distinguish resources + * with same name and namespace in different clusters. This field is not set anywhere right now + * and apiserver is going to ignore it if set in create or update request. + */ + clusterName?: string; + + /** + * CreationTimestamp is a timestamp representing the server time when this object was created. It + * is not guaranteed to be set in happens-before order across separate operations. Clients may + * not set this value. It is represented in RFC3339 form and is in UTC. Populated by the system. + * + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + */ + readonly creationTimestamp?: string; + + /** + * Number of seconds allowed for this object to gracefully terminate before it will be removed + * from the system. Only set when deletionTimestamp is also set. May only be shortened. + */ + readonly deletionGracePeriodSeconds?: number; + + /** + * DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field + * is set by the server when a graceful deletion is requested by the user, and is not directly + * settable by a client. The resource is expected to be deleted (no longer visible from resource + * lists, and not reachable by name) after the time in this field, once the finalizers list is + * empty. As long as the finalizers list contains items, deletion is blocked. Once the + * `deletionTimestamp` is set, this value may not be unset or be set further into the future, + * although it may be shortened or the resource may be deleted prior to this time. For example, + * a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a + * graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet + * will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the + * pod from the API. In the presence of network partitions, this object may still exist after + * this timestamp, until an administrator or automated process can determine the resource is + * fully terminated. If not set, graceful deletion of the object has not been requested. + * Populated by the system when a graceful deletion is requested. + * + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + */ + readonly deletionTimestamp?: string; + + /** + * Must be empty before the object is deleted from the registry. Each entry is an identifier for + * the responsible component that will remove the entry from the list. If the deletionTimestamp + * of the object is non-nil, entries in this list can only be removed. Finalizers may be + * processed and removed in any order. Order is NOT enforced because it introduces significant + * risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder + * it. If the finalizer list is processed in order, then this can lead to a situation in which + * the component responsible for the first finalizer in the list is waiting for a signal (field + * value, external system, or other) produced by a component responsible for a finalizer later in + * the list, resulting in a deadlock. Without enforced ordering finalizers are free to order + * amongst themselves and are not vulnerable to ordering changes in the list. + */ + finalizers?: string[]; + + /** + * GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the + * Name field has not been provided. If this field is used, the name returned to the client will + * be different than the name passed. This value will also be combined with a unique suffix. The + * provided value has the same validation rules as the Name field, and may be truncated by the + * length of the suffix required to make the value unique on the server. If this field is + * specified and the generated name exists, the server will NOT return a 409 - instead, it will + * either return 201 Created or 500 with Reason ServerTimeout indicating a unique name could not + * be found in the time allotted, and the client should retry (optionally after the time indicated + * in the Retry-After header). Applied only if Name is not specified. + * + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency + */ + generateName?: string; + + /** + * A sequence number representing a specific generation of the desired state. Populated by the + * system. + */ + readonly generation?: number; + + /** + * Map of string keys and values that can be used to organize and categorize (scope and select) + * objects. May match selectors of replication controllers and services. + * + * More info: http://kubernetes.io/docs/user-guide/labels + */ + labels?: Partial>; + + /** + * ManagedFields maps workflow-id and version to the set of fields that are managed by that + * workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set + * or understand this field. A workflow can be the user's name, a controller's name, or the name + * of a specific apply path like "ci-cd". The set of fields is always in the version that the + * workflow used when modifying the object. + */ + managedFields?: unknown[]; + + /** + * Name must be unique within a namespace. Is required when creating resources, although some + * resources may allow a client to request the generation of an appropriate name automatically. + * Name is primarily intended for creation idempotence and configuration definition. + * + * More info: http://kubernetes.io/docs/user-guide/identifiers#names + */ + readonly name: string; + + /** + * Namespace defines the space within which each name must be unique. An empty namespace is + * equivalent to the "default" namespace, but "default" is the canonical representation. Not all + * objects are required to be scoped to a namespace - the value of this field for those objects + * will be empty. Must be a DNS_LABEL. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/namespaces + */ + readonly namespace?: ScopedNamespace; + + /** + * List of objects depended by this object. If ALL objects in the list have been deleted, this + * object will be garbage collected. If this object is managed by a controller, then an entry in + * this list will point to this controller, with the controller field set to true. There cannot + * be more than one managing controller. + */ + ownerReferences?: OwnerReference[]; + + /** + * An opaque value that represents the internal version of this object that can be used by + * clients to determine when objects have changed. May be used for optimistic concurrency, change + * detection, and the watch operation on a resource or set of resources. Clients must treat these + * values as opaque and passed unmodified back to the server. They may only be valid for a + * particular resource or set of resources. Populated by the system. Value must be treated as + * opaque by clients. + * + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + */ + readonly resourceVersion?: string; + + /** + * SelfLink is a URL representing this object. Populated by the system. + */ + readonly selfLink?: string; + + /** + * UID is the unique in time and space value for this object. It is typically generated by the + * server on successful creation of a resource and is not allowed to change on PUT operations. + * Populated by the system. + * + * More info: http://kubernetes.io/docs/user-guide/identifiers#uids + */ + readonly uid?: string; + + [key: string]: unknown; } -export interface KubeObjectMetadata { - uid: string; - name: string; - namespace?: string; - creationTimestamp: string; - resourceVersion: string; - selfLink: string; - deletionTimestamp?: string; - finalizers?: string[]; - continue?: string; // provided when used "?limit=" query param to fetch objects list - labels?: { - [label: string]: string; - }; - annotations?: { - [annotation: string]: string; - }; - ownerReferences?: { - apiVersion: string; - kind: string; - name: string; - uid: string; - controller: boolean; - blockOwnerDeletion: boolean; - }[]; -} +export type KubeJsonApiObjectMetadata = Namespaced extends "namespace-scoped" + ? BaseKubeJsonApiObjectMetadata<"namespace-scoped"> & { readonly namespace: string } + : BaseKubeJsonApiObjectMetadata; + +export type KubeObjectMetadata = KubeJsonApiObjectMetadata & { + readonly selfLink: string; + readonly uid: string; + readonly name: string; + readonly resourceVersion: string; +}; export interface KubeStatusData { kind: string; @@ -62,6 +219,16 @@ export interface KubeStatusData { reason?: string; } +export function isKubeStatusData(object: unknown): object is KubeStatusData { + return isObject(object) + && hasTypedProperty(object, "kind", isString) + && hasTypedProperty(object, "apiVersion", isString) + && hasTypedProperty(object, "code", isNumber) + && hasOptionalTypedProperty(object, "message", isString) + && hasOptionalTypedProperty(object, "reason", isString) + && object.kind === "Status"; +} + export class KubeStatus { public readonly kind = "Status"; public readonly apiVersion: string; @@ -77,17 +244,36 @@ export class KubeStatus { } } -export interface KubeObjectStatus { - conditions?: { - lastTransitionTime: string; - message: string; - reason: string; - status: string; - type?: string; - }[]; +export interface BaseKubeObjectCondition { + /** + * Last time the condition transit from one status to another. + * + * @type Date + */ + lastTransitionTime?: string; + /** + * A human readable message indicating details about last transition. + */ + message?: string; + /** + * brief (usually one word) readon for the condition's last transition. + */ + reason?: string; + /** + * Status of the condition + */ + status: "True" | "False" | "Unknown"; + /** + * Type of the condition + */ + type: string; } -export type KubeMetaField = keyof KubeObjectMetadata; +export interface KubeObjectStatus { + conditions?: BaseKubeObjectCondition[]; +} + +export type KubeMetaField = keyof KubeJsonApiObjectMetadata; export class KubeCreationError extends Error { constructor(message: string, public data: any) { @@ -118,35 +304,95 @@ export type LabelMatchExpression = { } ); +export interface Toleration { + key?: string; + operator?: string; + effect?: string; + value?: string; + tolerationSeconds?: number; +} + +export interface ObjectReference { + apiVersion?: string; + fieldPath?: string; + kind?: string; + name: string; + namespace?: string; + resourceVersion?: string; + uid?: string; +} + +export interface LocalObjectReference { + name: string; +} + export interface TypedLocalObjectReference { apiGroup?: string; kind: string; name: string; } +export interface NodeAffinity { + nodeSelectorTerms?: LabelSelector[]; + weight: number; + preference: LabelSelector; +} + +export interface PodAffinity { + labelSelector: LabelSelector; + topologyKey: string; +} + +export interface SpecificAffinity { + requiredDuringSchedulingIgnoredDuringExecution?: T[]; + preferredDuringSchedulingIgnoredDuringExecution?: T[]; +} + +export interface Affinity { + nodeAffinity?: SpecificAffinity; + podAffinity?: SpecificAffinity; + podAntiAffinity?: SpecificAffinity; +} + export interface LabelSelector { matchLabels?: Record; matchExpressions?: LabelMatchExpression[]; } -export class KubeObject implements ItemObject { +export type KubeObjectScope = "namespace-scoped" | "cluster-scoped"; +export type ScopedNamespace = ( + Namespaced extends "namespace-scoped" + ? string + : Namespaced extends "cluster-scoped" + ? undefined + : string | undefined +); + +export class KubeObject< + Status = unknown, + Spec = unknown, + Namespaced extends KubeObjectScope = KubeObjectScope, +> implements ItemObject { static readonly kind?: string; static readonly namespaced?: boolean; static readonly apiBase?: string; - apiVersion: string; - kind: string; - metadata: Metadata; + apiVersion!: string; + kind!: string; + metadata!: KubeObjectMetadata; status?: Status; - spec?: Spec; - managedFields?: any; + spec!: Spec; - static create(data: KubeJsonApiData) { + static create< + Metadata extends KubeObjectMetadata = KubeObjectMetadata, + Status = unknown, + Spec = unknown, + >(data: KubeJsonApiData) { return new KubeObject(data); } - static isNonSystem(item: KubeJsonApiData | KubeObject) { - return !item.metadata.name.startsWith("system:"); + static isNonSystem(item: KubeJsonApiData | KubeObject) { + return !item.metadata.name?.startsWith("system:"); } static isJsonApiData(object: unknown): object is KubeJsonApiData { @@ -161,49 +407,49 @@ export class KubeObject { + static isPartialJsonApiMetadata(object: unknown): object is Partial { return ( isObject(object) - && hasOptionalProperty(object, "uid", isString) - && hasOptionalProperty(object, "name", isString) - && hasOptionalProperty(object, "resourceVersion", isString) - && hasOptionalProperty(object, "selfLink", isString) - && hasOptionalProperty(object, "namespace", isString) - && hasOptionalProperty(object, "creationTimestamp", isString) - && hasOptionalProperty(object, "continue", isString) - && hasOptionalProperty(object, "finalizers", bindPredicate(isTypedArray, isString)) - && hasOptionalProperty(object, "labels", bindPredicate(isRecord, isString, isString)) - && hasOptionalProperty(object, "annotations", bindPredicate(isRecord, isString, isString)) + && hasOptionalTypedProperty(object, "uid", isString) + && hasOptionalTypedProperty(object, "name", isString) + && hasOptionalTypedProperty(object, "resourceVersion", isString) + && hasOptionalTypedProperty(object, "selfLink", isString) + && hasOptionalTypedProperty(object, "namespace", isString) + && hasOptionalTypedProperty(object, "creationTimestamp", isString) + && hasOptionalTypedProperty(object, "continue", isString) + && hasOptionalTypedProperty(object, "finalizers", bindPredicate(isTypedArray, isString)) + && hasOptionalTypedProperty(object, "labels", bindPredicate(isRecord, isString, isString)) + && hasOptionalTypedProperty(object, "annotations", bindPredicate(isRecord, isString, isString)) ); } static isPartialJsonApiData(object: unknown): object is Partial { return ( isObject(object) - && hasOptionalProperty(object, "kind", isString) - && hasOptionalProperty(object, "apiVersion", isString) - && hasOptionalProperty(object, "metadata", KubeObject.isPartialJsonApiMetadata) + && hasOptionalTypedProperty(object, "kind", isString) + && hasOptionalTypedProperty(object, "apiVersion", isString) + && hasOptionalTypedProperty(object, "metadata", KubeObject.isPartialJsonApiMetadata) ); } @@ -217,7 +463,7 @@ export class KubeObject): string[] { if (!labels) return []; return Object.entries(labels).map(([name, value]) => `${name}=${value}`); @@ -240,7 +486,7 @@ export class KubeObject, Status, Spec>) { if (typeof data !== "object") { throw new TypeError(`Cannot create a KubeObject from ${typeof data}`); } @@ -265,13 +511,20 @@ export class KubeObject { // avoid "null" serialization via JSON.stringify when post data - return this.metadata.namespace || undefined; + return (this.metadata.namespace || undefined) as never; } /** @@ -279,6 +532,10 @@ export class KubeObject = { - type: "ADDED" | "MODIFIED" | "DELETED"; - object: T; +export type IKubeWatchEvent = { + readonly type: "ADDED" | "MODIFIED" | "DELETED"; + readonly object: T; } | { - type: "ERROR"; - object: KubeStatusData; + readonly type: "ERROR"; + readonly object?: KubeStatusData; }; diff --git a/src/common/k8s-api/workload-kube-object.ts b/src/common/k8s-api/workload-kube-object.ts deleted file mode 100644 index f612f14cd5..0000000000 --- a/src/common/k8s-api/workload-kube-object.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import get from "lodash/get"; -import { KubeObject } from "./kube-object"; - -export interface IToleration { - key?: string; - operator?: string; - effect?: string; - value?: string; - tolerationSeconds?: number; -} - -interface IMatchExpression { - key: string; - operator: string; - values: string[]; -} - -interface INodeAffinity { - nodeSelectorTerms?: { - matchExpressions: IMatchExpression[]; - }[]; - weight: number; - preference: { - matchExpressions: IMatchExpression[]; - }; -} - -interface IPodAffinity { - labelSelector: { - matchExpressions: IMatchExpression[]; - }; - topologyKey: string; -} - -export interface IAffinity { - nodeAffinity?: { - requiredDuringSchedulingIgnoredDuringExecution?: INodeAffinity[]; - preferredDuringSchedulingIgnoredDuringExecution?: INodeAffinity[]; - }; - podAffinity?: { - requiredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[]; - preferredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[]; - }; - podAntiAffinity?: { - requiredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[]; - preferredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[]; - }; -} - -export class WorkloadKubeObject extends KubeObject { - getSelectors(): string[] { - const selector = this.spec.selector; - - return KubeObject.stringifyLabels(selector ? selector.matchLabels : null); - } - - getNodeSelectors(): string[] { - const nodeSelector = get(this, "spec.template.spec.nodeSelector"); - - return KubeObject.stringifyLabels(nodeSelector); - } - - getTemplateLabels(): string[] { - const labels = get(this, "spec.template.metadata.labels"); - - return KubeObject.stringifyLabels(labels); - } - - getTolerations(): IToleration[] { - return get(this, "spec.template.spec.tolerations", []); - } - - getAffinity(): IAffinity { - return get(this, "spec.template.spec.affinity"); - } - - getAffinityNumber() { - const affinity = this.getAffinity(); - - if (!affinity) return 0; - - return Object.keys(affinity).length; - } -} diff --git a/src/common/k8s/resource-stack.ts b/src/common/k8s/resource-stack.ts index 29ade4b84a..d289a375b3 100644 --- a/src/common/k8s/resource-stack.ts +++ b/src/common/k8s/resource-stack.ts @@ -59,7 +59,7 @@ export class ResourceStack { throw new Error(response.stderr); } - return response.stdout; + return response.stdout ?? ""; } } @@ -83,7 +83,7 @@ export class ResourceStack { throw new Error(response.stderr); } - return response.stdout; + return response.stdout ?? ""; } } diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index 9a04a3f3d8..974b09ddf8 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -11,8 +11,9 @@ import yaml from "js-yaml"; import logger from "../main/logger"; import type { Cluster, Context, User } from "@kubernetes/client-node/dist/config_types"; import { newClusters, newContexts, newUsers } from "@kubernetes/client-node/dist/config_types"; -import { resolvePath } from "./utils"; +import { isDefined, resolvePath } from "./utils"; import Joi from "joi"; +import type { PartialDeep } from "type-fest"; export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config"); @@ -92,7 +93,7 @@ interface KubeConfigOptions { interface OptionsResult { options: KubeConfigOptions; - error: Joi.ValidationError; + error: Joi.ValidationError | undefined; } function loadToOptions(rawYaml: string): OptionsResult { @@ -135,7 +136,7 @@ export function loadFromOptions(options: KubeConfigOptions): KubeConfig { export interface ConfigResult { config: KubeConfig; - error: Joi.ValidationError; + error: Joi.ValidationError | undefined; } export function loadConfigFromString(content: string): ConfigResult { @@ -188,38 +189,44 @@ export function splitConfig(kubeConfig: KubeConfig): SplitConfigEntry[] { * @param kubeConfig The kubeconfig object to format as pretty yaml * @returns The yaml representation of the kubeconfig object */ -export function dumpConfigYaml(kubeConfig: Partial): string { - const clusters = kubeConfig.clusters.map(cluster => ({ - name: cluster.name, - cluster: { - "certificate-authority-data": cluster.caData, - "certificate-authority": cluster.caFile, - server: cluster.server, - "insecure-skip-tls-verify": cluster.skipTLSVerify, - }, - })); - const contexts = kubeConfig.contexts.map(context => ({ - name: context.name, - context: { - cluster: context.cluster, - user: context.user, - namespace: context.namespace, - }, - })); - const users = kubeConfig.users.map(user => ({ - name: user.name, - user: { - "client-certificate-data": user.certData, - "client-certificate": user.certFile, - "client-key-data": user.keyData, - "client-key": user.keyFile, - "auth-provider": user.authProvider, - exec: user.exec, - token: user.token, - username: user.username, - password: user.password, - }, - })); +export function dumpConfigYaml(kubeConfig: PartialDeep): string { + const clusters = kubeConfig.clusters + ?.filter(isDefined) + .map(cluster => ({ + name: cluster.name, + cluster: { + "certificate-authority-data": cluster.caData, + "certificate-authority": cluster.caFile, + server: cluster.server, + "insecure-skip-tls-verify": cluster.skipTLSVerify, + }, + })); + const contexts = kubeConfig.contexts + ?.filter(isDefined) + .map(context => ({ + name: context.name, + context: { + cluster: context.cluster, + user: context.user, + namespace: context.namespace, + }, + })); + const users = kubeConfig.users + ?.filter(isDefined) + .map(user => ({ + name: user.name, + user: { + "client-certificate-data": user.certData, + "client-certificate": user.certFile, + "client-key-data": user.keyData, + "client-key": user.keyFile, + "auth-provider": user.authProvider, + exec: user.exec, + token: user.token, + username: user.username, + password: user.password, + }, + })); const config = { apiVersion: "v1", kind: "Config", diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts index 1740071cfe..476c992582 100644 --- a/src/common/protocol-handler/router.ts +++ b/src/common/protocol-handler/router.ts @@ -6,7 +6,7 @@ import type { match } from "react-router"; import { matchPath } from "react-router"; import { countBy } from "lodash"; -import { iter } from "../utils"; +import { isDefined, iter } from "../utils"; import { pathToRegexp } from "path-to-regexp"; import logger from "../../main/logger"; import type Url from "url-parse"; @@ -83,7 +83,7 @@ export abstract class LensProtocolRouter { * @param url the parsed URL that initiated the `lens://` protocol * @returns true if a route has been found */ - protected _routeToInternal(url: Url>): RouteAttempt { + protected _routeToInternal(url: Url>): RouteAttempt { return this._route(this.internalRoutes.entries(), url); } @@ -93,7 +93,7 @@ export abstract class LensProtocolRouter { * @param routes the array of path schemas, handler pairs to match against * @param url the url (in its current state) */ - protected _findMatchingRoute(routes: Iterable<[string, RouteHandler]>, url: Url>): null | [match>, RouteHandler] { + protected _findMatchingRoute(routes: Iterable<[string, RouteHandler]>, url: Url>): null | [match>, RouteHandler] { const matches: [match>, RouteHandler][] = []; for (const [schema, handler] of routes) { @@ -120,7 +120,7 @@ export abstract class LensProtocolRouter { * @param routes the array of (path schemas, handler) pairs to match against * @param url the url (in its current state) */ - protected _route(routes: Iterable<[string, RouteHandler]>, url: Url>, extensionName?: string): RouteAttempt { + protected _route(routes: Iterable<[string, RouteHandler]>, url: Url>, extensionName?: string): RouteAttempt { const route = this._findMatchingRoute(routes, url); if (!route) { @@ -158,7 +158,7 @@ export abstract class LensProtocolRouter { * @param url the protocol request URI that was "open"-ed * @returns either the found name or the instance of `LensExtension` */ - protected async _findMatchingExtensionByName(url: Url>): Promise { + protected async _findMatchingExtensionByName(url: Url>): Promise { interface ExtensionUrlMatch { [EXTENSION_PUBLISHER_MATCH]: string; [EXTENSION_NAME_MATCH]: string; @@ -171,7 +171,7 @@ export abstract class LensProtocolRouter { } const { [EXTENSION_PUBLISHER_MATCH]: publisher, [EXTENSION_NAME_MATCH]: partialName } = match.params; - const name = [publisher, partialName].filter(Boolean).join("/"); + const name = [publisher, partialName].filter(isDefined).join("/"); const extensionLoader = this.dependencies.extensionLoader; @@ -219,7 +219,7 @@ export abstract class LensProtocolRouter { * Note: this function modifies its argument, do not reuse * @param url the protocol request URI that was "open"-ed */ - protected async _routeToExtension(url: Url>): Promise { + protected async _routeToExtension(url: Url>): Promise { const extension = await this._findMatchingExtensionByName(url); if (typeof extension === "string") { diff --git a/src/common/system-ca.ts b/src/common/system-ca.ts index 36886dba63..4e57ca2d7b 100644 --- a/src/common/system-ca.ts +++ b/src/common/system-ca.ts @@ -85,7 +85,7 @@ export async function injectSystemCAs() { injectCAs(osxRootCAs); } catch (error) { - console.warn(`[MAC-CA]: Error injecting root CAs from MacOSX. ${error?.message}`); + console.warn(`[MAC-CA]: Error injecting root CAs from MacOSX. ${error}`); } } @@ -96,7 +96,7 @@ export async function injectSystemCAs() { wincaAPI.inject("+", winRootCAs); } catch (error) { - console.warn(`[WIN-CA]: Error injecting root CAs from Windows. ${error?.message}`); + console.warn(`[WIN-CA]: Error injecting root CAs from Windows. ${error}`); } } } diff --git a/src/common/user-store/preferences-helpers.ts b/src/common/user-store/preferences-helpers.ts index 5e1fc597f3..e5248611b2 100644 --- a/src/common/user-store/preferences-helpers.ts +++ b/src/common/user-store/preferences-helpers.ts @@ -11,6 +11,9 @@ import type { editor } from "monaco-editor"; import merge from "lodash/merge"; import { SemVer } from "semver"; import { defaultTheme, defaultEditorFontFamily, defaultFontSize, defaultTerminalFontFamily } from "../vars"; +import type { ObservableMap } from "mobx"; +import { observable } from "mobx"; +import { readonly } from "../utils/readonly"; export interface KubeconfigSyncEntry extends KubeconfigSyncValue { filePath: string; @@ -28,8 +31,11 @@ export const defaultTerminalConfig: TerminalConfig = { fontFamily: defaultTerminalFontFamily, }; -export type EditorConfiguration = Pick; +interface BaseEditorConfiguration extends Required> { + lineNumbers: NonNullable>; +} + +export type EditorConfiguration = Required; export const defaultEditorConfig: EditorConfiguration = { tabSize: 2, @@ -41,7 +47,12 @@ export const defaultEditorConfig: EditorConfiguration = { side: "right", }, }; -interface PreferenceDescription { + +export type StoreType

= P extends PreferenceDescription + ? Store + : never; + +export interface PreferenceDescription { fromStore(val: T | undefined): R; toStore(val: R): T | undefined; } @@ -86,12 +97,14 @@ const terminalTheme: PreferenceDescription = { }, }; +export const defaultLocaleTimezone = "UTC"; + const localeTimezone: PreferenceDescription = { fromStore(val) { - return val || moment.tz.guess(true) || "UTC"; + return val || moment.tz.guess(true) || defaultLocaleTimezone; }, toStore(val) { - if (!val || val === moment.tz.guess(true) || val === "UTC") { + if (!val || val === moment.tz.guess(true) || val === defaultLocaleTimezone) { return undefined; } @@ -145,12 +158,14 @@ export interface DownloadMirror { } export const defaultPackageMirror = "default"; +const defaultDownloadMirrorData: DownloadMirror = { + url: "https://storage.googleapis.com/kubernetes-release/release", + label: "Default (Google)", + platforms: new Set(["darwin", "win32", "linux"]), +}; + export const packageMirrors = new Map([ - [defaultPackageMirror, { - url: "https://storage.googleapis.com/kubernetes-release/release", - label: "Default (Google)", - platforms: new Set(["darwin", "win32", "linux"]), - }], + [defaultPackageMirror, defaultDownloadMirrorData], ["china", { url: "https://mirror.azure.cn/kubernetes/kubectl", label: "China (Azure)", @@ -160,7 +175,9 @@ export const packageMirrors = new Map([ const downloadMirror: PreferenceDescription = { fromStore(val) { - return packageMirrors.has(val) ? val : defaultPackageMirror; + return !val || !packageMirrors.has(val) + ? defaultPackageMirror + : val; }, toStore(val) { if (!val || val === defaultPackageMirror) { @@ -257,9 +274,9 @@ const hiddenTableColumns: PreferenceDescription<[string, string[]][], Map> = { +const syncKubeconfigEntries: PreferenceDescription> = { fromStore(val) { - return new Map( + return observable.map( val ?.map(({ filePath, ...rest }) => [filePath, rest]) ?? [[mainKubeFolder, {}]], @@ -274,7 +291,7 @@ const syncKubeconfigEntries: PreferenceDescription = { +const editorConfiguration: PreferenceDescription | undefined, EditorConfiguration> = { fromStore(val) { return merge(defaultEditorConfig, val); }, @@ -292,7 +309,11 @@ const terminalConfig: PreferenceDescription = { }, }; -const updateChannels = new Map([ +export interface UpdateChannelInfo { + label: string; +} + +export const updateChannels = readonly(new Map([ ["latest", { label: "Stable", }], @@ -302,12 +323,14 @@ const updateChannels = new Map([ ["alpha", { label: "Alpha", }], -]); -const defaultUpdateChannel = new SemVer(getAppVersion()).prerelease[0]?.toString() || "latest"; +])); +export const defaultUpdateChannel = new SemVer(getAppVersion()).prerelease[0]?.toString() || "latest"; const updateChannel: PreferenceDescription = { fromStore(val) { - return updateChannels.has(val) ? val : defaultUpdateChannel; + return !val || !updateChannels.has(val) + ? defaultUpdateChannel + : val; }, toStore(val) { if (!updateChannels.has(val) || val === defaultUpdateChannel) { @@ -318,29 +341,27 @@ const updateChannel: PreferenceDescription = { }, }; -export enum ExtensionRegistryLocation { - DEFAULT = "default", - NPMRC = "npmrc", - CUSTOM = "custom", -} +export type ExtensionRegistryLocation = "default" | "npmrc" | "custom"; + export type ExtensionRegistry = { - location: ExtensionRegistryLocation.DEFAULT | ExtensionRegistryLocation.NPMRC; + location: "default" | "npmrc"; customUrl?: undefined; } | { - location: ExtensionRegistryLocation.CUSTOM; + location: "custom"; customUrl: string; }; +export const defaultExtensionRegistryUrlLocation = "default"; export const defaultExtensionRegistryUrl = "https://registry.npmjs.org"; const extensionRegistryUrl: PreferenceDescription = { fromStore(val) { return val ?? { - location: ExtensionRegistryLocation.DEFAULT, + location: defaultExtensionRegistryUrlLocation, }; }, toStore(val) { - if (val.location === ExtensionRegistryLocation.DEFAULT) { + if (val.location === defaultExtensionRegistryUrlLocation) { return undefined; } @@ -381,7 +402,3 @@ export const DESCRIPTORS = { updateChannel, extensionRegistryUrl, }; - -export const CONSTANTS = { - updateChannels, -}; diff --git a/src/common/user-store/user-store.ts b/src/common/user-store/user-store.ts index a948def9d8..f20ce9c4c3 100644 --- a/src/common/user-store/user-store.ts +++ b/src/common/user-store/user-store.ts @@ -11,9 +11,9 @@ import migrations, { fileNameMigration } from "../../migrations/user-store"; import { getAppVersion } from "../utils/app-version"; import { kubeConfigDefaultPath } from "../kube-helpers"; import { appEventBus } from "../app-event-bus/event-bus"; -import { getOrInsertSet, toggle, toJS, entries, fromEntries } from "../../renderer/utils"; +import { getOrInsertSet, toggle, toJS, object } from "../../renderer/utils"; import { DESCRIPTORS } from "./preferences-helpers"; -import type { EditorConfiguration, ExtensionRegistry, KubeconfigSyncValue, UserPreferencesModel, TerminalConfig } from "./preferences-helpers"; +import type { UserPreferencesModel, StoreType } from "./preferences-helpers"; import logger from "../../main/logger"; export interface UserStoreModel { @@ -42,47 +42,61 @@ export class UserStore extends BaseStore /* implements UserStore /** * used in add-cluster page for providing context + * @deprecated No longer used */ @observable kubeConfigPath = kubeConfigDefaultPath; + + /** + * @deprecated No longer used + */ @observable seenContexts = observable.set(); + + /** + * @deprecated No longer used + */ @observable newContexts = observable.set(); - @observable allowTelemetry: boolean; - @observable allowErrorReporting: boolean; - @observable allowUntrustedCAs: boolean; - @observable colorTheme: string; - @observable terminalTheme: string; - @observable localeTimezone: string; - @observable downloadMirror: string; - @observable httpsProxy?: string; - @observable shell?: string; - @observable downloadBinariesPath?: string; - @observable kubectlBinariesPath?: string; - @observable terminalCopyOnSelect: boolean; - @observable terminalConfig: TerminalConfig; - @observable updateChannel?: string; - @observable extensionRegistryUrl: ExtensionRegistry; + + @observable allowTelemetry!: StoreType; + @observable allowErrorReporting!: StoreType; + @observable allowUntrustedCAs!: StoreType; + @observable colorTheme!: StoreType; + @observable terminalTheme!: StoreType; + @observable localeTimezone!: StoreType; + @observable downloadMirror!: StoreType; + @observable httpsProxy!: StoreType; + @observable shell!: StoreType; + @observable downloadBinariesPath!: StoreType; + @observable kubectlBinariesPath!: StoreType; + @observable terminalCopyOnSelect!: StoreType; + @observable terminalConfig!: StoreType; + @observable updateChannel!: StoreType; + @observable extensionRegistryUrl!: StoreType; /** * Download kubectl binaries matching cluster version */ - @observable downloadKubectlBinaries: boolean; - @observable openAtLogin: boolean; + @observable downloadKubectlBinaries!: StoreType; + + /** + * Whether the application should open itself at login. + */ + @observable openAtLogin!: StoreType; /** * The column IDs under each configurable table ID that have been configured * to not be shown */ - hiddenTableColumns = observable.map>(); + @observable hiddenTableColumns!: StoreType; /** * Monaco editor configs */ - @observable editorConfiguration: EditorConfiguration; + @observable editorConfiguration!: StoreType; /** * The set of file/folder paths to be synced */ - syncKubeconfigEntries = observable.map(); + @observable syncKubeconfigEntries!: StoreType; @computed get isNewVersion() { return semver.gt(getAppVersion(), this.lastSeenAppVersion); @@ -120,7 +134,7 @@ export class UserStore extends BaseStore /* implements UserStore * @param columnIds The list of IDs the check if one is hidden * @returns true if at least one column under the table is set to hidden */ - isTableColumnHidden(tableId: string, ...columnIds: string[]): boolean { + isTableColumnHidden(tableId: string, ...columnIds: (string | undefined)[]): boolean { if (columnIds.length === 0) { return false; } @@ -131,7 +145,7 @@ export class UserStore extends BaseStore /* implements UserStore return false; } - return columnIds.some(columnId => config.has(columnId)); + return columnIds.some(columnId => columnId && config.has(columnId)); } /** @@ -152,11 +166,6 @@ export class UserStore extends BaseStore /* implements UserStore this.lastSeenAppVersion = getAppVersion(); } - @action - setLocaleTimezone(tz: string) { - this.localeTimezone = tz; - } - @action protected fromStore({ lastSeenAppVersion, preferences }: Partial = {}) { logger.debug("UserStore.fromStore()", { lastSeenAppVersion, preferences }); @@ -165,7 +174,7 @@ export class UserStore extends BaseStore /* implements UserStore this.lastSeenAppVersion = lastSeenAppVersion; } - for (const [key, { fromStore }] of entries(DESCRIPTORS)) { + for (const [key, { fromStore }] of object.entries(DESCRIPTORS)) { const curVal = this[key]; const newVal = fromStore((preferences)?.[key] as never) as never; @@ -180,8 +189,8 @@ export class UserStore extends BaseStore /* implements UserStore } toJSON(): UserStoreModel { - const preferences = fromEntries( - entries(DESCRIPTORS) + const preferences = object.fromEntries( + object.entries(DESCRIPTORS) .map(([key, { toStore }]) => [key, toStore(this[key] as never)]), ) as UserPreferencesModel; diff --git a/src/common/utils/autobind.ts b/src/common/utils/autobind.ts index a543781715..49feb435e3 100644 --- a/src/common/utils/autobind.ts +++ b/src/common/utils/autobind.ts @@ -6,11 +6,12 @@ import type { Options } from "auto-bind"; import autoBindClass from "auto-bind"; import autoBindReactClass from "auto-bind/react"; +import React from "react"; // Automatically bind methods to their class instance export function autoBind(obj: T, opts?: Options): T { - if ("componentWillUnmount" in obj) { - return autoBindReactClass(obj as any, opts); + if (obj instanceof React.Component) { + return autoBindReactClass(obj, opts); } return autoBindClass(obj, opts); diff --git a/src/common/utils/buildUrl.ts b/src/common/utils/buildUrl.ts index 787086f027..61639cea4d 100644 --- a/src/common/utils/buildUrl.ts +++ b/src/common/utils/buildUrl.ts @@ -4,6 +4,12 @@ */ import { compile } from "path-to-regexp"; +import type { RouteProps } from "react-router"; +import { isDefined } from "./type-narrowing"; + +export interface UrlRouteProps extends RouteProps { + path: string; +} export interface URLParams

{ params?: P; @@ -21,7 +27,7 @@ export function buildURL

(path: str fragment && `#${fragment}`, ]; - return parts.filter(Boolean).join(""); + return parts.filter(isDefined).join(""); } export function buildURLPositional

(path: string) { diff --git a/src/common/utils/camelCase.ts b/src/common/utils/camelCase.ts index 3f30696a10..02854ca4e6 100644 --- a/src/common/utils/camelCase.ts +++ b/src/common/utils/camelCase.ts @@ -4,22 +4,26 @@ */ // Convert object's keys to camelCase format -import { camelCase, isPlainObject } from "lodash"; +import { camelCase } from "lodash"; +import type { SingleOrMany } from "."; +import { isObject } from "./type-narrowing"; -export function toCamelCase(obj: Record): any { +export function toCamelCase[]>(obj: T): T; +export function toCamelCase>(obj: T): T; + +export function toCamelCase(obj: SingleOrMany | unknown>): SingleOrMany | unknown> { if (Array.isArray(obj)) { return obj.map(toCamelCase); } - else if (isPlainObject(obj)) { - return Object.keys(obj).reduce((result, key) => { - const value = obj[key]; - result[camelCase(key)] = typeof value === "object" ? toCamelCase(value) : value; + if (isObject(obj)) { + return Object.fromEntries( + Object.entries(obj) + .map(([key, value]) => { + return [camelCase(key), toCamelCase(value)]; + }), + ); + } - return result; - }, {} as any); - } - else { - return obj; - } + return obj; } diff --git a/src/common/utils/cluster-id-url-parsing.ts b/src/common/utils/cluster-id-url-parsing.ts index ded15d0472..5ba85eb382 100644 --- a/src/common/utils/cluster-id-url-parsing.ts +++ b/src/common/utils/cluster-id-url-parsing.ts @@ -3,6 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import { once } from "lodash/fp"; import type { ClusterId } from "../cluster-types"; /** @@ -26,23 +27,11 @@ export function getClusterFrameUrl(clusterId: ClusterId) { return `//${clusterId}.${location.host}`; } -/** - * Get the result of `getClusterIdFromHost` from the current `location.host` - */ -export function getHostedClusterId(): ClusterId | undefined { - // catch being called in main - if (typeof location === "undefined") { - return undefined; - } - - return getClusterIdFromHost(location.host); -} - /** * Returns true only if code is running within a cluster iframe context */ -export function isClusterPageContext(): boolean { +export const isClusterPageContext = once(() => { if (typeof window === "undefined") return false; return !!getClusterIdFromHost(window.location.host); -} +}); diff --git a/src/common/utils/collection-functions.ts b/src/common/utils/collection-functions.ts index 76646601eb..48f9fa4b4e 100644 --- a/src/common/utils/collection-functions.ts +++ b/src/common/utils/collection-functions.ts @@ -4,6 +4,7 @@ */ import { runInAction } from "mobx"; +import { isDefined } from "./type-narrowing"; /** * Get the value behind `key`. If it was not present, first insert `value` @@ -17,7 +18,17 @@ export function getOrInsert(map: Map, key: K, value: V): V { map.set(key, value); } - return map.get(key); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return map.get(key)!; +} + +/** + * Updates map and returns the value that was just inserted + */ +export function put(map: Map, key: K, value: V): V { + map.set(key, value); + + return value; } /** @@ -45,7 +56,8 @@ export function getOrInsertWith(map: Map, key: K, builder: () => V): map.set(key, builder()); } - return map.get(key); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return map.get(key)!; } /** @@ -83,7 +95,8 @@ export function strictGet(map: Map, key: K): V { throw new TypeError("key not in map"); } - return map.get(key); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return map.get(key)!; } /** @@ -99,3 +112,10 @@ export function toggle(set: Set, key: K): void { } }); } + +/** + * A helper function to also check for defined-ness + */ +export function includes(src: T[], value: T | null | undefined): boolean { + return isDefined(value) && src.includes(value); +} diff --git a/src/common/utils/disposer.ts b/src/common/utils/disposer.ts index a578160c3b..db71148404 100644 --- a/src/common/utils/disposer.ts +++ b/src/common/utils/disposer.ts @@ -11,7 +11,7 @@ interface Extendable { export type ExtendableDisposer = Disposer & Extendable; -export function disposer(...args: Disposer[]): ExtendableDisposer { +export function disposer(...args: (Disposer | undefined | null)[]): ExtendableDisposer { const res = () => { args.forEach(dispose => dispose?.()); args.length = 0; diff --git a/src/common/utils/downloadFile.ts b/src/common/utils/downloadFile.ts index 3ff60470d3..5f3e658aa6 100644 --- a/src/common/utils/downloadFile.ts +++ b/src/common/utils/downloadFile.ts @@ -4,6 +4,8 @@ */ import request from "request"; +import type { JsonValue } from "type-fest"; +import { parse } from "./json"; export interface DownloadFileOptions { url: string; @@ -41,11 +43,11 @@ export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions) }; } -export function downloadJson(args: DownloadFileOptions): DownloadFileTicket { +export function downloadJson(args: DownloadFileOptions): DownloadFileTicket { const { promise, ...rest } = downloadFile(args); return { - promise: promise.then(res => JSON.parse(res.toString())), + promise: promise.then(res => parse(res.toString())), ...rest, }; } diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index eab3239350..6bdde405c7 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -14,7 +14,6 @@ export * from "./abort-controller"; export * from "./app-version"; export * from "./autobind"; export * from "./camelCase"; -export * from "./cloneJson"; export * from "./cluster-id-url-parsing"; export * from "./collection-functions"; export * from "./convertCpu"; @@ -28,7 +27,6 @@ export * from "./formatDuration"; export * from "./getRandId"; export * from "./hash-set"; export * from "./n-fircate"; -export * from "./objects"; export * from "./openBrowser"; export * from "./paths"; export * from "./promise-exec"; @@ -39,7 +37,9 @@ export * from "./splitArray"; export * from "./tar"; export * from "./toJS"; export * from "./type-narrowing"; +export * from "./types"; export * from "./wait-for-path"; +export * from "./wait"; export type { Tuple } from "./tuple"; @@ -47,10 +47,16 @@ import * as iter from "./iter"; import * as array from "./array"; import * as tuple from "./tuple"; import * as base64 from "./base64"; +import * as object from "./objects"; +import * as json from "./json"; +import * as string from "./string"; export { iter, array, tuple, base64, + object, + json, + string, }; diff --git a/src/common/utils/is-allowed-resource.injectable.ts b/src/common/utils/is-allowed-resource.injectable.ts index f66029c018..67050a7f02 100644 --- a/src/common/utils/is-allowed-resource.injectable.ts +++ b/src/common/utils/is-allowed-resource.injectable.ts @@ -12,14 +12,14 @@ export type IsAllowedResource = (resource: KubeResource) => boolean; const isAllowedResourceInjectable = getInjectable({ id: "is-allowed-resource", - instantiate: (di, resourceName: KubeResource) => { + instantiate: (di, resourceName: string) => { const allowedResources = di.inject(allowedResourcesInjectable); return computed(() => allowedResources.get().has(resourceName)); }, lifecycle: lifecycleEnum.keyedSingleton({ - getInstanceKey: (di, resource: KubeResource) => resource, + getInstanceKey: (di, resource: string) => resource, }), }); diff --git a/src/common/utils/iter.ts b/src/common/utils/iter.ts index e1a9f41724..ac4ba60cc4 100644 --- a/src/common/utils/iter.ts +++ b/src/common/utils/iter.ts @@ -5,14 +5,67 @@ export type Falsey = false | 0 | "" | null | undefined; +export function pipeline(src: Iterable) { + return Iterator[createPipeline](src); +} + +const createPipeline = Symbol("create-pipeline"); + +export class Iterator { + #inner: Iterable; + + static [createPipeline](inner: Iterable) { + return new Iterator(inner); + } + + private constructor(inner: Iterable) { + this.#inner = inner; + } + + /** + * Wrap the interior iterator with an filter + */ + public filter(fn: (val: T) => any): Iterator { + this.#inner = filter(this.#inner, fn); + + return this; + } + + /** + * Wrap the interior iterator with an filterMap + */ + public filterMap(fn: (val: T) => Falsey | U): Iterator { + this.#inner = filterMap(this.#inner, fn); + + return this as never; + } + + /** + * Consume the interior iterator until an element matches the callback and return that + */ + public find(fn: (val: T) => any): T | undefined { + return find(this.#inner, fn); + } + + /** + * Consume the interior iterator and produce a new type + */ + public collect(fn: (values: Iterable) => U): U { + return fn(this.#inner); + } + + public map(fn: (val: T) => U): Iterator { + this.#inner = map(this.#inner, fn); + + return this as never; + } +} + /** * Create a new type safe empty Iterable * @returns An `Iterable` that yields 0 items */ -// eslint-disable-next-line require-yield -export function* newEmpty(): IterableIterator { - return; -} +export function* newEmpty(): IterableIterator {} /** * Creates a new `Iterable` that yields at most n items from src. @@ -167,25 +220,31 @@ export function reduce(src: Iterable, reducer: (acc: R, cur: T) => * @param connector The string value to intersperse between the yielded values * @returns The concatenated entries of `src` interspersed with copies of `connector` */ -export function join(src: Iterable, connector = ","): string { +export function join(src: Iterable, connector = ","): string { return reduce(src, (acc, cur) => `${acc}${connector}${cur}`, ""); } /** - * Returns the next value after iterating over the iterable `index` times. - * - * For example: `nth(["a", "b"], 0)` will return `"a"` - * For example: `nth(["a", "b"], 1)` will return `"b"` - * For example: `nth(["a", "b"], 2)` will return `undefined` + * Iterate `n` times and then return the next value. + * @param src The value to iterate over + * @param n The zero-index value for the item to return to. */ -export function nth(src: Iterable, index: number): T | undefined { - const iteree = src[Symbol.iterator](); +export function nth(src: Iterable, n: number): T | undefined { + const iterator = src[Symbol.iterator](); - while (index-- > 0) { - iteree.next(); + while (n --> 0) { + iterator.next(); } - return iteree.next().value; + return iterator.next().value; +} + +/** + * A convenience function to get the first item of an iterator + * @param src The value to iterate over + */ +export function first(src: Iterable): T | undefined { + return nth(src, 0); } /** diff --git a/src/common/utils/json.ts b/src/common/utils/json.ts new file mode 100644 index 0000000000..53d357f05c --- /dev/null +++ b/src/common/utils/json.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { JsonValue } from "type-fest"; + +export function parse(input: string): JsonValue { + return JSON.parse(input); +} diff --git a/src/common/utils/lazy-initialized.ts b/src/common/utils/lazy-initialized.ts index 866999e208..62247873fe 100644 --- a/src/common/utils/lazy-initialized.ts +++ b/src/common/utils/lazy-initialized.ts @@ -23,7 +23,8 @@ export function lazyInitialized(builder: () => T): LazyInitialized { return { get() { if (called) { - return value; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return value!; } value = builder(); diff --git a/src/common/utils/objects.ts b/src/common/utils/objects.ts index 342d1b00a1..f736198c91 100644 --- a/src/common/utils/objects.ts +++ b/src/common/utils/objects.ts @@ -8,9 +8,16 @@ * be a specific subset */ export function fromEntries(entries: Iterable): Record { - return Object.fromEntries(entries) as { [k in Key]: T }; + return Object.fromEntries(entries) as Record; } -export function entries>(obj: T): [keyof T, T[keyof T]][] { - return Object.entries(obj); +export function entries(obj: Record | null | undefined): [K, V][]; +export function entries(obj: Partial> | null | undefined): [K, V | undefined][]; + +export function entries(obj: Record | null | undefined): [K, V][] { + if (obj && typeof obj == "object") { + return Object.entries(obj) as never; + } + + return [] as never; } diff --git a/src/common/utils/readableStream.ts b/src/common/utils/readableStream.ts index 750d4cc901..5fd49ba699 100644 --- a/src/common/utils/readableStream.ts +++ b/src/common/utils/readableStream.ts @@ -24,7 +24,7 @@ export class ReadableWebToNodeStream extends Readable { * https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader */ private reader: ReadableStreamReader; - private pendingRead: Promise>; + private pendingRead?: Promise>; /** * diff --git a/src/common/utils/readonly.ts b/src/common/utils/readonly.ts new file mode 100644 index 0000000000..b379594d9d --- /dev/null +++ b/src/common/utils/readonly.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ReadonlyDeep } from "type-fest"; + +export function readonly(src: T): ReadonlyDeep { + return src as ReadonlyDeep; +} diff --git a/src/common/utils/singleton.ts b/src/common/utils/singleton.ts index 5f1172b660..d553de52db 100644 --- a/src/common/utils/singleton.ts +++ b/src/common/utils/singleton.ts @@ -51,6 +51,8 @@ export class Singleton { * Default: `true` * @returns An instance of the child class */ + static getInstance(this: StaticThis, strict?: true): T; + static getInstance(this: StaticThis, strict: false): T | undefined; static getInstance(this: StaticThis, strict = true): T | undefined { if (!Singleton.instances.has(this) && strict) { throw new TypeError(`instance of ${this.name} is not created`); diff --git a/src/common/utils/sort-compare.ts b/src/common/utils/sort-compare.ts index f05379cabd..df4c59b961 100644 --- a/src/common/utils/sort-compare.ts +++ b/src/common/utils/sort-compare.ts @@ -51,7 +51,7 @@ export function sortCompare(left: T, right: T): Ordering { interface ChartVersion { version: string; - __version?: SemVer; + __version?: SemVer | null; } export function sortCompareChartVersions(left: ChartVersion, right: ChartVersion): Ordering { @@ -74,13 +74,13 @@ export function sortCompareChartVersions(left: ChartVersion, right: ChartVersion export function sortCharts(charts: RawHelmChart[]) { interface ExtendedHelmChart extends RawHelmChart { - __version: SemVer; + __version?: SemVer | null; } const chartsWithVersion = Array.from( iter.map( charts, - (chart => { + chart => { const __version = coerce(chart.version, { includePrerelease: true, loose: true }); if (!__version) { @@ -90,7 +90,7 @@ export function sortCharts(charts: RawHelmChart[]) { (chart as ExtendedHelmChart).__version = __version; return chart as ExtendedHelmChart; - }), + }, ), ); diff --git a/src/common/utils/string.ts b/src/common/utils/string.ts new file mode 100644 index 0000000000..5b535aa384 --- /dev/null +++ b/src/common/utils/string.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * Transforms `val` so that the first character is uppercased + */ +export function uppercaseFirst(val: string): string { + if (val.length === 0) { + return ""; + } + + if (val.length === 1) { + return val.toUpperCase(); + } + + const [first, ...rest] = val; + + return first.toUpperCase() + rest; +} diff --git a/src/common/utils/tar.ts b/src/common/utils/tar.ts index a72f8b4a1a..d351ec2507 100644 --- a/src/common/utils/tar.ts +++ b/src/common/utils/tar.ts @@ -8,14 +8,26 @@ import type { ExtractOptions, FileStat } from "tar"; import tar from "tar"; import path from "path"; +import { parse } from "./json"; +import type { JsonValue } from "type-fest"; -export interface ReadFileFromTarOpts { +export type ReadFileFromTarOpts = { tarPath: string; filePath: string; - parseJson?: boolean; -} +} & ( + ParseJson extends true + ? { + parseJson: true; + } + : { + parseJson?: false; + } +); -export function readFileFromTar({ tarPath, filePath, parseJson }: ReadFileFromTarOpts): Promise { +export function readFileFromTar(opts: ReadFileFromTarOpts): Promise; +export function readFileFromTar(opts: ReadFileFromTarOpts): Promise; + +export function readFileFromTar({ tarPath, filePath, parseJson = false }: ReadFileFromTarOpts): Promise { return new Promise((resolve, reject) => { const fileChunks: Buffer[] = []; @@ -32,7 +44,7 @@ export function readFileFromTar({ tarPath, filePath, parseJson }: Re }); entry.once("end", () => { const data = Buffer.concat(fileChunks); - const result = parseJson ? JSON.parse(data.toString("utf8")) : data; + const result = parseJson ? parse(data.toString("utf8")) : data; resolve(result); }); @@ -51,7 +63,7 @@ export async function listTarEntries(filePath: string): Promise { await tar.list({ file: filePath, onentry: (entry: FileStat) => { - entries.push(path.normalize(entry.path as any as string)); + entries.push(path.normalize(entry.path as unknown as string)); }, }); diff --git a/src/common/utils/tuple.ts b/src/common/utils/tuple.ts index 019dafcffc..2714ec23fa 100644 --- a/src/common/utils/tuple.ts +++ b/src/common/utils/tuple.ts @@ -24,6 +24,9 @@ type TupleOfImpl = R["length"] extends * @yields A tuple of the next element from each of the sources * @returns The tuple of all the sources as soon as at least one of the sources is exausted */ +export function zip(src1: T[]): Iterator<[T], Tuple>; +export function zip(src1: T[], src2: T[]): Iterator<[T, T], Tuple>; + export function* zip(...sources: Tuple): Iterator, Tuple> { const maxSafeLength = Math.min(...sources.map(source => source.length)); diff --git a/src/common/utils/type-narrowing.ts b/src/common/utils/type-narrowing.ts index d03dfa6b9a..bd5a404e14 100644 --- a/src/common/utils/type-narrowing.ts +++ b/src/common/utils/type-narrowing.ts @@ -3,6 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { ExecException, ExecFileException } from "child_process"; +import type { IncomingMessage } from "http"; + /** * Narrows `val` to include the property `key` (if true is returned) * @param val The object to be tested @@ -38,7 +41,7 @@ export function hasTypedProperty(val * @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing) * @param isValid a function to check if the field (when present) is valid */ -export function hasOptionalProperty(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]?: V }) { +export function hasOptionalTypedProperty(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]?: V }) { if (hasOwnProperty(val, key)) { return typeof val[key] === "undefined" || isValid(val[key]); } @@ -73,6 +76,29 @@ export function isString(val: unknown): val is string { return typeof val === "string"; } +/** + * checks if val is of type Buffer + * @param val the value to be checked + */export function isBuffer(val: unknown): val is Buffer { + return val instanceof Buffer; +} + +/** + * checks if val is of type number + * @param val the value to be checked + */ +export function isNumber(val: unknown): val is number { + return typeof val === "number"; +} + +/** + * checks if val is of type boolean + * @param val the value to be checked + */ +export function isBoolean(val: unknown): val is boolean { + return typeof val === "boolean"; +} + /** * checks if val is of type object and isn't null * @param val the value to be checked @@ -81,6 +107,20 @@ export function isObject(val: unknown): val is object { return typeof val === "object" && val !== null; } +/** + * checks if `val` is defined, useful for filtering out undefined values in a strict manner + */ +export function isDefined(val: T | undefined | null): val is T { + return val != null; +} + +/** + * Checks if the value in the second position is non-nullable + */ +export function hasDefinedTupleValue(pair: [K, V | undefined | null]): pair is [K, V] { + return pair[1] != null; +} + /** * Creates a new predicate function (with the same predicate) from `fn`. Such * that it can be called with just the value to be tested. @@ -92,3 +132,77 @@ export function isObject(val: unknown): val is object { export function bindPredicate(fn: (arg1: unknown, ...args: FnArgs) => arg1 is T, ...boundArgs: FnArgs): (arg1: unknown) => arg1 is T { return (arg1: unknown): arg1 is T => fn(arg1, ...boundArgs); } + +export function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return isObject(error) + && hasOptionalTypedProperty(error, "code", isString) + && hasOptionalTypedProperty(error, "path", isString) + && hasOptionalTypedProperty(error, "syscall", isString) + && hasOptionalTypedProperty(error, "errno", isNumber) + && error instanceof Error; +} + +export function isExecException(error: unknown): error is ExecException { + return isObject(error) + && hasOptionalTypedProperty(error, "cmd", isString) + && hasOptionalTypedProperty(error, "killed", isBoolean) + && hasOptionalTypedProperty(error, "signal", isString) + && hasOptionalTypedProperty(error, "code", isNumber) + && error instanceof Error; +} + +export function isExecFileException(error: unknown): error is ExecFileException { + return isExecException(error) && isErrnoException(error); +} + +export type OutputFormat = "string" | "buffer"; +export type ComputeOutputFormat = Format extends "string" + ? string + : Format extends "buffer" + ? Buffer + : string | Buffer; + +export interface ChildProcessExecpetion extends ExecFileException { + stderr: ComputeOutputFormat; + stdout: ComputeOutputFormat; +} + +const isStringOrBuffer = (val: unknown): val is string | Buffer => isString(val) || isBuffer(val); + +export function isChildProcessError(error: unknown, format?: OutputFormat): error is ChildProcessExecpetion { + if (!isExecFileException(error)) { + return false; + } + + if (format === "string") { + return hasTypedProperty(error, "stderr", isString) + && hasTypedProperty(error, "stdout", isString); + } else if (format === "buffer") { + return hasTypedProperty(error, "stderr", isBuffer) + && hasTypedProperty(error, "stdout", isBuffer); + } else { + return hasTypedProperty(error, "stderr", isStringOrBuffer) + && hasTypedProperty(error, "stdout", isStringOrBuffer); + } +} + +export interface RequestLikeError extends Error { + statusCode?: number; + failed?: boolean; + timedOut?: boolean; + error?: string; + response?: IncomingMessage & { body?: any }; +} + +/** + * A type guard for checking if the error is similar in shape to a request package error + */ +export function isRequestError(error: unknown): error is RequestLikeError { + return isObject(error) + && hasOptionalTypedProperty(error, "statusCode", isNumber) + && hasOptionalTypedProperty(error, "failed", isBoolean) + && hasOptionalTypedProperty(error, "timedOut", isBoolean) + && hasOptionalTypedProperty(error, "error", isString) + && hasOptionalTypedProperty(error, "response", isObject) + && error instanceof Error; +} diff --git a/src/common/utils/types.ts b/src/common/utils/types.ts new file mode 100644 index 0000000000..1261c04e61 --- /dev/null +++ b/src/common/utils/types.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * This type helps define which fields of some type will always be defined + */ +export type Defaulted = Required> & Omit; + +export type OptionVarient = { + type: Key; +} & Pick & { + [OtherKey in Exclude]?: undefined; +}; + +export type SingleOrMany = T | T[]; + +export type IfEquals = + (() => G extends T ? 1 : 2) extends + (() => G extends U ? 1 : 2) ? Y : N; diff --git a/src/common/utils/wait.ts b/src/common/utils/wait.ts new file mode 100644 index 0000000000..4e6ef7634a --- /dev/null +++ b/src/common/utils/wait.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { IComputedValue } from "mobx"; +import { runInAction, when } from "mobx"; +import type { Disposer } from "."; + +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +export async function waitUntilDefinied(getter: (() => T | null | undefined) | IComputedValue, opts?: { timeout?: number }): Promise { + return new Promise((resolve, reject) => { + let res: T | null | undefined; + + when( + () => { + res = typeof getter === "function" + ? getter() + : getter.get(); + + if (res != null) { + resolve(res); + + return true; + } + + return false; + }, + () => {}, + { + onError: reject, + ...opts, + }, + ); + }); +} + +export function onceDefined(getter: () => T | null | undefined, action: (val: T) => void): Disposer { + let res: T | null | undefined; + + return when( + () => { + res = getter(); + + if (res != null) { + const r = res; + + runInAction(() => { + action(r); + }); + + return true; + } + + return false; + }, + () => {}, + ); +} diff --git a/src/common/vars.ts b/src/common/vars.ts index 52c9a15156..d3437ba550 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -108,8 +108,8 @@ export const staticFilesDirectory = path.resolve( ); // Apis -export const apiPrefix = "/api" as string; // local router apis -export const apiKubePrefix = "/api-kube" as string; // k8s cluster apis +export const apiPrefix = "/api"; // local router apis +export const apiKubePrefix = "/api-kube"; // k8s cluster apis // Links export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" as string; diff --git a/src/extensions/__tests__/lens-extension.test.ts b/src/extensions/__tests__/lens-extension.test.ts index fe46d32afc..bde98fd0cb 100644 --- a/src/extensions/__tests__/lens-extension.test.ts +++ b/src/extensions/__tests__/lens-extension.test.ts @@ -9,7 +9,7 @@ import { stdout, stderr } from "process"; console = new Console(stdout, stderr); -let ext: LensExtension = null; +let ext: LensExtension; describe("lens extension", () => { beforeEach(async () => { diff --git a/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-object-for-extension-api.ts b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-object-for-extension-api.ts index b9ed0f826e..f9016f045b 100644 --- a/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-object-for-extension-api.ts +++ b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-object-for-extension-api.ts @@ -7,17 +7,23 @@ import { asLegacyGlobalForExtensionApi } from "./as-legacy-global-object-for-ext import { getLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api"; import loggerInjectable from "../../common/logger.injectable"; -export const asLegacyGlobalSingletonForExtensionApi = < - Instance, - InstantiationParameter = void, ->( - injectable: Injectable, - instantiationParameter?: InstantiationParameter, - ) => { +export interface LegacySingleton { + createInstance(): T; + getInstance(): T; + resetInstance(): void; +} + +export function asLegacyGlobalSingletonForExtensionApi(injectable: Injectable): LegacySingleton; +export function asLegacyGlobalSingletonForExtensionApi(injectable: Injectable, param: InstantiationParameter): LegacySingleton; + +export function asLegacyGlobalSingletonForExtensionApi( + injectable: Injectable, + instantiationParameter?: InstantiationParameter, +): LegacySingleton { const instance = asLegacyGlobalForExtensionApi( - injectable, + injectable as never, instantiationParameter, - ); + ) as Instance; return { createInstance: () => instance, @@ -33,4 +39,4 @@ export const asLegacyGlobalSingletonForExtensionApi = < ); }, }; -}; +} diff --git a/src/extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api.ts b/src/extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api.ts index 4e4dfba0a8..f9b8f15365 100644 --- a/src/extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api.ts +++ b/src/extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api.ts @@ -3,6 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import type { DiContainer } from "@ogre-tools/injectable"; +import assert from "assert"; const legacyGlobalDis = new Map(); @@ -28,5 +29,10 @@ export const getLegacyGlobalDiForExtensionApi = () => { return globalDis[0]; }; -export const getEnvironmentSpecificLegacyGlobalDiForExtensionApi = (environment: Environments) => - legacyGlobalDis.get(environment); +export function getEnvironmentSpecificLegacyGlobalDiForExtensionApi(environment: Environments) { + const di = legacyGlobalDis.get(environment); + + assert(di, `missing di for environment=${Environments[environment]}`); + + return di; +} diff --git a/src/extensions/extension-discovery/extension-discovery.test.ts b/src/extensions/extension-discovery/extension-discovery.test.ts index 84737a50d2..c761ee5eef 100644 --- a/src/extensions/extension-discovery/extension-discovery.test.ts +++ b/src/extensions/extension-discovery/extension-discovery.test.ts @@ -3,6 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { FSWatcher } from "chokidar"; import { watch } from "chokidar"; import path from "path"; import os from "os"; @@ -66,7 +67,7 @@ describe("ExtensionDiscovery", () => { }); it("emits add for added extension", async (done) => { - let addHandler: (filePath: string) => void; + let addHandler!: (filePath: string) => void; mockedFse.readJson.mockImplementation((p) => { expect(p).toBe(path.join(os.homedir(), ".k8slens/extensions/my-extension/package.json")); @@ -79,7 +80,7 @@ describe("ExtensionDiscovery", () => { mockedFse.pathExists.mockImplementation(() => true); - const mockWatchInstance: any = { + const mockWatchInstance = { on: jest.fn((event: string, handler: typeof addHandler) => { if (event === "add") { addHandler = handler; @@ -87,11 +88,9 @@ describe("ExtensionDiscovery", () => { return mockWatchInstance; }), - }; + } as unknown as FSWatcher; - mockedWatch.mockImplementationOnce(() => - (mockWatchInstance) as any, - ); + mockedWatch.mockImplementationOnce(() => mockWatchInstance); // Need to force isLoaded to be true so that the file watching is started extensionDiscovery.isLoaded = true; @@ -118,9 +117,9 @@ describe("ExtensionDiscovery", () => { }); it("doesn't emit add for added file under extension", async done => { - let addHandler: (filePath: string) => void; + let addHandler!: (filePath: string) => void; - const mockWatchInstance: any = { + const mockWatchInstance = { on: jest.fn((event: string, handler: typeof addHandler) => { if (event === "add") { addHandler = handler; @@ -128,11 +127,9 @@ describe("ExtensionDiscovery", () => { return mockWatchInstance; }), - }; + } as unknown as FSWatcher; - mockedWatch.mockImplementationOnce(() => - (mockWatchInstance) as any, - ); + mockedWatch.mockImplementationOnce(() => mockWatchInstance); // Need to force isLoaded to be true so that the file watching is started extensionDiscovery.isLoaded = true; diff --git a/src/extensions/extension-discovery/extension-discovery.ts b/src/extensions/extension-discovery/extension-discovery.ts index 846a13f17e..30cca5de96 100644 --- a/src/extensions/extension-discovery/extension-discovery.ts +++ b/src/extensions/extension-discovery/extension-discovery.ts @@ -11,7 +11,7 @@ import { makeObservable, observable, reaction, when } from "mobx"; import os from "os"; import path from "path"; import { broadcastMessage, ipcMainHandle, ipcRendererOn } from "../../common/ipc"; -import { toJS } from "../../common/utils"; +import { isErrnoException, toJS } from "../../common/utils"; import logger from "../../main/logger"; import type { ExtensionsStore } from "../extensions-store/extensions-store"; import type { ExtensionLoader } from "../extension-loader"; @@ -81,7 +81,7 @@ interface LoadFromFolderOptions { * - "remove": When extension is removed. The event is of type LensExtensionId */ export class ExtensionDiscovery { - protected bundledFolderPath: string; + protected bundledFolderPath!: string; private loadStarted = false; private extensions: Map = new Map(); @@ -271,7 +271,13 @@ export class ExtensionDiscovery { * @param extensionId The ID of the extension to uninstall. */ async uninstallExtension(extensionId: LensExtensionId): Promise { - const { manifest, absolutePath } = this.extensions.get(extensionId) ?? this.dependencies.extensionLoader.getExtension(extensionId); + const extension = this.extensions.get(extensionId) ?? this.dependencies.extensionLoader.getExtension(extensionId); + + if (!extension) { + return void logger.warn(`${logModule} could not uninstall extension, not found`, { id: extensionId }); + } + + const { manifest, absolutePath } = extension; logger.info(`${logModule} Uninstalling ${manifest.name}`); @@ -369,7 +375,7 @@ export class ExtensionDiscovery { isCompatible, }; } catch (error) { - if (error.code === "ENOTDIR") { + if (isErrnoException(error) && error.code === "ENOTDIR") { // ignore this error, probably from .DS_Store file logger.debug(`${logModule}: failed to load extension manifest through a not-dir-like at ${manifestPath}`); } else { @@ -392,10 +398,12 @@ export class ExtensionDiscovery { try { await this.dependencies.installExtension(extension.absolutePath); } catch (error) { - const message = error.message || error || "unknown error"; + const message = error instanceof Error + ? error.message + : String(error || "unknown error"); const { name, version } = extension.manifest; - logger.error(`${logModule}: failed to install user extension ${name}@${version}`, message); + logger.error(`${logModule}: failed to install user extension ${name}@${version}: ${message}`); } } } diff --git a/src/extensions/extension-installer/extension-installer.ts b/src/extensions/extension-installer/extension-installer.ts index 45d84eb0da..0c0a1554dc 100644 --- a/src/extensions/extension-installer/extension-installer.ts +++ b/src/extensions/extension-installer/extension-installer.ts @@ -74,7 +74,7 @@ export class ExtensionInstaller { }); let stderr = ""; - child.stderr.on("data", data => { + child.stderr?.on("data", data => { stderr += String(data); }); diff --git a/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.ts b/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.ts index 666a535c3e..003d9eed6b 100644 --- a/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.ts +++ b/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.ts @@ -10,7 +10,7 @@ import { action, makeObservable, observable } from "mobx"; import path from "path"; import { BaseStore } from "../../../../common/base-store"; import type { LensExtensionId } from "../../../lens-extension"; -import { toJS } from "../../../../common/utils"; +import { getOrInsertWith, toJS } from "../../../../common/utils"; interface FSProvisionModel { extensions: Record; // extension names to paths @@ -41,16 +41,12 @@ export class FileSystemProvisionerStore extends BaseStore { * @returns path to the folder that the extension can safely write files to. */ async requestDirectory(extensionName: string): Promise { - if (!this.registeredExtensions.has(extensionName)) { + const dirPath = getOrInsertWith(this.registeredExtensions, extensionName, () => { const salt = randomBytes(32).toString("hex"); const hashedName = SHA256(`${extensionName}/${salt}`).toString(); - const dirPath = path.resolve(this.dependencies.directoryForExtensionData, hashedName); - - this.registeredExtensions.set(extensionName, dirPath); - } - - const dirPath = this.registeredExtensions.get(extensionName); + return path.resolve(this.dependencies.directoryForExtensionData, hashedName); + }); await fse.ensureDir(dirPath); diff --git a/src/extensions/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index e1e1896fc1..d957dc4d4a 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -10,9 +10,9 @@ import { action, computed, makeObservable, observable, observe, reaction, when } import path from "path"; import { broadcastMessage, ipcMainOn, ipcRendererOn, ipcMainHandle } from "../../common/ipc"; import type { Disposer } from "../../common/utils"; -import { toJS } from "../../common/utils"; +import { isDefined, toJS } from "../../common/utils"; import logger from "../../main/logger"; -import type { KubernetesCluster } from "../common-api/catalog"; +import type { CatalogEntity, KubernetesCluster } from "../common-api/catalog"; import type { InstalledExtension } from "../extension-discovery/extension-discovery"; import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "../lens-extension"; import type { LensRendererExtension } from "../lens-renderer-extension"; @@ -20,6 +20,7 @@ import * as registries from "../registries"; import type { LensExtensionState } from "../extensions-store/extensions-store"; import { extensionLoaderFromMainChannel, extensionLoaderFromRendererChannel } from "../../common/ipc/extension-handling"; import { requestExtensionLoaderInitialState } from "../../renderer/ipc"; +import assert from "assert"; const logModule = "[EXTENSIONS-LOADER]"; @@ -152,7 +153,7 @@ export class ExtensionLoader { ); } - initExtensions(extensions?: Map) { + initExtensions(extensions: Map) { this.extensions.replace(extensions); } @@ -188,7 +189,11 @@ export class ExtensionLoader { } setIsEnabled(lensExtensionId: LensExtensionId, isEnabled: boolean) { - this.extensions.get(lensExtensionId).isEnabled = isEnabled; + const extension = this.extensions.get(lensExtensionId); + + assert(extension, `Must register extension ${lensExtensionId} with before enabling it`); + + extension.isEnabled = isEnabled; } protected async initMain() { @@ -248,7 +253,8 @@ export class ExtensionLoader { loadOnClusterManagerRenderer = () => { logger.debug(`${logModule}: load on main renderer (cluster manager)`); - return this.autoInitExtensions(async (extension: LensRendererExtension) => { + return this.autoInitExtensions(async (ext) => { + const extension = ext as LensRendererExtension; const removeItems = [ registries.EntitySettingRegistry.getInstance().add(extension.entitySettings), registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems), @@ -266,12 +272,15 @@ export class ExtensionLoader { }); }; - loadOnClusterRenderer = (getCluster: () => KubernetesCluster) => { + loadOnClusterRenderer = (getCluster: () => CatalogEntity) => { logger.debug(`${logModule}: load on cluster renderer (dashboard)`); - this.autoInitExtensions(async (extension: LensRendererExtension) => { + this.autoInitExtensions(async (ext) => { + const entity = getCluster() as KubernetesCluster; + const extension = ext as LensRendererExtension; + // getCluster must be a callback, as the entity might be available only after an extension has been loaded - if ((await extension.isEnabledForCluster(getCluster())) === false) { + if ((await extension.isEnabledForCluster(entity)) === false) { return []; } @@ -332,7 +341,9 @@ export class ExtensionLoader { } return null; - }).filter(extension => Boolean(extension)); + }) + // Remove null values + .filter(isDefined); // We first need to wait until each extension's `onActivate` is resolved or rejected, // as this might register new catalog categories. Afterwards we can safely .enable the extension. @@ -346,7 +357,7 @@ export class ExtensionLoader { ); extensions.forEach(({ instance }) => { - const installationCount = (this.dependencies.extensionInstallationCounter.get(instance.sanitizedExtensionId) | 0) + 1; + const installationCount = (this.dependencies.extensionInstallationCounter.get(instance.sanitizedExtensionId) ?? 0) + 1; this.dependencies.extensionInstallationCounter.set(instance.sanitizedExtensionId, installationCount); @@ -389,7 +400,7 @@ export class ExtensionLoader { try { return __non_webpack_require__(extAbsolutePath).default; } catch (error) { - if (ipcRenderer) { + if (window && error instanceof window.Error) { console.error(`${logModule}: can't load ${entryPointName} for "${extension.manifest.name}": ${error.stack || error}`, extension); } else { logger.error(`${logModule}: can't load ${entryPointName} for "${extension.manifest.name}": ${error}`, { extension }); @@ -399,12 +410,12 @@ export class ExtensionLoader { return null; } - getExtension(extId: LensExtensionId): InstalledExtension { + getExtension(extId: LensExtensionId) { return this.extensions.get(extId); } - getInstanceById(extId: LensExtensionId): E { - return this.instances.get(extId) as E; + getInstanceById(extId: LensExtensionId) { + return this.instances.get(extId); } toJSON(): Map { diff --git a/src/extensions/extension-store.ts b/src/extensions/extension-store.ts index 561b314e6a..62799f9843 100644 --- a/src/extensions/extension-store.ts +++ b/src/extensions/extension-store.ts @@ -6,10 +6,11 @@ import { BaseStore } from "../common/base-store"; import * as path from "path"; import type { LensExtension } from "./lens-extension"; +import assert from "assert"; export abstract class ExtensionStore extends BaseStore { readonly displayName = "ExtensionStore"; - protected extension: LensExtension; + protected extension?: LensExtension; loadExtension(extension: LensExtension) { this.extension = extension; @@ -24,6 +25,8 @@ export abstract class ExtensionStore extends BaseStore { } protected cwd() { + assert(this.extension, "must call this.load() first"); + return path.join(super.cwd(), "extension-store", this.extension.name); } } diff --git a/src/extensions/extensions-store/extensions-store.ts b/src/extensions/extensions-store/extensions-store.ts index fe43601abf..c1345bfec9 100644 --- a/src/extensions/extensions-store/extensions-store.ts +++ b/src/extensions/extensions-store/extensions-store.ts @@ -42,7 +42,7 @@ export class ExtensionsStore extends BaseStore { return isBundled || Boolean(this.state.get(id)?.enabled); } - mergeState = action((extensionsState: Record) => { + mergeState = action((extensionsState: Record | [LensExtensionId, LensExtensionState][]) => { this.state.merge(extensionsState); }); diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index 36a0955f47..1758f0698e 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -68,10 +68,10 @@ export class LensExtension { return this.manifest.description; } - private dependencies: LensExtensionDependencies; + private readonly dependencies!: LensExtensionDependencies; [setLensExtensionDependencies] = (dependencies: LensExtensionDependencies) => { - this.dependencies = dependencies; + (this as unknown as { dependencies: LensExtensionDependencies }).dependencies = dependencies; }; /** @@ -86,7 +86,7 @@ export class LensExtension { } @action - async enable(register: (ext: LensExtension) => Promise) { + async enable(register: (ext: this) => Promise) { if (this._isEnabled) { return; } @@ -136,11 +136,13 @@ export function sanitizeExtensionName(name: string) { return name.replace("@", "").replace("/", "--"); } -export const getSanitizedPath = (...parts: string[]) => parts - .filter(Boolean) - .join("/") - .replace(/\/+/g, "/") - .replace(/\/$/, ""); // normalize multi-slashes (e.g. coming from page.id) +export function getSanitizedPath(...parts: string[]) { + return parts + .filter(Boolean) + .join("/") + .replace(/\/+/g, "/") + .replace(/\/$/, ""); +} // normalize multi-slashes (e.g. coming from page.id) export function extensionDisplayName(name: string, version: string) { return `${name}@${version}`; diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 9c897431dc..02329930b1 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -28,8 +28,8 @@ import routesInjectable from "../renderer/routes/routes.injectable"; import { fromPairs, map, matches, toPairs } from "lodash/fp"; import extensionPageParametersInjectable from "../renderer/routes/extension-page-parameters.injectable"; import { pipeline } from "@ogre-tools/fp"; -import { getExtensionRoutePath } from "../renderer/routes/get-extension-route-path"; import { navigateToRouteInjectionToken } from "../common/front-end-routing/navigate-to-route-injection-token"; +import { getExtensionRoutePath } from "../renderer/routes/for-extension"; export class LensRendererExtension extends LensExtension { globalPages: registries.PageRegistration[] = []; @@ -50,49 +50,41 @@ export class LensRendererExtension extends LensExtension { additionalCategoryColumns: AdditionalCategoryColumnRegistration[] = []; customCategoryViews: CustomCategoryViewRegistration[] = []; - async navigate

(pageId?: string, params?: P) { - const di = getEnvironmentSpecificLegacyGlobalDiForExtensionApi( - Environments.renderer, - ); - + async navigate(pageId?: string, params: object = {}) { + const di = getEnvironmentSpecificLegacyGlobalDiForExtensionApi(Environments.renderer); const navigateToRoute = di.inject(navigateToRouteInjectionToken); const routes = di.inject(routesInjectable).get(); + const targetRegistration = [...this.globalPages, ...this.clusterPages] + .find(registration => registration.id === (pageId || undefined)); - const targetRegistration = [...this.globalPages, ...this.clusterPages].find( - registration => registration.id === (pageId || undefined), - ); + if (!targetRegistration) { + return; + } const targetRoutePath = getExtensionRoutePath(this, targetRegistration.id); - const targetRoute = routes.find(matches({ path: targetRoutePath })); - if (targetRoute) { - const normalizedParams = di.inject(extensionPageParametersInjectable, { - extension: this, - registration: targetRegistration, - }); - - const query = pipeline( - params, - - toPairs, - - map(([key, value]) => { - const normalizedParam = normalizedParams[key]; - - return [ - key, - normalizedParam.stringify(value), - ]; - }), - - fromPairs, - ); - - navigateToRoute(targetRoute, { - query, - }); + if (!targetRoute) { + return; } + + const normalizedParams = di.inject(extensionPageParametersInjectable, { + extension: this, + registration: targetRegistration, + }); + const query = pipeline( + params, + toPairs, + map(([key, value]) => [ + key, + normalizedParams[key].stringify(value), + ]), + fromPairs, + ); + + navigateToRoute(targetRoute, { + query, + }); } /** diff --git a/src/extensions/main-api/catalog.ts b/src/extensions/main-api/catalog.ts index 1881f9fe85..d2b447f2b4 100644 --- a/src/extensions/main-api/catalog.ts +++ b/src/extensions/main-api/catalog.ts @@ -8,10 +8,16 @@ import { catalogEntityRegistry as registry } from "../../main/catalog"; export { catalogCategoryRegistry as catalogCategories } from "../../common/catalog/catalog-category-registry"; -export class CatalogEntityRegistry { - getItemsForApiKind(apiVersion: string, kind: string): T[] { - return registry.getItemsForApiKind(apiVersion, kind); - } +export interface CatalogEntityRegistry { + getItemsForApiKind(apiVersion: string, kind: string): CatalogEntity[]; + /** + * @deprecated use a cast instead of a unbounded type parameter + */ + getItemsForApiKind(apiVersion: string, kind: string): T[]; } -export const catalogEntities = new CatalogEntityRegistry(); +export const catalogEntities: CatalogEntityRegistry = { + getItemsForApiKind(apiVersion: string, kind: string) { + return registry.filterItemsForApiKind(apiVersion, kind); + }, +}; diff --git a/src/extensions/main-api/k8s-api.ts b/src/extensions/main-api/k8s-api.ts index e9ea0650ac..8515f41e0e 100644 --- a/src/extensions/main-api/k8s-api.ts +++ b/src/extensions/main-api/k8s-api.ts @@ -16,30 +16,30 @@ export { apiManager } from "../../common/k8s-api/api-manager"; export { KubeApi, forCluster, forRemoteCluster } from "../../common/k8s-api/kube-api"; export { KubeObject, KubeStatus } from "../../common/k8s-api/kube-object"; export { KubeObjectStore } from "../../common/k8s-api/kube-object.store"; -export { Pod, podsApi, PodsApi } from "../../common/k8s-api/endpoints/pods.api"; -export { Node, nodesApi, NodesApi } from "../../common/k8s-api/endpoints/nodes.api"; +export { Pod, podApi as podsApi, PodApi as PodsApi } from "../../common/k8s-api/endpoints/pods.api"; +export { Node, nodeApi as nodesApi, NodeApi as NodesApi } from "../../common/k8s-api/endpoints/nodes.api"; export { Deployment, deploymentApi, DeploymentApi } from "../../common/k8s-api/endpoints/deployment.api"; export { DaemonSet, daemonSetApi } from "../../common/k8s-api/endpoints/daemon-set.api"; export { StatefulSet, statefulSetApi } from "../../common/k8s-api/endpoints/stateful-set.api"; export { Job, jobApi } from "../../common/k8s-api/endpoints/job.api"; export { CronJob, cronJobApi } from "../../common/k8s-api/endpoints/cron-job.api"; export { ConfigMap, configMapApi } from "../../common/k8s-api/endpoints/configmap.api"; -export { Secret, secretsApi } from "../../common/k8s-api/endpoints/secret.api"; +export { Secret, secretApi as secretsApi } from "../../common/k8s-api/endpoints/secret.api"; export { ReplicaSet, replicaSetApi } from "../../common/k8s-api/endpoints/replica-set.api"; export { ResourceQuota, resourceQuotaApi } from "../../common/k8s-api/endpoints/resource-quota.api"; export { LimitRange, limitRangeApi } from "../../common/k8s-api/endpoints/limit-range.api"; -export { HorizontalPodAutoscaler, hpaApi } from "../../common/k8s-api/endpoints/hpa.api"; -export { PodDisruptionBudget, pdbApi } from "../../common/k8s-api/endpoints/poddisruptionbudget.api"; +export { HorizontalPodAutoscaler, horizontalPodAutoscalerApi as hpaApi } from "../../common/k8s-api/endpoints/hpa.api"; +export { PodDisruptionBudget, podDisruptionBudgetApi as pdbApi } from "../../common/k8s-api/endpoints/poddisruptionbudget.api"; export { Service, serviceApi } from "../../common/k8s-api/endpoints/service.api"; -export { Endpoint, endpointApi } from "../../common/k8s-api/endpoints/endpoint.api"; +export { Endpoints as Endpoint, endpointsApi as endpointApi } from "../../common/k8s-api/endpoints/endpoint.api"; export { Ingress, ingressApi, IngressApi } from "../../common/k8s-api/endpoints/ingress.api"; export { NetworkPolicy, networkPolicyApi } from "../../common/k8s-api/endpoints/network-policy.api"; export { PersistentVolume, persistentVolumeApi } from "../../common/k8s-api/endpoints/persistent-volume.api"; -export { PersistentVolumeClaim, pvcApi, PersistentVolumeClaimsApi } from "../../common/k8s-api/endpoints/persistent-volume-claims.api"; +export { PersistentVolumeClaim, persistentVolumeClaimApi as pvcApi, PersistentVolumeClaimApi as PersistentVolumeClaimsApi } from "../../common/k8s-api/endpoints/persistent-volume-claims.api"; export { StorageClass, storageClassApi } from "../../common/k8s-api/endpoints/storage-class.api"; export { Namespace, namespacesApi } from "../../common/k8s-api/endpoints/namespaces.api"; export { KubeEvent, eventApi } from "../../common/k8s-api/endpoints/events.api"; -export { ServiceAccount, serviceAccountsApi } from "../../common/k8s-api/endpoints/service-accounts.api"; +export { ServiceAccount, serviceAccountApi as serviceAccountsApi } from "../../common/k8s-api/endpoints/service-accounts.api"; export { Role, roleApi } from "../../common/k8s-api/endpoints/role.api"; export { RoleBinding, roleBindingApi } from "../../common/k8s-api/endpoints/role-binding.api"; export { ClusterRole, clusterRoleApi } from "../../common/k8s-api/endpoints/cluster-role.api"; @@ -49,6 +49,6 @@ export { CustomResourceDefinition, crdApi } from "../../common/k8s-api/endpoints // types export type { ILocalKubeApiConfig, IRemoteKubeApiConfig, IKubeApiCluster } from "../../common/k8s-api/kube-api"; export type { IPodContainer, IPodContainerStatus } from "../../common/k8s-api/endpoints/pods.api"; -export type { ISecretRef } from "../../common/k8s-api/endpoints/secret.api"; -export type { KubeObjectMetadata, KubeStatusData } from "../../common/k8s-api/kube-object"; +export type { SecretReference as ISecretRef } from "../../common/k8s-api/endpoints/secret.api"; +export type { KubeObjectMetadata, KubeJsonApiObjectMetadata, KubeStatusData } from "../../common/k8s-api/kube-object"; export type { KubeObjectStoreLoadAllParams, KubeObjectStoreLoadingParams, KubeObjectStoreSubscribeParams } from "../../common/k8s-api/kube-object.store"; diff --git a/src/extensions/registries/base-registry.ts b/src/extensions/registries/base-registry.ts index 73dd6141b8..b0c833f295 100644 --- a/src/extensions/registries/base-registry.ts +++ b/src/extensions/registries/base-registry.ts @@ -5,6 +5,7 @@ // Base class for extensions-api registries import { action, observable, makeObservable } from "mobx"; +import type { Disposer } from "../../common/utils"; import { Singleton } from "../../common/utils"; import type { LensExtension } from "../lens-extension"; @@ -21,7 +22,7 @@ export class BaseRegistry extends Singleton { } @action - add(items: T | T[], extension?: LensExtension) { + add(items: T | T[], extension?: LensExtension): Disposer { const itemArray = [items].flat() as T[]; itemArray.forEach(item => { @@ -33,7 +34,7 @@ export class BaseRegistry extends Singleton { // eslint-disable-next-line unused-imports/no-unused-vars-ts protected getRegisteredItem(item: T, extension?: LensExtension): I { - return item as any; + return item as never; } @action diff --git a/src/extensions/registries/catalog-entity-detail-registry.ts b/src/extensions/registries/catalog-entity-detail-registry.ts index ac1f676ebf..9c2aeb348c 100644 --- a/src/extensions/registries/catalog-entity-detail-registry.ts +++ b/src/extensions/registries/catalog-entity-detail-registry.ts @@ -4,6 +4,7 @@ */ import type React from "react"; +import type { Disposer } from "../../common/utils"; import type { CatalogEntity } from "../common-api/catalog"; import { BaseRegistry } from "./base-registry"; @@ -23,6 +24,10 @@ export interface CatalogEntityDetailRegistration { } export class CatalogEntityDetailRegistry extends BaseRegistry> { + add(items: CatalogEntityDetailRegistration | CatalogEntityDetailRegistration[]): Disposer { + return super.add(items as never); + } + getItemsForKind(kind: string, apiVersion: string) { const items = this.getItems().filter((item) => { return item.kind === kind && item.apiVersions.includes(apiVersion); diff --git a/src/extensions/registries/kube-object-detail-registry.ts b/src/extensions/registries/kube-object-detail-registry.ts index 964d575b52..7f6620fc95 100644 --- a/src/extensions/registries/kube-object-detail-registry.ts +++ b/src/extensions/registries/kube-object-detail-registry.ts @@ -4,6 +4,7 @@ */ import type React from "react"; +import type { Disposer } from "../../common/utils"; import type { KubeObjectDetailsProps } from "../renderer-api/components"; import type { KubeObject } from "../renderer-api/k8s-api"; import { BaseRegistry } from "./base-registry"; @@ -12,14 +13,20 @@ export interface KubeObjectDetailComponents { Details: React.ComponentType>; } -export interface KubeObjectDetailRegistration { +export interface KubeObjectDetailRegistration { kind: string; apiVersions: string[]; - components: KubeObjectDetailComponents; + components: KubeObjectDetailComponents; priority?: number; } export class KubeObjectDetailRegistry extends BaseRegistry { + add(items: KubeObjectDetailRegistration[]): Disposer; + add(item: KubeObjectDetailRegistration): Disposer; + add(items: KubeObjectDetailRegistration | KubeObjectDetailRegistration[]): Disposer { + return super.add(items); + } + getItemsForKind(kind: string, apiVersion: string) { const items = this.getItems().filter((item) => { return item.kind === kind && item.apiVersions.includes(apiVersion); diff --git a/src/extensions/registries/protocol-handler.ts b/src/extensions/registries/protocol-handler.ts index c463243131..9fdf390bee 100644 --- a/src/extensions/registries/protocol-handler.ts +++ b/src/extensions/registries/protocol-handler.ts @@ -20,12 +20,12 @@ export interface RouteParams { /** * the parts of the URI query string */ - search: Record; + search: Record; /** * the matching parts of the path. The dynamic parts of the URI path. */ - pathname: Record; + pathname: Record; /** * if the most specific path schema that is matched does not cover the whole diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 21733bd85e..eec93653f8 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -33,9 +33,7 @@ export * from "../../renderer/components/switch"; export * from "../../renderer/components/input/input"; // command-overlay -export const CommandOverlay = asLegacyGlobalForExtensionApi( - commandOverlayInjectable, -); +export const CommandOverlay = asLegacyGlobalForExtensionApi(commandOverlayInjectable); export type { CategoryColumnRegistration, @@ -81,39 +79,30 @@ export * from "../../renderer/components/+events/kube-event-details"; // specific exports export * from "../../renderer/components/status-brick"; -export const createTerminalTab = asLegacyGlobalFunctionForExtensionApi( - createTerminalTabInjectable, +export const createTerminalTab = asLegacyGlobalFunctionForExtensionApi(createTerminalTabInjectable); + +export const terminalStore = asLegacyGlobalObjectForExtensionApiWithModifications( + terminalStoreInjectable, + { + sendCommand: asLegacyGlobalFunctionForExtensionApi(sendCommandInjectable), + }, ); -export const terminalStore = - asLegacyGlobalObjectForExtensionApiWithModifications( - terminalStoreInjectable, - { - sendCommand: asLegacyGlobalFunctionForExtensionApi(sendCommandInjectable), - }, - ); +const renameTab = asLegacyGlobalFunctionForExtensionApi(renameTabInjectable); export const logTabStore = asLegacyGlobalObjectForExtensionApiWithModifications( logTabStoreInjectable, { - createPodTab: asLegacyGlobalFunctionForExtensionApi( - createPodLogsTabInjectable, - ), - - createWorkloadTab: asLegacyGlobalFunctionForExtensionApi( - createWorkloadLogsTabInjectable, - ), - + createPodTab: asLegacyGlobalFunctionForExtensionApi(createPodLogsTabInjectable), + createWorkloadTab: asLegacyGlobalFunctionForExtensionApi(createWorkloadLogsTabInjectable), renameTab: (tabId: string): void => { - const renameTab = - asLegacyGlobalFunctionForExtensionApi(renameTabInjectable); - const tabData = logTabStore.getData(tabId); - const pod = podsStore.getById(tabData.selectedPodId); + const pod = podsStore.getById(tabData?.selectedPodId); - renameTab(tabId, `Pod ${pod.getName()}`); + if (pod) { + renameTab(tabId, `Pod ${pod.getName()}`); + } }, - tabs: undefined, }, ); diff --git a/src/extensions/renderer-api/k8s-api.ts b/src/extensions/renderer-api/k8s-api.ts index f4952b2cd6..68673c4e01 100644 --- a/src/extensions/renderer-api/k8s-api.ts +++ b/src/extensions/renderer-api/k8s-api.ts @@ -25,30 +25,30 @@ export { apiManager } from "../../common/k8s-api/api-manager"; export { KubeObjectStore } from "../../common/k8s-api/kube-object.store"; export { KubeApi, forCluster, forRemoteCluster } from "../../common/k8s-api/kube-api"; export { KubeObject, KubeStatus } from "../../common/k8s-api/kube-object"; -export { Pod, podsApi, PodsApi } from "../../common/k8s-api/endpoints"; -export { Node, nodesApi, NodesApi } from "../../common/k8s-api/endpoints"; +export { Pod, podApi as podsApi, PodApi as PodsApi } from "../../common/k8s-api/endpoints"; +export { Node, nodeApi as nodesApi, NodeApi as NodesApi } from "../../common/k8s-api/endpoints"; export { Deployment, deploymentApi, DeploymentApi } from "../../common/k8s-api/endpoints"; export { DaemonSet, daemonSetApi } from "../../common/k8s-api/endpoints"; export { StatefulSet, statefulSetApi } from "../../common/k8s-api/endpoints"; export { Job, jobApi } from "../../common/k8s-api/endpoints"; export { CronJob, cronJobApi } from "../../common/k8s-api/endpoints"; export { ConfigMap, configMapApi } from "../../common/k8s-api/endpoints"; -export { Secret, secretsApi } from "../../common/k8s-api/endpoints"; +export { Secret, secretApi as secretsApi } from "../../common/k8s-api/endpoints"; export { ReplicaSet, replicaSetApi } from "../../common/k8s-api/endpoints"; export { ResourceQuota, resourceQuotaApi } from "../../common/k8s-api/endpoints"; export { LimitRange, limitRangeApi } from "../../common/k8s-api/endpoints"; -export { HorizontalPodAutoscaler, hpaApi } from "../../common/k8s-api/endpoints"; -export { PodDisruptionBudget, pdbApi } from "../../common/k8s-api/endpoints"; +export { HorizontalPodAutoscaler, horizontalPodAutoscalerApi as hpaApi } from "../../common/k8s-api/endpoints"; +export { PodDisruptionBudget, podDisruptionBudgetApi as pdbApi } from "../../common/k8s-api/endpoints"; export { Service, serviceApi } from "../../common/k8s-api/endpoints"; -export { Endpoint, endpointApi } from "../../common/k8s-api/endpoints"; +export { Endpoints as Endpoint, endpointsApi as endpointApi } from "../../common/k8s-api/endpoints"; export { Ingress, ingressApi, IngressApi } from "../../common/k8s-api/endpoints"; export { NetworkPolicy, networkPolicyApi } from "../../common/k8s-api/endpoints"; export { PersistentVolume, persistentVolumeApi } from "../../common/k8s-api/endpoints"; -export { PersistentVolumeClaim, pvcApi, PersistentVolumeClaimsApi } from "../../common/k8s-api/endpoints"; +export { PersistentVolumeClaim, persistentVolumeClaimApi as pvcApi, PersistentVolumeClaimApi as PersistentVolumeClaimsApi } from "../../common/k8s-api/endpoints"; export { StorageClass, storageClassApi } from "../../common/k8s-api/endpoints"; export { Namespace, namespacesApi } from "../../common/k8s-api/endpoints"; export { KubeEvent, eventApi } from "../../common/k8s-api/endpoints"; -export { ServiceAccount, serviceAccountsApi } from "../../common/k8s-api/endpoints"; +export { ServiceAccount, serviceAccountApi as serviceAccountsApi } from "../../common/k8s-api/endpoints"; export { Role, roleApi } from "../../common/k8s-api/endpoints"; export { RoleBinding, roleBindingApi } from "../../common/k8s-api/endpoints"; export { ClusterRole, clusterRoleApi } from "../../common/k8s-api/endpoints"; @@ -60,9 +60,9 @@ export { KubeJsonApi } from "../../common/k8s-api/kube-json-api"; // types export type { ILocalKubeApiConfig, IRemoteKubeApiConfig, IKubeApiCluster } from "../../common/k8s-api/kube-api"; export type { IPodContainer, IPodContainerStatus } from "../../common/k8s-api/endpoints"; -export type { ISecretRef } from "../../common/k8s-api/endpoints"; +export type { SecretReference as ISecretRef } from "../../common/k8s-api/endpoints"; export type { KubeObjectStatus } from "./kube-object-status"; -export type { KubeObjectMetadata, KubeStatusData } from "../../common/k8s-api/kube-object"; +export type { KubeJsonApiObjectMetadata as KubeObjectMetadata, KubeStatusData } from "../../common/k8s-api/kube-object"; export type { KubeObjectStoreLoadAllParams, KubeObjectStoreLoadingParams, KubeObjectStoreSubscribeParams } from "../../common/k8s-api/kube-object.store"; // stores @@ -74,23 +74,23 @@ export type { DaemonSetStore } from "../../renderer/components/+workloads-daemon export type { StatefulSetStore } from "../../renderer/components/+workloads-statefulsets/statefulset.store"; export type { JobStore } from "../../renderer/components/+workloads-jobs/job.store"; export type { CronJobStore } from "../../renderer/components/+workloads-cronjobs/cronjob.store"; -export type { ConfigMapsStore } from "../../renderer/components/+config-maps/config-maps.store"; -export type { SecretsStore } from "../../renderer/components/+config-secrets/secrets.store"; +export type { ConfigMapStore as ConfigMapsStore } from "../../renderer/components/+config-maps/config-maps.store"; +export type { SecretStore as SecretsStore } from "../../renderer/components/+config-secrets/secrets.store"; export type { ReplicaSetStore } from "../../renderer/components/+workloads-replicasets/replicasets.store"; -export type { ResourceQuotasStore } from "../../renderer/components/+config-resource-quotas/resource-quotas.store"; -export type { LimitRangesStore } from "../../renderer/components/+config-limit-ranges/limit-ranges.store"; -export type { HPAStore } from "../../renderer/components/+config-autoscalers/hpa.store"; -export type { PodDisruptionBudgetsStore } from "../../renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.store"; +export type { ResourceQuotaStore as ResourceQuotasStore } from "../../renderer/components/+config-resource-quotas/resource-quotas.store"; +export type { LimitRangeStore as LimitRangesStore } from "../../renderer/components/+config-limit-ranges/limit-ranges.store"; +export type { HorizontalPodAutoscalerStore as HPAStore } from "../../renderer/components/+config-autoscalers/hpa.store"; +export type { PodDisruptionBudgetStore as PodDisruptionBudgetsStore } from "../../renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.store"; export type { ServiceStore } from "../../renderer/components/+network-services/services.store"; export type { EndpointStore } from "../../renderer/components/+network-endpoints/endpoints.store"; export type { IngressStore } from "../../renderer/components/+network-ingresses/ingress.store"; export type { NetworkPolicyStore } from "../../renderer/components/+network-policies/network-policy.store"; export type { PersistentVolumesStore } from "../../renderer/components/+storage-volumes/volumes.store"; -export type { VolumeClaimStore } from "../../renderer/components/+storage-volume-claims/volume-claim.store"; +export type { PersistentVolumeClaimStore as VolumeClaimStore } from "../../renderer/components/+storage-volume-claims/volume-claim.store"; export type { StorageClassStore } from "../../renderer/components/+storage-classes/storage-class.store"; export type { NamespaceStore } from "../../renderer/components/+namespaces/namespace-store/namespace.store"; export type { ServiceAccountsStore } from "../../renderer/components/+user-management/+service-accounts/store"; export type { RolesStore } from "../../renderer/components/+user-management/+roles/store"; -export type { RoleBindingsStore } from "../../renderer/components/+user-management/+role-bindings/store"; +export type { RoleBindingStore as RoleBindingsStore } from "../../renderer/components/+user-management/+role-bindings/store"; export type { CRDStore } from "../../renderer/components/+custom-resources/crd.store"; export type { CRDResourceStore } from "../../renderer/components/+custom-resources/crd-resource.store"; diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index d570919f3e..f8c699b473 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -43,6 +43,8 @@ import { createClusterInjectionToken } from "../../common/cluster/create-cluster import authorizationReviewInjectable from "../../common/cluster/authorization-review.injectable"; import listNamespacesInjectable from "../../common/cluster/list-namespaces.injectable"; import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable"; +import type { ClusterContextHandler } from "../context-handler/context-handler"; +import { parse } from "url"; console = new Console(process.stdout, process.stderr); // fix mockFS @@ -83,9 +85,17 @@ describe("create clusters", () => { di.override(authorizationReviewInjectable, () => () => () => Promise.resolve(true)); di.override(listNamespacesInjectable, () => () => () => Promise.resolve([ "default" ])); - di.override(createContextHandlerInjectable, () => () => { - throw new Error("you should never come here"); - }); + di.override(createContextHandlerInjectable, () => (cluster) => ({ + restartServer: jest.fn(), + stopServer: jest.fn(), + clusterUrl: parse(cluster.apiUrl), + getApiTarget: jest.fn(), + getPrometheusDetails: jest.fn(), + resolveAuthProxyCa: jest.fn(), + resolveAuthProxyUrl: jest.fn(), + setupPrometheus: jest.fn(), + ensureServer: jest.fn(), + } as ClusterContextHandler)); createCluster = di.inject(createClusterInjectionToken); @@ -121,11 +131,6 @@ describe("create clusters", () => { kubeConfigPath: "minikube-config.yml", }); - cluster.contextHandler = { - ensureServer: jest.fn(), - stopServer: jest.fn(), - } as any; - jest.spyOn(cluster, "reconnect"); jest.spyOn(cluster, "refreshConnectionStatus"); diff --git a/src/main/__test__/context-handler.test.ts b/src/main/__test__/context-handler.test.ts index 7acedb7bcf..f48a8583f7 100644 --- a/src/main/__test__/context-handler.test.ts +++ b/src/main/__test__/context-handler.test.ts @@ -4,7 +4,7 @@ */ import { UserStore } from "../../common/user-store"; -import type { ContextHandler } from "../context-handler/context-handler"; +import type { ClusterContextHandler } from "../context-handler/context-handler"; import type { PrometheusService } from "../prometheus"; import { PrometheusProvider, PrometheusProviderRegistry } from "../prometheus"; import mockFs from "mock-fs"; @@ -48,7 +48,7 @@ class TestProvider extends PrometheusProvider { throw new Error("getQuery is not implemented."); } - async getPrometheusService(): Promise { + async getPrometheusService(): Promise { switch (this.alwaysFail) { case ServiceResult.Success: return { @@ -66,14 +66,14 @@ class TestProvider extends PrometheusProvider { } const clusterStub = { - getProxyKubeconfig: (): any => ({ - makeApiClient: (): any => undefined, + getProxyKubeconfig: () => ({ + makeApiClient: (): void => undefined, }), apiUrl: "http://localhost:81", -} as Cluster; +} as unknown as Cluster; describe("ContextHandler", () => { - let createContextHandler: (cluster: Cluster) => ContextHandler; + let createContextHandler: (cluster: Cluster) => ClusterContextHandler; beforeEach(async () => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); @@ -119,7 +119,7 @@ describe("ContextHandler", () => { expect(() => { // TODO: Unit test shouldn't access protected or private methods - const contextHandler = createContextHandler(clusterStub) as any; + const contextHandler = createContextHandler(clusterStub) as unknown as { getPrometheusService(): Promise }; return contextHandler.getPrometheusService(); }).rejects.toBeDefined(); @@ -149,7 +149,7 @@ describe("ContextHandler", () => { } // TODO: Unit test shouldn't access protected or private methods - const contextHandler = createContextHandler(clusterStub) as any; + const contextHandler = createContextHandler(clusterStub) as unknown as { getPrometheusService(): Promise }; const service = await contextHandler.getPrometheusService(); @@ -180,7 +180,7 @@ describe("ContextHandler", () => { } // TODO: Unit test shouldn't access protected or private methods - const contextHandler = createContextHandler(clusterStub) as any; + const contextHandler = createContextHandler(clusterStub) as unknown as { getPrometheusService(): Promise }; const service = await contextHandler.getPrometheusService(); @@ -217,7 +217,7 @@ describe("ContextHandler", () => { } // TODO: Unit test shouldn't access protected or private methods - const contextHandler = createContextHandler(clusterStub) as any; + const contextHandler = createContextHandler(clusterStub) as unknown as { getPrometheusService(): Promise }; const service = await contextHandler.getPrometheusService(); @@ -233,7 +233,7 @@ describe("ContextHandler", () => { reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); // TODO: Unit test shouldn't access protected or private methods - const contextHandler = createContextHandler(clusterStub) as any; + const contextHandler = createContextHandler(clusterStub) as unknown as { getPrometheusService(): Promise }; const service = await contextHandler.getPrometheusService(); diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index 36af031ecf..5147a7a165 100644 --- a/src/main/__test__/kube-auth-proxy.test.ts +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -142,69 +142,69 @@ describe("kube auth proxy tests", () => { beforeEach(async () => { mockedCP = mock(); listeners = new EventEmitter(); + const stderr = mockedCP.stderr = mock(); + const stdout = mockedCP.stdout = mock(); jest.spyOn(Kubectl.prototype, "checkBinary").mockReturnValueOnce(Promise.resolve(true)); jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false)); - mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => { + mockedCP.on.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): ChildProcess => { listeners.on(event, listener); return mockedCP; }); - mockedCP.stderr = mock(); - mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.on(`stderr/${event}`, listener); + mockedCP.stderr.on.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.on(`stderr/${String(event)}`, listener); - return mockedCP.stderr; + return stderr; }); - mockedCP.stderr.off.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.off(`stderr/${event}`, listener); + mockedCP.stderr.off.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stderr/${String(event)}`, listener); - return mockedCP.stderr; + return stderr; }); - mockedCP.stderr.removeListener.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.off(`stderr/${event}`, listener); + mockedCP.stderr.removeListener.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stderr/${String(event)}`, listener); - return mockedCP.stderr; + return stderr; }); - mockedCP.stderr.once.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.once(`stderr/${event}`, listener); + mockedCP.stderr.once.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.once(`stderr/${String(event)}`, listener); - return mockedCP.stderr; + return stderr; }); - mockedCP.stderr.removeAllListeners.mockImplementation((event?: string): Readable => { - listeners.removeAllListeners(event ?? `stderr/${event}`); + mockedCP.stderr.removeAllListeners.mockImplementation((event?: string | symbol): Readable => { + listeners.removeAllListeners(event ?? `stderr/${String(event)}`); - return mockedCP.stderr; + return stderr; }); - mockedCP.stdout = mock(); - mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.on(`stdout/${event}`, listener); + mockedCP.stdout.on.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.on(`stdout/${String(event)}`, listener); if (event === "data") { listeners.emit("stdout/data", "Starting to serve on 127.0.0.1:9191"); } - return mockedCP.stdout; + return stdout; }); - mockedCP.stdout.once.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.once(`stdout/${event}`, listener); + mockedCP.stdout.once.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.once(`stdout/${String(event)}`, listener); - return mockedCP.stdout; + return stdout; }); - mockedCP.stdout.off.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.off(`stdout/${event}`, listener); + mockedCP.stdout.off.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stdout/${String(event)}`, listener); - return mockedCP.stdout; + return stdout; }); - mockedCP.stdout.removeListener.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners.off(`stdout/${event}`, listener); + mockedCP.stdout.removeListener.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stdout/${String(event)}`, listener); - return mockedCP.stdout; + return stdout; }); - mockedCP.stdout.removeAllListeners.mockImplementation((event?: string): Readable => { - listeners.removeAllListeners(event ?? `stdout/${event}`); + mockedCP.stdout.removeAllListeners.mockImplementation((event?: string | symbol): Readable => { + listeners.removeAllListeners(event ?? `stdout/${String(event)}`); - return mockedCP.stdout; + return stdout; }); mockSpawn.mockImplementationOnce((command: string): ChildProcess => { expect(path.basename(command).split(".")[0]).toBe("lens-k8s-proxy"); diff --git a/src/main/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts index 91792bfaf8..393b42c256 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -43,13 +43,14 @@ import { createClusterInjectionToken } from "../../common/cluster/create-cluster import directoryForTempInjectable from "../../common/app-paths/directory-for-temp/directory-for-temp.injectable"; import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable"; import type { DiContainer } from "@ogre-tools/injectable"; +import { parse } from "url"; console = new Console(process.stdout, process.stderr); // fix mockFS describe("kubeconfig manager tests", () => { let clusterFake: Cluster; let createKubeconfigManager: (cluster: Cluster) => KubeconfigManager; - let di: DiContainer; + let di: DiContainer; beforeEach(async () => { di = getDiForUnitTesting({ doGeneralOverrides: true }); @@ -82,9 +83,17 @@ describe("kubeconfig manager tests", () => { await di.runSetups(); - di.override(createContextHandlerInjectable, () => () => { - throw new Error("you should never come here"); - }); + di.override(createContextHandlerInjectable, () => (cluster) => ({ + restartServer: jest.fn(), + stopServer: jest.fn(), + clusterUrl: parse(cluster.apiUrl), + getApiTarget: jest.fn(), + getPrometheusDetails: jest.fn(), + resolveAuthProxyCa: jest.fn(), + resolveAuthProxyUrl: jest.fn(), + setupPrometheus: jest.fn(), + ensureServer: jest.fn(), + })); const createCluster = di.inject(createClusterInjectionToken); @@ -96,10 +105,6 @@ describe("kubeconfig manager tests", () => { kubeConfigPath: "minikube-config.yml", }); - clusterFake.contextHandler = { - ensureServer: () => Promise.resolve(), - } as any; - jest.spyOn(KubeconfigManager.prototype, "resolveProxyUrl", "get").mockReturnValue("http://127.0.0.1:9191/foo"); }); diff --git a/src/main/__test__/static-file-route.test.ts b/src/main/__test__/static-file-route.test.ts index bd66b0b08d..0077bb4e65 100644 --- a/src/main/__test__/static-file-route.test.ts +++ b/src/main/__test__/static-file-route.test.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { LensApiRequest, Route } from "../router/router"; +import type { LensApiRequest, Route } from "../router/route"; import staticFileRouteInjectable from "../routes/static-file-route.injectable"; import { getDiForUnitTesting } from "../getDiForUnitTesting"; @@ -24,7 +24,7 @@ jest.mock("electron", () => ({ })); describe("static-file-route", () => { - let handleStaticFileRoute: Route; + let handleStaticFileRoute: Route; beforeEach(async () => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); @@ -40,7 +40,7 @@ describe("static-file-route", () => { path: "../index.ts", }, raw: {}, - } as LensApiRequest; + } as LensApiRequest<"/{path*}">; const result = await handleStaticFileRoute.handler(request); @@ -57,7 +57,7 @@ describe("static-file-route", () => { path: "router.test.ts", }, raw: { req }, - } as LensApiRequest; + } as LensApiRequest<"/{path*}">; const result = await handleStaticFileRoute.handler(request); diff --git a/src/main/app-paths/get-electron-app-path/get-electron-app-path.injectable.ts b/src/main/app-paths/get-electron-app-path/get-electron-app-path.injectable.ts index 4a548a1f8e..b6d1dcc4e2 100644 --- a/src/main/app-paths/get-electron-app-path/get-electron-app-path.injectable.ts +++ b/src/main/app-paths/get-electron-app-path/get-electron-app-path.injectable.ts @@ -9,8 +9,9 @@ import { getElectronAppPath } from "./get-electron-app-path"; const getElectronAppPathInjectable = getInjectable({ id: "get-electron-app-path", - instantiate: (di) => - getElectronAppPath({ app: di.inject(electronAppInjectable) }), + instantiate: (di) => getElectronAppPath({ + app: di.inject(electronAppInjectable), + }), }); export default getElectronAppPathInjectable; diff --git a/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts b/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts index 2c0d19e571..d8d86a551c 100644 --- a/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts +++ b/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts @@ -11,7 +11,7 @@ import joinPathsInjectable from "../../../common/path/join-paths.injectable"; import { joinPathsFake } from "../../../common/test-utils/join-paths-fake"; describe("get-electron-app-path", () => { - let getElectronAppPath: (name: string) => string | null; + let getElectronAppPath: (name: string) => string; beforeEach(async () => { const di = getDiForUnitTesting({ doGeneralOverrides: false }); @@ -37,7 +37,7 @@ describe("get-electron-app-path", () => { await di.runSetups(); - getElectronAppPath = di.inject(getElectronAppPathInjectable); + getElectronAppPath = di.inject(getElectronAppPathInjectable) as (name: string) => string; }); it("given app path exists, when called, returns app path", () => { diff --git a/src/main/app-paths/get-electron-app-path/get-electron-app-path.ts b/src/main/app-paths/get-electron-app-path/get-electron-app-path.ts index cbd70cc02c..f7531b89b1 100644 --- a/src/main/app-paths/get-electron-app-path/get-electron-app-path.ts +++ b/src/main/app-paths/get-electron-app-path/get-electron-app-path.ts @@ -9,12 +9,16 @@ interface Dependencies { app: App; } -export const getElectronAppPath = - ({ app }: Dependencies) => - (name: PathName) : string | null => { - try { - return app.getPath(name); - } catch (e) { - return ""; - } - }; +export type GetElectronAppPath = (name: PathName) => string; + +export const getElectronAppPath = ({ + app, +}: Dependencies): GetElectronAppPath => ( + (name) => { + try { + return app.getPath(name); + } catch (e) { + return ""; + } + } +); diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index 0b7a5609b8..f1d311d1fd 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -15,7 +15,7 @@ import { ipcMain } from "electron"; import { nextUpdateChannel } from "./utils/update-channel"; import { UserStore } from "../common/user-store"; -let installVersion: null | string = null; +let installVersion: undefined | string; export function isAutoUpdateEnabled() { return autoUpdater.isUpdaterActive() && isPublishConfigured; diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts index 782c6e5950..9e0749b5f2 100644 --- a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts @@ -53,7 +53,7 @@ describe("kubeconfig-sync.source tests", () => { }); di.override(clusterStoreInjectable, () => - ClusterStore.createInstance({ createCluster: () => null }), + ClusterStore.createInstance({ createCluster: () => null as never }), ); di.permitSideEffects(getConfigurationFileModelInjectable); diff --git a/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.ts b/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.ts index a9e1038f53..41b6324915 100644 --- a/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.ts +++ b/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.ts @@ -9,6 +9,7 @@ import type { CatalogEntity } from "../../../common/catalog"; import { catalogEntityRegistry } from "../../catalog"; import type { FSWatcher } from "chokidar"; import { watch } from "chokidar"; +import type { Stats } from "fs"; import fs from "fs"; import path from "path"; import type stream from "stream"; @@ -322,12 +323,13 @@ const watchFileChanges = (filePath: string, dependencies: Dependencies): [ICompu stabilityThreshold: 1000, }, atomic: 150, // for "atomic writes" + alwaysStat: true, }); const diffChangedConfig = diffChangedConfigFor(dependencies); watcher - .on("change", (childFilePath, stats) => { + .on("change", (childFilePath, stats: Stats): void => { const cleanup = cleanupFns.get(childFilePath); if (!cleanup) { @@ -343,7 +345,7 @@ const watchFileChanges = (filePath: string, dependencies: Dependencies): [ICompu maxAllowedFileReadSize, })); }) - .on("add", (childFilePath, stats) => { + .on("add", (childFilePath, stats: Stats): void => { if (isFolderSync) { const fileName = path.basename(childFilePath); @@ -368,7 +370,7 @@ const watchFileChanges = (filePath: string, dependencies: Dependencies): [ICompu }) .on("error", error => logger.error(`${logPrefix} watching file/folder failed: ${error}`, { filePath })); } catch (error) { - console.log(error.stack); + console.log((error as { stack: unknown }).stack); logger.warn(`${logPrefix} failed to start watching changes: ${error}`); } })(); diff --git a/src/main/catalog/__tests__/catalog-entity-registry.test.ts b/src/main/catalog/__tests__/catalog-entity-registry.test.ts index 049af06318..59aeeba84c 100644 --- a/src/main/catalog/__tests__/catalog-entity-registry.test.ts +++ b/src/main/catalog/__tests__/catalog-entity-registry.test.ts @@ -68,7 +68,7 @@ describe("CatalogEntityRegistry", () => { describe("addSource", () => { it ("allows to add an observable source", () => { - const source = observable.array([]); + const source = observable.array([]); registry.addObservableSource("test", source); expect(registry.items.length).toEqual(0); @@ -79,7 +79,7 @@ describe("CatalogEntityRegistry", () => { }); it ("added source change triggers reaction", (done) => { - const source = observable.array([]); + const source = observable.array([]); registry.addObservableSource("test", source); reaction(() => registry.items, () => { @@ -92,7 +92,7 @@ describe("CatalogEntityRegistry", () => { describe("removeSource", () => { it ("removes source", () => { - const source = observable.array([]); + const source = observable.array([]); registry.addObservableSource("test", source); source.push(entity); diff --git a/src/main/catalog/catalog-entity-registry.ts b/src/main/catalog/catalog-entity-registry.ts index fa633e093c..084b4fdb84 100644 --- a/src/main/catalog/catalog-entity-registry.ts +++ b/src/main/catalog/catalog-entity-registry.ts @@ -4,7 +4,7 @@ */ import { action, computed, type IComputedValue, type IObservableArray, makeObservable, observable } from "mobx"; -import type { CatalogCategoryRegistry, CatalogEntity, CatalogEntityConstructor } from "../../common/catalog"; +import type { CatalogCategoryRegistry, CatalogEntity } from "../../common/catalog"; import { catalogCategoryRegistry } from "../../common/catalog"; import { iter } from "../../common/utils"; @@ -36,16 +36,16 @@ export class CatalogEntityRegistry { ); } - getById(id: string): T | undefined { - return this.items.find(entity => entity.getId() === id) as T | undefined; + findById(id: string): CatalogEntity | undefined { + return this.items.find(entity => entity.getId() === id); } - getItemsForApiKind(apiVersion: string, kind: string): T[] { - return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[]; + filterItemsForApiKind(apiVersion: string, kind: string): CatalogEntity[] { + return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind); } - getItemsByEntityClass(constructor: CatalogEntityConstructor): T[] { - return this.items.filter((item) => item instanceof constructor) as T[]; + filterItemsByPredicate(filter: (item: CatalogEntity) => item is E): E[] { + return this.items.filter(filter); } } diff --git a/src/main/child-process/spawn.injectable.ts b/src/main/child-process/spawn.injectable.ts index d313e19d95..558aefc78e 100644 --- a/src/main/child-process/spawn.injectable.ts +++ b/src/main/child-process/spawn.injectable.ts @@ -5,12 +5,12 @@ import { getInjectable } from "@ogre-tools/injectable"; import { spawn } from "child_process"; +export type Spawn = typeof spawn; + const spawnInjectable = getInjectable({ id: "spawn", - instantiate: () => { - return spawn; - }, + instantiate: (): Spawn => spawn, causesSideEffects: true, }); diff --git a/src/main/cluster-detectors/base-cluster-detector.ts b/src/main/cluster-detectors/base-cluster-detector.ts index a5a2564ab4..f4c9fc963d 100644 --- a/src/main/cluster-detectors/base-cluster-detector.ts +++ b/src/main/cluster-detectors/base-cluster-detector.ts @@ -12,15 +12,12 @@ export interface ClusterDetectionResult { accuracy: number; } -export class BaseClusterDetector { - key: string; +export abstract class BaseClusterDetector { + abstract readonly key: string; - constructor(public cluster: Cluster) { - } + constructor(public readonly cluster: Cluster) {} - detect(): Promise { - return null; - } + abstract detect(): Promise; protected async k8sRequest(path: string, options: RequestPromiseOptions = {}): Promise { return k8sRequest(this.cluster, path, options); diff --git a/src/main/cluster-detectors/detector-registry.ts b/src/main/cluster-detectors/detector-registry.ts index e2741c1353..ed92ea9332 100644 --- a/src/main/cluster-detectors/detector-registry.ts +++ b/src/main/cluster-detectors/detector-registry.ts @@ -9,10 +9,12 @@ import { Singleton } from "../../common/utils"; import type { Cluster } from "../../common/cluster/cluster"; import type { BaseClusterDetector, ClusterDetectionResult } from "./base-cluster-detector"; -export class DetectorRegistry extends Singleton { - registry = observable.array([], { deep: false }); +export type DetectorConstructor = new (cluster: Cluster) => BaseClusterDetector; - add(detectorClass: typeof BaseClusterDetector): this { +export class DetectorRegistry extends Singleton { + registry = observable.array([], { deep: false }); + + add(detectorClass: DetectorConstructor): this { this.registry.push(detectorClass); return this; diff --git a/src/main/cluster-detectors/distribution-detector.ts b/src/main/cluster-detectors/distribution-detector.ts index 83610fdaa0..c912fcf5b5 100644 --- a/src/main/cluster-detectors/distribution-detector.ts +++ b/src/main/cluster-detectors/distribution-detector.ts @@ -8,12 +8,11 @@ import { ClusterMetadataKey } from "../../common/cluster-types"; export class DistributionDetector extends BaseClusterDetector { key = ClusterMetadataKey.DISTRIBUTION; - version: string; public async detect() { - this.version = await this.getKubernetesVersion(); + const version = await this.getKubernetesVersion(); - if (this.isRke()) { + if (this.isRke(version)) { return { value: "rke", accuracy: 80 }; } @@ -21,19 +20,19 @@ export class DistributionDetector extends BaseClusterDetector { return { value: "rancher-desktop", accuracy: 80 }; } - if (this.isK3s()) { + if (this.isK3s(version)) { return { value: "k3s", accuracy: 80 }; } - if (this.isGKE()) { + if (this.isGKE(version)) { return { value: "gke", accuracy: 80 }; } - if (this.isEKS()) { + if (this.isEKS(version)) { return { value: "eks", accuracy: 80 }; } - if (this.isIKS()) { + if (this.isIKS(version)) { return { value: "iks", accuracy: 80 }; } @@ -45,27 +44,27 @@ export class DistributionDetector extends BaseClusterDetector { return { value: "digitalocean", accuracy: 90 }; } - if (this.isK0s()) { + if (this.isK0s(version)) { return { value: "k0s", accuracy: 80 }; } - if (this.isVMWare()) { + if (this.isVMWare(version)) { return { value: "vmware", accuracy: 90 }; } - if (this.isMirantis()) { + if (this.isMirantis(version)) { return { value: "mirantis", accuracy: 90 }; } - if (this.isAlibaba()) { + if (this.isAlibaba(version)) { return { value: "alibaba", accuracy: 90 }; } - if (this.isHuawei()) { + if (this.isHuawei(version)) { return { value: "huawei", accuracy: 90 }; } - if (this.isTke()) { + if (this.isTke(version)) { return { value: "tencent", accuracy: 90 }; } @@ -85,11 +84,11 @@ export class DistributionDetector extends BaseClusterDetector { return { value: "docker-desktop", accuracy: 80 }; } - if (this.isCustom() && await this.isOpenshift()) { + if (this.isCustom(version) && await this.isOpenshift()) { return { value: "openshift", accuracy: 90 }; } - if (this.isCustom()) { + if (this.isCustom(version)) { return { value: "custom", accuracy: 10 }; } @@ -104,24 +103,24 @@ export class DistributionDetector extends BaseClusterDetector { return response.gitVersion; } - protected isGKE() { - return this.version.includes("gke"); + protected isGKE(version: string) { + return version.includes("gke"); } - protected isEKS() { - return this.version.includes("eks"); + protected isEKS(version: string) { + return version.includes("eks"); } - protected isIKS() { - return this.version.includes("IKS"); + protected isIKS(version: string) { + return version.includes("IKS"); } protected isAKS() { return this.cluster.apiUrl.includes("azmk8s.io"); } - protected isMirantis() { - return this.version.includes("-mirantis-") || this.version.includes("-docker-"); + protected isMirantis(version: string) { + return version.includes("-mirantis-") || version.includes("-docker-"); } protected isDigitalOcean() { @@ -144,40 +143,40 @@ export class DistributionDetector extends BaseClusterDetector { return this.cluster.contextName === "docker-desktop"; } - protected isTke() { - return this.version.includes("-tke."); + protected isTke(version: string) { + return version.includes("-tke."); } - protected isCustom() { - return this.version.includes("+"); + protected isCustom(version: string) { + return version.includes("+"); } - protected isVMWare() { - return this.version.includes("+vmware"); + protected isVMWare(version: string) { + return version.includes("+vmware"); } - protected isRke() { - return this.version.includes("-rancher"); + protected isRke(version: string) { + return version.includes("-rancher"); } protected isRancherDesktop() { return this.cluster.contextName === "rancher-desktop"; } - protected isK3s() { - return this.version.includes("+k3s"); + protected isK3s(version: string) { + return version.includes("+k3s"); } - protected isK0s() { - return this.version.includes("-k0s") || this.version.includes("+k0s"); + protected isK0s(version: string) { + return version.includes("-k0s") || version.includes("+k0s"); } - protected isAlibaba() { - return this.version.includes("-aliyun"); + protected isAlibaba(version: string) { + return version.includes("-aliyun"); } - protected isHuawei() { - return this.version.includes("-CCE"); + protected isHuawei(version: string) { + return version.includes("-CCE"); } protected async isOpenshift() { diff --git a/src/main/cluster-detectors/version-detector.ts b/src/main/cluster-detectors/version-detector.ts index 50b55b433d..cc228734c5 100644 --- a/src/main/cluster-detectors/version-detector.ts +++ b/src/main/cluster-detectors/version-detector.ts @@ -8,7 +8,6 @@ import { ClusterMetadataKey } from "../../common/cluster-types"; export class VersionDetector extends BaseClusterDetector { key = ClusterMetadataKey.VERSION; - value: string; public async detect() { const version = await this.getKubernetesVersion(); diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 1991ce8ea1..24ab6fbbd4 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -9,10 +9,10 @@ import { action, makeObservable, observable, observe, reaction, toJS } from "mob import type { Cluster } from "../common/cluster/cluster"; import logger from "./logger"; import { apiKubePrefix } from "../common/vars"; -import { getClusterIdFromHost, Singleton } from "../common/utils"; +import { getClusterIdFromHost, isErrnoException, Singleton } from "../common/utils"; import { catalogEntityRegistry } from "./catalog"; import type { KubernetesClusterPrometheusMetrics } from "../common/catalog-entities/kubernetes-cluster"; -import { KubernetesCluster, LensKubernetesClusterStatus } from "../common/catalog-entities/kubernetes-cluster"; +import { isKubernetesCluster, KubernetesCluster, LensKubernetesClusterStatus } from "../common/catalog-entities/kubernetes-cluster"; import { ipcMainOn } from "../common/ipc"; import { once } from "lodash"; import { ClusterStore } from "../common/cluster-store/cluster-store"; @@ -49,12 +49,12 @@ export class ClusterManager extends Singleton { ); reaction( - () => catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster) as KubernetesCluster[], + () => catalogEntityRegistry.filterItemsByPredicate(isKubernetesCluster), entities => this.syncClustersFromCatalog(entities), ); reaction(() => [ - catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster), + catalogEntityRegistry.filterItemsByPredicate(isKubernetesCluster), this.visibleCluster, ] as const, ([entities, visibleCluster]) => { for (const entity of entities) { @@ -68,7 +68,7 @@ export class ClusterManager extends Singleton { observe(this.deleting, change => { if (change.type === "add") { - this.updateEntityStatus(catalogEntityRegistry.getById(change.newValue)); + this.updateEntityStatus(catalogEntityRegistry.findById(change.newValue) as KubernetesCluster); } }); @@ -187,7 +187,7 @@ export class ClusterManager extends Singleton { */ this.store.addCluster(model); } catch (error) { - if (error.code === "ENOENT" && error.path === entity.spec.kubeconfigPath) { + if (isErrnoException(error) && error.code === "ENOENT" && error.path === entity.spec.kubeconfigPath) { logger.warn(`${logPrefix} kubeconfig file disappeared`, model); } else { logger.error(`${logPrefix} failed to add cluster: ${error}`, model); @@ -197,7 +197,7 @@ export class ClusterManager extends Singleton { cluster.kubeConfigPath = entity.spec.kubeconfigPath; cluster.contextName = entity.spec.kubeconfigContext; - if (entity.spec.accessibleNamespace) { + if (entity.spec.accessibleNamespaces) { cluster.accessibleNamespaces = entity.spec.accessibleNamespaces; } @@ -248,9 +248,13 @@ export class ClusterManager extends Singleton { }); } - getClusterForRequest(req: http.IncomingMessage): Cluster { + getClusterForRequest(req: http.IncomingMessage): Cluster | undefined { + if (!req.headers.host) { + return undefined; + } + // lens-server is connecting to 127.0.0.1:/ - if (req.headers.host.startsWith("127.0.0.1")) { + if (req.url && req.headers.host.startsWith("127.0.0.1")) { const clusterId = req.url.split("/")[1]; const cluster = this.store.getById(clusterId); diff --git a/src/main/context-handler/context-handler.ts b/src/main/context-handler/context-handler.ts index 46e100c07e..33a9049b28 100644 --- a/src/main/context-handler/context-handler.ts +++ b/src/main/context-handler/context-handler.ts @@ -32,8 +32,20 @@ interface Dependencies { authProxyCa: string; } -export class ContextHandler { - public clusterUrl: UrlWithStringQuery; +export interface ClusterContextHandler { + readonly clusterUrl: UrlWithStringQuery; + setupPrometheus(preferences?: ClusterPrometheusPreferences): void; + getPrometheusDetails(): Promise; + resolveAuthProxyUrl(): Promise; + resolveAuthProxyCa(): string; + getApiTarget(isLongRunningRequest?: boolean): Promise; + restartServer(): Promise; + ensureServer(): Promise; + stopServer(): void; +} + +export class ContextHandler implements ClusterContextHandler { + public readonly clusterUrl: UrlWithStringQuery; protected kubeAuthProxy?: KubeAuthProxy; protected apiTarget?: httpProxy.ServerOptions; protected prometheusProvider?: string; @@ -46,7 +58,7 @@ export class ContextHandler { public setupPrometheus(preferences: ClusterPrometheusPreferences = {}) { this.prometheusProvider = preferences.prometheusProvider?.type; - this.prometheus = preferences.prometheus || null; + this.prometheus = preferences.prometheus; } public async getPrometheusDetails(): Promise { @@ -82,7 +94,7 @@ export class ContextHandler { } protected async getPrometheusService(): Promise { - if (this.prometheus !== null && this.prometheusProvider !== null) { + if (this.prometheus && this.prometheusProvider) { return { id: this.prometheusProvider, namespace: this.prometheus.namespace, @@ -117,11 +129,11 @@ export class ContextHandler { throw Object.assign(new Error("No Prometheus service found"), { cause: errors }); } - async resolveAuthProxyUrl() { - await this.ensureServer(); + async resolveAuthProxyUrl(): Promise { + const kubeAuthProxy = await this.ensureServerHelper(); const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : ""; - return `https://127.0.0.1:${this.kubeAuthProxy.port}${this.kubeAuthProxy.apiPrefix}${path}`; + return `https://127.0.0.1:${kubeAuthProxy.port}${kubeAuthProxy.apiPrefix}${path}`; } resolveAuthProxyCa() { @@ -139,30 +151,32 @@ export class ContextHandler { } protected async newApiTarget(timeout: number): Promise { - await this.ensureServer(); - - const ca = this.dependencies.authProxyCa; + const kubeAuthProxy = await this.ensureServerHelper(); + const ca = this.resolveAuthProxyCa(); const clusterPath = this.clusterUrl.path !== "/" ? this.clusterUrl.path : ""; - const apiPrefix = `${this.kubeAuthProxy.apiPrefix}${clusterPath}`; + const apiPrefix = `${kubeAuthProxy.apiPrefix}${clusterPath}`; + const headers: Record = {}; + + if (this.clusterUrl.hostname) { + headers.Host = this.clusterUrl.hostname; + } return { target: { protocol: "https:", host: "127.0.0.1", - port: this.kubeAuthProxy.port, + port: kubeAuthProxy.port, path: apiPrefix, ca, }, changeOrigin: true, timeout, secure: true, - headers: { - "Host": this.clusterUrl.hostname, - }, + headers, }; } - async ensureServer() { + protected async ensureServerHelper(): Promise { if (!this.kubeAuthProxy) { const proxyEnv = Object.assign({}, process.env); @@ -171,9 +185,23 @@ export class ContextHandler { } this.kubeAuthProxy = this.dependencies.createKubeAuthProxy(this.cluster, proxyEnv); await this.kubeAuthProxy.run(); + + return this.kubeAuthProxy; } await this.kubeAuthProxy.whenReady; + + return this.kubeAuthProxy; + } + + async ensureServer(): Promise { + await this.ensureServerHelper(); + } + + async restartServer(): Promise { + this.stopServer(); + + await this.ensureServerHelper(); } stopServer() { diff --git a/src/main/context-handler/create-context-handler.injectable.ts b/src/main/context-handler/create-context-handler.injectable.ts index 86867ccdfb..0740fe7425 100644 --- a/src/main/context-handler/create-context-handler.injectable.ts +++ b/src/main/context-handler/create-context-handler.injectable.ts @@ -5,6 +5,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import selfsigned from "selfsigned"; import type { Cluster } from "../../common/cluster/cluster"; +import type { ClusterContextHandler } from "./context-handler"; import { ContextHandler } from "./context-handler"; import createKubeAuthProxyInjectable from "../kube-auth-proxy/create-kube-auth-proxy.injectable"; import { getKubeAuthProxyCertificate } from "../kube-auth-proxy/get-kube-auth-proxy-certificate"; @@ -14,15 +15,18 @@ const createContextHandlerInjectable = getInjectable({ id: "create-context-handler", instantiate: (di) => { - return (cluster: Cluster) => { + const createKubeAuthProxy = di.inject(createKubeAuthProxyInjectable); + + return (cluster: Cluster): ClusterContextHandler => { const clusterUrl = new URLParse(cluster.apiUrl); - const dependencies = { - createKubeAuthProxy: di.inject(createKubeAuthProxyInjectable), - authProxyCa: getKubeAuthProxyCertificate(clusterUrl.hostname, selfsigned.generate).cert, - }; - - return new ContextHandler(dependencies, cluster); + return new ContextHandler( + { + createKubeAuthProxy, + authProxyCa: getKubeAuthProxyCertificate(clusterUrl.hostname, selfsigned.generate).cert, + }, + cluster, + ); }; }, }); diff --git a/src/main/getDiForUnitTesting.ts b/src/main/getDiForUnitTesting.ts index b1306457c5..823500a074 100644 --- a/src/main/getDiForUnitTesting.ts +++ b/src/main/getDiForUnitTesting.ts @@ -83,13 +83,12 @@ export const getDiForUnitTesting = ( di.override(appNameInjectable, () => "some-electron-app-name"); di.override(registerChannelInjectable, () => () => undefined); di.override(directoryForBundledBinariesInjectable, () => "some-bin-directory"); - di.override(spawnInjectable, () => () => { return { stderr: { on: jest.fn(), removeAllListeners: jest.fn() }, stdout: { on: jest.fn(), removeAllListeners: jest.fn() }, on: jest.fn(), - } as any; + } as never; }); di.override(writeJsonFileInjectable, () => () => { diff --git a/src/main/helm/exec.ts b/src/main/helm/exec.ts index 5d0305b2f6..19971a66e5 100644 --- a/src/main/helm/exec.ts +++ b/src/main/helm/exec.ts @@ -5,15 +5,21 @@ import { promiseExecFile } from "../../common/utils/promise-exec"; import type { BaseEncodingOptions } from "fs"; -import type { ExecFileOptions } from "child_process"; +import type { ExecFileOptions, ExecFileOptionsWithStringEncoding } from "child_process"; import { helmBinaryPath } from "../../common/vars"; import { UserStore } from "../../common/user-store"; +import { isChildProcessError } from "../../common/utils"; /** * ExecFile the bundled helm CLI * @returns STDOUT */ -export async function execHelm(args: string[], options?: BaseEncodingOptions & ExecFileOptions): Promise { +export async function execHelm(args: string[], { encoding, ...rest }: BaseEncodingOptions & ExecFileOptions = {}): Promise { + const options: ExecFileOptionsWithStringEncoding = { + encoding: encoding ?? "utf-8", + ...rest, + }; + try { const opts = { ...options }; @@ -27,6 +33,10 @@ export async function execHelm(args: string[], options?: BaseEncodingOptions & E return stdout; } catch (error) { - throw error?.stderr || error; + if (isChildProcessError(error, "string")) { + throw error.stderr || error; + } + + throw error; } } diff --git a/src/main/helm/helm-chart-manager.ts b/src/main/helm/helm-chart-manager.ts index 5c15bfc6b8..1f12bba051 100644 --- a/src/main/helm/helm-chart-manager.ts +++ b/src/main/helm/helm-chart-manager.ts @@ -9,8 +9,10 @@ import * as yaml from "js-yaml"; import type { HelmRepo } from "./helm-repo-manager"; import logger from "../logger"; import type { RepoHelmChartList } from "../../common/k8s-api/endpoints/helm-charts.api"; -import { iter, sortCharts } from "../../common/utils"; +import { iter, put, sortCharts } from "../../common/utils"; import { execHelm } from "./exec"; +import type { SetRequired } from "type-fest"; +import { assert } from "console"; interface ChartCacheEntry { data: Buffer; @@ -23,9 +25,15 @@ export interface HelmCacheFile { } export class HelmChartManager { - static #cache = new Map(); + static readonly #cache = new Map(); - private constructor(protected repo: HelmRepo) {} + protected readonly repo: SetRequired; + + private constructor(repo: HelmRepo) { + assert(repo.cacheFilePath, "CacheFilePath must be provided on the helm repo"); + + this.repo = repo as SetRequired; + } static forRepo(repo: HelmRepo) { return new this(repo); @@ -76,25 +84,30 @@ export class HelmChartManager { const normalized = normalizeHelmCharts(this.repo.name, data.entries); - HelmChartManager.#cache.set(this.repo.name, { - data: v8.serialize(normalized), - mtimeMs: cacheFileStats.mtimeMs, - }); + return put( + HelmChartManager.#cache, + this.repo.name, + { + data: v8.serialize(normalized), + mtimeMs: cacheFileStats.mtimeMs, + }, + ); } protected async cachedYaml(): Promise { - if (!HelmChartManager.#cache.has(this.repo.name)) { - await this.updateYamlCache(); + let cacheEntry = HelmChartManager.#cache.get(this.repo.name); + + if (!cacheEntry) { + cacheEntry = await this.updateYamlCache(); } else { const newStats = await fs.promises.stat(this.repo.cacheFilePath); - const cacheEntry = HelmChartManager.#cache.get(this.repo.name); if (cacheEntry.mtimeMs < newStats.mtimeMs) { - await this.updateYamlCache(); + cacheEntry = await this.updateYamlCache(); } } - return v8.deserialize(HelmChartManager.#cache.get(this.repo.name).data); + return v8.deserialize(cacheEntry.data); } } diff --git a/src/main/helm/helm-release-manager.ts b/src/main/helm/helm-release-manager.ts index aac0e8aaca..c537a31aab 100644 --- a/src/main/helm/helm-release-manager.ts +++ b/src/main/helm/helm-release-manager.ts @@ -9,6 +9,9 @@ import * as yaml from "js-yaml"; import { toCamelCase } from "../../common/utils/camelCase"; import { execFile } from "child_process"; import { execHelm } from "./exec"; +import assert from "assert"; +import type { JsonObject, JsonValue } from "type-fest"; +import { isObject, json } from "../../common/utils"; export async function listReleases(pathToKubeconfig: string, namespace?: string): Promise[]> { const args = [ @@ -25,17 +28,17 @@ export async function listReleases(pathToKubeconfig: string, namespace?: string) args.push("--kubeconfig", pathToKubeconfig); - const output = JSON.parse(await execHelm(args)); + const output = json.parse(await execHelm(args)); if (!Array.isArray(output) || output.length == 0) { return []; } - return output.map(toCamelCase); + return output.filter(isObject).map(toCamelCase); } -export async function installChart(chart: string, values: any, name: string | undefined = "", namespace: string, version: string, kubeconfigPath: string) { +export async function installChart(chart: string, values: JsonValue, name: string | undefined = "", namespace: string, version: string, kubeconfigPath: string) { const valuesFilePath = tempy.file({ name: "values.yaml" }); await fse.writeFile(valuesFilePath, yaml.dump(values)); @@ -110,10 +113,14 @@ export async function getRelease(name: string, namespace: string, kubeconfigPath "--output", "json", ]; - const release = JSON.parse(await execHelm(args, { + const release = json.parse(await execHelm(args, { maxBuffer: 32 * 1024 * 1024 * 1024, // 32 MiB })); + if (!isObject(release) || Array.isArray(release)) { + return undefined; + } + release.resources = await getResources(name, namespace, kubeconfigPath, kubectlPath); return release; @@ -155,7 +162,7 @@ export async function getValues(name: string, { namespace, all = false, kubeconf } export async function getHistory(name: string, namespace: string, kubeconfigPath: string) { - return JSON.parse(await execHelm([ + return json.parse(await execHelm([ "history", name, "--output", "json", @@ -193,7 +200,7 @@ async function getResources(name: string, namespace: string, kubeconfigPath: str try { const helmOutput = await execHelm(helmArgs); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { let stdout = ""; let stderr = ""; const kubectl = execFile(kubectlPath, kubectlArgs); @@ -202,7 +209,9 @@ async function getResources(name: string, namespace: string, kubeconfigPath: str .on("exit", (code, signal) => { if (typeof code === "number") { if (code === 0) { - resolve(JSON.parse(stdout).items); + const output = json.parse(stdout) as { items: JsonObject[] }; + + resolve(output.items); } else { reject(stderr); } @@ -212,10 +221,11 @@ async function getResources(name: string, namespace: string, kubeconfigPath: str }) .on("error", reject); + assert(kubectl.stderr && kubectl.stdout && kubectl.stdin, "For some reason the IO streams are undefined"); + kubectl.stderr.on("data", output => stderr += output); kubectl.stdout.on("data", output => stdout += output); - kubectl.stdin.write(helmOutput); - kubectl.stdin.end(); + kubectl.stdin.end(helmOutput); }); } catch { return []; diff --git a/src/main/helm/helm-repo-manager.ts b/src/main/helm/helm-repo-manager.ts index 7f174cfa8e..b5a578982b 100644 --- a/src/main/helm/helm-repo-manager.ts +++ b/src/main/helm/helm-repo-manager.ts @@ -11,9 +11,9 @@ import orderBy from "lodash/orderBy"; import logger from "../logger"; import { execHelm } from "./exec"; -export type HelmEnv = Record & { - HELM_REPOSITORY_CACHE?: string; - HELM_REPOSITORY_CONFIG?: string; +export type HelmEnv = Partial> & { + HELM_REPOSITORY_CACHE: string; + HELM_REPOSITORY_CONFIG: string; }; export interface HelmRepoConfig { @@ -32,10 +32,14 @@ export interface HelmRepo { password?: string; } +interface EnsuredHelmRepoManagerData { + helmEnv: HelmEnv; + didUpdateOnce: boolean; +} + export class HelmRepoManager extends Singleton { - protected repos: HelmRepo[]; - protected helmEnv: HelmEnv; - protected didUpdateOnce: boolean; + protected helmEnv?: HelmEnv; + protected didUpdateOnce?: boolean; public async loadAvailableRepos(): Promise { const res = await customRequestPromise({ @@ -48,10 +52,10 @@ export class HelmRepoManager extends Singleton { return orderBy(res.body as HelmRepo[], repo => repo.name); } - private async ensureInitialized() { + private async ensureInitialized(): Promise { this.helmEnv ??= await this.parseHelmEnv(); - const repos = await this.list(); + const repos = await this.list(this.helmEnv); if (repos.length === 0) { await this.addRepo({ @@ -64,12 +68,17 @@ export class HelmRepoManager extends Singleton { await this.update(); this.didUpdateOnce = true; } + + return { + didUpdateOnce: this.didUpdateOnce, + helmEnv: this.helmEnv, + }; } protected async parseHelmEnv() { const output = await execHelm(["env"]); const lines = output.split(/\r?\n/); // split by new line feed - const env: HelmEnv = {}; + const env: Partial> = {}; lines.forEach((line: string) => { const [key, value] = line.split("="); @@ -79,18 +88,18 @@ export class HelmRepoManager extends Singleton { } }); - return env; + return env as HelmEnv; } - public async repo(name: string): Promise { + public async repo(name: string): Promise { const repos = await this.repositories(); return repos.find(repo => repo.name === name); } - private async list(): Promise { + private async list(helmEnv: HelmEnv): Promise { try { - const rawConfig = await readFile(this.helmEnv.HELM_REPOSITORY_CONFIG, "utf8"); + const rawConfig = await readFile(helmEnv.HELM_REPOSITORY_CONFIG, "utf8"); const parsedConfig = yaml.load(rawConfig) as HelmRepoConfig; if (typeof parsedConfig === "object" && parsedConfig) { @@ -105,13 +114,13 @@ export class HelmRepoManager extends Singleton { public async repositories(): Promise { try { - await this.ensureInitialized(); + const { helmEnv } = await this.ensureInitialized(); - const repos = await this.list(); + const repos = await this.list(helmEnv); return repos.map(repo => ({ ...repo, - cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml`, + cacheFilePath: `${helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml`, })); } catch (error) { logger.error(`[HELM]: repositories listing error`, error); diff --git a/src/main/helm/helm-service.ts b/src/main/helm/helm-service.ts index d3a17d3f63..adc9ea30a5 100644 --- a/src/main/helm/helm-service.ts +++ b/src/main/helm/helm-service.ts @@ -8,6 +8,8 @@ import logger from "../logger"; import { HelmRepoManager } from "./helm-repo-manager"; import { HelmChartManager } from "./helm-chart-manager"; import { deleteRelease, getHistory, getRelease, getValues, installChart, listReleases, rollback, upgradeRelease } from "./helm-release-manager"; +import type { JsonObject } from "type-fest"; +import { object } from "../../common/utils"; interface GetReleaseValuesArgs { cluster: Cluster; @@ -15,8 +17,22 @@ interface GetReleaseValuesArgs { all: boolean; } +export interface InstallChartArgs { + chart: string; + values: JsonObject; + name: string; + namespace: string; + version: string; +} + +export interface UpdateChartArgs { + chart: string; + values: JsonObject; + version: string; +} + class HelmService { - public async installChart(cluster: Cluster, data: { chart: string; values: {}; name: string; namespace: string; version: string }) { + public async installChart(cluster: Cluster, data: InstallChartArgs) { const proxyKubeconfig = await cluster.getProxyKubeconfigPath(); return installChart(data.chart, data.values, data.name, data.namespace, data.version, proxyKubeconfig); @@ -25,13 +41,18 @@ class HelmService { public async listCharts() { const repositories = await HelmRepoManager.getInstance().repositories(); - return Object.fromEntries( - await Promise.all(repositories.map(async repo => [repo.name, await HelmChartManager.forRepo(repo).charts()])), + return object.fromEntries( + await Promise.all(repositories.map(async repo => [repo.name, await HelmChartManager.forRepo(repo).charts()] as const)), ); } public async getChart(repoName: string, chartName: string, version = "") { const repo = await HelmRepoManager.getInstance().repo(repoName); + + if (!repo) { + return undefined; + } + const chartManager = HelmChartManager.forRepo(repo); return { @@ -43,10 +64,14 @@ class HelmService { public async getChartValues(repoName: string, chartName: string, version = "") { const repo = await HelmRepoManager.getInstance().repo(repoName); + if (!repo) { + return undefined; + } + return HelmChartManager.forRepo(repo).getValues(chartName, version); } - public async listReleases(cluster: Cluster, namespace: string = null) { + public async listReleases(cluster: Cluster, namespace?: string) { const proxyKubeconfig = await cluster.getProxyKubeconfigPath(); logger.debug("list releases"); @@ -88,7 +113,7 @@ class HelmService { return deleteRelease(releaseName, namespace, proxyKubeconfig); } - public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: { chart: string; values: {}; version: string }) { + public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: UpdateChartArgs) { const proxyKubeconfig = await cluster.getProxyKubeconfigPath(); const kubectl = await cluster.ensureKubectl(); const kubectlPath = await kubectl.getPath(); diff --git a/src/main/index.ts b/src/main/index.ts index 5b4f965a93..fd05da0252 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -55,6 +55,7 @@ import shellApiRequestInjectable from "./proxy-functions/shell-api-request/shell import userStoreInjectable from "../common/user-store/user-store.injectable"; import trayMenuItemsInjectable from "./tray/tray-menu-items.injectable"; import { broadcastNativeThemeOnUpdate } from "./native-theme"; +import assert from "assert"; import windowManagerInjectable from "./window-manager.injectable"; import navigateToPreferencesInjectable from "../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; import syncGeneralCatalogEntitiesInjectable from "./catalog-sources/sync-general-catalog-entities.injectable"; @@ -249,11 +250,13 @@ async function main(di: DiContainer) { logger.info("🔌 Starting LensProxy"); await lensProxy.listen(); // lensProxy.port available } catch (error) { - dialog.showErrorBox("Lens Error", `Could not start proxy: ${error?.message || "unknown error"}`); + dialog.showErrorBox("Lens Error", `Could not start proxy: ${error ? String(error) : "unknown error"}`); return app.exit(); } + assert(lensProxy.port, "Lens Proxy failed to start"); + // test proxy connection try { logger.info("🔎 Testing LensProxy connection ..."); @@ -350,7 +353,7 @@ async function main(di: DiContainer) { extensionLoader.initExtensions(extensions); } catch (error) { - dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`); + dialog.showErrorBox("Lens Error", `Could not load extensions${error ? `: ${String(error)}` : ""}`); console.error(error); console.trace(); } diff --git a/src/main/ipc/dialog.ts b/src/main/ipc/dialog.ts index f1630db85f..78dd4b69a9 100644 --- a/src/main/ipc/dialog.ts +++ b/src/main/ipc/dialog.ts @@ -4,10 +4,12 @@ */ import type { OpenDialogOptions } from "electron"; -import { BrowserWindow, dialog } from "electron"; +import { dialog } from "electron"; +import { WindowManager } from "../window-manager"; export async function showOpenDialog(dialogOptions: OpenDialogOptions): Promise<{ canceled: boolean; filePaths: string[] }> { - const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), dialogOptions); + const window = await WindowManager.getInstance().ensureMainWindow(); + const { canceled, filePaths } = await dialog.showOpenDialog(window, dialogOptions); return { canceled, filePaths }; } diff --git a/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts b/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts index 9d62a04cef..c3706f7c08 100644 --- a/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts +++ b/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts @@ -12,6 +12,7 @@ import { getBinaryName } from "../../common/vars"; import directoryForBundledBinariesInjectable from "../../common/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable"; import spawnInjectable from "../child-process/spawn.injectable"; import { getKubeAuthProxyCertificate } from "./get-kube-auth-proxy-certificate"; +import loggerInjectable from "../../common/logger.injectable"; export type CreateKubeAuthProxy = (cluster: Cluster, environmentVariables: NodeJS.ProcessEnv) => KubeAuthProxy; @@ -27,6 +28,7 @@ const createKubeAuthProxyInjectable = getInjectable({ proxyBinPath: path.join(di.inject(directoryForBundledBinariesInjectable), binaryName), proxyCert: getKubeAuthProxyCertificate(clusterUrl.hostname, selfsigned.generate), spawn: di.inject(spawnInjectable), + logger: di.inject(loggerInjectable), }; return new KubeAuthProxy(dependencies, cluster, environmentVariables); diff --git a/src/main/kube-auth-proxy/get-kube-auth-proxy-certificate.ts b/src/main/kube-auth-proxy/get-kube-auth-proxy-certificate.ts index fb4c13da60..f6fa71702c 100644 --- a/src/main/kube-auth-proxy/get-kube-auth-proxy-certificate.ts +++ b/src/main/kube-auth-proxy/get-kube-auth-proxy-certificate.ts @@ -4,36 +4,32 @@ */ import type * as selfsigned from "selfsigned"; +import { getOrInsertWith } from "../../common/utils"; type SelfSignedGenerate = typeof selfsigned.generate; -const certCache: Map = new Map(); +const certCache = new Map(); -export function getKubeAuthProxyCertificate(hostname: string, generate: SelfSignedGenerate, useCache = true): selfsigned.SelfSignedCert { - if (useCache && certCache.has(hostname)) { - return certCache.get(hostname); - } - - const opts = [ - { name: "commonName", value: "Lens Certificate Authority" }, - { name: "organizationName", value: "Lens" }, - ]; - - const cert = generate(opts, { - keySize: 2048, - algorithm: "sha256", - days: 365, - extensions: [ - { name: "basicConstraints", cA: true }, - { name: "subjectAltName", altNames: [ - { type: 2, value: hostname }, - { type: 2, value: "localhost" }, - { type: 7, ip: "127.0.0.1" }, - ] }, +export function getKubeAuthProxyCertificate(hostname: string, generate: SelfSignedGenerate): selfsigned.SelfSignedCert { + return getOrInsertWith(certCache, hostname, () => generate( + [ + { name: "commonName", value: "Lens Certificate Authority" }, + { name: "organizationName", value: "Lens" }, ], - }); - - certCache.set(hostname, cert); - - return cert; + { + keySize: 2048, + algorithm: "sha256", + days: 365, + extensions: [ + { name: "basicConstraints", cA: true }, + { + name: "subjectAltName", altNames: [ + { type: 2, value: hostname }, + { type: 2, value: "localhost" }, + { type: 7, ip: "127.0.0.1" }, + ], + }, + ], + }, + )); } diff --git a/src/main/kube-auth-proxy/kube-auth-proxy.ts b/src/main/kube-auth-proxy/kube-auth-proxy.ts index dfeff29c98..414ea334da 100644 --- a/src/main/kube-auth-proxy/kube-auth-proxy.ts +++ b/src/main/kube-auth-proxy/kube-auth-proxy.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { ChildProcess, spawn } from "child_process"; +import type { ChildProcess } from "child_process"; import { waitUntilUsed } from "tcp-port-used"; import { randomBytes } from "crypto"; import type { Cluster } from "../../common/cluster/cluster"; @@ -11,27 +11,36 @@ import logger from "../logger"; import { getPortFrom } from "../utils/get-port"; import { makeObservable, observable, when } from "mobx"; import type { SelfSignedCert } from "selfsigned"; +import assert from "assert"; +import { TypedRegEx } from "typed-regex"; +import type { Spawn } from "../child-process/spawn.injectable"; +import type { Logger } from "../../common/logger"; -const startingServeRegex = /starting to serve on (?

.+)/i; +const startingServeRegex = TypedRegEx("starting to serve on (?
.+)", "i"); export interface KubeAuthProxyDependencies { - proxyBinPath: string; - proxyCert: SelfSignedCert; - spawn: typeof spawn; + readonly proxyBinPath: string; + readonly proxyCert: SelfSignedCert; + spawn: Spawn; + readonly logger: Logger; } export class KubeAuthProxy { public readonly apiPrefix = `/${randomBytes(8).toString("hex")}`; public get port(): number { - return this._port; + const port = this._port; + + assert(port, "port has not yet been initialized"); + + return port; } - protected _port: number; + protected _port?: number; protected proxyProcess?: ChildProcess; @observable protected ready = false; - constructor(private dependencies: KubeAuthProxyDependencies, protected readonly cluster: Cluster, protected readonly env: NodeJS.ProcessEnv) { + constructor(private readonly dependencies: KubeAuthProxyDependencies, protected readonly cluster: Cluster, protected readonly env: NodeJS.ProcessEnv) { makeObservable(this); } @@ -63,7 +72,7 @@ export class KubeAuthProxy { }); this.proxyProcess.on("exit", (code) => { - this.cluster.broadcastConnectUpdate(`proxy exited with code: ${code}`, code > 0); + this.cluster.broadcastConnectUpdate(`proxy exited with code: ${code}`, code ? code > 0: false); this.exit(); }); @@ -72,6 +81,9 @@ export class KubeAuthProxy { this.exit(); }); + assert(this.proxyProcess.stderr); + assert(this.proxyProcess.stdout); + this.proxyProcess.stderr.on("data", (data: Buffer) => { if (data.includes("http: TLS handshake error")) { return; @@ -91,10 +103,13 @@ export class KubeAuthProxy { onFind: () => this.cluster.broadcastConnectUpdate("Authentication proxy started"), }); + logger.info(`[KUBE-AUTH-PROXY]: found port=${this._port}`); + try { await waitUntilUsed(this.port, 500, 10000); this.ready = true; } catch (error) { + logger.warn("[KUBE-AUTH-PROXY]: waitUntilUsed failed", error); this.cluster.broadcastConnectUpdate("Proxy port failed to be used within timelimit, restarting...", true); this.exit(); @@ -108,10 +123,10 @@ export class KubeAuthProxy { if (this.proxyProcess) { logger.debug("[KUBE-AUTH]: stopping local proxy", this.cluster.getMeta()); this.proxyProcess.removeAllListeners(); - this.proxyProcess.stderr.removeAllListeners(); - this.proxyProcess.stdout.removeAllListeners(); + this.proxyProcess.stderr?.removeAllListeners(); + this.proxyProcess.stdout?.removeAllListeners(); this.proxyProcess.kill(); - this.proxyProcess = null; + this.proxyProcess = undefined; } } } diff --git a/src/main/kubeconfig-manager/kubeconfig-manager.ts b/src/main/kubeconfig-manager/kubeconfig-manager.ts index fb72b5ebac..158c1b5a88 100644 --- a/src/main/kubeconfig-manager/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager/kubeconfig-manager.ts @@ -5,12 +5,14 @@ import type { KubeConfig } from "@kubernetes/client-node"; import type { Cluster } from "../../common/cluster/cluster"; -import type { ContextHandler } from "../context-handler/context-handler"; +import type { ClusterContextHandler } from "../context-handler/context-handler"; import path from "path"; import fs from "fs-extra"; import { dumpConfigYaml } from "../../common/kube-helpers"; import logger from "../logger"; import { LensProxy } from "../lens-proxy"; +import { isErrnoException } from "../../common/utils"; +import type { PartialDeep } from "type-fest"; interface Dependencies { directoryForTemp: string; @@ -26,7 +28,7 @@ export class KubeconfigManager { */ protected tempFilePath: string | null | undefined = null; - protected contextHandler: ContextHandler; + protected readonly contextHandler: ClusterContextHandler; constructor(private dependencies: Dependencies, protected cluster: Cluster) { this.contextHandler = cluster.contextHandler; @@ -42,7 +44,7 @@ export class KubeconfigManager { } if (this.tempFilePath === null || !(await fs.pathExists(this.tempFilePath))) { - await this.ensureFile(); + return await this.ensureFile(); } return this.tempFilePath; @@ -61,7 +63,7 @@ export class KubeconfigManager { try { await fs.unlink(this.tempFilePath); } catch (error) { - if (error.code !== "ENOENT") { + if (isErrnoException(error) && error.code !== "ENOENT") { throw error; } } finally { @@ -72,7 +74,8 @@ export class KubeconfigManager { protected async ensureFile() { try { await this.contextHandler.ensureServer(); - this.tempFilePath = await this.createProxyKubeconfig(); + + return this.tempFilePath = await this.createProxyKubeconfig(); } catch (error) { throw Object.assign(new Error("Failed to creat temp config for auth-proxy"), { cause: error }); } @@ -94,13 +97,12 @@ export class KubeconfigManager { `kubeconfig-${id}`, ); const kubeConfig = await cluster.getKubeconfig(); - const proxyConfig: Partial = { + const proxyConfig: PartialDeep = { currentContext: contextName, clusters: [ { name: contextName, server: this.resolveProxyUrl, - skipTLSVerify: undefined, }, ], users: [ @@ -111,7 +113,7 @@ export class KubeconfigManager { user: "proxy", name: contextName, cluster: contextName, - namespace: cluster.defaultNamespace || kubeConfig.getContextObject(contextName).namespace, + namespace: cluster.defaultNamespace || kubeConfig.getContextObject(contextName)?.namespace, }, ], }; diff --git a/src/main/kubectl/kubectl.ts b/src/main/kubectl/kubectl.ts index 690b9970b9..06716e50a4 100644 --- a/src/main/kubectl/kubectl.ts +++ b/src/main/kubectl/kubectl.ts @@ -53,12 +53,11 @@ interface Dependencies { export class Kubectl { public kubectlVersion: string; - protected directory: string; protected url: string; protected path: string; protected dirname: string; - public static readonly bundledKubectlVersion: string = bundledVersion; + public static readonly bundledKubectlVersion = bundledVersion; public static invalidBundle = false; constructor(private dependencies: Dependencies, clusterVersion: string) { @@ -70,12 +69,14 @@ export class Kubectl { version = new SemVer(Kubectl.bundledKubectlVersion); } - const minorVersion = `${version.major}.${version.minor}`; + const fromMajorMinor = kubectlMap.get(`${version.major}.${version.minor}`); - /* minorVersion is the first two digits of kube server version - if the version map includes that, use that version, if not, fallback to the exact x.y.z of kube version */ - if (kubectlMap.has(minorVersion)) { - this.kubectlVersion = kubectlMap.get(minorVersion); + /** + * minorVersion is the first two digits of kube server version if the version map includes that, + * use that version, if not, fallback to the exact x.y.z of kube version + */ + if (fromMajorMinor) { + this.kubectlVersion = fromMajorMinor; logger.debug(`Set kubectl version ${this.kubectlVersion} for cluster version ${clusterVersion} using version map`); } else { this.kubectlVersion = version.format(); @@ -176,8 +177,8 @@ export class Kubectl { return true; } logger.error(`Local kubectl is version ${version}, expected ${this.kubectlVersion}, unlinking`); - } catch (err) { - logger.error(`Local kubectl failed to run properly (${err.message}), unlinking`); + } catch (error) { + logger.error(`Local kubectl failed to run properly (${error}), unlinking`); } await fs.promises.unlink(this.path); } @@ -348,7 +349,8 @@ export class Kubectl { // MacOS packages are only available from default const { url } = packageMirrors.get(this.dependencies.userStore.downloadMirror) - ?? packageMirrors.get(defaultPackageMirror); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ?? packageMirrors.get(defaultPackageMirror)!; return url; } diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index 945c7269e8..c7627c10d7 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -9,15 +9,19 @@ import spdy from "spdy"; import type httpProxy from "http-proxy"; import { apiPrefix, apiKubePrefix } from "../common/vars"; import type { Router } from "./router/router"; -import type { ContextHandler } from "./context-handler/context-handler"; +import type { ClusterContextHandler } from "./context-handler/context-handler"; import logger from "./logger"; import { Singleton } from "../common/utils"; import type { Cluster } from "../common/cluster/cluster"; import type { ProxyApiRequestArgs } from "./proxy-functions"; import { appEventBus } from "../common/app-event-bus/event-bus"; import { getBoolean } from "./utils/parse-query"; +import assert from "assert"; +import type { SetRequired } from "type-fest"; -type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | null; +type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | undefined; + +export type ServerIncomingMessage = SetRequired; export interface LensProxyFunctions { getClusterForRequest: GetClusterForRequest; @@ -53,13 +57,12 @@ const disallowedPorts = new Set([ ]); export class LensProxy extends Singleton { - protected origin: string; protected proxyServer: http.Server; protected closed = false; protected retryCounters = new Map(); protected getClusterForRequest: GetClusterForRequest; - public port: number; + public port?: number; constructor(protected router: Router, protected proxy: httpProxy, { shellApiRequest, kubeApiUpgradeRequest, getClusterForRequest }: LensProxyFunctions) { super(); @@ -73,12 +76,12 @@ export class LensProxy extends Singleton { plain: true, protocols: ["http/1.1", "spdy/3.1"], }, - }, (req: http.IncomingMessage, res: http.ServerResponse) => { - this.handleRequest(req, res); + }, (req, res) => { + this.handleRequest(req as ServerIncomingMessage, res); }); this.proxyServer - .on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { + .on("upgrade", (req: ServerIncomingMessage, socket: net.Socket, head: Buffer) => { const cluster = getClusterForRequest(req); if (!cluster) { @@ -197,7 +200,7 @@ export class LensProxy extends Singleton { logger.debug(`Retrying proxy request to url: ${reqId}`); setTimeout(() => { this.retryCounters.set(reqId, retryCount + 1); - this.handleRequest(req, res) + this.handleRequest(req as ServerIncomingMessage, res) .catch(error => logger.error(`[LENS-PROXY]: failed to handle request on proxy error: ${error}`)); }, timeoutMs); } @@ -214,8 +217,8 @@ export class LensProxy extends Singleton { return proxy; } - protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise { - if (req.url.startsWith(apiKubePrefix)) { + protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ClusterContextHandler): Promise { + if (req.url?.startsWith(apiKubePrefix)) { delete req.headers.authorization; req.url = req.url.replace(apiKubePrefix, ""); @@ -223,11 +226,13 @@ export class LensProxy extends Singleton { } } - protected getRequestId(req: http.IncomingMessage) { + protected getRequestId(req: http.IncomingMessage): string { + assert(req.headers.host); + return req.headers.host + req.url; } - protected async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) { + protected async handleRequest(req: ServerIncomingMessage, res: http.ServerResponse) { const cluster = this.getClusterForRequest(req); if (cluster) { @@ -237,6 +242,7 @@ export class LensProxy extends Singleton { return this.proxy.web(req, res, proxyTarget); } } + this.router.route(cluster, req, res); } } diff --git a/src/main/menu/application-menu-items.injectable.ts b/src/main/menu/application-menu-items.injectable.ts index 9e95414838..26c4d6e3c6 100644 --- a/src/main/menu/application-menu-items.injectable.ts +++ b/src/main/menu/application-menu-items.injectable.ts @@ -11,8 +11,6 @@ import { openBrowser } from "../../common/utils"; import { showAbout } from "./menu"; import windowManagerInjectable from "../window-manager.injectable"; import type { - BrowserWindow, - MenuItem, MenuItemConstructorOptions } from "electron"; import { webContents, @@ -29,7 +27,7 @@ import navigateToAddClusterInjectable from "../../common/front-end-routing/route import isMacInjectable from "../../common/vars/is-mac.injectable"; import { computed } from "mobx"; -function ignoreIf(check: boolean, menuItems: MenuItemConstructorOptions[]) { +function ignoreIf(check: boolean, menuItems: MenuItemOpts[]) { return check ? [] : menuItems; } @@ -69,8 +67,10 @@ const applicationMenuItemsInjectable = getInjectable({ { label: `About ${productName}`, id: "about", - click(menuItem: MenuItem, browserWindow: BrowserWindow) { - showAbout(browserWindow); + click(menuItem, browserWindow) { + if (browserWindow) { + showAbout(browserWindow); + } }, }, ...ignoreIf(autoUpdateDisabled, [ @@ -145,19 +145,14 @@ const applicationMenuItemsInjectable = getInjectable({ }, }, ]), - { type: "separator" }, - - ...(isMac - ? ([ - { - role: "close", - label: "Close Window", - accelerator: "Shift+Cmd+W", - }, - ] as MenuItemConstructorOptions[]) - : []), - + ...ignoreIf(!isMac, [ + { + role: "close", + label: "Close Window", + accelerator: "Shift+Cmd+W", + }, + ]), ...ignoreIf(isMac, [ { label: "Exit", @@ -286,8 +281,10 @@ const applicationMenuItemsInjectable = getInjectable({ { label: `About ${productName}`, id: "about", - click(menuItem: MenuItem, browserWindow: BrowserWindow) { - showAbout(browserWindow); + click(menuItem, browserWindow) { + if (browserWindow) { + showAbout(browserWindow); + } }, }, ...ignoreIf(autoUpdateDisabled, [ @@ -314,7 +311,9 @@ const applicationMenuItemsInjectable = getInjectable({ // Modify menu from extensions-api for (const menuItem of electronMenuItems.get()) { - if (!appMenu.has(menuItem.parentId)) { + const parentMenu = appMenu.get(menuItem.parentId); + + if (!parentMenu) { logger.error( `[MENU]: cannot register menu item for parentId=${menuItem.parentId}, parent item doesn't exist`, { menuItem }, @@ -323,7 +322,7 @@ const applicationMenuItemsInjectable = getInjectable({ continue; } - appMenu.get(menuItem.parentId).submenu.push(menuItem); + (parentMenu.submenu ??= []).push(menuItem); } if (!isMac) { diff --git a/src/main/menu/menu.ts b/src/main/menu/menu.ts index 4d5092b666..6b610a8985 100644 --- a/src/main/menu/menu.ts +++ b/src/main/menu/menu.ts @@ -38,9 +38,7 @@ export function showAbout(browserWindow: BrowserWindow) { }); } -export function buildMenu( - applicationMenuItems: MenuItemOpts[], -) { +export function buildMenu(applicationMenuItems: MenuItemOpts[]) { Menu.setApplicationMenu( Menu.buildFromTemplate(applicationMenuItems), ); diff --git a/src/main/navigate-to-url/navigate-to-url.injectable.ts b/src/main/navigate-to-url/navigate-to-url.injectable.ts index 5a3cd2bf49..883e1387fc 100644 --- a/src/main/navigate-to-url/navigate-to-url.injectable.ts +++ b/src/main/navigate-to-url/navigate-to-url.injectable.ts @@ -13,7 +13,7 @@ const navigateToUrlInjectable = getInjectable({ const windowManager = di.inject(windowManagerInjectable); return (url) => { - windowManager.navigateSync(url); + windowManager.navigate(url); }; }, diff --git a/src/main/prometheus/provider-registry.ts b/src/main/prometheus/provider-registry.ts index 21c64d7a67..e92c3c2dee 100644 --- a/src/main/prometheus/provider-registry.ts +++ b/src/main/prometheus/provider-registry.ts @@ -4,8 +4,7 @@ */ import type { CoreV1Api } from "@kubernetes/client-node"; -import { inspect } from "util"; -import { Singleton } from "../../common/utils"; +import { isRequestError, Singleton } from "../../common/utils"; export interface PrometheusService { id: string; @@ -30,9 +29,9 @@ export abstract class PrometheusProvider { protected async getFirstNamespacedService(client: CoreV1Api, ...selectors: string[]): Promise { try { for (const selector of selectors) { - const { body: { items: [service] }} = await client.listServiceForAllNamespaces(null, null, null, selector); + const { body: { items: [service] }} = await client.listServiceForAllNamespaces(undefined, undefined, undefined, selector); - if (service) { + if (service?.metadata?.namespace && service.metadata.name && service.spec?.ports) { return { id: this.id, namespace: service.metadata.namespace, @@ -42,7 +41,7 @@ export abstract class PrometheusProvider { } } } catch (error) { - throw new Error(`Failed to list services for Prometheus${this.name} in all namespaces: ${error.response.body.message}`); + throw new Error(`Failed to list services for Prometheus${this.name} in all namespaces: ${isRequestError(error) ? error.response?.body.message : error}`); } throw new Error(`No service found for Prometheus${this.name} from any namespace`); @@ -50,8 +49,11 @@ export abstract class PrometheusProvider { protected async getNamespacedService(client: CoreV1Api, name: string, namespace: string): Promise { try { - const resp = await client.readNamespacedService(name, namespace); - const service = resp.body; + const { body: service } = await client.readNamespacedService(name, namespace); + + if (!service.metadata?.namespace || !service.metadata.name || !service.spec?.ports) { + throw new Error(`Service returned from Prometheus${this.name} in namespace="${namespace}" did not have required information`); + } return { id: this.id, @@ -60,7 +62,7 @@ export abstract class PrometheusProvider { port: service.spec.ports[0].port, }; } catch(error) { - throw new Error(`Failed to list services for Prometheus${this.name} in namespace=${inspect(namespace, false, undefined, false)}: ${error.response.body.message}`); + throw new Error(`Failed to list services for Prometheus${this.name} in namespace="${namespace}": ${isRequestError(error) ? error.response?.body.message : error}`); } } } diff --git a/src/main/protocol-handler/__test__/router.test.ts b/src/main/protocol-handler/__test__/router.test.ts index d8c88f7f22..f084277deb 100644 --- a/src/main/protocol-handler/__test__/router.test.ts +++ b/src/main/protocol-handler/__test__/router.test.ts @@ -100,7 +100,7 @@ describe("protocol router tests", () => { }); extensionLoader.instances.set(extId, ext); - (extensionsStore as any).state.set(extId, { enabled: true, name: "@mirantis/minikube" }); + extensionsStore.mergeState([[extId, { enabled: true, name: "@mirantis/minikube" }]]); lpr.addInternalHandler("/", noop); @@ -179,7 +179,7 @@ describe("protocol router tests", () => { }); extensionLoader.instances.set(extId, ext); - (extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); + extensionsStore.mergeState([[extId, { enabled: true, name: "@foobar/icecream" }]]); try { expect(await lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined(); @@ -217,7 +217,7 @@ describe("protocol router tests", () => { }); extensionLoader.instances.set(extId, ext); - (extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); + extensionsStore.mergeState([[extId, { enabled: true, name: "@foobar/icecream" }]]); } { @@ -242,11 +242,13 @@ describe("protocol router tests", () => { }); extensionLoader.instances.set(extId, ext); - (extensionsStore as any).state.set(extId, { enabled: true, name: "icecream" }); + extensionsStore.mergeState([[extId, { enabled: true, name: "icecream" }]]); } - (extensionsStore as any).state.set("@foobar/icecream", { enabled: true, name: "@foobar/icecream" }); - (extensionsStore as any).state.set("icecream", { enabled: true, name: "icecream" }); + extensionsStore.mergeState([ + ["@foobar/icecream", { enabled: true, name: "@foobar/icecream" }], + ["icecream", { enabled: true, name: "icecream" }], + ]); try { expect(await lpr.route("lens://extension/icecream/page")).toBeUndefined(); diff --git a/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts b/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts index e40ba5fa11..cc79840b79 100644 --- a/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts +++ b/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts @@ -84,7 +84,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { await this._routeToExtension(url); } } catch (error) { - broadcastMessage(ProtocolHandlerInvalid, error.toString(), rawUrl); + broadcastMessage(ProtocolHandlerInvalid, error ? String(error) : "unknown error", rawUrl); if (error instanceof proto.RoutingError) { logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { url: error.url }); @@ -118,7 +118,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { return ""; } - protected _routeToInternal(url: URLParse>): RouteAttempt { + protected _routeToInternal(url: URLParse>): RouteAttempt { const rawUrl = url.toString(); // for sending to renderer const attempt = super._routeToInternal(url); @@ -127,7 +127,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { return attempt; } - protected async _routeToExtension(url: URLParse>): Promise { + protected async _routeToExtension(url: URLParse>): Promise { const rawUrl = url.toString(); // for sending to renderer /** diff --git a/src/main/proxy-functions/kube-api-upgrade-request.ts b/src/main/proxy-functions/kube-api-upgrade-request.ts index 899d22164e..c69d7349ec 100644 --- a/src/main/proxy-functions/kube-api-upgrade-request.ts +++ b/src/main/proxy-functions/kube-api-upgrade-request.ts @@ -4,7 +4,8 @@ */ import { chunk } from "lodash"; -import tls from "tls"; +import type { ConnectionOptions } from "tls"; +import { connect } from "tls"; import url from "url"; import { apiKubePrefix } from "../../common/vars"; import type { ProxyApiRequestArgs } from "./types"; @@ -13,16 +14,16 @@ const skipRawHeaders = new Set(["Host", "Authorization"]); export async function kubeApiUpgradeRequest({ req, socket, head, cluster }: ProxyApiRequestArgs) { const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); - const proxyCa = await cluster.contextHandler.resolveAuthProxyCa(); + const proxyCa = cluster.contextHandler.resolveAuthProxyCa(); const apiUrl = url.parse(cluster.apiUrl); const pUrl = url.parse(proxyUrl); - const connectOpts = { - port: parseInt(pUrl.port), - host: pUrl.hostname, + const connectOpts: ConnectionOptions = { + port: pUrl.port ? parseInt(pUrl.port) : undefined, + host: pUrl.hostname ?? undefined, ca: proxyCa, }; - const proxySocket = tls.connect(connectOpts, () => { + const proxySocket = connect(connectOpts, () => { proxySocket.write(`${req.method} ${pUrl.path} HTTP/1.1\r\n`); proxySocket.write(`Host: ${apiUrl.host}\r\n`); diff --git a/src/main/proxy-functions/shell-api-request/shell-api-request.ts b/src/main/proxy-functions/shell-api-request/shell-api-request.ts index 8a797f5f68..4455b12f5b 100644 --- a/src/main/proxy-functions/shell-api-request/shell-api-request.ts +++ b/src/main/proxy-functions/shell-api-request/shell-api-request.ts @@ -13,7 +13,7 @@ import type { Cluster } from "../../../common/cluster/cluster"; import type { ClusterId } from "../../../common/cluster-types"; interface Dependencies { - authenticateRequest: (clusterId: ClusterId, tabId: string, shellToken: string) => boolean; + authenticateRequest: (clusterId: ClusterId, tabId: string, shellToken: string | undefined) => boolean; createShellSession: (args: { webSocket: WebSocket; @@ -27,7 +27,7 @@ export const shellApiRequest = ({ createShellSession, authenticateRequest }: Dep const cluster = ClusterManager.getInstance().getClusterForRequest(req); const { query: { node: nodeName, shellToken, id: tabId }} = new URLParse(req.url, true); - if (!cluster || !authenticateRequest(cluster.id, tabId, shellToken)) { + if (!tabId || !cluster || !authenticateRequest(cluster.id, tabId, shellToken)) { socket.write("Invalid shell request"); return void socket.end(); diff --git a/src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts b/src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts index bafd073c14..ab5e46eb77 100644 --- a/src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts +++ b/src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts @@ -31,10 +31,10 @@ export class ShellRequestAuthenticator { * @param token The value that is being presented as a one time authentication token * @returns `true` if `token` was valid, false otherwise */ - authenticate = (clusterId: ClusterId, tabId: string, token: string): boolean => { + authenticate = (clusterId: ClusterId, tabId: string, token: string | undefined): boolean => { const clusterTokens = this.tokens.get(clusterId); - if (!clusterTokens) { + if (!clusterTokens || !tabId || !token) { return false; } diff --git a/src/main/proxy-functions/types.ts b/src/main/proxy-functions/types.ts index 4d7e3a684e..01bd3cebd0 100644 --- a/src/main/proxy-functions/types.ts +++ b/src/main/proxy-functions/types.ts @@ -5,10 +5,11 @@ import type http from "http"; import type net from "net"; +import type { SetRequired } from "type-fest"; import type { Cluster } from "../../common/cluster/cluster"; export interface ProxyApiRequestArgs { - req: http.IncomingMessage; + req: SetRequired; socket: net.Socket; head: Buffer; cluster: Cluster; diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index 11c26ad171..30c85e8fa0 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -12,7 +12,7 @@ import path from "path"; import tempy from "tempy"; import logger from "./logger"; import { appEventBus } from "../common/app-event-bus/event-bus"; -import { cloneJsonObject } from "../common/utils"; +import { isChildProcessError } from "../common/utils"; import type { Patch } from "rfc6902"; import { promiseExecFile } from "../common/utils/promise-exec"; @@ -54,7 +54,11 @@ export class ResourceApplier { return stdout; } catch (error) { - throw error.stderr ?? error; + if (isChildProcessError(error)) { + throw error.stderr ?? error; + } + + throw error; } } @@ -92,7 +96,11 @@ export class ResourceApplier { return stdout; } catch (error) { - throw error?.stderr ?? error; + if (isChildProcessError(error)) { + throw error.stderr ?? error; + } + + throw error; } finally { await fs.unlink(fileName); } @@ -142,15 +150,16 @@ export class ResourceApplier { } protected sanitizeObject(resource: KubernetesObject | any) { - resource = cloneJsonObject(resource); - delete resource.status; - delete resource.metadata?.resourceVersion; - const annotations = resource.metadata?.annotations; + const res = JSON.parse(JSON.stringify(resource)); + + delete res.status; + delete res.metadata?.resourceVersion; + const annotations = res.metadata?.annotations; if (annotations) { delete annotations["kubectl.kubernetes.io/last-applied-configuration"]; } - return resource; + return res; } } diff --git a/src/main/router/route.ts b/src/main/router/route.ts new file mode 100644 index 0000000000..90b0210746 --- /dev/null +++ b/src/main/router/route.ts @@ -0,0 +1,147 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { Cluster } from "../../common/cluster/cluster"; +import type http from "http"; +import type httpProxy from "http-proxy"; +import type { LensApiResultContentType } from "./router-content-types"; +import type { URLSearchParams } from "url"; +import type Joi from "joi"; + +export type InferParam< + T extends string, + PathParams extends Record, +> = + T extends `{${infer P}?}` + ? PathParams & Partial> + : T extends `{${infer P}}` + ? PathParams & Record + : PathParams; + +export type InferParamFromPath

= + P extends `${string}/{${infer B}*}${infer Tail}` + ? Tail extends "" + ? Record + : never + : P extends `${infer A}/${infer B}` + ? InferParam> + : InferParam; + +export interface LensApiRequest { + path: Path; + payload: unknown; + params: InferParamFromPath; + cluster: Cluster | undefined; + query: URLSearchParams; + raw: { + req: http.IncomingMessage; + res: http.ServerResponse; + }; +} + +export interface ClusterLensApiRequest extends LensApiRequest { + cluster: Cluster; +} + +export interface LensApiResult { + statusCode?: number; + response?: Response; + error?: any; + contentType?: LensApiResultContentType; + headers?: Partial>; + proxy?: httpProxy; +} + +export type RouteResponse = + | LensApiResult + | void; + +export interface RouteHandler{ + (request: LensApiRequest): RouteResponse | Promise>; +} + +export interface BaseRoutePaths { + path: Path; + method: "get" | "post" | "put" | "patch" | "delete"; +} + +export interface PayloadValidator { + validate(payload: unknown): Joi.ValidationResult; +} + +export interface ValidatorBaseRoutePaths extends BaseRoutePaths { + payloadValidator: PayloadValidator; +} + +export interface Route extends BaseRoutePaths { + handler: RouteHandler; +} + +export interface BindHandler { + (handler: RouteHandler): Route; +} + +export function route(parts: BaseRoutePaths): BindHandler { + return (handler) => ({ + ...parts, + handler, + }); +} + +export interface ClusterRouteHandler{ + (request: ClusterLensApiRequest): RouteResponse | Promise>; +} + +export interface BindClusterHandler { + (handler: ClusterRouteHandler): Route; +} + +export function clusterRoute(parts: BaseRoutePaths): BindClusterHandler { + return (handler) => ({ + ...parts, + handler: ({ cluster, ...rest }) => { + if (!cluster) { + return { + error: "Cluster missing", + statusCode: 400, + }; + } + + return handler({ cluster, ...rest }); + }, + }); +} + +export interface ValidatedClusterLensApiRequest extends ClusterLensApiRequest { + payload: Payload; +} + +export interface ValidatedClusterRouteHandler { + (request: ValidatedClusterLensApiRequest): RouteResponse | Promise>; +} + +export interface BindValidatedClusterHandler { + (handler: ValidatedClusterRouteHandler): Route; +} + +export function payloadValidatedClusterRoute({ payloadValidator, ...parts }: ValidatorBaseRoutePaths): BindValidatedClusterHandler { + const boundClusterRoute = clusterRoute(parts); + + return (handler) => boundClusterRoute(({ payload, ...rest }) => { + const validationResult = payloadValidator.validate(payload); + + if (validationResult.error) { + return { + error: validationResult.error, + statusCode: 400, + }; + } + + return handler({ + payload: validationResult.value, + ...rest, + }); + }); +} diff --git a/src/main/router/router-content-types.ts b/src/main/router/router-content-types.ts index 8271c1ea06..4106e6b750 100644 --- a/src/main/router/router-content-types.ts +++ b/src/main/router/router-content-types.ts @@ -2,7 +2,7 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { LensApiResult } from "./router"; +import type { LensApiResult } from "./route"; export interface LensApiResultContentType { resultMapper: (result: LensApiResult) => ({ diff --git a/src/main/router/router.injectable.ts b/src/main/router/router.injectable.ts index 138219d035..23d6c78fc4 100644 --- a/src/main/router/router.injectable.ts +++ b/src/main/router/router.injectable.ts @@ -2,15 +2,29 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; -import type { Route } from "./router"; +import type { Injectable } from "@ogre-tools/injectable"; +import { getInjectable, getInjectionToken, lifecycleEnum } from "@ogre-tools/injectable"; import { Router } from "./router"; import parseRequestInjectable from "./parse-request.injectable"; +import type { Route } from "./route"; -export const routeInjectionToken = getInjectionToken>({ +export const routeInjectionToken = getInjectionToken>({ id: "route-injection-token", }); +export function getRouteInjectable< + T, + Path extends string, +>( + opts: Omit, Route, void>, "lifecycle" | "injectionToken">, +): Injectable, Route, void> { + return { + ...opts, + injectionToken: routeInjectionToken as never, + lifecycle: lifecycleEnum.singleton as never, + }; +} + const routerInjectable = getInjectable({ id: "router", diff --git a/src/main/router/router.test.ts b/src/main/router/router.test.ts index 96f0587163..cc31c8de26 100644 --- a/src/main/router/router.test.ts +++ b/src/main/router/router.test.ts @@ -5,22 +5,33 @@ import routerInjectable, { routeInjectionToken } from "./router.injectable"; import { getDiForUnitTesting } from "../getDiForUnitTesting"; -import type { Router, RouteHandler, Route } from "./router"; +import type { Router } from "./router"; import type { Cluster } from "../../common/cluster/cluster"; import { Request } from "mock-http"; import { getInjectable } from "@ogre-tools/injectable"; -import type { AsyncFnMock } from "@async-fn/jest"; import asyncFn from "@async-fn/jest"; import parseRequestInjectable from "./parse-request.injectable"; import { contentTypes } from "./router-content-types"; import mockFs from "mock-fs"; +import type { MockInstance } from "jest-mock"; +import type { SetRequired } from "type-fest"; +import type { Route, RouteHandler } from "./route"; + +type AsyncFnMock< + TToBeMocked extends (...args: any[]) => any, + TArguments extends Parameters = Parameters, + TResolve extends Awaited> = Awaited>, +> = MockInstance<(...args: TArguments) => Promise, TArguments> & { + resolve: (resolvedValue: TResolve) => Promise; + reject: (rejectValue?: any) => Promise; +} & ((...args: TArguments) => Promise); describe("router", () => { let router: Router; - let routeHandlerMock: AsyncFnMock>; + let routeHandlerMock: AsyncFnMock>; beforeEach(async () => { - routeHandlerMock = asyncFn(); + routeHandlerMock = asyncFn() as AsyncFnMock>; const di = getDiForUnitTesting({ doGeneralOverrides: true }); @@ -37,7 +48,7 @@ describe("router", () => { method: "get", path: "/some-path", handler: routeHandlerMock, - } as Route), + } as Route), injectionToken: routeInjectionToken, }); @@ -54,7 +65,7 @@ describe("router", () => { describe("when navigating to the route", () => { let actualPromise: Promise; let clusterStub: Cluster; - let requestStub: Request; + let requestStub: SetRequired; let responseStub: any; beforeEach(() => { @@ -64,7 +75,7 @@ describe("router", () => { headers: { "content-type": "application/json", }, - }); + }) as SetRequired; responseStub = { end: jest.fn(), setHeader: jest.fn(), write: jest.fn(), statusCode: undefined }; diff --git a/src/main/router/router.ts b/src/main/router/router.ts index 545462ead6..0138d14629 100644 --- a/src/main/router/router.ts +++ b/src/main/router/router.ts @@ -5,42 +5,20 @@ import Call from "@hapi/call"; import type http from "http"; -import type httpProxy from "http-proxy"; import { toPairs } from "lodash/fp"; import type { Cluster } from "../../common/cluster/cluster"; -import type { LensApiResultContentType } from "./router-content-types"; import { contentTypes } from "./router-content-types"; +import type { ServerIncomingMessage } from "../lens-proxy"; +import type { LensApiRequest, LensApiResult, Route } from "./route"; export interface RouterRequestOpts { req: http.IncomingMessage; res: http.ServerResponse; - cluster: Cluster; - params: RouteParams; + cluster: Cluster | undefined; + params: any; url: URL; } -export interface RouteParams extends Record { - path?: string; // *-route - namespace?: string; - service?: string; - account?: string; - release?: string; - repo?: string; - chart?: string; -} - -export interface LensApiRequest

{ - path: string; - payload: P; - params: RouteParams; - cluster: Cluster; - query: URLSearchParams; - raw: { - req: http.IncomingMessage; - res: http.ServerResponse; - }; -} - interface Dependencies { parseRequest: (request: http.IncomingMessage, _: null, options: { parse: boolean; output: string }) => Promise<{ payload: any }>; } @@ -48,13 +26,13 @@ interface Dependencies { export class Router { protected router = new Call.Router(); - constructor(routes: Route[], private dependencies: Dependencies) { + constructor(routes: Route[], private dependencies: Dependencies) { routes.forEach(route => { this.router.add({ method: route.method, path: route.path }, handleRoute(route)); }); } - public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse): Promise { + public async route(cluster: Cluster | undefined, req: ServerIncomingMessage, res: http.ServerResponse): Promise { const url = new URL(req.url, "http://localhost"); const path = url.pathname; const method = req.method.toLowerCase(); @@ -72,7 +50,7 @@ export class Router { return false; } - protected async getRequest(opts: RouterRequestOpts): Promise { + protected async getRequest(opts: RouterRequestOpts): Promise> { const { req, res, url, cluster, params } = opts; const { payload } = await this.dependencies.parseRequest(req, null, { @@ -86,37 +64,14 @@ export class Router { raw: { req, res, }, - query: url.searchParams, + query: url.searchParams as never, payload, params, }; } } -export interface LensApiResult { - statusCode?: number; - response?: TResult; - error?: any; - contentType?: LensApiResultContentType; - headers?: { [name: string]: string }; - proxy?: httpProxy; -} - -export type RouteHandler = ( - request: LensApiRequest -) => - | Promise> - | Promise - | LensApiResult - | void; - -export interface Route { - path: string; - method: "get" | "post" | "put" | "patch" | "delete"; - handler: RouteHandler; -} - -const handleRoute = (route: Route) => async (request: LensApiRequest, response: http.ServerResponse) => { +const handleRoute = (route: Route) => async (request: LensApiRequest, response: http.ServerResponse) => { let result: LensApiResult | void; const writeServerResponse = writeServerResponseFor(response); @@ -126,7 +81,7 @@ const handleRoute = (route: Route) => async (request: LensApiRequest, r } catch(error) { const mappedResult = contentTypes.txt.resultMapper({ statusCode: 500, - error: error.toString(), + error: error ? String(error) : "unknown error", }); writeServerResponse(mappedResult); diff --git a/src/main/routes/helm/charts/get-chart-route.injectable.ts b/src/main/routes/helm/charts/get-chart-route.injectable.ts index 523be0e1a6..cc711be5d5 100644 --- a/src/main/routes/helm/charts/get-chart-route.injectable.ts +++ b/src/main/routes/helm/charts/get-chart-route.injectable.ts @@ -2,35 +2,28 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import type { Route } from "../../../router/router"; -import { helmService } from "../../../helm/helm-service"; +import { getRouteInjectable } from "../../../router/router.injectable"; import { apiPrefix } from "../../../../common/vars"; -import type { RawHelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api"; +import { route } from "../../../router/route"; +import { helmService } from "../../../helm/helm-service"; -interface GetChartResponse { - readme: string; - versions: RawHelmChart[]; -} - -const getChartRouteInjectable = getInjectable({ +const getChartRouteInjectable = getRouteInjectable({ id: "get-chart-route", - instantiate: (): Route => ({ + instantiate: () => route({ method: "get", path: `${apiPrefix}/v2/charts/{repo}/{chart}`, + })(async ({ params, query }) => { + const { repo, chart } = params; - handler: async ({ params, query }) => ({ + return { response: await helmService.getChart( - params.repo, - params.chart, - query.get("version"), + repo, + chart, + query.get("version") ?? undefined, ), - }), + }; }), - - injectionToken: routeInjectionToken, }); export default getChartRouteInjectable; diff --git a/src/main/routes/helm/charts/get-chart-values-route.injectable.ts b/src/main/routes/helm/charts/get-chart-values-route.injectable.ts index 4eeeb263ec..57fd067ed5 100644 --- a/src/main/routes/helm/charts/get-chart-values-route.injectable.ts +++ b/src/main/routes/helm/charts/get-chart-values-route.injectable.ts @@ -2,32 +2,24 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import type { Route } from "../../../router/router"; +import { getRouteInjectable } from "../../../router/router.injectable"; import { helmService } from "../../../helm/helm-service"; import { apiPrefix } from "../../../../common/vars"; +import { route } from "../../../router/route"; -const getChartRouteValuesInjectable = getInjectable({ +const getChartRouteValuesInjectable = getRouteInjectable({ id: "get-chart-route-values", - instantiate: (): Route => ({ + instantiate: () => route({ method: "get", path: `${apiPrefix}/v2/charts/{repo}/{chart}/values`, - - handler: async ({ - params, - query, - }) => ({ - response: await helmService.getChartValues( - params.repo, - params.chart, - query.get("version"), - ), - }), - }), - - injectionToken: routeInjectionToken, + })(async ({ params, query }) => ({ + response: await helmService.getChartValues( + params.repo, + params.chart, + query.get("version") ?? undefined, + ), + })), }); export default getChartRouteValuesInjectable; diff --git a/src/main/routes/helm/charts/list-charts-route.injectable.ts b/src/main/routes/helm/charts/list-charts-route.injectable.ts index a1273ca7af..670cc9b889 100644 --- a/src/main/routes/helm/charts/list-charts-route.injectable.ts +++ b/src/main/routes/helm/charts/list-charts-route.injectable.ts @@ -2,25 +2,20 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import type { Route } from "../../../router/router"; +import { getRouteInjectable } from "../../../router/router.injectable"; import { helmService } from "../../../helm/helm-service"; import { apiPrefix } from "../../../../common/vars"; +import { route } from "../../../router/route"; -const listChartsRouteInjectable = getInjectable({ +const listChartsRouteInjectable = getRouteInjectable({ id: "list-charts-route", - instantiate: (): Route => ({ + instantiate: () => route({ method: "get", path: `${apiPrefix}/v2/charts`, - - handler: async () => ({ - response: await helmService.listCharts(), - }), - }), - - injectionToken: routeInjectionToken, + })(async () => ({ + response: await helmService.listCharts(), + })), }); export default listChartsRouteInjectable; diff --git a/src/main/routes/helm/releases/delete-release-route.injectable.ts b/src/main/routes/helm/releases/delete-release-route.injectable.ts index 6487cc7ce8..b17a8ea5c3 100644 --- a/src/main/routes/helm/releases/delete-release-route.injectable.ts +++ b/src/main/routes/helm/releases/delete-release-route.injectable.ts @@ -3,32 +3,23 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; +import { clusterRoute } from "../../../router/route"; -const deleteReleaseRouteInjectable = getInjectable({ +const deleteReleaseRouteInjectable = getRouteInjectable({ id: "delete-release-route", - instantiate: (): Route => ({ + instantiate: () => clusterRoute({ method: "delete", path: `${apiPrefix}/v2/releases/{namespace}/{release}`, - - handler: async (request) => { - const { cluster, params } = request; - - return { - response: await helmService.deleteRelease( - cluster, - params.release, - params.namespace, - ), - }; - }, - }), - - injectionToken: routeInjectionToken, + })(async ({ cluster, params: { release, namespace }}) => ({ + response: await helmService.deleteRelease( + cluster, + release, + namespace, + ), + })), }); export default deleteReleaseRouteInjectable; diff --git a/src/main/routes/helm/releases/get-release-history-route.injectable.ts b/src/main/routes/helm/releases/get-release-history-route.injectable.ts index 5ceadb2695..a58f125a97 100644 --- a/src/main/routes/helm/releases/get-release-history-route.injectable.ts +++ b/src/main/routes/helm/releases/get-release-history-route.injectable.ts @@ -3,32 +3,23 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; +import { clusterRoute } from "../../../router/route"; -const getReleaseRouteHistoryInjectable = getInjectable({ +const getReleaseRouteHistoryInjectable = getRouteInjectable({ id: "get-release-history-route", - instantiate: (): Route => ({ + instantiate: () => clusterRoute({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}/history`, - - handler: async (request) => { - const { cluster, params } = request; - - return { - response: await helmService.getReleaseHistory( - cluster, - params.release, - params.namespace, - ), - }; - }, - }), - - injectionToken: routeInjectionToken, + })(async ({ cluster, params }) => ({ + response: await helmService.getReleaseHistory( + cluster, + params.release, + params.namespace, + ), + })), }); export default getReleaseRouteHistoryInjectable; diff --git a/src/main/routes/helm/releases/get-release-route.injectable.ts b/src/main/routes/helm/releases/get-release-route.injectable.ts index e11ab64466..f2e8917ebf 100644 --- a/src/main/routes/helm/releases/get-release-route.injectable.ts +++ b/src/main/routes/helm/releases/get-release-route.injectable.ts @@ -3,32 +3,23 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; +import { clusterRoute } from "../../../router/route"; -const getReleaseRouteInjectable = getInjectable({ +const getReleaseRouteInjectable = getRouteInjectable({ id: "get-release-route", - instantiate: (): Route => ({ + instantiate: () => clusterRoute({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}`, - - handler: async (request) => { - const { cluster, params } = request; - - return { - response: await helmService.getRelease( - cluster, - params.release, - params.namespace, - ), - }; - }, - }), - - injectionToken: routeInjectionToken, + })(async ({ cluster, params }) => ({ + response: await helmService.getRelease( + cluster, + params.release, + params.namespace, + ), + })), }); export default getReleaseRouteInjectable; diff --git a/src/main/routes/helm/releases/get-release-values-route.injectable.ts b/src/main/routes/helm/releases/get-release-values-route.injectable.ts index 92227042e7..6908257f5b 100644 --- a/src/main/routes/helm/releases/get-release-values-route.injectable.ts +++ b/src/main/routes/helm/releases/get-release-values-route.injectable.ts @@ -3,37 +3,27 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; import { getBoolean } from "../../../utils/parse-query"; import { contentTypes } from "../../../router/router-content-types"; +import { clusterRoute } from "../../../router/route"; -const getReleaseRouteValuesInjectable = getInjectable({ +const getReleaseRouteValuesInjectable = getRouteInjectable({ id: "get-release-values-route", - instantiate: (): Route => ({ + instantiate: () => clusterRoute({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}/values`, + })(async ({ cluster, params: { namespace, release }, query }) => ({ + response: await helmService.getReleaseValues(release, { + cluster, + namespace, + all: getBoolean(query, "all"), + }), - handler: async (request) => { - const { cluster, params: { namespace, release }, query } = request; - const all = getBoolean(query, "all"); - - return { - response: await helmService.getReleaseValues(release, { - cluster, - namespace, - all, - }), - - contentType: contentTypes.txt, - }; - }, - }), - - injectionToken: routeInjectionToken, + contentType: contentTypes.txt, + })), }); export default getReleaseRouteValuesInjectable; diff --git a/src/main/routes/helm/releases/install-chart-route.injectable.ts b/src/main/routes/helm/releases/install-chart-route.injectable.ts index 879e39c4ac..bb1d0c923c 100644 --- a/src/main/routes/helm/releases/install-chart-route.injectable.ts +++ b/src/main/routes/helm/releases/install-chart-route.injectable.ts @@ -3,34 +3,42 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; +import type { InstallChartArgs } from "../../../helm/helm-service"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; +import Joi from "joi"; +import { payloadValidatedClusterRoute } from "../../../router/route"; -interface InstallChartResponse { - log: string; - release: { name: string; namespace: string }; -} +const installChartArgsValidator = Joi.object({ + chart: Joi + .string() + .required(), + values: Joi + .object() + .required() + .unknown(true), + name: Joi + .string() + .required(), + namespace: Joi + .string() + .required(), + version: Joi + .string() + .required(), +}); -const installChartRouteInjectable = getInjectable({ +const installChartRouteInjectable = getRouteInjectable({ id: "install-chart-route", - instantiate: () : Route => ({ + instantiate: () => payloadValidatedClusterRoute({ method: "post", path: `${apiPrefix}/v2/releases`, - - handler: async (request) => { - const { payload, cluster } = request; - - return { - response: await helmService.installChart(cluster, payload), - statusCode: 201, - }; - }, - }), - - injectionToken: routeInjectionToken, + payloadValidator: installChartArgsValidator, + })(async ({ payload, cluster }) => ({ + response: await helmService.installChart(cluster, payload), + statusCode: 201, + })), }); export default installChartRouteInjectable; diff --git a/src/main/routes/helm/releases/list-releases-route.injectable.ts b/src/main/routes/helm/releases/list-releases-route.injectable.ts index b73c618ff3..b57d646526 100644 --- a/src/main/routes/helm/releases/list-releases-route.injectable.ts +++ b/src/main/routes/helm/releases/list-releases-route.injectable.ts @@ -3,28 +3,19 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; +import { clusterRoute } from "../../../router/route"; -const listReleasesRouteInjectable = getInjectable({ +const listReleasesRouteInjectable = getRouteInjectable({ id: "list-releases-route", - instantiate: (): Route> => ({ + instantiate: () => clusterRoute({ method: "get", path: `${apiPrefix}/v2/releases/{namespace?}`, - - handler: async (request) => { - const { cluster, params } = request; - - return { - response: await helmService.listReleases(cluster, params.namespace), - }; - }, - }), - - injectionToken: routeInjectionToken, + })(async ({ cluster, params }) => ({ + response: await helmService.listReleases(cluster, params.namespace), + })), }); export default listReleasesRouteInjectable; diff --git a/src/main/routes/helm/releases/rollback-release-route.injectable.ts b/src/main/routes/helm/releases/rollback-release-route.injectable.ts index 1880dc7e1e..36a92c2820 100644 --- a/src/main/routes/helm/releases/rollback-release-route.injectable.ts +++ b/src/main/routes/helm/releases/rollback-release-route.injectable.ts @@ -3,26 +3,31 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; +import Joi from "joi"; +import { payloadValidatedClusterRoute } from "../../../router/route"; -const rollbackReleaseRouteInjectable = getInjectable({ +interface RollbackReleasePayload { + revision: number; +} + +const rollbackReleasePayloadValidator = Joi.object({ + revision: Joi + .number() + .required(), +}); + +const rollbackReleaseRouteInjectable = getRouteInjectable({ id: "rollback-release-route", - instantiate: (): Route => ({ + instantiate: () => payloadValidatedClusterRoute({ method: "put", path: `${apiPrefix}/v2/releases/{namespace}/{release}/rollback`, - - handler: async (request) => { - const { cluster, params, payload } = request; - - await helmService.rollback(cluster, params.release, params.namespace, payload.revision); - }, + payloadValidator: rollbackReleasePayloadValidator, + })(async ({ cluster, params: { release, namespace }, payload }) => { + await helmService.rollback(cluster, release, namespace, payload.revision); }), - - injectionToken: routeInjectionToken, }); export default rollbackReleaseRouteInjectable; diff --git a/src/main/routes/helm/releases/update-release-route.injectable.ts b/src/main/routes/helm/releases/update-release-route.injectable.ts index f97dc1a2ad..0d3602f505 100644 --- a/src/main/routes/helm/releases/update-release-route.injectable.ts +++ b/src/main/routes/helm/releases/update-release-route.injectable.ts @@ -3,38 +3,39 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiPrefix } from "../../../../common/vars"; -import type { Route } from "../../../router/router"; +import type { UpdateChartArgs } from "../../../helm/helm-service"; import { helmService } from "../../../helm/helm-service"; -import { routeInjectionToken } from "../../../router/router.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; +import { getRouteInjectable } from "../../../router/router.injectable"; +import { payloadValidatedClusterRoute } from "../../../router/route"; +import Joi from "joi"; -interface UpdateReleaseResponse { - log: string; - release: { name: string; namespace: string }; -} +const updateChartArgsValidator = Joi.object({ + chart: Joi + .string() + .required(), + version: Joi + .string() + .required(), + values: Joi + .object() + .unknown(true), +}); -const updateReleaseRouteInjectable = getInjectable({ +const updateReleaseRouteInjectable = getRouteInjectable({ id: "update-release-route", - instantiate: (): Route => ({ + instantiate: () => payloadValidatedClusterRoute({ method: "put", path: `${apiPrefix}/v2/releases/{namespace}/{release}`, - - handler: async ({ + payloadValidator: updateChartArgsValidator, + })(async ({ cluster, params, payload }) => ({ + response: await helmService.updateRelease( cluster, - params, + params.release, + params.namespace, payload, - }) => ({ - response: await helmService.updateRelease( - cluster, - params.release, - params.namespace, - payload, - ), - }), - }), - - injectionToken: routeInjectionToken, + ), + })), }); export default updateReleaseRouteInjectable; diff --git a/src/main/routes/kubeconfig-route/get-service-account-route.injectable.ts b/src/main/routes/kubeconfig-route/get-service-account-route.injectable.ts index b0b2ae96e9..48bdc5f644 100644 --- a/src/main/routes/kubeconfig-route/get-service-account-route.injectable.ts +++ b/src/main/routes/kubeconfig-route/get-service-account-route.injectable.ts @@ -3,43 +3,88 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; import { apiPrefix } from "../../../common/vars"; -import type { Route } from "../../router/router"; -import { routeInjectionToken } from "../../router/router.injectable"; +import { getRouteInjectable } from "../../router/router.injectable"; import type { Cluster } from "../../../common/cluster/cluster"; import type { V1Secret } from "@kubernetes/client-node"; import { CoreV1Api } from "@kubernetes/client-node"; +import { clusterRoute } from "../../router/route"; -const getServiceAccountRouteInjectable = getInjectable({ +const getServiceAccountRouteInjectable = getRouteInjectable({ id: "get-service-account-route", - instantiate: (): Route> => ({ + instantiate: () => clusterRoute({ method: "get", path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}`, + })(async ({ params, cluster }) => { + const client = (await cluster.getProxyKubeconfig()).makeApiClient(CoreV1Api); + const secretList = await client.listNamespacedSecret(params.namespace); - handler: async (request) => { - const { params, cluster } = request; - const client = (await cluster.getProxyKubeconfig()).makeApiClient(CoreV1Api); - const secretList = await client.listNamespacedSecret(params.namespace); + const secret = secretList.body.items.find(secret => { + const { annotations } = secret.metadata ?? {}; - const secret = secretList.body.items.find(secret => { - const { annotations } = secret.metadata; + return annotations?.["kubernetes.io/service-account.name"] === params.account; + }); - return annotations && annotations["kubernetes.io/service-account.name"] == params.account; - }); + if (!secret) { + return { + error: "No secret found", + statusCode: 404, + }; + } - return { response: generateKubeConfig(params.account, secret, cluster) }; - }, + const kubeconfig = generateKubeConfig(params.account, secret, cluster); + + if (!kubeconfig) { + return { + error: "No secret found", + statusCode: 404, + }; + } + + return { + response: kubeconfig, + }; }), - - injectionToken: routeInjectionToken, }); export default getServiceAccountRouteInjectable; -function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster) { - const tokenData = Buffer.from(secret.data["token"], "base64"); +interface ServiceAccountKubeConfig { + apiVersion: string; + kind: string; + clusters: { + name: string; + cluster: { + server: string; + "certificate-authority-data": string; + }; + }[]; + users: { + name: string; + user: { + token: string; + }; + }[]; + contexts: { + name: string; + context: { + user: string; + cluster: string; + namespace: string | undefined; + }; + }[]; + "current-context": string; +} + +function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster): ServiceAccountKubeConfig | undefined { + if (!secret.data || !secret.metadata) { + return undefined; + } + + const { token, "ca.crt": caCrt } = secret.data; + + const tokenData = Buffer.from(token, "base64"); return { "apiVersion": "v1", @@ -49,7 +94,7 @@ function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster "name": cluster.contextName, "cluster": { "server": cluster.apiUrl, - "certificate-authority-data": secret.data["ca.crt"], + "certificate-authority-data": caCrt, }, }, ], diff --git a/src/main/routes/metrics/add-metrics-route.injectable.ts b/src/main/routes/metrics/add-metrics-route.injectable.ts index 470fecd56c..c78c33249c 100644 --- a/src/main/routes/metrics/add-metrics-route.injectable.ts +++ b/src/main/routes/metrics/add-metrics-route.injectable.ts @@ -3,16 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; import { apiPrefix } from "../../../common/vars"; -import type { LensApiRequest, Route } from "../../router/router"; -import { routeInjectionToken } from "../../router/router.injectable"; +import { getRouteInjectable } from "../../router/router.injectable"; import type { ClusterPrometheusMetadata } from "../../../common/cluster-types"; import { ClusterMetadataKey } from "../../../common/cluster-types"; import logger from "../../logger"; import type { Cluster } from "../../../common/cluster/cluster"; import { getMetrics } from "../../k8s-request"; import type { IMetricsQuery } from "./metrics-query"; +import { clusterRoute } from "../../router/route"; +import { isObject } from "lodash"; +import { isRequestError } from "../../../common/utils"; // This is used for backoff retry tracking. const ATTEMPTS = [false, false, false, false, true]; @@ -27,8 +28,13 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa try { return await getMetrics(cluster, prometheusPath, { query, ...queryParams }); } catch (error) { - if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) { - logger.error("[Metrics]: metrics not available", error?.response ? error.response?.body : error); + if (isRequestError(error)) { + if (lastAttempt || (error.statusCode && error.statusCode >= 400 && error.statusCode < 500)) { + logger.error("[Metrics]: metrics not available", error?.response ? error.response?.body : error); + throw new Error("Metrics not available"); + } + } else { + logger.error("[Metrics]: metrics not available", error); throw new Error("Metrics not available"); } @@ -43,67 +49,66 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa return Promise.all(queries.map(loadMetric)); } -const addMetricsRoute = async ({ cluster, payload, query }: LensApiRequest) => { - const queryParams: IMetricsQuery = Object.fromEntries(query.entries()); - const prometheusMetadata: ClusterPrometheusMetadata = {}; - - try { - const { prometheusPath, provider } = await cluster.contextHandler.getPrometheusDetails(); - - prometheusMetadata.provider = provider?.id; - prometheusMetadata.autoDetected = !cluster.preferences.prometheusProvider?.type; - - if (!prometheusPath) { - prometheusMetadata.success = false; - - return { response: {}}; - } - - // return data in same structure as query - if (typeof payload === "string") { - const [data] = await loadMetrics([payload], cluster, prometheusPath, queryParams); - - return { response: data }; - } - - if (Array.isArray(payload)) { - const data = await loadMetrics(payload, cluster, prometheusPath, queryParams); - - return { response: data }; - } - - const queries = Object.entries>(payload) - .map(([queryName, queryOpts]) => ( - provider.getQuery(queryOpts, queryName) - )); - - const result = await loadMetrics(queries, cluster, prometheusPath, queryParams); - const data = Object.fromEntries(Object.keys(payload).map((metricName, i) => [metricName, result[i]])); - - prometheusMetadata.success = true; - - return { response: data }; - } catch (error) { - prometheusMetadata.success = false; - - logger.warn(`[METRICS-ROUTE]: failed to get metrics for clusterId=${cluster.id}:`, error); - - return { response: {}}; - } finally { - cluster.metadata[ClusterMetadataKey.PROMETHEUS] = prometheusMetadata; - } -}; - -const addMetricsRouteInjectable = getInjectable({ +const addMetricsRouteInjectable = getRouteInjectable({ id: "add-metrics-route", - instantiate: (): Route => ({ + instantiate: () => clusterRoute({ method: "post", path: `${apiPrefix}/metrics`, - handler: addMetricsRoute, - }), + })(async ({ cluster, payload, query }) => { + const queryParams: IMetricsQuery = Object.fromEntries(query.entries()); + const prometheusMetadata: ClusterPrometheusMetadata = {}; - injectionToken: routeInjectionToken, + try { + const { prometheusPath, provider } = await cluster.contextHandler.getPrometheusDetails(); + + prometheusMetadata.provider = provider?.id; + prometheusMetadata.autoDetected = !cluster.preferences.prometheusProvider?.type; + + if (!prometheusPath) { + prometheusMetadata.success = false; + + return { response: {}}; + } + + // return data in same structure as query + if (typeof payload === "string") { + const [data] = await loadMetrics([payload], cluster, prometheusPath, queryParams); + + return { response: data }; + } + + if (Array.isArray(payload)) { + const data = await loadMetrics(payload, cluster, prometheusPath, queryParams); + + return { response: data }; + } + + if (isObject(payload)) { + const queries = Object.entries(payload as Record>) + .map(([queryName, queryOpts]) => ( + provider.getQuery(queryOpts, queryName) + )); + + const result = await loadMetrics(queries, cluster, prometheusPath, queryParams); + const data = Object.fromEntries(Object.keys(payload).map((metricName, i) => [metricName, result[i]])); + + prometheusMetadata.success = true; + + return { response: data }; + } + + return { response: {}}; + } catch (error) { + prometheusMetadata.success = false; + + logger.warn(`[METRICS-ROUTE]: failed to get metrics for clusterId=${cluster.id}:`, error); + + return { response: {}}; + } finally { + cluster.metadata[ClusterMetadataKey.PROMETHEUS] = prometheusMetadata; + } + }), }); export default addMetricsRouteInjectable; diff --git a/src/main/routes/metrics/get-metric-providers-route.injectable.ts b/src/main/routes/metrics/get-metric-providers-route.injectable.ts index 8fcba9d984..6284bd916e 100644 --- a/src/main/routes/metrics/get-metric-providers-route.injectable.ts +++ b/src/main/routes/metrics/get-metric-providers-route.injectable.ts @@ -3,32 +3,26 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; import { apiPrefix } from "../../../common/vars"; -import type { Route } from "../../router/router"; -import { routeInjectionToken } from "../../router/router.injectable"; +import { getRouteInjectable } from "../../router/router.injectable"; import { PrometheusProviderRegistry } from "../../prometheus"; -import type { MetricProviderInfo } from "../../../common/k8s-api/endpoints/metrics.api"; +import { route } from "../../router/route"; -const getMetricProvidersRouteInjectable = getInjectable({ +const getMetricProvidersRouteInjectable = getRouteInjectable({ id: "get-metric-providers-route", - instantiate: (): Route => ({ + instantiate: () => route({ method: "get", path: `${apiPrefix}/metrics/providers`, - - handler: () => { - const providers: MetricProviderInfo[] = []; - - for (const { name, id, isConfigurable } of PrometheusProviderRegistry.getInstance().providers.values()) { - providers.push({ name, id, isConfigurable }); - } - - return { response: providers }; - }, - }), - - injectionToken: routeInjectionToken, + })(() => ({ + response: Array.from( + PrometheusProviderRegistry + .getInstance() + .providers + .values(), + ({ name, id, isConfigurable }) => ({ name, id, isConfigurable }), + ), + })), }); export default getMetricProvidersRouteInjectable; diff --git a/src/main/routes/port-forward/functionality/port-forward.ts b/src/main/routes/port-forward/functionality/port-forward.ts index 497176ebf4..eafe465972 100644 --- a/src/main/routes/port-forward/functionality/port-forward.ts +++ b/src/main/routes/port-forward/functionality/port-forward.ts @@ -7,8 +7,9 @@ import { getPortFrom } from "../../../utils/get-port"; import type { ChildProcessWithoutNullStreams } from "child_process"; import { spawn } from "child_process"; import * as tcpPortUsed from "tcp-port-used"; +import { TypedRegEx } from "typed-regex"; -const internalPortRegex = /^forwarding from (?

.+) ->/i; +const internalPortRegex = TypedRegEx("^forwarding from (?
.+) ->", "i"); export interface PortForwardArgs { clusterId: string; @@ -36,7 +37,7 @@ export class PortForward { )); } - public process: ChildProcessWithoutNullStreams; + public process?: ChildProcessWithoutNullStreams; public clusterId: string; public kind: string; public namespace: string; @@ -98,6 +99,6 @@ export class PortForward { } public async stop() { - this.process.kill(); + this.process?.kill(); } } diff --git a/src/main/routes/port-forward/get-current-port-forward-route.injectable.ts b/src/main/routes/port-forward/get-current-port-forward-route.injectable.ts index a5b83edbf6..769046c804 100644 --- a/src/main/routes/port-forward/get-current-port-forward-route.injectable.ts +++ b/src/main/routes/port-forward/get-current-port-forward-route.injectable.ts @@ -2,36 +2,33 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../router/router.injectable"; -import type { LensApiRequest, Route } from "../../router/router"; +import { getRouteInjectable } from "../../router/router.injectable"; import { apiPrefix } from "../../../common/vars"; import { PortForward } from "./functionality/port-forward"; +import { clusterRoute } from "../../router/route"; -const getCurrentPortForward = async (request: LensApiRequest) => { - const { params, query, cluster } = request; - const { namespace, resourceType, resourceName } = params; - const port = Number(query.get("port")); - const forwardPort = Number(query.get("forwardPort")); - - const portForward = PortForward.getPortforward({ - clusterId: cluster.id, kind: resourceType, name: resourceName, - namespace, port, forwardPort, - }); - - return { response: { port: portForward?.forwardPort ?? null }}; -}; - -const getCurrentPortForwardRouteInjectable = getInjectable({ +const getCurrentPortForwardRouteInjectable = getRouteInjectable({ id: "get-current-port-forward-route", - instantiate: (): Route<{ port: number }> => ({ + instantiate: () => clusterRoute({ method: "get", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`, - handler: getCurrentPortForward, - }), + })(async ({ params, query, cluster }) => { + const { namespace, resourceType, resourceName } = params; + const port = Number(query.get("port")); + const forwardPort = Number(query.get("forwardPort")); - injectionToken: routeInjectionToken, + const portForward = PortForward.getPortforward({ + clusterId: cluster.id, kind: resourceType, name: resourceName, + namespace, port, forwardPort, + }); + + return { + response: { + port: portForward?.forwardPort ?? null, + }, + }; + }), }); export default getCurrentPortForwardRouteInjectable; diff --git a/src/main/routes/port-forward/start-port-forward-route.injectable.ts b/src/main/routes/port-forward/start-port-forward-route.injectable.ts index 50afbd2575..ba7a857255 100644 --- a/src/main/routes/port-forward/start-port-forward-route.injectable.ts +++ b/src/main/routes/port-forward/start-port-forward-route.injectable.ts @@ -2,97 +2,90 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../router/router.injectable"; -import type { LensApiRequest, Route } from "../../router/router"; +import { getRouteInjectable } from "../../router/router.injectable"; import { apiPrefix } from "../../../common/vars"; -import type { PortForwardArgs } from "./functionality/port-forward"; import { PortForward } from "./functionality/port-forward"; import logger from "../../logger"; import createPortForwardInjectable from "./functionality/create-port-forward.injectable"; +import { clusterRoute } from "../../router/route"; -interface Dependencies { - createPortForward: (pathToKubeConfig: string, args: PortForwardArgs) => PortForward; -} +const startPortForwardRouteInjectable = getRouteInjectable({ + id: "start-current-port-forward-route", -const startPortForward = ({ createPortForward }: Dependencies) => async (request: LensApiRequest) => { - const { params, query, cluster } = request; - const { namespace, resourceType, resourceName } = params; - const port = Number(query.get("port")); - const forwardPort = Number(query.get("forwardPort")); + instantiate: (di) => { + const createPortForward = di.inject(createPortForwardInjectable); - try { - let portForward = PortForward.getPortforward({ - clusterId: cluster.id, - kind: resourceType, - name: resourceName, - namespace, - port, - forwardPort, - }); + return clusterRoute({ + method: "post", + path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`, + })(async ({ params, query, cluster }) => { + const { namespace, resourceType, resourceName } = params; + const port = Number(query.get("port")); + const forwardPort = Number(query.get("forwardPort")); - if (!portForward) { - logger.info( - `Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`, - ); - - const thePort = - 0 < forwardPort && forwardPort < 65536 ? forwardPort : 0; - - portForward = createPortForward(await cluster.getProxyKubeconfigPath(), { - clusterId: cluster.id, - kind: resourceType, - namespace, - name: resourceName, - port, - forwardPort: thePort, - }); - - const started = await portForward.start(); - - if (!started) { - logger.error("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { + try { + let portForward = PortForward.getPortforward({ + clusterId: cluster.id, + kind: resourceType, + name: resourceName, namespace, port, - resourceType, - resourceName, + forwardPort, }); + if (!portForward) { + logger.info( + `Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`, + ); + + const thePort = 0 < forwardPort && forwardPort < 65536 + ? forwardPort + : 0; + + portForward = createPortForward(await cluster.getProxyKubeconfigPath(), { + clusterId: cluster.id, + kind: resourceType, + namespace, + name: resourceName, + port, + forwardPort: thePort, + }); + + const started = await portForward.start(); + + if (!started) { + logger.error("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { + namespace, + port, + resourceType, + resourceName, + }); + + return { + error: { + message: `Failed to forward port ${port} to ${ + thePort ? forwardPort : "random port" + }`, + }, + }; + } + } + + return { response: { port: portForward.forwardPort }}; + } catch (error) { + logger.error( + `[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, + { namespace, port, resourceType, resourceName }, + ); + return { error: { - message: `Failed to forward port ${port} to ${ - thePort ? forwardPort : "random port" - }`, + message: `Failed to forward port ${port}`, }, }; } - } - - return { response: { port: portForward.forwardPort }}; - } catch (error) { - logger.error( - `[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, - { namespace, port, resourceType, resourceName }, - ); - - return { - error: { - message: `Failed to forward port ${port}`, - }, - }; - } -}; - -const startPortForwardRouteInjectable = getInjectable({ - id: "start-current-port-forward-route", - - instantiate: (di): Route<{ port: number }> => ({ - method: "post", - path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`, - handler: startPortForward({ createPortForward: di.inject(createPortForwardInjectable) }), - }), - - injectionToken: routeInjectionToken, + }); + }, }); export default startPortForwardRouteInjectable; diff --git a/src/main/routes/port-forward/stop-current-port-forward-route.injectable.ts b/src/main/routes/port-forward/stop-current-port-forward-route.injectable.ts index b9fce502c1..6ae23af9c2 100644 --- a/src/main/routes/port-forward/stop-current-port-forward-route.injectable.ts +++ b/src/main/routes/port-forward/stop-current-port-forward-route.injectable.ts @@ -2,50 +2,42 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../router/router.injectable"; -import type { LensApiRequest, Route } from "../../router/router"; +import { getRouteInjectable } from "../../router/router.injectable"; import { apiPrefix } from "../../../common/vars"; import { PortForward } from "./functionality/port-forward"; import logger from "../../logger"; +import { clusterRoute } from "../../router/route"; -const stopCurrentPortForward = async (request: LensApiRequest) => { - const { params, query, cluster } = request; - const { namespace, resourceType, resourceName } = params; - const port = Number(query.get("port")); - const forwardPort = Number(query.get("forwardPort")); - - const portForward = PortForward.getPortforward({ - clusterId: cluster.id, kind: resourceType, name: resourceName, - namespace, port, forwardPort, - }); - - try { - await portForward.stop(); - - return { response: { status: true }}; - } catch (error) { - logger.error("[PORT-FORWARD-ROUTE]: error stopping a port-forward", { namespace, port, forwardPort, resourceType, resourceName }); - - return { - error: { - message: `error stopping a forward port ${port}`, - }, - }; - - } -}; - -const stopCurrentPortForwardRouteInjectable = getInjectable({ +const stopCurrentPortForwardRouteInjectable = getRouteInjectable({ id: "stop-current-port-forward-route", - instantiate: (): Route<{ status: boolean }> => ({ + instantiate: () => clusterRoute({ method: "delete", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`, - handler: stopCurrentPortForward, - }), + })(async ({ params, query, cluster }) => { + const { namespace, resourceType, resourceName } = params; + const port = Number(query.get("port")); + const forwardPort = Number(query.get("forwardPort")); + const portForward = PortForward.getPortforward({ + clusterId: cluster.id, kind: resourceType, name: resourceName, + namespace, port, forwardPort, + }); - injectionToken: routeInjectionToken, + try { + await portForward?.stop(); + + return { response: { status: true }}; + } catch (error) { + logger.error("[PORT-FORWARD-ROUTE]: error stopping a port-forward", { namespace, port, forwardPort, resourceType, resourceName }); + + return { + error: { + message: `error stopping a forward port ${port}`, + }, + }; + + } + }), }); export default stopCurrentPortForwardRouteInjectable; diff --git a/src/main/routes/resource-applier/apply-resource-route.injectable.ts b/src/main/routes/resource-applier/apply-resource-route.injectable.ts index b1db11cfa3..45f484fd3f 100644 --- a/src/main/routes/resource-applier/apply-resource-route.injectable.ts +++ b/src/main/routes/resource-applier/apply-resource-route.injectable.ts @@ -2,25 +2,20 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../router/router.injectable"; -import type { Route } from "../../router/router"; +import { getRouteInjectable } from "../../router/router.injectable"; import { apiPrefix } from "../../../common/vars"; import { ResourceApplier } from "../../resource-applier"; +import { clusterRoute } from "../../router/route"; -const applyResourceRouteInjectable = getInjectable({ +const applyResourceRouteInjectable = getRouteInjectable({ id: "apply-resource-route", - instantiate: (): Route => ({ + instantiate: () => clusterRoute({ method: "post", path: `${apiPrefix}/stack`, - - handler: async ({ cluster, payload }) => ({ - response: await new ResourceApplier(cluster).apply(payload), - }), - }), - - injectionToken: routeInjectionToken, + })(async ({ cluster, payload }) => ({ + response: await new ResourceApplier(cluster).apply(payload), + })), }); export default applyResourceRouteInjectable; diff --git a/src/main/routes/resource-applier/patch-resource-route.injectable.ts b/src/main/routes/resource-applier/patch-resource-route.injectable.ts index 2ac26db726..53506d6bb0 100644 --- a/src/main/routes/resource-applier/patch-resource-route.injectable.ts +++ b/src/main/routes/resource-applier/patch-resource-route.injectable.ts @@ -2,30 +2,56 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { routeInjectionToken } from "../../router/router.injectable"; -import type { Route } from "../../router/router"; +import { getRouteInjectable } from "../../router/router.injectable"; import { apiPrefix } from "../../../common/vars"; import { ResourceApplier } from "../../resource-applier"; +import { payloadValidatedClusterRoute } from "../../router/route"; +import Joi from "joi"; +import type { Patch } from "rfc6902"; -const patchResourceRouteInjectable = getInjectable({ +interface PatchResourcePayload { + name: string; + kind: string; + patch: Patch; + ns?: string; +} + +const patchResourcePayloadValidator = Joi.object({ + name: Joi + .string() + .required(), + kind: Joi + .string() + .required(), + ns: Joi + .string() + .optional(), + patch: Joi + .array() + .allow( + Joi.object({ + op: Joi + .string() + .required(), + }).unknown(true), + ), +}); + +const patchResourceRouteInjectable = getRouteInjectable({ id: "patch-resource-route", - instantiate: (): Route => ({ + instantiate: () => payloadValidatedClusterRoute({ method: "patch", path: `${apiPrefix}/stack`, - - handler: async ({ cluster, payload }) => ({ - response: await new ResourceApplier(cluster).patch( - payload.name, - payload.kind, - payload.patch, - payload.ns, - ), - }), - }), - - injectionToken: routeInjectionToken, + payloadValidator: patchResourcePayloadValidator, + })(async ({ cluster, payload }) => ({ + response: await new ResourceApplier(cluster).patch( + payload.name, + payload.kind, + payload.patch, + payload.ns, + ), + })), }); export default patchResourceRouteInjectable; diff --git a/src/main/routes/static-file-route.injectable.ts b/src/main/routes/static-file-route.injectable.ts index 9e90e23eef..479cc88e4c 100644 --- a/src/main/routes/static-file-route.injectable.ts +++ b/src/main/routes/static-file-route.injectable.ts @@ -2,12 +2,10 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import type { LensApiRequest, Route } from "../router/router"; import type { SupportedFileExtension } from "../router/router-content-types"; import { contentTypes } from "../router/router-content-types"; import logger from "../logger"; -import { routeInjectionToken } from "../router/router.injectable"; +import { getRouteInjectable } from "../router/router.injectable"; import { appName, publicPath, staticFilesDirectory } from "../../common/vars"; import path from "path"; import isDevelopmentInjectable from "../../common/vars/is-development.injectable"; @@ -18,6 +16,8 @@ import getAbsolutePathInjectable from "../../common/path/get-absolute-path.injec import type { JoinPaths } from "../../common/path/join-paths.injectable"; import joinPathsInjectable from "../../common/path/join-paths.injectable"; import { webpackDevServerPort } from "../../../webpack/vars"; +import type { LensApiRequest, RouteResponse } from "../router/route"; +import { route } from "../router/route"; interface ProductionDependencies { readFileBuffer: (path: string) => Promise; @@ -27,7 +27,7 @@ interface ProductionDependencies { const handleStaticFileInProduction = ({ readFileBuffer, getAbsolutePath, joinPaths }: ProductionDependencies) => - async ({ params }: LensApiRequest) => { + async ({ params }: LensApiRequest<"/{path*}">): Promise> => { const staticPath = getAbsolutePath(staticFilesDirectory); let filePath = params.path; @@ -49,7 +49,7 @@ const handleStaticFileInProduction = return { response: await readFileBuffer(asset), contentType }; } catch (err) { if (retryCount > 5) { - logger.error("handleStaticFile:", err.toString()); + logger.error("handleStaticFile:", String(err)); return { statusCode: 404 }; } @@ -67,10 +67,8 @@ interface DevelopmentDependencies { const handleStaticFileInDevelopment = ({ proxy }: DevelopmentDependencies) => - (apiReq: LensApiRequest) => { - const { req, res } = apiReq.raw; - - if (req.url === "/" || !req.url.startsWith("/build/")) { + ({ raw: { req, res }}: LensApiRequest<"/{path*}">): RouteResponse => { + if (req.url === "/" || !req.url?.startsWith("/build/")) { req.url = `${publicPath}/${appName}.html`; } @@ -81,25 +79,27 @@ const handleStaticFileInDevelopment = return { proxy }; }; -const staticFileRouteInjectable = getInjectable({ +const staticFileRouteInjectable = getRouteInjectable({ id: "static-file-route", - instantiate: (di): Route => { + instantiate: (di) => { const isDevelopment = di.inject(isDevelopmentInjectable); - const readFileBuffer = di.inject(readFileBufferInjectable); - const getAbsolutePath = di.inject(getAbsolutePathInjectable); - const joinPaths = di.inject(joinPathsInjectable); - return { + return route({ method: "get", path: `/{path*}`, - handler: isDevelopment - ? handleStaticFileInDevelopment({ proxy: httpProxy.createProxy() }) - : handleStaticFileInProduction({ readFileBuffer, getAbsolutePath, joinPaths }), - }; + })( + isDevelopment + ? handleStaticFileInDevelopment({ + proxy: httpProxy.createProxy(), + }) + : handleStaticFileInProduction({ + readFileBuffer: di.inject(readFileBufferInjectable), + getAbsolutePath: di.inject(getAbsolutePathInjectable), + joinPaths: di.inject(joinPathsInjectable), + }), + ); }, - - injectionToken: routeInjectionToken, }); export default staticFileRouteInjectable; diff --git a/src/main/routes/versions/get-version-route.injectable.ts b/src/main/routes/versions/get-version-route.injectable.ts index 357c09f22d..aa846c171c 100644 --- a/src/main/routes/versions/get-version-route.injectable.ts +++ b/src/main/routes/versions/get-version-route.injectable.ts @@ -2,22 +2,21 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getInjectable } from "@ogre-tools/injectable"; -import type { Route } from "../../router/router"; -import { routeInjectionToken } from "../../router/router.injectable"; +import { getRouteInjectable } from "../../router/router.injectable"; import { getAppVersion } from "../../../common/utils"; +import { route } from "../../router/route"; -const getVersionRouteInjectable = getInjectable({ +const getVersionRouteInjectable = getRouteInjectable({ id: "get-version-route", - instantiate: (): Route<{ version: string }> => ({ + instantiate: () => route({ method: "get", path: `/version`, - - handler: () => ({ response: { version: getAppVersion() }}), - }), - - injectionToken: routeInjectionToken, + })(() => ({ + response: { + version: getAppVersion(), + }, + })), }); export default getVersionRouteInjectable; diff --git a/src/main/shell-session/local-shell-session/local-shell-session.ts b/src/main/shell-session/local-shell-session/local-shell-session.ts index 314f8700ec..bced1e52f6 100644 --- a/src/main/shell-session/local-shell-session/local-shell-session.ts +++ b/src/main/shell-session/local-shell-session/local-shell-session.ts @@ -15,7 +15,7 @@ import { baseBinariesDir } from "../../../common/vars"; export class LocalShellSession extends ShellSession { ShellType = "shell"; - constructor(protected shellEnvModify: (clusterId: ClusterId, env: Record) => Record, kubectl: Kubectl, websocket: WebSocket, cluster: Cluster, terminalId: string) { + constructor(protected shellEnvModify: (clusterId: ClusterId, env: Record) => Record, kubectl: Kubectl, websocket: WebSocket, cluster: Cluster, terminalId: string) { super(kubectl, websocket, cluster, terminalId); } @@ -34,9 +34,14 @@ export class LocalShellSession extends ShellSession { env = this.shellEnvModify(this.cluster.id, env); const shell = env.PTYSHELL; + + if (!shell) { + throw new Error("PTYSHELL is not defined with the environment"); + } + const args = await this.getShellArgs(shell); - await this.openShellProcess(env.PTYSHELL, args, env); + await this.openShellProcess(shell, args, env); } protected async getShellArgs(shell: string): Promise { diff --git a/src/main/shell-session/node-shell-session/node-shell-session.ts b/src/main/shell-session/node-shell-session/node-shell-session.ts index 8c5861bff5..55e9479084 100644 --- a/src/main/shell-session/node-shell-session/node-shell-session.ts +++ b/src/main/shell-session/node-shell-session/node-shell-session.ts @@ -5,12 +5,12 @@ import type WebSocket from "ws"; import { v4 as uuid } from "uuid"; -import * as k8s from "@kubernetes/client-node"; +import { Watch, CoreV1Api } from "@kubernetes/client-node"; import type { KubeConfig } from "@kubernetes/client-node"; import type { Cluster } from "../../../common/cluster/cluster"; import { ShellOpenError, ShellSession } from "../shell-session"; -import { get } from "lodash"; -import { Node, NodesApi } from "../../../common/k8s-api/endpoints"; +import { get, once } from "lodash"; +import { Node, NodeApi } from "../../../common/k8s-api/endpoints"; import { KubeJsonApi } from "../../../common/k8s-api/kube-json-api"; import logger from "../../logger"; import { TerminalChannels } from "../../../renderer/api/terminal-api"; @@ -20,7 +20,6 @@ export class NodeShellSession extends ShellSession { ShellType = "node-shell"; protected readonly podName = `node-shell-${uuid()}`; - protected kc: KubeConfig; protected readonly cwd: string | undefined = undefined; @@ -29,17 +28,27 @@ export class NodeShellSession extends ShellSession { } public async open() { - this.kc = await this.cluster.getProxyKubeconfig(); + const kc = await this.cluster.getProxyKubeconfig(); + const coreApi = kc.makeApiClient(CoreV1Api); const shell = await this.kubectl.getPath(); + const cleanup = once(() => { + coreApi + .deleteNamespacedPod(this.podName, "kube-system") + .catch(error => logger.warn(`[NODE-SHELL]: failed to remove pod shell`, error)); + }); + + this.websocket.once("close", cleanup); + try { - await this.createNodeShellPod(); - await this.waitForRunningPod(); + await this.createNodeShellPod(coreApi); + await this.waitForRunningPod(kc); } catch (error) { - this.deleteNodeShellPod(); + cleanup(); + this.send({ type: TerminalChannels.STDOUT, - data: `Error occurred: ${get(error, "response.body.message", error?.toString() || "unknown error")}`, + data: `Error occurred: ${get(error, "response.body.message", error ? String(error) : "unknown error")}`, }); throw new ShellOpenError("failed to create node pod", error); @@ -47,11 +56,16 @@ export class NodeShellSession extends ShellSession { const env = await this.getCachedShellEnv(); const args = ["exec", "-i", "-t", "-n", "kube-system", this.podName, "--"]; - const nodeApi = new NodesApi({ + const nodeApi = new NodeApi({ objectConstructor: Node, request: KubeJsonApi.forCluster(this.cluster.id), }); const node = await nodeApi.get({ name: this.nodeName }); + + if (!node) { + throw new Error(`No node with name=${this.nodeName} found`); + } + const nodeOs = node.getOperatingSystem(); switch (nodeOs) { @@ -69,16 +83,14 @@ export class NodeShellSession extends ShellSession { await this.openShellProcess(shell, args, env); } - protected createNodeShellPod() { + protected createNodeShellPod(coreApi: CoreV1Api) { const imagePullSecrets = this.cluster.imagePullSecret ? [{ name: this.cluster.imagePullSecret, }] : undefined; - return this - .kc - .makeApiClient(k8s.CoreV1Api) + return coreApi .createNamespacedPod("kube-system", { metadata: { name: this.podName, @@ -109,11 +121,11 @@ export class NodeShellSession extends ShellSession { }); } - protected waitForRunningPod(): Promise { + protected waitForRunningPod(kc: KubeConfig): Promise { logger.debug(`[NODE-SHELL]: waiting for ${this.podName} to be running`); return new Promise((resolve, reject) => { - new k8s.Watch(this.kc) + new Watch(kc) .watch(`/api/v1/namespaces/kube-system/pods`, {}, // callback is called for each received object. @@ -146,17 +158,4 @@ export class NodeShellSession extends ShellSession { }); }); } - - protected exit() { - super.exit(); - this.deleteNodeShellPod(); - } - - protected deleteNodeShellPod() { - this - .kc - .makeApiClient(k8s.CoreV1Api) - .deleteNamespacedPod(this.podName, "kube-system") - .catch(error => logger.warn(`[NODE-SHELL]: failed to remove pod shell`, error)); - } } diff --git a/src/main/shell-session/shell-env-modifier/terminal-shell-env-modifiers.ts b/src/main/shell-session/shell-env-modifier/terminal-shell-env-modifiers.ts index f60631e554..259a1a7c65 100644 --- a/src/main/shell-session/shell-env-modifier/terminal-shell-env-modifiers.ts +++ b/src/main/shell-session/shell-env-modifier/terminal-shell-env-modifiers.ts @@ -6,19 +6,20 @@ import type { IComputedValue } from "mobx"; import { computed } from "mobx"; import type { ClusterId } from "../../../common/cluster-types"; +import { isDefined } from "../../../common/utils"; import type { LensMainExtension } from "../../../extensions/lens-main-extension"; import { catalogEntityRegistry } from "../../catalog"; - + interface Dependencies { extensions: IComputedValue; } - -export const terminalShellEnvModify = ({ extensions }: Dependencies) => - (clusterId: ClusterId, env: Record) => { + +export const terminalShellEnvModify = ({ extensions }: Dependencies) => + (clusterId: ClusterId, env: Record) => { const terminalShellEnvModifiers = computed(() => ( extensions.get() .map((extension) => extension.terminalShellEnvModifier) - .filter(Boolean) + .filter(isDefined) )) .get(); @@ -26,7 +27,7 @@ export const terminalShellEnvModify = ({ extensions }: Dependencies) => return env; } - const entity = catalogEntityRegistry.getById(clusterId); + const entity = catalogEntityRegistry.findById(clusterId); if (entity) { const ctx = { catalogEntity: entity }; diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts index 831c24bc02..e96f847c8b 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -20,9 +20,10 @@ import type { TerminalMessage } from "../../renderer/api/terminal-api"; import { TerminalChannels } from "../../renderer/api/terminal-api"; import { deserialize, serialize } from "v8"; import { stat } from "fs/promises"; +import { getOrInsertWith } from "../../common/utils"; export class ShellOpenError extends Error { - constructor(message: string, public cause: Error) { + constructor(message: string, public cause: unknown) { super(`${message}: ${cause}`); this.name = this.constructor.name; Error.captureStackTrace(this); @@ -106,10 +107,10 @@ export enum WebSocketCloseEvent { } export abstract class ShellSession { - abstract ShellType: string; + abstract readonly ShellType: string; - private static shellEnvs = new Map>(); - private static processes = new Map(); + private static readonly shellEnvs = new Map>(); + private static readonly processes = new Map(); /** * Kill all remaining shell backing processes. Should be called when about to @@ -128,35 +129,32 @@ export abstract class ShellSession { } protected running = false; - protected kubectlBinDirP: Promise; - protected kubeconfigPathP: Promise; + protected readonly kubectlBinDirP: Promise; + protected readonly kubeconfigPathP: Promise; protected readonly terminalId: string; protected abstract get cwd(): string | undefined; - protected ensureShellProcess(shell: string, args: string[], env: Record, cwd: string): { shellProcess: pty.IPty; resume: boolean } { + protected ensureShellProcess(shell: string, args: string[], env: Record, cwd: string): { shellProcess: pty.IPty; resume: boolean } { const resume = ShellSession.processes.has(this.terminalId); - - if (!resume) { - ShellSession.processes.set(this.terminalId, pty.spawn(shell, args, { + const shellProcess = getOrInsertWith(ShellSession.processes, this.terminalId, () => ( + pty.spawn(shell, args, { rows: 30, cols: 80, cwd, - env, + env: env as Record, name: "xterm-256color", // TODO: Something else is broken here so we need to force the use of winPty on windows useConpty: false, - })); - } - - const shellProcess = ShellSession.processes.get(this.terminalId); + }) + )); logger.info(`[SHELL-SESSION]: PTY for ${this.terminalId} is ${resume ? "resumed" : "started"} with PID=${shellProcess.pid}`); return { shellProcess, resume }; } - constructor(protected kubectl: Kubectl, protected websocket: WebSocket, protected cluster: Cluster, terminalId: string) { + constructor(protected readonly kubectl: Kubectl, protected readonly websocket: WebSocket, protected readonly cluster: Cluster, terminalId: string) { this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath(); this.kubectlBinDirP = this.kubectl.binDir(); this.terminalId = `${cluster.id}:${terminalId}`; @@ -166,7 +164,7 @@ export abstract class ShellSession { this.websocket.send(serialize(message)); } - protected async getCwd(env: Record): Promise { + protected async getCwd(env: Record): Promise { const cwdOptions = [this.cwd]; if (isWindows) { @@ -207,7 +205,7 @@ export abstract class ShellSession { return "."; // Always valid } - protected async openShellProcess(shell: string, args: string[], env: Record) { + protected async openShellProcess(shell: string, args: string[], env: Record) { const cwd = await this.getCwd(env); const { shellProcess, resume } = this.ensureShellProcess(shell, args, env, cwd); @@ -234,7 +232,7 @@ export abstract class ShellSession { }); this.websocket - .on("message", (data: string | Uint8Array) => { + .on("message", (data: string | Uint8Array): void => { if (!this.running) { return void logger.debug(`[SHELL-SESSION]: received message from ${this.terminalId}, but shellProcess isn't running`); } @@ -261,7 +259,7 @@ export abstract class ShellSession { logger.error(`[SHELL-SESSION]: failed to handle message for ${this.terminalId}`, error); } }) - .on("close", code => { + .once("close", code => { logger.info(`[SHELL-SESSION]: websocket for ${this.terminalId} closed with code=${WebSocketCloseEvent[code]}(${code})`, { cluster: this.cluster.getMeta() }); const stopShellSession = this.running diff --git a/src/main/tray/tray.ts b/src/main/tray/tray.ts index af129ac27a..85ed84845e 100644 --- a/src/main/tray/tray.ts +++ b/src/main/tray/tray.ts @@ -25,7 +25,7 @@ import { JSDOM } from "jsdom"; const TRAY_LOG_PREFIX = "[TRAY]"; // note: instance of Tray should be saved somewhere, otherwise it disappears -export let tray: Tray; +export let tray: Tray | null = null; interface CreateTrayIconArgs { shouldUseDarkColors: boolean; @@ -103,7 +103,7 @@ export async function initTray( try { const menu = createTrayMenu(windowManager, toJS(trayMenuItems.get()), navigateToPreferences); - tray.setContextMenu(menu); + tray?.setContextMenu(menu); } catch (error) { logger.error(`${TRAY_LOG_PREFIX}: building failed`, { error }); } @@ -122,7 +122,7 @@ function getMenuItemConstructorOptions(trayItem: TrayMenuRegistration): Electron ...trayItem, submenu: trayItem.submenu ? trayItem.submenu.map(getMenuItemConstructorOptions) : undefined, click: trayItem.click ? () => { - trayItem.click(trayItem); + trayItem.click?.(trayItem); } : undefined, }; } diff --git a/src/main/utils/get-port.ts b/src/main/utils/get-port.ts index 372477bb09..6ce76f6795 100644 --- a/src/main/utils/get-port.ts +++ b/src/main/utils/get-port.ts @@ -12,7 +12,15 @@ interface GetPortArgs { * Should be case insensitive * Must have a named matching group called `address` */ - lineRegex: RegExp; + lineRegex: { + match: (line: string) => { + matched: boolean; + groups?: { + address?: string; + }; + raw?: RegExpExecArray; + }; + }; /** * Called when the port is found */ @@ -35,15 +43,15 @@ export function getPortFrom(stream: Readable, args: GetPortArgs): Promise((resolve, reject) => { - const handler = (data: any) => { - const logItem: string = data.toString(); - const match = logItem.match(args.lineRegex); + const handler = (data: unknown) => { + const logItem = String(data); + const match = args.lineRegex.match(logItem); logLines.push(logItem); - if (match) { + if (match.matched) { // use unknown protocol so that there is no default port - const addr = new URLParse(`s://${match.groups.address.trim()}`); + const addr = new URLParse(`s://${match.groups?.address?.trim()}`); args.onFind?.(); stream.off("data", handler); diff --git a/src/main/utils/update-channel.ts b/src/main/utils/update-channel.ts index aabd9a9216..598d0f0bfd 100644 --- a/src/main/utils/update-channel.ts +++ b/src/main/utils/update-channel.ts @@ -9,7 +9,7 @@ * @param channel The current channel that did not have a new version associated with it * @returns The channel name of the next release version */ -export function nextUpdateChannel(defaultChannel: string, channel: string): string { +export function nextUpdateChannel(defaultChannel: string, channel: string | null): string { switch (channel) { case "alpha": return "beta"; diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index 260a82ccc0..4097bf1ab7 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -18,10 +18,6 @@ import { isMac, productName } from "../common/vars"; import { LensProxy } from "./lens-proxy"; import { bundledExtensionsLoaded } from "../common/ipc/extension-handling"; -function isHideable(window: BrowserWindow | null): boolean { - return Boolean(window && !window.isDestroyed()); -} - export interface SendToViewArgs { channel: string; frameInfo?: ClusterFrameInfo; @@ -31,12 +27,11 @@ export interface SendToViewArgs { export class WindowManager extends Singleton { public mainContentUrl = `http://localhost:${LensProxy.getInstance().port}`; - protected mainWindow: BrowserWindow; - protected splashWindow: BrowserWindow; - protected windowState: windowStateKeeper.State; - protected disposers: Record = {}; + protected mainWindow?: BrowserWindow; + protected splashWindow?: BrowserWindow; + protected windowState?: windowStateKeeper.State; - @observable activeClusterId: ClusterId; + @observable activeClusterId?: ClusterId; constructor() { super(); @@ -44,7 +39,7 @@ export class WindowManager extends Singleton { this.bindEvents(); } - private async initMainWindow(showSplash: boolean) { + private async initMainWindow(showSplash: boolean): Promise { // Manage main window size and position with state persistence if (!this.windowState) { this.windowState = windowStateKeeper({ @@ -88,9 +83,9 @@ export class WindowManager extends Singleton { }) .on("closed", () => { // clean up - this.windowState.unmanage(); - this.mainWindow = null; - this.splashWindow = null; + this.windowState?.unmanage(); + this.mainWindow = undefined; + this.splashWindow = undefined; app.dock?.hide(); // hide icon in dock (mac-os) }) .webContents @@ -142,13 +137,17 @@ export class WindowManager extends Singleton { } try { - if (showSplash) await this.showSplash(); + if (showSplash) { + await this.showSplash(); + } logger.info(`[WINDOW-MANAGER]: Loading Main window from url: ${this.mainContentUrl} ...`); await this.mainWindow.loadURL(this.mainContentUrl); } catch (error) { logger.error("Loading main window failed", { error }); - dialog.showErrorBox("ERROR!", error.toString()); + dialog.showErrorBox("ERROR!", String(error)); } + + return this.mainWindow; } protected bindEvents() { @@ -166,7 +165,7 @@ export class WindowManager extends Singleton { viewHasLoaded = new Promise(resolve => { ipcMain.once(bundledExtensionsLoaded, () => resolve()); }); - await this.initMainWindow(showSplash); + this.mainWindow = await this.initMainWindow(showSplash); } try { @@ -181,27 +180,26 @@ export class WindowManager extends Singleton { appEventBus.emit({ name: "app", action: "start" }); }, 1000); } catch (error) { - logger.error(`Showing main window failed: ${error.stack || error}`); - dialog.showErrorBox("ERROR!", error.toString()); + logger.error(`Showing main window failed`, error); + dialog.showErrorBox("ERROR!", String(error)); } return this.mainWindow; } - sendToView({ channel, frameInfo, data = [] }: SendToViewArgs) { + private sendToView(window: BrowserWindow, { channel, frameInfo, data = [] }: SendToViewArgs) { if (frameInfo) { - this.mainWindow.webContents.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...data); + window.webContents.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...data); } else { - this.mainWindow.webContents.send(channel, ...data); + window.webContents.send(channel, ...data); } } async navigateExtension(extId: string, pageId?: string, params?: Record, frameId?: number) { - await this.ensureMainWindow(); - + const window = await this.ensureMainWindow(); const frameInfo = iter.find(clusterFrameMap.values(), frameInfo => frameInfo.frameId === frameId); - this.sendToView({ + this.sendToView(window, { channel: "extension:navigate", frameInfo, data: [extId, pageId, params], @@ -209,34 +207,44 @@ export class WindowManager extends Singleton { } async navigate(url: string, frameId?: number) { - await this.ensureMainWindow(); + const window = await this.ensureMainWindow(); - this.navigateSync(url, frameId); + this.navigateSync(window, url, frameId); } - navigateSync(url: string, frameId?: number) { + navigateSync(window: BrowserWindow, url: string, frameId?: number) { const frameInfo = iter.find(clusterFrameMap.values(), frameInfo => frameInfo.frameId === frameId); const channel = frameInfo ? IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER : IpcRendererNavigationEvents.NAVIGATE_IN_APP; - this.sendToView({ + this.sendToView(window, { channel, frameInfo, data: [url], }); } - reload() { - const frameInfo = clusterFrameMap.get(this.activeClusterId); + private getActiveClusterFrameInfo() { + if (this.activeClusterId) { + return clusterFrameMap.get(this.activeClusterId); + } - if (frameInfo) { - this.sendToView({ channel: IpcRendererNavigationEvents.RELOAD_PAGE, frameInfo }); + return undefined; + } + + reload() { + const frameInfo = this.getActiveClusterFrameInfo(); + + if (frameInfo && this.mainWindow) { + this.sendToView(this.mainWindow, { channel: IpcRendererNavigationEvents.RELOAD_PAGE, frameInfo }); } else { - webContents.getAllWebContents().filter(wc => wc.getType() === "window").forEach(wc => { - wc.reload(); - wc.clearHistory(); - }); + webContents.getAllWebContents() + .filter(wc => wc.getType() === "window") + .forEach(wc => { + wc.reload(); + wc.clearHistory(); + }); } } @@ -263,23 +271,19 @@ export class WindowManager extends Singleton { } hide() { - if (isHideable(this.mainWindow)) { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.hide(); } - if (isHideable(this.splashWindow)) { + if (this.splashWindow && !this.splashWindow.isDestroyed()) { this.splashWindow.hide(); } } destroy() { - this.mainWindow.destroy(); - this.splashWindow.destroy(); - this.mainWindow = null; - this.splashWindow = null; - Object.entries(this.disposers).forEach(([name, dispose]) => { - dispose(); - delete this.disposers[name]; - }); + this.mainWindow?.destroy(); + this.splashWindow?.destroy(); + this.mainWindow = undefined; + this.splashWindow = undefined; } } diff --git a/src/migrations/cluster-store/3.6.0-beta.1.ts b/src/migrations/cluster-store/3.6.0-beta.1.ts index d399c9f324..09dbd1fd61 100644 --- a/src/migrations/cluster-store/3.6.0-beta.1.ts +++ b/src/migrations/cluster-store/3.6.0-beta.1.ts @@ -21,7 +21,7 @@ import getCustomKubeConfigDirectoryInjectable from "../../common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable"; interface Pre360ClusterModel extends ClusterModel { - kubeConfig: string; + kubeConfig?: string; } export default { @@ -47,6 +47,10 @@ export default { try { const absPath = getCustomKubeConfigDirectory(clusterModel.id); + if (!clusterModel.kubeConfig) { + continue; + } + // take the embedded kubeconfig and dump it into a file fse.writeFileSync(absPath, clusterModel.kubeConfig, { encoding: "utf-8", mode: 0o600 }); @@ -75,7 +79,7 @@ export default { } } catch (error) { migrationLog(`Failed to migrate cluster icon for cluster "${clusterModel.id}"`, error); - delete clusterModel.preferences.icon; + delete clusterModel.preferences?.icon; } migratedClusters.push(clusterModel); diff --git a/src/migrations/cluster-store/5.0.0-beta.10.ts b/src/migrations/cluster-store/5.0.0-beta.10.ts index a9fc759ee4..9172969a85 100644 --- a/src/migrations/cluster-store/5.0.0-beta.10.ts +++ b/src/migrations/cluster-store/5.0.0-beta.10.ts @@ -9,6 +9,7 @@ import type { ClusterModel } from "../../common/cluster-types"; import type { MigrationDeclaration } from "../helpers"; import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import { isErrnoException } from "../../common/utils"; interface Pre500WorkspaceStoreModel { workspaces: { @@ -35,15 +36,18 @@ export default { const clusters: ClusterModel[] = store.get("clusters") ?? []; for (const cluster of clusters) { - if (cluster.workspace && workspaces.has(cluster.workspace)) { - cluster.labels ??= {}; - cluster.labels.workspace = workspaces.get(cluster.workspace); + if (cluster.workspace) { + const workspace = workspaces.get(cluster.workspace); + + if (workspace) { + (cluster.labels ??= {}).workspace = workspace; + } } } store.set("clusters", clusters); } catch (error) { - if (!(error.code === "ENOENT" && error.path.endsWith("lens-workspace-store.json"))) { + if (isErrnoException(error) && !(error.code === "ENOENT" && error.path?.endsWith("lens-workspace-store.json"))) { // ignore lens-workspace-store.json being missing throw error; } diff --git a/src/migrations/cluster-store/5.0.0-beta.13.ts b/src/migrations/cluster-store/5.0.0-beta.13.ts index d386d006d2..dc85a04718 100644 --- a/src/migrations/cluster-store/5.0.0-beta.13.ts +++ b/src/migrations/cluster-store/5.0.0-beta.13.ts @@ -12,6 +12,7 @@ import { moveSync, removeSync } from "fs-extra"; import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import { isDefined } from "../../common/utils"; function mergePrometheusPreferences(left: ClusterPrometheusPreferences, right: ClusterPrometheusPreferences): ClusterPrometheusPreferences { if (left.prometheus && left.prometheusProvider) { @@ -50,12 +51,14 @@ function mergeLabels(left: Record, right: Record }; } -function mergeSet(...iterables: Iterable[]): string[] { +function mergeSet(...iterables: Iterable[]): string[] { const res = new Set(); for (const iterable of iterables) { for (const val of iterable) { - res.add(val); + if (val) { + res.add(val); + } } } @@ -104,16 +107,17 @@ export default { for (const { id: oldId, ...cluster } of oldClusters) { const newId = generateNewIdFor(cluster); + const newCluster = clusters.get(newId); - if (clusters.has(newId)) { + if (newCluster) { migrationLog(`Duplicate entries for ${newId}`, { oldId }); - clusters.set(newId, mergeClusterModel(clusters.get(newId), cluster)); + clusters.set(newId, mergeClusterModel(newCluster, cluster)); } else { migrationLog(`First entry for ${newId}`, { oldId }); clusters.set(newId, { ...cluster, id: newId, - workspaces: [cluster.workspace].filter(Boolean), + workspaces: [cluster.workspace].filter(isDefined), }); moveStorageFolder({ folder, newId, oldId }); } diff --git a/src/migrations/hotbar-store/5.0.0-alpha.2.ts b/src/migrations/hotbar-store/5.0.0-alpha.2.ts index 8291bb267d..1ede94f87b 100644 --- a/src/migrations/hotbar-store/5.0.0-alpha.2.ts +++ b/src/migrations/hotbar-store/5.0.0-alpha.2.ts @@ -14,9 +14,9 @@ export default { const rawHotbars = store.get("hotbars"); const hotbars: Hotbar[] = Array.isArray(rawHotbars) ? rawHotbars : []; - store.set("hotbars", hotbars.map((hotbar) => ({ - id: uuid.v4(), - ...hotbar, + store.set("hotbars", hotbars.map(({ id, ...rest }) => ({ + id: id || uuid.v4(), + ...rest, }))); }, } as MigrationDeclaration; diff --git a/src/migrations/hotbar-store/5.0.0-beta.10.ts b/src/migrations/hotbar-store/5.0.0-beta.10.ts index 15466b993e..b547a52832 100644 --- a/src/migrations/hotbar-store/5.0.0-beta.10.ts +++ b/src/migrations/hotbar-store/5.0.0-beta.10.ts @@ -14,10 +14,9 @@ import type { MigrationDeclaration } from "../helpers"; import { migrationLog } from "../helpers"; import { generateNewIdFor } from "../utils"; import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -import directoryForUserDataInjectable - from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import catalogCatalogEntityInjectable - from "../../common/catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; +import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import catalogCatalogEntityInjectable from "../../common/catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; +import { isDefined, isErrnoException } from "../../common/utils"; interface Pre500WorkspaceStoreModel { workspaces: { @@ -58,7 +57,7 @@ export default { try { const workspaceStoreData: Pre500WorkspaceStoreModel = fse.readJsonSync(path.join(userDataPath, "lens-workspace-store.json")); - const { clusters }: ClusterStoreModel = fse.readJSONSync(path.join(userDataPath, "lens-cluster-store.json")); + const { clusters = [] }: ClusterStoreModel = fse.readJSONSync(path.join(userDataPath, "lens-cluster-store.json")); const workspaceHotbars = new Map(); // mapping from WorkspaceId to HotBar for (const { id, name } of workspaceStoreData.workspaces) { @@ -78,14 +77,14 @@ export default { workspaceHotbars.set("default", { name, id, - items: items.filter(Boolean), + items: items.filter(isDefined), }); } for (const cluster of clusters) { const uid = generateNewIdFor(cluster); - for (const workspaceId of cluster.workspaces ?? [cluster.workspace].filter(Boolean)) { + for (const workspaceId of cluster.workspaces ?? [cluster.workspace].filter(isDefined)) { const workspaceHotbar = workspaceHotbars.get(workspaceId); if (!workspaceHotbar) { @@ -99,7 +98,7 @@ export default { workspaceHotbar.items.push({ entity: { uid: generateNewIdFor(cluster), - name: cluster.preferences.clusterName || cluster.contextName, + name: cluster.preferences?.clusterName || cluster.contextName, }, }); } @@ -156,7 +155,7 @@ export default { } catch (error) { // ignore files being missing - if (error.code !== "ENOENT") { + if (isErrnoException(error) && error.code !== "ENOENT") { throw error; } } diff --git a/src/migrations/hotbar-store/5.0.0-beta.5.ts b/src/migrations/hotbar-store/5.0.0-beta.5.ts index bb7ceb2567..f65bc7825d 100644 --- a/src/migrations/hotbar-store/5.0.0-beta.5.ts +++ b/src/migrations/hotbar-store/5.0.0-beta.5.ts @@ -16,14 +16,19 @@ export default { for (const hotbar of hotbars) { for (let i = 0; i < hotbar.items.length; i += 1) { const item = hotbar.items[i]; - const entity = catalogEntityRegistry.items.find((entity) => entity.getId() === item?.entity.uid); + + if (!item) { + continue; + } + + const entity = catalogEntityRegistry.findById(item.entity.uid); if (!entity) { // Clear disabled item hotbar.items[i] = null; } else { // Save additional data - hotbar.items[i].entity = { + item.entity = { ...item.entity, name: entity.metadata.name, source: entity.metadata.source, diff --git a/src/migrations/user-store/5.0.3-beta.1.ts b/src/migrations/user-store/5.0.3-beta.1.ts index f46949942c..40f93312cf 100644 --- a/src/migrations/user-store/5.0.3-beta.1.ts +++ b/src/migrations/user-store/5.0.3-beta.1.ts @@ -10,7 +10,7 @@ import type { ClusterStoreModel } from "../../common/cluster-store/cluster-store import type { KubeconfigSyncEntry, UserPreferencesModel } from "../../common/user-store"; import type { MigrationDeclaration } from "../helpers"; import { migrationLog } from "../helpers"; -import { isLogicalChildPath } from "../../common/utils"; +import { isErrnoException, isLogicalChildPath } from "../../common/utils"; import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; @@ -69,7 +69,7 @@ export default { migrationLog("Final list of synced paths", updatedSyncEntries); store.set("preferences", { ...preferences, syncKubeconfigEntries: updatedSyncEntries }); } catch (error) { - if (error.code !== "ENOENT") { + if (isErrnoException(error) && error.code !== "ENOENT") { // ignore files being missing throw error; } diff --git a/src/migrations/user-store/file-name-migration.ts b/src/migrations/user-store/file-name-migration.ts index c9075aaa6f..bff2ecaff1 100644 --- a/src/migrations/user-store/file-name-migration.ts +++ b/src/migrations/user-store/file-name-migration.ts @@ -8,6 +8,7 @@ import path from "path"; import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import { isErrnoException } from "../../common/utils"; export function fileNameMigration() { const di = getLegacyGlobalDiForExtensionApi(); @@ -19,10 +20,11 @@ export function fileNameMigration() { try { fse.moveSync(configJsonPath, lensUserStoreJsonPath); } catch (error) { - if (error.code === "ENOENT" && error.path === configJsonPath) { // (No such file or directory) - return; // file already moved - } else if (error.message === "dest already exists.") { + if (error instanceof Error && error.message === "dest already exists.") { fse.removeSync(configJsonPath); + } else if (isErrnoException(error) && error.code === "ENOENT" && error.path === configJsonPath) { + // (No such file or directory) + return; // file already moved } else { // pass other errors along throw error; diff --git a/src/renderer/api/__tests__/catalog-entity-registry.test.ts b/src/renderer/api/__tests__/catalog-entity-registry.test.ts index aeb537267e..fc7826da3f 100644 --- a/src/renderer/api/__tests__/catalog-entity-registry.test.ts +++ b/src/renderer/api/__tests__/catalog-entity-registry.test.ts @@ -9,6 +9,7 @@ import type { CatalogEntityData, CatalogEntityKindData } from "../catalog-entity import { CatalogCategory } from "../catalog-entity"; import { KubernetesCluster, WebLink } from "../../../common/catalog-entities"; import { observable } from "mobx"; +import { categoryVersion } from "../../../common/catalog"; class TestCatalogEntityRegistry extends CatalogEntityRegistry { replaceItems(items: Array) { @@ -26,10 +27,7 @@ class FooBarCategory extends CatalogCategory { public spec = { group: "entity.k8slens.dev", versions: [ - { - name: "v1alpha1", - entityClass: WebLink, - }, + categoryVersion("v1alpha1", WebLink), ], names: { kind: "FooBar", diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index 1cc3f24a0d..2b056eee9c 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -50,10 +50,14 @@ export class CatalogEntityRegistry { } protected getActiveEntityById() { - return this._entities.get(this.activeEntityId) || null; + if (!this.activeEntityId) { + return undefined; + } + + return this._entities.get(this.activeEntityId); } - get activeEntity(): CatalogEntity | null { + get activeEntity(): CatalogEntity | undefined { const entity = this.getActiveEntityById(); // If the entity was not found but there are rawEntities to be processed, @@ -68,7 +72,7 @@ export class CatalogEntityRegistry { return entity; } - set activeEntity(raw: CatalogEntity | string | null) { + set activeEntity(raw: CatalogEntity | string | undefined) { if (raw) { const id = typeof raw === "string" ? raw diff --git a/src/renderer/api/create-terminal-api.injectable.ts b/src/renderer/api/create-terminal-api.injectable.ts new file mode 100644 index 0000000000..23d181d726 --- /dev/null +++ b/src/renderer/api/create-terminal-api.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import hostedClusterIdInjectable from "../cluster/hosted-cluster-id.injectable"; +import type { TerminalApiDependencies, TerminalApiQuery } from "./terminal-api"; +import { TerminalApi } from "./terminal-api"; + +export type CreateTerminalApi = (query: TerminalApiQuery) => TerminalApi; + +const createTerminalApiInjectable = getInjectable({ + id: "create-terminal-api", + instantiate: (di): CreateTerminalApi => { + const hostedClusterId = di.inject(hostedClusterIdInjectable); + + assert(hostedClusterId, "Can only create terminal APIs within a cluster frame"); + + const dependencies: TerminalApiDependencies = { + hostedClusterId, + }; + + return (query) => new TerminalApi(dependencies, query); + }, +}); + +export default createTerminalApiInjectable; diff --git a/src/renderer/api/helpers/general-active-sync.ts b/src/renderer/api/helpers/general-active-sync.ts index ef548d7a9a..bdc38c5eee 100644 --- a/src/renderer/api/helpers/general-active-sync.ts +++ b/src/renderer/api/helpers/general-active-sync.ts @@ -7,12 +7,15 @@ import { when } from "mobx"; import { catalogCategoryRegistry } from "../../../common/catalog"; import { catalogEntityRegistry } from "../catalog-entity-registry"; import { isActiveRoute } from "../../navigation"; -import type { GeneralEntity } from "../../../common/catalog-entities"; +import assert from "assert"; export async function setEntityOnRouteMatch() { await when(() => catalogEntityRegistry.entities.size > 0); - const entities: GeneralEntity[] = catalogEntityRegistry.getItemsForCategory(catalogCategoryRegistry.getByName("General")); + const generalCategory = catalogCategoryRegistry.getByName("General"); + + assert(generalCategory); + const entities = catalogEntityRegistry.getItemsForCategory(generalCategory); const activeEntity = entities.find(entity => isActiveRoute(entity.spec.path)); if (activeEntity) { diff --git a/src/renderer/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index 1e8c86a77c..2e41b2f69d 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -3,7 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getHostedClusterId } from "../utils"; import type { WebSocketEvents } from "./websocket-api"; import { WebSocketApi } from "./websocket-api"; import isEqual from "lodash/isEqual"; @@ -49,23 +48,27 @@ enum TerminalColor { NO_COLOR = "\u001b[0m", } -export type TerminalApiQuery = Record & { +export interface TerminalApiQuery extends Record { id: string; node?: string; type?: string; -}; +} export interface TerminalEvents extends WebSocketEvents { ready: () => void; connected: () => void; } +export interface TerminalApiDependencies { + readonly hostedClusterId: string; +} + export class TerminalApi extends WebSocketApi { - protected size: { width: number; height: number }; + protected size?: { width: number; height: number }; @observable public isReady = false; - constructor(protected query: TerminalApiQuery) { + constructor(protected readonly dependencies: TerminalApiDependencies, protected readonly query: TerminalApiQuery) { super({ flushOnOpen: false, pingInterval: 30, @@ -86,7 +89,7 @@ export class TerminalApi extends WebSocketApi { this.emitStatus("Connecting ..."); } - const authTokenArray = await ipcRenderer.invoke("cluster:shell-api", getHostedClusterId(), this.query.id); + const authTokenArray = await ipcRenderer.invoke("cluster:shell-api", this.dependencies.hostedClusterId, this.query.id); if (!(authTokenArray instanceof Uint8Array)) { throw new TypeError("ShellApi token is not a Uint8Array"); @@ -114,11 +117,15 @@ export class TerminalApi extends WebSocketApi { // data is undefined if the event that was handled is "connected" if (data === undefined) { - /** - * Output the last line, the makes sure that the terminal isn't completely - * empty when the user refreshes. - */ - this.emit("data", window.localStorage.getItem(`${this.query.id}:last-data`)); + const lastData = window.localStorage.getItem(`${this.query.id}:last-data`); + + if (lastData) { + /** + * Output the last line, the makes sure that the terminal isn't completely + * empty when the user refreshes. + */ + this.emit("data", lastData); + } } }); @@ -126,7 +133,9 @@ export class TerminalApi extends WebSocketApi { this.prependListener("connected", onReady); super.connect(socketUrl); - this.socket.binaryType = "arraybuffer"; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.socket!.binaryType = "arraybuffer"; } sendMessage(message: TerminalMessage) { diff --git a/src/renderer/api/websocket-api.ts b/src/renderer/api/websocket-api.ts index 72ebc2d059..8a7072ec78 100644 --- a/src/renderer/api/websocket-api.ts +++ b/src/renderer/api/websocket-api.ts @@ -8,6 +8,7 @@ import EventEmitter from "events"; import type TypedEventEmitter from "typed-emitter"; import type { Arguments } from "typed-emitter"; import { isDevelopment } from "../../common/vars"; +import type { Defaulted } from "../utils"; interface WebsocketApiParams { /** @@ -62,18 +63,16 @@ export interface WebSocketEvents { close: () => void; } -type Defaulted = Required> & Omit; - export class WebSocketApi extends (EventEmitter as { new(): TypedEventEmitter }) { - protected socket?: WebSocket | null; + protected socket: WebSocket | null = null; protected pendingCommands: (string | ArrayBufferLike | Blob | ArrayBufferView)[] = []; - protected reconnectTimer?: any; - protected pingTimer?: any; + protected reconnectTimer?: number; + protected pingTimer?: number; protected params: Defaulted; @observable readyState = WebSocketApiState.PENDING; - private static defaultParams = { + private static readonly defaultParams = { logging: isDevelopment, reconnectDelay: 10, flushOnOpen: true, @@ -83,18 +82,25 @@ export class WebSocketApi extends (EventEmitter constructor(params: WebsocketApiParams) { super(); makeObservable(this); - this.params = Object.assign({}, WebSocketApi.defaultParams, params); + this.params = { + ...WebSocketApi.defaultParams, + ...params, + }; const { pingInterval } = this.params; if (pingInterval) { - this.pingTimer = setInterval(() => this.ping(), pingInterval * 1000); + this.pingTimer = window.setInterval(() => this.ping(), pingInterval * 1000); } } - get isConnected() { + protected getIsConnected(): this is (WebSocketApi & { socket: WebSocket }) { return this.socket?.readyState === WebSocket.OPEN && this.isOnline; } + get isConnected() { + return this.getIsConnected(); + } + get isOnline() { return navigator.onLine; } @@ -144,7 +150,7 @@ export class WebSocketApi extends (EventEmitter } send(command: string | ArrayBufferLike | Blob | ArrayBufferView) { - if (this.isConnected) { + if (this.getIsConnected()) { this.socket.send(command); } else { this.pendingCommands.push(command); @@ -186,7 +192,7 @@ export class WebSocketApi extends (EventEmitter this.writeLog("will reconnect in", `${reconnectDelay}s`); - this.reconnectTimer = setTimeout(() => this.connect(url), reconnectDelay * 1000); + this.reconnectTimer = window.setTimeout(() => this.connect(url), reconnectDelay * 1000); this.readyState = WebSocketApiState.RECONNECTING; } } else { diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 207ae3fd8f..d416722526 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -42,7 +42,8 @@ import navigateToAddClusterInjectable from "../common/front-end-routing/routes/ import addSyncEntriesInjectable from "./initializers/add-sync-entries.injectable"; import hotbarStoreInjectable from "../common/hotbar-store.injectable"; import { bindEvents } from "./navigation/events"; -import deleteClusterDialogModelInjectable from "./components/delete-cluster-dialog/delete-cluster-dialog-model/delete-cluster-dialog-model.injectable"; +import assert from "assert"; +import openDeleteClusterDialogInjectable from "./components/delete-cluster-dialog/open.injectable"; import { init } from "@sentry/electron/renderer"; configurePackages(); // global packages @@ -70,6 +71,9 @@ export async function bootstrap(di: DiContainer) { bindEvents(); const rootElem = document.getElementById("app"); + + assert(rootElem); + const rootNode = createRoot(rootElem); const logPrefix = `[BOOTSTRAP-${process.isMainFrame ? "ROOT" : "CLUSTER"}-FRAME]:`; @@ -103,7 +107,7 @@ export async function bootstrap(di: DiContainer) { logger.info(`${logPrefix} initializing Catalog`); initializers.initCatalog({ openCommandDialog: di.inject(commandOverlayInjectable).open, - deleteClusterDialogModel: di.inject(deleteClusterDialogModelInjectable), + openDeleteClusterDialog: di.inject(openDeleteClusterDialogInjectable), }); const extensionLoader = di.inject(extensionLoaderInjectable); @@ -146,7 +150,6 @@ export async function bootstrap(di: DiContainer) { // TODO: Introduce proper architectural boundaries between root and cluster iframes if (process.isMainFrame) { initializeApp = di.inject(initRootFrameInjectable); - App = (await import("./frames/root-frame/root-frame")).RootFrame; } else { initializeApp = di.inject(initClusterFrameInjectable); diff --git a/src/renderer/cluster-frame-context/cluster-frame-context.injectable.ts b/src/renderer/cluster-frame-context/cluster-frame-context.injectable.ts index ad71a3dd17..e066a05102 100644 --- a/src/renderer/cluster-frame-context/cluster-frame-context.injectable.ts +++ b/src/renderer/cluster-frame-context/cluster-frame-context.injectable.ts @@ -5,7 +5,8 @@ import { getInjectable } from "@ogre-tools/injectable"; import { ClusterFrameContext } from "./cluster-frame-context"; import namespaceStoreInjectable from "../components/+namespaces/namespace-store/namespace-store.injectable"; -import hostedClusterInjectable from "../../common/cluster-store/hosted-cluster.injectable"; +import hostedClusterInjectable from "../cluster/hosted-cluster.injectable"; +import assert from "assert"; const clusterFrameContextInjectable = getInjectable({ id: "cluster-frame-context", @@ -13,13 +14,11 @@ const clusterFrameContextInjectable = getInjectable({ instantiate: (di) => { const cluster = di.inject(hostedClusterInjectable); - return new ClusterFrameContext( - cluster, + assert(cluster, "This can only be injected within a cluster frame"); - { - namespaceStore: di.inject(namespaceStoreInjectable), - }, - ); + return new ClusterFrameContext(cluster, { + namespaceStore: di.inject(namespaceStoreInjectable), + }); }, }); diff --git a/src/renderer/cluster/hosted-cluster-id.injectable.ts b/src/renderer/cluster/hosted-cluster-id.injectable.ts new file mode 100644 index 0000000000..854e004796 --- /dev/null +++ b/src/renderer/cluster/hosted-cluster-id.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { getClusterIdFromHost } from "../../common/utils"; + +const hostedClusterIdInjectable = getInjectable({ + id: "hosted-cluster-id", + instantiate: () => getClusterIdFromHost(location.host), + causesSideEffects: true, +}); + +export default hostedClusterIdInjectable; diff --git a/src/common/cluster-store/hosted-cluster.injectable.ts b/src/renderer/cluster/hosted-cluster.injectable.ts similarity index 52% rename from src/common/cluster-store/hosted-cluster.injectable.ts rename to src/renderer/cluster/hosted-cluster.injectable.ts index 1bcafc9238..a466984952 100644 --- a/src/common/cluster-store/hosted-cluster.injectable.ts +++ b/src/renderer/cluster/hosted-cluster.injectable.ts @@ -3,16 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { getHostedClusterId } from "../utils"; -import clusterStoreInjectable from "./cluster-store.injectable"; +import hostedClusterIdInjectable from "./hosted-cluster-id.injectable"; +import clusterStoreInjectable from "../../common/cluster-store/cluster-store.injectable"; const hostedClusterInjectable = getInjectable({ id: "hosted-cluster", instantiate: (di) => { - const hostedClusterId = getHostedClusterId(); + const hostedClusterId = di.inject(hostedClusterIdInjectable); + const store = di.inject(clusterStoreInjectable); - return di.inject(clusterStoreInjectable).getById(hostedClusterId); + return store.getById(hostedClusterId); }, }); diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index ce53440966..069846c0ac 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -16,7 +16,7 @@ import * as uuid from "uuid"; import { appEventBus } from "../../../common/app-event-bus/event-bus"; import { loadConfigFromString, splitConfig } from "../../../common/kube-helpers"; import { docsUrl } from "../../../common/vars"; -import { iter } from "../../utils"; +import { isDefined, iter } from "../../utils"; import { Button } from "../button"; import { Notifications } from "../notifications"; import { SettingLayout } from "../layout/setting-layout"; @@ -66,7 +66,7 @@ class NonInjectedAddCluster extends React.Component { return [ ...this.errors, ...iter.map(this.kubeContexts.values(), ({ error }) => error), - ].filter(Boolean); + ].filter(isDefined); } readonly refreshContexts = debounce(action(() => { @@ -106,8 +106,18 @@ class NonInjectedAddCluster extends React.Component {

Add Clusters from Kubeconfig

- Clusters added here are not merged into the ~/.kube/config file.{" "} - Read more about adding clusters. + {"Clusters added here are "} + not + {" merged into the "} + ~/.kube/config + {" file. "} + + Read more about adding clusters. +

{ }); it("should contain a kind column if activeCategory is falsy", () => { - expect(getCategoryColumns({ activeCategory: null }).renderTableHeader.find(elem => elem.title === "Kind")).toBeTruthy(); + expect(getCategoryColumns({ activeCategory: null }).renderTableHeader.find(elem => elem?.title === "Kind")).toBeTruthy(); }); it("should not contain a kind column if activeCategory is truthy", () => { - expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "Kind")).toBeFalsy(); + expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "Kind")).toBeFalsy(); }); it("should include the default columns if the provided category doesn't provide any", () => { - expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "Source")).toBeTruthy(); + expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "Source")).toBeTruthy(); }); it("should not include the default columns if the provided category provides any", () => { - expect(getCategoryColumns({ activeCategory: new TestCategory([]) }).renderTableHeader.find(elem => elem.title === "Source")).toBeFalsy(); + expect(getCategoryColumns({ activeCategory: new TestCategory([]) }).renderTableHeader.find(elem => elem?.title === "Source")).toBeFalsy(); }); it("should include the displayColumns from the provided category", () => { @@ -80,7 +83,7 @@ describe("Custom Category Columns", () => { }, ]; - expect(getCategoryColumns({ activeCategory: new TestCategory(columns) }).renderTableHeader.find(elem => elem.title === "Foo")).toBeTruthy(); + expect(getCategoryColumns({ activeCategory: new TestCategory(columns) }).renderTableHeader.find(elem => elem?.title === "Foo")).toBeTruthy(); }); }); @@ -117,11 +120,11 @@ describe("Custom Category Columns", () => { }); it("should include columns from extensions that match", () => { - expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "High")).toBeTruthy(); + expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "High")).toBeTruthy(); }); it("should not include columns from extensions that don't match", () => { - expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "High2")).toBeFalsy(); + expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "High2")).toBeFalsy(); }); }); }); diff --git a/src/renderer/components/+catalog/__tests__/custom-views.test.ts b/src/renderer/components/+catalog/__tests__/custom-views.test.ts index 84a0b6f3a4..8bd996505e 100644 --- a/src/renderer/components/+catalog/__tests__/custom-views.test.ts +++ b/src/renderer/components/+catalog/__tests__/custom-views.test.ts @@ -20,8 +20,8 @@ describe("Custom Category Views", () => { }); it("should order items correctly over all extensions", () => { - const component1 = (): React.ReactElement => null; - const component2 = (): React.ReactElement => null; + const component1 = (): React.ReactNode => null; + const component2 = (): React.ReactNode => null; di.override(rendererExtensionsInjectable, () => computed(() => [ { @@ -51,15 +51,15 @@ describe("Custom Category Views", () => { ] as LensRendererExtension[])); const customCategoryViews = di.inject(customCategoryViewsInjectable); - const { after } = customCategoryViews.get().get("foo").get("bar"); + const { after = [] } = customCategoryViews.get().get("foo")?.get("bar") ?? {}; expect(after[0].View).toBe(component2); expect(after[1].View).toBe(component1); }); it("should put put priority < 50 items in before", () => { - const component1 = (): React.ReactElement => null; - const component2 = (): React.ReactElement => null; + const component1 = (): React.ReactNode => null; + const component2 = (): React.ReactNode => null; di.override(rendererExtensionsInjectable, () => computed(() => [ { @@ -89,7 +89,7 @@ describe("Custom Category Views", () => { ] as LensRendererExtension[])); const customCategoryViews = di.inject(customCategoryViewsInjectable); - const { before } = customCategoryViews.get().get("foo").get("bar"); + const { before = [] } = customCategoryViews.get().get("foo")?.get("bar") ?? {}; expect(before[0].View).toBe(component1); }); diff --git a/src/renderer/components/+catalog/catalog-add-button.tsx b/src/renderer/components/+catalog/catalog-add-button.tsx index c56259837c..227dd811b5 100644 --- a/src/renderer/components/+catalog/catalog-add-button.tsx +++ b/src/renderer/components/+catalog/catalog-add-button.tsx @@ -114,18 +114,20 @@ export class CatalogAddButton extends React.Component { onClick={this.onButtonClick} > {this.items.map((menuItem, index) => { - return } - tooltipTitle={menuItem.title} - onClick={(evt) => { - evt.stopPropagation(); - menuItem.onClick(); - }} - TooltipClasses={{ - popper: "catalogSpeedDialPopper", - }} - />; + return ( + } + tooltipTitle={menuItem.title} + onClick={(evt) => { + evt.stopPropagation(); + menuItem.onClick(); + }} + TooltipClasses={{ + popper: "catalogSpeedDialPopper", + }} + /> + ); })} ); diff --git a/src/renderer/components/+catalog/catalog-entity-details.tsx b/src/renderer/components/+catalog/catalog-entity-details.tsx index 569e6b60c1..11f2d65291 100644 --- a/src/renderer/components/+catalog/catalog-entity-details.tsx +++ b/src/renderer/components/+catalog/catalog-entity-details.tsx @@ -36,7 +36,7 @@ export class CatalogEntityDetails extends Component ); - const showDefaultDetails = detailItems.find((item) => item.priority > 999) === undefined; + const showDefaultDetails = detailItems.find((item) => item.priority ?? 50 > 999) === undefined; return ( <> diff --git a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx index 93d9ea7e0d..0e7c4686ff 100644 --- a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx +++ b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx @@ -7,9 +7,9 @@ import React from "react"; import { cssNames } from "../../utils"; import type { MenuActionsProps } from "../menu/menu-actions"; import { MenuActions } from "../menu/menu-actions"; -import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; +import type { CatalogEntity, CatalogEntityContextMenu } from "../../api/catalog-entity"; import { observer } from "mobx-react"; -import { makeObservable, observable } from "mobx"; +import { observable } from "mobx"; import { navigate } from "../../navigation"; import { MenuItem } from "../menu"; import { ConfirmDialog } from "../confirm-dialog"; @@ -22,19 +22,13 @@ export interface CatalogEntityDrawerMenuProps extends M @observer export class CatalogEntityDrawerMenu extends React.Component> { - @observable private contextMenu: CatalogEntityContextMenuContext; - - constructor(props: CatalogEntityDrawerMenuProps) { - super(props); - makeObservable(this); - } + private readonly menuItems = observable.array(); componentDidMount() { - this.contextMenu = { - menuItems: [], - navigate: (url: string) => navigate(url), - }; - this.props.entity?.onContextMenuOpen(this.contextMenu); + this.props.entity?.onContextMenuOpen({ + menuItems: this.menuItems, + navigate: (url) => navigate(url), + }); } onMenuItemClick(menuItem: CatalogEntityContextMenu) { @@ -61,7 +55,7 @@ export class CatalogEntityDrawerMenu extends React.Comp const items: React.ReactChild[] = []; - for (const menuItem of this.contextMenu.menuItems) { + for (const menuItem of this.menuItems) { if (!menuItem.icon) { continue; } @@ -83,8 +77,22 @@ export class CatalogEntityDrawerMenu extends React.Comp } - removeContent={} + addContent={( + + )} + removeContent={( + + )} />, ); @@ -94,7 +102,7 @@ export class CatalogEntityDrawerMenu extends React.Comp render() { const { className, entity, ...menuProps } = this.props; - if (!this.contextMenu || !entity.isEnabled()) { + if (!this.menuItems.length || !entity.isEnabled()) { return null; } diff --git a/src/renderer/components/+catalog/catalog-entity-store/catalog-entity-store.injectable.ts b/src/renderer/components/+catalog/catalog-entity-store/catalog-entity-store.injectable.ts index 8509366307..1f5ea8f5b9 100644 --- a/src/renderer/components/+catalog/catalog-entity-store/catalog-entity-store.injectable.ts +++ b/src/renderer/components/+catalog/catalog-entity-store/catalog-entity-store.injectable.ts @@ -3,13 +3,13 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { CatalogEntityStore } from "./catalog-entity.store"; +import { catalogEntityStore } from "./catalog-entity.store"; import catalogEntityRegistryInjectable from "../../../api/catalog-entity-registry/catalog-entity-registry.injectable"; const catalogEntityStoreInjectable = getInjectable({ id: "catalog-entity-store", - instantiate: (di) => new CatalogEntityStore({ + instantiate: (di) => catalogEntityStore({ registry: di.inject(catalogEntityRegistryInjectable), }), }); diff --git a/src/renderer/components/+catalog/catalog-entity-store/catalog-entity.store.tsx b/src/renderer/components/+catalog/catalog-entity-store/catalog-entity.store.tsx index 732f5e822f..0cc4c8bff9 100644 --- a/src/renderer/components/+catalog/catalog-entity-store/catalog-entity.store.tsx +++ b/src/renderer/components/+catalog/catalog-entity-store/catalog-entity.store.tsx @@ -3,61 +3,78 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { computed, makeObservable, observable, reaction } from "mobx"; +import type { IComputedValue, IObservableValue } from "mobx"; +import { computed, observable, reaction } from "mobx"; import type { CatalogEntityRegistry } from "../../../api/catalog-entity-registry"; import type { CatalogEntity } from "../../../api/catalog-entity"; -import { ItemStore } from "../../../../common/item.store"; import type { CatalogCategory } from "../../../../common/catalog"; import { catalogCategoryRegistry } from "../../../../common/catalog"; -import { autoBind, disposer } from "../../../../common/utils"; +import type { Disposer } from "../../../../common/utils"; +import { disposer } from "../../../../common/utils"; +import type { ItemListStore } from "../../item-object-list"; interface Dependencies { registry: CatalogEntityRegistry; } -export class CatalogEntityStore extends ItemStore { - constructor(private dependencies: Dependencies) { - super(); - makeObservable(this); - autoBind(this); - } +export type CatalogEntityStore = ItemListStore & { + readonly entities: IComputedValue; + readonly activeCategory: IObservableValue; + readonly selectedItemId: IObservableValue; + readonly selectedItem: IComputedValue; + watch(): Disposer; + onRun(entity: CatalogEntity): void; +}; - @observable activeCategory?: CatalogCategory; - @observable selectedItemId?: string; +export function catalogEntityStore({ registry }: Dependencies): CatalogEntityStore { + const activeCategory = observable.box(undefined); + const selectedItemId = observable.box(undefined); + const entities = computed(() => { + const category = activeCategory.get(); - @computed get entities() { - if (!this.activeCategory) { - return this.dependencies.registry.filteredItems; + return category + ? registry.getItemsForCategory(category, { filtered: true }) + : registry.filteredItems; + }); + const selectedItem = computed(() => { + const id = selectedItemId.get(); + + if (!id) { + return undefined; } - return this.dependencies.registry.getItemsForCategory(this.activeCategory, { filtered: true }); - } + return entities.get().find(entity => entity.getId() === id); + }); + const loadAll = () => { + const category = activeCategory.get(); - @computed get selectedItem() { - return this.entities.find(e => e.getId() === this.selectedItemId); - } - - watch() { - return disposer( - reaction(() => this.entities, () => this.loadAll()), - reaction(() => this.activeCategory, () => this.loadAll(), { delay: 100 }), - ); - } - - loadAll() { - if (this.activeCategory) { - this.activeCategory.emit("load"); + if (category) { + category.emit("load"); } else { for (const category of catalogCategoryRegistry.items) { category.emit("load"); } } + }; - // concurrency is true to fix bug if catalog filter is removed and added at the same time - return this.loadItems(() => this.entities, undefined, true); - } - - onRun(entity: CatalogEntity): void { - this.dependencies.registry.onRun(entity); - } + return { + entities, + selectedItem, + activeCategory, + selectedItemId, + watch: () => disposer( + reaction(() => entities.get(), loadAll), + reaction(() => activeCategory.get(), loadAll, { delay: 100 }), + ), + onRun: entity => registry.onRun(entity), + failedLoading: false, + getTotalCount: () => registry.filteredItems.length, + isLoaded: true, + isSelected: (item) => item.getId() === selectedItemId.get(), + isSelectedAll: () => false, + pickOnlySelected: () => [], + toggleSelection: () => {}, + toggleSelectionAll: () => {}, + removeSelectedItems: async () => {}, + }; } diff --git a/src/renderer/components/+catalog/catalog-menu.tsx b/src/renderer/components/+catalog/catalog-menu.tsx index d9bbb71613..8a672ddb44 100644 --- a/src/renderer/components/+catalog/catalog-menu.tsx +++ b/src/renderer/components/+catalog/catalog-menu.tsx @@ -18,7 +18,7 @@ import { observer } from "mobx-react"; import { CatalogCategoryLabel } from "./catalog-category-label"; export interface CatalogMenuProps { - activeItem: string; + activeTab: string | undefined; onItemClick: (id: string) => void; } @@ -54,9 +54,14 @@ export const CatalogMenu = observer((props: CatalogMenuProps) => { defaultExpanded={["catalog"]} defaultCollapseIcon={} defaultExpandIcon={} - selected={props.activeItem || "browse"} + selected={props.activeTab || "browse"} > - props.onItemClick("*")}/> + props.onItemClick("*")} + /> Categories
} diff --git a/src/renderer/components/+catalog/catalog.test.tsx b/src/renderer/components/+catalog/catalog.test.tsx index 9a481d9535..f1c669e3ee 100644 --- a/src/renderer/components/+catalog/catalog.test.tsx +++ b/src/renderer/components/+catalog/catalog.test.tsx @@ -27,6 +27,7 @@ import getConfigurationFileModelInjectable from "../../../common/get-configurati import appVersionInjectable from "../../../common/get-configuration-file-model/app-version/app-version.injectable"; import type { AppEvent } from "../../../common/app-event-bus/event-bus"; import appEventBusInjectable from "../../../common/app-event-bus/app-event-bus.injectable"; +import { computed } from "mobx"; mockWindow(); jest.mock("electron", () => ({ @@ -86,6 +87,8 @@ describe("", () => { let catalogEntityStore: CatalogEntityStore; let catalogEntityRegistry: CatalogEntityRegistry; let emitEvent: (event: AppEvent) => void; + let onRun: jest.MockedFunction<(context: CatalogEntityActionContext) => void | Promise>; + let catalogEntityItem: MockCatalogEntity; let render: DiRender; beforeEach(async () => { @@ -105,6 +108,8 @@ describe("", () => { CatalogEntityDetailRegistry.createInstance(); render = renderFor(di); + onRun = jest.fn(); + catalogEntityItem = createMockCatalogEntity(onRun); const catalogCategoryRegistry = new CatalogCategoryRegistry(); @@ -119,6 +124,9 @@ describe("", () => { })); catalogEntityStore = di.inject(catalogEntityStoreInjectable); + jest + .spyOn(catalogEntityStore, "selectedItem", "get") + .mockImplementation(() => computed(() => catalogEntityItem)); }); afterEach(() => { @@ -132,14 +140,6 @@ describe("", () => { }); it("can use catalogEntityRegistry.addOnBeforeRun to add hooks for catalog entities", (done) => { - const onRun = jest.fn(); - const catalogEntityItem = createMockCatalogEntity(onRun); - - // mock as if there is a selected item > the detail panel opens - jest - .spyOn(catalogEntityStore, "selectedItem", "get") - .mockImplementation(() => catalogEntityItem); - catalogEntityRegistry.addOnBeforeRun( (event) => { expect(event.target.getId()).toBe("a_catalogEntity_uid"); @@ -160,14 +160,6 @@ describe("", () => { }); it("onBeforeRun prevents event => onRun wont be triggered", (done) => { - const onRun = jest.fn(); - const catalogEntityItem = createMockCatalogEntity(onRun); - - // mock as if there is a selected item > the detail panel opens - jest - .spyOn(catalogEntityStore, "selectedItem", "get") - .mockImplementation(() => catalogEntityItem); - catalogEntityRegistry.addOnBeforeRun( (e) => { setTimeout(() => { @@ -184,14 +176,6 @@ describe("", () => { }); it("addOnBeforeRun throw an exception => onRun will be triggered", (done) => { - const onRun = jest.fn(); - const catalogEntityItem = createMockCatalogEntity(onRun); - - // mock as if there is a selected item > the detail panel opens - jest - .spyOn(catalogEntityStore, "selectedItem", "get") - .mockImplementation(() => catalogEntityItem); - catalogEntityRegistry.addOnBeforeRun( () => { setTimeout(() => { @@ -209,13 +193,7 @@ describe("", () => { }); it("addOnRunHook return a promise and does not prevent run event => onRun()", (done) => { - const onRun = jest.fn(() => done()); - const catalogEntityItem = createMockCatalogEntity(onRun); - - // mock as if there is a selected item > the detail panel opens - jest - .spyOn(catalogEntityStore, "selectedItem", "get") - .mockImplementation(() => catalogEntityItem); + onRun.mockImplementation(() => done()); catalogEntityRegistry.addOnBeforeRun( async () => { @@ -229,14 +207,6 @@ describe("", () => { }); it("addOnRunHook return a promise and prevents event wont be triggered", (done) => { - const onRun = jest.fn(); - const catalogEntityItem = createMockCatalogEntity(onRun); - - // mock as if there is a selected item > the detail panel opens - jest - .spyOn(catalogEntityStore, "selectedItem", "get") - .mockImplementation(() => catalogEntityItem); - catalogEntityRegistry.addOnBeforeRun( async (e) => { expect(onRun).not.toBeCalled(); @@ -256,14 +226,6 @@ describe("", () => { }); it("addOnRunHook return a promise and reject => onRun will be triggered", (done) => { - const onRun = jest.fn(); - const catalogEntityItem = createMockCatalogEntity(onRun); - - // mock as if there is a selected item > the detail panel opens - jest - .spyOn(catalogEntityStore, "selectedItem", "get") - .mockImplementation(() => catalogEntityItem); - catalogEntityRegistry.addOnBeforeRun( async () => { setTimeout(() => { diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index e8ab985d1c..53e08f9839 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -13,9 +13,9 @@ import { action, computed, makeObservable, observable, reaction, runInAction, wh import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store"; import { navigate } from "../../navigation"; import { MenuItem, MenuActions } from "../menu"; -import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; +import type { CatalogEntityContextMenu } from "../../api/catalog-entity"; import { ConfirmDialog } from "../confirm-dialog"; -import type { CatalogEntity } from "../../../common/catalog"; +import type { CatalogCategory, CatalogEntity } from "../../../common/catalog"; import { catalogCategoryRegistry } from "../../../common/catalog"; import { CatalogAddButton } from "./catalog-add-button"; import { Notifications } from "../notifications"; @@ -62,7 +62,7 @@ interface Dependencies { @observer class NonInjectedCatalog extends React.Component { - @observable private contextMenu: CatalogEntityContextMenuContext; + private readonly menuItems = observable.array(); @observable activeTab?: string; constructor(props: Dependencies) { @@ -91,10 +91,6 @@ class NonInjectedCatalog extends React.Component { } async componentDidMount() { - this.contextMenu = { - menuItems: observable.array([]), - navigate: (url: string) => navigate(url), - }; disposeOnUnmount(this, [ this.props.catalogEntityStore.watch(), reaction(() => this.routeActiveTab, async (routeTab) => { @@ -104,24 +100,29 @@ class NonInjectedCatalog extends React.Component { runInAction(() => { this.activeTab = routeTab; - this.props.catalogEntityStore.activeCategory = item; + this.props.catalogEntityStore.activeCategory.set(item); }); } catch (error) { console.error(error); - Notifications.error(

Unknown category: {routeTab}

); + Notifications.error(( +

+ {"Unknown category: "} + {routeTab} +

+ )); } }, { fireImmediately: true }), ]); // If active category is filtered out, automatically switch to the first category disposeOnUnmount(this, reaction(() => catalogCategoryRegistry.filteredItems, () => { - if (!catalogCategoryRegistry.filteredItems.find(item => item.getId() === this.props.catalogEntityStore.activeCategory.getId())) { + if (!catalogCategoryRegistry.filteredItems.find(item => item.getId() === this.props.catalogEntityStore.activeCategory.get()?.getId())) { const item = catalogCategoryRegistry.filteredItems[0]; runInAction(() => { if (item) { this.activeTab = item.getId(); - this.props.catalogEntityStore.activeCategory = item; + this.props.catalogEntityStore.activeCategory.set(item); } }); } @@ -143,7 +144,7 @@ class NonInjectedCatalog extends React.Component { onDetails = (entity: CatalogEntity) => { if (this.props.catalogEntityStore.selectedItemId) { - this.props.catalogEntityStore.selectedItemId = null; + this.props.catalogEntityStore.selectedItemId.set(undefined); } else { this.props.catalogEntityStore.onRun(entity); } @@ -190,26 +191,25 @@ class NonInjectedCatalog extends React.Component { } }); - renderNavigation() { - return ( - - ); - } - renderItemMenu = (entity: CatalogEntity) => { const onOpen = () => { - this.contextMenu.menuItems = []; - - entity.onContextMenuOpen(this.contextMenu); + this.menuItems.clear(); + entity.onContextMenuOpen({ + menuItems: this.menuItems, + navigate: (url) => navigate(url), + }); }; return ( - this.props.catalogEntityStore.selectedItemId = entity.getId()}> + this.props.catalogEntityStore.selectedItemId.set(entity.getId())} + > View Details { - this.contextMenu.menuItems.map((menuItem, index) => ( + this.menuItems.map((menuItem, index) => ( this.onMenuItemClick(menuItem)}> {menuItem.title} @@ -244,7 +244,6 @@ class NonInjectedCatalog extends React.Component { isItemInHotbar ? this.removeFromHotbar(entity) : this.addToHotbar(entity))} @@ -253,15 +252,12 @@ class NonInjectedCatalog extends React.Component { ); } - renderViews = () => { - const { catalogEntityStore, customCategoryViews } = this.props; - const { activeCategory } = catalogEntityStore; - + renderViews = (activeCategory: CatalogCategory | undefined) => { if (!activeCategory) { - return this.renderList(); + return this.renderList(activeCategory); } - const customViews = customCategoryViews.get() + const customViews = this.props.customCategoryViews.get() .get(activeCategory.spec.group) ?.get(activeCategory.spec.names.kind); const renderView = ({ View }: CustomCategoryViewComponents, index: number) => ( @@ -274,15 +270,14 @@ class NonInjectedCatalog extends React.Component { return ( <> {customViews?.before.map(renderView)} - {this.renderList()} + {this.renderList(activeCategory)} {customViews?.after.map(renderView)} ); }; - renderList() { + renderList(activeCategory: CatalogCategory | undefined) { const { catalogEntityStore, getCategoryColumns } = this.props; - const { activeCategory } = catalogEntityStore; const tableId = activeCategory ? `catalog-items-${activeCategory.metadata.name.replace(" ", "")}` : "catalog-items"; @@ -292,14 +287,15 @@ class NonInjectedCatalog extends React.Component { } return ( - className={styles.Catalog} tableId={tableId} renderHeaderTitle={activeCategory?.metadata.name ?? "Browse All"} isSelectable={false} isConfigurable={true} + preloadStores={false} store={catalogEntityStore} - getItems={() => catalogEntityStore.entities} + getItems={() => catalogEntityStore.entities.get()} customizeTableRowProps={entity => ({ disabled: !entity.isEnabled(), })} @@ -315,27 +311,37 @@ class NonInjectedCatalog extends React.Component { return null; } - const selectedEntity = this.props.catalogEntityStore.selectedItem; + const activeCategory = this.props.catalogEntityStore.activeCategory.get(); + const selectedItem = this.props.catalogEntityStore.selectedItem.get(); return ( - + + )} + >
- {this.renderViews()} + {this.renderViews(activeCategory)}
{ - selectedEntity - ? this.props.catalogEntityStore.selectedItemId = null} - onRun={() => this.props.catalogEntityStore.onRun(selectedEntity)} - /> - : ( - - - + selectedItem + ? ( + this.props.catalogEntityStore.selectedItemId.set(undefined)} + onRun={() => this.props.catalogEntityStore.onRun(selectedItem)} + /> ) + : activeCategory + ? ( + + + + ) + : null }
); diff --git a/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx b/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx index 0958213658..8a277b962e 100644 --- a/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx +++ b/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx @@ -31,15 +31,17 @@ function NonInjectedHotbarToggleMenuItem({ const [itemInHotbar, setItemInHotbar] = useState(hotbarStore.isAddedToActive(entity)); return ( - { - if (itemInHotbar) { - hotbarStore.removeFromHotbar(entity.getId()); - setItemInHotbar(false); - } else { - hotbarStore.addToHotbar(entity); - setItemInHotbar(true); - } - }}> + { + if (itemInHotbar) { + hotbarStore.removeFromHotbar(entity.getId()); + setItemInHotbar(false); + } else { + hotbarStore.addToHotbar(entity); + setItemInHotbar(true); + } + }} + > {itemInHotbar ? removeContent : addContent } ); diff --git a/src/renderer/components/+catalog/name-category-column.injectable.tsx b/src/renderer/components/+catalog/name-category-column.injectable.tsx index 27fd4072a0..173a8bc5fa 100644 --- a/src/renderer/components/+catalog/name-category-column.injectable.tsx +++ b/src/renderer/components/+catalog/name-category-column.injectable.tsx @@ -37,7 +37,6 @@ const renderEntityName = (hotbarStore: HotbarStore) => (entity: CatalogEntity) = React.ReactElement; ageMs: number; @@ -46,7 +46,7 @@ export class ClusterIssues extends React.Component { makeObservable(this); } - @computed get warnings(): IWarning[] { + @computed get warnings(): Warning[] { return [ ...nodesStore.items.flatMap(node => ( node.getWarningConditions() @@ -75,6 +75,11 @@ export class ClusterIssues extends React.Component { getTableRow = (uid: string) => { const { warnings } = this; const warning = warnings.find(warn => warn.getId() == uid); + + if (!warning) { + return undefined; + } + const { getId, getName, message, kind, selfLink, renderAge } = warning; return ( @@ -85,7 +90,7 @@ export class ClusterIssues extends React.Component { onClick={prevDefault(() => toggleDetails(selfLink))} > - {message} + {message ?? ""} {getName()} @@ -112,7 +117,12 @@ export class ClusterIssues extends React.Component { if (!warnings.length) { return (
- +

No issues found

Everything is fine in the Cluster

@@ -122,7 +132,8 @@ export class ClusterIssues extends React.Component { return ( <> - Warnings: {warnings.length} + + {` Warnings: ${warnings.length}`} clusterOverviewStore.metricNodeRole = metric} + onChange={metric => clusterOverviewStore.metricNodeRole = metric} > @@ -41,7 +41,7 @@ const NonInjectedClusterMetricSwitchers = observer(({ clusterOverviewStore }: De asButtons className={cssNames("RadioGroup flex gaps", { disabled: disableMetrics })} value={clusterOverviewStore.metricType} - onChange={(value: MetricType) => clusterOverviewStore.metricType = value} + onChange={value => clusterOverviewStore.metricType = value} > diff --git a/src/renderer/components/+cluster/cluster-metrics.tsx b/src/renderer/components/+cluster/cluster-metrics.tsx index fd69842c41..e744383a94 100644 --- a/src/renderer/components/+cluster/cluster-metrics.tsx +++ b/src/renderer/components/+cluster/cluster-metrics.tsx @@ -5,7 +5,7 @@ import styles from "./cluster-metrics.module.scss"; -import React from "react"; +import React, { useState } from "react"; import { observer } from "mobx-react"; import type { ChartOptions, ChartPoint } from "chart.js"; import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store"; @@ -13,19 +13,19 @@ import { MetricType } from "./cluster-overview-store/cluster-overview-store"; import { BarChart } from "../chart"; import { bytesToUnits, cssNames } from "../../utils"; import { Spinner } from "../spinner"; -import { ZebraStripes } from "../chart/zebra-stripes.plugin"; +import { ZebraStripesPlugin } from "../chart/zebra-stripes.plugin"; import { ClusterNoMetrics } from "./cluster-no-metrics"; import { ClusterMetricSwitchers } from "./cluster-metric-switchers"; import { getMetricLastPoints } from "../../../common/k8s-api/endpoints/metrics.api"; import { withInjectables } from "@ogre-tools/injectable-react"; -import clusterOverviewStoreInjectable - from "./cluster-overview-store/cluster-overview-store.injectable"; +import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable"; interface Dependencies { clusterOverviewStore: ClusterOverviewStore; } const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics }}: Dependencies) => { + const [plugins] = useState([new ZebraStripesPlugin()]); const { memoryCapacity, cpuCapacity } = getMetricLastPoints(metrics); const metricValues = getMetricsValues(metrics); const colors = { cpu: "#3D90CE", memory: "#C93DCE" }; @@ -52,9 +52,13 @@ const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType tooltips: { callbacks: { label: ({ index }, data) => { - const value = data.datasets[0].data[index] as ChartPoint; + if (!index) { + return ""; + } - return value.y.toString(); + const value = data.datasets?.[0].data?.[index] as ChartPoint; + + return value.y?.toString() ?? ""; }, }, }, @@ -71,7 +75,11 @@ const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType tooltips: { callbacks: { label: ({ index }, data) => { - const value = data.datasets[0].data[index] as ChartPoint; + if (!index) { + return ""; + } + + const value = data.datasets?.[0].data?.[index] as ChartPoint; return bytesToUnits(parseInt(value.y as string), { precision: 3 }); }, @@ -96,7 +104,7 @@ const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType data={{ datasets }} timeLabelStep={5} showLegend={false} - plugins={[ZebraStripes]} + plugins={plugins} className={styles.chart} /> ); diff --git a/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.injectable.ts b/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.injectable.ts index f80fdcf4ee..cfd2262ab7 100644 --- a/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.injectable.ts +++ b/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.injectable.ts @@ -12,6 +12,7 @@ import { } from "./cluster-overview-store"; import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; import apiManagerInjectable from "../../kube-object-menu/dependencies/api-manager.injectable"; +import { clusterApi } from "../../../../common/k8s-api/endpoints"; const clusterOverviewStoreInjectable = getInjectable({ id: "cluster-overview-store", @@ -29,7 +30,7 @@ const clusterOverviewStoreInjectable = getInjectable({ const store = new ClusterOverviewStore({ storage, - }); + }, clusterApi); const apiManager = di.inject(apiManagerInjectable); diff --git a/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.ts b/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.ts index 414f31ccbc..20cf9c6c2b 100644 --- a/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.ts +++ b/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.ts @@ -5,8 +5,8 @@ import { action, observable, reaction, when, makeObservable } from "mobx"; import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; -import type { Cluster } from "../../../../common/k8s-api/endpoints"; -import { clusterApi, getMetricsByNodeNames, type IClusterMetrics } from "../../../../common/k8s-api/endpoints"; +import type { Cluster, ClusterApi } from "../../../../common/k8s-api/endpoints"; +import { getMetricsByNodeNames, type ClusterMetricData } from "../../../../common/k8s-api/endpoints"; import type { StorageHelper } from "../../../utils"; import { autoBind } from "../../../utils"; import { type IMetricsReqParams, normalizeMetrics } from "../../../../common/k8s-api/endpoints/metrics.api"; @@ -31,10 +31,8 @@ interface Dependencies { storage: StorageHelper; } -export class ClusterOverviewStore extends KubeObjectStore implements ClusterOverviewStorageState { - api = clusterApi; - - @observable metrics: Partial = {}; +export class ClusterOverviewStore extends KubeObjectStore implements ClusterOverviewStorageState { + @observable metrics: Partial = {}; @observable metricsLoaded = false; get metricType(): MetricType { @@ -53,8 +51,8 @@ export class ClusterOverviewStore extends KubeObjectStore implements Cl this.dependencies.storage.merge({ metricNodeRole: value }); } - constructor(private dependencies: Dependencies ) { - super(); + constructor(protected readonly dependencies: Dependencies, api: ClusterApi) { + super(api); makeObservable(this); autoBind(this); @@ -89,7 +87,7 @@ export class ClusterOverviewStore extends KubeObjectStore implements Cl this.metricsLoaded = true; } - getMetricsValues(source: Partial): [number, string][] { + getMetricsValues(source: Partial): [number, string][] { switch (this.metricType) { case MetricType.CPU: return normalizeMetrics(source.cpuUsage).data.result[0].values; diff --git a/src/renderer/components/+cluster/cluster-overview.tsx b/src/renderer/components/+cluster/cluster-overview.tsx index 36590df34b..61098c3f0b 100644 --- a/src/renderer/components/+cluster/cluster-overview.tsx +++ b/src/renderer/components/+cluster/cluster-overview.tsx @@ -10,9 +10,8 @@ import { reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { nodesStore } from "../+nodes/nodes.store"; import { podsStore } from "../+workloads-pods/pods.store"; -import type { Disposer } from "../../utils"; -import { getHostedClusterId, interval } from "../../utils"; -import { TabLayout } from "../layout/tab-layout-2"; +import { interval } from "../../utils"; +import { TabLayout } from "../layout/tab-layout"; import { Spinner } from "../spinner"; import { ClusterIssues } from "./cluster-issues"; import { ClusterMetrics } from "./cluster-metrics"; @@ -20,17 +19,19 @@ import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-over import { ClusterPieCharts } from "./cluster-pie-charts"; import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { ClusterMetricsResourceType } from "../../../common/cluster-types"; -import { ClusterStore } from "../../../common/cluster-store/cluster-store"; import { eventStore } from "../+events/event.store"; import { withInjectables } from "@ogre-tools/injectable-react"; import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable"; +import type { SubscribeStores } from "../../kube-watch-api/kube-watch-api"; +import type { Cluster } from "../../../common/cluster/cluster"; +import hostedClusterInjectable from "../../cluster/hosted-cluster.injectable"; +import assert from "assert"; interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer; + subscribeStores: SubscribeStores; clusterOverviewStore: ClusterOverviewStore; + hostedCluster: Cluster; } @observer @@ -38,9 +39,7 @@ class NonInjectedClusterOverview extends React.Component { private metricPoller = interval(60, () => this.loadMetrics()); loadMetrics() { - const cluster = ClusterStore.getInstance().getById(getHostedClusterId()); - - if (cluster.available) { + if (this.props.hostedCluster.available) { this.props.clusterOverviewStore.loadMetrics(); } } @@ -66,7 +65,7 @@ class NonInjectedClusterOverview extends React.Component { this.metricPoller.stop(); } - renderMetrics(isMetricsHidden: boolean) { + renderMetrics(isMetricsHidden?: boolean) { if (isMetricsHidden) { return null; } @@ -79,7 +78,7 @@ class NonInjectedClusterOverview extends React.Component { ); } - renderClusterOverview(isLoaded: boolean, isMetricsHidden: boolean) { + renderClusterOverview(isLoaded: boolean, isMetricsHidden?: boolean) { if (!isLoaded) { return ; } @@ -106,13 +105,16 @@ class NonInjectedClusterOverview extends React.Component { } } -export const ClusterOverview = withInjectables( - NonInjectedClusterOverview, +export const ClusterOverview = withInjectables(NonInjectedClusterOverview, { + getProps: (di) => { + const hostedCluster = di.inject(hostedClusterInjectable); - { - getProps: (di) => ({ + assert(hostedCluster, "Only allowed to renderer ClusterOverview within cluster frame"); + + return { subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, clusterOverviewStore: di.inject(clusterOverviewStoreInjectable), - }), + hostedCluster, + }; }, -); +}); diff --git a/src/renderer/components/+config-autoscalers/hpa-details.tsx b/src/renderer/components/+config-autoscalers/hpa-details.tsx index 714de37b22..867b00f0f2 100644 --- a/src/renderer/components/+config-autoscalers/hpa-details.tsx +++ b/src/renderer/components/+config-autoscalers/hpa-details.tsx @@ -12,7 +12,7 @@ import { DrawerItem, DrawerTitle } from "../drawer"; import { Badge } from "../badge"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { cssNames } from "../../utils"; -import type { IHpaMetric } from "../../../common/k8s-api/endpoints/hpa.api"; +import type { HorizontalPodAutoscalerMetricSpec, HorizontalPodAutoscalerMetricTarget } from "../../../common/k8s-api/endpoints/hpa.api"; import { HorizontalPodAutoscaler, HpaMetricType } from "../../../common/k8s-api/endpoints/hpa.api"; import { Table, TableCell, TableHead, TableRow } from "../table"; import { apiManager } from "../../../common/k8s-api/api-manager"; @@ -25,38 +25,75 @@ export interface HpaDetailsProps extends KubeObjectDetailsProps { + private renderTargetLink(target: HorizontalPodAutoscalerMetricTarget | undefined) { + if (!target) { + return null; + } + + const { object: hpa } = this.props; + const { kind, name } = target; + const objectUrl = getDetailsUrl(apiManager.lookupApiLink(target, hpa)); + + return ( + <> + on + + {`${kind}/${name}`} + + + ); + } + renderMetrics() { const { object: hpa } = this.props; - const renderName = (metric: IHpaMetric) => { + const renderName = (metric: HorizontalPodAutoscalerMetricSpec) => { switch (metric.type) { + case HpaMetricType.ContainerResource: + + // fallthrough case HpaMetricType.Resource: { - const addition = metric.resource.targetAverageUtilization + const metricSpec = metric.resource ?? metric.containerResource; + const addition = metricSpec.targetAverageUtilization ? "(as a percentage of request)" : ""; - return <>Resource {metric.resource.name} on Pods {addition}; - } - case HpaMetricType.Pods: - return <>{metric.pods.metricName} on Pods; - - case HpaMetricType.Object: { - const { target } = metric.object; - const { kind, name } = target; - const objectUrl = getDetailsUrl(apiManager.lookupApiLink(target, hpa)); - return ( <> - {metric.object.metricName} on{" "} - {kind}/{name} + Resource + {metricSpec.name} + {" "} + on Pods + {addition} + + ); + } + case HpaMetricType.Pods: + return ( + <> + {metric.pods.metricName} + {" "} + on Pods + + ); + + case HpaMetricType.Object: { + return ( + <> + {metric.object.metricName} + {" "} + {this.renderTargetLink(metric.object.target)} ); } case HpaMetricType.External: return ( <> - {metric.external.metricName} on{" "} - {JSON.stringify(metric.external.selector)} + {metric.external.metricName} + {" "} + on + {" "} + {JSON.stringify(metric.external.metricSelector)} ); } @@ -103,7 +140,9 @@ export class HpaDetails extends React.Component { {scaleTargetRef && ( - {scaleTargetRef.kind}/{scaleTargetRef.name} + {scaleTargetRef.kind} + / + {scaleTargetRef.name} )} @@ -120,19 +159,20 @@ export class HpaDetails extends React.Component { {hpa.getReplicas()} - - {hpa.getConditions().map(({ type, tooltip, isReady }) => { - if (!isReady) return null; - - return ( + + {hpa.getReadyConditions() + .map(({ type, tooltip, isReady }) => ( - ); - })} + ))} Metrics diff --git a/src/renderer/components/+config-autoscalers/hpa.store.ts b/src/renderer/components/+config-autoscalers/hpa.store.ts index d42ff1e4a8..6afb02fb55 100644 --- a/src/renderer/components/+config-autoscalers/hpa.store.ts +++ b/src/renderer/components/+config-autoscalers/hpa.store.ts @@ -4,13 +4,18 @@ */ import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { HorizontalPodAutoscaler } from "../../../common/k8s-api/endpoints/hpa.api"; -import { hpaApi } from "../../../common/k8s-api/endpoints/hpa.api"; +import type { HorizontalPodAutoscaler, HorizontalPodAutoscalerApi } from "../../../common/k8s-api/endpoints/hpa.api"; +import { horizontalPodAutoscalerApi } from "../../../common/k8s-api/endpoints/hpa.api"; import { apiManager } from "../../../common/k8s-api/api-manager"; +import { isClusterPageContext } from "../../utils"; -export class HPAStore extends KubeObjectStore { - api = hpaApi; +export class HorizontalPodAutoscalerStore extends KubeObjectStore { } -export const hpaStore = new HPAStore(); -apiManager.registerStore(hpaStore); +export const hpaStore = isClusterPageContext() + ? new HorizontalPodAutoscalerStore(horizontalPodAutoscalerApi) + : undefined as never; + +if (isClusterPageContext()) { + apiManager.registerStore(hpaStore); +} diff --git a/src/renderer/components/+config-autoscalers/hpa.tsx b/src/renderer/components/+config-autoscalers/hpa.tsx index 51866e491e..9e3bd47a7e 100644 --- a/src/renderer/components/+config-autoscalers/hpa.tsx +++ b/src/renderer/components/+config-autoscalers/hpa.tsx @@ -38,7 +38,13 @@ export class HorizontalPodAutoscalers extends React.Component { const metricsRemain = metrics.length > 1 ? `+${metrics.length - 1} more...` : ""; - return

{hpa.getMetricValues(metrics[0])} {metricsRemain}

; + return ( +

+ {hpa.getMetricValues(metrics[0])} + {" "} + {metricsRemain} +

+ ); } render() { diff --git a/src/renderer/components/+config-limit-ranges/limit-range-details.tsx b/src/renderer/components/+config-limit-ranges/limit-range-details.tsx index 1448fc2b1f..313da25667 100644 --- a/src/renderer/components/+config-limit-ranges/limit-range-details.tsx +++ b/src/renderer/components/+config-limit-ranges/limit-range-details.tsx @@ -75,27 +75,27 @@ export class LimitRangeDetails extends React.Component {
- {containerLimits.length > 0 && - - { - renderLimitDetails(containerLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) - } - - } - {podLimits.length > 0 && - - { - renderLimitDetails(podLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) - } - - } - {pvcLimits.length > 0 && - - { - renderLimitDetails(pvcLimits, [Resource.STORAGE]) - } - - } + {containerLimits.length > 0 && ( + + { + renderLimitDetails(containerLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) + } + + )} + {podLimits.length > 0 && ( + + { + renderLimitDetails(podLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) + } + + )} + {pvcLimits.length > 0 && ( + + { + renderLimitDetails(pvcLimits, [Resource.STORAGE]) + } + + )}
); } diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts b/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts index dd5ef975ff..65769f0b54 100644 --- a/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts +++ b/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts @@ -5,12 +5,17 @@ import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import { apiManager } from "../../../common/k8s-api/api-manager"; -import type { LimitRange } from "../../../common/k8s-api/endpoints/limit-range.api"; +import type { LimitRange, LimitRangeApi } from "../../../common/k8s-api/endpoints/limit-range.api"; import { limitRangeApi } from "../../../common/k8s-api/endpoints/limit-range.api"; +import { isClusterPageContext } from "../../utils"; -export class LimitRangesStore extends KubeObjectStore { - api = limitRangeApi; +export class LimitRangeStore extends KubeObjectStore { } -export const limitRangeStore = new LimitRangesStore(); -apiManager.registerStore(limitRangeStore); +export const limitRangeStore = isClusterPageContext() + ? new LimitRangeStore(limitRangeApi) + : undefined as never; + +if (isClusterPageContext()) { + apiManager.registerStore(limitRangeStore); +} diff --git a/src/renderer/components/+config-maps/config-map-details.tsx b/src/renderer/components/+config-maps/config-map-details.tsx index 61d2ea4ae1..e299df86a6 100644 --- a/src/renderer/components/+config-maps/config-map-details.tsx +++ b/src/renderer/components/+config-maps/config-map-details.tsx @@ -12,7 +12,7 @@ import { DrawerTitle } from "../drawer"; import { Notifications } from "../notifications"; import { Input } from "../input"; import { Button } from "../button"; -import { configMapsStore } from "./config-maps.store"; +import { configMapStore } from "./config-maps.store"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { ConfigMap } from "../../../common/k8s-api/endpoints"; import { KubeObjectMeta } from "../kube-object-meta"; @@ -24,7 +24,7 @@ export interface ConfigMapDetailsProps extends KubeObjectDetailsProps @observer export class ConfigMapDetails extends React.Component { @observable isSaving = false; - @observable data = observable.map(); + @observable data = observable.map(); constructor(props: ConfigMapDetailsProps) { super(props); @@ -48,15 +48,17 @@ export class ConfigMapDetails extends React.Component { try { this.isSaving = true; - await configMapsStore.update(configMap, { + await configMapStore.update(configMap, { ...configMap, data: Object.fromEntries(this.data), }); - Notifications.ok( + Notifications.ok((

- <>ConfigMap {configMap.getName()} successfully updated. -

, - ); + {"ConfigMap "} + {configMap.getName()} + {" successfully updated."} +

+ )); } catch (error) { Notifications.error(`Failed to save config map: ${error}`); } finally { @@ -104,7 +106,8 @@ export class ConfigMapDetails extends React.Component { }
- {this.quotaEntries.map(([quota, value]) => { - return ( -
-
{quota}
-
{value}
- this.quotas[quota] = ""} /> -
- ); - })} + {this.quotaEntries.map(([quota, value]) => ( +
+
{quota}
+
{value}
+ this.quotas[quota] = ""} /> +
+ ))}
diff --git a/src/renderer/components/+config-resource-quotas/resource-quota-details.tsx b/src/renderer/components/+config-resource-quotas/resource-quota-details.tsx index 3a1cff2080..2443ea60a3 100644 --- a/src/renderer/components/+config-resource-quotas/resource-quota-details.tsx +++ b/src/renderer/components/+config-resource-quotas/resource-quota-details.tsx @@ -8,7 +8,7 @@ import React from "react"; import kebabCase from "lodash/kebabCase"; import { observer } from "mobx-react"; import { DrawerItem, DrawerTitle } from "../drawer"; -import { cpuUnitsToNumber, cssNames, unitsToBytes, metricUnitsToNumber } from "../../utils"; +import { cpuUnitsToNumber, cssNames, unitsToBytes, metricUnitsToNumber, object, hasDefinedTupleValue } from "../../utils"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { ResourceQuota } from "../../../common/k8s-api/endpoints/resource-quota.api"; import { LineProgress } from "../line-progress"; @@ -32,25 +32,29 @@ function transformUnit(name: string, value: string): number { } function renderQuotas(quota: ResourceQuota): JSX.Element[] { - const { hard = {}, used = {}} = quota.status; + const { hard = {}, used = {}} = quota.status ?? {}; - return Object.entries(hard) - .filter(([name]) => used[name]) + return object.entries(hard) + .filter(hasDefinedTupleValue) .map(([name, value]) => { - const current = transformUnit(name, used[name]); + const current = transformUnit(name, value); const max = transformUnit(name, value); const usage = max === 0 ? 100 : Math.ceil(current / max * 100); // special case 0 max as always 100% usage return (
{name} - {used[name]} / {value} + + {`${used[name]} / ${value}`} + Set: {value}. Usage: {`${usage}%`}

- } + tooltip={( +

+ {`Set: ${value}. Usage: ${usage}%`} +

+ )} />
); diff --git a/src/renderer/components/+config-resource-quotas/resource-quotas.store.ts b/src/renderer/components/+config-resource-quotas/resource-quotas.store.ts index ff801c0483..6de7858d92 100644 --- a/src/renderer/components/+config-resource-quotas/resource-quotas.store.ts +++ b/src/renderer/components/+config-resource-quotas/resource-quotas.store.ts @@ -4,13 +4,18 @@ */ import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { ResourceQuota } from "../../../common/k8s-api/endpoints/resource-quota.api"; +import type { ResourceQuota, ResourceQuotaApi } from "../../../common/k8s-api/endpoints/resource-quota.api"; import { resourceQuotaApi } from "../../../common/k8s-api/endpoints/resource-quota.api"; import { apiManager } from "../../../common/k8s-api/api-manager"; +import { isClusterPageContext } from "../../utils"; -export class ResourceQuotasStore extends KubeObjectStore { - api = resourceQuotaApi; +export class ResourceQuotaStore extends KubeObjectStore { } -export const resourceQuotaStore = new ResourceQuotasStore(); -apiManager.registerStore(resourceQuotaStore); +export const resourceQuotaStore = isClusterPageContext() + ? new ResourceQuotaStore(resourceQuotaApi) + : undefined as never; + +if (isClusterPageContext()) { + apiManager.registerStore(resourceQuotaStore); +} diff --git a/src/renderer/components/+config-secrets/__tests__/secret-details.test.tsx b/src/renderer/components/+config-secrets/__tests__/secret-details.test.tsx index df8c1c1cf8..ac502182fb 100644 --- a/src/renderer/components/+config-secrets/__tests__/secret-details.test.tsx +++ b/src/renderer/components/+config-secrets/__tests__/secret-details.test.tsx @@ -20,6 +20,8 @@ describe("SecretDetails tests", () => { name: "test", resourceVersion: "1", uid: "uid", + namespace: "default", + selfLink: "/api/v1/secrets/default/test", }, data: { foobar: "", diff --git a/src/renderer/components/+config-secrets/add-secret-dialog.tsx b/src/renderer/components/+config-secrets/add-secret-dialog.tsx index 45950cce03..b7922db014 100644 --- a/src/renderer/components/+config-secrets/add-secret-dialog.tsx +++ b/src/renderer/components/+config-secrets/add-secret-dialog.tsx @@ -13,18 +13,16 @@ import { Dialog } from "../dialog"; import { Wizard, WizardStep } from "../wizard"; import { Input } from "../input"; import { systemName } from "../input/input_validators"; -import type { Secret } from "../../../common/k8s-api/endpoints"; -import { secretsApi, SecretType } from "../../../common/k8s-api/endpoints"; +import { secretApi, SecretType } from "../../../common/k8s-api/endpoints"; import { SubTitle } from "../layout/sub-title"; import { NamespaceSelect } from "../+namespaces/namespace-select"; -import type { SelectOption } from "../select"; import { Select } from "../select"; import { Icon } from "../icon"; -import type { KubeObjectMetadata } from "../../../common/k8s-api/kube-object"; -import { base64 } from "../../utils"; +import { base64, iter } from "../../utils"; import { Notifications } from "../notifications"; import upperFirst from "lodash/upperFirst"; import { showDetails } from "../kube-detail-params"; +import { fromEntries } from "../../../common/utils/objects"; export interface AddSecretDialogProps extends Partial { } @@ -36,7 +34,7 @@ interface ISecretTemplateField { } interface ISecretTemplate { - [field: string]: ISecretTemplateField[]; + [field: string]: ISecretTemplateField[] | undefined; annotations?: ISecretTemplateField[]; labels?: ISecretTemplateField[]; data?: ISecretTemplateField[]; @@ -63,7 +61,7 @@ export class AddSecretDialog extends React.Component { dialogState.isOpen = false; } - private secretTemplate: { [p: string]: ISecretTemplate } = { + private secretTemplate: Partial> = { [SecretType.Opaque]: {}, [SecretType.ServiceAccountToken]: { annotations: [ @@ -91,61 +89,59 @@ export class AddSecretDialog extends React.Component { AddSecretDialog.close(); }; - private getDataFromFields = (fields: ISecretTemplateField[] = [], processValue?: (val: string) => string) => { - return fields.reduce((data, field) => { - const { key, value } = field; - - if (key) { - data[key] = processValue ? processValue(value) : value; - } - - return data; - }, {}); + private getDataFromFields = (fields: ISecretTemplateField[] = [], processValue: (val: string) => string = (val => val)) => { + return iter.pipeline(fields) + .filterMap(({ key, value }) => ( + value + ? [key, processValue(value)] as const + : undefined + )) + .collect(fromEntries); }; createSecret = async () => { const { name, namespace, type } = this; - const { data = [], labels = [], annotations = [] } = this.secret[type]; - const secret: Partial = { - type, - data: this.getDataFromFields(data, val => val ? base64.encode(val) : ""), - metadata: { - name, - namespace, - annotations: this.getDataFromFields(annotations), - labels: this.getDataFromFields(labels), - } as KubeObjectMetadata, - }; + const { data = [], labels = [], annotations = [] } = this.secret[type] ?? {}; try { - const newSecret = await secretsApi.create({ namespace, name }, secret); + const newSecret = await secretApi.create({ namespace, name }, { + type, + data: this.getDataFromFields(data, val => val ? base64.encode(val) : ""), + metadata: { + name, + namespace, + annotations: this.getDataFromFields(annotations), + labels: this.getDataFromFields(labels), + }, + }); - showDetails(newSecret.selfLink); + showDetails(newSecret?.selfLink); this.close(); } catch (err) { - Notifications.error(err); + Notifications.checkedError(err, "Unknown error occured while creating a Secret"); } }; - addField = (field: ISecretField) => { - const fields = this.secret[this.type][field] || []; + private getFields(field: ISecretField) { + return (this.secret[this.type] ??= {})[field] ??= []; + } - fields.push({ key: "", value: "" }); - this.secret[this.type][field] = fields; + addField = (field: ISecretField) => { + this.getFields(field).push({ key: "", value: "" }); }; removeField = (field: ISecretField, index: number) => { - const fields = this.secret[this.type][field] || []; - - fields.splice(index, 1); + this.getFields(field).splice(index, 1); }; renderFields(field: ISecretField) { - const fields = this.secret[this.type][field] || []; - return ( <> - + { />
- {fields.map((item, index) => { - const { key = "", value = "", required } = item; - - return ( + {this.getFields(field) + .map((item, index) => (
item.key = v} + title={item.key} + tabIndex={item.required ? -1 : 0} + readOnly={item.required} + value={item.key} + onChange={v => item.key = v} /> item.value = v} + value={item.value} + onChange={v => item.value = v} /> this.removeField(field, index)} + onClick={() => this.removeField(field, index)} />
- ); - })} + ))}
); @@ -204,15 +200,21 @@ export class AddSecretDialog extends React.Component { close={this.close} > - +
this.name = v} + value={name} + onChange={v => this.name = v} />
@@ -222,7 +224,7 @@ export class AddSecretDialog extends React.Component { id="secret-namespace-input" themeName="light" value={namespace} - onChange={({ value }) => this.namespace = value} + onChange={value => this.namespace = value ?? "default"} />
@@ -231,7 +233,8 @@ export class AddSecretDialog extends React.Component { id="secret-input" themeName="light" options={this.types} - value={type} onChange={({ value }: SelectOption) => this.type = value} + value={type} + onChange={value => this.type = value ?? SecretType.Opaque} />
diff --git a/src/renderer/components/+config-secrets/secret-details.tsx b/src/renderer/components/+config-secrets/secret-details.tsx index aecce6e1a0..e497f07c6b 100644 --- a/src/renderer/components/+config-secrets/secret-details.tsx +++ b/src/renderer/components/+config-secrets/secret-details.tsx @@ -14,7 +14,7 @@ import { Button } from "../button"; import { Notifications } from "../notifications"; import { base64, toggle } from "../../utils"; import { Icon } from "../icon"; -import { secretsStore } from "./secrets.store"; +import { secretStore } from "./secrets.store"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { Secret } from "../../../common/k8s-api/endpoints"; import { KubeObjectMeta } from "../kube-object-meta"; @@ -26,7 +26,7 @@ export interface SecretDetailsProps extends KubeObjectDetailsProps { @observer export class SecretDetails extends React.Component { @observable isSaving = false; - @observable data: { [name: string]: string } = {}; + @observable data: Partial> = {}; @observable revealSecret = observable.set(); constructor(props: SecretDetailsProps) { @@ -53,10 +53,10 @@ export class SecretDetails extends React.Component { this.isSaving = true; try { - await secretsStore.update(secret, { ...secret, data: this.data }); + await secretStore.update(secret, { ...secret, data: this.data }); Notifications.ok("Secret successfully updated."); } catch (err) { - Notifications.error(err); + Notifications.checkedError(err, "Unknown error occured while updating the secret"); } this.isSaving = false; }; @@ -65,11 +65,11 @@ export class SecretDetails extends React.Component { this.data[name] = encoded ? value : base64.encode(value); }; - renderSecret = ([name, value]: [string, string]) => { + renderSecret = ([name, value]: [string, string | undefined]) => { let decodedVal: string | undefined; try { - decodedVal = base64.decode(value); + decodedVal = value ? base64.decode(value) : undefined; } catch { /** * The value failed to be decoded, so don't show the visibility @@ -85,7 +85,11 @@ export class SecretDetails extends React.Component { } return ( -
+
{name}
{ {secrets.map(this.renderSecret)}
- {printerColumns.length > 0 && + {printerColumns.length > 0 && ( <> Additional Printer Columns @@ -133,8 +140,8 @@ export class CRDDetails extends React.Component { }
- } - {validation && + )} + {validation && ( <> Validation { style={{ height: 400 }} /> - } + )} ); } diff --git a/src/renderer/components/+custom-resources/crd-list.tsx b/src/renderer/components/+custom-resources/crd-list.tsx index 9b3df2ce14..d140399239 100644 --- a/src/renderer/components/+custom-resources/crd-list.tsx +++ b/src/renderer/components/+custom-resources/crd-list.tsx @@ -6,13 +6,12 @@ import "./crd-list.scss"; import React from "react"; -import { computed, makeObservable } from "mobx"; +import { computed, makeObservable, observable } from "mobx"; import { observer } from "mobx-react"; import { Link } from "react-router-dom"; -import { stopPropagation } from "../../utils"; +import { iter, stopPropagation } from "../../utils"; import { KubeObjectListLayout } from "../kube-object-list-layout"; import { crdStore } from "./crd.store"; -import type { SelectOption } from "../select"; import { Select } from "../select"; import { createPageParam } from "../../navigation"; import { Icon } from "../icon"; @@ -34,32 +33,36 @@ enum columnId { @observer export class CustomResourceDefinitions extends React.Component { + private readonly selectedGroups = observable.set(crdGroupsUrlParam.get()); + constructor(props: {}) { super(props); makeObservable(this); } - get selectedGroups(): string[] { - return crdGroupsUrlParam.get(); - } - @computed get items() { - if (this.selectedGroups.length) { - return crdStore.items.filter(item => this.selectedGroups.includes(item.getGroup())); + if (this.selectedGroups.size) { + return crdStore.items.filter(item => this.selectedGroups.has(item.getGroup())); } return crdStore.items; // show all by default } - toggleSelection(group: string) { - const groups = new Set(crdGroupsUrlParam.get()); - - if (groups.has(group)) { - groups.delete(group); - } else { - groups.add(group); - } + toggleSelection = (groups: readonly string[]) => { + this.selectedGroups.replace(groups); crdGroupsUrlParam.set([...groups]); + }; + + private getPlaceholder() { + if (this.selectedGroups.size === 0) { + return "All groups"; + } + + const prefix = this.selectedGroups.size === 1 + ? "Group" + : "Groups"; + + return `${prefix}: ${iter.join(this.selectedGroups, ", ")}`; } render() { @@ -90,42 +93,38 @@ export class CustomResourceDefinitions extends React.Component { crd => -crd.getCreationTimestamp(), ]} renderHeaderTitle="Custom Resources" - customizeHeader={({ filters, ...headerPlaceholders }) => { - let placeholder = <>All groups; - - if (selectedGroups.length == 1) placeholder = <>Group: {selectedGroups[0]}; - if (selectedGroups.length >= 2) placeholder = <>Groups: {selectedGroups.join(", ")}; - - return { - // todo: move to global filters - filters: ( - <> - {filters} - this.selectedGroups.has(opt)} + isMulti={true} + formatOptionLabel={(group) => ( +
+ + {group} + {selectedGroups.has(group) && ( + + )} +
+ )} + /> + + ), + ...headerPlaceholders, + })} renderTableHeader={[ { title: "Resource", className: "kind", sortBy: columnId.kind, id: columnId.kind }, { title: "Group", className: "group", sortBy: columnId.group, id: columnId.group }, @@ -134,7 +133,11 @@ export class CustomResourceDefinitions extends React.Component { { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={crd => [ - + {crd.getResourceKind()} , crd.getGroup(), diff --git a/src/renderer/components/+custom-resources/crd-resource-details.tsx b/src/renderer/components/+custom-resources/crd-resource-details.tsx index 1c43769e2e..dbdc13c7c3 100644 --- a/src/renderer/components/+custom-resources/crd-resource-details.tsx +++ b/src/renderer/components/+custom-resources/crd-resource-details.tsx @@ -16,7 +16,7 @@ import { Input } from "../input"; import type { AdditionalPrinterColumnsV1 } from "../../../common/k8s-api/endpoints/crd.api"; import { CustomResourceDefinition } from "../../../common/k8s-api/endpoints/crd.api"; import { convertKubectlJsonPathToNodeJsonPath } from "../../utils/jsonPath"; -import type { KubeObjectMetadata, KubeObjectStatus } from "../../../common/k8s-api/kube-object"; +import type { KubeObjectStatus } from "../../../common/k8s-api/kube-object"; import { KubeObject } from "../../../common/k8s-api/kube-object"; import logger from "../../../common/logger"; import { JSONPath } from "@astronautlabs/jsonpath"; @@ -49,25 +49,34 @@ function convertSpecValue(value: any): any { export class CustomResourceDetails extends React.Component { renderAdditionalColumns(resource: KubeObject, columns: AdditionalPrinterColumnsV1[]) { return columns.map(({ name, jsonPath }) => ( - + {convertSpecValue(JSONPath.query(resource, convertKubectlJsonPathToNodeJsonPath(jsonPath)))} )); } - renderStatus(customResource: KubeObject, columns: AdditionalPrinterColumnsV1[]) { + renderStatus(customResource: KubeObject, columns: AdditionalPrinterColumnsV1[]) { const showStatus = !columns.find(column => column.name == "Status") && Array.isArray(customResource.status?.conditions); if (!showStatus) { return null; } - const conditions = customResource.status.conditions - .filter(({ type, reason }) => type || reason) - .map(({ type, reason, message, status }) => ({ kind: type || reason, message, status })) + const conditions = customResource.status?.conditions + ?.filter(({ type, reason }) => type || reason) + .map(({ type, reason, message, status }) => ({ + kind: type || reason || "", + message, + status, + })) .map(({ kind, message, status }, index) => ( + {conditions} ); diff --git a/src/renderer/components/+custom-resources/crd-resource.store.ts b/src/renderer/components/+custom-resources/crd-resource.store.ts index 0ccfad18b4..80982ff979 100644 --- a/src/renderer/components/+custom-resources/crd-resource.store.ts +++ b/src/renderer/components/+custom-resources/crd-resource.store.ts @@ -7,7 +7,7 @@ import type { KubeApi } from "../../../common/k8s-api/kube-api"; import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import type { KubeObject } from "../../../common/k8s-api/kube-object"; -export class CRDResourceStore extends KubeObjectStore { +export class CRDResourceStore extends KubeObjectStore> { constructor(api: KubeApi) { super(api); } diff --git a/src/renderer/components/+custom-resources/crd-resources.tsx b/src/renderer/components/+custom-resources/crd-resources.tsx index 08d56488bd..f330705f46 100644 --- a/src/renderer/components/+custom-resources/crd-resources.tsx +++ b/src/renderer/components/+custom-resources/crd-resources.tsx @@ -47,7 +47,7 @@ class NonInjectedCrdResources extends React.Component { render() { const { crd, store } = this; - if (!crd) { + if (!crd || !store) { return null; } @@ -85,7 +85,9 @@ class NonInjectedCrdResources extends React.Component { })} renderTableHeader={[ { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - isNamespaced && { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + isNamespaced + ? { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace } + : undefined, ...extraColumns.map(({ name }) => ({ title: name, className: name.toLowerCase(), @@ -102,9 +104,13 @@ class NonInjectedCrdResources extends React.Component { ]} failedToLoadMessage={( <> -

Failed to load {crd.getPluralName()}

+

+ {`Failed to load ${crd.getPluralName()}`} +

{!version.served && ( -

Prefered version ({crd.getGroup()}/{version.name}) is not served

+

+ {`Prefered version (${crd.getGroup()}/${version.name}) is not served`} +

)} )} diff --git a/src/renderer/components/+custom-resources/crd.store.ts b/src/renderer/components/+custom-resources/crd.store.ts index 90e58aef75..e3a3409b83 100644 --- a/src/renderer/components/+custom-resources/crd.store.ts +++ b/src/renderer/components/+custom-resources/crd.store.ts @@ -6,7 +6,7 @@ import { computed, reaction, makeObservable } from "mobx"; import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import { autoBind } from "../../utils"; -import type { CustomResourceDefinition } from "../../../common/k8s-api/endpoints/crd.api"; +import type { CustomResourceDefinition, CustomResourceDefinitionApi } from "../../../common/k8s-api/endpoints/crd.api"; import { crdApi } from "../../../common/k8s-api/endpoints/crd.api"; import { apiManager } from "../../../common/k8s-api/api-manager"; import { KubeApi } from "../../../common/k8s-api/kube-api"; @@ -28,11 +28,9 @@ function initStore(crd: CustomResourceDefinition) { } } -export class CRDStore extends KubeObjectStore { - api = crdApi; - +export class CRDStore extends KubeObjectStore { constructor() { - super(); + super(crdApi); makeObservable(this); autoBind(this); diff --git a/src/renderer/components/+custom-resources/custom-resource-sidebar-items.injectable.tsx b/src/renderer/components/+custom-resources/custom-resource-sidebar-items.injectable.tsx index b6ddf7cf10..a6e331ea09 100644 --- a/src/renderer/components/+custom-resources/custom-resource-sidebar-items.injectable.tsx +++ b/src/renderer/components/+custom-resources/custom-resource-sidebar-items.injectable.tsx @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { noop, some } from "lodash/fp"; +import { noop } from "lodash/fp"; import { computed } from "mobx"; import type { @@ -55,7 +55,7 @@ const customResourceSidebarItemsInjectable = getInjectable({ title: "Custom Resources", getIcon: () => , onClick: noop, - isVisible: computed(() => some(item => item.isVisible.get(), childrenAndGrandChildren)), + isVisible: computed(() => childrenAndGrandChildren.some(item => item.isVisible?.get())), orderNumber: 110, }; diff --git a/src/renderer/components/+entity-settings/entity-settings.tsx b/src/renderer/components/+entity-settings/entity-settings.tsx index fc23e94a19..2194490672 100644 --- a/src/renderer/components/+entity-settings/entity-settings.tsx +++ b/src/renderer/components/+entity-settings/entity-settings.tsx @@ -27,7 +27,7 @@ interface Dependencies { @observer class NonInjectedEntitySettings extends React.Component { - @observable activeTab: string; + @observable activeTab?: string; constructor(props: Dependencies) { super(props); @@ -94,7 +94,12 @@ class NonInjectedEntitySettings extends React.Component { {this.entity.getName()} - + { groups.map((group, groupIndex) => (
diff --git a/src/renderer/components/+events/duration-absolute.tsx b/src/renderer/components/+events/duration-absolute.tsx new file mode 100644 index 0000000000..2aa1229d74 --- /dev/null +++ b/src/renderer/components/+events/duration-absolute.tsx @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import React from "react"; +import { ReactiveDuration } from "../duration/reactive-duration"; +import { LocaleDate } from "../locale-date"; + +export interface DurationAbsoluteTimestampProps { + timestamp: string | undefined; +} + +export const DurationAbsoluteTimestamp = ({ timestamp }: DurationAbsoluteTimestampProps) => { + if (!timestamp) { + return <>{""}; + } + + return ( + <> + + {" ago "} + ( + + ) + + ); +}; diff --git a/src/renderer/components/+events/event-details.tsx b/src/renderer/components/+events/event-details.tsx index 8ab3e665c1..9227b3e73e 100644 --- a/src/renderer/components/+events/event-details.tsx +++ b/src/renderer/components/+events/event-details.tsx @@ -14,11 +14,10 @@ import type { KubeObjectDetailsProps } from "../kube-object-details"; import { KubeEvent } from "../../../common/k8s-api/endpoints/events.api"; import { KubeObjectMeta } from "../kube-object-meta"; import { Table, TableCell, TableHead, TableRow } from "../table"; -import { LocaleDate } from "../locale-date"; import { getDetailsUrl } from "../kube-detail-params"; import { apiManager } from "../../../common/k8s-api/api-manager"; import logger from "../../../common/logger"; -import { ReactiveDuration } from "../duration/reactive-duration"; +import { DurationAbsoluteTimestamp } from "./duration-absolute"; export interface EventDetailsProps extends KubeObjectDetailsProps { } @@ -55,14 +54,10 @@ export class EventDetails extends React.Component { {event.getSource()}
- - {" ago "} - () + - - {" ago "} - () + {count} diff --git a/src/renderer/components/+events/event.store.ts b/src/renderer/components/+events/event.store.ts index 1c4e835b9f..4a66b6d69d 100644 --- a/src/renderer/components/+events/event.store.ts +++ b/src/renderer/components/+events/event.store.ts @@ -6,21 +6,17 @@ import groupBy from "lodash/groupBy"; import compact from "lodash/compact"; import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import { autoBind } from "../../utils"; -import type { KubeEvent } from "../../../common/k8s-api/endpoints/events.api"; +import { autoBind, isClusterPageContext } from "../../utils"; +import type { KubeEvent, KubeEventApi } from "../../../common/k8s-api/endpoints/events.api"; import { eventApi } from "../../../common/k8s-api/endpoints/events.api"; import type { KubeObject } from "../../../common/k8s-api/kube-object"; import { Pod } from "../../../common/k8s-api/endpoints/pods.api"; import { podsStore } from "../+workloads-pods/pods.store"; import { apiManager } from "../../../common/k8s-api/api-manager"; -export class EventStore extends KubeObjectStore { - api = eventApi; - limit = 1000; - saveLimit = 50000; - - constructor() { - super(); +export class EventStore extends KubeObjectStore { + constructor(api: KubeEventApi) { + super(api, { limit: 1000 }); autoBind(this); } @@ -54,7 +50,7 @@ export class EventStore extends KubeObjectStore { if (kind == Pod.kind) { // Wipe out running pods const pod = podsStore.items.find(pod => pod.getId() == uid); - if (!pod || (!pod.hasIssues() && pod.spec.priority < 500000)) return undefined; + if (!pod || (!pod.hasIssues() && (pod.spec?.priority ?? 0) < 500000)) return undefined; } return recent; @@ -68,5 +64,10 @@ export class EventStore extends KubeObjectStore { } } -export const eventStore = new EventStore(); -apiManager.registerStore(eventStore); +export const eventStore = isClusterPageContext() + ? new EventStore(eventApi) + : undefined as never; + +if (isClusterPageContext()) { + apiManager.registerStore(eventStore); +} diff --git a/src/renderer/components/+events/events.tsx b/src/renderer/components/+events/events.tsx index b81e30fbe2..472b2ff7ca 100644 --- a/src/renderer/components/+events/events.tsx +++ b/src/renderer/components/+events/events.tsx @@ -5,7 +5,7 @@ import "./events.scss"; -import React, { Fragment } from "react"; +import React from "react"; import { computed, observable, makeObservable } from "mobx"; import { observer } from "mobx-react"; import { orderBy } from "lodash"; @@ -14,7 +14,7 @@ import type { EventStore } from "./event.store"; import { eventStore } from "./event.store"; import type { KubeObjectListLayoutProps } from "../kube-object-list-layout"; import { KubeObjectListLayout } from "../kube-object-list-layout"; -import type { KubeEvent } from "../../../common/k8s-api/endpoints/events.api"; +import type { KubeEvent, KubeEventApi, KubeEventData } from "../../../common/k8s-api/endpoints/events.api"; import type { TableSortCallbacks, TableSortParams } from "../table"; import type { HeaderCustomizer } from "../item-object-list"; import { Tooltip } from "../tooltip"; @@ -40,7 +40,7 @@ enum columnId { lastSeen = "last-seen", } -export interface EventsProps extends Partial> { +export interface EventsProps extends Partial> { className?: IClassName; compact?: boolean; compactLimit?: number; @@ -69,7 +69,7 @@ class NonInjectedEvents extends React.Component { [columnId.object]: event => event.involvedObject.name, [columnId.count]: event => event.count, [columnId.age]: event => -event.getCreationTimestamp(), - [columnId.lastSeen]: event => -new Date(event.lastTimestamp).getTime(), + [columnId.lastSeen]: event => event.lastTimestamp ? -new Date(event.lastTimestamp).getTime() : 0, }; constructor(props: Dependencies & EventsProps) { @@ -87,7 +87,7 @@ class NonInjectedEvents extends React.Component { // we must sort items before passing to "KubeObjectListLayout -> Table" // to make it work with "compact=true" (proper table sorting actions + initial items) - return orderBy(items, this.sortingCallbacks[sortBy], order as any); + return orderBy(items, this.sortingCallbacks[sortBy], order); } @computed get visibleItems(): KubeEvent[] { @@ -113,20 +113,30 @@ class NonInjectedEvents extends React.Component { return { title, - info: ({visibleItems.length} of {items.length}), + info: ( + + {"("} + {visibleItems.length} + {" of "} + {items.length} + {")"} + + ), }; } return { - info: <> - {info} - - , + info: ( + <> + {info} + + + ), title, ...headerPlaceholders, }; @@ -180,22 +190,28 @@ class NonInjectedEvents extends React.Component { { className: { warning: isWarning }, title: ( - + <> {message} {message} - + ), }, event.getNs(), - - {involvedObject.kind}: {involvedObject.name} + + {`${involvedObject.kind}: ${involvedObject.name}`} , event.getSource(), event.count, , - , + event.lastTimestamp + ? + : "", ]; }} /> diff --git a/src/renderer/components/+events/kube-event-details.tsx b/src/renderer/components/+events/kube-event-details.tsx index 4bcb164af9..0c120c6dd4 100644 --- a/src/renderer/components/+events/kube-event-details.tsx +++ b/src/renderer/components/+events/kube-event-details.tsx @@ -9,22 +9,21 @@ import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import { KubeObject } from "../../../common/k8s-api/kube-object"; import { DrawerItem, DrawerTitle } from "../drawer"; -import type { Disposer } from "../../utils"; import { cssNames } from "../../utils"; import { LocaleDate } from "../locale-date"; import { eventStore } from "./event.store"; import logger from "../../../common/logger"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import { withInjectables } from "@ogre-tools/injectable-react"; import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable"; +import type { SubscribeStores } from "../../kube-watch-api/kube-watch-api"; export interface KubeEventDetailsProps { object: KubeObject; } interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer; + subscribeStores: SubscribeStores; } @observer @@ -73,9 +72,11 @@ class NonInjectedKubeEventDetails extends React.Component {event.involvedObject.fieldPath} - - - + {event.lastTimestamp && ( + + + + )} ))} diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index a29e9bb004..e56e88aed9 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -21,10 +21,9 @@ import { renderFor } from "../../test-utils/renderFor"; import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable"; import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import directoryForDownloadsInjectable from "../../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable"; -import getConfigurationFileModelInjectable - from "../../../../common/get-configuration-file-model/get-configuration-file-model.injectable"; -import appVersionInjectable - from "../../../../common/get-configuration-file-model/app-version/app-version.injectable"; +import getConfigurationFileModelInjectable from "../../../../common/get-configuration-file-model/get-configuration-file-model.injectable"; +import appVersionInjectable from "../../../../common/get-configuration-file-model/app-version/app-version.injectable"; +import assert from "assert"; mockWindow(); @@ -98,10 +97,17 @@ describe("Extensions", () => { it("disables uninstall and disable buttons while uninstalling", async () => { extensionDiscovery.isLoaded = true; - const res = render(<>); + const res = render(( + <> + + + + )); const table = res.getByTestId("extensions-table"); const menuTrigger = table.querySelector("div[role=row]:first-of-type .actions .Icon"); + assert(menuTrigger); + fireEvent.click(menuTrigger); expect(res.getByText("Disable")).toHaveAttribute("aria-disabled", "false"); @@ -125,7 +131,7 @@ describe("Extensions", () => { it("disables install button while installing", async () => { const res = render(); - (fse.unlink as jest.MockedFunction).mockReturnValue(Promise.resolve() as any); + (fse.unlink as jest.MockedFunction).mockReturnValue(Promise.resolve()); fireEvent.change(res.getByPlaceholderText("File path or URL", { exact: false, diff --git a/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.tsx b/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.tsx index c737bb97d1..df6279cf43 100644 --- a/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.tsx +++ b/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.tsx @@ -66,8 +66,11 @@ export const attemptInstallByInfo = ({ attemptInstall, getBaseRegistryUrl, exten } else { Notifications.error(

- The {name} extension does not have a version or tag{" "} - {version}. + {"The "} + {name} + {" extension does not have a version or tag "} + {version} + .

, ); @@ -82,20 +85,24 @@ export const attemptInstallByInfo = ({ attemptInstall, getBaseRegistryUrl, exten ) // ignore pre-releases for auto picking the version .filter(version => version.prerelease.length === 0); + const latestVersion = lodash.reduce(versions, (prev, curr) => prev.compareMain(curr) === -1 ? curr : prev); - version = lodash.reduce(versions, (prev, curr) => - prev.compareMain(curr) === -1 ? curr : prev, - ).format(); + if (!latestVersion) { + console.error("No versions supplied for that extension", { name }); + Notifications.error(`No versions found for ${name}`); + + return disposer(); + } + + version = latestVersion.format(); } if (requireConfirmation) { const proceed = await ConfirmDialog.confirm({ message: (

- Are you sure you want to install{" "} - - {name}@{version} - + {"Are you sure you want to install "} + {`${name}@${version}`} ?

), @@ -108,7 +115,8 @@ export const attemptInstallByInfo = ({ attemptInstall, getBaseRegistryUrl, exten } } - const url = json.versions[version].dist.tarball; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const url = json.versions[version!].dist.tarball; const fileName = path.basename(url); const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 }); diff --git a/src/renderer/components/+extensions/attempt-install/attempt-install.tsx b/src/renderer/components/+extensions/attempt-install/attempt-install.tsx index 0d9d7fc7e8..9d83862357 100644 --- a/src/renderer/components/+extensions/attempt-install/attempt-install.tsx +++ b/src/renderer/components/+extensions/attempt-install/attempt-install.tsx @@ -74,7 +74,9 @@ export const attemptInstall =
Extension Install Collision:

- The {name} extension is currently {curState.toLowerCase()}. + {"The "} + {name} + {` extension is currently ${curState.toLowerCase()}.`}

Will not proceed with this current install request.

, @@ -92,21 +94,20 @@ export const attemptInstall =

- Install extension{" "} - - {name}@{version} - - ? + {"Install extension "} + {`${name}@${version}`} + ?

- Description: {description} + {"Description: "} + {description}

shell.openPath(extensionFolder)} > - Warning: {name}@{oldVersion} will be removed before - installation. + Warning: + {` ${name}@${oldVersion} will be removed before installation.`}
, ); diff --git a/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx b/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx index 69ff2a59a2..44b02f6351 100644 --- a/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx +++ b/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx @@ -75,7 +75,9 @@ export const unpackExtension = Notifications.ok(

- Extension {displayName} successfully installed! + {"Extension "} + {displayName} + {" successfully installed!"}

, ); } catch (error) { @@ -87,7 +89,9 @@ export const unpackExtension = ); Notifications.error(

- Installing extension {displayName} has failed:{" "} + {"Installing extension "} + {displayName} + {" has failed: "} {message}

, ); diff --git a/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx b/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx index 86e4244337..ad3432574b 100644 --- a/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx +++ b/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import type { LensExtensionManifest } from "../../../../../extensions/lens-extension"; -import { listTarEntries, readFileFromTar } from "../../../../../common/utils"; +import { hasTypedProperty, isObject, isString, listTarEntries, readFileFromTar } from "../../../../../common/utils"; import { manifestFilename } from "../../../../../extensions/extension-discovery/extension-discovery"; import path from "path"; @@ -31,17 +31,21 @@ export const validatePackage = async ( throw new Error(`invalid extension bundle, ${manifestFilename} not found`); } - const manifest = await readFileFromTar({ + const manifest = await readFileFromTar({ tarPath: filePath, filePath: manifestLocation, parseJson: true, }); - if (!manifest.main && !manifest.renderer) { - throw new Error( - `${manifestFilename} must specify "main" and/or "renderer" fields`, - ); + if ( + isObject(manifest) + && ( + hasTypedProperty(manifest, "main", isString) + || hasTypedProperty(manifest, "renderer", isString) + ) + ) { + return manifest as unknown as LensExtensionManifest; } - return manifest; + throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); }; diff --git a/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.tsx b/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.tsx index 2954a2e8e6..71bbe16a8b 100644 --- a/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.tsx +++ b/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.tsx @@ -22,7 +22,9 @@ export const confirmUninstallExtension = const confirmed = await ConfirmDialog.confirm({ message: (

- Are you sure you want to uninstall extension {displayName}? + {"Are you sure you want to uninstall extension "} + {displayName} + ?

), labelOk: "Yes", diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 21a96eb873..59744ccea6 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -72,15 +72,33 @@ class NonInjectedExtensions extends React.Component { return ( - +

Extensions

- Add new features via Lens Extensions.{" "} - Check out docs{" "} - and list of available extensions. + {"Add new features via Lens Extensions. Check out the "} + + docs + + {" and list of "} + + available extensions + + .

diff --git a/src/renderer/components/+extensions/get-base-registry-url/get-base-registry-url.tsx b/src/renderer/components/+extensions/get-base-registry-url/get-base-registry-url.tsx index 89606beb5d..c3a4ff8381 100644 --- a/src/renderer/components/+extensions/get-base-registry-url/get-base-registry-url.tsx +++ b/src/renderer/components/+extensions/get-base-registry-url/get-base-registry-url.tsx @@ -5,7 +5,7 @@ import React from "react"; import type { ExtensionRegistry } from "../../../../common/user-store/preferences-helpers"; -import { defaultExtensionRegistryUrl, ExtensionRegistryLocation } from "../../../../common/user-store/preferences-helpers"; +import { defaultExtensionRegistryUrl } from "../../../../common/user-store/preferences-helpers"; import { promiseExecFile } from "../../../utils"; import { Notifications } from "../../notifications"; @@ -17,10 +17,10 @@ export const getBaseRegistryUrl = ({ getRegistryUrlPreference }: Dependencies) = const extensionRegistryUrl = getRegistryUrlPreference(); switch (extensionRegistryUrl.location) { - case ExtensionRegistryLocation.CUSTOM: + case "custom": return extensionRegistryUrl.customUrl; - case ExtensionRegistryLocation.NPMRC: { + case "npmrc": { try { const filteredEnv = Object.fromEntries( Object.entries(process.env) @@ -30,13 +30,20 @@ export const getBaseRegistryUrl = ({ getRegistryUrlPreference }: Dependencies) = return stdout.trim(); } catch (error) { - Notifications.error(

Failed to get configured registry from .npmrc. Falling back to default registry

); + Notifications.error(( +

+ Failed to get configured registry from + .npmrc + . Falling back to default registry. +

+ )); console.warn("[EXTENSIONS]: failed to get configured registry from .npmrc", error); } - // fallthrough } + + // fallthrough default: - case ExtensionRegistryLocation.DEFAULT: + case "default": return defaultExtensionRegistryUrl; } }; diff --git a/src/renderer/components/+extensions/get-message-from-error/get-message-from-error.ts b/src/renderer/components/+extensions/get-message-from-error/get-message-from-error.ts index c6d99a51e3..ecbb4f9145 100644 --- a/src/renderer/components/+extensions/get-message-from-error/get-message-from-error.ts +++ b/src/renderer/components/+extensions/get-message-from-error/get-message-from-error.ts @@ -2,16 +2,19 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -export function getMessageFromError(error: any): string { + +import { hasTypedProperty, isDefined } from "../../../utils"; + +export function getMessageFromError(error: unknown): string { if (!error || typeof error !== "object") { return "an error has occurred"; } - if (error.message) { - return String(error.message); + if (error instanceof Error) { + return error.message; } - if (error.err) { + if (hasTypedProperty(error, "err", isDefined)) { return String(error.err); } diff --git a/src/renderer/components/+extensions/install-from-input/install-from-input.tsx b/src/renderer/components/+extensions/install-from-input/install-from-input.tsx index 9fd3442a29..3f4c9a4480 100644 --- a/src/renderer/components/+extensions/install-from-input/install-from-input.tsx +++ b/src/renderer/components/+extensions/install-from-input/install-from-input.tsx @@ -14,6 +14,7 @@ import { readFileNotify } from "../read-file-notify/read-file-notify"; import type { InstallRequest } from "../attempt-install/install-request"; import type { ExtensionInfo } from "../attempt-install-by-info/attempt-install-by-info"; import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store"; +import { AsyncInputValidationError } from "../../input/input_validators"; interface Dependencies { attemptInstall: (request: InstallRequest, disposer?: ExtendableDisposer) => Promise; @@ -21,34 +22,59 @@ interface Dependencies { extensionInstallationStateStore: ExtensionInstallationStateStore; } -export const installFromInput = ({ attemptInstall, attemptInstallByInfo, extensionInstallationStateStore }: Dependencies) => async (input: string) => { - let disposer: ExtendableDisposer | undefined = undefined; +export const installFromInput = ({ + attemptInstall, + attemptInstallByInfo, + extensionInstallationStateStore, +}: Dependencies) => ( + async (input: string): Promise => { + let disposer: ExtendableDisposer | undefined = undefined; - try { - // fixme: improve error messages for non-tar-file URLs - if (InputValidators.isUrl.validate(input)) { - // install via url - disposer = extensionInstallationStateStore.startPreInstall(); - const { promise } = downloadFile({ url: input, timeout: 10 * 60 * 1000 }); - const fileName = path.basename(input); + try { + // fixme: improve error messages for non-tar-file URLs + if (InputValidators.isUrl.validate(input, {})) { + // install via url + disposer = extensionInstallationStateStore.startPreInstall(); + const { promise } = downloadFile({ url: input, timeout: 10 * 60 * 1000 }); + const fileName = path.basename(input); - await attemptInstall({ fileName, dataP: promise }, disposer); - } else if (InputValidators.isPath.validate(input)) { - // install from system path - const fileName = path.basename(input); + return await attemptInstall({ fileName, dataP: promise }, disposer); + } - await attemptInstall({ fileName, dataP: readFileNotify(input) }); - } else if (InputValidators.isExtensionNameInstall.validate(input)) { - const [{ groups: { name, version }}] = [...input.matchAll(InputValidators.isExtensionNameInstallRegex)]; + try { + await InputValidators.isPath.validate(input, {}); - await attemptInstallByInfo({ name, version }); + // install from system path + const fileName = path.basename(input); + + return await attemptInstall({ fileName, dataP: readFileNotify(input) }); + } catch (error) { + if (error instanceof AsyncInputValidationError) { + const extNameCaptures = InputValidators.isExtensionNameInstallRegex.captures(input); + + if (extNameCaptures) { + const { name, version } = extNameCaptures; + + return await attemptInstallByInfo({ name, version }); + } + } else { + throw error; + } + } + + throw new Error(`Unknown format of input: ${input}`); + } catch (error) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-INSTALL]: installation has failed: ${message}`, { error, installPath: input }); + Notifications.error(( +

+ {"Installation has failed: "} + {message} +

+ )); + } finally { + disposer?.(); } - } catch (error) { - const message = getMessageFromError(error); - - logger.info(`[EXTENSION-INSTALL]: installation has failed: ${message}`, { error, installPath: input }); - Notifications.error(

Installation has failed: {message}

); - } finally { - disposer?.(); } -}; +); diff --git a/src/renderer/components/+extensions/install.tsx b/src/renderer/components/+extensions/install.tsx index a3fee3064b..52c0bd8906 100644 --- a/src/renderer/components/+extensions/install.tsx +++ b/src/renderer/components/+extensions/install.tsx @@ -9,7 +9,6 @@ import { prevDefault } from "../../utils"; import { Button } from "../button"; import { Icon } from "../icon"; import { observer } from "mobx-react"; -import type { InputValidator } from "../input"; import { Input, InputValidators } from "../input"; import { SubTitle } from "../layout/sub-title"; import { TooltipPosition } from "../tooltip"; @@ -17,6 +16,7 @@ import type { ExtensionInstallationStateStore } from "../../../extensions/extens import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; import { withInjectables } from "@ogre-tools/injectable-react"; +import { inputValidator } from "../input/input_validators"; export interface InstallProps { installPath: string; @@ -36,12 +36,12 @@ const installInputValidators = [ InputValidators.isExtensionNameInstall, ]; -const installInputValidator: InputValidator = { +const installInputValidator = inputValidator({ message: "Invalid URL, absolute path, or extension name", - validate: (value: string) => ( - installInputValidators.some(({ validate }) => validate(value)) + validate: (value: string, props) => ( + installInputValidators.some(({ validate }) => validate(value, props)) ), -}; +}); const NonInjectedInstall: React.FC = ({ installPath, @@ -71,14 +71,14 @@ const NonInjectedInstall: React.FC = ({ value={installPath} onChange={onChange} onSubmit={installFromInput} - iconRight={ + iconRight={( - } + )} />
@@ -95,7 +95,8 @@ const NonInjectedInstall: React.FC = ({
- Pro-Tip: you can drag-n-drop tarball-file to this area + Pro-Tip + : you can drag-n-drop tarball-file to this area
); diff --git a/src/renderer/components/+extensions/installed-extensions.tsx b/src/renderer/components/+extensions/installed-extensions.tsx index 768253008b..8bde9c344b 100644 --- a/src/renderer/components/+extensions/installed-extensions.tsx +++ b/src/renderer/components/+extensions/installed-extensions.tsx @@ -46,12 +46,6 @@ function getStatus(extension: InstalledExtension) { } const NonInjectedInstalledExtensions = observer(({ extensionDiscovery, extensionInstallationStateStore, extensions, uninstall, enable, disable }: Dependencies & InstalledExtensionsProps) => { - const filters = [ - (extension: InstalledExtension) => extension.manifest.name, - (extension: InstalledExtension) => getStatus(extension), - (extension: InstalledExtension) => extension.manifest.version, - ]; - const columns = useMemo( () => [ { @@ -169,7 +163,11 @@ const NonInjectedInstalledExtensions = observer(({ extensionDiscovery, extension columns={columns} data={data} items={extensions} - filters={filters} + filters={[ + (extension) => extension.manifest.name, + (extension) => getStatus(extension), + (extension) => extension.manifest.version, + ]} />
); diff --git a/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx b/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx index 3380df97d7..3db43813a2 100644 --- a/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx +++ b/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx @@ -19,45 +19,61 @@ interface Dependencies { extensionInstallationStateStore: ExtensionInstallationStateStore; } -export const uninstallExtension = - ({ extensionLoader, extensionDiscovery, extensionInstallationStateStore }: Dependencies) => - async (extensionId: LensExtensionId): Promise => { - const { manifest } = extensionLoader.getExtension(extensionId); - const displayName = extensionDisplayName(manifest.name, manifest.version); +export const uninstallExtension = ({ + extensionLoader, + extensionDiscovery, + extensionInstallationStateStore, +}: Dependencies) => ( + async (extensionId: LensExtensionId): Promise => { + const ext = extensionLoader.getExtension(extensionId); - try { - logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`); - extensionInstallationStateStore.setUninstalling(extensionId); + if (!ext) { + logger.debug(`[EXTENSIONS]: cannot uninstall ${extensionId}, was not installed`); - await extensionDiscovery.uninstallExtension(extensionId); + return true; + } - // wait for the ExtensionLoader to actually uninstall the extension - await when(() => !extensionLoader.userExtensions.has(extensionId)); + const { manifest } = ext; + const displayName = extensionDisplayName(manifest.name, manifest.version); - Notifications.ok( -

- Extension {displayName} successfully uninstalled! -

, - ); + try { + logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`); + extensionInstallationStateStore.setUninstalling(extensionId); - return true; - } catch (error) { - const message = getMessageFromError(error); + await extensionDiscovery.uninstallExtension(extensionId); - logger.info( - `[EXTENSION-UNINSTALL]: uninstalling ${displayName} has failed: ${error}`, - { error }, - ); - Notifications.error( -

- Uninstalling extension {displayName} has failed:{" "} - {message} -

, - ); + // wait for the ExtensionLoader to actually uninstall the extension + await when(() => !extensionLoader.userExtensions.has(extensionId)); - return false; - } finally { + Notifications.ok( +

+ {"Extension "} + {displayName} + {" successfully uninstalled!"} +

, + ); + + return true; + } catch (error) { + const message = getMessageFromError(error); + + logger.info( + `[EXTENSION-UNINSTALL]: uninstalling ${displayName} has failed: ${error}`, + { error }, + ); + Notifications.error( +

+ {"Uninstalling extension "} + {displayName} + {" has failed: "} + {message} +

, + ); + + return false; + } finally { // Remove uninstall state on uninstall failure - extensionInstallationStateStore.clearUninstalling(extensionId); - } - }; + extensionInstallationStateStore.clearUninstalling(extensionId); + } + } +); diff --git a/src/renderer/components/+helm-charts/helm-chart-details.tsx b/src/renderer/components/+helm-charts/helm-chart-details.tsx index 182b4a0878..6bd7c8592a 100644 --- a/src/renderer/components/+helm-charts/helm-chart-details.tsx +++ b/src/renderer/components/+helm-charts/helm-chart-details.tsx @@ -8,14 +8,14 @@ import "./helm-chart-details.scss"; import React, { Component } from "react"; import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api"; import { getChartDetails } from "../../../common/k8s-api/endpoints/helm-charts.api"; -import { observable, makeObservable, reaction } from "mobx"; +import { observable, reaction, runInAction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { Drawer, DrawerItem } from "../drawer"; import { autoBind, stopPropagation } from "../../utils"; import { MarkdownViewer } from "../markdown-viewer"; import { Spinner } from "../spinner"; import { Button } from "../button"; -import { Select, type SelectOption } from "../select"; +import { Select } from "../select"; import { Badge } from "../badge"; import { Tooltip, withStyles } from "@material-ui/core"; import { withInjectables } from "@ogre-tools/injectable-react"; @@ -39,11 +39,11 @@ interface Dependencies { @observer class NonInjectedHelmChartDetails extends Component { - @observable chartVersions: HelmChart[]; - @observable selectedChart?: HelmChart; - @observable readme?: string; + readonly chartVersions = observable.array(); + readonly selectedChart = observable.box(); + readonly readme = observable.box(undefined); - private abortController?: AbortController; + private abortController = new AbortController(); constructor(props: HelmChartDetailsProps & Dependencies) { super(props); @@ -52,25 +52,28 @@ class NonInjectedHelmChartDetails extends Component this.props.chart, async ({ name, repo, version }) => { - try { - this.selectedChart = undefined; - this.chartVersions = undefined; - this.readme = undefined; + runInAction(() => { + this.selectedChart.set(undefined); + this.chartVersions.clear(); + this.readme.set(""); + }); + try { const { readme, versions } = await getChartDetails(repo, name, { version }); - this.readme = readme; - this.chartVersions = versions; - this.selectedChart = versions[0]; + runInAction(() => { + this.readme.set(readme); + this.chartVersions.replace(versions); + this.selectedChart.set(versions[0]); + }); } catch (error) { - Notifications.error(error); - this.selectedChart = null; + Notifications.checkedError(error, "Unknown error occured while getting chart details"); } }, { fireImmediately: true, @@ -78,29 +81,33 @@ class NonInjectedHelmChartDetails extends Component) { - this.selectedChart = chart; - this.readme = null; + async onVersionChange(c: HelmChart | null | undefined) { + const chart = c ?? this.chartVersions[0]; + + runInAction(() => { + this.selectedChart.set(chart ?? undefined); + this.readme.set(undefined); + }); try { - this.abortController?.abort(); + this.abortController.abort(); this.abortController = new AbortController(); const { chart: { name, repo }} = this.props; const { readme } = await getChartDetails(repo, name, { version: chart.version, reqInit: { signal: this.abortController.signal }}); - this.readme = readme; + this.readme.set(readme); } catch (error) { - Notifications.error(error); + Notifications.checkedError(error, "Unknown error occured while getting chart details"); } } - install() { - this.props.createInstallChartTab(this.selectedChart); + install(selectedChart: HelmChart) { + this.props.createInstallChartTab(selectedChart); this.props.hideDetails(); } - renderIntroduction() { - const { selectedChart, chartVersions, onVersionChange } = this; + renderIntroduction(selectedChart: HelmChart) { + const { chartVersions, onVersionChange } = this; const placeholder = require("./helm-placeholder.svg"); return ( @@ -113,37 +120,57 @@ class NonInjectedHelmChartDetails extends Component
{selectedChart.getDescription()} -
- + `${value.revision} - ${value.chart} - ${value.app_version}, updated: ${new Date(value.updated).toLocaleString()}`} + onChange={value => this.revision.set(value ?? undefined)} + /> + + ); + } + + renderContent(release: HelmRelease) { + return ( + + Rollback + {release.getName()} + + )} + done={this.close} + > + this.rollback(release)} + loading={this.isLoading.get()} + > + {this.renderRevisionContent()} + + + ); + } + + render() { + const { state, ...dialogProps } = this.props; + const release = state.get(); + + return ( + this.onOpen(release)) : undefined} + close={this.close} + > + {release && this.renderContent(release)} + + ); + } +} + +export const ReleaseRollbackDialog = withInjectables( + NonInjectedReleaseRollbackDialog, + + { + getProps: (di, props) => ({ + rollbackRelease: di.inject(rollbackReleaseInjectable), + state: di.inject(releaseRollbackDialogStateInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/components/+helm-releases/dialog/open.injectable.ts b/src/renderer/components/+helm-releases/dialog/open.injectable.ts new file mode 100644 index 0000000000..da1ec2106d --- /dev/null +++ b/src/renderer/components/+helm-releases/dialog/open.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable } from "@ogre-tools/injectable"; +import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api"; +import releaseRollbackDialogStateInjectable from "./state.injectable"; + +export type OpenHelmReleaseRollbackDialog = (release: HelmRelease) => void; + +const openHelmReleaseRollbackDialogInjectable = getInjectable({ + id: "open-helm-release-dialog", + instantiate: (di): OpenHelmReleaseRollbackDialog => { + const state = di.inject(releaseRollbackDialogStateInjectable); + + return release => state.set(release); + }, +}); + +export default openHelmReleaseRollbackDialogInjectable; diff --git a/src/renderer/components/+helm-releases/dialog/state.injectable.ts b/src/renderer/components/+helm-releases/dialog/state.injectable.ts new file mode 100644 index 0000000000..4551eab915 --- /dev/null +++ b/src/renderer/components/+helm-releases/dialog/state.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { observable } from "mobx"; +import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api"; + +const releaseRollbackDialogStateInjectable = getInjectable({ + id: "release-rollback-dialog-state", + instantiate: () => observable.box(undefined), +}); + +export default releaseRollbackDialogStateInjectable; diff --git a/src/renderer/components/+helm-releases/release-details/release-details.injectable.ts b/src/renderer/components/+helm-releases/release-details/release-details.injectable.ts index 096259a969..359d1d6644 100644 --- a/src/renderer/components/+helm-releases/release-details/release-details.injectable.ts +++ b/src/renderer/components/+helm-releases/release-details/release-details.injectable.ts @@ -6,16 +6,19 @@ import { getInjectable } from "@ogre-tools/injectable"; import { getRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api"; import { asyncComputed } from "@ogre-tools/injectable-react"; import releaseInjectable from "./release.injectable"; +import { waitUntilDefinied } from "../../../utils"; const releaseDetailsInjectable = getInjectable({ id: "release-details", - instantiate: (di) => - asyncComputed(async () => { - const release = di.inject(releaseInjectable).get(); + instantiate: (di) => { + const releaseComputed = di.inject(releaseInjectable); - return await getRelease(release.name, release.namespace); - }), + return asyncComputed(async () => { + const release = await waitUntilDefinied(releaseComputed); + + return getRelease(release.name, release.namespace); + });}, }); export default releaseDetailsInjectable; diff --git a/src/renderer/components/+helm-releases/release-details/release-details.tsx b/src/renderer/components/+helm-releases/release-details/release-details.tsx index c43798cf60..9ead50c94f 100644 --- a/src/renderer/components/+helm-releases/release-details/release-details.tsx +++ b/src/renderer/components/+helm-releases/release-details/release-details.tsx @@ -11,7 +11,7 @@ import type { IComputedValue } from "mobx"; import { computed, makeObservable, observable } from "mobx"; import { Link } from "react-router-dom"; import kebabCase from "lodash/kebabCase"; -import type { HelmRelease, IReleaseDetails, IReleaseUpdateDetails, IReleaseUpdatePayload } from "../../../../common/k8s-api/endpoints/helm-releases.api"; +import type { HelmRelease, HelmReleaseDetails, HelmReleaseUpdateDetails, HelmReleaseUpdatePayload } from "../../../../common/k8s-api/endpoints/helm-releases.api"; import { HelmReleaseMenu } from "../release-menu"; import { Drawer, DrawerItem, DrawerTitle } from "../../drawer"; import { Badge } from "../../badge"; @@ -35,18 +35,19 @@ import releaseInjectable from "./release.injectable"; import releaseDetailsInjectable from "./release-details.injectable"; import releaseValuesInjectable from "./release-values.injectable"; import userSuppliedValuesAreShownInjectable from "./user-supplied-values-are-shown.injectable"; -import type { KubeObject } from "../../../../common/k8s-api/kube-object"; import { KubeObjectAge } from "../../kube-object/age"; +import type { KubeJsonApiData } from "../../../../common/k8s-api/kube-json-api"; +import { entries } from "../../../../common/utils/objects"; export interface ReleaseDetailsProps { hideDetails(): void; } interface Dependencies { - release: IComputedValue; - releaseDetails: IAsyncComputed; + release: IComputedValue; + releaseDetails: IAsyncComputed; releaseValues: IAsyncComputed; - updateRelease: (name: string, namespace: string, payload: IReleaseUpdatePayload) => Promise; + updateRelease: (name: string, namespace: string, payload: HelmReleaseUpdatePayload) => Promise; createUpgradeChartTab: (release: HelmRelease) => void; userSuppliedValuesAreShown: { toggle: () => void; value: boolean }; } @@ -55,28 +56,24 @@ interface Dependencies { class NonInjectedReleaseDetails extends Component { @observable saving = false; - private nonSavedValues: string; + private nonSavedValues = ""; constructor(props: ReleaseDetailsProps & Dependencies) { super(props); makeObservable(this); } - @computed get release() { - return this.props.release.get(); - } - @computed get details() { return this.props.releaseDetails.value.get(); } - updateValues = async () => { - const name = this.release.getName(); - const namespace = this.release.getNs(); + updateValues = async (release: HelmRelease) => { + const name = release.getName(); + const namespace = release.getNs(); const data = { - chart: this.release.getChart(), - repo: await this.release.getRepo(), - version: this.release.getVersion(), + chart: release.getChart(), + repo: await release.getRepo(), + version: release.getVersion(), values: this.nonSavedValues, }; @@ -85,24 +82,28 @@ class NonInjectedReleaseDetails extends ComponentRelease {name} successfully updated!

, +

+ Release + {name} + {" successfully updated!"} +

, ); this.props.releaseValues.invalidate(); } catch (err) { - Notifications.error(err); + Notifications.checkedError(err, "Unknown error occured while updating release"); } this.saving = false; }; - upgradeVersion = () => { - const { hideDetails } = this.props; + upgradeVersion = (release: HelmRelease) => { + const { hideDetails, createUpgradeChartTab } = this.props; - this.props.createUpgradeChartTab(this.release); + createUpgradeChartTab(release); hideDetails(); }; - renderValues() { + renderValues(release: HelmRelease) { return ( {() => { @@ -133,7 +134,7 @@ class NonInjectedReleaseDetails extends Component this.updateValues(release)} /> @@ -154,28 +155,27 @@ class NonInjectedReleaseDetails extends Component { - Object.entries(groupBy(resources, item => item.kind)) + entries(groupBy(resources, item => item.kind)) .map(([kind, items]) => ( Name - {items[0].getNs() && Namespace} + {items[0].metadata.namespace && Namespace} Age {items.map(item => { - const name = item.getName(); - const namespace = item.getNs(); + const { name, namespace, uid } = item.metadata; const api = apiManager.getApi(api => api.kind === kind && api.apiVersionWithGroup == item.apiVersion); const detailsUrl = api ? getDetailsUrl(api.getUrl({ name, namespace })) : ""; return ( - + {detailsUrl ? {name} : name} @@ -198,9 +198,7 @@ class NonInjectedReleaseDetails extends Component; } @@ -211,35 +209,40 @@ class NonInjectedReleaseDetails extends Component
- {this.release.getChart()} + {release.getChart()}
- {this.release.getUpdated()} ago ({this.release.updated}) + {release.getUpdated()} + {` ago (${release.updated})`} - {this.release.getNs()} + {release.getNs()}
- {this.release.getVersion()} + {release.getVersion()}
- + - {this.renderValues()} + {this.renderValues(release)} Notes {this.renderNotes()} Resources @@ -250,38 +253,37 @@ class NonInjectedReleaseDetails extends Component; + const release = this.props.release.get(); return ( + )} > - {this.renderContent()} + {release && this.renderContent(release)} ); } } -export const ReleaseDetails = withInjectables( - NonInjectedReleaseDetails, - - { - getProps: (di, props) => ({ - release: di.inject(releaseInjectable), - releaseDetails: di.inject(releaseDetailsInjectable), - releaseValues: di.inject(releaseValuesInjectable), - - userSuppliedValuesAreShown: di.inject(userSuppliedValuesAreShownInjectable), - - updateRelease: di.inject(updateReleaseInjectable), - createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable), - ...props, - }), - }, -); +export const ReleaseDetails = withInjectables(NonInjectedReleaseDetails, { + getProps: (di, props) => ({ + ...props, + release: di.inject(releaseInjectable), + releaseDetails: di.inject(releaseDetailsInjectable), + releaseValues: di.inject(releaseValuesInjectable), + userSuppliedValuesAreShown: di.inject(userSuppliedValuesAreShownInjectable), + updateRelease: di.inject(updateReleaseInjectable), + createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable), + }), +}); diff --git a/src/renderer/components/+helm-releases/release-menu.tsx b/src/renderer/components/+helm-releases/release-menu.tsx index 473c3fb20d..511b104600 100644 --- a/src/renderer/components/+helm-releases/release-menu.tsx +++ b/src/renderer/components/+helm-releases/release-menu.tsx @@ -12,8 +12,9 @@ import { MenuItem } from "../menu"; import { Icon } from "../icon"; import { withInjectables } from "@ogre-tools/injectable-react"; import createUpgradeChartTabInjectable from "../dock/upgrade-chart/create-upgrade-chart-tab.injectable"; -import releaseRollbackDialogModelInjectable from "./release-rollback-dialog-model/release-rollback-dialog-model.injectable"; import deleteReleaseInjectable from "./delete-release/delete-release.injectable"; +import type { OpenHelmReleaseRollbackDialog } from "./dialog/open.injectable"; +import openHelmReleaseRollbackDialogInjectable from "./dialog/open.injectable"; export interface HelmReleaseMenuProps extends MenuActionsProps { release: HelmRelease; @@ -23,7 +24,7 @@ export interface HelmReleaseMenuProps extends MenuActionsProps { interface Dependencies { deleteRelease: (release: HelmRelease) => Promise; createUpgradeChartTab: (release: HelmRelease) => void; - openRollbackDialog: (release: HelmRelease) => void; + openRollbackDialog: OpenHelmReleaseRollbackDialog; } class NonInjectedHelmReleaseMenu extends React.Component { @@ -52,12 +53,20 @@ class NonInjectedHelmReleaseMenu extends React.Component {hasRollback && ( - + Rollback )} - + Upgrade @@ -72,7 +81,13 @@ class NonInjectedHelmReleaseMenu extends React.Component

Remove Helm Release {release.name}?

} + removeConfirmationMessage={() => ( +

+ Remove Helm Release + {release.name} + ? +

+ )} > {this.renderContent()} @@ -80,16 +95,11 @@ class NonInjectedHelmReleaseMenu extends React.Component( - NonInjectedHelmReleaseMenu, - - { - getProps: (di, props) => ({ - deleteRelease: di.inject(deleteReleaseInjectable), - createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable), - openRollbackDialog: di.inject(releaseRollbackDialogModelInjectable).open, - - ...props, - }), - }, -); +export const HelmReleaseMenu = withInjectables(NonInjectedHelmReleaseMenu, { + getProps: (di, props) => ({ + ...props, + deleteRelease: di.inject(deleteReleaseInjectable), + createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable), + openRollbackDialog: di.inject(openHelmReleaseRollbackDialogInjectable), + }), +}); diff --git a/src/renderer/components/+helm-releases/release-rollback-dialog-model/release-rollback-dialog-model.injectable.ts b/src/renderer/components/+helm-releases/release-rollback-dialog-model/release-rollback-dialog-model.injectable.ts deleted file mode 100644 index b81bdf438b..0000000000 --- a/src/renderer/components/+helm-releases/release-rollback-dialog-model/release-rollback-dialog-model.injectable.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import { ReleaseRollbackDialogModel } from "./release-rollback-dialog-model"; - -const releaseRollbackDialogModelInjectable = getInjectable({ - id: "release-rollback-dialog-model", - instantiate: () => new ReleaseRollbackDialogModel(), -}); - -export default releaseRollbackDialogModelInjectable; diff --git a/src/renderer/components/+helm-releases/release-rollback-dialog-model/release-rollback-dialog-model.ts b/src/renderer/components/+helm-releases/release-rollback-dialog-model/release-rollback-dialog-model.ts deleted file mode 100644 index 21ec78534a..0000000000 --- a/src/renderer/components/+helm-releases/release-rollback-dialog-model/release-rollback-dialog-model.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { computed, observable, makeObservable, action } from "mobx"; -import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api"; - -export class ReleaseRollbackDialogModel { - release: HelmRelease | null = null; - - constructor() { - makeObservable(this, { - isOpen: computed, - release: observable, - open: action, - close: action, - }); - } - - get isOpen() { - return !!this.release; - } - - open = (release: HelmRelease) => { - this.release = release; - }; - - close = () => { - this.release = null; - }; -} diff --git a/src/renderer/components/+helm-releases/release-rollback-dialog.tsx b/src/renderer/components/+helm-releases/release-rollback-dialog.tsx deleted file mode 100644 index e7f9868c75..0000000000 --- a/src/renderer/components/+helm-releases/release-rollback-dialog.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./release-rollback-dialog.scss"; - -import React from "react"; -import { observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; -import type { DialogProps } from "../dialog"; -import { Dialog } from "../dialog"; -import { Wizard, WizardStep } from "../wizard"; -import type { HelmRelease } from "../../../common/k8s-api/endpoints/helm-releases.api"; -import { getReleaseHistory, type IReleaseRevision } from "../../../common/k8s-api/endpoints/helm-releases.api"; -import type { SelectOption } from "../select"; -import { Select } from "../select"; -import { Notifications } from "../notifications"; -import orderBy from "lodash/orderBy"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import releaseRollbackDialogModelInjectable - from "./release-rollback-dialog-model/release-rollback-dialog-model.injectable"; -import type { ReleaseRollbackDialogModel } from "./release-rollback-dialog-model/release-rollback-dialog-model"; -import rollbackReleaseInjectable from "./rollback-release/rollback-release.injectable"; - -export interface ReleaseRollbackDialogProps extends DialogProps { -} - -interface Dependencies { - rollbackRelease: (releaseName: string, namespace: string, revisionNumber: number) => Promise; - model: ReleaseRollbackDialogModel; -} - -@observer -class NonInjectedReleaseRollbackDialog extends React.Component { - @observable isLoading = false; - @observable revision: IReleaseRevision; - @observable revisions = observable.array(); - - constructor(props: ReleaseRollbackDialogProps & Dependencies) { - super(props); - makeObservable(this); - } - - get release(): HelmRelease { - return this.props.model.release; - } - - onOpen = async () => { - this.isLoading = true; - let releases = await getReleaseHistory(this.release.getName(), this.release.getNs()); - - releases = orderBy(releases, "revision", "desc"); // sort - this.revisions.replace(releases); - this.revision = this.revisions[0]; - this.isLoading = false; - }; - - rollback = async () => { - const revisionNumber = this.revision.revision; - - try { - await this.props.rollbackRelease(this.release.getName(), this.release.getNs(), revisionNumber); - this.props.model.close(); - } catch (err) { - Notifications.error(err); - } - }; - - renderContent() { - const { revision, revisions } = this; - - if (!revision) { - return

No revisions to rollback.

; - } - - return ( -
- Revision - this.namespace = v.toLowerCase()} + value={namespace} + onChange={v => this.namespace = v.toLowerCase()} /> diff --git a/src/renderer/components/+namespaces/namespace-details.tsx b/src/renderer/components/+namespaces/namespace-details.tsx index 4d94e8990d..6114fc23e1 100644 --- a/src/renderer/components/+namespaces/namespace-details.tsx +++ b/src/renderer/components/+namespaces/namespace-details.tsx @@ -9,9 +9,8 @@ import React from "react"; import { computed, makeObservable, observable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { DrawerItem } from "../drawer"; -import type { Disposer } from "../../utils"; -import { cssNames } from "../../utils"; -import { getMetricsForNamespace, type IPodMetrics, Namespace } from "../../../common/k8s-api/endpoints"; +import { cssNames } from "../../utils"; +import { getMetricsForNamespace, type PodMetricData, Namespace } from "../../../common/k8s-api/endpoints"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { Link } from "react-router-dom"; import { Spinner } from "../spinner"; @@ -24,22 +23,21 @@ import { ClusterMetricsResourceType } from "../../../common/cluster-types"; import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { getDetailsUrl } from "../kube-detail-params"; import logger from "../../../common/logger"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; import { withInjectables } from "@ogre-tools/injectable-react"; import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable"; +import type { SubscribeStores } from "../../kube-watch-api/kube-watch-api"; export interface NamespaceDetailsProps extends KubeObjectDetailsProps { } interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer; + subscribeStores: SubscribeStores; } @observer class NonInjectedNamespaceDetails extends React.Component { - @observable metrics: IPodMetrics = null; + @observable metrics: PodMetricData | null = null; constructor(props: NamespaceDetailsProps & Dependencies) { super(props); @@ -96,7 +94,9 @@ class NonInjectedNamespaceDetails extends React.Component @@ -110,7 +110,7 @@ class NonInjectedNamespaceDetails extends React.Component {!this.quotas && resourceQuotaStore.isLoading && } {this.quotas.map(quota => { - return ( + return quota.selfLink && ( {quota.getName()} @@ -120,7 +120,7 @@ class NonInjectedNamespaceDetails extends React.Component {!this.limitranges && limitRangeStore.isLoading && } {this.limitranges.map(limitrange => { - return ( + return limitrange.selfLink && ( {limitrange.getName()} diff --git a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.ts b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.ts deleted file mode 100644 index dfdbd987ea..0000000000 --- a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { observable, makeObservable, action, untracked } from "mobx"; -import type { NamespaceStore } from "../namespace-store/namespace.store"; -import type { SelectOption } from "../../select"; -import { isMac } from "../../../../common/vars"; - -interface Dependencies { - namespaceStore: NamespaceStore; -} - -export class NamespaceSelectFilterModel { - constructor(private dependencies: Dependencies) { - makeObservable(this, { - menuIsOpen: observable, - closeMenu: action, - openMenu: action, - reset: action, - }); - } - - menuIsOpen = false; - - closeMenu = () => { - this.menuIsOpen = false; - }; - - openMenu = () => { - this.menuIsOpen = true; - }; - - get selectedNames() { - return untracked(() => this.dependencies.namespaceStore.selectedNames); - } - - isSelected = (namespace: string | string[]) => - this.dependencies.namespaceStore.hasContext(namespace); - - selectSingle = (namespace: string) => { - this.dependencies.namespaceStore.selectSingle(namespace); - }; - - selectAll = () => { - this.dependencies.namespaceStore.selectAll(); - }; - - onChange = ([{ value: namespace }]: SelectOption[]) => { - if (namespace) { - if (this.isMultiSelection) { - this.dependencies.namespaceStore.toggleSingle(namespace); - } else { - this.dependencies.namespaceStore.selectSingle(namespace); - } - } else { - this.dependencies.namespaceStore.selectAll(); - } - }; - - onClick = () => { - if (!this.menuIsOpen) { - this.openMenu(); - } else if (!this.isMultiSelection) { - this.closeMenu(); - } - }; - - private isMultiSelection = false; - - onKeyDown = (event: React.KeyboardEvent) => { - if (isSelectionKey(event)) { - this.isMultiSelection = true; - } - }; - - onKeyUp = (event: React.KeyboardEvent) => { - if (isSelectionKey(event)) { - this.isMultiSelection = false; - } - }; - - reset = () => { - this.isMultiSelection = false; - this.closeMenu(); - }; -} - -const isSelectionKey = (event: React.KeyboardEvent): boolean => { - if (isMac) { - return event.key === "Meta"; - } - - return event.key === "Control"; // windows or linux -}; diff --git a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx new file mode 100644 index 0000000000..76445a46df --- /dev/null +++ b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx @@ -0,0 +1,155 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import React from "react"; +import { observable, makeObservable, action, untracked, computed } from "mobx"; +import type { NamespaceStore } from "../namespace-store/namespace.store"; +import { isMac } from "../../../../common/vars"; +import type { ActionMeta } from "react-select"; +import { Icon } from "../../icon"; + +interface Dependencies { + readonly namespaceStore: NamespaceStore; +} + +export const selectAllNamespaces = Symbol("all-namespaces-selected"); + +export type SelectAllNamespaces = typeof selectAllNamespaces; + +export class NamespaceSelectFilterModel { + constructor(private readonly dependencies: Dependencies) { + makeObservable(this, { + menuIsOpen: observable, + closeMenu: action, + openMenu: action, + reset: action, + }); + } + + readonly options = computed(() => { + const baseOptions = this.dependencies.namespaceStore.items.map(ns => ns.getName()); + + baseOptions.sort(( + (left, right) => + +this.selectedNames.has(right) + - +this.selectedNames.has(left) + )); + + return [selectAllNamespaces, ...baseOptions] as const; + }); + + formatOptionLabel = (option: string | SelectAllNamespaces) => { + if (option === selectAllNamespaces) { + return "All Namespaces"; + } + + const isSelected = this.isSelected(option); + + return ( +
+ + {option} + {isSelected && ( + + )} +
+ ); + }; + + getOptionLabel = (option: string | SelectAllNamespaces) => { + if (option === selectAllNamespaces) { + return "All Namespaces"; + } + + return option; + }; + + menuIsOpen = false; + + closeMenu = () => { + this.menuIsOpen = false; + }; + + openMenu = () => { + this.menuIsOpen = true; + }; + + get selectedNames() { + return untracked(() => this.dependencies.namespaceStore.selectedNames); + } + + isSelected = (namespace: string | string[]) => + this.dependencies.namespaceStore.hasContext(namespace); + + selectSingle = (namespace: string) => { + this.dependencies.namespaceStore.selectSingle(namespace); + }; + + selectAll = () => { + this.dependencies.namespaceStore.selectAll(); + }; + + onChange = (namespace: unknown, action: ActionMeta) => { + switch (action.action) { + case "clear": + this.dependencies.namespaceStore.selectAll(); + break; + case "deselect-option": + if (typeof action.option === "string") { + this.dependencies.namespaceStore.toggleSingle(action.option); + } + break; + case "select-option": + if (action.option === selectAllNamespaces) { + this.dependencies.namespaceStore.selectAll(); + } else if (action.option) { + if (this.isMultiSelection) { + this.dependencies.namespaceStore.toggleSingle(action.option); + } else { + this.dependencies.namespaceStore.selectSingle(action.option); + } + } + break; + } + }; + + onClick = () => { + if (!this.menuIsOpen) { + this.openMenu(); + } else if (!this.isMultiSelection) { + this.closeMenu(); + } + }; + + private isMultiSelection = false; + + onKeyDown = (event: React.KeyboardEvent) => { + if (isSelectionKey(event)) { + this.isMultiSelection = true; + } + }; + + onKeyUp = (event: React.KeyboardEvent) => { + if (isSelectionKey(event)) { + this.isMultiSelection = false; + } + }; + + reset = () => { + this.isMultiSelection = false; + this.closeMenu(); + }; +} + +const isSelectionKey = (event: React.KeyboardEvent): boolean => { + if (isMac) { + return event.key === "Meta"; + } + + return event.key === "Control"; // windows or linux +}; diff --git a/src/renderer/components/+namespaces/namespace-select-filter.tsx b/src/renderer/components/+namespaces/namespace-select-filter.tsx index f1db8122d9..b641b222ce 100644 --- a/src/renderer/components/+namespaces/namespace-select-filter.tsx +++ b/src/renderer/components/+namespaces/namespace-select-filter.tsx @@ -7,77 +7,58 @@ import "./namespace-select-filter.scss"; import React from "react"; import { observer } from "mobx-react"; -import { components, type PlaceholderProps } from "react-select"; - -import { Icon } from "../icon"; -import { NamespaceSelect } from "./namespace-select"; +import type { PlaceholderProps } from "react-select"; +import { components } from "react-select"; import type { NamespaceStore } from "./namespace-store/namespace.store"; - -import type { SelectOption, SelectProps } from "../select"; +import { Select } from "../select"; import { withInjectables } from "@ogre-tools/injectable-react"; -import type { NamespaceSelectFilterModel } from "./namespace-select-filter-model/namespace-select-filter-model"; +import type { NamespaceSelectFilterModel, SelectAllNamespaces } from "./namespace-select-filter-model/namespace-select-filter-model"; import namespaceSelectFilterModelInjectable from "./namespace-select-filter-model/namespace-select-filter-model.injectable"; import namespaceStoreInjectable from "./namespace-store/namespace-store.injectable"; +interface NamespaceSelectFilterProps { + id: string; +} + interface Dependencies { model: NamespaceSelectFilterModel; } -const NonInjectedNamespaceSelectFilter = observer(({ model }: SelectProps & Dependencies) => ( -
- - +model.selectedNames.has(right.value) - - +model.selectedNames.has(left.value) - } - /> -
-)); +const NonInjectedNamespaceSelectFilter = observer(({ model, id }: Dependencies & NamespaceSelectFilterProps) => { + return ( +
+ + id={id} + isMulti={true} + isClearable={false} + menuIsOpen={model.menuIsOpen} + components={{ Placeholder }} + closeMenuOnSelect={false} + controlShouldRenderValue={false} + onChange={model.onChange} + onBlur={model.reset} + formatOptionLabel={model.formatOptionLabel} + getOptionLabel={model.getOptionLabel} + options={model.options.get()} + className="NamespaceSelect NamespaceSelectFilter" + menuClass="NamespaceSelectFilterMenu" + /> +
+ ); +}); - -const formatOptionLabelFor = - (model: NamespaceSelectFilterModel) => - ({ value: namespace, label }: SelectOption) => { - if (namespace) { - const isSelected = model.isSelected(namespace); - - return ( -
- - {namespace} - {isSelected && } -
- ); - } - - return label; - }; - -export const NamespaceSelectFilter = withInjectables(NonInjectedNamespaceSelectFilter, { +export const NamespaceSelectFilter = withInjectables(NonInjectedNamespaceSelectFilter, { getProps: (di, props) => ({ model: di.inject(namespaceSelectFilterModelInjectable), ...props, }), }); -export interface CustomPlaceholderProps extends PlaceholderProps {} +export interface CustomPlaceholderProps extends PlaceholderProps {} interface PlaceholderDependencies { namespaceStore: NamespaceStore; @@ -103,8 +84,7 @@ const NonInjectedPlaceholder = observer(({ namespaceStore, ...props }: CustomPla {getPlaceholder()} ); -}, -); +}); const Placeholder = withInjectables( NonInjectedPlaceholder, { getProps: (di, props) => ({ diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index d28758b0cb..e1a4f1b083 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -5,10 +5,10 @@ import "./namespace-select.scss"; -import React from "react"; -import { computed, makeObservable } from "mobx"; +import React, { useEffect, useState } from "react"; +import { computed } from "mobx"; import { observer } from "mobx-react"; -import type { SelectOption, SelectProps } from "../select"; +import type { SelectProps } from "../select"; import { Select } from "../select"; import { cssNames } from "../../utils"; import { Icon } from "../icon"; @@ -16,87 +16,69 @@ import type { NamespaceStore } from "./namespace-store/namespace.store"; import { withInjectables } from "@ogre-tools/injectable-react"; import namespaceStoreInjectable from "./namespace-store/namespace-store.injectable"; -export interface NamespaceSelectProps extends SelectProps { - showIcons?: boolean; - sort?: (a: SelectOption, b: SelectOption) => number; - showAllNamespacesOption?: boolean; // show "All namespaces" option on the top (default: false) - customizeOptions?(options: SelectOption[]): SelectOption[]; -} +export type NamespaceSelectSort = (left: string, right: string) => number; -const defaultProps: Partial = { - showIcons: true, -}; +export interface NamespaceSelectProps extends SelectProps { + showIcons?: boolean; + sort?: NamespaceSelectSort; + options?: undefined; +} interface Dependencies { namespaceStore: NamespaceStore; } -@observer -class NonInjectedNamespaceSelect extends React.Component { - static defaultProps = defaultProps as object; - - constructor(props: NamespaceSelectProps & Dependencies) { - super(props); - makeObservable(this); - } - - // No subscribe here because the subscribe is in (the cluster frame root component) - - @computed.struct get options(): SelectOption[] { - const { customizeOptions, showAllNamespacesOption, sort } = this.props; - let options: SelectOption[] = this.props.namespaceStore.items.map(ns => ({ value: ns.getName() })); - - if (sort) { - options.sort(sort); - } - - if (showAllNamespacesOption) { - options.unshift({ label: "All Namespaces", value: "" }); - } - - if (customizeOptions) { - options = customizeOptions(options); - } - - return options; - } - - formatOptionLabel = (option: SelectOption) => { - const { showIcons } = this.props; - const { value, label } = option; - - return label || ( - <> - {showIcons && } - {value} - - ); - }; - - render() { - const { className, showIcons, customizeOptions, components = {}, namespaceStore, ...selectProps } = this.props; - - return ( - + ); +}); + +const InjectedNamespaceSelect = withInjectables>(NonInjectedNamespaceSelect, { + getProps: (di, props) => ({ + ...props, + namespaceStore: di.inject(namespaceStoreInjectable), + }), +}); + +export function NamespaceSelect(props: NamespaceSelectProps): JSX.Element { + return ; +} diff --git a/src/renderer/components/+namespaces/namespace-store/namespace-store.injectable.ts b/src/renderer/components/+namespaces/namespace-store/namespace-store.injectable.ts index 219094be1e..636bb58993 100644 --- a/src/renderer/components/+namespaces/namespace-store/namespace-store.injectable.ts +++ b/src/renderer/components/+namespaces/namespace-store/namespace-store.injectable.ts @@ -6,6 +6,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import { NamespaceStore } from "./namespace.store"; import apiManagerInjectable from "../../kube-object-menu/dependencies/api-manager.injectable"; import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; +import { namespacesApi } from "../../../../common/k8s-api/endpoints"; const namespaceStoreInjectable = getInjectable({ id: "namespace-store", @@ -20,7 +21,7 @@ const namespaceStoreInjectable = getInjectable({ const namespaceStore = new NamespaceStore({ storage, - }); + }, namespacesApi); const apiManager = di.inject(apiManagerInjectable); diff --git a/src/renderer/components/+namespaces/namespace-store/namespace.store.ts b/src/renderer/components/+namespaces/namespace-store/namespace.store.ts index 3c02d880c1..abf89979a2 100644 --- a/src/renderer/components/+namespaces/namespace-store/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace-store/namespace.store.ts @@ -9,17 +9,16 @@ import type { StorageHelper } from "../../../utils"; import { autoBind, noop, toggle } from "../../../utils"; import type { KubeObjectStoreLoadingParams } from "../../../../common/k8s-api/kube-object.store"; import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; -import { Namespace, namespacesApi } from "../../../../common/k8s-api/endpoints/namespaces.api"; +import type { NamespaceApi } from "../../../../common/k8s-api/endpoints/namespaces.api"; +import { Namespace } from "../../../../common/k8s-api/endpoints/namespaces.api"; interface Dependencies { storage: StorageHelper; } -export class NamespaceStore extends KubeObjectStore { - api = namespacesApi; - - constructor(private dependencies: Dependencies) { - super(); +export class NamespaceStore extends KubeObjectStore { + constructor(protected readonly dependencies: Dependencies, api: NamespaceApi) { + super(api); makeObservable(this); autoBind(this); @@ -115,7 +114,7 @@ export class NamespaceStore extends KubeObjectStore { * if user has given static list of namespaces let's not start watches * because watch adds stuff that's not wanted or will just fail */ - if (this.context?.cluster.accessibleNamespaces.length > 0) { + if (!this.context?.cluster?.accessibleNamespaces.length) { return noop; } @@ -146,7 +145,7 @@ export class NamespaceStore extends KubeObjectStore { clearSelected(namespaces?: string | string[]) { if (namespaces) { const resettingNamespaces = [namespaces].flat(); - const newNamespaces = this.dependencies.storage.get().filter(ns => !resettingNamespaces.includes(ns)); + const newNamespaces = this.dependencies.storage.get()?.filter(ns => !resettingNamespaces.includes(ns)); this.dependencies.storage.set(newNamespaces); } else { diff --git a/src/renderer/components/+namespaces/route.tsx b/src/renderer/components/+namespaces/route.tsx index 00956306ef..ba1103c5d5 100644 --- a/src/renderer/components/+namespaces/route.tsx +++ b/src/renderer/components/+namespaces/route.tsx @@ -6,7 +6,7 @@ import "./namespaces.scss"; import React from "react"; -import { NamespaceStatus } from "../../../common/k8s-api/endpoints"; +import { NamespaceStatusKind } from "../../../common/k8s-api/endpoints"; import { AddNamespaceDialog } from "./add-namespace-dialog"; import { TabLayout } from "../layout/tab-layout-2"; import { Badge } from "../badge"; @@ -58,7 +58,13 @@ export const NonInjectedNamespacesRoute = ({ namespaceStore, openAddNamespaceDia renderTableContents={namespace => [ namespace.getName(), , - namespace.getLabels().map(label => ), + namespace.getLabels().map(label => ( + + )), , { title: namespace.getStatus(), className: namespace.getStatus().toLowerCase() }, ]} @@ -67,7 +73,7 @@ export const NonInjectedNamespacesRoute = ({ namespaceStore, openAddNamespaceDia onAdd: openAddNamespaceDialog, }} customizeTableRowProps={item => ({ - disabled: item.getStatus() === NamespaceStatus.TERMINATING, + disabled: item.getStatus() === NamespaceStatusKind.TERMINATING, })} /> diff --git a/src/renderer/components/+network-endpoints/endpoint-details.tsx b/src/renderer/components/+network-endpoints/endpoint-details.tsx index 13a3a99ff0..3821c68047 100644 --- a/src/renderer/components/+network-endpoints/endpoint-details.tsx +++ b/src/renderer/components/+network-endpoints/endpoint-details.tsx @@ -9,16 +9,16 @@ import React from "react"; import { observer } from "mobx-react"; import { DrawerTitle } from "../drawer"; import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { Endpoint } from "../../../common/k8s-api/endpoints"; +import { Endpoints } from "../../../common/k8s-api/endpoints"; import { KubeObjectMeta } from "../kube-object-meta"; import { EndpointSubsetList } from "./endpoint-subset-list"; import logger from "../../../common/logger"; -export interface EndpointDetailsProps extends KubeObjectDetailsProps { +export interface EndpointsDetailsProps extends KubeObjectDetailsProps { } @observer -export class EndpointDetails extends React.Component { +export class EndpointsDetails extends React.Component { render() { const { object: endpoint } = this.props; @@ -26,7 +26,7 @@ export class EndpointDetails extends React.Component { return null; } - if (!(endpoint instanceof Endpoint)) { + if (!(endpoint instanceof Endpoints)) { logger.error("[EndpointDetails]: passed object that is not an instanceof Endpoint", endpoint); return null; @@ -38,7 +38,11 @@ export class EndpointDetails extends React.Component { Subsets { endpoint.getEndpointSubsets().map((subset) => ( - + )) }
diff --git a/src/renderer/components/+network-endpoints/endpoint-subset-list.tsx b/src/renderer/components/+network-endpoints/endpoint-subset-list.tsx index ec27f77134..44fbfe681a 100644 --- a/src/renderer/components/+network-endpoints/endpoint-subset-list.tsx +++ b/src/renderer/components/+network-endpoints/endpoint-subset-list.tsx @@ -7,7 +7,7 @@ import "./endpoint-subset-list.scss"; import React from "react"; import { observer } from "mobx-react"; -import type { EndpointSubset, Endpoint, EndpointAddress } from "../../../common/k8s-api/endpoints"; +import type { EndpointSubset, Endpoints, EndpointAddress } from "../../../common/k8s-api/endpoints"; import { Table, TableCell, TableHead, TableRow } from "../table"; import { apiManager } from "../../../common/k8s-api/api-manager"; import { Link } from "react-router-dom"; @@ -15,8 +15,8 @@ import { getDetailsUrl } from "../kube-detail-params"; import { autoBind } from "../../../common/utils"; export interface EndpointSubsetListProps { - subset: EndpointSubset; - endpoint: Endpoint; + subset: Required; + endpoint: Endpoints; } @observer @@ -27,15 +27,13 @@ export class EndpointSubsetList extends React.Component } getAddressTableRow(ip: string) { - const { subset } = this.props; - const address = subset.getAddresses().find(address => address.getId() == ip); + const address = this.props.subset.addresses.find(address => address.ip == ip); return this.renderAddressTableRow(address); } getNotReadyAddressTableRow(ip: string) { - const { subset } = this.props; - const address = subset.getNotReadyAddresses().find(address => address.getId() == ip); + const address = this.props.subset.notReadyAddresses.find(address => address.ip == ip); return this.renderAddressTableRow(address); } @@ -58,26 +56,30 @@ export class EndpointSubsetList extends React.Component Target { - !virtual && addresses.map(address => this.getAddressTableRow(address.getId())) + !virtual && addresses.map(address => this.getAddressTableRow(address.ip)) }
); } - renderAddressTableRow(address: EndpointAddress) { + renderAddressTableRow(address: EndpointAddress | undefined) { + if (!address) { + return undefined; + } + const { endpoint } = this.props; return ( {address.ip} {address.hostname} { address.targetRef && ( - + {address.targetRef.name} )} @@ -87,9 +89,7 @@ export class EndpointSubsetList extends React.Component } render() { - const { subset } = this.props; - const addresses = subset.getAddresses(); - const notReadyAddresses = subset.getNotReadyAddresses(); + const { subset: { addresses, ports, notReadyAddresses }} = this.props; const addressesVirtual = addresses.length > 100; const notReadyAddressesVirtual = notReadyAddresses.length > 100; @@ -111,7 +111,7 @@ export class EndpointSubsetList extends React.Component Hostname Target - { !addressesVirtual && addresses.map(address => this.getAddressTableRow(address.getId())) } + { !addressesVirtual && addresses.map(address => this.getAddressTableRow(address.ip)) } )} @@ -132,7 +132,7 @@ export class EndpointSubsetList extends React.Component Hostname Target - { !notReadyAddressesVirtual && notReadyAddresses.map(address => this.getNotReadyAddressTableRow(address.getId())) } + { !notReadyAddressesVirtual && notReadyAddresses.map(address => this.getNotReadyAddressTableRow(address.ip)) } )} @@ -150,18 +150,16 @@ export class EndpointSubsetList extends React.Component Protocol { - subset.ports.map(port => { - return ( - - {port.port} - {port.name} - {port.protocol} - - ); - }) + ports.map(port => ( + + {port.port} + {port.name} + {port.protocol} + + )) } diff --git a/src/renderer/components/+network-endpoints/endpoints.store.ts b/src/renderer/components/+network-endpoints/endpoints.store.ts index 3b80f63465..6b9d3b1594 100644 --- a/src/renderer/components/+network-endpoints/endpoints.store.ts +++ b/src/renderer/components/+network-endpoints/endpoints.store.ts @@ -4,13 +4,12 @@ */ import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { Endpoint } from "../../../common/k8s-api/endpoints/endpoint.api"; -import { endpointApi } from "../../../common/k8s-api/endpoints/endpoint.api"; +import type { Endpoints, EndpointsApi, EndpointsData } from "../../../common/k8s-api/endpoints/endpoint.api"; +import { endpointsApi } from "../../../common/k8s-api/endpoints/endpoint.api"; import { apiManager } from "../../../common/k8s-api/api-manager"; -export class EndpointStore extends KubeObjectStore { - api = endpointApi; +export class EndpointStore extends KubeObjectStore { } -export const endpointStore = new EndpointStore(); +export const endpointStore = new EndpointStore(endpointsApi); apiManager.registerStore(endpointStore); diff --git a/src/renderer/components/+network-endpoints/endpoints.tsx b/src/renderer/components/+network-endpoints/endpoints.tsx index b1411f9681..29344c0962 100644 --- a/src/renderer/components/+network-endpoints/endpoints.tsx +++ b/src/renderer/components/+network-endpoints/endpoints.tsx @@ -28,7 +28,8 @@ export class Endpoints extends React.Component { endpoint.getName(), [columnId.namespace]: endpoint => endpoint.getNs(), diff --git a/src/renderer/components/+network-ingresses/ingress-charts.tsx b/src/renderer/components/+network-ingresses/ingress-charts.tsx index c82493c652..cc8d02ee76 100644 --- a/src/renderer/components/+network-ingresses/ingress-charts.tsx +++ b/src/renderer/components/+network-ingresses/ingress-charts.tsx @@ -5,23 +5,20 @@ import React, { useContext } from "react"; import { observer } from "mobx-react"; -import type { ChartOptions, ChartPoint } from "chart.js"; -import type { IIngressMetrics, Ingress } from "../../../common/k8s-api/endpoints"; -import { BarChart, memoryOptions } from "../chart"; +import type { ChartDataSets } from "../chart"; +import { BarChart } from "../chart"; import { normalizeMetrics, isMetricsEmpty } from "../../../common/k8s-api/endpoints/metrics.api"; import { NoMetrics } from "../resource-metrics/no-metrics"; -import type { IResourceMetricsValue } from "../resource-metrics"; import { ResourceMetricsContext } from "../resource-metrics"; - -type IContext = IResourceMetricsValue; +import { type MetricsTab, metricTabOptions } from "../chart/options"; export const IngressCharts = observer(() => { - const { params: { metrics }, tabId, object } = useContext(ResourceMetricsContext); - const id = object.getId(); + const { metrics, tab, object } = useContext(ResourceMetricsContext) ?? {}; - if (!metrics) return null; + if (!metrics || !object || !tab) return null; if (isMetricsEmpty(metrics)) return ; + const id = object.getId(); const values = Object.values(metrics) .map(normalizeMetrics) .map(({ data }) => data.result[0].values); @@ -32,9 +29,8 @@ export const IngressCharts = observer(() => { responseDurationSeconds, ] = values; - const datasets = [ - // Network - [ + const datasets: Partial> = { + Network: [ { id: `${id}-bytesSentSuccess`, label: `Bytes sent, status 2xx`, @@ -50,8 +46,7 @@ export const IngressCharts = observer(() => { data: bytesSentFailure.map(([x, y]) => ({ x, y })), }, ], - // Duration - [ + Duration: [ { id: `${id}-requestDurationSeconds`, label: `Request`, @@ -67,36 +62,13 @@ export const IngressCharts = observer(() => { data: responseDurationSeconds.map(([x, y]) => ({ x, y })), }, ], - ]; - - const durationOptions: ChartOptions = { - scales: { - yAxes: [{ - ticks: { - callback: value => value, - }, - }], - }, - tooltips: { - callbacks: { - label: ({ datasetIndex, index }, { datasets }) => { - const { label, data } = datasets[datasetIndex]; - const value = data[index] as ChartPoint; - const chartTooltipSec = `sec`; - - return `${label}: ${parseFloat(value.y as string).toFixed(3)} ${chartTooltipSec}`; - }, - }, - }, }; - const options = [memoryOptions, durationOptions]; - return ( ); }); diff --git a/src/renderer/components/+network-ingresses/ingress-details.tsx b/src/renderer/components/+network-ingresses/ingress-details.tsx index bb088be389..b82571c751 100644 --- a/src/renderer/components/+network-ingresses/ingress-details.tsx +++ b/src/renderer/components/+network-ingresses/ingress-details.tsx @@ -16,7 +16,7 @@ import { ResourceMetrics } from "../resource-metrics"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { IngressCharts } from "./ingress-charts"; import { KubeObjectMeta } from "../kube-object-meta"; -import { computeRuleDeclarations, getMetricsForIngress, type IIngressMetrics } from "../../../common/k8s-api/endpoints/ingress.api"; +import { computeRuleDeclarations, getMetricsForIngress, type IngressMetricData } from "../../../common/k8s-api/endpoints/ingress.api"; import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { ClusterMetricsResourceType } from "../../../common/cluster-types"; import logger from "../../../common/logger"; @@ -26,7 +26,7 @@ export interface IngressDetailsProps extends KubeObjectDetailsProps { @observer export class IngressDetails extends React.Component { - @observable metrics: IIngressMetrics = null; + @observable metrics: IngressMetricData | null = null; constructor(props: IngressDetailsProps) { super(props); @@ -53,7 +53,7 @@ export class IngressDetails extends React.Component {
{rule.host && (
- <>Host: {rule.host} + {`Host: ${rule.host}`}
)} {rule.http && ( @@ -72,7 +72,11 @@ export class IngressDetails extends React.Component { { displayAsLink ? ( - + {url} ) @@ -125,22 +129,20 @@ export class IngressDetails extends React.Component { return null; } - const { spec, status } = ingress; - const ingressPoints = status?.loadBalancer?.ingress; - const { metrics } = this; - const metricTabs = [ - "Network", - "Duration", - ]; const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Ingress); - const { serviceName, servicePort } = ingress.getServiceNamePort(); + const port = ingress.getServiceNamePort(); return (
{!isMetricHidden && ( @@ -149,21 +151,21 @@ export class IngressDetails extends React.Component { {ingress.getPorts()} - {spec.tls && - - {spec.tls.map((tls, index) =>

{tls.secretName}

)} -
- } - {serviceName && servicePort && - - {serviceName}:{servicePort} - - } + {ingress.spec.tls && ( + + {ingress.spec.tls.map((tls, index) =>

{tls.secretName}

)} +
+ )} + {port && ( + + {`${port.serviceName}:${port.servicePort}`} + + )} Rules {this.renderPaths(ingress)} Load-Balancer Ingress Points - {this.renderIngressPoints(ingressPoints)} + {this.renderIngressPoints(ingress.status?.loadBalancer?.ingress ?? [])}
); } diff --git a/src/renderer/components/+network-ingresses/ingress.store.ts b/src/renderer/components/+network-ingresses/ingress.store.ts index 4cb3ef7d1a..fb89549deb 100644 --- a/src/renderer/components/+network-ingresses/ingress.store.ts +++ b/src/renderer/components/+network-ingresses/ingress.store.ts @@ -3,13 +3,18 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { apiManager } from "../../../common/k8s-api/api-manager"; -import type { Ingress } from "../../../common/k8s-api/endpoints"; +import type { Ingress, IngressApi } from "../../../common/k8s-api/endpoints"; import { ingressApi } from "../../../common/k8s-api/endpoints"; import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import { isClusterPageContext } from "../../utils"; -export class IngressStore extends KubeObjectStore { - api = ingressApi; +export class IngressStore extends KubeObjectStore { } -export const ingressStore = new IngressStore(); -apiManager.registerStore(ingressStore); +export const ingressStore = isClusterPageContext() + ? new IngressStore(ingressApi) + : undefined as never; + +if (isClusterPageContext()) { + apiManager.registerStore(ingressStore); +} diff --git a/src/renderer/components/+network-ingresses/ingresses.tsx b/src/renderer/components/+network-ingresses/ingresses.tsx index a585025324..882bb3df15 100644 --- a/src/renderer/components/+network-ingresses/ingresses.tsx +++ b/src/renderer/components/+network-ingresses/ingresses.tsx @@ -30,7 +30,8 @@ export class Ingresses extends React.Component { ingress.getName(), [columnId.namespace]: ingress => ingress.getNs(), @@ -65,10 +66,15 @@ export class Ingresses extends React.Component { onClick={e => e.stopPropagation()} > {decl.url} - ⇢ {decl.service} + + {` ⇢ ${decl.service}`} + + ) + : ( + + {`${decl.url} ⇢ ${decl.service}`} ) - : {decl.url} ⇢ {decl.service} )), , ]} diff --git a/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx b/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx index 294fc323ae..b6ac2766de 100644 --- a/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx +++ b/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx @@ -5,7 +5,6 @@ import React from "react"; import { findByTestId, findByText, render } from "@testing-library/react"; -import type { NetworkPolicySpec } from "../../../../common/k8s-api/endpoints"; import { NetworkPolicy } from "../../../../common/k8s-api/endpoints"; import { NetworkPolicyDetails } from "../network-policy-details"; @@ -13,26 +12,35 @@ jest.mock("../../kube-object-meta"); describe("NetworkPolicyDetails", () => { it("should render w/o errors", () => { - const policy = new NetworkPolicy({ metadata: {} as any, spec: {}} as any); + const policy = new NetworkPolicy({ + metadata: {} as never, + spec: {} as never, + apiVersion: "networking.k8s.io/v1", + kind: "NetworkPolicy", + }); const { container } = render(); expect(container).toBeInstanceOf(HTMLElement); }); it("should render egress nodeSelector", async () => { - const spec: NetworkPolicySpec = { - egress: [{ - to: [{ - namespaceSelector: { - matchLabels: { - foo: "bar", + const policy = new NetworkPolicy({ + metadata: {} as never, + spec: { + egress: [{ + to: [{ + namespaceSelector: { + matchLabels: { + foo: "bar", + }, }, - }, + }], }], - }], - podSelector: {}, - }; - const policy = new NetworkPolicy({ metadata: {} as any, spec } as any); + podSelector: {}, + }, + apiVersion: "networking.k8s.io/v1", + kind: "NetworkPolicy", + }); const { container } = render(); expect(await findByTestId(container, "egress-0")).toBeInstanceOf(HTMLElement); @@ -40,15 +48,19 @@ describe("NetworkPolicyDetails", () => { }); it("should not crash if egress nodeSelector doesn't have matchLabels", async () => { - const spec: NetworkPolicySpec = { - egress: [{ - to: [{ - namespaceSelector: {}, + const policy = new NetworkPolicy({ + metadata: {} as never, + spec: { + egress: [{ + to: [{ + namespaceSelector: {}, + }], }], - }], - podSelector: {}, - }; - const policy = new NetworkPolicy({ metadata: {} as any, spec } as any); + podSelector: {}, + }, + apiVersion: "networking.k8s.io/v1", + kind: "NetworkPolicy", + }); const { container } = render(); expect(container).toBeInstanceOf(HTMLElement); diff --git a/src/renderer/components/+network-policies/network-policy-details.tsx b/src/renderer/components/+network-policies/network-policy-details.tsx index f04cdac495..264d626ba9 100644 --- a/src/renderer/components/+network-policies/network-policy-details.tsx +++ b/src/renderer/components/+network-policies/network-policy-details.tsx @@ -53,7 +53,11 @@ export class NetworkPolicyDetails extends React.Component
  • {key}: {value}
  • ); + .map(([key, value]) => ( +
  • + {`${key}: ${value}`} +
  • + )); } renderMatchExpressions(matchExpressions: LabelMatchExpression[] | undefined) { @@ -65,12 +69,16 @@ export class NetworkPolicyDetails extends React.Component{expr.key} ({expr.operator}); + return ( +
  • + {`${expr.key} (${expr.operator})`} +
  • + ); case "In": case "NotIn": return (
  • - {expr.key}({expr.operator}) + {`${expr.key} (${expr.operator})`}
      {expr.values.map((value, index) =>
    • {value}
    • )}
    @@ -133,7 +141,10 @@ export class NetworkPolicyDetails extends React.Component {ports.map(({ protocol = "TCP", port = "", endPort }, index) => (
  • - {protocol}:{port}{typeof endPort === "number" && `:${endPort}`} + {protocol} + : + {port} + {typeof endPort === "number" && `:${endPort}`}
  • ))} diff --git a/src/renderer/components/+network-policies/network-policy.store.ts b/src/renderer/components/+network-policies/network-policy.store.ts index 4b5908afd7..b0c2aba324 100644 --- a/src/renderer/components/+network-policies/network-policy.store.ts +++ b/src/renderer/components/+network-policies/network-policy.store.ts @@ -4,13 +4,12 @@ */ import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { NetworkPolicy } from "../../../common/k8s-api/endpoints/network-policy.api"; +import type { NetworkPolicy, NetworkPolicyApi } from "../../../common/k8s-api/endpoints/network-policy.api"; import { networkPolicyApi } from "../../../common/k8s-api/endpoints/network-policy.api"; import { apiManager } from "../../../common/k8s-api/api-manager"; -export class NetworkPolicyStore extends KubeObjectStore { - api = networkPolicyApi; +export class NetworkPolicyStore extends KubeObjectStore { } -export const networkPolicyStore = new NetworkPolicyStore(); +export const networkPolicyStore = new NetworkPolicyStore(networkPolicyApi); apiManager.registerStore(networkPolicyStore); diff --git a/src/renderer/components/+network-port-forwards/port-forward-details.tsx b/src/renderer/components/+network-port-forwards/port-forward-details.tsx index 4ab2db0264..dbc3156660 100644 --- a/src/renderer/components/+network-port-forwards/port-forward-details.tsx +++ b/src/renderer/components/+network-port-forwards/port-forward-details.tsx @@ -11,7 +11,7 @@ import type { PortForwardItem } from "../../port-forward"; import { portForwardAddress } from "../../port-forward"; import { Drawer, DrawerItem } from "../drawer"; import { cssNames } from "../../utils"; -import { podsApi, serviceApi } from "../../../common/k8s-api/endpoints"; +import { podApi, serviceApi } from "../../../common/k8s-api/endpoints"; import { getDetailsUrl } from "../kube-detail-params"; import { PortForwardMenu } from "./port-forward-menu"; @@ -27,7 +27,7 @@ export class PortForwardDetails extends React.Component const name = portForward.getName(); const api = { "service": serviceApi, - "pod": podsApi, + "pod": podApi, }[portForward.kind]; if (!api) { @@ -77,7 +77,13 @@ export class PortForwardDetails extends React.Component render() { const { hideDetails, portForward } = this.props; - const toolbar = ; + const toolbar = ( + + ); return ( this.portForwardStore.stop(portForward)}> - + Stop ); @@ -72,7 +76,11 @@ class NonInjectedPortForwardMenu - + Start ); @@ -85,14 +93,22 @@ class NonInjectedPortForwardMenu - { portForward.status === "Active" && + { portForward.status === "Active" && ( openPortForward(portForward)}> - + Open - } + )} this.props.openPortForwardDialog(portForward)}> - + Edit {this.renderStartStopMenuItem()} diff --git a/src/renderer/components/+network-port-forwards/port-forwards.tsx b/src/renderer/components/+network-port-forwards/port-forwards.tsx index 489af01d62..fc6b4c4735 100644 --- a/src/renderer/components/+network-port-forwards/port-forwards.tsx +++ b/src/renderer/components/+network-port-forwards/port-forwards.tsx @@ -53,7 +53,13 @@ class NonInjectedPortForwards extends React.Component { @computed get selectedPortForward() { - return this.props.portForwardStore.getById(this.props.forwardport.get()); + const forwardport = this.props.forwardport.get(); + + if (!forwardport) { + return undefined; + } + + return this.props.portForwardStore.getById(forwardport); } onDetails = (item: PortForwardItem) => { @@ -79,7 +85,11 @@ class NonInjectedPortForwards extends React.Component { return (
    - <>Stop forwarding from {forwardPorts}? + <> + {"Stop forwarding from "} + {forwardPorts} + ? +
    ); } diff --git a/src/renderer/components/+network-services/service-details-endpoint.tsx b/src/renderer/components/+network-services/service-details-endpoint.tsx index 936bec2302..b35c62fc72 100644 --- a/src/renderer/components/+network-services/service-details-endpoint.tsx +++ b/src/renderer/components/+network-services/service-details-endpoint.tsx @@ -3,7 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { KubeObject } from "../../../common/k8s-api/kube-object"; import { observer } from "mobx-react"; import React from "react"; import { Table, TableHead, TableCell, TableRow } from "../table"; @@ -11,21 +10,29 @@ import { prevDefault } from "../../utils"; import { endpointStore } from "../+network-endpoints/endpoints.store"; import { Spinner } from "../spinner"; import { showDetails } from "../kube-detail-params"; +import logger from "../../../common/logger"; +import { Endpoints } from "../../../common/k8s-api/endpoints"; export interface ServiceDetailsEndpointProps { - endpoint: KubeObject; + endpoints: Endpoints; } @observer export class ServiceDetailsEndpoint extends React.Component { render() { - const { endpoint } = this.props; + const { endpoints } = this.props; - if (!endpoint && !endpointStore.isLoaded) return ( + if (!endpoints && !endpointStore.isLoaded) return (
    ); - if (!endpoint) { + if (!endpoints) { + return null; + } + + if (!(endpoints instanceof Endpoints)) { + logger.error("[ServiceDetailsEndpoint]: passed object that is not an instanceof Endpoints", endpoints); + return null; } @@ -42,12 +49,12 @@ export class ServiceDetailsEndpoint extends React.ComponentEndpoints showDetails(endpoint.selfLink, false))} + onClick={prevDefault(() => showDetails(endpoints.selfLink, false))} > - {endpoint.getName()} - { endpoint.toString()} + {endpoints.getName()} + { endpoints.toString()}
    diff --git a/src/renderer/components/+network-services/service-details.tsx b/src/renderer/components/+network-services/service-details.tsx index 9bfa65bbbc..138a0a98fc 100644 --- a/src/renderer/components/+network-services/service-details.tsx +++ b/src/renderer/components/+network-services/service-details.tsx @@ -17,20 +17,16 @@ import { endpointStore } from "../+network-endpoints/endpoints.store"; import { ServiceDetailsEndpoint } from "./service-details-endpoint"; import type { PortForwardStore } from "../../port-forward"; import logger from "../../../common/logger"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import type { Disposer } from "../../../common/utils"; import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable - from "../../kube-watch-api/kube-watch-api.injectable"; +import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable"; import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable"; -import type { KubeWatchSubscribeStoreOptions } from "../../kube-watch-api/kube-watch-api"; +import type { SubscribeStores } from "../../kube-watch-api/kube-watch-api"; export interface ServiceDetailsProps extends KubeObjectDetailsProps { } interface Dependencies { - subscribeStores: (stores: KubeObjectStore[], options: KubeWatchSubscribeStoreOptions) => Disposer; + subscribeStores: SubscribeStores; portForwardStore: PortForwardStore; } @@ -63,7 +59,7 @@ class NonInjectedServiceDetails extends React.Component -
    diff --git a/src/renderer/components/cluster-settings/components/cluster-metrics-setting.tsx b/src/renderer/components/cluster-settings/components/cluster-metrics-setting.tsx index 58c6e57388..c47dd9e579 100644 --- a/src/renderer/components/cluster-settings/components/cluster-metrics-setting.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-metrics-setting.tsx @@ -5,7 +5,6 @@ import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; -import type { SelectOption } from "../../select/select"; import { Select } from "../../select/select"; import { Icon } from "../../icon/icon"; import { Button } from "../../button/button"; @@ -41,8 +40,8 @@ export class ClusterMetricsSetting extends React.Component[]) => { - for (const { value } of values) { + onChangeSelect = (values: readonly ClusterMetricsResourceType[]) => { + for (const value of values) { if (this.hiddenMetrics.has(value)) { this.hiddenMetrics.delete(value); } else { @@ -64,10 +63,16 @@ export class ClusterMetricsSetting extends React.Component) => ( + formatOptionLabel = (resource: ClusterMetricsResourceType) => (
    {resource} - {this.hiddenMetrics.has(resource) && } + {this.hiddenMetrics.has(resource) && ( + + )}
    ); diff --git a/src/renderer/components/cluster-settings/components/cluster-prometheus-setting.tsx b/src/renderer/components/cluster-settings/components/cluster-prometheus-setting.tsx index 8675456fd8..6b512a12a7 100644 --- a/src/renderer/components/cluster-settings/components/cluster-prometheus-setting.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-prometheus-setting.tsx @@ -7,7 +7,6 @@ import React from "react"; import { observer, disposeOnUnmount } from "mobx-react"; import type { Cluster } from "../../../../common/cluster/cluster"; import { SubTitle } from "../../layout/sub-title"; -import type { SelectOption } from "../../select"; import { Select } from "../../select"; import { Input } from "../../input"; import { observable, computed, autorun, makeObservable } from "mobx"; @@ -15,22 +14,27 @@ import { productName } from "../../../../common/vars"; import type { MetricProviderInfo } from "../../../../common/k8s-api/endpoints/metrics.api"; import { metricsApi } from "../../../../common/k8s-api/endpoints/metrics.api"; import { Spinner } from "../../spinner"; +import type { GroupBase } from "react-select"; export interface ClusterPrometheusSettingProps { cluster: Cluster; } +const autoDetectPrometheus = Symbol("auto-detect-prometheus"); + +type ProviderOption = typeof autoDetectPrometheus | string; + @observer export class ClusterPrometheusSetting extends React.Component { @observable path = ""; - @observable provider = ""; + @observable provider: ProviderOption = autoDetectPrometheus; @observable loading = true; - @observable loadedOptions: MetricProviderInfo[] = []; + loadedOptions = observable.map(); - @computed get options(): SelectOption[] { + @computed get options(): ProviderOption[] { return [ - { value: "", label: "Auto detect" }, - ...this.loadedOptions.map(({ name, id }) => ({ value: id, label: name })), + autoDetectPrometheus, + ...this.loadedOptions.keys(), ]; } @@ -40,11 +44,11 @@ export class ClusterPrometheusSetting extends React.Component opt.id === this.provider) - ?.isConfigurable, - ); + if (this.provider === autoDetectPrometheus) { + return false; + } + + return this.loadedOptions.get(this.provider)?.isConfigurable ?? false; } componentDidMount() { @@ -72,22 +76,19 @@ export class ClusterPrometheusSetting extends React.Component { this.loading = false; - - if (values) { - this.loadedOptions = values; - } + this.loadedOptions.replace(values.map(provider => [provider.id, provider])); }); } parsePrometheusPath = () => { if (!this.provider || !this.path) { - return null; + return undefined; } const parsed = this.path.split(/\/|:/, 3); const apiPrefix = this.path.substring(parsed.join("/").length); if (!parsed[0] || !parsed[1] || !parsed[2]) { - return null; + return undefined; } return { @@ -99,9 +100,9 @@ export class ClusterPrometheusSetting extends React.Component { - this.props.cluster.preferences.prometheusProvider = this.provider ? - { type: this.provider } : - null; + this.props.cluster.preferences.prometheusProvider = typeof this.provider === "string" + ? { type: this.provider } + : undefined; }; onSavePath = () => { @@ -112,28 +113,30 @@ export class ClusterPrometheusSetting extends React.Component
    - + { this.loading ? - : <> - - An address to an existing Prometheus installation{" "} - ({"/:"}). {productName} tries to auto-detect address if left empty. + {`An address to an existing Prometheus installation (/:). ${productName} tries to auto-detect address if left empty.`}
    diff --git a/src/renderer/components/cluster-settings/components/cluster-show-metrics.tsx b/src/renderer/components/cluster-settings/components/cluster-show-metrics.tsx index 29bfcd2e21..f813ab6ab4 100644 --- a/src/renderer/components/cluster-settings/components/cluster-show-metrics.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-show-metrics.tsx @@ -53,7 +53,11 @@ export class ShowMetricsSetting extends React.Component const tooltipId = `${name}`; return ( - + {name} { const clusterIsActive = this.props.matchedClusterId.get() !== undefined; if (clusterIsActive) { - broadcastMessage(`command-palette:${catalogEntityRegistry.activeEntity.getId()}:open`); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + broadcastMessage(`command-palette:${catalogEntityRegistry.activeEntity!.getId()}:open`); } else { commandOverlay.open(); } diff --git a/src/renderer/components/command-palette/command-dialog.tsx b/src/renderer/components/command-palette/command-dialog.tsx index 5a7f64802e..746f4b6956 100644 --- a/src/renderer/components/command-palette/command-dialog.tsx +++ b/src/renderer/components/command-palette/command-dialog.tsx @@ -15,21 +15,24 @@ import { broadcastMessage } from "../../../common/ipc"; import { IpcRendererNavigationEvents } from "../../navigation/events"; import type { RegisteredCommand } from "./registered-commands/commands"; import { iter } from "../../utils"; -import { orderBy } from "lodash"; import { withInjectables } from "@ogre-tools/injectable-react"; import registeredCommandsInjectable from "./registered-commands/registered-commands.injectable"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; interface Dependencies { commands: IComputedValue>; - activeEntity?: CatalogEntity; + activeEntity: CatalogEntity; closeCommandOverlay: () => void; } const NonInjectedCommandDialog = observer(({ commands, activeEntity, closeCommandOverlay }: Dependencies) => { const [searchValue, setSearchValue] = useState(""); - const executeAction = (commandId: string) => { + const executeAction = (commandId: string | null) => { + if (!commandId) { + return; + } + const command = commands.get().get(commandId); if (!command) { @@ -58,36 +61,37 @@ const NonInjectedCommandDialog = observer(({ commands, activeEntity, closeComman const context = { entity: activeEntity, }; - const activeCommands = iter.filter(commands.get().values(), command => { - try { - return command.isActive(context); - } catch (error) { - console.error(`[COMMAND-DIALOG]: isActive for ${command.id} threw an error, defaulting to false`, error); - } - return false; - }); - const options = Array.from(activeCommands, ({ id, title }) => ({ - value: id, - label: typeof title === "function" - ? title(context) - : title, - })); - - // Make sure the options are in the correct order - orderBy(options, "label", "asc"); + const activeCommands = iter.pipeline(commands.get().values()) + .filter(command => { + try { + return command.isActive(context); + } catch (error) { + return void console.error(`[COMMAND-DIALOG]: isActive for ${command.id} threw an error, defaulting to false`, error); + } + }) + .map(({ id, ...rest }) => [id, rest] as const) + .collect(items => new Map(items)); return ( this.newCurrentContext = value} - themeName="light" - className="ml-[1px] mr-[1px]" - /> - - ); - } - - renderDeleteMessage() { - const { cluster } = this.props.model; - - if (cluster.isInLocalKubeconfig()) { - return ( -
    - Delete the {cluster.getMeta().name} context from Lens's internal kubeconfig? -
    - ); - } - - return ( -
    - Delete the {cluster.getMeta().name} context from {cluster.kubeConfigPath}? -
    - ); - } - - getWarningMessage() { - const { cluster, config } = this.props.model; - const contexts = config.contexts.filter(context => context.name !== cluster.contextName); - - if (!contexts.length) { - return ( -

    - This will remove the last context in kubeconfig. There will be no active context. -

    - ); - } - - if (this.isCurrentContext()) { - return ( -

    - This will remove active context in kubeconfig. Use drop down below to select a different one. -

    - ); - } - - if (cluster.isInLocalKubeconfig()) { - return ( -

    - Are you sure you want to delete it? It can be re-added through the copy/paste mechanism. -

    - ); - } - - return ( -

    The contents of kubeconfig file will be changed!

    - ); - } - - renderWarning() { - return ( -
    - - {this.getWarningMessage()} -
    - ); - } - - render() { - const { cluster, config, isOpen } = this.props.model; - - if (!cluster || !config) return null; - - const contexts = config.contexts.filter(context => context.name !== cluster.contextName); - - return ( - -
    - {this.renderDeleteMessage()} - {this.renderWarning()} -
    - {contexts.length > 0 && ( - <> -
    - - Select current-context{" "} - {!this.isCurrentContext() && "(optional)"} - - )} - value={this.showContextSwitch} - onChange={value => this.showContextSwitch = this.isCurrentContext() ? true : value} - /> -
    - {this.renderCurrentContextSwitch()} - - )} -
    -
    -
    -
    - ); - } -} - -export const DeleteClusterDialog = withInjectables( - NonInjectedDeleteClusterDialog, - - { - getProps: (di) => ({ - hotbarStore: di.inject(hotbarStoreInjectable), - model: di.inject(deleteClusterDialogModelInjectable), - }), - }, -); diff --git a/src/renderer/components/delete-cluster-dialog/index.ts b/src/renderer/components/delete-cluster-dialog/index.ts index 7ce20559f8..af33fbe2ff 100644 --- a/src/renderer/components/delete-cluster-dialog/index.ts +++ b/src/renderer/components/delete-cluster-dialog/index.ts @@ -3,4 +3,4 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export * from "./delete-cluster-dialog"; +export * from "./view"; diff --git a/src/renderer/components/delete-cluster-dialog/open.injectable.ts b/src/renderer/components/delete-cluster-dialog/open.injectable.ts new file mode 100644 index 0000000000..992956845f --- /dev/null +++ b/src/renderer/components/delete-cluster-dialog/open.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { DeleteClusterDialogState } from "./state.injectable"; +import deleteClusterDialogStateInjectable from "./state.injectable"; + +export type OpenDeleteClusterDialog = (props: DeleteClusterDialogState) => void; + +const openDeleteClusterDialogInjectable = getInjectable({ + id: "open-delete-cluster-dialog", + instantiate: (di): OpenDeleteClusterDialog => { + const state = di.inject(deleteClusterDialogStateInjectable); + + return (props) => state.set(props); + }, +}); + +export default openDeleteClusterDialogInjectable; diff --git a/src/renderer/components/delete-cluster-dialog/state.injectable.ts b/src/renderer/components/delete-cluster-dialog/state.injectable.ts new file mode 100644 index 0000000000..8287ba6445 --- /dev/null +++ b/src/renderer/components/delete-cluster-dialog/state.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { KubeConfig } from "@kubernetes/client-node"; +import { getInjectable } from "@ogre-tools/injectable"; +import { observable } from "mobx"; +import type { Cluster } from "../../../common/cluster/cluster"; + +export interface DeleteClusterDialogState { + config: KubeConfig; + cluster: Cluster; +} + +const deleteClusterDialogStateInjectable = getInjectable({ + id: "delete-cluster-dialog-state", + instantiate: () => observable.box(), +}); + +export default deleteClusterDialogStateInjectable; diff --git a/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.module.scss b/src/renderer/components/delete-cluster-dialog/view.module.scss similarity index 100% rename from src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.module.scss rename to src/renderer/components/delete-cluster-dialog/view.module.scss diff --git a/src/renderer/components/delete-cluster-dialog/view.tsx b/src/renderer/components/delete-cluster-dialog/view.tsx new file mode 100644 index 0000000000..796e0ca836 --- /dev/null +++ b/src/renderer/components/delete-cluster-dialog/view.tsx @@ -0,0 +1,266 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import styles from "./view.module.scss"; + +import type { IObservableValue } from "mobx"; +import { action, observable } from "mobx"; +import { observer } from "mobx-react"; +import React from "react"; + +import { Button } from "../button"; +import { saveKubeconfig } from "./save-config"; +import { Notifications } from "../notifications"; +import { Dialog } from "../dialog"; +import { Icon } from "../icon"; +import { Select } from "../select"; +import { Checkbox } from "../checkbox"; +import { requestClearClusterAsDeleting, requestDeleteCluster, requestSetClusterAsDeleting } from "../../ipc"; +import type { HotbarStore } from "../../../common/hotbar-store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import hotbarStoreInjectable from "../../../common/hotbar-store.injectable"; +import type { DeleteClusterDialogState } from "./state.injectable"; +import deleteClusterDialogStateInjectable from "./state.injectable"; + +export interface Dependencies { + state: IObservableValue; + hotbarStore: HotbarStore; +} + +@observer +class NonInjectedDeleteClusterDialog extends React.Component { + private readonly showContextSwitch = observable.box(false); + private readonly newCurrentContext = observable.box(""); + + @action + onOpen(state: DeleteClusterDialogState) { + this.newCurrentContext.set(""); + this.showContextSwitch.set(this.isCurrentContext(state)); + } + + onClose = () => { + this.showContextSwitch.set(false); + }; + + removeContext(state: DeleteClusterDialogState) { + state.config.contexts = state.config.contexts.filter(item => + item.name !== state.cluster.contextName, + ); + } + + changeCurrentContext(state: DeleteClusterDialogState) { + const newCurrentContext = this.newCurrentContext.get(); + const showContextSwitch = this.showContextSwitch.get(); + + if (newCurrentContext && showContextSwitch) { + state.config.currentContext = newCurrentContext; + } + } + + async onDelete(state: DeleteClusterDialogState) { + const { cluster, config } = state; + + await requestSetClusterAsDeleting(cluster.id); + this.removeContext(state); + this.changeCurrentContext(state); + + try { + await saveKubeconfig(config, cluster.kubeConfigPath); + this.props.hotbarStore.removeAllHotbarItems(cluster.id); + await requestDeleteCluster(cluster.id); + } catch(error) { + Notifications.error(`Cannot remove cluster, failed to process config file. ${error}`); + } finally { + await requestClearClusterAsDeleting(cluster.id); + } + + this.onClose(); + } + + shouldDeleteBeDisabled({ cluster, config }: DeleteClusterDialogState): boolean { + const noContextsAvailable = config.contexts.filter(context => context.name !== cluster.contextName).length == 0; + const newContextNotSelected = this.newCurrentContext.get() === ""; + const showContextSwitch = this.showContextSwitch.get(); + + if (noContextsAvailable) { + return false; + } + + return showContextSwitch && newContextNotSelected; + } + + isCurrentContext({ cluster, config }: DeleteClusterDialogState) { + return config.currentContext == cluster.contextName; + } + + renderCurrentContextSwitch({ cluster, config }: DeleteClusterDialogState) { + if (!this.showContextSwitch.get()) { + return null; + } + + return ( +
    + id="create-resource-resource-templates-input" - autoConvertOptions={false} controlShouldRenderValue={false} // always keep initial placeholder className="TemplateSelect" placeholder="Select Template ..." options={this.props.createResourceTemplates.get()} + formatGroupLabel={group => group.label} menuPlacement="top" themeName="outlined" - onChange={ this.onSelectTemplate} + onChange={(item) => { + if (item) { + this.props.createResourceTabStore.setData(this.tabId, item.value); + } + }} />
    ); diff --git a/src/renderer/components/dock/dock-tab-store/dock-tab.store.ts b/src/renderer/components/dock/dock-tab-store/dock-tab.store.ts index a88c937b69..55ca4f075e 100644 --- a/src/renderer/components/dock/dock-tab-store/dock-tab.store.ts +++ b/src/renderer/components/dock/dock-tab-store/dock-tab.store.ts @@ -41,11 +41,11 @@ export class DockTabStore { // auto-save to local-storage if (storageKey) { - this.storage = this.dependencies.createStorage(storageKey, {}); + const storage = this.storage = this.dependencies.createStorage(storageKey, {}); - this.storage.whenReady.then(() => { - this.data.replace(this.storage.value); - reaction(() => this.toJSON(), data => this.storage.set(data)); + storage.whenReady.then(() => { + this.data.replace(storage.value); + reaction(() => this.toJSON(), data => storage.set(data)); }); } } diff --git a/src/renderer/components/dock/dock-tab.tsx b/src/renderer/components/dock/dock-tab.tsx index 2ad9492584..1e6dc64077 100644 --- a/src/renderer/components/dock/dock-tab.tsx +++ b/src/renderer/components/dock/dock-tab.tsx @@ -13,7 +13,7 @@ import type { TabProps } from "../tabs"; import { Tab } from "../tabs"; import { Icon } from "../icon"; import { Menu, MenuItem } from "../menu"; -import { observable, makeObservable } from "mobx"; +import { observable } from "mobx"; import { isMac } from "../../../common/vars"; import { withInjectables } from "@ogre-tools/injectable-react"; import dockStoreInjectable from "./dock/store.injectable"; @@ -29,48 +29,43 @@ interface Dependencies { @observer class NonInjectedDockTab extends React.Component { - @observable menuVisible = false; + private readonly menuVisible = observable.box(false); constructor(props: DockTabProps & Dependencies) { super(props); - makeObservable(this); autoBind(this); } - get tabId() { - return this.props.value.id; + close(id: string) { + this.props.dockStore.closeTab(id); } - close() { - this.props.dockStore.closeTab(this.tabId); - } - - renderMenu() { + renderMenu(tabId: string) { const { closeTab, closeAllTabs, closeOtherTabs, closeTabsToTheRight, tabs, getTabIndex } = this.props.dockStore; const closeAllDisabled = tabs.length === 1; const closeOtherDisabled = tabs.length === 1; - const closeRightDisabled = getTabIndex(this.tabId) === tabs.length - 1; + const closeRightDisabled = getTabIndex(tabId) === tabs.length - 1; return ( this.menuVisible = true} - close={() => this.menuVisible = false} + isOpen={this.menuVisible.get()} + open={() => this.menuVisible.set(true)} + close={() => this.menuVisible.set(false)} toggleEvent="contextmenu" > - closeTab(this.tabId)}> + closeTab(tabId)}> Close closeAllTabs()} disabled={closeAllDisabled}> Close all tabs - closeOtherTabs(this.tabId)} disabled={closeOtherDisabled}> + closeOtherTabs(tabId)} disabled={closeOtherDisabled}> Close other tabs - closeTabsToTheRight(this.tabId)} disabled={closeRightDisabled}> + closeTabsToTheRight(tabId)} disabled={closeRightDisabled}> Close tabs to the right @@ -79,52 +74,56 @@ class NonInjectedDockTab extends React.Component { render() { const { className, moreActions, dockStore, active, ...tabProps } = this.props; - const { title, pinned } = tabProps.value; - const label = ( -
    - {title} - {moreActions} - {!pinned && ( -
    - -
    - )} - {title} -
    - ); + + if (!tabProps.value) { + return; + } + + const { title, pinned, id } = tabProps.value; + const close = prevDefault(() => this.close(id)); return ( <> this.menuVisible = true} - label={label} + onContextMenu={() => this.menuVisible.set(true)} + label={( +
    + {title} + {moreActions} + {!pinned && ( +
    + +
    + )} + + {title} + +
    + )} /> - {this.renderMenu()} + {this.renderMenu(id)} ); } } -export const DockTab = withInjectables( - NonInjectedDockTab, - - { - getProps: (di, props) => ({ - dockStore: di.inject(dockStoreInjectable), - ...props, - }), - }, -); +export const DockTab = withInjectables(NonInjectedDockTab, { + getProps: (di, props) => ({ + dockStore: di.inject(dockStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/dock/dock-tabs.tsx b/src/renderer/components/dock/dock-tabs.tsx index dbf7f3ad56..5733d1f161 100644 --- a/src/renderer/components/dock/dock-tabs.tsx +++ b/src/renderer/components/dock/dock-tabs.tsx @@ -17,17 +17,17 @@ import { cssVar } from "../../utils"; export interface DockTabsProps { tabs: DockTabModel[]; autoFocus: boolean; - selectedTab: DockTabModel; + selectedTab: DockTabModel | undefined; onChangeTab: (tab: DockTabModel) => void; } export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: DockTabsProps) => { - const elem = useRef(); + const elem = useRef(null); const minTabSize = useRef(0); const [showScrollbar, setShowScrollbar] = useState(false); const getTabElements = (): HTMLDivElement[] => { - return Array.from(elem.current?.querySelectorAll(".Tabs .Tab")); + return Array.from(elem.current?.querySelectorAll(".Tabs .Tab") ?? []); }; const renderTab = (tab?: DockTabModel) => { @@ -70,9 +70,11 @@ export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: DockTabs }; useEffect(() => { - const cssVars = cssVar(elem.current); + if (elem.current) { + const cssVars = cssVar(elem.current); - minTabSize.current = cssVars.get("--min-tab-width").valueOf(); + minTabSize.current = cssVars.get("--min-tab-width").valueOf(); + } }); useResizeObserver(elem.current, () => { @@ -81,7 +83,11 @@ export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: DockTabs }); return ( -
    +
    { - private element = React.createRef(); + private readonly element = React.createRef(); componentDidMount() { document.addEventListener("keydown", this.onKeyDown); @@ -58,7 +58,7 @@ class NonInjectedDock extends React.Component { const { code, ctrlKey, metaKey, shiftKey } = evt; // Determine if user working inside or using any other areas in app - const dockIsFocused = this.element?.current.contains(document.activeElement); + const dockIsFocused = this.element.current?.contains(document.activeElement); if (!selectedTab || !dockIsFocused) return; @@ -68,15 +68,15 @@ class NonInjectedDock extends React.Component { if ((ctrlKey && code === "KeyW") || (metaKey && code === "KeyW")) { closeTab(selectedTab.id); - this.element?.current.focus(); // Avoid loosing focus when closing tab + this.element.current?.focus(); // Avoid loosing focus when closing tab } if(ctrlKey && code === "Period") { - this.switchToNextTab(); + this.switchToNextTab(selectedTab, Direction.NEXT); } if(ctrlKey && code === "Comma") { - this.switchToNextTab(Direction.PREV); + this.switchToNextTab(selectedTab, Direction.PREV); } }; @@ -85,11 +85,11 @@ class NonInjectedDock extends React.Component { open(); selectTab(tab.id); - this.element?.current.focus(); + this.element.current?.focus(); }; - switchToNextTab = (direction: Direction = Direction.NEXT) => { - const { tabs, selectedTab } = this.props.dockStore; + switchToNextTab = (selectedTab: DockTab, direction: Direction) => { + const { tabs } = this.props.dockStore; const currentIndex = tabs.indexOf(selectedTab); const nextIndex = currentIndex + direction; @@ -160,9 +160,17 @@ class NonInjectedDock extends React.Component { />
    - + this.props.createTerminalTab()}> - + Terminal session this.props.createResourceTab()}> diff --git a/src/renderer/components/dock/dock/dock-storage.injectable.ts b/src/renderer/components/dock/dock/dock-storage.injectable.ts index ad3dbca7d1..ce2754dcab 100644 --- a/src/renderer/components/dock/dock/dock-storage.injectable.ts +++ b/src/renderer/components/dock/dock/dock-storage.injectable.ts @@ -23,6 +23,7 @@ const dockStorageInjectable = getInjectable({ pinned: false, }, ], + isOpen: false, }); }, }); diff --git a/src/renderer/components/dock/dock/store.ts b/src/renderer/components/dock/dock/store.ts index f932dda079..6b6078d23b 100644 --- a/src/renderer/components/dock/dock/store.ts +++ b/src/renderer/components/dock/dock/store.ts @@ -70,7 +70,7 @@ export interface DockStorageState { height: number; tabs: DockTab[]; selectedTabId?: TabId; - isOpen?: boolean; + isOpen: boolean; } export interface DockTabChangeEvent { @@ -157,7 +157,7 @@ export class DockStore implements DockStorageState { ); } - set selectedTabId(tabId: TabId) { + set selectedTabId(tabId: TabId | undefined) { if (tabId && !this.getTabById(tabId)) return; // skip invalid ids this.dependencies.storage.merge({ selectedTabId: tabId }); @@ -283,7 +283,7 @@ export class DockStore implements DockStorageState { const tabNumbers = this.tabs .filter(tab => tab.kind === kind) .map(tab => { - const tabNumber = +tab.title.match(/\d+/); + const tabNumber = Number(tab.title.match(/\d+/)); return tabNumber === 0 ? 1 : tabNumber; // tab without a number is first }); @@ -343,7 +343,7 @@ export class DockStore implements DockStorageState { this.selectTab(newTab.id); } else { - this.selectedTabId = null; + this.selectedTabId = undefined; this.close(); } } @@ -375,12 +375,14 @@ export class DockStore implements DockStorageState { renameTab(tabId: TabId, title: string) { const tab = this.getTabById(tabId); - tab.title = title; + if (tab) { + tab.title = title; + } } @action selectTab(tabId: TabId) { - this.selectedTabId = this.getTabById(tabId)?.id ?? null; + this.selectedTabId = this.getTabById(tabId)?.id; } @action diff --git a/src/renderer/components/dock/edit-resource/store.ts b/src/renderer/components/dock/edit-resource/store.ts index 772d06ddb0..bb68b22a7f 100644 --- a/src/renderer/components/dock/edit-resource/store.ts +++ b/src/renderer/components/dock/edit-resource/store.ts @@ -36,19 +36,29 @@ export class EditResourceTabStore extends DockTabStore { return super.isReady(tabId) && Boolean(this.getResource(tabId)); // ready to edit resource } - getStore(tabId: TabId): KubeObjectStore | undefined { - return apiManager.getStore(this.getResourcePath(tabId)); + getStore(tabId: TabId): KubeObjectStore | undefined { + const apiPath = this.getResourcePath(tabId); + + return apiPath + ? apiManager.getStore(apiPath) + : undefined; } getResource(tabId: TabId): KubeObject | undefined { - return this.getStore(tabId)?.getByPath(this.getResourcePath(tabId)); + const apiPath = this.getResourcePath(tabId); + + if (apiPath) { + return apiManager.getStore(apiPath)?.getByPath(apiPath); + } + + return undefined; } getResourcePath(tabId: TabId): string | undefined { return this.getData(tabId)?.resource; } - getTabIdByResource(object: KubeObject): TabId { + getTabIdByResource(object: KubeObject): string | undefined { return this.findTabIdFromData(({ resource }) => object.selfLink === resource); } diff --git a/src/renderer/components/dock/edit-resource/view.tsx b/src/renderer/components/dock/edit-resource/view.tsx index 9d92848f49..52609681f7 100644 --- a/src/renderer/components/dock/edit-resource/view.tsx +++ b/src/renderer/components/dock/edit-resource/view.tsx @@ -4,11 +4,11 @@ */ import React from "react"; -import { autorun, computed, makeObservable, observable } from "mobx"; +import { autorun, makeObservable, observable } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import yaml from "js-yaml"; import type { DockTab, TabId } from "../dock/store"; -import type { EditResourceTabStore } from "./store"; +import type { EditingResource, EditResourceTabStore } from "./store"; import { InfoPanel } from "../info-panel"; import { Badge } from "../../badge"; import { EditorPanel } from "../editor-panel"; @@ -17,8 +17,9 @@ import type { KubeObject } from "../../../../common/k8s-api/kube-object"; import { createPatch } from "rfc6902"; import { withInjectables } from "@ogre-tools/injectable-react"; import editResourceTabStoreInjectable from "./store.injectable"; -import { noop } from "../../../utils"; +import { noop, onceDefined } from "../../../utils"; import closeDockTabInjectable from "../dock/close-dock-tab.injectable"; +import type { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; export interface EditResourceProps { tab: DockTab; @@ -29,9 +30,16 @@ interface Dependencies { closeTab: (tabId: TabId) => void; } +interface SaveDraftArgs { + tabData: EditingResource; + resource: KubeObject; + store: KubeObjectStore; +} + @observer class NonInjectedEditResource extends React.Component { @observable error = ""; + @observable draft = ""; constructor(props: EditResourceProps & Dependencies) { super(props); @@ -40,13 +48,32 @@ class NonInjectedEditResource extends React.Component { - const store = this.props.editResourceStore.getStore(this.props.tab.id); - const tabData = this.props.editResourceStore.getData(this.props.tab.id); - const obj = this.resource; + onceDefined( + () => { + const tabData = this.tabData; + const resource = this.resource; - if (!obj) { - if (store?.isLoaded) { + if (tabData && resource) { + return { tabData, resource }; + } + + return undefined; + }, + ({ tabData, resource }) => { + if (typeof tabData.draft === "string") { + this.draft = tabData.draft; + } else { + this.draft = tabData.firstDraft = yaml.dump(resource.toPlainObject()); + } + }, + ), + autorun(() => { + const store = this.store; + const tabData = this.tabData; + const resource = this.resource; + + if (!resource && store && tabData) { + if (store.isLoaded) { // auto-close tab when resource removed from store this.props.closeTab(this.props.tab.id); } else if (!store.isLoading) { @@ -62,68 +89,45 @@ class NonInjectedEditResource extends React.Component { - this.error = ""; // reset first - this.saveDraft(draft); - }; - - onError = (error?: Error | string) => { - this.error = error.toString(); - }; - - save = async () => { + async save({ resource, store, tabData }: SaveDraftArgs) { if (this.error) { return null; } - const store = this.props.editResourceStore.getStore(this.tabId); const currentVersion = yaml.load(this.draft); - const firstVersion = yaml.load(this.props.editResourceStore.getData(this.tabId).firstDraft ?? this.draft); + const firstVersion = yaml.load(tabData.firstDraft ?? this.draft); const patches = createPatch(firstVersion, currentVersion); - const updatedResource = await store.patch(this.resource, patches); + const updatedResource = await store.patch(resource, patches); this.props.editResourceStore.clearInitialDraft(this.tabId); return (

    - {updatedResource.kind} {updatedResource.getName()} updated. + {updatedResource.kind} + {" "} + {updatedResource.getName()} + {" updated."}

    ); - }; + } render() { - const { tabId, error, onChange, onError, save, draft, isReadyForEditing, resource } = this; + const { tabId, error, draft, tabData, resource, store } = this; - if (!isReadyForEditing) { - return ; + if (!tabData || !resource || !store) { + return ; } return ( @@ -131,22 +135,28 @@ class NonInjectedEditResource extends React.Component this.save({ resource, store, tabData })} submitLabel="Save" submittingMessage="Applying.." controls={(
    - Kind: - Name: - Namespace: + Kind: + + Name: + + Namespace: +
    )} /> { + this.error = ""; + tabData.draft = draft; + }} + onError={error => this.error = String(error)} />
    ); diff --git a/src/renderer/components/dock/editor-panel.tsx b/src/renderer/components/dock/editor-panel.tsx index a401e0f43e..33b7ce7593 100644 --- a/src/renderer/components/dock/editor-panel.tsx +++ b/src/renderer/components/dock/editor-panel.tsx @@ -36,7 +36,7 @@ const defaultProps: Partial = { class NonInjectedEditorPanel extends React.Component { static defaultProps = defaultProps as object; - @observable.ref editor?: MonacoEditor; + @observable.ref editor: MonacoEditor | null = null; constructor(props: EditorPanelProps & Dependencies) { super(props); diff --git a/src/renderer/components/dock/info-panel.tsx b/src/renderer/components/dock/info-panel.tsx index b94944ad55..2ebe465e50 100644 --- a/src/renderer/components/dock/info-panel.tsx +++ b/src/renderer/components/dock/info-panel.tsx @@ -20,7 +20,7 @@ import dockStoreInjectable from "./dock/store.injectable"; export interface InfoPanelProps extends OptionalProps { tabId: TabId; - submit?: () => Promise; + submit?: () => Promise; } export interface OptionalProps { @@ -79,11 +79,15 @@ class NonInjectedInfoPanel extends Component { this.waiting = true; try { - const result = await this.props.submit(); + const result = await this.props.submit?.(); - if (showNotifications && result) Notifications.ok(result); + if (showNotifications && result) { + Notifications.ok(result); + } } catch (error) { - if (showNotifications) Notifications.error(error.toString()); + if (showNotifications) { + Notifications.checkedError(error, "Unknown error while submitting"); + } } finally { this.waiting = false; } @@ -122,12 +126,22 @@ class NonInjectedInfoPanel extends Component {
    {showStatusPanel && (
    - {waiting ? <> {submittingMessage} : this.renderErrorIcon()} + {waiting ? ( + <> + + {" "} + {submittingMessage} + + ) : this.renderErrorIcon()}
    )} {showButtons && ( <> -
    ); } const { repo, name, version, namespace, releaseName } = chartData; - const panelControls = ( -
    - Chart - - Version - -
    - ); return (
    + Chart + + Version + +
    + )} error={this.error} - submit={install} + submit={() => install(chartData)} + disableSubmit={!chartData.namespace} submitLabel="Install" submittingMessage="Installing..." showSubmitClose={false} diff --git a/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx b/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx index fcbd1f1382..095334a629 100644 --- a/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx +++ b/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx @@ -6,7 +6,6 @@ import React from "react"; import "@testing-library/jest-dom/extend-expect"; import * as selectEvent from "react-select-event"; -import { Pod } from "../../../../../common/k8s-api/endpoints"; import { LogResourceSelector } from "../resource-selector"; import { dockerPod, deploymentPod1, deploymentPod2 } from "./pod.mock"; import { ThemeStore } from "../../../../theme.store"; @@ -22,10 +21,9 @@ import { LogTabViewModel } from "../logs-view-model"; import type { TabId } from "../../dock/store"; import userEvent from "@testing-library/user-event"; import { SearchStore } from "../../../../search-store/search-store"; -import getConfigurationFileModelInjectable - from "../../../../../common/get-configuration-file-model/get-configuration-file-model.injectable"; -import appVersionInjectable - from "../../../../../common/get-configuration-file-model/app-version/app-version.injectable"; +import getConfigurationFileModelInjectable from "../../../../../common/get-configuration-file-model/get-configuration-file-model.injectable"; +import appVersionInjectable from "../../../../../common/get-configuration-file-model/app-version/app-version.injectable"; +import assert from "assert"; jest.mock("electron", () => ({ app: { @@ -67,7 +65,7 @@ function mockLogTabViewModel(tabId: TabId, deps: Partial = {}): LogTabViewModel => { - const selectedPod = new Pod(dockerPod); + const selectedPod = dockerPod; return mockLogTabViewModel(tabId, { getLogTabData: () => ({ @@ -89,8 +87,8 @@ const getOnePodViewModel = (tabId: TabId, deps: Partial = {}): LogTabViewModel => { - const selectedPod = new Pod(deploymentPod1); - const anotherPod = new Pod(deploymentPod2); + const selectedPod = deploymentPod1; + const anotherPod = deploymentPod2; return mockLogTabViewModel(tabId, { getLogTabData: () => ({ @@ -183,8 +181,11 @@ describe("", () => { it("renders sibling pods in dropdown", async () => { const model = getFewPodsTabData("foobar"); const { container, findByText } = render(); + const selector = container.querySelector(".pod-selector"); - selectEvent.openMenu(container.querySelector(".pod-selector")); + assert(selector); + + selectEvent.openMenu(selector); expect(await findByText("deploymentPod2", { selector: ".pod-selector-menu .Select__option" })).toBeInTheDocument(); expect(await findByText("deploymentPod1", { selector: ".pod-selector-menu .Select__option" })).toBeInTheDocument(); }); @@ -192,8 +193,12 @@ describe("", () => { it("renders sibling containers in dropdown", async () => { const model = getFewPodsTabData("foobar"); const { findByText, container } = render(); + const selector = container.querySelector(".container-selector"); + + assert(selector); + + selectEvent.openMenu(selector); - selectEvent.openMenu(container.querySelector(".container-selector")); expect(await findByText("node-exporter-1")).toBeInTheDocument(); expect(await findByText("init-node-exporter")).toBeInTheDocument(); expect(await findByText("init-node-exporter-1")).toBeInTheDocument(); @@ -212,9 +217,11 @@ describe("", () => { const renameTab = jest.fn(); const model = getFewPodsTabData("foobar", { renameTab }); const { findByText, container } = render(); + const selector = container.querySelector(".pod-selector"); - selectEvent.openMenu(container.querySelector(".pod-selector")); + assert(selector); + selectEvent.openMenu(selector); userEvent.click(await findByText("deploymentPod2", { selector: ".pod-selector-menu .Select__option" })); expect(renameTab).toBeCalledWith("foobar", "Pod deploymentPod2"); }); diff --git a/src/renderer/components/dock/logs/__test__/log-search.test.tsx b/src/renderer/components/dock/logs/__test__/log-search.test.tsx index ab1a5b3e02..6af8717b20 100644 --- a/src/renderer/components/dock/logs/__test__/log-search.test.tsx +++ b/src/renderer/components/dock/logs/__test__/log-search.test.tsx @@ -5,7 +5,6 @@ import React from "react"; import { screen } from "@testing-library/react"; -import { Pod } from "../../../../../common/k8s-api/endpoints"; import { dockerPod } from "./pod.mock"; import { getDiForUnitTesting } from "../../../../getDiForUnitTesting"; import type { DiRender } from "../../../test-utils/renderFor"; @@ -37,7 +36,7 @@ function mockLogTabViewModel(tabId: TabId, deps: Partial = {}): LogTabViewModel => { - const selectedPod = new Pod(dockerPod); + const selectedPod = dockerPod; return mockLogTabViewModel(tabId, { getLogTabData: () => ({ diff --git a/src/renderer/components/dock/logs/__test__/pod.mock.ts b/src/renderer/components/dock/logs/__test__/pod.mock.ts index a74c7d5572..134e9b8f25 100644 --- a/src/renderer/components/dock/logs/__test__/pod.mock.ts +++ b/src/renderer/components/dock/logs/__test__/pod.mock.ts @@ -3,7 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export const dockerPod = { +import { Pod } from "../../../../../common/k8s-api/endpoints"; + +export const dockerPod = new Pod({ apiVersion: "v1", kind: "dummy", metadata: { @@ -12,9 +14,10 @@ export const dockerPod = { creationTimestamp: "dummy", resourceVersion: "dummy", namespace: "default", + selfLink: "/v1/pod/default/dockerExporter", }, spec: { - initContainers: [] as any, + initContainers: [], containers: [ { name: "docker-exporter", @@ -37,9 +40,9 @@ export const dockerPod = { podIP: "dummy", startTime: "dummy", }, -}; +}); -export const deploymentPod1 = { +export const deploymentPod1 = new Pod({ apiVersion: "v1", kind: "dummy", metadata: { @@ -48,6 +51,7 @@ export const deploymentPod1 = { creationTimestamp: "dummy", resourceVersion: "dummy", namespace: "default", + selfLink: "/v1/pod/default/deploymentPod1", ownerReferences: [{ apiVersion: "v1", kind: "Deployment", @@ -97,9 +101,9 @@ export const deploymentPod1 = { podIP: "dummy", startTime: "dummy", }, -}; +}); -export const deploymentPod2 = { +export const deploymentPod2 = new Pod({ apiVersion: "v1", kind: "dummy", metadata: { @@ -108,6 +112,7 @@ export const deploymentPod2 = { creationTimestamp: "dummy", resourceVersion: "dummy", namespace: "default", + selfLink: "/v1/pod/default/deploymentPod2", ownerReferences: [{ apiVersion: "v1", kind: "Deployment", @@ -157,9 +162,9 @@ export const deploymentPod2 = { podIP: "dummy", startTime: "dummy", }, -}; +}); -export const deploymentPod3 = { +export const deploymentPod3 = new Pod({ apiVersion: "v1", kind: "dummy", metadata: { @@ -168,6 +173,7 @@ export const deploymentPod3 = { creationTimestamp: "dummy", resourceVersion: "dummy", namespace: "default", + selfLink: "/v1/pod/default/deploymentPod3", ownerReferences: [{ apiVersion: "v1", kind: "Deployment", @@ -205,4 +211,4 @@ export const deploymentPod3 = { podIP: "dummy", startTime: "dummy", }, -}; +}); diff --git a/src/renderer/components/dock/logs/call-for-logs.injectable.ts b/src/renderer/components/dock/logs/call-for-logs.injectable.ts index 523503b0b9..0389cff0c1 100644 --- a/src/renderer/components/dock/logs/call-for-logs.injectable.ts +++ b/src/renderer/components/dock/logs/call-for-logs.injectable.ts @@ -3,11 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { podsApi } from "../../../../common/k8s-api/endpoints"; +import { podApi } from "../../../../common/k8s-api/endpoints"; const callForLogsInjectable = getInjectable({ id: "call-for-logs", - instantiate: () => podsApi.getLogs, + instantiate: () => podApi.getLogs, }); export default callForLogsInjectable; diff --git a/src/renderer/components/dock/logs/controls.tsx b/src/renderer/components/dock/logs/controls.tsx index 159b135435..e843348132 100644 --- a/src/renderer/components/dock/logs/controls.tsx +++ b/src/renderer/components/dock/logs/controls.tsx @@ -25,8 +25,9 @@ interface Dependencies { const NonInjectedLogControls = observer(({ openSaveFileDialog, model }: Dependencies & LogControlsProps) => { const tabData = model.logTabData.get(); + const pod = model.pod.get(); - if (!tabData) { + if (!tabData || !pod) { return null; } @@ -44,7 +45,7 @@ const NonInjectedLogControls = observer(({ openSaveFileDialog, model }: Dependen }; const downloadLogs = () => { - const fileName = model.pod.get().getName(); + const fileName = pod.getName(); const logsToDownload: string[] = showTimestamps ? model.logs.get() : model.logsWithoutTimestamps.get(); @@ -57,7 +58,8 @@ const NonInjectedLogControls = observer(({ openSaveFileDialog, model }: Dependen
    {since && ( - Logs from{" "} + Logs from + {" "} {new Date(since).toLocaleString()} )} diff --git a/src/renderer/components/dock/logs/create-workload-logs-tab.injectable.ts b/src/renderer/components/dock/logs/create-workload-logs-tab.injectable.ts index 119d782798..f4dfc997e9 100644 --- a/src/renderer/components/dock/logs/create-workload-logs-tab.injectable.ts +++ b/src/renderer/components/dock/logs/create-workload-logs-tab.injectable.ts @@ -4,13 +4,13 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { podsStore } from "../../+workloads-pods/pods.store"; -import type { WorkloadKubeObject } from "../../../../common/k8s-api/workload-kube-object"; +import type { DaemonSet, Deployment, Job, ReplicaSet, StatefulSet } from "../../../../common/k8s-api/endpoints"; import type { TabId } from "../dock/store"; import type { CreateLogsTabData } from "./create-logs-tab.injectable"; import createLogsTabInjectable from "./create-logs-tab.injectable"; export interface WorkloadLogsTabData { - workload: WorkloadKubeObject; + workload: StatefulSet | Job | Deployment | DaemonSet | ReplicaSet; } interface Dependencies { diff --git a/src/renderer/components/dock/logs/get-log-tab-data.injectable.ts b/src/renderer/components/dock/logs/get-log-tab-data.injectable.ts index 980c96cf09..3b7c7c4f69 100644 --- a/src/renderer/components/dock/logs/get-log-tab-data.injectable.ts +++ b/src/renderer/components/dock/logs/get-log-tab-data.injectable.ts @@ -12,7 +12,7 @@ const getLogTabDataInjectable = getInjectable({ instantiate: (di) => { const logTabStore = di.inject(logTabStoreInjectable); - return (tabId: string): LogTabData => logTabStore.getData(tabId); + return (tabId: string): LogTabData | undefined => logTabStore.getData(tabId); }, }); diff --git a/src/renderer/components/dock/logs/list.tsx b/src/renderer/components/dock/logs/list.tsx index 256dc5f26f..fba6bce048 100644 --- a/src/renderer/components/dock/logs/list.tsx +++ b/src/renderer/components/dock/logs/list.tsx @@ -16,6 +16,7 @@ import type { Align, ListOnScrollProps } from "react-window"; import { SearchStore } from "../../../search-store/search-store"; import { UserStore } from "../../../../common/user-store"; import { array, autoBind, cssNames } from "../../../utils"; +import type { VirtualListRef } from "../../virtual-list"; import { VirtualList } from "../../virtual-list"; import { ToBottom } from "./to-bottom"; import type { LogTabViewModel } from "../logs/logs-view-model"; @@ -33,7 +34,7 @@ export class LogList extends React.Component { @observable isLastLineVisible = true; private virtualListDiv = React.createRef(); // A reference for outer container in VirtualList - private virtualListRef = React.createRef(); // A reference for VirtualList component + private virtualListRef = React.createRef(); // A reference for VirtualList component private lineHeight = 18; // Height of a log line. Should correlate with styles in pod-log-list.scss constructor(props: LogListProps) { @@ -87,7 +88,7 @@ export class LogList extends React.Component { */ @computed get logs(): string[] { - const { showTimestamps } = this.props.model.logTabData.get(); + const { showTimestamps } = this.props.model.logTabData.get() ?? {}; if (!showTimestamps) { return this.props.model.logsWithoutTimestamps.get(); @@ -102,10 +103,8 @@ export class LogList extends React.Component { * Checks if JumpToBottom button should be visible and sets its observable * @param props Scrolling props from virtual list core */ - setButtonVisibility = action((props: ListOnScrollProps) => { + setButtonVisibility = action(({ scrollOffset }: ListOnScrollProps, { scrollHeight }: HTMLDivElement) => { const offset = 100 * this.lineHeight; - const { scrollHeight } = this.virtualListDiv.current; - const { scrollOffset } = props; if (scrollHeight - scrollOffset < offset) { this.isJumpButtonVisible = false; @@ -118,10 +117,7 @@ export class LogList extends React.Component { * Checks if last log line considered visible to user, setting its observable * @param props Scrolling props from virtual list core */ - setLastLineVisibility = action((props: ListOnScrollProps) => { - const { scrollHeight, clientHeight } = this.virtualListDiv.current; - const { scrollOffset } = props; - + setLastLineVisibility = action(({ scrollOffset }: ListOnScrollProps, { scrollHeight, clientHeight }: HTMLDivElement) => { this.isLastLineVisible = (clientHeight + scrollOffset) === scrollHeight; }); @@ -143,7 +139,7 @@ export class LogList extends React.Component { }; scrollToItem = (index: number, align: Align) => { - this.virtualListRef.current.scrollToItem(index, align); + this.virtualListRef.current?.scrollToItem(index, align); }; onScroll = (props: ListOnScrollProps) => { @@ -152,10 +148,13 @@ export class LogList extends React.Component { }; onScrollDebounced = debounce((props: ListOnScrollProps) => { - if (!this.virtualListDiv.current) return; - this.setButtonVisibility(props); - this.setLastLineVisibility(props); - this.checkLoadIntent(props); + const virtualList = this.virtualListDiv.current; + + if (virtualList) { + this.setButtonVisibility(props, virtualList); + this.setLastLineVisibility(props, virtualList); + this.checkLoadIntent(props); + } }, 700); // Increasing performance and giving some time for virtual list to settle down /** @@ -182,10 +181,12 @@ export class LogList extends React.Component { const lastItem = index === pieces.length - 1; const overlayValue = matches.next().value; const overlay = !lastItem - ? + ? ( + + ) : null; contents.push( @@ -220,7 +221,9 @@ export class LogList extends React.Component { if (!this.logs.length) { return (
    - There are no logs available for container {this.props.model.logTabData.get()?.selectedContainer} + There are no logs available for container + {" "} + {this.props.model.logTabData.get()?.selectedContainer}
    ); } diff --git a/src/renderer/components/dock/logs/load-logs.injectable.ts b/src/renderer/components/dock/logs/load-logs.injectable.ts index ec95bcbad2..5f2055417c 100644 --- a/src/renderer/components/dock/logs/load-logs.injectable.ts +++ b/src/renderer/components/dock/logs/load-logs.injectable.ts @@ -17,7 +17,7 @@ const loadLogsInjectable = getInjectable({ return ( tabId: string, pod: IComputedValue, - logTabData: IComputedValue, + logTabData: IComputedValue, ): Promise => logStore.load(tabId, pod, logTabData); }, }); diff --git a/src/renderer/components/dock/logs/logs-view-model.ts b/src/renderer/components/dock/logs/logs-view-model.ts index ed14ad6e91..76e7704710 100644 --- a/src/renderer/components/dock/logs/logs-view-model.ts +++ b/src/renderer/components/dock/logs/logs-view-model.ts @@ -8,15 +8,17 @@ import { computed } from "mobx"; import type { TabId } from "../dock/store"; import type { SearchStore } from "../../../search-store/search-store"; import type { Pod } from "../../../../common/k8s-api/endpoints"; +import { isDefined } from "../../../utils"; +import assert from "assert"; export interface LogTabViewModelDependencies { getLogs: (tabId: TabId) => string[]; getLogsWithoutTimestamps: (tabId: TabId) => string[]; getTimestampSplitLogs: (tabId: TabId) => [string, string][]; - getLogTabData: (tabId: TabId) => LogTabData; + getLogTabData: (tabId: TabId) => LogTabData | undefined; setLogTabData: (tabId: TabId, data: LogTabData) => void; - loadLogs: (tabId: TabId, pod: IComputedValue, logTabData: IComputedValue) => Promise; - reloadLogs: (tabId: TabId, pod: IComputedValue, logTabData: IComputedValue) => Promise; + loadLogs: (tabId: TabId, pod: IComputedValue, logTabData: IComputedValue) => Promise; + reloadLogs: (tabId: TabId, pod: IComputedValue, logTabData: IComputedValue) => Promise; renameTab: (tabId: TabId, title: string) => void; stopLoadingLogs: (tabId: TabId) => void; getPodById: (id: string) => Pod | undefined; @@ -48,7 +50,7 @@ export class LogTabViewModel { return this.dependencies.getPodsByOwnerId(data.owner.uid); } - return [this.dependencies.getPodById(data.selectedPodId)]; + return [this.dependencies.getPodById(data.selectedPodId)].filter(isDefined); }); readonly pod = computed(() => { const data = this.logTabData.get(); @@ -61,7 +63,11 @@ export class LogTabViewModel { }); updateLogTabData = (partialData: Partial) => { - this.dependencies.setLogTabData(this.tabId, { ...this.logTabData.get(), ...partialData }); + const data = this.logTabData.get(); + + assert(data, "Can only update data once it is set"); + + this.dependencies.setLogTabData(this.tabId, { ...data, ...partialData }); }; loadLogs = () => this.dependencies.loadLogs(this.tabId, this.pod, this.logTabData); diff --git a/src/renderer/components/dock/logs/reload-logs.injectable.ts b/src/renderer/components/dock/logs/reload-logs.injectable.ts index a286c063bc..ef2e878c87 100644 --- a/src/renderer/components/dock/logs/reload-logs.injectable.ts +++ b/src/renderer/components/dock/logs/reload-logs.injectable.ts @@ -17,7 +17,7 @@ const reloadLogsInjectable = getInjectable({ return ( tabId: string, pod: IComputedValue, - logTabData: IComputedValue, + logTabData: IComputedValue, ): Promise => logStore.reload(tabId, pod, logTabData); }, }); diff --git a/src/renderer/components/dock/logs/resource-selector.tsx b/src/renderer/components/dock/logs/resource-selector.tsx index 97ad7b3dfc..40ecc3761a 100644 --- a/src/renderer/components/dock/logs/resource-selector.tsx +++ b/src/renderer/components/dock/logs/resource-selector.tsx @@ -9,20 +9,17 @@ import React from "react"; import { observer } from "mobx-react"; import { Badge } from "../../badge"; -import type { SelectOption } from "../../select"; import { Select } from "../../select"; import type { LogTabViewModel } from "./logs-view-model"; import type { IPodContainer, Pod } from "../../../../common/k8s-api/endpoints"; +import type { GroupBase } from "react-select"; export interface LogResourceSelectorProps { model: LogTabViewModel; } -function getSelectOptions(containers: IPodContainer[]): SelectOption[] { - return containers.map(container => ({ - value: container.name, - label: container.name, - })); +function getSelectOptions(containers: IPodContainer[]) { + return containers.map(container => container.name); } export const LogResourceSelector = observer(({ model }: LogResourceSelectorProps) => { @@ -40,14 +37,22 @@ export const LogResourceSelector = observer(({ model }: LogResourceSelectorProps return null; } - const onContainerChange = (option: SelectOption) => { + const onContainerChange = (container: string | null) => { + if (!container) { + return; + } + model.updateLogTabData({ - selectedContainer: option.value, + selectedContainer: container, }); model.reloadLogs(); }; - const onPodChange = ({ value }: SelectOption) => { + const onPodChange = (value: Pod | null) => { + if (!value) { + return; + } + model.updateLogTabData({ selectedPodId: value.getId(), selectedContainer: value.getAllContainers()[0]?.name, @@ -67,39 +72,35 @@ export const LogResourceSelector = observer(({ model }: LogResourceSelectorProps }, ]; - const podSelectOptions = pods.map(pod => ({ - label: pod.getName(), - value: pod, - })); - return (
    - Namespace + Namespace + {" "} + { owner && ( <> - Owner + Owner + {" "} + ) } Pod > id="container-selector-input" options={containerSelectOptions} - value={{ label: selectedContainer, value: selectedContainer }} + value={selectedContainer} onChange={onContainerChange} - autoConvertOptions={false} className="container-selector" menuClass="container-selector-menu" /> diff --git a/src/renderer/components/dock/logs/search.tsx b/src/renderer/components/dock/logs/search.tsx index ae633f2a5d..892874c53a 100644 --- a/src/renderer/components/dock/logs/search.tsx +++ b/src/renderer/components/dock/logs/search.tsx @@ -73,7 +73,7 @@ export const LogSearch = observer(({ onSearch, scrollToOverlay, model: { logTabD showClearIcon={true} contentRight={totalFinds > 0 && (
    - {activeFind}/{totalFinds} + {`${activeFind} / ${totalFinds}`}
    )} onClear={onClear} diff --git a/src/renderer/components/dock/logs/store.ts b/src/renderer/components/dock/logs/store.ts index 987a6db095..60d7ef992f 100644 --- a/src/renderer/components/dock/logs/store.ts +++ b/src/renderer/components/dock/logs/store.ts @@ -4,8 +4,9 @@ */ import type { IComputedValue } from "mobx"; -import { observable, when } from "mobx"; +import { observable } from "mobx"; import type { IPodLogsQuery, Pod } from "../../../../common/k8s-api/endpoints"; +import { waitUntilDefinied } from "../../../../common/utils/wait"; import type { IntervalFn } from "../../../utils"; import { getOrInsertWith, interval } from "../../../utils"; import type { TabId } from "../dock/store"; @@ -45,7 +46,7 @@ export class LogStore { * Also, it handles loading errors, rewriting whole logs with error * messages */ - public async load(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): Promise { + public async load(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): Promise { try { const logs = await this.loadLogs(computedPod, logTabData, { tailLines: this.getLogLines(tabId) + logLinesToLoad, @@ -58,7 +59,7 @@ export class LogStore { } } - private getRefresher(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): IntervalFn { + private getRefresher(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): IntervalFn { return getOrInsertWith(this.refreshers, tabId, () => ( interval(10, () => { if (this.podLogs.has(tabId)) { @@ -82,13 +83,14 @@ export class LogStore { * starting from last line received. * @param tabId */ - public async loadMore(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): Promise { - if (!this.podLogs.get(tabId).length) { + public async loadMore(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): Promise { + const oldLogs = this.podLogs.get(tabId); + + if (!oldLogs?.length) { return; } try { - const oldLogs = this.podLogs.get(tabId); const logs = await this.loadLogs(computedPod, logTabData, { sinceTime: this.getLastSinceTime(tabId), }); @@ -107,11 +109,23 @@ export class LogStore { * @param params request parameters described in IPodLogsQuery interface * @returns A fetch request promise */ - private async loadLogs(computedPod: IComputedValue, logTabData: IComputedValue, params: Partial): Promise { - await when(() => Boolean(computedPod.get() && logTabData.get()), { timeout: 5_000 }); + private async loadLogs(computedPod: IComputedValue, logTabData: IComputedValue, params: Partial): Promise { + const { + pod, + tabData: { + selectedContainer, + showPrevious, + }, + } = await waitUntilDefinied(() => { + const pod = computedPod.get(); + const tabData = logTabData.get(); - const { selectedContainer, showPrevious } = logTabData.get(); - const pod = computedPod.get(); + if (pod && tabData) { + return { pod, tabData }; + } + + return undefined; + }); const namespace = pod.getNs(); const name = pod.getName(); @@ -177,9 +191,9 @@ export class LogStore { * @param tabId */ getLastSinceTime(tabId: TabId): string { - const logs = this.podLogs.get(tabId); + const logs = this.podLogs.get(tabId) ?? []; const timestamps = this.getTimestamps(logs[logs.length - 1]); - const stamp = new Date(timestamps ? timestamps[0] : null); + const stamp = timestamps[0] ? new Date(timestamps[0]) : new Date(); stamp.setSeconds(stamp.getSeconds() + 1); // avoid duplicates from last second @@ -197,7 +211,7 @@ export class LogStore { } getTimestamps(logs: string): RegExpMatchArray { - return logs.match(/^\d+\S+/gm); + return logs.match(/^\d+\S+/gm) ?? []; } removeTimestamps(logs: string): string { @@ -208,7 +222,7 @@ export class LogStore { this.podLogs.delete(tabId); } - reload(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): Promise { + reload(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): Promise { this.clearLogs(tabId); return this.load(tabId, computedPod, logTabData); diff --git a/src/renderer/components/dock/logs/view.tsx b/src/renderer/components/dock/logs/view.tsx index 01002efc62..a624fad597 100644 --- a/src/renderer/components/dock/logs/view.tsx +++ b/src/renderer/components/dock/logs/view.tsx @@ -14,12 +14,9 @@ import { withInjectables } from "@ogre-tools/injectable-react"; import logsViewModelInjectable from "./logs-view-model.injectable"; import type { LogTabViewModel } from "./logs-view-model"; import type { DockTab } from "../dock/store"; -import type { Disposer } from "../../../utils"; import { cssNames } from "../../../utils"; -import type { KubeWatchSubscribeStoreOptions } from "../../../kube-watch-api/kube-watch-api"; +import type { SubscribeStores } from "../../../kube-watch-api/kube-watch-api"; import subscribeStoresInjectable from "../../../kube-watch-api/subscribe-stores.injectable"; -import type { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../../common/k8s-api/kube-object"; import { podsStore } from "../../+workloads-pods/pods.store"; export interface LogsDockTabProps { @@ -29,7 +26,7 @@ export interface LogsDockTabProps { interface Dependencies { model: LogTabViewModel; - subscribeStores: (stores: KubeObjectStore[], opts?: KubeWatchSubscribeStoreOptions) => Disposer; + subscribeStores: SubscribeStores; } const NonInjectedLogsDockTab = observer(({ className, tab, model, subscribeStores }: Dependencies & LogsDockTabProps) => { diff --git a/src/renderer/components/dock/terminal/create-terminal.injectable.ts b/src/renderer/components/dock/terminal/create-terminal.injectable.ts index 40bba50224..4cd4f404b8 100644 --- a/src/renderer/components/dock/terminal/create-terminal.injectable.ts +++ b/src/renderer/components/dock/terminal/create-terminal.injectable.ts @@ -7,9 +7,11 @@ import { Terminal } from "./terminal"; import type { TabId } from "../dock/store"; import type { TerminalApi } from "../../../api/terminal-api"; +export type CreateTerminal = (tabId: TabId, api: TerminalApi) => Terminal; + const createTerminalInjectable = getInjectable({ id: "create-terminal", - instantiate: () => (tabId: TabId, api: TerminalApi) => new Terminal(tabId, api), + instantiate: (): CreateTerminal => (tabId, api) => new Terminal(tabId, api), }); export default createTerminalInjectable; diff --git a/src/renderer/components/dock/terminal/dock-tab.tsx b/src/renderer/components/dock/terminal/dock-tab.tsx index 7e9eb14221..64fce73a03 100644 --- a/src/renderer/components/dock/terminal/dock-tab.tsx +++ b/src/renderer/components/dock/terminal/dock-tab.tsx @@ -34,22 +34,36 @@ class NonInjectedTerminalTab exte componentDidMount() { disposeOnUnmount(this, [ - reaction(() => this.isDisconnected === true, () => { - this.props.dockStore.closeTab(this.tabId); - }), + reaction(() => this.isDisconnected, this.close), ]); } - get tabId() { - return this.props.value.id; + private close() { + const { tabId } = this; + + if (tabId) { + this.props.dockStore.closeTab(tabId); + } } - get isDisconnected() { - return this.props.terminalStore.isDisconnected(this.tabId); + private get tabId() { + return this.props.value?.id; } - reconnect() { - this.props.terminalStore.reconnect(this.tabId); + private get isDisconnected() { + const { tabId } = this; + + return tabId + ? this.props.terminalStore.isDisconnected(tabId) + : false; + } + + private reconnect() { + const { tabId } = this; + + if (tabId) { + this.props.terminalStore.reconnect(tabId); + } } render() { diff --git a/src/renderer/components/dock/terminal/get-terminal-api.injectable.ts b/src/renderer/components/dock/terminal/get-terminal-api.injectable.ts index 2dcf0fa62b..fdb6b97145 100644 --- a/src/renderer/components/dock/terminal/get-terminal-api.injectable.ts +++ b/src/renderer/components/dock/terminal/get-terminal-api.injectable.ts @@ -3,7 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import type { TerminalApi } from "../../../api/terminal-api"; import type { TabId } from "../dock/store"; import terminalStoreInjectable from "./store.injectable"; @@ -13,7 +12,7 @@ const getTerminalApiInjectable = getInjectable({ instantiate: (di) => { const terminalStore = di.inject(terminalStoreInjectable); - return (tabId: TabId): TerminalApi => terminalStore.getTerminalApi(tabId); + return (tabId: TabId) => terminalStore.getTerminalApi(tabId); }, }); diff --git a/src/renderer/components/dock/terminal/send-command.injectable.ts b/src/renderer/components/dock/terminal/send-command.injectable.ts index edc0a2b7ed..24f7e019c8 100644 --- a/src/renderer/components/dock/terminal/send-command.injectable.ts +++ b/src/renderer/components/dock/terminal/send-command.injectable.ts @@ -4,6 +4,7 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { when } from "mobx"; +import { waitUntilDefinied } from "../../../../common/utils/wait"; import type { TerminalApi } from "../../../api/terminal-api"; import { TerminalChannels } from "../../../api/terminal-api"; import { noop } from "../../../utils"; @@ -16,7 +17,7 @@ import getTerminalApiInjectable from "./get-terminal-api.injectable"; interface Dependencies { selectTab: (tabId: TabId) => void; createTerminalTab: () => DockTab; - getTerminalApi: (tabId: TabId) => TerminalApi; + getTerminalApi: (tabId: TabId) => TerminalApi | undefined; } export interface SendCommandOptions { @@ -37,7 +38,7 @@ export interface SendCommandOptions { } const sendCommand = ({ selectTab, createTerminalTab, getTerminalApi }: Dependencies) => async (command: string, options: SendCommandOptions = {}): Promise => { - let { tabId } = options; + let tabId: string | undefined = options.tabId; if (tabId) { selectTab(tabId); @@ -45,10 +46,12 @@ const sendCommand = ({ selectTab, createTerminalTab, getTerminalApi }: Dependenc tabId = createTerminalTab().id; } - await when(() => Boolean(getTerminalApi(tabId))); - - const terminalApi = getTerminalApi(tabId); - const shellIsReady = when(() =>terminalApi.isReady); + const terminalApi = await waitUntilDefinied(() => ( + tabId + ? getTerminalApi(tabId) + : undefined + )); + const shellIsReady = when(() => terminalApi.isReady); const notifyVeryLong = setTimeout(() => { shellIsReady.cancel(); Notifications.info( diff --git a/src/renderer/components/dock/terminal/store.injectable.ts b/src/renderer/components/dock/terminal/store.injectable.ts index b04e8e932e..595307ac16 100644 --- a/src/renderer/components/dock/terminal/store.injectable.ts +++ b/src/renderer/components/dock/terminal/store.injectable.ts @@ -5,12 +5,14 @@ import { getInjectable } from "@ogre-tools/injectable"; import { TerminalStore } from "./store"; import createTerminalInjectable from "./create-terminal.injectable"; +import createTerminalApiInjectable from "../../../api/create-terminal-api.injectable"; const terminalStoreInjectable = getInjectable({ id: "terminal-store", instantiate: (di) => new TerminalStore({ createTerminal: di.inject(createTerminalInjectable), + createTerminalApi: di.inject(createTerminalApiInjectable), }), }); diff --git a/src/renderer/components/dock/terminal/store.ts b/src/renderer/components/dock/terminal/store.ts index a54dccb820..d8292d5751 100644 --- a/src/renderer/components/dock/terminal/store.ts +++ b/src/renderer/components/dock/terminal/store.ts @@ -5,16 +5,19 @@ import { action, observable } from "mobx"; import type { Terminal } from "./terminal"; -import { TerminalApi } from "../../../api/terminal-api"; +import type { TerminalApi } from "../../../api/terminal-api"; import type { DockTab, TabId } from "../dock/store"; import { WebSocketApiState } from "../../../api/websocket-api"; +import type { CreateTerminalApi } from "../../../api/create-terminal-api.injectable"; +import type { CreateTerminal } from "./create-terminal.injectable"; export interface ITerminalTab extends DockTab { node?: string; // activate node shell mode } interface Dependencies { - createTerminal: (tabId: TabId, api: TerminalApi) => Terminal; + createTerminal: CreateTerminal; + createTerminalApi: CreateTerminalApi; } export class TerminalStore { @@ -29,7 +32,7 @@ export class TerminalStore { if (this.isConnected(tab.id)) { return; } - const api = new TerminalApi({ + const api = this.dependencies.createTerminalApi({ id: tab.id, node: tab.node, }); diff --git a/src/renderer/components/dock/terminal/terminal.ts b/src/renderer/components/dock/terminal/terminal.ts index d6f1701028..ff2202c719 100644 --- a/src/renderer/components/dock/terminal/terminal.ts +++ b/src/renderer/components/dock/terminal/terminal.ts @@ -18,15 +18,22 @@ import { UserStore } from "../../../../common/user-store"; import { clipboard } from "electron"; import logger from "../../../../common/logger"; import type { TerminalConfig } from "../../../../common/user-store/preferences-helpers"; +import assert from "assert"; export class Terminal { private terminalConfig: TerminalConfig = UserStore.getInstance().terminalConfig; - public static get spawningPool() { - return document.getElementById("terminal-init"); + public static spawningPool: HTMLElement; + + static { + const pool = document.getElementById("terminal-init"); + + assert(pool, "FATAL: missing #terminal-init tag in DOM"); + + this.spawningPool = pool; } - private xterm: XTerm | null = new XTerm({ + private xterm: XTerm = new XTerm({ cursorBlink: true, cursorStyle: "bar", fontSize: this.terminalConfig.fontSize, @@ -37,14 +44,17 @@ export class Terminal { private disposer = disposer(); get elem() { - return this.xterm?.element; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.xterm.element!; } get viewport() { - return this.xterm.element.querySelector(".xterm-viewport"); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.elem.querySelector(".xterm-viewport")!; } attachTo(parentElem: HTMLElement) { + assert(this.elem, "Terminal should always be mounted somewhere"); parentElem.appendChild(this.elem); this.onActivate(); } @@ -98,11 +108,8 @@ export class Terminal { } destroy() { - if (this.xterm) { - this.disposer(); - this.xterm.dispose(); - this.xterm = null; - } + this.disposer(); + this.xterm.dispose(); } fit = () => { diff --git a/src/renderer/components/dock/terminal/view.tsx b/src/renderer/components/dock/terminal/view.tsx index ec58a2a83b..f6e213f274 100644 --- a/src/renderer/components/dock/terminal/view.tsx +++ b/src/renderer/components/dock/terminal/view.tsx @@ -15,6 +15,7 @@ import type { DockTab, DockStore } from "../dock/store"; import { withInjectables } from "@ogre-tools/injectable-react"; import dockStoreInjectable from "../dock/store.injectable"; import terminalStoreInjectable from "./store.injectable"; +import assert from "assert"; export interface TerminalWindowProps { tab: DockTab; @@ -27,13 +28,17 @@ interface Dependencies { @observer class NonInjectedTerminalWindow extends React.Component { - public elem: HTMLElement; - public terminal: Terminal; + public elem: HTMLElement | null = null; + public terminal!: Terminal; componentDidMount() { this.props.terminalStore.connect(this.props.tab); - this.terminal = this.props.terminalStore.getTerminal(this.props.tab.id); - this.terminal.attachTo(this.elem); + const terminal = this.props.terminalStore.getTerminal(this.props.tab.id); + + assert(terminal, "Terminal must be created for tab before mounting"); + this.terminal = terminal; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.terminal.attachTo(this.elem!); disposeOnUnmount(this, [ // refresh terminal available space (cols/rows) when resized @@ -46,8 +51,12 @@ class NonInjectedTerminalWindow extends React.Component { @action async reloadValues(tabId: TabId) { this.values.clearData(tabId); // reset - const { releaseName, releaseNamespace } = this.getData(tabId); + const data = this.getData(tabId); + + assert(data, "cannot reload values if no data"); + + const { releaseName, releaseNamespace } = data; const values = await getReleaseValues(releaseName, releaseNamespace, true); this.values.setData(tabId, values); } - getTabIdByRelease(releaseName: string): TabId { + getTabIdByRelease(releaseName: string) { return this.releaseNameReverseLookup.get(releaseName); } } diff --git a/src/renderer/components/dock/upgrade-chart/view.tsx b/src/renderer/components/dock/upgrade-chart/view.tsx index f35a3602b1..7c418eb1c9 100644 --- a/src/renderer/components/dock/upgrade-chart/view.tsx +++ b/src/renderer/components/dock/upgrade-chart/view.tsx @@ -16,14 +16,14 @@ import { Spinner } from "../../spinner"; import { Badge } from "../../badge"; import { EditorPanel } from "../editor-panel"; import { helmChartStore, type IChartVersion } from "../../+helm-charts/helm-chart.store"; -import type { HelmRelease, IReleaseUpdateDetails, IReleaseUpdatePayload } from "../../../../common/k8s-api/endpoints/helm-releases.api"; -import type { SelectOption } from "../../select"; +import type { HelmRelease, HelmReleaseUpdateDetails, HelmReleaseUpdatePayload } from "../../../../common/k8s-api/endpoints/helm-releases.api"; import { Select } from "../../select"; import type { IAsyncComputed } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react"; import upgradeChartTabStoreInjectable from "./store.injectable"; import updateReleaseInjectable from "../../+helm-releases/update-release/update-release.injectable"; import releasesInjectable from "../../+helm-releases/releases.injectable"; +import type { GroupBase } from "react-select"; export interface UpgradeChartProps { className?: string; @@ -33,14 +33,14 @@ export interface UpgradeChartProps { interface Dependencies { releases: IAsyncComputed; upgradeChartTabStore: UpgradeChartTabStore; - updateRelease: (name: string, namespace: string, payload: IReleaseUpdatePayload) => Promise; + updateRelease: (name: string, namespace: string, payload: HelmReleaseUpdatePayload) => Promise; } @observer export class NonInjectedUpgradeChart extends React.Component { - @observable error: string; + @observable error?: string; @observable versions = observable.array(); - @observable version: IChartVersion; + @observable version: IChartVersion | undefined = undefined; constructor(props: UpgradeChartProps & Dependencies) { super(props); @@ -70,7 +70,7 @@ export class NonInjectedUpgradeChart extends React.Component { - if (this.error) return null; + if (this.error || !this.release || !this.version || !this.value) { + return null; + } + const { version, repo } = this.version; const releaseName = this.release.getName(); const releaseNs = this.release.getNs(); @@ -122,13 +125,16 @@ export class NonInjectedUpgradeChart extends React.Component - Release {releaseName} successfully upgraded to version {version} + {"Release "} + {releaseName} + {" successfully upgraded to version "} + {version}

    ); }; - formatVersionLabel = ({ value }: SelectOption) => { - const chartName = this.release.getChart(); + formatVersionLabel = (value: IChartVersion) => { + const chartName = this.release?.getChart() ?? ""; const { repo, version } = value; return `${repo}/${chartName}-${version}`; @@ -144,19 +150,25 @@ export class NonInjectedUpgradeChart extends React.Component - Release - Namespace - Version + Release + {" "} + + Namespace + {" "} + + Version + {" "} + Upgrade version - this.handlePickFiles(event.target.files)} - /> -
    ; + return ( +
    + + this.handlePickFiles(event.target.files)} + /> +
    + ); } getIconRight(): React.ReactNode { @@ -225,3 +231,5 @@ export class FilePicker extends React.Component { } } } + +export const FilePicker = (props: FilePickerProps) => ; diff --git a/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx index 0d4c6491b1..e88492623b 100644 --- a/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx +++ b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx @@ -21,11 +21,11 @@ import getConfigurationFileModelInjectable from "../../../../common/get-configur import appVersionInjectable from "../../../../common/get-configuration-file-model/app-version/app-version.injectable"; import type { HotbarStore } from "../../../../common/hotbar-store"; -const mockHotbars: { [id: string]: any } = { +const mockHotbars: Partial> = { "1": { id: "1", name: "Default", - items: [] as any, + items: [], }, }; @@ -71,7 +71,7 @@ describe("", () => { }, hotbarIndex: () => 0, getDisplayLabel: () => "1: Default", - }) as any as HotbarStore); + }) as unknown as HotbarStore); await di.runSetups(); @@ -89,7 +89,7 @@ describe("", () => { remove: removeMock, hotbarIndex: () => 0, getDisplayLabel: () => "1: Default", - }) as any as HotbarStore); + }) as unknown as HotbarStore); await di.runSetups(); diff --git a/src/renderer/components/hotbar/hotbar-add-command.tsx b/src/renderer/components/hotbar/hotbar-add-command.tsx index 4b0af4ca19..527e00dfff 100644 --- a/src/renderer/components/hotbar/hotbar-add-command.tsx +++ b/src/renderer/components/hotbar/hotbar-add-command.tsx @@ -15,8 +15,8 @@ import uniqueHotbarNameInjectable from "../input/validators/unique-hotbar-name.i interface Dependencies { closeCommandOverlay: () => void; - addHotbar: (data: CreateHotbarData, { setActive }?: CreateHotbarOptions) => void; - uniqueHotbarName: InputValidator; + addHotbar: (data: CreateHotbarData, opts: CreateHotbarOptions) => void; + uniqueHotbarName: InputValidator; } const NonInjectedHotbarAddCommand = observer(({ closeCommandOverlay, addHotbar, uniqueHotbarName }: Dependencies) => { diff --git a/src/renderer/components/hotbar/hotbar-cell.tsx b/src/renderer/components/hotbar/hotbar-cell.tsx index c44e3c3f5f..90dfc49000 100644 --- a/src/renderer/components/hotbar/hotbar-cell.tsx +++ b/src/renderer/components/hotbar/hotbar-cell.tsx @@ -19,7 +19,7 @@ export function HotbarCell({ innerRef, children, className, ...rest }: HotbarCel const [animating, setAnimating] = useState(false); const onAnimationEnd = () => { setAnimating(false); }; const onClick = () => { - setAnimating(!className.includes("isDraggingOver")); + setAnimating(!className?.includes("isDraggingOver")); }; return ( diff --git a/src/renderer/components/hotbar/hotbar-entity-icon.tsx b/src/renderer/components/hotbar/hotbar-entity-icon.tsx index c314c38763..e4c26e45f0 100644 --- a/src/renderer/components/hotbar/hotbar-entity-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-entity-icon.tsx @@ -103,7 +103,11 @@ export class HotbarEntityIcon extends React.Component { onMenuOpen={() => this.onMenuOpen()} disabled={!entity} menuItems={this.contextMenu.menuItems} - tooltip={`${entity.getName()} (${entity.metadata.source})`} + tooltip={( + entity.metadata.source + ? `${entity.getName()} (${entity.metadata.source})` + : entity.getName() + )} {...elemProps} > { this.ledIcon } diff --git a/src/renderer/components/hotbar/hotbar-icon.tsx b/src/renderer/components/hotbar/hotbar-icon.tsx index f976ecd03e..558f306c59 100644 --- a/src/renderer/components/hotbar/hotbar-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-icon.tsx @@ -19,7 +19,7 @@ import { Tooltip } from "../tooltip"; export interface HotbarIconProps extends AvatarProps { uid: string; - source: string; + source?: string; material?: string; onMenuOpen?: () => void; active?: boolean; @@ -56,12 +56,16 @@ export const HotbarIcon = observer(({ menuItems = [], size = 40, tooltip, ...pro return (
    0 })}> - {tooltip && {tooltip}} + {tooltip && ( + + {tooltip} + + )} toggleMenu()}> + close={() => toggleMenu()} + > { menuItems.map((menuItem) => ( onMenuItemClick(menuItem)}> diff --git a/src/renderer/components/hotbar/hotbar-menu.tsx b/src/renderer/components/hotbar/hotbar-menu.tsx index c7550a0744..61e24e90aa 100644 --- a/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/src/renderer/components/hotbar/hotbar-menu.tsx @@ -44,14 +44,14 @@ class NonInjectedHotbarMenu extends React.Component item?.entity.uid == entityId); return draggableItemIndex > cellIndex ? "animateDown" : "animateUp"; @@ -111,7 +115,11 @@ class NonInjectedHotbarMenu extends React.Component {item && ( - + {(provided, snapshot) => { const style = { zIndex: defaultHotbarCells - index, @@ -142,7 +150,7 @@ class NonInjectedHotbarMenu extends React.Component
    - +
    ); } diff --git a/src/renderer/components/hotbar/hotbar-remove-command.tsx b/src/renderer/components/hotbar/hotbar-remove-command.tsx index 27189bdebb..07e37a3e80 100644 --- a/src/renderer/components/hotbar/hotbar-remove-command.tsx +++ b/src/renderer/components/hotbar/hotbar-remove-command.tsx @@ -16,58 +16,54 @@ interface Dependencies { closeCommandOverlay: () => void; hotbarStore: { hotbars: Hotbar[]; - getById: (id: string) => Hotbar | undefined; remove: (hotbar: Hotbar) => void; getDisplayLabel: (hotbar: Hotbar) => string; }; } -const NonInjectedHotbarRemoveCommand = observer(({ closeCommandOverlay, hotbarStore }: Dependencies) => { - const options = hotbarStore.hotbars.map(hotbar => ({ - value: hotbar.id, - label: hotbarStore.getDisplayLabel(hotbar), - })); +const NonInjectedHotbarRemoveCommand = observer(({ + closeCommandOverlay, + hotbarStore, +}: Dependencies) => ( + onChange(v.value)} - components={{ DropdownIndicator: null, IndicatorSeparator: null }} - menuIsOpen={true} - options={options} - autoFocus={true} - escapeClearsValue={false} - placeholder="Remove hotbar" - /> - ); -}); + closeCommandOverlay(); + // TODO: make confirm dialog injectable + ConfirmDialog.open({ + okButtonProps: { + label: "Remove Hotbar", + primary: false, + accent: true, + }, + ok: () => hotbarStore.remove(hotbar), + message: ( +
    +

    + Are you sure you want remove hotbar + {" "} + + {hotbar.name} + + ? +

    +
    + ), + }); + } } + components={{ DropdownIndicator: null, IndicatorSeparator: null }} + menuIsOpen={true} + options={hotbarStore.hotbars} + getOptionLabel={hotbar => hotbarStore.getDisplayLabel(hotbar)} + autoFocus={true} + escapeClearsValue={false} + placeholder="Remove hotbar" + /> +)); export const HotbarRemoveCommand = withInjectables(NonInjectedHotbarRemoveCommand, { getProps: (di, props) => ({ diff --git a/src/renderer/components/hotbar/hotbar-rename-command.tsx b/src/renderer/components/hotbar/hotbar-rename-command.tsx index 6b69b7b7a3..429e42598f 100644 --- a/src/renderer/components/hotbar/hotbar-rename-command.tsx +++ b/src/renderer/components/hotbar/hotbar-rename-command.tsx @@ -22,21 +22,22 @@ interface Dependencies { setHotbarName: (id: string, name: string) => void; getDisplayLabel: (hotbar: Hotbar) => string; }; - uniqueHotbarName: InputValidator; + uniqueHotbarName: InputValidator; } -const NonInjectedHotbarRenameCommand = observer(({ closeCommandOverlay, hotbarStore, uniqueHotbarName }: Dependencies) => { +const NonInjectedHotbarRenameCommand = observer(({ + closeCommandOverlay, + hotbarStore, + uniqueHotbarName, +}: Dependencies) => { const [hotbarId, setHotbarId] = useState(""); const [hotbarName, setHotbarName] = useState(""); - const options = hotbarStore.hotbars.map(hotbar => ({ - value: hotbar.id, - label: hotbarStore.getDisplayLabel(hotbar), - })); - - const onSelect = (id: string) => { - setHotbarId(id); - setHotbarName(hotbarStore.getById(id).name); + const onSelect = (hotbar: Hotbar | null) => { + if (hotbar) { + setHotbarId(hotbar.id); + setHotbarName(hotbar.name); + } }; const onSubmit = (name: string) => { if (!name.trim()) { @@ -72,10 +73,11 @@ const NonInjectedHotbarRenameCommand = observer(({ closeCommandOverlay, hotbarSt { + switch (value) { + case hotbarAddAction: + return commandOverlay.open(); + case hotbarRemoveAction: + return commandOverlay.open(); + case hotbarRenameAction: + return commandOverlay.open(); - return ( -