From dcf253e7d5fb1eba2ddaaffcd62e4f93a4d40f33 Mon Sep 17 00:00:00 2001 From: Panu Horsmalahti Date: Wed, 2 Dec 2020 09:55:52 +0200 Subject: [PATCH] Add eslint rule padding-line-between-statements (#1593) Signed-off-by: Panu Horsmalahti --- .eslintrc.js | 27 +++++++++++++ build/build_tray_icon.ts | 4 +- build/download_kubectl.ts | 10 +++++ build/notarize.js | 2 + extensions/example-extension/page.tsx | 2 + .../kube-object-event-status/src/resolver.tsx | 7 ++++ .../src/metrics-feature.ts | 2 + extensions/node-menu/src/node-menu.tsx | 2 + extensions/pod-menu/src/logs-menu.tsx | 4 ++ extensions/pod-menu/src/shell-menu.tsx | 5 +++ .../telemetry/src/telemetry-preference.tsx | 1 + extensions/telemetry/src/tracker.ts | 10 +++++ integration/__tests__/app.tests.ts | 15 ++++--- integration/helpers/utils.ts | 2 + src/common/__tests__/cluster-store.test.ts | 31 +++++++++++++++ src/common/__tests__/event-bus.test.ts | 1 + src/common/__tests__/search-store.test.ts | 2 +- src/common/__tests__/user-store.test.ts | 1 + src/common/__tests__/workspace-store.test.ts | 2 +- src/common/base-store.ts | 7 ++++ src/common/cluster-ipc.ts | 7 ++++ src/common/cluster-store.ts | 21 ++++++++++ src/common/custom-errors.ts | 1 + src/common/event-emitter.ts | 3 ++ src/common/ipc.ts | 5 +++ src/common/kube-helpers.ts | 11 +++++- src/common/prometheus-providers.ts | 1 + src/common/rbac.ts | 2 + src/common/register-protocol.ts | 1 + src/common/request.ts | 1 + src/common/search-store.ts | 10 +++++ src/common/system-ca.ts | 2 + src/common/user-store.ts | 5 +++ src/common/utils/autobind.ts | 3 +- src/common/utils/buildUrl.ts | 2 + src/common/utils/camelCase.ts | 2 + src/common/utils/debouncePromise.ts | 1 + src/common/utils/downloadFile.ts | 1 + src/common/utils/getRandId.ts | 1 + src/common/utils/saveToAppFiles.ts | 2 + src/common/utils/singleton.ts | 1 + src/common/utils/splitArray.ts | 2 + src/common/utils/tar.ts | 3 ++ src/common/vars.ts | 1 + src/common/workspace-store.ts | 14 +++++++ src/extensions/cluster-feature.ts | 3 ++ src/extensions/core-api/app.ts | 1 + src/extensions/extension-discovery.ts | 6 ++- src/extensions/extension-installer.ts | 1 + src/extensions/extension-loader.ts | 5 +++ src/extensions/extension-store.ts | 2 + src/extensions/extensions-store.ts | 4 ++ src/extensions/lens-extension.ts | 2 + src/extensions/lens-main-extension.ts | 1 + src/extensions/lens-renderer-extension.ts | 1 + .../__tests__/page-registry.test.ts | 3 ++ src/extensions/registries/base-registry.ts | 2 + .../registries/kube-object-detail-registry.ts | 2 + .../registries/page-menu-registry.ts | 4 ++ src/extensions/registries/page-registry.ts | 6 +++ src/extensions/stores/cluster-store.ts | 1 + src/main/__test__/cluster.test.ts | 7 ++++ src/main/__test__/kube-auth-proxy.test.ts | 6 +++ src/main/__test__/kubeconfig-manager.test.ts | 4 +- src/main/app-updater.ts | 1 + .../base-cluster-detector.ts | 1 + .../cluster-detectors/cluster-id-detector.ts | 3 ++ .../cluster-detectors/detector-registry.ts | 6 +++ .../distribution-detector.ts | 11 ++++++ .../cluster-detectors/last-seen-detector.ts | 1 + .../cluster-detectors/nodes-count-detector.ts | 2 + .../cluster-detectors/version-detector.ts | 2 + src/main/cluster-manager.ts | 5 +++ src/main/cluster.ts | 30 ++++++++++++++ src/main/context-handler.ts | 15 +++++++ src/main/exit-app.ts | 1 + src/main/extension-filesystem.ts | 3 ++ src/main/helm/helm-chart-manager.ts | 9 +++++ src/main/helm/helm-cli.ts | 1 + src/main/helm/helm-release-manager.ts | 15 ++++++- src/main/helm/helm-repo-manager.ts | 14 +++++++ src/main/helm/helm-service.ts | 17 ++++++++ src/main/index.ts | 5 +++ src/main/kube-auth-proxy.ts | 6 +++ src/main/kubeconfig-manager.ts | 5 ++- src/main/kubectl.ts | 36 +++++++++++++++-- src/main/kubectl_spec.ts | 6 +++ src/main/lens-binary.ts | 11 +++++- src/main/lens-proxy.ts | 19 +++++++++ src/main/logger.ts | 3 -- src/main/menu.ts | 15 ++++--- src/main/node-shell-session.ts | 11 +++++- src/main/port.ts | 3 ++ src/main/port_spec.ts | 2 + src/main/prometheus/helm.ts | 3 ++ src/main/prometheus/lens.ts | 2 + src/main/prometheus/operator.ts | 4 ++ src/main/prometheus/provider-registry.ts | 1 + src/main/prometheus/stacklight.ts | 2 + src/main/resource-applier.ts | 13 +++++++ src/main/router.ts | 12 ++++++ src/main/routes/helm-route.ts | 21 ++++++++++ src/main/routes/kubeconfig-route.ts | 4 +- src/main/routes/metrics-route.ts | 8 ++++ src/main/routes/port-forward-route.ts | 8 +++- src/main/routes/resource-applier-route.ts | 2 + src/main/routes/watch-route.ts | 4 ++ src/main/shell-session.ts | 10 +++++ src/main/shell-sync.ts | 3 +- src/main/tray.ts | 7 ++++ src/main/window-manager.ts | 5 +++ src/migrations/cluster-store/2.0.0-beta.2.ts | 1 + src/migrations/cluster-store/2.4.1.ts | 2 + src/migrations/cluster-store/2.6.0-beta.2.ts | 3 ++ src/migrations/cluster-store/2.6.0-beta.3.ts | 7 ++++ src/migrations/cluster-store/2.7.0-beta.0.ts | 2 + src/migrations/cluster-store/2.7.0-beta.1.ts | 5 +++ src/migrations/cluster-store/3.6.0-beta.1.ts | 1 + src/migrations/cluster-store/snap.ts | 3 ++ src/renderer/api/api-manager.ts | 2 + src/renderer/api/endpoints/cluster.api.ts | 1 + src/renderer/api/endpoints/crd.api.ts | 5 +++ src/renderer/api/endpoints/cron-job.api.ts | 3 ++ src/renderer/api/endpoints/daemon-set.api.ts | 1 + src/renderer/api/endpoints/deployment.api.ts | 3 ++ src/renderer/api/endpoints/endpoint.api.ts | 5 +++ src/renderer/api/endpoints/events.api.ts | 3 ++ src/renderer/api/endpoints/helm-charts.api.ts | 2 + .../api/endpoints/helm-releases.api.ts | 17 ++++++++ src/renderer/api/endpoints/hpa.api.ts | 6 +++ src/renderer/api/endpoints/ingress.api.ts | 8 +++- src/renderer/api/endpoints/job.api.ts | 4 ++ src/renderer/api/endpoints/metrics.api.ts | 8 ++++ .../api/endpoints/network-policy.api.ts | 2 + src/renderer/api/endpoints/nodes.api.ts | 9 +++++ .../endpoints/persistent-volume-claims.api.ts | 5 +++ .../api/endpoints/persistent-volume.api.ts | 5 +++ .../api/endpoints/poddisruptionbudget.api.ts | 1 + src/renderer/api/endpoints/pods.api.ts | 27 +++++++++++++ .../api/endpoints/podsecuritypolicy.api.ts | 1 + src/renderer/api/endpoints/replica-set.api.ts | 1 + .../api/endpoints/resource-applier.api.ts | 3 ++ .../api/endpoints/resource-quota.api.ts | 1 + .../endpoints/selfsubjectrulesreviews.api.ts | 4 ++ src/renderer/api/endpoints/service.api.ts | 4 ++ .../api/endpoints/stateful-set.api.ts | 1 + .../api/endpoints/storage-class.api.ts | 1 + src/renderer/api/json-api.ts | 11 ++++++ src/renderer/api/kube-api-parse.ts | 7 +++- src/renderer/api/kube-api.ts | 14 +++++++ src/renderer/api/kube-json-api.ts | 2 + src/renderer/api/kube-object.ts | 7 ++++ src/renderer/api/kube-watch-api.ts | 14 +++++++ src/renderer/api/terminal-api.ts | 11 ++++++ src/renderer/api/websocket-api.ts | 7 ++++ src/renderer/api/workload-kube-object.ts | 5 +++ src/renderer/bootstrap.tsx | 1 + .../components/+add-cluster/add-cluster.tsx | 20 ++++++++++ .../+apps-helm-charts/helm-chart-details.tsx | 4 ++ .../+apps-helm-charts/helm-chart.store.ts | 5 +++ .../+apps-helm-charts/helm-charts.tsx | 1 + .../+apps-releases/release-details.tsx | 15 +++++++ .../+apps-releases/release-menu.tsx | 4 ++ .../release-rollback-dialog.tsx | 5 +++ .../+apps-releases/release.store.ts | 13 +++++++ .../components/+apps-releases/releases.tsx | 3 ++ src/renderer/components/+apps/apps.tsx | 1 + .../+cluster-settings/cluster-settings.tsx | 2 + .../components/cluster-icon-setting.tsx | 3 ++ .../components/cluster-prometheus-setting.tsx | 6 +++ .../components/install-feature.tsx | 2 + .../components/remove-cluster-button.tsx | 2 + .../components/+cluster-settings/features.tsx | 1 + .../components/+cluster-settings/status.tsx | 2 + .../components/+cluster/cluster-issues.tsx | 7 ++++ .../+cluster/cluster-metric-switchers.tsx | 1 + .../components/+cluster/cluster-metrics.tsx | 4 ++ .../+cluster/cluster-pie-charts.tsx | 5 +++ .../components/+cluster/cluster.store.ts | 5 +++ src/renderer/components/+cluster/cluster.tsx | 2 + .../+config-autoscalers/hpa-details.tsx | 6 +++ .../components/+config-autoscalers/hpa.tsx | 2 + .../+config-maps/config-map-details.tsx | 4 ++ .../pod-disruption-budgets-details.tsx | 2 + .../add-quota-dialog.tsx | 4 ++ .../resource-quota-details.tsx | 3 ++ .../+config-secrets/add-secret-dialog.tsx | 9 +++++ .../+config-secrets/secret-details.tsx | 7 ++++ src/renderer/components/+config/config.tsx | 6 +++ .../+custom-resources/crd-details.tsx | 4 ++ .../components/+custom-resources/crd-list.tsx | 5 +++ .../crd-resource-details.tsx | 2 + .../+custom-resources/crd-resources.tsx | 6 +++ .../components/+custom-resources/crd.store.ts | 5 +++ .../components/+events/event-details.tsx | 2 + .../components/+events/event.store.ts | 5 +++ src/renderer/components/+events/events.tsx | 3 ++ .../components/+events/kube-event-details.tsx | 3 ++ .../components/+events/kube-event-icon.tsx | 3 ++ .../components/+extensions/extensions.tsx | 13 +++++++ .../components/+landing-page/landing-page.tsx | 1 + .../+namespaces/add-namespace-dialog.tsx | 2 + .../+namespaces/namespace-details.tsx | 3 ++ .../+namespaces/namespace-select.tsx | 8 ++++ .../components/+namespaces/namespace.store.ts | 5 +++ .../+network-endpoints/endpoint-details.tsx | 2 + .../endpoint-subset-list.tsx | 3 ++ .../+network-endpoints/endpoints.tsx | 1 + .../+network-ingresses/ingress-charts.tsx | 1 + .../+network-ingresses/ingress-details.tsx | 4 ++ .../+network-ingresses/ingresses.tsx | 1 + .../network-policy-details.tsx | 15 +++++++ .../service-details-endpoint.tsx | 3 ++ .../+network-services/service-details.tsx | 2 + .../service-port-component.tsx | 3 ++ src/renderer/components/+network/network.tsx | 5 +++ .../components/+nodes/node-charts.tsx | 1 + .../components/+nodes/node-details.tsx | 3 ++ src/renderer/components/+nodes/nodes.store.ts | 3 ++ src/renderer/components/+nodes/nodes.tsx | 9 +++++ .../pod-security-policy-details.tsx | 3 ++ .../components/+preferences/preferences.tsx | 7 ++++ .../storage-class-details.tsx | 2 + .../volume-claim-details.tsx | 2 + .../+storage-volume-claims/volume-claims.tsx | 1 + .../+storage-volumes/volume-details.tsx | 2 + .../components/+storage-volumes/volumes.tsx | 1 + src/renderer/components/+storage/storage.tsx | 1 + .../add-role-binding-dialog.tsx | 12 ++++++ .../role-binding-details.tsx | 5 +++ .../role-bindings.store.ts | 3 ++ .../add-role-dialog.tsx | 2 + .../+user-management-roles/role-details.tsx | 2 + .../+user-management-roles/roles.store.ts | 1 + .../create-service-account-dialog.tsx | 3 ++ .../service-accounts-details.tsx | 10 +++++ .../service-accounts-secret.tsx | 2 + .../service-accounts.store.ts | 1 + .../service-accounts.tsx | 1 + .../+user-management/user-management.tsx | 3 ++ .../components/+whats-new/whats-new.tsx | 1 + .../+workloads-cronjobs/cronjob-details.tsx | 3 ++ .../cronjob-trigger-dialog.tsx | 3 ++ .../+workloads-cronjobs/cronjob.store.ts | 4 ++ .../+workloads-cronjobs/cronjobs.tsx | 1 + .../daemonset-details.tsx | 2 + .../+workloads-daemonsets/daemonsets.store.ts | 4 ++ .../deployment-details.tsx | 4 ++ .../deployment-scale-dialog.test.tsx | 9 +++++ .../deployment-scale-dialog.tsx | 5 +++ .../deployments.store.ts | 4 ++ .../+workloads-deployments/deployments.tsx | 3 ++ .../+workloads-jobs/job-details.tsx | 3 ++ .../components/+workloads-jobs/job.store.ts | 3 ++ .../components/+workloads-jobs/jobs.tsx | 1 + .../+workloads-overview/overview-statuses.tsx | 1 + .../overview-workload-status.tsx | 3 ++ .../+workloads-overview/overview.tsx | 10 +++++ .../+workloads-pods/pod-container-env.tsx | 12 ++++++ .../+workloads-pods/pod-container-port.tsx | 3 ++ .../pod-details-affinities.tsx | 2 + .../+workloads-pods/pod-details-container.tsx | 5 +++ .../+workloads-pods/pod-details-list.tsx | 8 ++++ .../+workloads-pods/pod-details-secrets.tsx | 1 + .../+workloads-pods/pod-details-statuses.tsx | 2 + .../pod-details-tolerations.tsx | 3 ++ .../+workloads-pods/pod-details.tsx | 5 +++ .../components/+workloads-pods/pods.store.ts | 9 +++++ .../components/+workloads-pods/pods.tsx | 2 + .../replicaset-details.tsx | 2 + .../replicasets.store.ts | 1 + .../+workloads-replicasets/replicasets.tsx | 2 + .../statefulset-details.tsx | 2 + .../statefulset-scale-dialog.test.tsx | 9 +++++ .../statefulset-scale-dialog.tsx | 5 +++ .../statefulset.store.ts | 4 ++ .../+workloads-statefulsets/statefulsets.tsx | 2 + .../components/+workloads/workloads.tsx | 7 ++++ .../components/+workspaces/workspace-menu.tsx | 3 ++ .../components/+workspaces/workspaces.tsx | 10 +++++ .../components/ace-editor/ace-editor.tsx | 5 +++ .../add-remove-buttons/add-remove-buttons.tsx | 2 + src/renderer/components/animate/animate.tsx | 3 ++ src/renderer/components/app-init/app-init.tsx | 3 ++ src/renderer/components/app.tsx | 15 +++++++ src/renderer/components/badge/badge.tsx | 1 + src/renderer/components/button/button.tsx | 1 + src/renderer/components/chart/bar-chart.tsx | 14 +++++++ src/renderer/components/chart/chart.tsx | 12 ++++++ src/renderer/components/chart/pie-chart.tsx | 5 +++ .../components/chart/useRealTimeMetrics.ts | 2 + .../components/chart/zebra-stripes.plugin.ts | 3 ++ src/renderer/components/checkbox/checkbox.tsx | 2 + .../components/clipboard/clipboard.tsx | 4 ++ .../components/cluster-icon/cluster-icon.tsx | 1 + .../components/cluster-manager/bottom-bar.tsx | 2 + .../cluster-manager/cluster-manager.tsx | 2 + .../cluster-manager/cluster-status.tsx | 3 ++ .../cluster-manager/cluster-view.tsx | 1 + .../cluster-manager/clusters-menu.tsx | 7 ++++ .../components/cluster-manager/lens-views.ts | 4 ++ .../confirm-dialog/confirm-dialog.tsx | 1 + src/renderer/components/dialog/dialog.tsx | 8 ++++ .../components/dialog/logs-dialog.tsx | 1 + .../components/dock/create-resource.tsx | 5 +++ .../components/dock/dock-tab.store.ts | 4 ++ src/renderer/components/dock/dock-tab.tsx | 1 + src/renderer/components/dock/dock.store.ts | 12 ++++++ src/renderer/components/dock/dock.tsx | 10 +++++ .../components/dock/edit-resource.store.ts | 6 +++ .../components/dock/edit-resource.tsx | 6 +++ src/renderer/components/dock/editor-panel.tsx | 3 ++ src/renderer/components/dock/info-panel.tsx | 5 +++ .../components/dock/install-chart.store.ts | 6 ++- .../components/dock/install-chart.tsx | 6 +++ .../components/dock/pod-log-controls.tsx | 4 ++ src/renderer/components/dock/pod-log-list.tsx | 17 ++++++++ .../components/dock/pod-logs.store.ts | 15 +++++++ src/renderer/components/dock/pod-logs.tsx | 5 +++ .../components/dock/terminal-window.tsx | 1 + .../components/dock/terminal.store.ts | 10 ++++- src/renderer/components/dock/terminal.ts | 8 ++++ .../components/dock/upgrade-chart.store.ts | 15 +++++++ .../components/dock/upgrade-chart.tsx | 8 ++++ .../components/drawer/drawer-item-labels.tsx | 2 + .../components/drawer/drawer-item.tsx | 1 + .../drawer/drawer-param-toggler.tsx | 1 + .../components/drawer/drawer-title.tsx | 1 + src/renderer/components/drawer/drawer.tsx | 8 ++++ .../error-boundary/error-boundary.tsx | 3 ++ .../components/file-picker/file-picker.tsx | 9 +++++ src/renderer/components/icon/icon.tsx | 8 ++++ .../components/input/drop-file-input.tsx | 6 +++ src/renderer/components/input/file-input.tsx | 5 +++ src/renderer/components/input/input.tsx | 20 ++++++++++ .../components/input/input_validators.ts | 2 + .../components/input/search-input-url.tsx | 2 + .../components/input/search-input.tsx | 4 ++ .../item-object-list/filter-icon.tsx | 1 + .../item-object-list/item-list-layout.tsx | 39 +++++++++++++++++++ .../item-object-list/page-filters-list.tsx | 3 ++ .../item-object-list/page-filters-select.tsx | 9 +++++ .../item-object-list/page-filters.store.ts | 8 ++++ .../kube-object-status-icon.tsx | 6 +++ .../kube-object/kube-object-details.tsx | 8 ++++ .../kube-object/kube-object-list-layout.tsx | 1 + .../kube-object/kube-object-menu.tsx | 8 ++++ .../kube-object/kube-object-meta.tsx | 3 ++ .../kubeconfig-dialog/kubeconfig-dialog.tsx | 3 ++ .../components/layout/login-layout.tsx | 1 + .../components/layout/main-layout.tsx | 2 + .../components/layout/page-layout.tsx | 2 + src/renderer/components/layout/sidebar.tsx | 12 ++++++ src/renderer/components/layout/sub-header.tsx | 2 + src/renderer/components/layout/sub-title.tsx | 2 + src/renderer/components/layout/tab-layout.tsx | 2 + .../components/layout/wizard-layout.tsx | 1 + .../line-progress/line-progress.tsx | 2 + .../markdown-viewer/markdown-viewer.tsx | 1 + src/renderer/components/menu/menu-actions.tsx | 6 +++ src/renderer/components/menu/menu.tsx | 24 ++++++++++++ src/renderer/components/no-items/no-items.tsx | 1 + .../notifications/notifications.store.ts | 5 +++ .../notifications/notifications.tsx | 4 ++ src/renderer/components/radio/radio.tsx | 5 +++ .../resizing-anchor/resizing-anchor.tsx | 8 ++++ .../resource-metrics-text.tsx | 1 + src/renderer/components/select/select.tsx | 7 ++++ src/renderer/components/slider/slider.tsx | 1 + .../components/spinner/cube-spinner.tsx | 1 + .../components/status-brick/status-brick.tsx | 1 + src/renderer/components/stepper/stepper.tsx | 3 ++ src/renderer/components/table/table-cell.tsx | 6 +++ src/renderer/components/table/table-head.tsx | 1 + src/renderer/components/table/table-row.tsx | 1 + src/renderer/components/table/table.tsx | 19 +++++++++ src/renderer/components/tabs/tabs.tsx | 8 ++++ src/renderer/components/tooltip/tooltip.tsx | 9 +++++ .../components/tooltip/withTooltip.tsx | 3 ++ .../components/virtual-list/virtual-list.tsx | 7 ++++ src/renderer/components/wizard/wizard.tsx | 9 +++++ src/renderer/hooks/useInterval.ts | 1 + src/renderer/hooks/useStorage.ts | 1 + src/renderer/i18n.ts | 2 + src/renderer/item.store.ts | 15 +++++++ src/renderer/kube-object.store.ts | 15 +++++++ src/renderer/navigation.ts | 7 ++++ src/renderer/theme.store.ts | 5 +++ .../utils/__tests__/formatDuration.test.ts | 1 + src/renderer/utils/cancelableFetch.ts | 6 ++- src/renderer/utils/convertCpu.ts | 2 + src/renderer/utils/convertMemory.ts | 2 + src/renderer/utils/copyToClipboard.ts | 4 ++ src/renderer/utils/createStorage.ts | 6 +++ src/renderer/utils/cssNames.ts | 2 + src/renderer/utils/cssVar.ts | 1 + src/renderer/utils/formatDuration.ts | 1 - src/renderer/utils/interval.ts | 3 ++ src/renderer/utils/metricUnitsToNumber.ts | 1 + src/renderer/utils/prevDefault.ts | 1 + src/renderer/utils/saveFile.ts | 1 + 401 files changed, 2018 insertions(+), 40 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index d59ea493e5..fad2b04a6a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -49,6 +49,15 @@ module.exports = { "object-shorthand": "error", "prefer-template": "error", "template-curly-spacing": "error", + "padding-line-between-statements": [ + "error", + { "blankLine": "always", "prev": "*", "next": "return" }, + { "blankLine": "always", "prev": "*", "next": "block-like" }, + { "blankLine": "always", "prev": "*", "next": "function" }, + { "blankLine": "always", "prev": "*", "next": "class" }, + { "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" }, + { "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]}, + ] } }, { @@ -94,6 +103,15 @@ module.exports = { "object-shorthand": "error", "prefer-template": "error", "template-curly-spacing": "error", + "padding-line-between-statements": [ + "error", + { "blankLine": "always", "prev": "*", "next": "return" }, + { "blankLine": "always", "prev": "*", "next": "block-like" }, + { "blankLine": "always", "prev": "*", "next": "function" }, + { "blankLine": "always", "prev": "*", "next": "class" }, + { "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" }, + { "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]}, + ] }, }, { @@ -146,6 +164,15 @@ module.exports = { "object-shorthand": "error", "prefer-template": "error", "template-curly-spacing": "error", + "padding-line-between-statements": [ + "error", + { "blankLine": "always", "prev": "*", "next": "return" }, + { "blankLine": "always", "prev": "*", "next": "block-like" }, + { "blankLine": "always", "prev": "*", "next": "function" }, + { "blankLine": "always", "prev": "*", "next": "class" }, + { "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" }, + { "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]}, + ] }, } ] diff --git a/build/build_tray_icon.ts b/build/build_tray_icon.ts index e02d884412..3aded6fdf5 100644 --- a/build/build_tray_icon.ts +++ b/build/build_tray_icon.ts @@ -17,14 +17,15 @@ export async function generateTrayIcon( outputFilename += shouldUseDarkColors ? "_dark" : ""; dpiSuffix = dpiSuffix !== "1x" ? `@${dpiSuffix}` : ""; const pngIconDestPath = path.resolve(outputFolder, `${outputFilename}${dpiSuffix}.png`); + try { // Modify .SVG colors const trayIconColor = shouldUseDarkColors ? "white" : "black"; const svgDom = await jsdom.JSDOM.fromFile(svgIconPath); const svgRoot = svgDom.window.document.body.getElementsByTagName("svg")[0]; + svgRoot.innerHTML += ``; const svgIconBuffer = Buffer.from(svgRoot.outerHTML); - // Resize and convert to .PNG const pngIconBuffer: Buffer = await sharp(svgIconBuffer) .resize({ width: pixelSize, height: pixelSize }) @@ -45,6 +46,7 @@ const iconSizes: Record = { "2x": 32, "3x": 48, }; + Object.entries(iconSizes).forEach(([dpiSuffix, pixelSize]) => { generateTrayIcon({ dpiSuffix, pixelSize, shouldUseDarkColors: false }); generateTrayIcon({ dpiSuffix, pixelSize, shouldUseDarkColors: true }); diff --git a/build/download_kubectl.ts b/build/download_kubectl.ts index 8c405dee5a..f046b23e30 100644 --- a/build/download_kubectl.ts +++ b/build/download_kubectl.ts @@ -15,6 +15,7 @@ class KubectlDownloader { constructor(clusterVersion: string, platform: string, arch: string, target: string) { this.kubectlVersion = clusterVersion; const binaryName = platform === "windows" ? "kubectl.exe" : "kubectl"; + this.url = `https://storage.googleapis.com/kubernetes-release/release/v${this.kubectlVersion}/bin/${platform}/${arch}/${binaryName}`; this.dirname = path.dirname(target); this.path = target; @@ -30,16 +31,20 @@ class KubectlDownloader { if (response.headers["etag"]) { return response.headers["etag"].replace(/"/g, ""); } + return ""; } public async checkBinary() { const exists = await pathExists(this.path); + if (exists) { const hash = md5File.sync(this.path); const etag = await this.urlEtag(); + if(hash == etag) { console.log("Kubectl md5sum matches the remote etag"); + return true; } @@ -52,13 +57,16 @@ class KubectlDownloader { public async downloadKubectl() { const exists = await this.checkBinary(); + if(exists) { console.log("Already exists and is valid"); + return; } await ensureDir(path.dirname(this.path), 0o755); const file = fs.createWriteStream(this.path); + console.log(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`); const requestOpts: request.UriOptions & request.CoreOptions = { uri: this.url, @@ -78,6 +86,7 @@ class KubectlDownloader { fs.unlink(this.path, () => {}); throw(error); }); + return new Promise((resolve, reject) => { file.on("close", () => { console.log("kubectl binary download closed"); @@ -103,6 +112,7 @@ const downloads = [ downloads.forEach((dlOpts) => { console.log(dlOpts); const downloader = new KubectlDownloader(downloadVersion, dlOpts.platform, dlOpts.arch, dlOpts.target); + console.log(`Downloading: ${JSON.stringify(dlOpts)}`); downloader.downloadKubectl().then(() => downloader.checkBinary().then(() => console.log("Download complete"))); }); diff --git a/build/notarize.js b/build/notarize.js index 5b3bcba4b7..ef4144993c 100644 --- a/build/notarize.js +++ b/build/notarize.js @@ -2,9 +2,11 @@ const { notarize } = require("electron-notarize"); exports.default = async function notarizing(context) { const { electronPlatformName, appOutDir } = context; + if (electronPlatformName !== "darwin") { return; } + if (!process.env.APPLEID || !process.env.APPLEIDPASS) { return; } diff --git a/extensions/example-extension/page.tsx b/extensions/example-extension/page.tsx index e9eb361ceb..3318cafc00 100644 --- a/extensions/example-extension/page.tsx +++ b/extensions/example-extension/page.tsx @@ -10,6 +10,7 @@ export function ExampleIcon(props: Component.IconProps) { export class ExamplePage extends React.Component<{ extension: LensRendererExtension }> { deactivate = () => { const { extension } = this.props; + extension.disable(); }; @@ -17,6 +18,7 @@ export class ExamplePage extends React.Component<{ extension: LensRendererExtens const doodleStyle = { width: "200px" }; + return (
diff --git a/extensions/kube-object-event-status/src/resolver.tsx b/extensions/kube-object-event-status/src/resolver.tsx index e3921a9cd0..69691c2e79 100644 --- a/extensions/kube-object-event-status/src/resolver.tsx +++ b/extensions/kube-object-event-status/src/resolver.tsx @@ -4,10 +4,12 @@ export function resolveStatus(object: K8sApi.KubeObject): K8sApi.KubeObjectStatu const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi); const events = (eventStore as K8sApi.EventStore).getEventsByObject(object); const warnings = events.filter(evt => evt.isWarning()); + if (!events.length || !warnings.length) { return null; } const event = [...warnings, ...events][0]; // get latest event + return { level: K8sApi.KubeObjectStatusLevel.WARNING, text: `${event.message}`, @@ -22,10 +24,12 @@ export function resolveStatusForPods(pod: K8sApi.Pod): K8sApi.KubeObjectStatus { const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi); const events = (eventStore as K8sApi.EventStore).getEventsByObject(pod); const warnings = events.filter(evt => evt.isWarning()); + if (!events.length || !warnings.length) { return null; } const event = [...warnings, ...events][0]; // get latest event + return { level: K8sApi.KubeObjectStatusLevel.WARNING, text: `${event.message}`, @@ -37,13 +41,16 @@ export function resolveStatusForCronJobs(cronJob: K8sApi.CronJob): K8sApi.KubeOb const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi); let events = (eventStore as K8sApi.EventStore).getEventsByObject(cronJob); const warnings = events.filter(evt => evt.isWarning()); + if (cronJob.isNeverRun()) { events = events.filter(event => event.reason != "FailedNeedsStart"); } + if (!events.length || !warnings.length) { return null; } const event = [...warnings, ...events][0]; // get latest event + return { level: K8sApi.KubeObjectStatusLevel.WARNING, text: `${event.message}`, diff --git a/extensions/metrics-cluster-feature/src/metrics-feature.ts b/extensions/metrics-cluster-feature/src/metrics-feature.ts index 904591d2a3..e29a9156bd 100644 --- a/extensions/metrics-cluster-feature/src/metrics-feature.ts +++ b/extensions/metrics-cluster-feature/src/metrics-feature.ts @@ -53,6 +53,7 @@ export class MetricsFeature extends ClusterFeature.Feature { // Check if there are storageclasses const storageClassApi = K8sApi.forCluster(cluster, K8sApi.StorageClass); const scs = await storageClassApi.list(); + this.templateContext.persistence.enabled = scs.some(sc => ( sc.metadata?.annotations?.["storageclass.kubernetes.io/is-default-class"] === "true" || sc.metadata?.annotations?.["storageclass.beta.kubernetes.io/is-default-class"] === "true" @@ -69,6 +70,7 @@ export class MetricsFeature extends ClusterFeature.Feature { try { const statefulSet = K8sApi.forCluster(cluster, K8sApi.StatefulSet); const prometheus = await statefulSet.get({name: "prometheus", namespace: "lens-metrics"}); + if (prometheus?.kind) { this.status.installed = true; this.status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1]; diff --git a/extensions/node-menu/src/node-menu.tsx b/extensions/node-menu/src/node-menu.tsx index 7284518ac4..cd4075f532 100644 --- a/extensions/node-menu/src/node-menu.tsx +++ b/extensions/node-menu/src/node-menu.tsx @@ -6,6 +6,7 @@ export interface NodeMenuProps extends Component.KubeObjectMenuProps { const command = `kubectl drain ${nodeName} --delete-local-data --ignore-daemonsets --force`; + Component.ConfirmDialog.open({ ok: () => sendToTerminal(command), labelOk: `Drain Node`, diff --git a/extensions/pod-menu/src/logs-menu.tsx b/extensions/pod-menu/src/logs-menu.tsx index 2556691ca2..dfe3870d12 100644 --- a/extensions/pod-menu/src/logs-menu.tsx +++ b/extensions/pod-menu/src/logs-menu.tsx @@ -8,6 +8,7 @@ export class PodLogsMenu extends React.Component { showLogs(container: K8sApi.IPodContainer) { Navigation.hideDetails(); const pod = this.props.object; + Component.createPodLogsTab({ pod, containers: pod.getContainers(), @@ -22,7 +23,9 @@ export class PodLogsMenu extends React.Component { const { object: pod, toolbar } = this.props; const containers = pod.getAllContainers(); const statuses = pod.getContainerStatuses(); + if (!containers.length) return null; + return ( this.showLogs(containers[0]))}> @@ -40,6 +43,7 @@ export class PodLogsMenu extends React.Component { className={Util.cssNames(Object.keys(status.state)[0], { ready: status.ready })} /> ) : null; + return ( this.showLogs(container))} className="flex align-center"> {brick} diff --git a/extensions/pod-menu/src/shell-menu.tsx b/extensions/pod-menu/src/shell-menu.tsx index d33db02d8c..a93739e89f 100644 --- a/extensions/pod-menu/src/shell-menu.tsx +++ b/extensions/pod-menu/src/shell-menu.tsx @@ -12,9 +12,11 @@ export class PodShellMenu extends React.Component { const { object: pod } = this.props; const containerParam = container ? `-c ${container}` : ""; let command = `kubectl exec -i -t -n ${pod.getNs()} ${pod.getName()} ${containerParam} "--"`; + if (window.navigator.platform !== "Win32") { command = `exec ${command}`; } + if (pod.getSelectedNodeOs() === "windows") { command = `${command} powershell`; } else { @@ -34,7 +36,9 @@ export class PodShellMenu extends React.Component { render() { const { object, toolbar } = this.props; const containers = object.getRunningContainers(); + if (!containers.length) return null; + return ( this.execShell(containers[0].name))}> @@ -46,6 +50,7 @@ export class PodShellMenu extends React.Component { { containers.map(container => { const { name } = container; + return ( this.execShell(name))} className="flex align-center"> diff --git a/extensions/telemetry/src/telemetry-preference.tsx b/extensions/telemetry/src/telemetry-preference.tsx index f96ed43fb8..b1ae12e5c3 100644 --- a/extensions/telemetry/src/telemetry-preference.tsx +++ b/extensions/telemetry/src/telemetry-preference.tsx @@ -7,6 +7,7 @@ import { TelemetryPreferencesStore } from "./telemetry-preferences-store"; export class TelemetryPreferenceInput extends React.Component<{telemetry: TelemetryPreferencesStore}, {}> { render() { const { telemetry } = this.props; + return ( { this.event(ev.name, ev.action, ev.params); }; + this.eventHandlers.push(handler); EventBus.appEventBus.addListener(handler); } watchExtensions() { let previousExtensions = App.getEnabledExtensions(); + this.disposers.push(reaction(() => App.getEnabledExtensions(), (currentExtensions) => { const removedExtensions = previousExtensions.filter(x => !currentExtensions.includes(x)); + removedExtensions.forEach(ext => { this.event("extension", "disable", { extension: ext }); }); const newExtensions = currentExtensions.filter(x => !previousExtensions.includes(x)); + newExtensions.forEach(ext => { this.event("extension", "enable", { extension: ext }); }); @@ -82,6 +87,7 @@ export class Tracker extends Util.Singleton { for (const handler of this.eventHandlers) { EventBus.appEventBus.removeListener(handler); } + if (this.reportInterval) { clearInterval(this.reportInterval); } @@ -125,12 +131,14 @@ export class Tracker extends Util.Singleton { protected resolveOS() { let os = ""; + if (App.isMac) { os = "MacOS"; } else if(App.isWindows) { os = "Windows"; } else if (App.isLinux) { os = "Linux"; + if (App.isSnap) { os += "; Snap"; } else { @@ -139,12 +147,14 @@ export class Tracker extends Util.Singleton { } else { os = "Unknown"; } + return os; } protected async event(eventCategory: string, eventAction: string, otherParams = {}) { try { const allowed = await this.isTelemetryAllowed(); + if (!allowed) { return; } diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index 814465d426..8672076226 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -14,9 +14,7 @@ jest.setTimeout(60000); describe("Lens integration tests", () => { const TEST_NAMESPACE = "integration-tests"; const BACKSPACE = "\uE003"; - let app: Application; - const appStart = async () => { app = util.setup(); await app.start(); @@ -25,19 +23,19 @@ describe("Lens integration tests", () => { await app.client.windowByIndex(0); await app.client.waitUntilWindowLoaded(); }; - const clickWhatsNew = async (app: Application) => { await app.client.waitUntilTextExists("h1", "What's new?"); await app.client.click("button.primary"); await app.client.waitUntilTextExists("h1", "Welcome"); }; - const minikubeReady = (): boolean => { // determine if minikube is running { const { status } = spawnSync("minikube status", { shell: true }); + if (status !== 0) { console.warn("minikube not running"); + return false; } } @@ -45,6 +43,7 @@ describe("Lens integration tests", () => { // Remove TEST_NAMESPACE if it already exists { const { status } = spawnSync(`minikube kubectl -- get namespace ${TEST_NAMESPACE}`, { shell: true }); + if (status === 0) { console.warn(`Removing existing ${TEST_NAMESPACE} namespace`); @@ -52,8 +51,10 @@ describe("Lens integration tests", () => { `minikube kubectl -- delete namespace ${TEST_NAMESPACE}`, { shell: true }, ); + if (status !== 0) { console.warn(`Error removing ${TEST_NAMESPACE} namespace: ${stderr.toString()}`); + return false; } @@ -86,6 +87,7 @@ describe("Lens integration tests", () => { describe("preferences page", () => { it('shows "preferences"', async () => { const appName: string = process.platform === "darwin" ? "Lens" : "File"; + await app.electron.ipcRenderer.send("test-menu-item-click", appName, "Preferences"); await app.client.waitUntilTextExists("h2", "Preferences"); }); @@ -153,13 +155,13 @@ describe("Lens integration tests", () => { await app.client.waitUntilTextExists("div", "Select kubeconfig file"); await app.client.click("div.Select__control"); // show the context drop-down list await app.client.waitUntilTextExists("div", "minikube"); + if (!await app.client.$("button.primary").isEnabled()) { await app.client.click("div.minikube"); // select minikube context } // else the only context, which must be 'minikube', is automatically selected await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button) await app.client.click("button.primary"); // add minikube cluster }; - const waitForMinikubeDashboard = async (app: Application) => { await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); await app.client.waitForExist(`iframe[name="minikube"]`); @@ -169,7 +171,6 @@ describe("Lens integration tests", () => { util.describeIf(ready)("cluster tests", () => { let clusterAdded = false; - const addCluster = async () => { await clickWhatsNew(app); await addMinikubeCluster(app); @@ -443,6 +444,7 @@ describe("Lens integration tests", () => { expectedText: "Custom Resources" }] }]; + tests.forEach(({ drawer = "", drawerId = "", pages }) => { if (drawer !== "") { it(`shows ${drawer} drawer`, async () => { @@ -458,6 +460,7 @@ describe("Lens integration tests", () => { await app.client.waitUntilTextExists(expectedSelector, expectedText); }); }); + if (drawer !== "") { // hide the drawer it(`hides ${drawer} drawer`, async () => { diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index 263caa50a7..f445a9ae48 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -30,7 +30,9 @@ type AsyncPidGetter = () => Promise; export async function tearDown(app: Application) { const pid = await (app.mainProcess.pid as any as AsyncPidGetter)(); + await app.stop(); + try { process.kill(pid, "SIGKILL"); } catch (e) { diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index d2d31302d1..fcbd5ebd6b 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -31,8 +31,10 @@ describe("empty config", () => { "lens-cluster-store.json": JSON.stringify({}) } }; + mockFs(mockOpts); clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); }); @@ -59,6 +61,7 @@ describe("empty config", () => { it("adds new cluster to store", async () => { const storedCluster = clusterStore.getById("foo"); + expect(storedCluster.id).toBe("foo"); expect(storedCluster.preferences.terminalCWD).toBe("/tmp"); expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5"); @@ -67,6 +70,7 @@ describe("empty config", () => { it("adds cluster to default workspace", () => { const storedCluster = clusterStore.getById("foo"); + expect(storedCluster.workspace).toBe("default"); }); @@ -114,6 +118,7 @@ describe("empty config", () => { it("gets clusters by workspaces", () => { const wsClusters = clusterStore.getByWorkspaceId("workstation"); const defaultClusters = clusterStore.getByWorkspaceId("default"); + expect(defaultClusters.length).toBe(0); expect(wsClusters.length).toBe(2); expect(wsClusters[0].id).toBe("prod"); @@ -122,6 +127,7 @@ describe("empty config", () => { it("check if cluster's kubeconfig file saved", () => { const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig"); + expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig"); }); @@ -129,6 +135,7 @@ describe("empty config", () => { clusterStore.swapIconOrders("workstation", 1, 1); const clusters = clusterStore.getByWorkspaceId("workstation"); + expect(clusters[0].id).toBe("prod"); expect(clusters[0].preferences.iconOrder).toBe(0); expect(clusters[1].id).toBe("dev"); @@ -139,6 +146,7 @@ describe("empty config", () => { clusterStore.swapIconOrders("workstation", 0, 1); const clusters = clusterStore.getByWorkspaceId("workstation"); + expect(clusters[0].id).toBe("dev"); expect(clusters[0].preferences.iconOrder).toBe(0); expect(clusters[1].id).toBe("prod"); @@ -192,8 +200,10 @@ describe("config with existing clusters", () => { }) } }; + mockFs(mockOpts); clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); }); @@ -203,6 +213,7 @@ describe("config with existing clusters", () => { it("allows to retrieve a cluster", () => { const storedCluster = clusterStore.getById("cluster1"); + expect(storedCluster.id).toBe("cluster1"); expect(storedCluster.preferences.terminalCWD).toBe("/foo"); }); @@ -210,13 +221,16 @@ describe("config with existing clusters", () => { it("allows to delete a cluster", () => { clusterStore.removeById("cluster2"); const storedCluster = clusterStore.getById("cluster1"); + expect(storedCluster).toBeTruthy(); const storedCluster2 = clusterStore.getById("cluster2"); + expect(storedCluster2).toBeUndefined(); }); it("allows getting all of the clusters", async () => { const storedClusters = clusterStore.clustersList; + expect(storedClusters.length).toBe(3); expect(storedClusters[0].id).toBe("cluster1"); expect(storedClusters[0].preferences.terminalCWD).toBe("/foo"); @@ -227,6 +241,7 @@ describe("config with existing clusters", () => { it("marks owned cluster disabled by default", () => { const storedClusters = clusterStore.clustersList; + expect(storedClusters[0].enabled).toBe(true); expect(storedClusters[2].enabled).toBe(false); }); @@ -247,8 +262,10 @@ describe("pre 2.0 config with an existing cluster", () => { }) } }; + mockFs(mockOpts); clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); }); @@ -258,6 +275,7 @@ describe("pre 2.0 config with an existing cluster", () => { it("migrates to modern format with kubeconfig in a file", async () => { const config = clusterStore.clustersList[0].kubeConfigPath; + expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content"); }); }); @@ -279,8 +297,10 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => }) } }; + mockFs(mockOpts); clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); }); @@ -292,6 +312,7 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => const file = clusterStore.clustersList[0].kubeConfigPath; const config = fs.readFileSync(file, "utf8"); const kc = yaml.safeLoad(config); + expect(kc.users[0].user["auth-provider"].config["access-token"]).toBe("should be string"); expect(kc.users[0].user["auth-provider"].config["expiry"]).toBe("should be string"); }); @@ -319,8 +340,10 @@ describe("pre 2.6.0 config with a cluster icon", () => { "icon_path": testDataIcon, } }; + mockFs(mockOpts); clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); }); @@ -330,6 +353,7 @@ describe("pre 2.6.0 config with a cluster icon", () => { it("moves the icon into preferences", async () => { const storedClusterData = clusterStore.clustersList[0]; + expect(storedClusterData.hasOwnProperty("icon")).toBe(false); expect(storedClusterData.preferences.hasOwnProperty("icon")).toBe(true); expect(storedClusterData.preferences.icon.startsWith("data:;base64,")).toBe(true); @@ -356,8 +380,10 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => { }) } }; + mockFs(mockOpts); clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); }); @@ -367,6 +393,7 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => { it("adds cluster to default workspace", async () => { const storedClusterData = clusterStore.clustersList[0]; + expect(storedClusterData.workspace).toBe("default"); }); }); @@ -396,8 +423,10 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => { "icon_path": testDataIcon, } }; + mockFs(mockOpts); clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); }); @@ -407,11 +436,13 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => { it("migrates to modern format with kubeconfig in a file", async () => { const config = clusterStore.clustersList[0].kubeConfigPath; + expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content"); }); it("migrates to modern format with icon not in file", async () => { const { icon } = clusterStore.clustersList[0].preferences; + 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 cfd82f54a3..e8159acaf0 100644 --- a/src/common/__tests__/event-bus.test.ts +++ b/src/common/__tests__/event-bus.test.ts @@ -4,6 +4,7 @@ describe("event bus tests", () => { describe("emit", () => { it("emits an event", () => { let event: AppEvent = null; + appEventBus.addListener((data) => { event = data; }); diff --git a/src/common/__tests__/search-store.test.ts b/src/common/__tests__/search-store.test.ts index 3f4125d1c0..7939ef1d8c 100644 --- a/src/common/__tests__/search-store.test.ts +++ b/src/common/__tests__/search-store.test.ts @@ -5,7 +5,6 @@ import { SearchStore } from "../search-store"; let searchStore: SearchStore = null; - const logs = [ "1:M 30 Oct 2020 16:17:41.553 # Connection with replica 172.17.0.12:6379 lost", "1:M 30 Oct 2020 16:17:41.623 * Replica 172.17.0.12:6379 asks for synchronization", @@ -64,6 +63,7 @@ describe("search store tests", () => { it("escapes string for using in regex", () => { const regex = searchStore.escapeRegex("some.interesting-query\\#?()[]"); + expect(regex).toBe("some\\.interesting\\-query\\\\\\#\\?\\(\\)\\[\\]"); }); diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index 2a05cd9adb..08ca359ce5 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -59,6 +59,7 @@ describe("user store tests", () => { it("correctly resets theme to default value", async () => { const us = UserStore.getInstance(); + us.isLoaded = true; us.preferences.colorTheme = "some other theme"; diff --git a/src/common/__tests__/workspace-store.test.ts b/src/common/__tests__/workspace-store.test.ts index a15128f4e3..e69ebda0aa 100644 --- a/src/common/__tests__/workspace-store.test.ts +++ b/src/common/__tests__/workspace-store.test.ts @@ -44,7 +44,6 @@ describe("workspace store tests", () => { it("can update workspace description", () => { const ws = WorkspaceStore.getInstance(); - const workspace = ws.addWorkspace(new Workspace({ id: "foobar", name: "foobar", @@ -65,6 +64,7 @@ describe("workspace store tests", () => { })); const workspace = ws.getById("123"); + expect(workspace.name).toBe("foobar"); expect(workspace.enabled).toBe(true); }); diff --git a/src/common/base-store.ts b/src/common/base-store.ts index f376236697..f7ad946bfd 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -55,6 +55,7 @@ export abstract class BaseStore extends Singleton { if (this.params.autoLoad) { await this.load(); } + if (this.params.syncEnabled) { await this.whenLoaded; this.enableSync(); @@ -63,6 +64,7 @@ export abstract class BaseStore extends Singleton { async load() { const { autoLoad, syncEnabled, ...confOptions } = this.params; + this.storeConfig = new Config({ ...confOptions, projectName: "lens", @@ -90,19 +92,23 @@ export abstract class BaseStore extends Singleton { this.syncDisposers.push( reaction(() => this.toJSON(), model => this.onModelChange(model), this.params.syncOptions), ); + if (ipcMain) { const callback = (event: IpcMainEvent, model: T) => { logger.silly(`[STORE]: SYNC ${this.name} from renderer`, { model }); this.onSync(model); }; + subscribeToBroadcast(this.syncMainChannel, callback); this.syncDisposers.push(() => unsubscribeFromBroadcast(this.syncMainChannel, callback)); } + if (ipcRenderer) { const callback = (event: IpcRendererEvent, model: T) => { logger.silly(`[STORE]: SYNC ${this.name} from main`, { model }); this.onSyncFromMain(model); }; + subscribeToBroadcast(this.syncRendererChannel, callback); this.syncDisposers.push(() => unsubscribeFromBroadcast(this.syncRendererChannel, callback)); } @@ -127,6 +133,7 @@ export abstract class BaseStore extends Singleton { protected applyWithoutSync(callback: () => void) { this.disableSync(); runInAction(callback); + if (this.params.syncEnabled) { this.enableSync(); } diff --git a/src/common/cluster-ipc.ts b/src/common/cluster-ipc.ts index 18c0ea0a12..48626f5307 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/cluster-ipc.ts @@ -15,6 +15,7 @@ export const clusterKubectlApplyAllHandler = "cluster:kubectl-apply-all"; if (ipcMain) { handleRequest(clusterActivateHandler, (event, clusterId: ClusterId, force = false) => { const cluster = clusterStore.getById(clusterId); + if (cluster) { return cluster.activate(force); } @@ -22,20 +23,24 @@ if (ipcMain) { handleRequest(clusterSetFrameIdHandler, (event, clusterId: ClusterId, frameId: number) => { const cluster = clusterStore.getById(clusterId); + if (cluster) { clusterFrameMap.set(cluster.id, frameId); + return cluster.pushState(); } }); handleRequest(clusterRefreshHandler, (event, clusterId: ClusterId) => { const cluster = clusterStore.getById(clusterId); + if (cluster) return cluster.refresh({ refreshMetadata: true }); }); handleRequest(clusterDisconnectHandler, (event, clusterId: ClusterId) => { appEventBus.emit({name: "cluster", action: "stop"}); const cluster = clusterStore.getById(clusterId); + if (cluster) { cluster.disconnect(); clusterFrameMap.delete(cluster.id); @@ -45,8 +50,10 @@ if (ipcMain) { handleRequest(clusterKubectlApplyAllHandler, (event, clusterId: ClusterId, resources: string[]) => { appEventBus.emit({name: "cluster", action: "kubectl-apply-all"}); const cluster = clusterStore.getById(clusterId); + if (cluster) { const applier = new ResourceApplier(cluster); + applier.kubectlApplyAll(resources); } else { throw `${clusterId} is not a valid cluster id`; diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 5a9827eee5..d8bd28f1e8 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -98,7 +98,9 @@ export class ClusterStore extends BaseStore { static embedCustomKubeConfig(clusterId: ClusterId, kubeConfig: KubeConfig | string): string { const filePath = ClusterStore.getCustomKubeConfigPath(clusterId); const fileContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig); + saveToAppFiles(filePath, fileContents, { mode: 0o600 }); + return filePath; } @@ -127,11 +129,14 @@ export class ClusterStore extends BaseStore { id: string; state: ClusterState; }; + if (ipcRenderer) { logger.info("[CLUSTER-STORE] requesting initial state sync"); const clusterStates: clusterStateSync[] = await requestMain(ClusterStore.stateRequestChannel); + clusterStates.forEach((clusterState) => { const cluster = this.getById(clusterState.id); + if (cluster) { cluster.setState(clusterState.state); } @@ -139,12 +144,14 @@ export class ClusterStore extends BaseStore { } else { handleRequest(ClusterStore.stateRequestChannel, (): clusterStateSync[] => { const states: clusterStateSync[] = []; + this.clustersList.forEach((cluster) => { states.push({ state: cluster.getState(), id: cluster.id }); }); + return states; }); } @@ -207,6 +214,7 @@ export class ClusterStore extends BaseStore { @action setActive(id: ClusterId) { const clusterId = this.clusters.has(id) ? id : null; + this.activeCluster = clusterId; workspaceStore.setLastActiveClusterId(clusterId); } @@ -214,11 +222,13 @@ export class ClusterStore extends BaseStore { @action swapIconOrders(workspace: WorkspaceId, from: number, to: number) { const clusters = this.getByWorkspaceId(workspace); + if (from < 0 || to < 0 || from >= clusters.length || to >= clusters.length || isNaN(from) || isNaN(to)) { throw new Error(`invalid from<->to arguments`); } move.mutate(clusters, from, to); + for (const i in clusters) { // This resets the iconOrder to the current display order clusters[i].preferences.iconOrder = +i; @@ -236,12 +246,14 @@ export class ClusterStore extends BaseStore { getByWorkspaceId(workspaceId: string): Cluster[] { const clusters = Array.from(this.clusters.values()) .filter(cluster => cluster.workspace === workspaceId); + return _.sortBy(clusters, cluster => cluster.preferences.iconOrder); } @action addClusters(...models: ClusterModel[]): Cluster[] { const clusters: Cluster[] = []; + models.forEach(model => { clusters.push(this.addCluster(model)); }); @@ -253,13 +265,16 @@ export class ClusterStore extends BaseStore { addCluster(model: ClusterModel | Cluster): Cluster { appEventBus.emit({ name: "cluster", action: "add" }); let cluster = model as Cluster; + if (!(model instanceof Cluster)) { cluster = new Cluster(model); } + if (!cluster.isManaged) { cluster.enabled = true; } this.clusters.set(model.id, cluster); + return cluster; } @@ -271,11 +286,14 @@ export class ClusterStore extends BaseStore { async removeById(clusterId: ClusterId) { appEventBus.emit({ name: "cluster", action: "remove" }); const cluster = this.getById(clusterId); + if (cluster) { this.clusters.delete(clusterId); + if (this.activeCluster === clusterId) { this.setActive(null); } + // remove only custom kubeconfigs (pasted as text) if (cluster.kubeConfigPath == ClusterStore.getCustomKubeConfigPath(clusterId)) { unlink(cluster.kubeConfigPath).catch(() => null); @@ -299,10 +317,12 @@ export class ClusterStore extends BaseStore { // update new clusters for (const clusterModel of clusters) { let cluster = currentClusters.get(clusterModel.id); + if (cluster) { cluster.updateModel(clusterModel); } else { cluster = new Cluster(clusterModel); + if (!cluster.isManaged) { cluster.enabled = true; } @@ -336,6 +356,7 @@ export const clusterStore = ClusterStore.getInstance(); export function getClusterIdFromHost(hostname: string): ClusterId { const subDomains = hostname.split(":")[0].split("."); + return subDomains.slice(-2)[0]; // e.g host == "%clusterId.localhost:45345" } diff --git a/src/common/custom-errors.ts b/src/common/custom-errors.ts index 3c7750487a..9bcf3a998a 100644 --- a/src/common/custom-errors.ts +++ b/src/common/custom-errors.ts @@ -2,6 +2,7 @@ export class ExecValidationNotFoundError extends Error { constructor(execPath: string, isAbsolute: boolean) { super(`User Exec command "${execPath}" not found on host.`); let message = `User Exec command "${execPath}" not found on host.`; + if (!isAbsolute) { message += ` Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig`; } diff --git a/src/common/event-emitter.ts b/src/common/event-emitter.ts index d018dce337..2c0cfafebc 100644 --- a/src/common/event-emitter.ts +++ b/src/common/event-emitter.ts @@ -13,6 +13,7 @@ export class EventEmitter { addListener(callback: Callback, options: Options = {}) { if (options.prepend) { const listeners = [...this.listeners]; + listeners.unshift([callback, options]); this.listeners = new Map(listeners); } @@ -33,7 +34,9 @@ export class EventEmitter { [...this.listeners].every(([callback, options]) => { if (options.once) this.removeListener(callback); const result = callback(...data); + if (result === false) return; // break cycle + return true; }); } diff --git a/src/common/ipc.ts b/src/common/ipc.ts index 2d7852681d..628aa503f8 100644 --- a/src/common/ipc.ts +++ b/src/common/ipc.ts @@ -16,18 +16,22 @@ export async function requestMain(channel: string, ...args: any[]) { async function getSubFrames(): Promise { const subFrames: number[] = []; + clusterFrameMap.forEach(frameId => { subFrames.push(frameId); }); + return subFrames; } export function broadcastMessage(channel: string, ...args: any[]) { const views = (webContents || remote?.webContents)?.getAllWebContents(); + if (!views) return; views.forEach(webContent => { const type = webContent.getType(); + logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args }); webContent.send(channel, ...args); getSubFrames().then((frames) => { @@ -36,6 +40,7 @@ export function broadcastMessage(channel: string, ...args: any[]) { }); }).catch((e) => e); }); + if (ipcRenderer) { ipcRenderer.send(channel, ...args); } else { diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index b8d88c2227..bb0e6b86d2 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -13,6 +13,7 @@ function resolveTilde(filePath: string) { if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { return filePath.replace("~", os.homedir()); } + return filePath; } @@ -40,12 +41,15 @@ export function validateConfig(config: KubeConfig | string): KubeConfig { config = loadConfig(config); } logger.debug(`validating kube config: ${JSON.stringify(config)}`); + if (!config.users || config.users.length == 0) { throw new Error("No users provided in config"); } + if (!config.clusters || config.clusters.length == 0) { throw new Error("No clusters provided in config"); } + if (!config.contexts || config.contexts.length == 0) { throw new Error("No contexts provided in config"); } @@ -58,11 +62,13 @@ export function validateConfig(config: KubeConfig | string): KubeConfig { */ export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] { const configs: KubeConfig[] = []; + if (!kubeConfig.contexts) { return configs; } kubeConfig.contexts.forEach(ctx => { const kc = new KubeConfig(); + kc.clusters = [kubeConfig.getCluster(ctx.cluster)].filter(n => n); kc.users = [kubeConfig.getUser(ctx.user)].filter(n => n); kc.contexts = [kubeConfig.getContextObject(ctx.name)].filter(n => n); @@ -70,6 +76,7 @@ export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] { configs.push(kc); }); + return configs; } @@ -153,11 +160,13 @@ export function validateKubeConfig (config: KubeConfig) { logger.debug(`validateKubeConfig: validating kubeconfig - ${JSON.stringify(config)}`); // Validate the User Object - const user = config.getCurrentUser(); + const user = config.getCurrentUser(); + if (user.exec) { const execCommand = user.exec["command"]; // check if the command is absolute or not const isAbsolute = path.isAbsolute(execCommand); + // validate the exec struct in the user object, start with the command field logger.debug(`validateKubeConfig: validating user exec command - ${JSON.stringify(execCommand)}`); diff --git a/src/common/prometheus-providers.ts b/src/common/prometheus-providers.ts index 6f6b379ec7..a5c515b338 100644 --- a/src/common/prometheus-providers.ts +++ b/src/common/prometheus-providers.ts @@ -6,6 +6,7 @@ import { PrometheusProviderRegistry } from "../main/prometheus/provider-registry [PrometheusLens, PrometheusHelm, PrometheusOperator, PrometheusStacklight].forEach(providerClass => { const provider = new providerClass(); + PrometheusProviderRegistry.registerProvider(provider.id, provider); }); diff --git a/src/common/rbac.ts b/src/common/rbac.ts index 4097de95c9..702d87d394 100644 --- a/src/common/rbac.ts +++ b/src/common/rbac.ts @@ -42,10 +42,12 @@ export function isAllowedResource(resources: KubeResource | KubeResource[]) { resources = [resources]; } const { allowedResources = [] } = getHostedCluster() || {}; + for (const resource of resources) { if (!allowedResources.includes(resource)) { return false; } } + return true; } diff --git a/src/common/register-protocol.ts b/src/common/register-protocol.ts index b8e9bb3690..be09991488 100644 --- a/src/common/register-protocol.ts +++ b/src/common/register-protocol.ts @@ -7,6 +7,7 @@ export function registerFileProtocol(name: string, basePath: string) { protocol.registerFileProtocol(name, (request, callback) => { const filePath = request.url.replace(`${name}://`, ""); const absPath = path.resolve(basePath, filePath); + callback({ path: absPath }); }); } diff --git a/src/common/request.ts b/src/common/request.ts index 1ebec8c33f..ca34f4a961 100644 --- a/src/common/request.ts +++ b/src/common/request.ts @@ -7,6 +7,7 @@ import { userStore } from "./user-store"; function getDefaultRequestOpts(): Partial { const { httpsProxy, allowUntrustedCAs } = userStore.preferences; + return { proxy: httpsProxy || undefined, rejectUnauthorized: !allowUntrustedCAs, diff --git a/src/common/search-store.ts b/src/common/search-store.ts index 3288bbb3a0..a3aba9dcbe 100644 --- a/src/common/search-store.ts +++ b/src/common/search-store.ts @@ -14,8 +14,10 @@ export class SearchStore { @action onSearch(text: string[], query = this.searchQuery) { this.searchQuery = query; + if (!query) { this.reset(); + return; } this.occurrences = this.findOccurences(text, query); @@ -36,11 +38,14 @@ export class SearchStore { findOccurences(text: string[], query: string) { if (!text) return []; const occurences: number[] = []; + text.forEach((line, index) => { const regex = new RegExp(this.escapeRegex(query), "gi"); const matches = [...line.matchAll(regex)]; + matches.forEach(() => occurences.push(index)); }); + return occurences; } @@ -51,9 +56,11 @@ export class SearchStore { */ getNextOverlay(loopOver = false) { const next = this.activeOverlayIndex + 1; + if (next > this.occurrences.length - 1) { return loopOver ? 0 : this.activeOverlayIndex; } + return next; } @@ -64,9 +71,11 @@ export class SearchStore { */ getPrevOverlay(loopOver = false) { const prev = this.activeOverlayIndex - 1; + if (prev < 0) { return loopOver ? this.occurrences.length - 1 : this.activeOverlayIndex; } + return prev; } @@ -104,6 +113,7 @@ export class SearchStore { @autobind() isActiveOverlay(line: number, occurence: number) { const firstLineIndex = this.occurrences.findIndex(item => item === line); + return firstLineIndex + occurence === this.activeOverlayIndex; } diff --git a/src/common/system-ca.ts b/src/common/system-ca.ts index 3beb135449..6119436fe6 100644 --- a/src/common/system-ca.ts +++ b/src/common/system-ca.ts @@ -6,9 +6,11 @@ import logger from "../main/logger"; if (isMac) { for (const crt of macca.all()) { const attributes = crt.issuer?.attributes?.map((a: any) => `${a.name}=${a.value}`); + logger.debug(`Using host CA: ${attributes.join(",")}`); } } + if (isWindows) { winca.inject("+"); // see: https://github.com/ukoloff/win-ca#caveats } diff --git a/src/common/user-store.ts b/src/common/user-store.ts index 8b694c4f04..0195fcce70 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -102,6 +102,7 @@ export class UserStore extends BaseStore { protected refreshNewContexts = async () => { try { const kubeConfig = await readFile(this.kubeConfigPath, "utf8"); + if (kubeConfig) { this.newContexts.clear(); loadConfig(kubeConfig).getContexts() @@ -118,6 +119,7 @@ export class UserStore extends BaseStore { @action markNewContextsAsSeen() { const { seenContexts, newContexts } = this; + this.seenContexts.replace([...seenContexts, ...newContexts]); this.newContexts.clear(); } @@ -133,9 +135,11 @@ export class UserStore extends BaseStore { @action protected async fromStore(data: Partial = {}) { const { lastSeenAppVersion, seenContexts = [], preferences, kubeConfigPath } = data; + if (lastSeenAppVersion) { this.lastSeenAppVersion = lastSeenAppVersion; } + if (kubeConfigPath) { this.kubeConfigPath = kubeConfigPath; } @@ -150,6 +154,7 @@ export class UserStore extends BaseStore { seenContexts: Array.from(this.seenContexts), preferences: this.preferences, }; + return toJS(model, { recurseEverything: true, }); diff --git a/src/common/utils/autobind.ts b/src/common/utils/autobind.ts index 8daca1ab85..b5c706e362 100644 --- a/src/common/utils/autobind.ts +++ b/src/common/utils/autobind.ts @@ -12,7 +12,6 @@ export function autobind() { function bindClass(constructor: T) { const proto = constructor.prototype; const descriptors = Object.getOwnPropertyDescriptors(proto); - const skipMethod = (methodName: string) => { return methodName === "constructor" || typeof descriptors[methodName].value !== "function"; @@ -21,6 +20,7 @@ function bindClass(constructor: T) { Object.keys(descriptors).forEach(prop => { if (skipMethod(prop)) return; const boundDescriptor = bindMethod(proto, prop, descriptors[prop]); + Object.defineProperty(proto, prop, boundDescriptor); }); } @@ -38,6 +38,7 @@ function bindMethod(target: object, prop?: string, descriptor?: PropertyDescript get() { if (this === target) return func; // direct access from prototype if (!boundFunc.has(this)) boundFunc.set(this, func.bind(this)); + return boundFunc.get(this); } }); diff --git a/src/common/utils/buildUrl.ts b/src/common/utils/buildUrl.ts index c26e054491..e3bad9b302 100644 --- a/src/common/utils/buildUrl.ts +++ b/src/common/utils/buildUrl.ts @@ -7,8 +7,10 @@ export interface IURLParams

{ export function buildURL

(path: string | any) { const pathBuilder = compile(String(path)); + return function ({ params, query }: IURLParams = {}) { const queryParams = query ? new URLSearchParams(Object.entries(query)).toString() : ""; + return pathBuilder(params) + (queryParams ? `?${queryParams}` : ""); }; } diff --git a/src/common/utils/camelCase.ts b/src/common/utils/camelCase.ts index 90c048cad5..306cb45190 100644 --- a/src/common/utils/camelCase.ts +++ b/src/common/utils/camelCase.ts @@ -8,7 +8,9 @@ export function toCamelCase(obj: Record): any { else if (isPlainObject(obj)) { return Object.keys(obj).reduce((result, key) => { const value = obj[key]; + result[camelCase(key)] = typeof value === "object" ? toCamelCase(value) : value; + return result; }, {} as any); } diff --git a/src/common/utils/debouncePromise.ts b/src/common/utils/debouncePromise.ts index e03c0e76bd..b5ad88d000 100755 --- a/src/common/utils/debouncePromise.ts +++ b/src/common/utils/debouncePromise.ts @@ -2,6 +2,7 @@ export function debouncePromise(func: (...args: F) => T | Promise, timeout = 0): (...args: F) => Promise { let timer: NodeJS.Timeout; + return (...params: any[]) => new Promise(resolve => { clearTimeout(timer); timer = global.setTimeout(() => resolve(func.apply(this, params)), timeout); diff --git a/src/common/utils/downloadFile.ts b/src/common/utils/downloadFile.ts index 4c65901d3d..dfa549da07 100644 --- a/src/common/utils/downloadFile.ts +++ b/src/common/utils/downloadFile.ts @@ -26,6 +26,7 @@ export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions) resolve(Buffer.concat(fileChunks)); }); }); + return { url, promise, diff --git a/src/common/utils/getRandId.ts b/src/common/utils/getRandId.ts index afe075085d..ef02e2f0eb 100644 --- a/src/common/utils/getRandId.ts +++ b/src/common/utils/getRandId.ts @@ -2,5 +2,6 @@ export function getRandId({ prefix = "", suffix = "", sep = "_" } = {}) { const randId = () => Math.random().toString(16).substr(2); + return [prefix, randId(), suffix].filter(s => s).join(sep); } diff --git a/src/common/utils/saveToAppFiles.ts b/src/common/utils/saveToAppFiles.ts index 57c47f0d70..87d09290c0 100644 --- a/src/common/utils/saveToAppFiles.ts +++ b/src/common/utils/saveToAppFiles.ts @@ -6,7 +6,9 @@ import { WriteFileOptions } from "fs"; export function saveToAppFiles(filePath: string, contents: any, options?: WriteFileOptions): string { const absPath = path.resolve((app || remote.app).getPath("userData"), filePath); + ensureDirSync(path.dirname(absPath)); writeFileSync(absPath, contents, options); + return absPath; } diff --git a/src/common/utils/singleton.ts b/src/common/utils/singleton.ts index ed3f0cc962..61269d10b1 100644 --- a/src/common/utils/singleton.ts +++ b/src/common/utils/singleton.ts @@ -16,6 +16,7 @@ class Singleton { if (!Singleton.instances.has(this)) { Singleton.instances.set(this, Reflect.construct(this, args)); } + return Singleton.instances.get(this) as T; } diff --git a/src/common/utils/splitArray.ts b/src/common/utils/splitArray.ts index f93392f736..7be367ebe6 100644 --- a/src/common/utils/splitArray.ts +++ b/src/common/utils/splitArray.ts @@ -12,8 +12,10 @@ */ export function splitArray(array: T[], element: T): [T[], T[], boolean] { const index = array.indexOf(element); + if (index < 0) { return [array, [], false]; } + return [array.slice(0, index), array.slice(index + 1, array.length), true]; } diff --git a/src/common/utils/tar.ts b/src/common/utils/tar.ts index bec7b5b3f2..f9876e2b27 100644 --- a/src/common/utils/tar.ts +++ b/src/common/utils/tar.ts @@ -26,6 +26,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; + resolve(result); }); }, @@ -39,12 +40,14 @@ export function readFileFromTar({ tarPath, filePath, parseJson }: Re export async function listTarEntries(filePath: string): Promise { const entries: string[] = []; + await tar.list({ file: filePath, onentry: (entry: FileStat) => { entries.push(path.normalize(entry.path as any as string)); }, }); + return entries; } diff --git a/src/common/vars.ts b/src/common/vars.ts index ac9f1336ee..396a1077c5 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -30,6 +30,7 @@ defineGlobal("__static", { if (isDevelopment) { return path.resolve(contextDir, "static"); } + return path.resolve(process.resourcesPath, "static"); } }); diff --git a/src/common/workspace-store.ts b/src/common/workspace-store.ts index 4d5af6da98..e1fa113ca3 100644 --- a/src/common/workspace-store.ts +++ b/src/common/workspace-store.ts @@ -125,11 +125,14 @@ export class WorkspaceStore extends BaseStore { id: string; state: WorkspaceState; }; + if (ipcRenderer) { logger.info("[WORKSPACE-STORE] requesting initial state sync"); const workspaceStates: workspaceStateSync[] = await requestMain(WorkspaceStore.stateRequestChannel); + workspaceStates.forEach((workspaceState) => { const workspace = this.getById(workspaceState.id); + if (workspace) { workspace.setState(workspaceState.state); } @@ -137,12 +140,14 @@ export class WorkspaceStore extends BaseStore { } else { handleRequest(WorkspaceStore.stateRequestChannel, (): workspaceStateSync[] => { const states: workspaceStateSync[] = []; + this.workspacesList.forEach((workspace) => { states.push({ state: workspace.getState(), id: workspace.id }); }); + return states; }); } @@ -202,6 +207,7 @@ export class WorkspaceStore extends BaseStore { @action setActive(id = WorkspaceStore.defaultId) { if (id === this.currentWorkspaceId) return; + if (!this.getById(id)) { throw new Error(`workspace ${id} doesn't exist`); } @@ -211,15 +217,18 @@ export class WorkspaceStore extends BaseStore { @action addWorkspace(workspace: Workspace) { const { id, name } = workspace; + if (!name.trim() || this.getByName(name.trim())) { return; } this.workspaces.set(id, workspace); + if (!workspace.isManaged) { workspace.enabled = true; } appEventBus.emit({name: "workspace", action: "add"}); + return workspace; } @@ -237,10 +246,13 @@ export class WorkspaceStore extends BaseStore { @action removeWorkspaceById(id: WorkspaceId) { const workspace = this.getById(id); + if (!workspace) return; + if (this.isDefault(id)) { throw new Error("Cannot remove default workspace"); } + if (this.currentWorkspaceId === id) { this.currentWorkspaceId = WorkspaceStore.defaultId; // reset to default } @@ -259,10 +271,12 @@ export class WorkspaceStore extends BaseStore { if (currentWorkspace) { this.currentWorkspaceId = currentWorkspace; } + if (workspaces.length) { this.workspaces.clear(); workspaces.forEach(ws => { const workspace = new Workspace(ws); + if (!workspace.isManaged) { workspace.enabled = true; } diff --git a/src/extensions/cluster-feature.ts b/src/extensions/cluster-feature.ts index bb60e9d9b4..625f2b5973 100644 --- a/src/extensions/cluster-feature.ts +++ b/src/extensions/cluster-feature.ts @@ -108,12 +108,15 @@ export abstract class ClusterFeature { */ protected renderTemplates(folderPath: string): string[] { const resources: string[] = []; + logger.info(`[FEATURE]: render templates from ${folderPath}`); fs.readdirSync(folderPath).forEach(filename => { const file = path.join(folderPath, filename); const raw = fs.readFileSync(file); + if (filename.endsWith(".hb")) { const template = hb.compile(raw.toString()); + resources.push(template(this.templateContext)); } else { resources.push(raw.toString()); diff --git a/src/extensions/core-api/app.ts b/src/extensions/core-api/app.ts index 2664711db4..2c3a7a4f59 100644 --- a/src/extensions/core-api/app.ts +++ b/src/extensions/core-api/app.ts @@ -3,6 +3,7 @@ import { extensionsStore } from "../extensions-store"; export const version = getAppVersion(); export { isSnap, isWindows, isMac, isLinux, appName, slackUrl, issuesTrackerUrl } from "../../common/vars"; + export function getEnabledExtensions(): string[] { return extensionsStore.enabledExtensions; } diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index 699e37042f..105b8e2041 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -25,6 +25,7 @@ export interface InstalledExtension { } const logModule = "[EXTENSION-DISCOVERY]"; + export const manifestFilename = "package.json"; /** @@ -133,7 +134,6 @@ export class ExtensionDiscovery { if (path.basename(filePath) === manifestFilename) { try { const absPath = path.dirname(filePath); - // this.loadExtensionFromPath updates this.packagesJson const extension = await this.loadExtensionFromPath(absPath); @@ -251,6 +251,7 @@ export class ExtensionDiscovery { manifestJson = __non_webpack_require__(manifestPath); const installedManifestPath = path.join(this.nodeModulesPath, manifestJson.name, "package.json"); + this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath); const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath); @@ -272,6 +273,7 @@ export class ExtensionDiscovery { async loadExtensions(): Promise> { const bundledExtensions = await this.loadBundledExtensions(); const localExtensions = await this.loadFromFolder(this.localFolderPath); + await this.installPackages(); const extensions = bundledExtensions.concat(localExtensions); @@ -333,12 +335,14 @@ export class ExtensionDiscovery { } const extension = await this.loadExtensionFromPath(absPath); + if (extension) { extensions.push(extension); } } logger.debug(`${logModule}: ${extensions.length} extensions loaded`, { folderPath, extensions }); + return extensions; } diff --git a/src/extensions/extension-installer.ts b/src/extensions/extension-installer.ts index 42863fe60e..2143c62287 100644 --- a/src/extensions/extension-installer.ts +++ b/src/extensions/extension-installer.ts @@ -37,6 +37,7 @@ export class ExtensionInstaller { cwd: extensionPackagesRoot(), silent: true }); + child.on("close", () => { resolve(); }); diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index eb49021391..71eaa19524 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -176,6 +176,7 @@ export class ExtensionLoader { loadOnClusterRenderer() { logger.info(`${logModule}: load on cluster renderer (dashboard)`); const cluster = getHostedCluster(); + this.autoInitExtensions(async (extension: LensRendererExtension) => { if (await extension.isEnabledForCluster(cluster) === false) { return []; @@ -209,11 +210,13 @@ export class ExtensionLoader { if (ext.isEnabled && !alreadyInit) { try { const LensExtensionClass = this.requireExtension(ext); + if (!LensExtensionClass) { continue; } const instance = new LensExtensionClass(ext); + instance.whenEnabled(() => register(instance)); instance.enable(); this.instances.set(extId, instance); @@ -231,12 +234,14 @@ export class ExtensionLoader { protected requireExtension(extension: InstalledExtension): LensExtensionConstructor { let extEntrypoint = ""; + try { if (ipcRenderer && extension.manifest.renderer) { extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.renderer)); } else if (!ipcRenderer && extension.manifest.main) { extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.main)); } + if (extEntrypoint !== "") { return __non_webpack_require__(extEntrypoint).default; } diff --git a/src/extensions/extension-store.ts b/src/extensions/extension-store.ts index a8078994ca..c1a1e62bd8 100644 --- a/src/extensions/extension-store.ts +++ b/src/extensions/extension-store.ts @@ -7,11 +7,13 @@ export abstract class ExtensionStore extends BaseStore { async loadExtension(extension: LensExtension) { this.extension = extension; + return super.load(); } async load() { if (!this.extension) { return; } + return super.load(); } diff --git a/src/extensions/extensions-store.ts b/src/extensions/extensions-store.ts index 2f865a165b..1533c9ad89 100644 --- a/src/extensions/extensions-store.ts +++ b/src/extensions/extensions-store.ts @@ -30,11 +30,13 @@ export class ExtensionsStore extends BaseStore { protected getState(extensionLoader: ExtensionLoader) { const state: Record = {}; + return Array.from(extensionLoader.userExtensions).reduce((state, [extId, ext]) => { state[extId] = { enabled: ext.isEnabled, name: ext.manifest.name, }; + return state; }, state); } @@ -47,6 +49,7 @@ export class ExtensionsStore extends BaseStore { reaction(() => this.state.toJS(), extensionsState => { extensionsState.forEach((state, extId) => { const ext = extensionLoader.getExtension(extId); + if (ext && !ext.isBundled) { ext.isEnabled = state.enabled; } @@ -61,6 +64,7 @@ export class ExtensionsStore extends BaseStore { isEnabled(extId: LensExtensionId) { const state = this.state.get(extId); + return state && state.enabled; // by default false } diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index 61dc6c0560..aaa6f60ac5 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -86,6 +86,7 @@ export class LensExtension { const cancelReaction = reaction(() => this.isEnabled, async (isEnabled) => { if (isEnabled) { const handlerDisposers = await handlers(); + disposers.push(...handlerDisposers); } else { unregisterHandlers(); @@ -93,6 +94,7 @@ export class LensExtension { }, { fireImmediately: true }); + return () => { unregisterHandlers(); cancelReaction(); diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index ab4d7a2a1f..f0e943540d 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -13,6 +13,7 @@ export class LensMainExtension extends LensExtension { pageId, params: params ?? {}, // compile to url with params }); + await windowManager.navigate(pageUrl, frameId); } } diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 0c11306efd..b6c00d8353 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -23,6 +23,7 @@ export class LensRendererExtension extends LensExtension { pageId, params: params ?? {}, // compile to url with params }); + navigate(pageUrl); } diff --git a/src/extensions/registries/__tests__/page-registry.test.ts b/src/extensions/registries/__tests__/page-registry.test.ts index b7f2cd1252..78db140ed7 100644 --- a/src/extensions/registries/__tests__/page-registry.test.ts +++ b/src/extensions/registries/__tests__/page-registry.test.ts @@ -73,6 +73,7 @@ describe("globalPageRegistry", () => { describe("getByPageMenuTarget", () => { it("matching to first registered page without id", () => { const page = globalPageRegistry.getByPageMenuTarget({ extensionId: ext.name }); + expect(page.id).toEqual(undefined); expect(page.extensionId).toEqual(ext.name); expect(page.routePath).toEqual(getExtensionPageUrl({ extensionId: ext.name })); @@ -83,6 +84,7 @@ describe("globalPageRegistry", () => { pageId: "test-page", extensionId: ext.name }); + expect(page.id).toEqual("test-page"); }); @@ -91,6 +93,7 @@ describe("globalPageRegistry", () => { pageId: "wrong-page", extensionId: ext.name }); + expect(page).toBeNull(); }); }); diff --git a/src/extensions/registries/base-registry.ts b/src/extensions/registries/base-registry.ts index 4bfb3f9cd2..6d5485b32b 100644 --- a/src/extensions/registries/base-registry.ts +++ b/src/extensions/registries/base-registry.ts @@ -14,7 +14,9 @@ export class BaseRegistry { @action add(items: T | T[]) { const itemArray = rectify(items); + this.items.push(...itemArray); + return () => this.remove(...itemArray); } diff --git a/src/extensions/registries/kube-object-detail-registry.ts b/src/extensions/registries/kube-object-detail-registry.ts index 2638e82ade..9c79b662ea 100644 --- a/src/extensions/registries/kube-object-detail-registry.ts +++ b/src/extensions/registries/kube-object-detail-registry.ts @@ -20,8 +20,10 @@ export class KubeObjectDetailRegistry extends BaseRegistry b.priority - a.priority); } } diff --git a/src/extensions/registries/page-menu-registry.ts b/src/extensions/registries/page-menu-registry.ts index 9f5c4861a4..8ccbc9cd6c 100644 --- a/src/extensions/registries/page-menu-registry.ts +++ b/src/extensions/registries/page-menu-registry.ts @@ -35,8 +35,10 @@ export class GlobalPageMenuRegistry extends BaseRegistry { extensionId: ext.name, ...(menuItem.target || {}), }; + return menuItem; }); + return super.add(normalizedItems); } } @@ -49,8 +51,10 @@ export class ClusterPageMenuRegistry extends BaseRegistry({ extensionId, pageId = "" name: sanitizeExtensionName(extensionId), // compile only with extension-id first and define base path }); const extPageRoutePath = path.join(extensionBaseUrl, pageId); // page-id might contain route :param-s, so don't compile yet + if (params) { return compile(extPageRoutePath)(params); // might throw error when required params not passed } + return extPageRoutePath; } @@ -56,6 +58,7 @@ export class PageRegistry extends BaseRegistry { add(items: PageRegistration | PageRegistration[], ext: LensExtension) { const itemArray = rectify(items); let registeredPages: RegisteredPage[] = []; + try { registeredPages = itemArray.map(page => ({ ...page, @@ -69,6 +72,7 @@ export class PageRegistry extends BaseRegistry { error: String(err), }); } + return super.add(registeredPages); } @@ -78,8 +82,10 @@ export class PageRegistry extends BaseRegistry { getByPageMenuTarget(target: PageMenuTarget = {}): RegisteredPage | null { const targetUrl = getExtensionPageUrl(target); + return this.getItems().find(({ id: pageId, extensionId }) => { const pageUrl = getExtensionPageUrl({ extensionId, pageId, params: target.params }); // compiled with provided params + return targetUrl === pageUrl; }) || null; } diff --git a/src/extensions/stores/cluster-store.ts b/src/extensions/stores/cluster-store.ts index 60c409b525..988302543e 100644 --- a/src/extensions/stores/cluster-store.ts +++ b/src/extensions/stores/cluster-store.ts @@ -40,6 +40,7 @@ export class ClusterStore extends Singleton { if (!this.activeClusterId) { return null; } + return this.getById(this.activeClusterId); } diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index c057501eac..20e56c8b84 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -75,6 +75,7 @@ describe("create clusters", () => { preferences: {}, }) }; + mockFs(mockOpts); jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValue(Promise.resolve(true)); c = new Cluster({ @@ -112,6 +113,7 @@ describe("create clusters", () => { it("activating cluster should try to connect to cluster and do a refresh", async () => { const port = await getFreePort(); + jest.spyOn(ContextHandler.prototype, "ensureServer"); const mockListNSs = jest.fn(); @@ -122,17 +124,20 @@ describe("create clusters", () => { }; } }; + jest.spyOn(Cluster.prototype, "isClusterAdmin").mockReturnValue(Promise.resolve(true)); jest.spyOn(Cluster.prototype, "canI") .mockImplementationOnce((attr: V1ResourceAttributes): Promise => { expect(attr.namespace).toBe("default"); expect(attr.resource).toBe("pods"); expect(attr.verb).toBe("list"); + return Promise.resolve(true); }) .mockImplementation((attr: V1ResourceAttributes): Promise => { expect(attr.namespace).toBe("default"); expect(attr.verb).toBe("list"); + return Promise.resolve(true); }); jest.spyOn(Cluster.prototype, "getProxyKubeconfig").mockReturnValue(mockKC as any); @@ -148,6 +153,7 @@ describe("create clusters", () => { mockedRequest.mockImplementationOnce(((uri: any) => { expect(uri).toBe(`http://localhost:${port}/api-kube/version`); + return Promise.resolve({ gitVersion: "1.2.3" }); }) as any); @@ -165,6 +171,7 @@ describe("create clusters", () => { kubeConfigPath: "minikube-config.yml", workspace: workspaceStore.currentWorkspaceId }); + await c.init(port); await c.activate(); diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index ac8322d75b..b161372555 100644 --- a/src/main/__test__/kube-auth-proxy.test.ts +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -49,6 +49,7 @@ describe("kube auth proxy tests", () => { it("calling exit multiple times shouldn't throw", async () => { const port = await getFreePort(); const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {}); + kap.exit(); kap.exit(); kap.exit(); @@ -69,24 +70,29 @@ describe("kube auth proxy tests", () => { jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false)); mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => { listeners[event] = listener; + return mockedCP; }); mockedCP.stderr = mock(); mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { listeners[`stderr/${event}`] = listener; + return mockedCP.stderr; }); mockedCP.stdout = mock(); mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { listeners[`stdout/${event}`] = listener; + return mockedCP.stdout; }); mockSpawn.mockImplementationOnce((command: string): ChildProcess => { expect(command).toBe(bundledKubectlPath()); + return mockedCP; }); mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve()); const cluster = new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }); + jest.spyOn(cluster, "apiUrl", "get").mockReturnValue("https://fake.k8s.internal"); proxy = new KubeAuthProxy(cluster, port, {}); }); diff --git a/src/main/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts index 152a13055d..5a5eca9548 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -64,6 +64,7 @@ describe("kubeconfig manager tests", () => { preferences: {}, }) }; + mockFs(mockOpts); }); @@ -86,6 +87,7 @@ describe("kubeconfig manager tests", () => { expect(kubeConfManager.getPath()).toBe("tmp/kubeconfig-foo"); const file = await fse.readFile(kubeConfManager.getPath()); const yml = loadYaml(file.toString()); + expect(yml["current-context"]).toBe("minikube"); expect(yml["clusters"][0]["cluster"]["server"]).toBe(`http://127.0.0.1:${port}/foo`); expect(yml["users"][0]["name"]).toBe("proxy"); @@ -101,8 +103,8 @@ describe("kubeconfig manager tests", () => { const contextHandler = new ContextHandler(cluster); const port = await getFreePort(); const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port); - const configPath = kubeConfManager.getPath(); + expect(await fse.pathExists(configPath)).toBe(true); await kubeConfManager.unlink(); expect(await fse.pathExists(configPath)).toBe(false); diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index c7b6659149..dd9ed97e69 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -14,6 +14,7 @@ export class AppUpdater { public start() { setInterval(AppUpdater.checkForUpdates, this.updateInterval); + return AppUpdater.checkForUpdates(); } } diff --git a/src/main/cluster-detectors/base-cluster-detector.ts b/src/main/cluster-detectors/base-cluster-detector.ts index f73cc2ac81..9d52e1a70e 100644 --- a/src/main/cluster-detectors/base-cluster-detector.ts +++ b/src/main/cluster-detectors/base-cluster-detector.ts @@ -20,6 +20,7 @@ export class BaseClusterDetector { protected async k8sRequest(path: string, options: RequestPromiseOptions = {}): Promise { const apiUrl = this.cluster.kubeProxyUrl + path; + return request(apiUrl, { json: true, timeout: 30000, diff --git a/src/main/cluster-detectors/cluster-id-detector.ts b/src/main/cluster-detectors/cluster-id-detector.ts index 2605ca269f..2e0cc694ff 100644 --- a/src/main/cluster-detectors/cluster-id-detector.ts +++ b/src/main/cluster-detectors/cluster-id-detector.ts @@ -7,17 +7,20 @@ export class ClusterIdDetector extends BaseClusterDetector { public async detect() { let id: string; + try { id = await this.getDefaultNamespaceId(); } catch(_) { id = this.cluster.apiUrl; } const value = createHash("sha256").update(id).digest("hex"); + return { value, accuracy: 100 }; } protected async getDefaultNamespaceId() { const response = await this.k8sRequest("/api/v1/namespaces/default"); + return response.metadata.uid; } } \ No newline at end of file diff --git a/src/main/cluster-detectors/detector-registry.ts b/src/main/cluster-detectors/detector-registry.ts index d4abe01304..43c56153c9 100644 --- a/src/main/cluster-detectors/detector-registry.ts +++ b/src/main/cluster-detectors/detector-registry.ts @@ -17,12 +17,16 @@ export class DetectorRegistry { async detectForCluster(cluster: Cluster): Promise { const results: {[key: string]: ClusterDetectionResult } = {}; + for (const detectorClass of this.registry) { const detector = new detectorClass(cluster); + try { const data = await detector.detect(); + if (!data) continue; const existingValue = results[detector.key]; + if (existingValue && existingValue.accuracy > data.accuracy) continue; // previous value exists and is more accurate results[detector.key] = data; } catch (e) { @@ -30,9 +34,11 @@ export class DetectorRegistry { } } const metadata: ClusterMetadata = {}; + for (const [key, result] of Object.entries(results)) { metadata[key] = result.value; } + return metadata; } } diff --git a/src/main/cluster-detectors/distribution-detector.ts b/src/main/cluster-detectors/distribution-detector.ts index 181425cb26..f4e981c568 100644 --- a/src/main/cluster-detectors/distribution-detector.ts +++ b/src/main/cluster-detectors/distribution-detector.ts @@ -7,30 +7,39 @@ export class DistributionDetector extends BaseClusterDetector { public async detect() { this.version = await this.getKubernetesVersion(); + if (await this.isRancher()) { return { value: "rancher", accuracy: 80}; } + if (this.isGKE()) { return { value: "gke", accuracy: 80}; } + if (this.isEKS()) { return { value: "eks", accuracy: 80}; } + if (this.isIKS()) { return { value: "iks", accuracy: 80}; } + if (this.isAKS()) { return { value: "aks", accuracy: 80}; } + if (this.isDigitalOcean()) { return { value: "digitalocean", accuracy: 90}; } + if (this.isMinikube()) { return { value: "minikube", accuracy: 80}; } + if (this.isCustom()) { return { value: "custom", accuracy: 10}; } + return { value: "unknown", accuracy: 10}; } @@ -38,6 +47,7 @@ export class DistributionDetector extends BaseClusterDetector { if (this.cluster.version) return this.cluster.version; const response = await this.k8sRequest("/version"); + return response.gitVersion; } @@ -72,6 +82,7 @@ export class DistributionDetector extends BaseClusterDetector { protected async isRancher() { try { const response = await this.k8sRequest(""); + return response.data.find((api: any) => api?.apiVersion?.group === "meta.cattle.io") !== undefined; } catch (e) { return false; diff --git a/src/main/cluster-detectors/last-seen-detector.ts b/src/main/cluster-detectors/last-seen-detector.ts index d56483625a..e648d5f2f9 100644 --- a/src/main/cluster-detectors/last-seen-detector.ts +++ b/src/main/cluster-detectors/last-seen-detector.ts @@ -8,6 +8,7 @@ export class LastSeenDetector extends BaseClusterDetector { if (!this.cluster.accessible) return null; await this.k8sRequest("/version"); + return { value: new Date().toJSON(), accuracy: 100 }; } } \ No newline at end of file diff --git a/src/main/cluster-detectors/nodes-count-detector.ts b/src/main/cluster-detectors/nodes-count-detector.ts index ba5fc93583..0ece5dd080 100644 --- a/src/main/cluster-detectors/nodes-count-detector.ts +++ b/src/main/cluster-detectors/nodes-count-detector.ts @@ -7,11 +7,13 @@ export class NodesCountDetector extends BaseClusterDetector { public async detect() { if (!this.cluster.accessible) return null; const nodeCount = await this.getNodeCount(); + return { value: nodeCount, accuracy: 100}; } protected async getNodeCount(): Promise { const response = await this.k8sRequest("/api/v1/nodes"); + return response.items.length; } } \ No newline at end of file diff --git a/src/main/cluster-detectors/version-detector.ts b/src/main/cluster-detectors/version-detector.ts index e59e6291b9..8080ef57a1 100644 --- a/src/main/cluster-detectors/version-detector.ts +++ b/src/main/cluster-detectors/version-detector.ts @@ -7,11 +7,13 @@ export class VersionDetector extends BaseClusterDetector { public async detect() { const version = await this.getKubernetesVersion(); + return { value: version, accuracy: 100}; } public async getKubernetesVersion() { const response = await this.k8sRequest("/version"); + return response.gitVersion; } } \ No newline at end of file diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 9b2e88ef89..5717c7278d 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -24,8 +24,10 @@ export class ClusterManager extends Singleton { // auto-stop removed clusters autorun(() => { const removedClusters = Array.from(clusterStore.removedClusters.values()); + if (removedClusters.length > 0) { const meta = removedClusters.map(cluster => cluster.getMeta()); + logger.info(`[CLUSTER-MANAGER]: removing clusters`, meta); removedClusters.forEach(cluster => cluster.disconnect()); clusterStore.removedClusters.clear(); @@ -70,7 +72,9 @@ export class ClusterManager extends Singleton { // lens-server is connecting to 127.0.0.1:/ if (req.headers.host.startsWith("127.0.0.1")) { const clusterId = req.url.split("/")[1]; + cluster = clusterStore.getById(clusterId); + if (cluster) { // we need to swap path prefix so that request is proxied to kube api req.url = req.url.replace(`/${clusterId}`, apiKubePrefix); @@ -79,6 +83,7 @@ export class ClusterManager extends Singleton { cluster = clusterStore.getById(req.headers["x-cluster-id"].toString()); } else { const clusterId = getClusterIdFromHost(req.headers.host); + cluster = clusterStore.getById(clusterId); } diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 3f03f90716..421856dc03 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -91,6 +91,7 @@ export class Cluster implements ClusterModel, ClusterState { @computed get prometheusPreferences(): ClusterPrometheusPreferences { const { prometheus, prometheusProvider } = this.preferences; + return toJS({ prometheus, prometheusProvider }, { recurseEverything: true, }); @@ -103,6 +104,7 @@ export class Cluster implements ClusterModel, ClusterState { constructor(model: ClusterModel) { this.updateModel(model); const kubeconfig = this.getKubeconfig(); + if (kubeconfig.getContextObject(this.contextName)) { this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server; } @@ -167,13 +169,16 @@ export class Cluster implements ClusterModel, ClusterState { } logger.info(`[CLUSTER]: activate`, this.getMeta()); await this.whenInitialized; + if (!this.eventDisposers.length) { this.bindEvents(); } + if (this.disconnected || !this.accessible) { await this.reconnect(); } await this.refreshConnectionStatus(); + if (this.accessible) { await this.refreshAllowedResources(); this.isAdmin = await this.isClusterAdmin(); @@ -181,11 +186,13 @@ export class Cluster implements ClusterModel, ClusterState { this.ensureKubectl(); } this.activated = true; + return this.pushState(); } protected async ensureKubectl() { this.kubeCtl = new Kubectl(this.version); + return this.kubeCtl.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard } @@ -215,9 +222,11 @@ export class Cluster implements ClusterModel, ClusterState { logger.info(`[CLUSTER]: refresh`, this.getMeta()); await this.whenInitialized; await this.refreshConnectionStatus(); + if (this.accessible) { this.isAdmin = await this.isClusterAdmin(); await this.refreshAllowedResources(); + if (opts.refreshMetadata) { this.refreshMetadata(); } @@ -231,12 +240,14 @@ export class Cluster implements ClusterModel, ClusterState { logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta()); const metadata = await detectorRegistry.detectForCluster(this); const existingMetadata = this.metadata; + this.metadata = Object.assign(existingMetadata, metadata); } @action async refreshConnectionStatus() { const connectionStatus = await this.getConnectionStatus(); + this.online = connectionStatus > ClusterStatus.Offline; this.accessible = connectionStatus == ClusterStatus.AccessGranted; } @@ -271,6 +282,7 @@ export class Cluster implements ClusterModel, ClusterState { getMetrics(prometheusPath: string, queryParams: IMetricsReqParams & { query: string }) { const prometheusPrefix = this.preferences.prometheus?.prefix || ""; const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`; + return this.k8sRequest(metricsPath, { timeout: 0, resolveWithFullResponse: false, @@ -283,43 +295,54 @@ export class Cluster implements ClusterModel, ClusterState { try { const versionDetector = new VersionDetector(this); const versionData = await versionDetector.detect(); + this.metadata.version = versionData.value; + return ClusterStatus.AccessGranted; } catch (error) { logger.error(`Failed to connect cluster "${this.contextName}": ${error}`); + if (error.statusCode) { if (error.statusCode >= 400 && error.statusCode < 500) { this.failureReason = "Invalid credentials"; + return ClusterStatus.AccessDenied; } else { this.failureReason = error.error || error.message; + return ClusterStatus.Offline; } } else if (error.failed === true) { if (error.timedOut === true) { this.failureReason = "Connection timed out"; + return ClusterStatus.Offline; } else { this.failureReason = "Failed to fetch credentials"; + return ClusterStatus.AccessDenied; } } this.failureReason = error.message; + return ClusterStatus.Offline; } } async canI(resourceAttributes: V1ResourceAttributes): Promise { const authApi = this.getProxyKubeconfig().makeApiClient(AuthorizationV1Api); + try { const accessReview = await authApi.createSelfSubjectAccessReview({ apiVersion: "authorization.k8s.io/v1", kind: "SelfSubjectAccessReview", spec: { resourceAttributes } }); + return accessReview.body.status.allowed; } catch (error) { logger.error(`failed to request selfSubjectAccessReview: ${error}`); + return false; } } @@ -343,6 +366,7 @@ export class Cluster implements ClusterModel, ClusterState { ownerRef: this.ownerRef, accessibleNamespaces: this.accessibleNamespaces, }; + return toJS(model, { recurseEverything: true }); @@ -363,6 +387,7 @@ export class Cluster implements ClusterModel, ClusterState { allowedNamespaces: this.allowedNamespaces, allowedResources: this.allowedResources, }; + return toJS(state, { recurseEverything: true }); @@ -397,6 +422,7 @@ export class Cluster implements ClusterModel, ClusterState { } const api = this.getProxyKubeconfig().makeApiClient(CoreV1Api); + try { const namespaceList = await api.listNamespace(); const nsAccessStatuses = await Promise.all( @@ -406,12 +432,15 @@ export class Cluster implements ClusterModel, ClusterState { verb: "list", })) ); + return namespaceList.body.items .filter((ns, i) => nsAccessStatuses[i]) .map(ns => ns.metadata.name); } catch (error) { const ctx = this.getProxyKubeconfig().getContextObject(this.contextName); + if (ctx.namespace) return [ctx.namespace]; + return []; } } @@ -429,6 +458,7 @@ export class Cluster implements ClusterModel, ClusterState { namespace: this.allowedNamespaces[0] })) ); + return apiResources .filter((resource, i) => resourceAccessStatuses[i]) .map(apiResource => apiResource.resource); diff --git a/src/main/context-handler.ts b/src/main/context-handler.ts index 2c0c0b4e8d..d67c495a84 100644 --- a/src/main/context-handler.ts +++ b/src/main/context-handler.ts @@ -25,28 +25,34 @@ export class ContextHandler { public setupPrometheus(preferences: ClusterPrometheusPreferences = {}) { this.prometheusProvider = preferences.prometheusProvider?.type; this.prometheusPath = null; + if (preferences.prometheus) { const { namespace, service, port } = preferences.prometheus; + this.prometheusPath = `${namespace}/services/${service}:${port}`; } } protected async resolvePrometheusPath(): Promise { const prometheusService = await this.getPrometheusService(); + if (!prometheusService) return null; const { service, namespace, port } = prometheusService; + return `${namespace}/services/${service}:${port}`; } async getPrometheusProvider() { if (!this.prometheusProvider) { const service = await this.getPrometheusService(); + if (!service) { return null; } logger.info(`using ${service.id} as prometheus provider`); this.prometheusProvider = service.id; } + return prometheusProviders.find(p => p.id === this.prometheusProvider); } @@ -54,9 +60,11 @@ export class ContextHandler { const providers = this.prometheusProvider ? prometheusProviders.filter(provider => provider.id == this.prometheusProvider) : prometheusProviders; const prometheusPromises: Promise[] = providers.map(async (provider: PrometheusProvider): Promise => { const apiClient = this.cluster.getProxyKubeconfig().makeApiClient(CoreV1Api); + return await provider.getPrometheusService(apiClient); }); const resolvedPrometheusServices = await Promise.all(prometheusPromises); + return resolvedPrometheusServices.filter(n => n)[0]; } @@ -64,12 +72,14 @@ export class ContextHandler { if (!this.prometheusPath) { this.prometheusPath = await this.resolvePrometheusPath(); } + return this.prometheusPath; } async resolveAuthProxyUrl() { const proxyPort = await this.ensurePort(); const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : ""; + return `http://127.0.0.1:${proxyPort}${path}`; } @@ -79,14 +89,17 @@ export class ContextHandler { } const timeout = isWatchRequest ? 4 * 60 * 60 * 1000 : 30000; // 4 hours for watch request, 30 seconds for the rest const apiTarget = await this.newApiTarget(timeout); + if (!isWatchRequest) { this.apiTarget = apiTarget; } + return apiTarget; } protected async newApiTarget(timeout: number): Promise { const proxyUrl = await this.resolveAuthProxyUrl(); + return { target: proxyUrl, changeOrigin: true, @@ -101,6 +114,7 @@ export class ContextHandler { if (!this.proxyPort) { this.proxyPort = await getFreePort(); } + return this.proxyPort; } @@ -108,6 +122,7 @@ export class ContextHandler { if (!this.kubeAuthProxy) { await this.ensurePort(); const proxyEnv = Object.assign({}, process.env); + if (this.cluster.preferences.httpsProxy) { proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy; } diff --git a/src/main/exit-app.ts b/src/main/exit-app.ts index a998a2811f..63fe7673e6 100644 --- a/src/main/exit-app.ts +++ b/src/main/exit-app.ts @@ -8,6 +8,7 @@ import logger from "./logger"; export function exitApp() { const windowManager = WindowManager.getInstance(); const clusterManager = ClusterManager.getInstance(); + appEventBus.emit({ name: "service", action: "close" }); windowManager.hide(); clusterManager.stop(); diff --git a/src/main/extension-filesystem.ts b/src/main/extension-filesystem.ts index fb3a4060be..c4ee622e1d 100644 --- a/src/main/extension-filesystem.ts +++ b/src/main/extension-filesystem.ts @@ -32,11 +32,14 @@ export class FilesystemProvisionerStore extends BaseStore { const salt = randomBytes(32).toString("hex"); const hashedName = SHA256(`${extensionName}/${salt}`).toString(); const dirPath = path.resolve(app.getPath("userData"), "extension_data", hashedName); + this.registeredExtensions.set(extensionName, dirPath); } const dirPath = this.registeredExtensions.get(extensionName); + await fse.ensureDir(dirPath); + return dirPath; } diff --git a/src/main/helm/helm-chart-manager.ts b/src/main/helm/helm-chart-manager.ts index a5920afb71..cf4a8e5ace 100644 --- a/src/main/helm/helm-chart-manager.ts +++ b/src/main/helm/helm-chart-manager.ts @@ -20,32 +20,39 @@ export class HelmChartManager { public async chart(name: string) { const charts = await this.charts(); + return charts[name]; } public async charts(): Promise { try { const cachedYaml = await this.cachedYaml(); + return cachedYaml["entries"]; } catch(error) { logger.error(error); + return []; } } public async getReadme(name: string, version = "") { const helm = await helmCli.binaryPath(); + if(version && version != "") { const { stdout } = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr);}); + return stdout; } else { const { stdout } = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name}`).catch((error) => { throw(error.stderr);}); + return stdout; } } public async getValues(name: string, version = "") { const helm = await helmCli.binaryPath(); + if(version && version != "") { const { stdout } = await promiseExec(`"${helm}" show values ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr);}); @@ -61,6 +68,7 @@ export class HelmChartManager { if (!(this.repo.name in this.cache)) { const cacheFile = await fs.promises.readFile(this.repo.cacheFilePath, "utf-8"); const data = yaml.safeLoad(cacheFile); + for(const key in data["entries"]) { data["entries"][key].forEach((version: any) => { version["repo"] = this.repo.name; @@ -69,6 +77,7 @@ export class HelmChartManager { } this.cache[this.repo.name] = Buffer.from(JSON.stringify(data)); } + return JSON.parse(this.cache[this.repo.name].toString()); } } diff --git a/src/main/helm/helm-cli.ts b/src/main/helm/helm-cli.ts index 90ad78ba1d..ca6f755896 100644 --- a/src/main/helm/helm-cli.ts +++ b/src/main/helm/helm-cli.ts @@ -12,6 +12,7 @@ export class HelmCli extends LensBinary { originalBinaryName: "helm", newBinaryName: "helm3" }; + super(opts); } diff --git a/src/main/helm/helm-release-manager.ts b/src/main/helm/helm-release-manager.ts index a669ff2a6c..220d665ae0 100644 --- a/src/main/helm/helm-release-manager.ts +++ b/src/main/helm/helm-release-manager.ts @@ -12,14 +12,15 @@ export class HelmReleaseManager { const helm = await helmCli.binaryPath(); const namespaceFlag = namespace ? `-n ${namespace}` : "--all-namespaces"; const { stdout } = await promiseExec(`"${helm}" ls --output json ${namespaceFlag} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);}); - const output = JSON.parse(stdout); + if (output.length == 0) { return output; } output.forEach((release: any, index: number) => { output[index] = toCamelCase(release); }); + return output; } @@ -27,15 +28,19 @@ export class HelmReleaseManager { public async installChart(chart: string, values: any, name: string, namespace: string, version: string, pathToKubeconfig: string){ const helm = await helmCli.binaryPath(); const fileName = tempy.file({name: "values.yaml"}); + await fs.promises.writeFile(fileName, yaml.safeDump(values)); + try { let generateName = ""; + if (!name) { generateName = "--generate-name"; name = ""; } const { stdout } = await promiseExec(`"${helm}" install ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} ${generateName}`).catch((error) => { throw(error.stderr);}); const releaseName = stdout.split("\n")[0].split(" ")[1].trim(); + return { log: stdout, release: { @@ -51,10 +56,12 @@ export class HelmReleaseManager { public async upgradeRelease(name: string, chart: string, values: any, namespace: string, version: string, cluster: Cluster){ const helm = await helmCli.binaryPath(); const fileName = tempy.file({name: "values.yaml"}); + await fs.promises.writeFile(fileName, yaml.safeDump(values)); try { const { stdout } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr);}); + return { log: stdout, release: this.getRelease(name, namespace, cluster) @@ -68,7 +75,9 @@ export class HelmReleaseManager { const helm = await helmCli.binaryPath(); const { stdout } = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr);}); const release = JSON.parse(stdout); + release.resources = await this.getResources(name, namespace, cluster); + return release; } @@ -82,18 +91,21 @@ export class HelmReleaseManager { public async getValues(name: string, namespace: string, pathToKubeconfig: string) { const helm = await helmCli.binaryPath(); const { stdout, } = await promiseExec(`"${helm}" get values ${name} --all --output yaml --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);}); + return stdout; } public async getHistory(name: string, namespace: string, pathToKubeconfig: string) { const helm = await helmCli.binaryPath(); const { stdout } = await promiseExec(`"${helm}" history ${name} --output json --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);}); + return JSON.parse(stdout); } public async rollback(name: string, namespace: string, revision: number, pathToKubeconfig: string) { const helm = await helmCli.binaryPath(); const { stdout } = await promiseExec(`"${helm}" rollback ${name} ${revision} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);}); + return stdout; } @@ -104,6 +116,7 @@ export class HelmReleaseManager { const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`).catch(() => { return { stdout: JSON.stringify({items: []})}; }); + return stdout; } } diff --git a/src/main/helm/helm-repo-manager.ts b/src/main/helm/helm-repo-manager.ts index fea000fec2..ae8595dae9 100644 --- a/src/main/helm/helm-repo-manager.ts +++ b/src/main/helm/helm-repo-manager.ts @@ -42,12 +42,14 @@ export class HelmRepoManager extends Singleton { resolveWithFullResponse: true, timeout: 10000, }); + return orderBy(res.body, repo => repo.name); } async init() { helmCli.setLogger(logger); await helmCli.ensureBinary(); + if (!this.initialized) { this.helmEnv = await this.parseHelmEnv(); await this.update(); @@ -62,12 +64,15 @@ export class HelmRepoManager extends Singleton { }); const lines = stdout.split(/\r?\n/); // split by new line feed const env: HelmEnv = {}; + lines.forEach((line: string) => { const [key, value] = line.split("="); + if (key && value) { env[key] = value.replace(/"/g, ""); // strip quotas } }); + return env; } @@ -75,6 +80,7 @@ export class HelmRepoManager extends Singleton { if (!this.initialized) { await this.init(); } + try { const repoConfigFile = this.helmEnv.HELM_REPOSITORY_CONFIG; const { repositories }: HelmRepoConfig = await readFile(repoConfigFile, "utf8") @@ -82,22 +88,27 @@ export class HelmRepoManager extends Singleton { .catch(() => ({ repositories: [] })); + if (!repositories.length) { await this.addRepo({ name: "bitnami", url: "https://charts.bitnami.com/bitnami" }); + return await this.repositories(); } + return repositories.map(repo => ({ ...repo, cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml` })); } catch (error) { logger.error(`[HELM]: repositories listing error "${error}"`); + return []; } } public async repository(name: string) { const repositories = await this.repositories(); + return repositories.find(repo => repo.name == name); } @@ -106,6 +117,7 @@ export class HelmRepoManager extends Singleton { const { stdout } = await promiseExec(`"${helm}" repo update`).catch((error) => { return { stdout: error.stdout }; }); + return stdout; } @@ -115,6 +127,7 @@ export class HelmRepoManager extends Singleton { const { stdout } = await promiseExec(`"${helm}" repo add ${name} ${url}`).catch((error) => { throw(error.stderr); }); + return stdout; } @@ -124,6 +137,7 @@ export class HelmRepoManager extends Singleton { const { stdout } = await promiseExec(`"${helm}" repo remove ${name}`).catch((error) => { throw(error.stderr); }); + return stdout; } } diff --git a/src/main/helm/helm-service.ts b/src/main/helm/helm-service.ts index 0ccec256ed..1918268075 100644 --- a/src/main/helm/helm-service.ts +++ b/src/main/helm/helm-service.ts @@ -11,18 +11,23 @@ class HelmService { public async listCharts() { const charts: any = {}; + await repoManager.init(); const repositories = await repoManager.repositories(); + for (const repo of repositories) { charts[repo.name] = {}; const manager = new HelmChartManager(repo); let entries = await manager.charts(); + entries = this.excludeDeprecated(entries); + for (const key in entries) { entries[key] = entries[key][0]; } charts[repo.name] = entries; } + return charts; } @@ -34,50 +39,60 @@ class HelmService { const repo = await repoManager.repository(repoName); const chartManager = new HelmChartManager(repo); const chart = await chartManager.chart(chartName); + result.readme = await chartManager.getReadme(chartName, version); result.versions = chart; + return result; } public async getChartValues(repoName: string, chartName: string, version = "") { const repo = await repoManager.repository(repoName); const chartManager = new HelmChartManager(repo); + return chartManager.getValues(chartName, version); } public async listReleases(cluster: Cluster, namespace: string = null) { await repoManager.init(); + return await releaseManager.listReleases(cluster.getProxyKubeconfigPath(), namespace); } public async getRelease(cluster: Cluster, releaseName: string, namespace: string) { logger.debug("Fetch release"); + return await releaseManager.getRelease(releaseName, namespace, cluster); } public async getReleaseValues(cluster: Cluster, releaseName: string, namespace: string) { logger.debug("Fetch release values"); + return await releaseManager.getValues(releaseName, namespace, cluster.getProxyKubeconfigPath()); } public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) { logger.debug("Fetch release history"); + return await releaseManager.getHistory(releaseName, namespace, cluster.getProxyKubeconfigPath()); } public async deleteRelease(cluster: Cluster, releaseName: string, namespace: string) { logger.debug("Delete release"); + return await releaseManager.deleteRelease(releaseName, namespace, cluster.getProxyKubeconfigPath()); } public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: { chart: string; values: {}; version: string }) { logger.debug("Upgrade release"); + return await releaseManager.upgradeRelease(releaseName, data.chart, data.values, namespace, data.version, cluster); } public async rollback(cluster: Cluster, releaseName: string, namespace: string, revision: number) { logger.debug("Rollback release"); const output = await releaseManager.rollback(releaseName, namespace, revision, cluster.getProxyKubeconfigPath()); + return { message: output }; } @@ -87,9 +102,11 @@ class HelmService { if (Array.isArray(entry)) { return entry[0]["deprecated"] != true; } + return entry["deprecated"] != true; }); } + return entries; } diff --git a/src/main/index.ts b/src/main/index.ts index 315526862c..cad2235743 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -34,11 +34,13 @@ let clusterManager: ClusterManager; let windowManager: WindowManager; app.setName(appName); + if (!process.env.CICD) { app.setPath("userData", workingDir); } mangleProxyEnv(); + if (app.commandLine.getSwitchValue("proxy-server") !== "") { process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server"); } @@ -48,6 +50,7 @@ app.on("ready", async () => { await shellSync(); const updater = new AppUpdater(); + updater.start(); registerFileProtocol("static", __static); @@ -110,6 +113,7 @@ app.on("ready", async () => { app.on("activate", (event, hasVisibleWindows) => { logger.info("APP:ACTIVATE", { hasVisibleWindows }); + if (!hasVisibleWindows) { windowManager.initMainWindow(); } @@ -121,6 +125,7 @@ app.on("will-quit", (event) => { appEventBus.emit({name: "app", action: "close"}); event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.) clusterManager?.stop(); // close cluster connections + return; // skip exit to make tray work, to quit go to app's global menu or tray's menu }); diff --git a/src/main/kube-auth-proxy.ts b/src/main/kube-auth-proxy.ts index 791b242104..589fe8fa16 100644 --- a/src/main/kube-auth-proxy.ts +++ b/src/main/kube-auth-proxy.ts @@ -45,6 +45,7 @@ export class KubeAuthProxy { "--accept-hosts", this.acceptHosts, "--reject-paths", "^[^/]" ]; + if (process.env.DEBUG_PROXY === "true") { args.push("-v", "9"); } @@ -62,6 +63,7 @@ export class KubeAuthProxy { this.proxyProcess.stdout.on("data", (data) => { let logItem = data.toString(); + if (logItem.startsWith("Starting to serve on")) { logItem = "Authentication proxy started\n"; } @@ -80,19 +82,23 @@ export class KubeAuthProxy { const error = data.split("http: proxy error:").slice(1).join("").trim(); let errorMsg = error; const jsonError = error.split("Response: ")[1]; + if (jsonError) { try { const parsedError = JSON.parse(jsonError); + errorMsg = parsedError.error_description || parsedError.error || jsonError; } catch (_) { errorMsg = jsonError.trim(); } } + return errorMsg; } protected async sendIpcLogMessage(res: KubeAuthProxyLog) { const channel = `kube-auth:${this.cluster.id}`; + logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() }); broadcastMessage(channel, res); } diff --git a/src/main/kubeconfig-manager.ts b/src/main/kubeconfig-manager.ts index b0251264e7..bd1b32c1f5 100644 --- a/src/main/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager.ts @@ -15,7 +15,9 @@ export class KubeconfigManager { static async create(cluster: Cluster, contextHandler: ContextHandler, port: number) { const kcm = new KubeconfigManager(cluster, contextHandler, port); + await kcm.init(); + return kcm; } @@ -66,13 +68,14 @@ export class KubeconfigManager { } ] }; - // write const configYaml = dumpConfigYaml(proxyConfig); + fs.ensureDir(path.dirname(tempFile)); fs.writeFileSync(tempFile, configYaml, { mode: 0o600 }); this.tempFile = tempFile; logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`); + return tempFile; } diff --git a/src/main/kubectl.ts b/src/main/kubectl.ts index ee059bde5b..0a96ee354b 100644 --- a/src/main/kubectl.ts +++ b/src/main/kubectl.ts @@ -27,12 +27,10 @@ const kubectlMap: Map = new Map([ ["1.18", "1.18.8"], ["1.19", "1.19.0"] ]); - const packageMirrors: Map = new Map([ ["default", "https://storage.googleapis.com/kubernetes-release/release"], ["china", "https://mirror.azure.cn/kubernetes/kubectl"] ]); - let bundledPath: string; const initScriptVersionString = "# lens-initscript v3\n"; @@ -41,6 +39,7 @@ export function bundledKubectlPath(): string { if (isDevelopment || isTestEnv) { const platformName = isWindows ? "windows" : process.platform; + bundledPath = path.join(process.cwd(), "binaries", "client", platformName, process.arch, "kubectl"); } else { bundledPath = path.join(process.resourcesPath, process.arch, "kubectl"); @@ -71,12 +70,14 @@ export class Kubectl { // Returns the single bundled Kubectl instance public static bundled() { if (!Kubectl.bundledInstance) Kubectl.bundledInstance = new Kubectl(Kubectl.bundledKubectlVersion); + return Kubectl.bundledInstance; } constructor(clusterVersion: string) { const versionParts = /^v?(\d+\.\d+)(.*)/.exec(clusterVersion); const minorVersion = versionParts[1]; + /* 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)) { @@ -134,18 +135,22 @@ export class Kubectl { // return binary name if bundled path is not functional if (!await this.checkBinary(this.getBundledPath(), false)) { Kubectl.invalidBundle = true; + return path.basename(this.getBundledPath()); } try { if (!await this.ensureKubectl()) { logger.error("Failed to ensure kubectl, fallback to the bundled version"); + return this.getBundledPath(); } + return this.path; } catch (err) { logger.error("Failed to ensure kubectl, fallback to the bundled version"); logger.error(err); + return this.getBundledPath(); } } @@ -154,28 +159,35 @@ export class Kubectl { try { await this.ensureKubectl(); await this.writeInitScripts(); + return this.dirname; } catch (err) { logger.error(err); + return ""; } } public async checkBinary(path: string, checkVersion = true) { const exists = await pathExists(path); + if (exists) { try { const { stdout } = await promiseExec(`"${path}" version --client=true -o json`); const output = JSON.parse(stdout); + if (!checkVersion) { return true; } let version: string = output.clientVersion.gitVersion; + if (version[0] === "v") { version = version.slice(1); } + if (version === this.kubectlVersion) { logger.debug(`Local kubectl is version ${this.kubectlVersion}`); + return true; } logger.error(`Local kubectl is version ${version}, expected ${this.kubectlVersion}, unlinking`); @@ -184,6 +196,7 @@ export class Kubectl { } await fs.promises.unlink(this.path); } + return false; } @@ -191,13 +204,16 @@ export class Kubectl { if (this.kubectlVersion === Kubectl.bundledKubectlVersion) { try { const exist = await pathExists(this.path); + if (!exist) { await fs.promises.copyFile(this.getBundledPath(), this.path); await fs.promises.chmod(this.path, 0o755); } + return true; } catch (err) { logger.error(`Could not copy the bundled kubectl to app-data: ${err}`); + return false; } } else { @@ -209,35 +225,44 @@ export class Kubectl { if (userStore.preferences?.downloadKubectlBinaries === false) { return true; } + if (Kubectl.invalidBundle) { logger.error(`Detected invalid bundle binary, returning ...`); + return false; } await ensureDir(this.dirname, 0o755); + return lockFile.lock(this.dirname).then(async (release) => { logger.debug(`Acquired a lock for ${this.kubectlVersion}`); const bundled = await this.checkBundled(); let isValid = await this.checkBinary(this.path, !bundled); + if (!isValid && !bundled) { await this.downloadKubectl().catch((error) => { logger.error(error); logger.debug(`Releasing lock for ${this.kubectlVersion}`); release(); + return false; }); isValid = !await this.checkBinary(this.path, false); } + if (!isValid) { logger.debug(`Releasing lock for ${this.kubectlVersion}`); release(); + return false; } logger.debug(`Releasing lock for ${this.kubectlVersion}`); release(); + return true; }).catch((e) => { logger.error(`Failed to get a lock for ${this.kubectlVersion}`); logger.error(e); + return false; }); } @@ -246,12 +271,14 @@ export class Kubectl { await ensureDir(path.dirname(this.path), 0o755); logger.info(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`); + return new Promise((resolve, reject) => { const stream = customRequest({ url: this.url, gzip: true, }); const file = fs.createWriteStream(this.path); + stream.on("complete", () => { logger.debug("kubectl binary download finished"); file.end(); @@ -279,8 +306,8 @@ export class Kubectl { const helmPath = helmCli.getBinaryDir(); const fsPromises = fs.promises; const bashScriptPath = path.join(this.dirname, ".bash_set_path"); - let bashScript = `${initScriptVersionString}`; + bashScript += "tempkubeconfig=\"$KUBECONFIG\"\n"; bashScript += "test -f \"/etc/profile\" && . \"/etc/profile\"\n"; bashScript += "if test -f \"$HOME/.bash_profile\"; then\n"; @@ -302,7 +329,6 @@ export class Kubectl { await fsPromises.writeFile(bashScriptPath, bashScript.toString(), { mode: 0o644 }); const zshScriptPath = path.join(this.dirname, ".zlogin"); - let zshScript = `${initScriptVersionString}`; zshScript += "tempkubeconfig=\"$KUBECONFIG\"\n"; @@ -335,9 +361,11 @@ export class Kubectl { protected getDownloadMirror() { const mirror = packageMirrors.get(userStore.preferences?.downloadMirror); + if (mirror) { return mirror; } + return packageMirrors.get("default"); // MacOS packages are only available from default } } diff --git a/src/main/kubectl_spec.ts b/src/main/kubectl_spec.ts index 9d5a5d1e1d..50d0b11374 100644 --- a/src/main/kubectl_spec.ts +++ b/src/main/kubectl_spec.ts @@ -8,12 +8,14 @@ jest.mock("../common/user-store"); describe("kubectlVersion", () => { it("returns bundled version if exactly same version used", async () => { const kubectl = new Kubectl(Kubectl.bundled().kubectlVersion); + expect(kubectl.kubectlVersion).toBe(Kubectl.bundled().kubectlVersion); }); it("returns bundled version if same major.minor version is used", async () => { const { bundledKubectlVersion } = packageInfo.config; const kubectl = new Kubectl(bundledKubectlVersion); + expect(kubectl.kubectlVersion).toBe(Kubectl.bundled().kubectlVersion); }); }); @@ -24,19 +26,23 @@ describe("getPath()", () => { const kubectl = new Kubectl(bundledKubectlVersion); const kubectlPath = await kubectl.getPath(); let binaryName = "kubectl"; + if (isWindows) { binaryName += ".exe"; } const expectedPath = path.join(Kubectl.kubectlDir, Kubectl.bundledKubectlVersion, binaryName); + expect(kubectlPath).toBe(expectedPath); }); it("returns plain binary name if bundled kubectl is non-functional", async () => { const { bundledKubectlVersion } = packageInfo.config; const kubectl = new Kubectl(bundledKubectlVersion); + jest.spyOn(kubectl, "getBundledPath").mockReturnValue("/invalid/path/kubectl"); const kubectlPath = await kubectl.getPath(); let binaryName = "kubectl"; + if (isWindows) { binaryName += ".exe"; } diff --git a/src/main/lens-binary.ts b/src/main/lens-binary.ts index bdd4c7ee62..3cf5a5fce7 100644 --- a/src/main/lens-binary.ts +++ b/src/main/lens-binary.ts @@ -31,6 +31,7 @@ export class LensBinary { constructor(opts: LensBinaryOpts) { const baseDir = opts.baseDir; + this.originalBinaryName = opts.originalBinaryName; this.binaryName = opts.newBinaryName || opts.originalBinaryName; this.binaryVersion = opts.version; @@ -50,11 +51,13 @@ export class LensBinary { this.arch = arch; this.platformName = isWindows ? "windows" : process.platform; this.dirname = path.normalize(path.join(baseDir, this.binaryName)); + if (isWindows) { this.binaryName = `${this.binaryName}.exe`; this.originalBinaryName = `${this.originalBinaryName}.exe`; } const tarName = this.getTarName(); + if (tarName) { this.tarPath = path.join(this.dirname, tarName); } @@ -70,6 +73,7 @@ export class LensBinary { public async binaryPath() { await this.ensureBinary(); + return this.getBinaryPath(); } @@ -96,20 +100,24 @@ export class LensBinary { public async binDir() { try { await this.ensureBinary(); + return this.dirname; } catch (err) { this.logger.error(err); + return ""; } } protected async checkBinary() { const exists = await pathExists(this.getBinaryPath()); + return exists; } public async ensureBinary() { const isValid = await this.checkBinary(); + if (!isValid) { await this.downloadBinary().catch((error) => { this.logger.error(error); @@ -148,6 +156,7 @@ export class LensBinary { protected async downloadBinary() { const binaryPath = this.tarPath || this.getBinaryPath(); + await ensureDir(this.getBinaryDir(), 0o755); const file = fs.createWriteStream(binaryPath); @@ -159,7 +168,6 @@ export class LensBinary { gzip: true, ...this.requestOpts }; - const stream = request(requestOpts); stream.on("complete", () => { @@ -174,6 +182,7 @@ export class LensBinary { }); throw(error); }); + return new Promise((resolve, reject) => { file.on("close", () => { this.logger.debug(`${this.originalBinaryName} binary download closed`); diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index 3f9433026c..5770429a7e 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -30,6 +30,7 @@ export class LensProxy { listen(port = this.port): this { this.proxyServer = this.buildCustomProxy().listen(port); logger.info(`LensProxy server has started at ${this.origin}`); + return this; } @@ -49,6 +50,7 @@ export class LensProxy { }, (req: http.IncomingMessage, res: http.ServerResponse) => { this.handleRequest(proxy, req, res); }); + spdyProxy.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { if (req.url.startsWith(`${apiPrefix}?`)) { this.handleWsUpgrade(req, socket, head); @@ -59,22 +61,27 @@ export class LensProxy { spdyProxy.on("error", (err) => { logger.error("proxy error", err); }); + return spdyProxy; } protected async handleProxyUpgrade(proxy: httpProxy, req: http.IncomingMessage, socket: net.Socket, head: Buffer) { const cluster = this.clusterManager.getClusterForRequest(req); + if (cluster) { const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); const apiUrl = url.parse(cluster.apiUrl); const pUrl = url.parse(proxyUrl); const connectOpts = { port: parseInt(pUrl.port), host: pUrl.hostname }; const proxySocket = new net.Socket(); + proxySocket.connect(connectOpts, () => { proxySocket.write(`${req.method} ${pUrl.path} HTTP/1.1\r\n`); proxySocket.write(`Host: ${apiUrl.host}\r\n`); + for (let i = 0; i < req.rawHeaders.length; i += 2) { const key = req.rawHeaders[i]; + if (key !== "Host" && key !== "Authorization") { proxySocket.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i+1]}\r\n`); } @@ -112,16 +119,20 @@ export class LensProxy { protected createProxy(): httpProxy { const proxy = httpProxy.createProxyServer(); + proxy.on("error", (error, req, res, target) => { if (this.closed) { return; } + if (target) { logger.debug(`Failed proxy to target: ${JSON.stringify(target, null, 2)}`); + if (req.method === "GET" && (!res.statusCode || res.statusCode >= 500)) { const reqId = this.getRequestId(req); const retryCount = this.retryCounters.get(reqId) || 0; const timeoutMs = retryCount * 250; + if (retryCount < 20) { logger.debug(`Retrying proxy request to url: ${reqId}`); setTimeout(() => { @@ -131,6 +142,7 @@ export class LensProxy { } } } + try { res.writeHead(500).end("Oops, something went wrong."); } catch (e) { @@ -143,9 +155,11 @@ export class LensProxy { protected createWsListener(): WebSocket.Server { const ws = new WebSocket.Server({ noServer: true }); + return ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => { const cluster = this.clusterManager.getClusterForRequest(req); const nodeParam = url.parse(req.url, true).query["node"]?.toString(); + openShell(socket, cluster, nodeParam); })); } @@ -155,6 +169,7 @@ export class LensProxy { delete req.headers.authorization; req.url = req.url.replace(apiKubePrefix, ""); const isWatchRequest = req.url.includes("watch="); + return await contextHandler.getApiTarget(isWatchRequest); } } @@ -165,11 +180,14 @@ export class LensProxy { protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) { const cluster = this.clusterManager.getClusterForRequest(req); + if (cluster) { const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler); + if (proxyTarget) { // allow to fetch apis in "clusterId.localhost:port" from "localhost:port" res.setHeader("Access-Control-Allow-Origin", this.origin); + return proxy.web(req, res, proxyTarget); } } @@ -178,6 +196,7 @@ export class LensProxy { protected async handleWsUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) { const wsServer = this.createWsListener(); + wsServer.handleUpgrade(req, socket, head, (con) => { wsServer.emit("connection", con, req); }); diff --git a/src/main/logger.ts b/src/main/logger.ts index 81d61e8002..0ddc7bb1f7 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -3,12 +3,10 @@ import winston from "winston"; import { isDebugging } from "../common/vars"; const logLevel = process.env.LOG_LEVEL ? process.env.LOG_LEVEL : isDebugging ? "debug" : "info"; - const consoleOptions: winston.transports.ConsoleTransportOptions = { handleExceptions: false, level: logLevel, }; - const fileOptions: winston.transports.FileTransportOptions = { handleExceptions: false, level: logLevel, @@ -18,7 +16,6 @@ const fileOptions: winston.transports.FileTransportOptions = { maxFiles: 16, tailable: true, }; - const logger = winston.createLogger({ format: winston.format.combine( winston.format.colorize(), diff --git a/src/main/menu.ts b/src/main/menu.ts index feccbafa21..2cddbb1b01 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -27,6 +27,7 @@ export function showAbout(browserWindow: BrowserWindow) { `Node: ${process.versions.node}`, `Copyright 2020 Mirantis, Inc.`, ]; + dialog.showMessageBoxSync(browserWindow, { title: `${isWindows ? " ".repeat(2) : ""}${appName}`, type: "info", @@ -39,6 +40,7 @@ export function showAbout(browserWindow: BrowserWindow) { export function buildMenu(windowManager: WindowManager) { function ignoreOnMac(menuItems: MenuItemConstructorOptions[]) { if (isMac) return []; + return menuItems; } @@ -48,6 +50,7 @@ export function buildMenu(windowManager: WindowManager) { item.enabled = false; }); } + return menuItems; } @@ -96,7 +99,6 @@ export function buildMenu(windowManager: WindowManager) { } ] }; - const fileMenu: MenuItemConstructorOptions = { label: "File", submenu: [ @@ -154,7 +156,6 @@ export function buildMenu(windowManager: WindowManager) { ]) ] }; - const editMenu: MenuItemConstructorOptions = { label: "Edit", submenu: [ @@ -169,7 +170,6 @@ export function buildMenu(windowManager: WindowManager) { { role: "selectAll" }, ] }; - const viewMenu: MenuItemConstructorOptions = { label: "View", submenu: [ @@ -203,7 +203,6 @@ export function buildMenu(windowManager: WindowManager) { { role: "togglefullscreen" } ] }; - const helpMenu: MenuItemConstructorOptions = { role: "help", submenu: [ @@ -235,7 +234,6 @@ export function buildMenu(windowManager: WindowManager) { ]) ] }; - // Prepare menu items order const appMenu: Record = { mac: macAppMenu, @@ -249,6 +247,7 @@ export function buildMenu(windowManager: WindowManager) { menuRegistry.getItems().forEach(({ parentId, ...menuItem }) => { try { const topMenu = appMenu[parentId as MenuTopId].submenu as MenuItemConstructorOptions[]; + topMenu.push(menuItem); } catch (err) { logger.error(`[MENU]: can't register menu item, parentId=${parentId}`, { menuItem }); @@ -260,6 +259,7 @@ export function buildMenu(windowManager: WindowManager) { } const menu = Menu.buildFromTemplate(Object.values(appMenu)); + Menu.setApplicationMenu(menu); if (isTestEnv) { @@ -273,6 +273,7 @@ export function buildMenu(windowManager: WindowManager) { for (const name of names) { parentLabels.push(name); menuItem = menu?.items?.find(item => item.label === name); + if (!menuItem) { break; } @@ -280,14 +281,18 @@ export function buildMenu(windowManager: WindowManager) { } const menuPath: string = parentLabels.join(" -> "); + if (!menuItem) { logger.info(`[MENU:test-menu-item-click] Cannot find menu item ${menuPath}`); + return; } const { enabled, visible, click } = menuItem; + if (enabled === false || visible === false || typeof click !== "function") { logger.info(`[MENU:test-menu-item-click] Menu item ${menuPath} not clickable`); + return; } diff --git a/src/main/node-shell-session.ts b/src/main/node-shell-session.ts index ea85b8ac2a..b67e776725 100644 --- a/src/main/node-shell-session.ts +++ b/src/main/node-shell-session.ts @@ -23,6 +23,7 @@ export class NodeShellSession extends ShellSession { public async open() { const shell = await this.kubectl.getPath(); let args = []; + if (this.createNodeShellPod(this.podId, this.nodeName)) { await this.waitForRunningPod(this.podId).catch(() => { this.exit(1001); @@ -31,6 +32,7 @@ export class NodeShellSession extends ShellSession { args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"]; const shellEnv = await this.getCachedShellEnv(); + this.shellProcess = pty.spawn(shell, args, { cols: 80, cwd: this.cwd() || shellEnv["HOME"], @@ -85,10 +87,13 @@ export class NodeShellSession extends ShellSession { } } } as k8s.V1Pod; + await k8sApi.createNamespacedPod("kube-system", pod).catch((error) => { logger.error(error); + return false; }); + return true; } @@ -98,6 +103,7 @@ export class NodeShellSession extends ShellSession { } this.kc = new k8s.KubeConfig(); this.kc.loadFromFile(this.kubeconfigPath); + return this.kc; } @@ -105,7 +111,6 @@ export class NodeShellSession extends ShellSession { return new Promise(async (resolve, reject) => { const kc = this.getKubeConfig(); const watch = new k8s.Watch(kc); - const req = await watch.watch(`/api/v1/namespaces/kube-system/pods`, {}, // callback is called for each received object. (type, obj) => { @@ -119,6 +124,7 @@ export class NodeShellSession extends ShellSession { reject(false); } ); + setTimeout(() => { req.abort(); reject(false); @@ -129,17 +135,20 @@ export class NodeShellSession extends ShellSession { protected deleteNodeShellPod() { const kc = this.getKubeConfig(); const k8sApi = kc.makeApiClient(k8s.CoreV1Api); + k8sApi.deleteNamespacedPod(this.podId, "kube-system"); } } export async function openShell(socket: WebSocket, cluster: Cluster, nodeName?: string): Promise { let shell: ShellSession; + if (nodeName) { shell = new NodeShellSession(socket, cluster, nodeName); } else { shell = new ShellSession(socket, cluster); } shell.open(); + return shell; } diff --git a/src/main/port.ts b/src/main/port.ts index 6ba8f71695..cd4c5701e8 100644 --- a/src/main/port.ts +++ b/src/main/port.ts @@ -5,11 +5,14 @@ import logger from "./logger"; export async function getFreePort(): Promise { logger.debug("Lookup new free port.."); + return new Promise((resolve, reject) => { const server = net.createServer(); + server.unref(); server.on("listening", () => { const port = (server.address() as AddressInfo).port; + server.close(() => resolve(port)); logger.debug(`New port found: ${port}`); }); diff --git a/src/main/port_spec.ts b/src/main/port_spec.ts index 279bf5951e..43c2326b92 100644 --- a/src/main/port_spec.ts +++ b/src/main/port_spec.ts @@ -9,10 +9,12 @@ jest.mock("net", () => { return new class MockServer extends EventEmitter { listen = jest.fn(() => { this.emit("listening"); + return this; }); address = () => { newPort = Math.round(Math.random() * 10000); + return { port: newPort }; diff --git a/src/main/prometheus/helm.ts b/src/main/prometheus/helm.ts index 56d739c630..438cc87a64 100644 --- a/src/main/prometheus/helm.ts +++ b/src/main/prometheus/helm.ts @@ -10,9 +10,11 @@ export class PrometheusHelm extends PrometheusLens { public async getPrometheusService(client: CoreV1Api): Promise { const labelSelector = "app=prometheus,component=server,heritage=Helm"; + try { const serviceList = await client.listServiceForAllNamespaces(false, "", null, labelSelector); const service = serviceList.body.items[0]; + if (!service) return; return { @@ -23,6 +25,7 @@ export class PrometheusHelm extends PrometheusLens { }; } catch(error) { logger.warn(`PrometheusHelm: failed to list services: ${error.toString()}`); + return; } } diff --git a/src/main/prometheus/lens.ts b/src/main/prometheus/lens.ts index 725771a033..b829285f33 100644 --- a/src/main/prometheus/lens.ts +++ b/src/main/prometheus/lens.ts @@ -11,6 +11,7 @@ export class PrometheusLens implements PrometheusProvider { try { const resp = await client.readNamespacedService("prometheus", "lens-metrics"); const service = resp.body; + return { id: this.id, namespace: service.metadata.namespace, @@ -72,6 +73,7 @@ export class PrometheusLens implements PrometheusProvider { case "ingress": const bytesSent = (ingress: string, statuses: string) => `sum(rate(nginx_ingress_controller_bytes_sent_sum{ingress="${ingress}", status=~"${statuses}"}[${this.rateAccuracy}])) by (ingress)`; + return { bytesSentSuccess: bytesSent(opts.igress, "^2\\\\d*"), bytesSentFailure: bytesSent(opts.ingres, "^5\\\\d*"), diff --git a/src/main/prometheus/operator.ts b/src/main/prometheus/operator.ts index 6880d4c27c..ee4c1e63bf 100644 --- a/src/main/prometheus/operator.ts +++ b/src/main/prometheus/operator.ts @@ -10,9 +10,11 @@ export class PrometheusOperator implements PrometheusProvider { public async getPrometheusService(client: CoreV1Api): Promise { try { let service: V1Service; + for (const labelSelector of ["operated-prometheus=true", "self-monitor=true"]) { if (!service) { const serviceList = await client.listServiceForAllNamespaces(null, null, null, labelSelector); + service = serviceList.body.items[0]; } } @@ -26,6 +28,7 @@ export class PrometheusOperator implements PrometheusProvider { }; } catch(error) { logger.warn(`PrometheusOperator: failed to list services: ${error.toString()}`); + return; } } @@ -80,6 +83,7 @@ export class PrometheusOperator implements PrometheusProvider { case "ingress": const bytesSent = (ingress: string, statuses: string) => `sum(rate(nginx_ingress_controller_bytes_sent_sum{ingress="${ingress}", status=~"${statuses}"}[${this.rateAccuracy}])) by (ingress)`; + return { bytesSentSuccess: bytesSent(opts.igress, "^2\\\\d*"), bytesSentFailure: bytesSent(opts.ingres, "^5\\\\d*"), diff --git a/src/main/prometheus/provider-registry.ts b/src/main/prometheus/provider-registry.ts index 641b1b8cf2..c649560c85 100644 --- a/src/main/prometheus/provider-registry.ts +++ b/src/main/prometheus/provider-registry.ts @@ -77,6 +77,7 @@ export class PrometheusProviderRegistry { if (!this.prometheusProviders[type]) { throw "Unknown Prometheus provider"; } + return this.prometheusProviders[type]; } diff --git a/src/main/prometheus/stacklight.ts b/src/main/prometheus/stacklight.ts index 84892214a1..5cb2773ca5 100644 --- a/src/main/prometheus/stacklight.ts +++ b/src/main/prometheus/stacklight.ts @@ -11,6 +11,7 @@ export class PrometheusStacklight implements PrometheusProvider { try { const resp = await client.readNamespacedService("prometheus-server", "stacklight"); const service = resp.body; + return { id: this.id, namespace: service.metadata.namespace, @@ -72,6 +73,7 @@ export class PrometheusStacklight implements PrometheusProvider { case "ingress": const bytesSent = (ingress: string, statuses: string) => `sum(rate(nginx_ingress_controller_bytes_sent_sum{ingress="${ingress}", status=~"${statuses}"}[${this.rateAccuracy}])) by (ingress)`; + return { bytesSentSuccess: bytesSent(opts.igress, "^2\\\\d*"), bytesSentFailure: bytesSent(opts.ingres, "^5\\\\d*"), diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index c919185ae9..d4070f2378 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -16,19 +16,24 @@ export class ResourceApplier { async apply(resource: KubernetesObject | any): Promise { resource = this.sanitizeObject(resource); appEventBus.emit({name: "resource", action: "apply"}); + return await this.kubectlApply(yaml.safeDump(resource)); } protected async kubectlApply(content: string): Promise { const { kubeCtl } = this.cluster; const kubectlPath = await kubeCtl.getPath(); + return new Promise((resolve, reject) => { const fileName = tempy.file({ name: "resource.yaml" }); + fs.writeFileSync(fileName, content); const cmd = `"${kubectlPath}" apply --kubeconfig "${this.cluster.getProxyKubeconfigPath()}" -o json -f "${fileName}"`; + logger.debug(`shooting manifests with: ${cmd}`); const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env); const httpsProxy = this.cluster.preferences?.httpsProxy; + if (httpsProxy) { execEnv["HTTPS_PROXY"] = httpsProxy; } @@ -37,6 +42,7 @@ export class ResourceApplier { if (stderr != "") { fs.unlinkSync(fileName); reject(stderr); + return; } fs.unlinkSync(fileName); @@ -48,20 +54,25 @@ export class ResourceApplier { public async kubectlApplyAll(resources: string[]): Promise { const { kubeCtl } = this.cluster; const kubectlPath = await kubeCtl.getPath(); + return new Promise((resolve, reject) => { const tmpDir = tempy.directory(); + // Dump each resource into tmpDir resources.forEach((resource, index) => { fs.writeFileSync(path.join(tmpDir, `${index}.yaml`), resource); }); const cmd = `"${kubectlPath}" apply --kubeconfig "${this.cluster.getProxyKubeconfigPath()}" -o json -f "${tmpDir}"`; + console.log("shooting manifests with:", cmd); exec(cmd, (error, stdout, stderr) => { if (error) { reject(`Error applying manifests:${error}`); } + if (stderr != "") { reject(stderr); + return; } resolve(stdout); @@ -74,9 +85,11 @@ export class ResourceApplier { delete resource.status; delete resource.metadata?.resourceVersion; const annotations = resource.metadata?.annotations; + if (annotations) { delete annotations["kubectl.kubernetes.io/last-applied-configuration"]; } + return resource; } } diff --git a/src/main/router.ts b/src/main/router.ts index f8aa76043d..896893a592 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -52,11 +52,15 @@ export class Router { const method = req.method.toLowerCase(); const matchingRoute = this.router.route(method, path); const routeFound = !matchingRoute.isBoom; + if (routeFound) { const request = await this.getRequest({ req, res, cluster, url, params: matchingRoute.params }); + await matchingRoute.route(request); + return true; } + return false; } @@ -66,6 +70,7 @@ export class Router { parse: true, output: "data", }); + return { cluster, path: url.pathname, @@ -92,23 +97,29 @@ export class Router { woff2: "font/woff2", ttf: "font/ttf" }; + return mimeTypes[path.extname(filename).slice(1)] || "text/plain"; } async handleStaticFile(filePath: string, res: http.ServerResponse, req: http.IncomingMessage, retryCount = 0) { const asset = path.join(__static, filePath); + try { const filename = path.basename(req.url); // redirect requests to [appName].js, [appName].html /sockjs-node/ to webpack-dev-server (for hot-reload support) const toWebpackDevServer = filename.includes(appName) || filename.includes("hot-update") || req.url.includes("sockjs-node"); + if (isDevelopment && toWebpackDevServer) { const redirectLocation = `http://localhost:${webpackDevServerPort}${req.url}`; + res.statusCode = 307; res.setHeader("Location", redirectLocation); res.end(); + return; } const data = await readFile(asset); + res.setHeader("Content-Type", this.getMimeType(asset)); res.write(data); res.end(); @@ -117,6 +128,7 @@ export class Router { logger.error("handleStaticFile:", err.toString()); res.statusCode = 404; res.end(); + return; } this.handleStaticFile(`${publicPath}/${appName}.html`, res, req, Math.max(retryCount, 0) + 1); diff --git a/src/main/routes/helm-route.ts b/src/main/routes/helm-route.ts index b9a6cf513b..4d1cae8bc2 100644 --- a/src/main/routes/helm-route.ts +++ b/src/main/routes/helm-route.ts @@ -7,13 +7,16 @@ class HelmApiRoute extends LensApi { public async listCharts(request: LensApiRequest) { const { response } = request; const charts = await helmService.listCharts(); + this.respondJson(response, charts); } public async getChart(request: LensApiRequest) { const { params, query, response } = request; + try { const chart = await helmService.getChart(params.repo, params.chart, query.get("version")); + this.respondJson(response, chart); } catch (error) { this.respondText(response, error, 422); @@ -22,8 +25,10 @@ class HelmApiRoute extends LensApi { public async getChartValues(request: LensApiRequest) { const { params, query, response } = request; + try { const values = await helmService.getChartValues(params.repo, params.chart, query.get("version")); + this.respondJson(response, values); } catch (error) { this.respondText(response, error, 422); @@ -32,8 +37,10 @@ class HelmApiRoute extends LensApi { public async installChart(request: LensApiRequest) { const { payload, cluster, response } = request; + try { const result = await helmService.installChart(cluster, payload); + this.respondJson(response, result, 201); } catch (error) { logger.debug(error); @@ -43,8 +50,10 @@ class HelmApiRoute extends LensApi { public async updateRelease(request: LensApiRequest) { const { cluster, params, payload, response } = request; + try { const result = await helmService.updateRelease(cluster, params.release, params.namespace, payload ); + this.respondJson(response, result); } catch (error) { logger.debug(error); @@ -54,8 +63,10 @@ class HelmApiRoute extends LensApi { public async rollbackRelease(request: LensApiRequest) { const { cluster, params, payload, response } = request; + try { const result = await helmService.rollback(cluster, params.release, params.namespace, payload.revision); + this.respondJson(response, result); } catch (error) { logger.debug(error); @@ -65,8 +76,10 @@ class HelmApiRoute extends LensApi { public async listReleases(request: LensApiRequest) { const { cluster, params, response } = request; + try { const result = await helmService.listReleases(cluster, params.namespace); + this.respondJson(response, result); } catch(error) { logger.debug(error); @@ -76,8 +89,10 @@ class HelmApiRoute extends LensApi { public async getRelease(request: LensApiRequest) { const { cluster, params, response } = request; + try { const result = await helmService.getRelease(cluster, params.release, params.namespace); + this.respondJson(response, result); } catch (error) { logger.debug(error); @@ -87,8 +102,10 @@ class HelmApiRoute extends LensApi { public async getReleaseValues(request: LensApiRequest) { const { cluster, params, response } = request; + try { const result = await helmService.getReleaseValues(cluster, params.release, params.namespace); + this.respondText(response, result); } catch (error) { logger.debug(error); @@ -98,8 +115,10 @@ class HelmApiRoute extends LensApi { public async getReleaseHistory(request: LensApiRequest) { const { cluster, params, response } = request; + try { const result = await helmService.getReleaseHistory(cluster, params.release, params.namespace); + this.respondJson(response, result); } catch (error) { logger.debug(error); @@ -109,8 +128,10 @@ class HelmApiRoute extends LensApi { public async deleteRelease(request: LensApiRequest) { const { cluster, params, response } = request; + try { const result = await helmService.deleteRelease(cluster, params.release, params.namespace); + this.respondJson(response, result); } catch (error) { logger.debug(error); diff --git a/src/main/routes/kubeconfig-route.ts b/src/main/routes/kubeconfig-route.ts index 088bf1167f..7fe5bcb9bc 100644 --- a/src/main/routes/kubeconfig-route.ts +++ b/src/main/routes/kubeconfig-route.ts @@ -5,6 +5,7 @@ import { CoreV1Api, V1Secret } from "@kubernetes/client-node"; function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster) { const tokenData = Buffer.from(secret.data["token"], "base64"); + return { "apiVersion": "v1", "kind": "Config", @@ -43,14 +44,15 @@ class KubeconfigRoute extends LensApi { public async routeServiceAccountRoute(request: LensApiRequest) { const { params, response, cluster} = request; - const client = cluster.getProxyKubeconfig().makeApiClient(CoreV1Api); const secretList = await client.listNamespacedSecret(params.namespace); const secret = secretList.body.items.find(secret => { const { annotations } = secret.metadata; + return annotations && annotations["kubernetes.io/service-account.name"] == params.account; }); const data = generateKubeConfig(params.account, secret, cluster); + this.respondJson(response, data); } } diff --git a/src/main/routes/metrics-route.ts b/src/main/routes/metrics-route.ts index 4572030cca..d132f7b0ae 100644 --- a/src/main/routes/metrics-route.ts +++ b/src/main/routes/metrics-route.ts @@ -44,24 +44,31 @@ class MetricsRoute extends LensApi { async routeMetrics({ response, cluster, payload, query }: LensApiRequest) { const queryParams: IMetricsQuery = Object.fromEntries(query.entries()); const prometheusMetadata: ClusterPrometheusMetadata = {}; + try { const [prometheusPath, prometheusProvider] = await Promise.all([ cluster.contextHandler.getPrometheusPath(), cluster.contextHandler.getPrometheusProvider() ]); + prometheusMetadata.provider = prometheusProvider?.id; prometheusMetadata.autoDetected = !cluster.preferences.prometheusProvider?.type; + if (!prometheusPath) { prometheusMetadata.success = false; this.respondJson(response, {}); + return; } + // return data in same structure as query if (typeof payload === "string") { const [data] = await loadMetrics([payload], cluster, prometheusPath, queryParams); + this.respondJson(response, data); } else if (Array.isArray(payload)) { const data = await loadMetrics(payload, cluster, prometheusPath, queryParams); + this.respondJson(response, data); } else { const queries = Object.entries(payload).map(([queryName, queryOpts]) => ( @@ -69,6 +76,7 @@ class MetricsRoute extends LensApi { )); const result = await loadMetrics(queries, cluster, prometheusPath, queryParams); const data = Object.fromEntries(Object.keys(payload).map((metricName, i) => [metricName, result[i]])); + this.respondJson(response, data); } prometheusMetadata.success = true; diff --git a/src/main/routes/port-forward-route.ts b/src/main/routes/port-forward-route.ts index 967783aa3f..33c34758a4 100644 --- a/src/main/routes/port-forward-route.ts +++ b/src/main/routes/port-forward-route.ts @@ -52,15 +52,19 @@ class PortForward { PortForward.portForwards.push(this); this.process.on("exit", () => { const index = PortForward.portForwards.indexOf(this); + if (index > -1) { PortForward.portForwards.splice(index, 1); } }); + try { await tcpPortUsed.waitUntilUsed(this.localPort, 500, 15000); + return true; } catch (error) { this.process.kill(); + return false; } } @@ -75,11 +79,11 @@ class PortForwardRoute extends LensApi { public async routePortForward(request: LensApiRequest) { const { params, response, cluster} = request; const { namespace, port, resourceType, resourceName } = params; - let portForward = PortForward.getPortforward({ clusterId: cluster.id, kind: resourceType, name: resourceName, namespace, port }); + if (!portForward) { logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`); portForward = new PortForward({ @@ -91,10 +95,12 @@ class PortForwardRoute extends LensApi { kubeConfig: cluster.getProxyKubeconfigPath() }); const started = await portForward.start(); + if (!started) { this.respondJson(response, { message: "Failed to open port-forward" }, 400); + return; } } diff --git a/src/main/routes/resource-applier-route.ts b/src/main/routes/resource-applier-route.ts index 8bbfec0d9c..1e532dde46 100644 --- a/src/main/routes/resource-applier-route.ts +++ b/src/main/routes/resource-applier-route.ts @@ -5,8 +5,10 @@ import { ResourceApplier } from "../resource-applier"; class ResourceApplierApiRoute extends LensApi { public async applyResource(request: LensApiRequest) { const { response, cluster, payload } = request; + try { const resource = await new ResourceApplier(cluster).apply(payload); + this.respondJson(response, [resource], 200); } catch (error) { this.respondText(response, error, 422); diff --git a/src/main/routes/watch-route.ts b/src/main/routes/watch-route.ts index d44d5cae7b..eb9f007eae 100644 --- a/src/main/routes/watch-route.ts +++ b/src/main/routes/watch-route.ts @@ -25,6 +25,7 @@ class ApiWatcher { } this.processor = setInterval(() => { const events = this.eventBuffer.splice(0); + events.map(event => this.sendEvent(event)); this.response.flushHeaders(); }, 50); @@ -38,6 +39,7 @@ class ApiWatcher { clearInterval(this.processor); } logger.debug(`Stopping watcher for api: ${this.apiUrl}`); + try { this.watchRequest.abort(); this.sendEvent({ @@ -81,6 +83,7 @@ class WatchRoute extends LensApi { message: "Empty request. Query params 'api' are not provided.", example: "?api=/api/v1/pods&api=/api/v1/nodes", }, 400); + return; } @@ -91,6 +94,7 @@ class WatchRoute extends LensApi { apis.forEach(apiUrl => { const watcher = new ApiWatcher(apiUrl, cluster.getProxyKubeconfig(), response); + watcher.start(); watchers.push(watcher); }); diff --git a/src/main/shell-session.ts b/src/main/shell-session.ts index 69979a51e3..19170695fc 100644 --- a/src/main/shell-session.ts +++ b/src/main/shell-session.ts @@ -39,11 +39,13 @@ export class ShellSession extends EventEmitter { public async open() { this.kubectlBinDir = await this.kubectl.binDir(); const pathFromPreferences = userStore.preferences.kubectlBinariesPath || this.kubectl.getBundledPath(); + this.kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? this.kubectlBinDir : path.dirname(pathFromPreferences); this.helmBinDir = helmCli.getBinaryDir(); const env = await this.getCachedShellEnv(); const shell = env.PTYSHELL; const args = await this.getShellArgs(shell); + this.shellProcess = pty.spawn(shell, args, { cols: 80, cwd: this.cwd() || env.HOME, @@ -65,6 +67,7 @@ export class ShellSession extends EventEmitter { if(!this.preferences || !this.preferences.terminalCWD || this.preferences.terminalCWD === "") { return null; } + return this.preferences.terminalCWD; } @@ -85,6 +88,7 @@ export class ShellSession extends EventEmitter { protected async getCachedShellEnv() { let env = ShellSession.shellEnvs.get(this.clusterId); + if (!env) { env = await this.getShellEnv(); ShellSession.shellEnvs.set(this.clusterId, env); @@ -122,11 +126,14 @@ export class ShellSession extends EventEmitter { env["KUBECONFIG"] = this.kubeconfigPath; env["TERM_PROGRAM"] = app.getName(); env["TERM_PROGRAM_VERSION"] = app.getVersion(); + if (this.preferences.httpsProxy) { env["HTTPS_PROXY"] = this.preferences.httpsProxy; } const no_proxy = ["localhost", "127.0.0.1", env["NO_PROXY"]]; + env["NO_PROXY"] = no_proxy.filter(address => !!address).join(); + if (env.DEBUG) { // do not pass debug option to bash delete env["DEBUG"]; } @@ -147,12 +154,14 @@ export class ShellSession extends EventEmitter { if (!this.running) { return; } const message = Buffer.from(data.slice(1, data.length), "base64").toString(); + switch (data[0]) { case "0": this.shellProcess.write(message); break; case "4": const resizeMsgObj = JSON.parse(message); + this.shellProcess.resize(resizeMsgObj["Width"], resizeMsgObj["Height"]); break; case "9": @@ -171,6 +180,7 @@ export class ShellSession extends EventEmitter { this.shellProcess.onExit(({ exitCode }) => { this.running = false; let timeout = 0; + if (exitCode > 0) { this.sendResponse("Terminal will auto-close in 15 seconds ..."); timeout = 15*1000; diff --git a/src/main/shell-sync.ts b/src/main/shell-sync.ts index 80e07a8ac5..b8b5bd81ea 100644 --- a/src/main/shell-sync.ts +++ b/src/main/shell-sync.ts @@ -14,8 +14,8 @@ interface Env { */ export async function shellSync() { const { shell } = os.userInfo(); - let envVars = {}; + try { envVars = await shellEnv(shell); } catch (error) { @@ -23,6 +23,7 @@ export async function shellSync() { } const env: Env = JSON.parse(JSON.stringify(envVars)); + if (!env.LANG) { // the LANG env var expects an underscore instead of electron's dash env.LANG = `${app.getLocale().replace("-", "_")}.UTF-8`; diff --git a/src/main/tray.ts b/src/main/tray.ts index d006ae6b43..50df26cb8f 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -28,11 +28,13 @@ export function initTray(windowManager: WindowManager) { const dispose = autorun(() => { try { const menu = createTrayMenu(windowManager); + buildTray(getTrayIcon(), menu); } catch (err) { logger.error(`[TRAY]: building failed: ${err}`); } }); + return () => { dispose(); tray?.destroy(); @@ -60,6 +62,7 @@ export function createTrayMenu(windowManager: WindowManager): Menu { async click() { // note: argument[1] (browserWindow) not available when app is not focused / hidden const browserWindow = await windowManager.ensureMainWindow(); + showAbout(browserWindow); }, }, @@ -82,11 +85,13 @@ export function createTrayMenu(windowManager: WindowManager): Menu { .filter(workspace => clusterStore.getByWorkspaceId(workspace.id).length > 0) // hide empty workspaces .map(workspace => { const clusters = clusterStore.getByWorkspaceId(workspace.id); + return { label: workspace.name, toolTip: workspace.description, submenu: clusters.map(cluster => { const { id: clusterId, name: label, online, workspace } = cluster; + return { label: `${online ? "✓" : "\x20".repeat(3)/*offset*/}${label}`, toolTip: clusterId, @@ -103,8 +108,10 @@ export function createTrayMenu(windowManager: WindowManager): Menu { label: "Check for updates", async click() { const result = await AppUpdater.checkForUpdates(); + if (!result) { const browserWindow = await windowManager.ensureMainWindow(); + dialog.showMessageBoxSync(browserWindow, { message: "No updates available", type: "info", diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index 1fa2614c50..b60e7400ea 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -37,11 +37,13 @@ export class WindowManager extends Singleton { defaultWidth: 1440, }); } + if (!this.mainWindow) { // show icon in dock (mac-os only) app.dock?.show(); const { width, height, x, y } = this.windowState; + this.mainWindow = new BrowserWindow({ x, y, width, height, show: false, @@ -80,6 +82,7 @@ export class WindowManager extends Singleton { app.dock?.hide(); // hide icon in dock (mac-os) }); } + try { if (showSplash) await this.showSplash(); await this.mainWindow.loadURL(this.mainUrl); @@ -109,6 +112,7 @@ export class WindowManager extends Singleton { async ensureMainWindow(): Promise { if (!this.mainWindow) await this.initMainWindow(); this.mainWindow.show(); + return this.mainWindow; } @@ -131,6 +135,7 @@ export class WindowManager extends Singleton { reload() { const frameId = clusterFrameMap.get(this.activeClusterId); + if (frameId) { this.sendToView({ channel: "renderer:reload", frameId }); } else { diff --git a/src/migrations/cluster-store/2.0.0-beta.2.ts b/src/migrations/cluster-store/2.0.0-beta.2.ts index d7f1ef73f0..ccc8932e70 100644 --- a/src/migrations/cluster-store/2.0.0-beta.2.ts +++ b/src/migrations/cluster-store/2.0.0-beta.2.ts @@ -8,6 +8,7 @@ export default migration({ run(store) { for (const value of store) { const contextName = value[0]; + // Looping all the keys gives out the store internal stuff too... if (contextName === "__internal__" || value[1].hasOwnProperty("kubeConfig")) continue; store.set(contextName, { kubeConfig: value[1] }); diff --git a/src/migrations/cluster-store/2.4.1.ts b/src/migrations/cluster-store/2.4.1.ts index 2bde7755f1..aa6936e432 100644 --- a/src/migrations/cluster-store/2.4.1.ts +++ b/src/migrations/cluster-store/2.4.1.ts @@ -6,8 +6,10 @@ export default migration({ run(store) { for (const value of store) { const contextName = value[0]; + if (contextName === "__internal__") continue; const cluster = value[1]; + store.set(contextName, { kubeConfig: cluster.kubeConfig, icon: cluster.icon || null, preferences: cluster.preferences || {} }); } } diff --git a/src/migrations/cluster-store/2.6.0-beta.2.ts b/src/migrations/cluster-store/2.6.0-beta.2.ts index ed4b648ce1..596d16c31f 100644 --- a/src/migrations/cluster-store/2.6.0-beta.2.ts +++ b/src/migrations/cluster-store/2.6.0-beta.2.ts @@ -6,9 +6,12 @@ export default migration({ run(store) { for (const value of store) { const clusterKey = value[0]; + if (clusterKey === "__internal__") continue; const cluster = value[1]; + if (!cluster.preferences) cluster.preferences = {}; + if (cluster.icon) { cluster.preferences.icon = cluster.icon; delete (cluster["icon"]); diff --git a/src/migrations/cluster-store/2.6.0-beta.3.ts b/src/migrations/cluster-store/2.6.0-beta.3.ts index 63decfac1e..779fff7e7d 100644 --- a/src/migrations/cluster-store/2.6.0-beta.3.ts +++ b/src/migrations/cluster-store/2.6.0-beta.3.ts @@ -6,19 +6,26 @@ export default migration({ run(store, log) { for (const value of store) { const clusterKey = value[0]; + if (clusterKey === "__internal__") continue; const cluster = value[1]; + if (!cluster.kubeConfig) continue; const kubeConfig = yaml.safeLoad(cluster.kubeConfig); + if (!kubeConfig.hasOwnProperty("users")) continue; const userObj = kubeConfig.users[0]; + if (userObj) { const user = userObj.user; + if (user["auth-provider"] && user["auth-provider"].config) { const authConfig = user["auth-provider"].config; + if (authConfig["access-token"]) { authConfig["access-token"] = `${authConfig["access-token"]}`; } + if (authConfig.expiry) { authConfig.expiry = `${authConfig.expiry}`; } diff --git a/src/migrations/cluster-store/2.7.0-beta.0.ts b/src/migrations/cluster-store/2.7.0-beta.0.ts index a3c103769f..2f02a047f4 100644 --- a/src/migrations/cluster-store/2.7.0-beta.0.ts +++ b/src/migrations/cluster-store/2.7.0-beta.0.ts @@ -6,8 +6,10 @@ export default migration({ run(store) { for (const value of store) { const clusterKey = value[0]; + if (clusterKey === "__internal__") continue; const cluster = value[1]; + cluster.workspace = "default"; store.set(clusterKey, cluster); } diff --git a/src/migrations/cluster-store/2.7.0-beta.1.ts b/src/migrations/cluster-store/2.7.0-beta.1.ts index 73eb66ec2f..cb3934d422 100644 --- a/src/migrations/cluster-store/2.7.0-beta.1.ts +++ b/src/migrations/cluster-store/2.7.0-beta.1.ts @@ -6,18 +6,23 @@ export default migration({ version: "2.7.0-beta.1", run(store) { const clusters: any[] = []; + for (const value of store) { const clusterKey = value[0]; + if (clusterKey === "__internal__") continue; if (clusterKey === "clusters") continue; const cluster = value[1]; + cluster.id = uuid(); + if (!cluster.preferences.clusterName) { cluster.preferences.clusterName = clusterKey; } clusters.push(cluster); store.delete(clusterKey); } + if (clusters.length > 0) { store.set("clusters", clusters); } 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 597aab4713..ca2d0ccbed 100644 --- a/src/migrations/cluster-store/3.6.0-beta.1.ts +++ b/src/migrations/cluster-store/3.6.0-beta.1.ts @@ -32,6 +32,7 @@ export default migration({ } catch (error) { printLog(`Failed to migrate Kubeconfig for cluster "${cluster.id}", removing cluster...`, error); + return undefined; } diff --git a/src/migrations/cluster-store/snap.ts b/src/migrations/cluster-store/snap.ts index 699f88716f..74b89aad9c 100644 --- a/src/migrations/cluster-store/snap.ts +++ b/src/migrations/cluster-store/snap.ts @@ -12,6 +12,7 @@ export default migration({ printLog("Migrating embedded kubeconfig paths"); const storedClusters: ClusterModel[] = store.get("clusters") || []; + if (!storedClusters.length) return; printLog("Number of clusters to migrate: ", storedClusters.length); @@ -22,8 +23,10 @@ export default migration({ */ if (!fs.existsSync(cluster.kubeConfigPath)) { const kubeconfigPath = cluster.kubeConfigPath.replace(/\/snap\/kontena-lens\/[0-9]*\//, "/snap/kontena-lens/current/"); + cluster.kubeConfigPath = kubeconfigPath; } + return cluster; }); diff --git a/src/renderer/api/api-manager.ts b/src/renderer/api/api-manager.ts index 6c72f633f3..68d4773540 100644 --- a/src/renderer/api/api-manager.ts +++ b/src/renderer/api/api-manager.ts @@ -25,6 +25,7 @@ export class ApiManager { protected resolveApi(api: string | KubeApi): KubeApi { if (typeof api === "string") return this.getApi(api); + return api; } @@ -33,6 +34,7 @@ export class ApiManager { else { const apis = Array.from(this.apis.entries()); const entry = apis.find(entry => entry[1] === api); + if (entry) this.unregisterApi(entry[0]); } } diff --git a/src/renderer/api/endpoints/cluster.api.ts b/src/renderer/api/endpoints/cluster.api.ts index d3017c691a..e96ab7f082 100644 --- a/src/renderer/api/endpoints/cluster.api.ts +++ b/src/renderer/api/endpoints/cluster.api.ts @@ -90,6 +90,7 @@ export class Cluster extends KubeObject { if (this.metadata.deletionTimestamp) return ClusterStatus.REMOVING; if (!this.status || !this.status) return ClusterStatus.CREATING; if (this.status.errorMessage) return ClusterStatus.ERROR; + return ClusterStatus.ACTIVE; } } diff --git a/src/renderer/api/endpoints/crd.api.ts b/src/renderer/api/endpoints/crd.api.ts index 02690a2afd..ad7d9d67ca 100644 --- a/src/renderer/api/endpoints/crd.api.ts +++ b/src/renderer/api/endpoints/crd.api.ts @@ -75,6 +75,7 @@ export class CustomResourceDefinition extends KubeObject { getResourceApiBase() { const { group } = this.spec; + return `/apis/${group}/${this.getVersion()}/${this.getPluralName()}`; } @@ -88,6 +89,7 @@ export class CustomResourceDefinition extends KubeObject { getResourceTitle() { const name = this.getPluralName(); + return name[0].toUpperCase() + name.substr(1); } @@ -124,6 +126,7 @@ export class CustomResourceDefinition extends KubeObject { const columns = this.spec.versions.find(a => this.getVersion() == a.name)?.additionalPrinterColumns ?? this.spec.additionalPrinterColumns?.map(({ JSONPath, ...rest }) => ({ ...rest, jsonPath: JSONPath })) // map to V1 shape ?? []; + return columns .filter(column => column.name != "Age") .filter(column => ignorePriority ? true : !column.priority); @@ -135,8 +138,10 @@ export class CustomResourceDefinition extends KubeObject { getConditions() { if (!this.status?.conditions) return []; + return this.status.conditions.map(condition => { const { message, reason, lastTransitionTime, status } = condition; + return { ...condition, isReady: status === "True", diff --git a/src/renderer/api/endpoints/cron-job.api.ts b/src/renderer/api/endpoints/cron-job.api.ts index 2cca8bfb3d..6669f34736 100644 --- a/src/renderer/api/endpoints/cron-job.api.ts +++ b/src/renderer/api/endpoints/cron-job.api.ts @@ -71,6 +71,7 @@ export class CronJob extends KubeObject { getLastScheduleTime() { if (!this.status.lastScheduleTime) return "-"; const diff = moment().diff(this.status.lastScheduleTime); + return formatDuration(diff, true); } @@ -84,7 +85,9 @@ export class CronJob extends KubeObject { const stamps = schedule.split(" "); const day = Number(stamps[stamps.length - 3]); // 1-31 const month = Number(stamps[stamps.length - 2]); // 1-12 + if (schedule.startsWith("@")) return false; + return day > daysInMonth[month - 1]; } } diff --git a/src/renderer/api/endpoints/daemon-set.api.ts b/src/renderer/api/endpoints/daemon-set.api.ts index 63fc6363e4..8dab807517 100644 --- a/src/renderer/api/endpoints/daemon-set.api.ts +++ b/src/renderer/api/endpoints/daemon-set.api.ts @@ -66,6 +66,7 @@ export class DaemonSet extends WorkloadKubeObject { getImages() { const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); const initContainers: IPodContainer[] = get(this, "spec.template.spec.initContainers", []); + return [...containers, ...initContainers].map(container => container.image); } } diff --git a/src/renderer/api/endpoints/deployment.api.ts b/src/renderer/api/endpoints/deployment.api.ts index e3572a0bc4..d876616470 100644 --- a/src/renderer/api/endpoints/deployment.api.ts +++ b/src/renderer/api/endpoints/deployment.api.ts @@ -171,10 +171,13 @@ export class Deployment extends WorkloadKubeObject { getConditions(activeOnly = false) { const { conditions } = this.status; + if (!conditions) return []; + if (activeOnly) { return conditions.filter(c => c.status === "True"); } + return conditions; } diff --git a/src/renderer/api/endpoints/endpoint.api.ts b/src/renderer/api/endpoints/endpoint.api.ts index 121836a637..d19c2f127e 100644 --- a/src/renderer/api/endpoints/endpoint.api.ts +++ b/src/renderer/api/endpoints/endpoint.api.ts @@ -73,11 +73,13 @@ export class EndpointSubset implements IEndpointSubset { getAddresses(): EndpointAddress[] { const addresses = this.addresses || []; + return addresses.map(a => new EndpointAddress(a)); } getNotReadyAddresses(): EndpointAddress[] { const notReadyAddresses = this.notReadyAddresses || []; + return notReadyAddresses.map(a => new EndpointAddress(a)); } @@ -85,10 +87,12 @@ export class EndpointSubset implements IEndpointSubset { if(!this.addresses) { return ""; } + return this.addresses.map(address => { if (!this.ports) { return address.ip; } + return this.ports.map(port => { return `${address.ip}:${port.port}`; }).join(", "); @@ -106,6 +110,7 @@ export class Endpoint extends KubeObject { getEndpointSubsets(): EndpointSubset[] { const subsets = this.subsets || []; + return subsets.map(s => new EndpointSubset(s)); } diff --git a/src/renderer/api/endpoints/events.api.ts b/src/renderer/api/endpoints/events.api.ts index 51dbf3c3b5..df9aa540a4 100644 --- a/src/renderer/api/endpoints/events.api.ts +++ b/src/renderer/api/endpoints/events.api.ts @@ -39,16 +39,19 @@ export class KubeEvent extends KubeObject { getSource() { const { component, host } = this.source; + return `${component} ${host || ""}`; } getFirstSeenTime() { const diff = moment().diff(this.firstTimestamp); + return formatDuration(diff, true); } getLastSeenTime() { const diff = moment().diff(this.lastTimestamp); + return formatDuration(diff, true); } } diff --git a/src/renderer/api/endpoints/helm-charts.api.ts b/src/renderer/api/endpoints/helm-charts.api.ts index 2964d18db2..a1fd497798 100644 --- a/src/renderer/api/endpoints/helm-charts.api.ts +++ b/src/renderer/api/endpoints/helm-charts.api.ts @@ -33,11 +33,13 @@ export const helmChartsApi = { get(repo: string, name: string, readmeVersion?: string) { const path = endpoint({ repo, name }); + return apiBase .get(`${path}?${stringify({ version: readmeVersion })}`) .then(data => { const versions = data.versions.map(HelmChart.create); const readme = data.readme; + return { readme, versions, diff --git a/src/renderer/api/endpoints/helm-releases.api.ts b/src/renderer/api/endpoints/helm-releases.api.ts index b93d29caa9..84e095721b 100644 --- a/src/renderer/api/endpoints/helm-releases.api.ts +++ b/src/renderer/api/endpoints/helm-releases.api.ts @@ -76,9 +76,11 @@ export const helmReleasesApi = { get(name: string, namespace: string) { const path = endpoint({ name, namespace }); + return apiBase.get(path).then(details => { const items: KubeObject[] = JSON.parse(details.resources).items; const resources = items.map(item => KubeObject.create(item)); + return { ...details, resources @@ -88,35 +90,43 @@ export const helmReleasesApi = { create(payload: IReleaseCreatePayload): Promise { const { repo, ...data } = payload; + data.chart = `${repo}/${data.chart}`; data.values = jsYaml.safeLoad(data.values); + return apiBase.post(endpoint(), { data }); }, update(name: string, namespace: string, payload: IReleaseUpdatePayload): Promise { const { repo, ...data } = payload; + data.chart = `${repo}/${data.chart}`; data.values = jsYaml.safeLoad(data.values); + return apiBase.put(endpoint({ name, namespace }), { data }); }, async delete(name: string, namespace: string) { const path = endpoint({ name, namespace }); + return apiBase.del(path); }, getValues(name: string, namespace: string) { const path = `${endpoint({ name, namespace })}/values`; + return apiBase.get(path); }, getHistory(name: string, namespace: string): Promise { const path = `${endpoint({ name, namespace })}/history`; + return apiBase.get(path); }, rollback(name: string, namespace: string, revision: number) { const path = `${endpoint({ name, namespace })}/rollback`; + return apiBase.put(path, { data: { revision @@ -157,10 +167,13 @@ export class HelmRelease implements ItemObject { getChart(withVersion = false) { let chart = this.chart; + if(!withVersion && this.getVersion() != "" ) { const search = new RegExp(`-${this.getVersion()}`); + chart = chart.replace(search, ""); } + return chart; } @@ -174,6 +187,7 @@ export class HelmRelease implements ItemObject { getVersion() { const versions = this.chart.match(/(v?\d+)[^-].*$/); + if (versions) { return versions[0]; } @@ -187,9 +201,11 @@ export class HelmRelease implements ItemObject { const updated = this.updated.replace(/\s\w*$/, ""); // 2019-11-26 10:58:09 +0300 MSK -> 2019-11-26 10:58:09 +0300 to pass into Date() const updatedDate = new Date(updated).getTime(); const diff = now - updatedDate; + if (humanize) { return formatDuration(diff, compact); } + return diff; } @@ -200,6 +216,7 @@ export class HelmRelease implements ItemObject { const version = this.getVersion(); const versions = await helmChartStore.getVersions(chartName); const chartVersion = versions.find(chartVersion => chartVersion.version === version); + return chartVersion ? chartVersion.repo : ""; } } diff --git a/src/renderer/api/endpoints/hpa.api.ts b/src/renderer/api/endpoints/hpa.api.ts index 657e34e38c..4876ee43eb 100644 --- a/src/renderer/api/endpoints/hpa.api.ts +++ b/src/renderer/api/endpoints/hpa.api.ts @@ -80,8 +80,10 @@ export class HorizontalPodAutoscaler extends KubeObject { getConditions() { if (!this.status.conditions) return []; + return this.status.conditions.map(condition => { const { message, reason, lastTransitionTime, status } = condition; + return { ...condition, isReady: status === "True", @@ -100,6 +102,7 @@ export class HorizontalPodAutoscaler extends KubeObject { protected getMetricName(metric: IHpaMetric): string { const { type, resource, pods, object, external } = metric; + switch (type) { case HpaMetricType.Resource: return resource.name; @@ -122,14 +125,17 @@ export class HorizontalPodAutoscaler extends KubeObject { const target = metric[metricType]; let currentValue = "unknown"; let targetValue = "unknown"; + if (current) { currentValue = current.currentAverageUtilization || current.currentAverageValue || current.currentValue; if (current.currentAverageUtilization) currentValue += "%"; } + if (target) { targetValue = target.targetAverageUtilization || target.targetAverageValue || target.targetValue; if (target.targetAverageUtilization) targetValue += "%"; } + return `${currentValue} / ${targetValue}`; } } diff --git a/src/renderer/api/endpoints/ingress.api.ts b/src/renderer/api/endpoints/ingress.api.ts index 5832dc55db..7d035ad591 100644 --- a/src/renderer/api/endpoints/ingress.api.ts +++ b/src/renderer/api/endpoints/ingress.api.ts @@ -6,6 +6,7 @@ import { KubeApi } from "../kube-api"; export class IngressApi extends KubeApi { getMetrics(ingress: string, namespace: string): Promise { const opts = { category: "ingress", ingress }; + return metricsApi.getMetrics({ bytesSentSuccess: opts, bytesSentFailure: opts, @@ -98,15 +99,18 @@ export class Ingress extends KubeObject { getRoutes() { const { spec: { tls, rules } } = this; + if (!rules) return []; let protocol = "http"; const routes: string[] = []; + if (tls && tls.length > 0) { protocol += "s"; } rules.map(rule => { const host = rule.host ? rule.host : "*"; + if (rule.http && rule.http.paths) { rule.http.paths.forEach(path => { const { serviceName, servicePort } = getBackendServiceNamePort(path.backend); @@ -132,7 +136,9 @@ export class Ingress extends KubeObject { getHosts() { const { spec: { rules } } = this; + if (!rules) return []; + return rules.filter(rule => rule.host).map(rule => rule.host); } @@ -141,7 +147,6 @@ export class Ingress extends KubeObject { 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; @@ -152,6 +157,7 @@ export class Ingress extends KubeObject { } else if (servicePort !== undefined) { ports.push(Number(servicePort)); } + if (tls && tls.length > 0) { ports.push(tlsPort); } diff --git a/src/renderer/api/endpoints/job.api.ts b/src/renderer/api/endpoints/job.api.ts index 1dc78fdc94..65b9bcfdc3 100644 --- a/src/renderer/api/endpoints/job.api.ts +++ b/src/renderer/api/endpoints/job.api.ts @@ -86,12 +86,15 @@ export class Job extends WorkloadKubeObject { // Type of Job condition could be only Complete or Failed // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.14/#jobcondition-v1-batch const { conditions } = this.status; + if (!conditions) return; + return conditions.find(({ status }) => status === "True"); } getImages() { const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); + return [...containers].map(container => container.image); } @@ -99,6 +102,7 @@ export class Job extends WorkloadKubeObject { const params: JsonApiParams = { query: { propagationPolicy: "Background" } }; + return super.delete(params); } } diff --git a/src/renderer/api/endpoints/metrics.api.ts b/src/renderer/api/endpoints/metrics.api.ts index 86c829bcd6..a530c68506 100644 --- a/src/renderer/api/endpoints/metrics.api.ts +++ b/src/renderer/api/endpoints/metrics.api.ts @@ -41,6 +41,7 @@ export const metricsApi = { if (!start && !end) { const timeNow = Date.now() / 1000; const now = moment.unix(timeNow).startOf("minute").unix(); // round date to minutes + start = now - range; end = now; } @@ -76,8 +77,10 @@ export function normalizeMetrics(metrics: IMetrics, frames = 60): IMetrics { // fill the gaps result.forEach(res => { if (!res.values || !res.values.length) return; + while (res.values.length < frames) { const timestamp = moment.unix(res.values[0][0]).subtract(1, "minute").unix(); + res.values.unshift([timestamp, "0"]); } }); @@ -101,14 +104,17 @@ export function isMetricsEmpty(metrics: { [key: string]: IMetrics }) { export function getItemMetrics(metrics: { [key: string]: IMetrics }, itemName: string): { [key: string]: IMetrics } { if (!metrics) return; const itemMetrics = { ...metrics }; + for (const metric in metrics) { if (!metrics[metric]?.data?.result) { continue; } const results = metrics[metric].data.result; const result = results.find(res => Object.values(res.metric)[0] == itemName); + itemMetrics[metric].data.result = result ? [result] : []; } + return itemMetrics; } @@ -118,11 +124,13 @@ export function getMetricLastPoints(metrics: { [key: string]: IMetrics }) { Object.keys(metrics).forEach(metricName => { try { const metric = metrics[metricName]; + if (metric.data.result.length) { result[metricName] = +metric.data.result[0].values.slice(-1)[0][1]; } } catch (e) { } + return result; }, {}); diff --git a/src/renderer/api/endpoints/network-policy.api.ts b/src/renderer/api/endpoints/network-policy.api.ts index 4ecd333854..eb531990c2 100644 --- a/src/renderer/api/endpoints/network-policy.api.ts +++ b/src/renderer/api/endpoints/network-policy.api.ts @@ -55,6 +55,7 @@ export class NetworkPolicy extends KubeObject { getMatchLabels(): string[] { if (!this.spec.podSelector || !this.spec.podSelector.matchLabels) return []; + return Object .entries(this.spec.podSelector.matchLabels) .map(data => data.join(":")); @@ -62,6 +63,7 @@ export class NetworkPolicy extends KubeObject { getTypes(): string[] { if (!this.spec.policyTypes) return []; + return this.spec.policyTypes; } } diff --git a/src/renderer/api/endpoints/nodes.api.ts b/src/renderer/api/endpoints/nodes.api.ts index c85cd8f9b0..d1794f0fb7 100644 --- a/src/renderer/api/endpoints/nodes.api.ts +++ b/src/renderer/api/endpoints/nodes.api.ts @@ -87,9 +87,12 @@ export class Node extends KubeObject { getNodeConditionText() { const { conditions } = this.status; + if (!conditions) return ""; + return conditions.reduce((types, current) => { if (current.status !== "True") return ""; + return types += ` ${current.type}`; }, ""); } @@ -112,19 +115,23 @@ export class Node extends KubeObject { getCpuCapacity() { 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; + return unitsToBytes(this.status.capacity.memory); } getConditions() { const conditions = this.status.conditions || []; + if (this.isUnschedulable()) { return [{ type: "SchedulingDisabled", status: "True" }, ...conditions]; } + return conditions; } @@ -134,6 +141,7 @@ export class Node extends KubeObject { getWarningConditions() { const goodConditions = ["Ready", "HostUpgrades", "SchedulingDisabled"]; + return this.getActiveConditions().filter(condition => { return !goodConditions.includes(condition.type); }); @@ -145,6 +153,7 @@ export class Node extends KubeObject { getOperatingSystem(): string { const label = this.getLabels().find(label => label.startsWith("kubernetes.io/os=")); + if (label) { return label.split("=", 2)[1]; } diff --git a/src/renderer/api/endpoints/persistent-volume-claims.api.ts b/src/renderer/api/endpoints/persistent-volume-claims.api.ts index 529aefa951..1d9e1f1dce 100644 --- a/src/renderer/api/endpoints/persistent-volume-claims.api.ts +++ b/src/renderer/api/endpoints/persistent-volume-claims.api.ts @@ -52,6 +52,7 @@ export class PersistentVolumeClaim extends KubeObject { getPods(allPods: Pod[]): Pod[] { const pods = allPods.filter(pod => pod.getNs() === this.getNs()); + return pods.filter(pod => { return pod.getVolumes().filter(volume => volume.persistentVolumeClaim && @@ -62,22 +63,26 @@ export class PersistentVolumeClaim extends KubeObject { getStorage(): string { if (!this.spec.resources || !this.spec.resources.requests) return "-"; + return this.spec.resources.requests.storage; } getMatchLabels(): string[] { if (!this.spec.selector || !this.spec.selector.matchLabels) return []; + 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; } getStatus(): string { if (this.status) return this.status.phase; + return "-"; } } diff --git a/src/renderer/api/endpoints/persistent-volume.api.ts b/src/renderer/api/endpoints/persistent-volume.api.ts index 5e31eeb028..dd5dbb616e 100644 --- a/src/renderer/api/endpoints/persistent-volume.api.ts +++ b/src/renderer/api/endpoints/persistent-volume.api.ts @@ -47,20 +47,25 @@ export class PersistentVolume extends KubeObject { getCapacity(inBytes = false) { const capacity = this.spec.capacity; + if (capacity) { if (inBytes) return unitsToBytes(capacity.storage); + return capacity.storage; } + return 0; } getStatus() { if (!this.status) return; + return this.status.phase || "-"; } getClaimRefName() { const { claimRef } = this.spec; + return claimRef ? claimRef.name : ""; } } diff --git a/src/renderer/api/endpoints/poddisruptionbudget.api.ts b/src/renderer/api/endpoints/poddisruptionbudget.api.ts index b76260ae6f..50ab2d5b3d 100644 --- a/src/renderer/api/endpoints/poddisruptionbudget.api.ts +++ b/src/renderer/api/endpoints/poddisruptionbudget.api.ts @@ -22,6 +22,7 @@ export class PodDisruptionBudget extends KubeObject { getSelectors() { const selector = this.spec.selector; + return KubeObject.stringifyLabels(selector ? selector.matchLabels : null); } diff --git a/src/renderer/api/endpoints/pods.api.ts b/src/renderer/api/endpoints/pods.api.ts index 3f62a3a952..11b581db8f 100644 --- a/src/renderer/api/endpoints/pods.api.ts +++ b/src/renderer/api/endpoints/pods.api.ts @@ -6,6 +6,7 @@ import { KubeApi } from "../kube-api"; export class PodsApi extends KubeApi { async getLogs(params: { namespace: string; name: string }, query?: IPodLogsQuery): Promise { const path = `${this.getUrl(params)}/log`; + return this.request.get(path, { query }); } @@ -247,6 +248,7 @@ export class Pod extends WorkloadKubeObject { getRunningContainers() { const statuses = this.getContainerStatuses(); + return this.getAllContainers().filter(container => { return statuses.find(status => status.name === container.name && !!status.state["running"]); } @@ -256,18 +258,23 @@ export class Pod extends WorkloadKubeObject { getContainerStatuses(includeInitContainers = true) { const statuses: IPodContainerStatus[] = []; const { containerStatuses, initContainerStatuses } = this.status; + if (containerStatuses) { statuses.push(...containerStatuses); } + if (includeInitContainers && initContainerStatuses) { statuses.push(...initContainerStatuses); } + return statuses; } getRestartsCount(): number { const { containerStatuses } = this.status; + if (!containerStatuses) return 0; + return containerStatuses.reduce((count, item) => count + item.restartCount, 0); } @@ -290,18 +297,23 @@ export class Pod extends WorkloadKubeObject { const goodConditions = ["Initialized", "Ready"].every(condition => !!this.getConditions().find(item => item.type === condition && item.status === "True") ); + if (reason === PodStatus.EVICTED) { return PodStatus.EVICTED; } + if (phase === PodStatus.FAILED) { return PodStatus.FAILED; } + if (phase === PodStatus.SUCCEEDED) { return PodStatus.SUCCEEDED; } + if (phase === PodStatus.RUNNING && goodConditions) { return PodStatus.RUNNING; } + return PodStatus.PENDING; } @@ -312,20 +324,26 @@ export class Pod extends WorkloadKubeObject { let message = ""; const statuses = this.getContainerStatuses(false); // not including initContainers + if (statuses.length) { statuses.forEach(status => { const { state } = status; + if (state.waiting) { const { reason } = state.waiting; + message = reason ? reason : "Waiting"; } + if (state.terminated) { const { reason } = state.terminated; + message = reason ? reason : "Terminated"; } }); } if (message) return message; + return this.getStatusPhase(); } @@ -349,7 +367,9 @@ export class Pod extends WorkloadKubeObject { getNodeSelectors(): string[] { const { nodeSelector } = this.spec; + if (!nodeSelector) return []; + return Object.entries(nodeSelector).map(values => values.join(": ")); } @@ -367,8 +387,10 @@ export class Pod extends WorkloadKubeObject { }); const crashLoop = !!this.getContainerStatuses().find(condition => { const waiting = condition.state.waiting; + return (waiting && waiting.reason == "CrashLoopBackOff"); }); + return ( notReady || crashLoop || @@ -391,18 +413,22 @@ export class Pod extends WorkloadKubeObject { periodSeconds, successThreshold, failureThreshold } = probeData; const probe = []; + // HTTP Request if (httpGet) { const { path, port, host, scheme } = httpGet; + probe.push( "http-get", `${scheme.toLowerCase()}://${host || ""}:${port || ""}${path || ""}`, ); } + // Command if (exec && exec.command) { probe.push(`exec [${exec.command.join(" ")}]`); } + // TCP Probe if (tcpSocket && tcpSocket.port) { probe.push(`tcp-socket :${tcpSocket.port}`); @@ -414,6 +440,7 @@ export class Pod extends WorkloadKubeObject { `#success=${successThreshold || "0"}`, `#failure=${failureThreshold || "0"}`, ); + return probe; } diff --git a/src/renderer/api/endpoints/podsecuritypolicy.api.ts b/src/renderer/api/endpoints/podsecuritypolicy.api.ts index c7981f65be..dc5113625d 100644 --- a/src/renderer/api/endpoints/podsecuritypolicy.api.ts +++ b/src/renderer/api/endpoints/podsecuritypolicy.api.ts @@ -78,6 +78,7 @@ export class PodSecurityPolicy extends KubeObject { getRules() { const { fsGroup, runAsGroup, runAsUser, supplementalGroups, seLinux } = this.spec; + return { fsGroup: fsGroup ? fsGroup.rule : "", runAsGroup: runAsGroup ? runAsGroup.rule : "", diff --git a/src/renderer/api/endpoints/replica-set.api.ts b/src/renderer/api/endpoints/replica-set.api.ts index d1081811e9..999de8c1ac 100644 --- a/src/renderer/api/endpoints/replica-set.api.ts +++ b/src/renderer/api/endpoints/replica-set.api.ts @@ -48,6 +48,7 @@ export class ReplicaSet extends WorkloadKubeObject { getImages() { const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); + return [...containers].map(container => container.image); } } diff --git a/src/renderer/api/endpoints/resource-applier.api.ts b/src/renderer/api/endpoints/resource-applier.api.ts index a2843a6262..a397cfdda0 100644 --- a/src/renderer/api/endpoints/resource-applier.api.ts +++ b/src/renderer/api/endpoints/resource-applier.api.ts @@ -13,17 +13,20 @@ export const resourceApplierApi = { if (typeof resource === "string") { resource = jsYaml.safeLoad(resource); } + return apiBase .post("/stack", { data: resource }) .then(data => { const items = data.map(obj => { const api = apiManager.getApi(obj.metadata.selfLink); + if (api) { return new api.objectConstructor(obj); } else { return new KubeObject(obj); } }); + return items.length === 1 ? items[0] : items; }); } diff --git a/src/renderer/api/endpoints/resource-quota.api.ts b/src/renderer/api/endpoints/resource-quota.api.ts index a19e4025c5..e2d37a9081 100644 --- a/src/renderer/api/endpoints/resource-quota.api.ts +++ b/src/renderer/api/endpoints/resource-quota.api.ts @@ -58,6 +58,7 @@ export class ResourceQuota extends KubeObject { getScopeSelector() { const { matchExpressions = [] } = this.spec.scopeSelector || {}; + return matchExpressions; } } diff --git a/src/renderer/api/endpoints/selfsubjectrulesreviews.api.ts b/src/renderer/api/endpoints/selfsubjectrulesreviews.api.ts index 149a94e678..f47fc29a4e 100644 --- a/src/renderer/api/endpoints/selfsubjectrulesreviews.api.ts +++ b/src/renderer/api/endpoints/selfsubjectrulesreviews.api.ts @@ -38,16 +38,19 @@ export class SelfSubjectRulesReview extends KubeObject { getResourceRules() { const rules = this.status && this.status.resourceRules || []; + return rules.map(rule => this.normalize(rule)); } getNonResourceRules() { const rules = this.status && this.status.nonResourceRules || []; + return rules.map(rule => this.normalize(rule)); } protected normalize(rule: ISelfSubjectReviewRule): ISelfSubjectReviewRule { const { apiGroups = [], resourceNames = [], verbs = [], nonResourceURLs = [], resources = [] } = rule; + return { apiGroups, nonResourceURLs, @@ -56,6 +59,7 @@ export class SelfSubjectRulesReview extends KubeObject { resources: resources.map((resource, index) => { const apiGroup = apiGroups.length >= index + 1 ? apiGroups[index] : apiGroups.slice(-1)[0]; const separator = apiGroup == "" ? "" : "."; + return resource + separator + apiGroup; }) }; diff --git a/src/renderer/api/endpoints/service.api.ts b/src/renderer/api/endpoints/service.api.ts index 219e02ab5d..6c02873139 100644 --- a/src/renderer/api/endpoints/service.api.ts +++ b/src/renderer/api/endpoints/service.api.ts @@ -61,9 +61,11 @@ export class Service extends KubeObject { getExternalIps() { const lb = this.getLoadBalancer(); + if (lb && lb.ingress) { return lb.ingress.map(val => val.ip || val.hostname); } + return this.spec.externalIPs || []; } @@ -73,11 +75,13 @@ export class Service extends KubeObject { getSelector(): string[] { if (!this.spec.selector) return []; + return Object.entries(this.spec.selector).map(val => val.join("=")); } getPorts(): ServicePort[] { const ports = this.spec.ports || []; + return ports.map(p => new ServicePort(p)); } diff --git a/src/renderer/api/endpoints/stateful-set.api.ts b/src/renderer/api/endpoints/stateful-set.api.ts index 72c2ea5776..add2a554ba 100644 --- a/src/renderer/api/endpoints/stateful-set.api.ts +++ b/src/renderer/api/endpoints/stateful-set.api.ts @@ -102,6 +102,7 @@ export class StatefulSet extends WorkloadKubeObject { getImages() { const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); + return [...containers].map(container => container.image); } } diff --git a/src/renderer/api/endpoints/storage-class.api.ts b/src/renderer/api/endpoints/storage-class.api.ts index adb2059e4a..085701742c 100644 --- a/src/renderer/api/endpoints/storage-class.api.ts +++ b/src/renderer/api/endpoints/storage-class.api.ts @@ -18,6 +18,7 @@ export class StorageClass extends KubeObject { isDefault() { const annotations = this.metadata.annotations || {}; + return ( annotations["storageclass.kubernetes.io/is-default-class"] === "true" || annotations["storageclass.beta.kubernetes.io/is-default-class"] === "true" diff --git a/src/renderer/api/json-api.ts b/src/renderer/api/json-api.ts index 177aa3c9c2..49c2cb1a28 100644 --- a/src/renderer/api/json-api.ts +++ b/src/renderer/api/json-api.ts @@ -75,11 +75,14 @@ export class JsonApi { let reqUrl = this.config.apiBase + path; const reqInit: RequestInit = { ...this.reqInit, ...init }; const { data, query } = params || {} as P; + if (data && !reqInit.body) { reqInit.body = JSON.stringify(data); } + if (query) { const queryString = stringify(query); + reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString; } const infoLog: JsonApiLog = { @@ -87,6 +90,7 @@ export class JsonApi { reqUrl, reqInit, }; + return cancelableFetch(reqUrl, reqInit).then(res => { return this.parseResponse(res, infoLog); }); @@ -94,21 +98,26 @@ export class JsonApi { protected parseResponse(res: Response, log: JsonApiLog): Promise { const { status } = res; + return res.text().then(text => { let data; + try { data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body } catch (e) { data = text; } + if (status >= 200 && status < 300) { this.onData.emit(data, res); this.writeLog({ ...log, data }); + return data; } else if (log.method === "GET" && res.status === 403) { this.writeLog({ ...log, data }); } else { const error = new JsonApiErrorParsed(data, this.parseError(data, res)); + this.onError.emit(error, res); this.writeLog({ ...log, error }); throw error; @@ -126,6 +135,7 @@ export class JsonApi { else if (error.message) { return [error.message]; } + return [res.statusText || "Error!"]; } @@ -133,6 +143,7 @@ export class JsonApi { if (!this.config.debug) return; const { method, reqUrl, ...params } = log; let textStyle = "font-weight: bold;"; + if (params.data) textStyle += "background: green; color: white;"; if (params.error) textStyle += "background: red; color: white;"; console.log(`%c${method} ${reqUrl}`, textStyle, params); diff --git a/src/renderer/api/kube-api-parse.ts b/src/renderer/api/kube-api-parse.ts index 174fdae918..285610bece 100644 --- a/src/renderer/api/kube-api-parse.ts +++ b/src/renderer/api/kube-api-parse.ts @@ -29,7 +29,6 @@ export function parseKubeApi(path: string): IKubeApiParsed { path = new URL(path, location.origin).pathname; const [, prefix, ...parts] = path.split("/"); const apiPrefix = `/${prefix}`; - const [left, right, namespaced] = splitArray(parts, "namespaces"); let apiGroup, apiVersion, namespace, resource, name; @@ -107,9 +106,11 @@ export function parseKubeApi(path: string): IKubeApiParsed { export function createKubeApiURL(ref: IKubeApiLinkRef): string { const { apiPrefix = "/apis", resource, apiVersion, name } = ref; let { namespace } = ref; + if (namespace) { namespace = `namespaces/${namespace}`; } + return [apiPrefix, apiVersion, namespace, resource, name] .filter(v => v) .join("/"); @@ -125,6 +126,7 @@ export function lookupApiLink(ref: IKubeObjectRef, parentObject: KubeObject): st // search in registered apis by 'kind' & 'apiVersion' const api = apiManager.getApi(api => api.kind === kind && api.apiVersionWithGroup == apiVersion); + if (api) { return api.getUrl({ namespace, name }); } @@ -132,8 +134,10 @@ export function lookupApiLink(ref: IKubeObjectRef, parentObject: KubeObject): st // lookup api by generated resource link const apiPrefixes = ["/apis", "/api"]; const resource = kind.endsWith("s") ? `${kind.toLowerCase()}es` : `${kind.toLowerCase()}s`; + for (const apiPrefix of apiPrefixes) { const apiLink = createKubeApiURL({ apiPrefix, apiVersion, name, namespace, resource }); + if (apiManager.getApi(apiLink)) { return apiLink; } @@ -141,6 +145,7 @@ export function lookupApiLink(ref: IKubeObjectRef, parentObject: KubeObject): st // resolve by kind only (hpa's might use refs to older versions of resources for example) const apiByKind = apiManager.getApi(api => api.kind === kind); + if (apiByKind) { return apiByKind.getUrl({ name, namespace }); } diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 3ba1f37f8a..e7934675c6 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -72,6 +72,7 @@ export function forCluster(cluster: IKubeApiCluster, kubeC "X-Cluster-ID": cluster.id } }); + return new KubeApi({ objectConstructor: kubeClass, request @@ -83,6 +84,7 @@ export class KubeApi { static watchAll(...apis: KubeApi[]) { const disposers = apis.map(api => api.watch()); + return () => disposers.forEach(unwatch => unwatch()); } @@ -106,6 +108,7 @@ export class KubeApi { kind = options.objectConstructor?.kind, isNamespaced = options.objectConstructor?.namespaced } = options || {}; + if (!options.apiBase) { options.apiBase = objectConstructor.apiBase; } @@ -205,6 +208,7 @@ export class KubeApi { }); const res = await this.request.get(`${this.apiPrefix}/${this.apiGroup}`); + Object.defineProperty(this, "apiVersionPreferred", { value: res?.preferredVersion?.version ?? null, }); @@ -236,6 +240,7 @@ export class KubeApi { namespace: this.isNamespaced ? namespace : undefined, name, }); + return resourcePath + (query ? `?${stringify(this.normalizeQuery(query))}` : ""); } @@ -243,14 +248,17 @@ export class KubeApi { if (query.labelSelector) { query.labelSelector = [query.labelSelector].flat().join(","); } + if (query.fieldSelector) { query.fieldSelector = [query.fieldSelector].flat().join(","); } + return query; } protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any { const KubeObjectConstructor = this.objectConstructor; + if (KubeObject.isJsonApiData(data)) { return new KubeObjectConstructor(data); } @@ -258,8 +266,10 @@ export class KubeApi { // process items list response if (KubeObject.isJsonApiDataList(data)) { const { apiVersion, items, metadata } = data; + this.setResourceVersion(namespace, metadata.resourceVersion); this.setResourceVersion("", metadata.resourceVersion); + return items.map(item => new KubeObjectConstructor({ kind: this.kind, apiVersion, @@ -277,6 +287,7 @@ export class KubeApi { async list({ namespace = "" } = {}, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); + return this.request .get(this.getUrl({ namespace }), { query }) .then(data => this.parseResponse(data, namespace)); @@ -284,6 +295,7 @@ export class KubeApi { async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); + return this.request .get(this.getUrl({ namespace, name }), { query }) .then(this.parseResponse); @@ -310,6 +322,7 @@ export class KubeApi { async update({ name = "", namespace = "default" } = {}, data?: Partial): Promise { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace, name }); + return this.request .put(apiUrl, { data }) .then(this.parseResponse); @@ -318,6 +331,7 @@ export class KubeApi { async delete({ name = "", namespace = "default" }) { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace, name }); + return this.request.del(apiUrl); } diff --git a/src/renderer/api/kube-json-api.ts b/src/renderer/api/kube-json-api.ts index ce7da50826..3026a9a956 100644 --- a/src/renderer/api/kube-json-api.ts +++ b/src/renderer/api/kube-json-api.ts @@ -45,9 +45,11 @@ export interface KubeJsonApiError extends JsonApiError { export class KubeJsonApi extends JsonApi { protected parseError(error: KubeJsonApiError | any, res: Response): string[] { const { status, reason, message } = error; + if (status && reason) { return [message || `${status}: ${reason}`]; } + return super.parseError(error, res); } } diff --git a/src/renderer/api/kube-object.ts b/src/renderer/api/kube-object.ts index 8d0e6123f3..08bd6401b9 100644 --- a/src/renderer/api/kube-object.ts +++ b/src/renderer/api/kube-object.ts @@ -65,6 +65,7 @@ export class KubeObject implements ItemObject { static stringifyLabels(labels: { [name: string]: string }): string[] { if (!labels) return []; + return Object.entries(labels).map(([name, value]) => `${name}=${value}`); } @@ -104,9 +105,11 @@ export class KubeObject implements ItemObject { return moment(this.metadata.creationTimestamp).fromNow(); } const diff = new Date().getTime() - new Date(this.metadata.creationTimestamp).getTime(); + if (humanize) { return formatDuration(diff, compact); } + return diff; } @@ -120,14 +123,17 @@ export class KubeObject implements ItemObject { getAnnotations(filter = false): string[] { const labels = KubeObject.stringifyLabels(this.metadata.annotations); + return filter ? labels.filter(label => { const skip = resourceApplierApi.annotations.some(key => label.startsWith(key)); + return !skip; }) : labels; } getOwnerRefs() { const refs = this.metadata.ownerReferences || []; + return refs.map(ownerRef => ({ ...ownerRef, namespace: this.getNs(), @@ -136,6 +142,7 @@ export class KubeObject implements ItemObject { getSearchFields() { const { getName, getId, getNs, getAnnotations, getLabels } = this; + return [ getName(), getNs(), diff --git a/src/renderer/api/kube-watch-api.ts b/src/renderer/api/kube-watch-api.ts index 8d5a318c3c..58665a11a1 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -53,8 +53,10 @@ export class KubeWatchApi { apis.forEach(api => { this.subscribers.set(api, this.getSubscribersCount(api) + 1); }); + return () => apis.forEach(api => { const count = this.getSubscribersCount(api) - 1; + if (count <= 0) this.subscribers.delete(api); else this.subscribers.set(api, count); }); @@ -62,9 +64,11 @@ export class KubeWatchApi { protected getQuery(): Partial { const { isAdmin, allowedNamespaces } = getHostedCluster(); + return { api: this.activeApis.map(api => { if (isAdmin) return api.getWatchUrl(); + return allowedNamespaces.map(namespace => api.getWatchUrl(namespace)); }).flat() }; @@ -74,11 +78,13 @@ export class KubeWatchApi { @autobind() protected connect() { if (this.evtSource) this.disconnect(); // close previous connection + if (!this.activeApis.length) { return; } const query = this.getQuery(); const apiUrl = `${apiPrefix}/watch?${stringify(query)}`; + this.evtSource = new EventSource(apiUrl); this.evtSource.onmessage = this.onMessage; this.evtSource.onerror = this.onError; @@ -102,6 +108,7 @@ export class KubeWatchApi { protected onMessage(evt: MessageEvent) { if (!evt.data) return; const data = JSON.parse(evt.data); + if ((data as IKubeWatchEvent).object) { this.onData.emit(data); } else { @@ -114,12 +121,14 @@ export class KubeWatchApi { this.disconnect(); const { apiBase, namespace } = KubeApi.parseApi(event.url); const api = apiManager.getApi(apiBase); + if (api) { try { await api.refreshResourceVersion({ namespace }); this.reconnect(); } catch (error) { console.error("failed to refresh resource version", error); + if (this.subscribers.size > 0) { setTimeout(() => { this.onRouteEvent(event); @@ -132,6 +141,7 @@ export class KubeWatchApi { protected onError(evt: MessageEvent) { const { reconnectAttempts: attemptsRemain, reconnectTimeoutMs } = this; + if (evt.eventPhase === EventSource.CLOSED) { if (attemptsRemain > 0) { this.reconnectAttempts--; @@ -150,13 +160,17 @@ export class KubeWatchApi { const listener = (evt: IKubeWatchEvent) => { const { selfLink, namespace, resourceVersion } = evt.object.metadata; const api = apiManager.getApi(selfLink); + api.setResourceVersion(namespace, resourceVersion); api.setResourceVersion("", resourceVersion); + if (store == apiManager.getStore(api)) { callback(evt); } }; + this.onData.addListener(listener); + return () => this.onData.removeListener(listener); } diff --git a/src/renderer/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index 39a86faae5..1a94052586 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -50,26 +50,32 @@ export class TerminalApi extends WebSocketApi { const { id, node } = this.options; const wss = `ws${protocol === "https:" ? "s" : ""}://`; const query: TerminalApiQuery = { id }; + if (port) { port = `:${port}`; } + if (node) { query.node = node; query.type = "node"; } + return `${wss}${hostname}${port}/api?${stringify(query)}`; } async connect() { const apiUrl = await this.getUrl(); + this.emitStatus("Connecting ..."); this.onData.addListener(this._onReady, { prepend: true }); + return super.connect(apiUrl); } destroy() { if (!this.socket) return; const exitCode = String.fromCharCode(4); // ctrl+d + this.sendCommand(exitCode); setTimeout(() => super.destroy(), 2000); } @@ -87,6 +93,7 @@ export class TerminalApi extends WebSocketApi { this.onData.removeListener(this._onReady); this.flush(); this.onData.emit(data); // re-emit data + return false; // prevent calling rest of listeners } @@ -100,6 +107,7 @@ export class TerminalApi extends WebSocketApi { sendTerminalSize(cols: number, rows: number) { const newSize = { Width: cols, Height: rows }; + if (!isEqual(this.size, newSize)) { this.sendCommand(JSON.stringify(newSize), TerminalChannels.TERMINAL_SIZE); this.size = newSize; @@ -108,6 +116,7 @@ export class TerminalApi extends WebSocketApi { protected parseMessage(data: string) { data = data.substr(1); // skip channel + return base64.decode(data); } @@ -125,10 +134,12 @@ export class TerminalApi extends WebSocketApi { protected emitStatus(data: string, options: { color?: TerminalColor; showTime?: boolean } = {}) { const { color, showTime } = options; + if (color) { data = `${color}${data}${TerminalColor.NO_COLOR}`; } let time; + if (showTime) { time = `${(new Date()).toLocaleString()} `; } diff --git a/src/renderer/api/websocket-api.ts b/src/renderer/api/websocket-api.ts index b9e0c22c96..79a92cf99e 100644 --- a/src/renderer/api/websocket-api.ts +++ b/src/renderer/api/websocket-api.ts @@ -47,9 +47,11 @@ export class WebSocketApi { constructor(protected params: IParams) { this.params = Object.assign({}, WebSocketApi.defaultParams, params); const { autoConnect, pingIntervalSeconds } = this.params; + if (autoConnect) { setTimeout(() => this.connect()); } + if (pingIntervalSeconds) { this.pingTimer = setInterval(() => this.ping(), pingIntervalSeconds * 1000); } @@ -57,6 +59,7 @@ export class WebSocketApi { get isConnected() { const state = this.socket ? this.socket.readyState : -1; + return state === WebSocket.OPEN && this.isOnline; } @@ -87,6 +90,7 @@ export class WebSocketApi { reconnect() { const { reconnectDelaySeconds } = this.params; + if (!reconnectDelaySeconds) return; this.writeLog("reconnect after", `${reconnectDelaySeconds}ms`); this.reconnectTimer = setTimeout(() => this.connect(), reconnectDelaySeconds * 1000); @@ -115,6 +119,7 @@ export class WebSocketApi { id: (Math.random() * Date.now()).toString(16).replace(".", ""), data: command, }; + if (this.isConnected) { this.socket.send(msg.data); } @@ -141,6 +146,7 @@ export class WebSocketApi { protected _onMessage(evt: MessageEvent) { const data = this.parseMessage(evt.data); + this.onData.emit(data); this.writeLog("%cMESSAGE", "color:black;font-weight:bold;", data); } @@ -151,6 +157,7 @@ export class WebSocketApi { protected _onClose(evt: CloseEvent) { const error = evt.code !== 1000 || !evt.wasClean; + if (error) { this.reconnect(); } diff --git a/src/renderer/api/workload-kube-object.ts b/src/renderer/api/workload-kube-object.ts index 51c0461f15..e0c6d3f121 100644 --- a/src/renderer/api/workload-kube-object.ts +++ b/src/renderer/api/workload-kube-object.ts @@ -51,16 +51,19 @@ 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); } @@ -74,7 +77,9 @@ export class WorkloadKubeObject extends KubeObject { getAffinityNumber() { const affinity = this.getAffinity(); + if (!affinity) return 0; + return Object.keys(affinity).length; } } \ No newline at end of file diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index ef22e72736..f2369df0fd 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -30,6 +30,7 @@ export { export async function bootstrap(App: AppComponent) { const rootElem = document.getElementById("app"); + rootElem.classList.toggle("is-mac", isMac); extensionLoader.init(); diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index e18a655f97..5122dfe02f 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -68,6 +68,7 @@ export class AddCluster extends React.Component { Notifications.error(

Can't setup {filePath} as kubeconfig: {String(err)}
); + if (throwError) { throw err; } @@ -82,12 +83,14 @@ export class AddCluster extends React.Component { switch (this.sourceTab) { case KubeConfigSourceTab.FILE: const contexts = this.getContexts(this.kubeConfigLocal); + this.kubeContexts.replace(contexts); break; case KubeConfigSourceTab.TEXT: try { this.error = ""; const contexts = this.getContexts(loadConfig(this.customConfig || "{}")); + this.kubeContexts.replace(contexts); } catch (err) { this.error = String(err); @@ -102,9 +105,11 @@ export class AddCluster extends React.Component { getContexts(config: KubeConfig): Map { const contexts = new Map(); + splitConfig(config).forEach(config => { contexts.set(config.currentContext, config); }); + return contexts; } @@ -116,6 +121,7 @@ export class AddCluster extends React.Component { message: _i18n._(t`Select custom kubeconfig file`), buttonLabel: _i18n._(t`Use configuration`), }); + if (!canceled && filePaths.length) { this.setKubeConfig(filePaths[0]); } @@ -129,9 +135,11 @@ export class AddCluster extends React.Component { @action addClusters = () => { let newClusters: ClusterModel[] = []; + try { if (!this.selectedContexts.length) { this.error = Please select at least one cluster context; + return; } this.error = ""; @@ -140,12 +148,16 @@ export class AddCluster extends React.Component { newClusters = this.selectedContexts.filter(context => { try { const kubeConfig = this.kubeContexts.get(context); + validateKubeConfig(kubeConfig); + return true; } catch (err) { this.error = String(err.message); + if (err instanceof ExecValidationNotFoundError) { Notifications.error(Error while adding cluster(s): {this.error}); + return false; } else { throw new Error(err); @@ -157,6 +169,7 @@ export class AddCluster extends React.Component { const kubeConfigPath = this.sourceTab === KubeConfigSourceTab.FILE ? this.kubeConfigPath // save link to original kubeconfig in file-system : ClusterStore.embedCustomKubeConfig(clusterId, kubeConfig); // save in app-files folder + return { id: clusterId, kubeConfigPath, @@ -171,8 +184,10 @@ export class AddCluster extends React.Component { runInAction(() => { clusterStore.addClusters(...newClusters); + if (newClusters.length === 1) { const clusterId = newClusters[0].id; + clusterStore.setActive(clusterId); navigate(clusterViewURL({ params: { clusterId } })); } else { @@ -271,6 +286,7 @@ export class AddCluster extends React.Component { const placeholder = this.selectedContexts.length > 0 ? Selected contexts: {this.selectedContexts.length} : Select contexts; + return (
{ const { ...dialogProps } = this.props; const { namespace, name, type } = this; const header =
Create Secret
; + return ( { disposeOnUnmount(this, [ autorun(() => { const { object: secret } = this.props; + if (secret) { this.data = secret.data; this.revealSecret = {}; @@ -41,7 +42,9 @@ export class SecretDetails extends React.Component { saveSecret = async () => { const { object: secret } = this.props; + this.isSaving = true; + try { await secretsStore.update(secret, { ...secret, data: this.data }); Notifications.ok(Secret successfully updated.); @@ -57,7 +60,9 @@ export class SecretDetails extends React.Component { render() { const { object: secret } = this.props; + if (!secret) return null; + return (
@@ -71,12 +76,14 @@ export class SecretDetails extends React.Component { Object.entries(this.data).map(([name, value]) => { const revealSecret = this.revealSecret[name]; let decodedVal = ""; + try { decodedVal = base64.decode(value); } catch { decodedVal = ""; } value = revealSecret ? decodedVal : value; + return (
{name}
diff --git a/src/renderer/components/+config/config.tsx b/src/renderer/components/+config/config.tsx index 70fa04d9b1..0d26baf812 100644 --- a/src/renderer/components/+config/config.tsx +++ b/src/renderer/components/+config/config.tsx @@ -15,6 +15,7 @@ export class Config extends React.Component { static get tabRoutes(): TabLayoutRoute[] { const query = namespaceStore.getContextParams(); const routes: TabLayoutRoute[] = []; + if (isAllowedResource("configmaps")) { routes.push({ title: ConfigMaps, @@ -23,6 +24,7 @@ export class Config extends React.Component { routePath: configMapsRoute.path.toString(), }); } + if (isAllowedResource("secrets")) { routes.push({ title: Secrets, @@ -31,6 +33,7 @@ export class Config extends React.Component { routePath: secretsRoute.path.toString(), }); } + if (isAllowedResource("resourcequotas")) { routes.push({ title: Resource Quotas, @@ -39,6 +42,7 @@ export class Config extends React.Component { routePath: resourceQuotaRoute.path.toString(), }); } + if (isAllowedResource("horizontalpodautoscalers")) { routes.push({ title: HPA, @@ -47,6 +51,7 @@ export class Config extends React.Component { routePath: hpaRoute.path.toString(), }); } + if (isAllowedResource("poddisruptionbudgets")) { routes.push({ title: Pod Disruption Budgets, @@ -55,6 +60,7 @@ export class Config extends React.Component { routePath: pdbRoute.path.toString(), }); } + return routes; } diff --git a/src/renderer/components/+custom-resources/crd-details.tsx b/src/renderer/components/+custom-resources/crd-details.tsx index 2ab52b2bad..f542a165fd 100644 --- a/src/renderer/components/+custom-resources/crd-details.tsx +++ b/src/renderer/components/+custom-resources/crd-details.tsx @@ -22,10 +22,12 @@ interface Props extends KubeObjectDetailsProps { export class CRDDetails extends React.Component { render() { const { object: crd } = this.props; + if (!crd) return null; const { plural, singular, kind, listKind } = crd.getNames(); const printerColumns = crd.getPrinterColumns(); const validation = crd.getValidation(); + return (
@@ -60,6 +62,7 @@ export class CRDDetails extends React.Component { { crd.getConditions().map(condition => { const { type, message, lastTransitionTime, status } = condition; + return ( { { printerColumns.map((column, index) => { const { name, type, jsonPath } = column; + return ( {name} diff --git a/src/renderer/components/+custom-resources/crd-list.tsx b/src/renderer/components/+custom-resources/crd-list.tsx index 1d91a1738b..83a05250a0 100644 --- a/src/renderer/components/+custom-resources/crd-list.tsx +++ b/src/renderer/components/+custom-resources/crd-list.tsx @@ -30,6 +30,7 @@ export class CrdList extends React.Component { onGroupChange(group: string) { const groups = [...this.groups]; const index = groups.findIndex(item => item == group); + if (index !== -1) groups.splice(index, 1); else groups.push(group); setQueryParams({ groups }); @@ -43,6 +44,7 @@ export class CrdList extends React.Component { [sortBy.version]: (crd: CustomResourceDefinition) => crd.getVersion(), [sortBy.scope]: (crd: CustomResourceDefinition) => crd.getScope(), }; + return ( Custom Resources} customizeHeader={() => { 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: ( @@ -71,6 +75,7 @@ export class CrdList extends React.Component { controlShouldRenderValue={false} formatOptionLabel={({ value: group }: SelectOption) => { const isSelected = selectedGroups.includes(group); + return (
diff --git a/src/renderer/components/+custom-resources/crd-resource-details.tsx b/src/renderer/components/+custom-resources/crd-resource-details.tsx index d04962c0dc..6610fb4b56 100644 --- a/src/renderer/components/+custom-resources/crd-resource-details.tsx +++ b/src/renderer/components/+custom-resources/crd-resource-details.tsx @@ -53,6 +53,7 @@ export class CrdResourceDetails extends React.Component { renderStatus(crd: CustomResourceDefinition, columns: AdditionalPrinterColumnsV1[]) { const showStatus = !columns.find(column => column.name == "Status") && crd.status?.conditions; + if (!showStatus) { return null; } @@ -77,6 +78,7 @@ export class CrdResourceDetails extends React.Component { render() { const { props: { object }, crd } = this; + if (!object || !crd) { return null; } diff --git a/src/renderer/components/+custom-resources/crd-resources.tsx b/src/renderer/components/+custom-resources/crd-resources.tsx index 6afe40ae3c..a4a52ef867 100644 --- a/src/renderer/components/+custom-resources/crd-resources.tsx +++ b/src/renderer/components/+custom-resources/crd-resources.tsx @@ -28,6 +28,7 @@ export class CrdResources extends React.Component { disposeOnUnmount(this, [ autorun(() => { const { store } = this; + if (store && !store.isLoading && !store.isLoaded) { store.loadAll(); } @@ -37,16 +38,19 @@ export class CrdResources extends React.Component { @computed get crd() { const { group, name } = this.props.match.params; + return crdStore.getByGroup(group, name); } @computed get store() { if (!this.crd) return null; + return apiManager.getStore(this.crd.getResourceApiBase()); } render() { const { crd, store } = this; + if (!crd) return null; const isNamespaced = crd.isNamespaced(); const extraColumns = crd.getPrinterColumns(false); // Cols with priority bigger than 0 are shown in details @@ -55,6 +59,7 @@ export class CrdResources extends React.Component { [sortBy.namespace]: (item: KubeObject) => item.getNs(), [sortBy.age]: (item: KubeObject) => item.metadata.creationTimestamp, }; + extraColumns.forEach(column => { sortingCallbacks[column.name] = (item: KubeObject) => jsonPath.value(item, column.jsonPath.slice(1)); }); @@ -74,6 +79,7 @@ export class CrdResources extends React.Component { isNamespaced && { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, ...extraColumns.map(column => { const { name } = column; + return { title: name, className: name.toLowerCase(), diff --git a/src/renderer/components/+custom-resources/crd.store.ts b/src/renderer/components/+custom-resources/crd.store.ts index 54e1d3df66..64aefc1fe1 100644 --- a/src/renderer/components/+custom-resources/crd.store.ts +++ b/src/renderer/components/+custom-resources/crd.store.ts @@ -38,17 +38,22 @@ export class CRDStore extends KubeObjectStore { @computed get groups() { const groups: Record = {}; + return this.items.reduce((groups, crd) => { const group = crd.getGroup(); + if (!groups[group]) groups[group] = []; groups[group].push(crd); + return groups; }, groups); } getByGroup(group: string, pluralName: string) { const crdInGroup = this.groups[group]; + if (!crdInGroup) return null; + return crdInGroup.find(crd => crd.getPluralName() === pluralName); } diff --git a/src/renderer/components/+events/event-details.tsx b/src/renderer/components/+events/event-details.tsx index 3d6f51ab0b..d514d67521 100644 --- a/src/renderer/components/+events/event-details.tsx +++ b/src/renderer/components/+events/event-details.tsx @@ -21,9 +21,11 @@ interface Props extends KubeObjectDetailsProps { export class EventDetails extends React.Component { render() { const { object: event } = this.props; + if (!event) return; const { message, reason, count, type, involvedObject } = event; const { kind, name, namespace, fieldPath } = involvedObject; + return (
diff --git a/src/renderer/components/+events/event.store.ts b/src/renderer/components/+events/event.store.ts index 484c9701c1..39d4d5df83 100644 --- a/src/renderer/components/+events/event.store.ts +++ b/src/renderer/components/+events/event.store.ts @@ -28,6 +28,7 @@ export class EventStore extends KubeObjectStore { if(obj.kind == "Node") { return obj.getName() == evt.involvedObject.uid && evt.involvedObject.kind == "Node"; } + return obj.getId() == evt.involvedObject.uid; }); } @@ -38,12 +39,16 @@ export class EventStore extends KubeObjectStore { const eventsWithError = Object.values(groupsByInvolvedObject).map(events => { const recent = events[0]; const { kind, uid } = recent.involvedObject; + 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; } + return recent; }); + return compact(eventsWithError); } } diff --git a/src/renderer/components/+events/events.tsx b/src/renderer/components/+events/events.tsx index f50f2adcb9..3d6977c656 100644 --- a/src/renderer/components/+events/events.tsx +++ b/src/renderer/components/+events/events.tsx @@ -88,6 +88,7 @@ export class Events extends React.Component { const tooltipId = `message-${event.getId()}`; const isWarning = type === "Warning"; const detailsUrl = getDetailsUrl(lookupApiLink(involvedObject, event)); + return [ { className: { warning: isWarning }, @@ -114,9 +115,11 @@ export class Events extends React.Component { ]} /> ); + if (compact) { return events; } + return ( {events} diff --git a/src/renderer/components/+events/kube-event-details.tsx b/src/renderer/components/+events/kube-event-details.tsx index 297c4e791f..a7da4f5276 100644 --- a/src/renderer/components/+events/kube-event-details.tsx +++ b/src/renderer/components/+events/kube-event-details.tsx @@ -21,6 +21,7 @@ export class KubeEventDetails extends React.Component { render() { const { object } = this.props; const events = eventStore.getEventsByObject(object); + if (!events.length) { return ( @@ -28,6 +29,7 @@ export class KubeEventDetails extends React.Component { ); } + return (
@@ -36,6 +38,7 @@ export class KubeEventDetails extends React.Component {
{events.map(evt => { const { message, count, lastTimestamp, involvedObject } = evt; + return (
diff --git a/src/renderer/components/+events/kube-event-icon.tsx b/src/renderer/components/+events/kube-event-icon.tsx index 15a91ea5b3..d23b331ead 100644 --- a/src/renderer/components/+events/kube-event-icon.tsx +++ b/src/renderer/components/+events/kube-event-icon.tsx @@ -24,11 +24,14 @@ export class KubeEventIcon extends React.Component { const { object, showWarningsOnly, filterEvents } = this.props; const events = eventStore.getEventsByObject(object); let warnings = events.filter(evt => evt.isWarning()); + if (filterEvents) warnings = filterEvents(warnings); + if (!events.length || (showWarningsOnly && !warnings.length)) { return null; } const event = [...warnings, ...events][0]; // get latest event + return ( { const { name, description } = ext.manifest; + return [ name.toLowerCase().includes(searchText), description?.toLowerCase().includes(searchText), @@ -149,6 +150,7 @@ export class Extensions extends React.Component { installFromUrlOrPath = async () => { const { installPath } = this; + if (!installPath) return; const fileName = path.basename(installPath); @@ -158,6 +160,7 @@ export class Extensions extends React.Component { if (InputValidators.isUrl.validate(installPath)) { const { promise: filePromise } = downloadFile({ url: installPath, timeout: 60000 /*1m*/ }); const data = await filePromise; + this.requestInstall({ fileName, data }); } // otherwise installing from system path @@ -173,6 +176,7 @@ export class Extensions extends React.Component { installOnDrop = (files: File[]) => { logger.info("Install from D&D"); + return this.requestInstall( files.map(file => ({ fileName: path.basename(file.path), @@ -190,8 +194,10 @@ export class Extensions extends React.Component { .map(async request => { try { const data = await fse.readFile(request.filePath); + request.data = data; preloadedRequests.push(request); + return request; } catch(error) { if (showError) { @@ -209,6 +215,7 @@ export class Extensions extends React.Component { // tarball from npm contains single root folder "package/*" const firstFile = tarFiles[0]; + if (!firstFile) { throw new Error(`invalid extension bundle, ${manifestFilename} not found`); } @@ -230,6 +237,7 @@ export class Extensions extends React.Component { if (!manifest.lens && !manifest.renderer) { throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); } + return manifest; } @@ -241,6 +249,7 @@ export class Extensions extends React.Component { requests.forEach(req => { const tempFile = this.getExtensionPackageTemp(req.fileName); + fse.writeFileSync(tempFile, req.data); }); @@ -248,6 +257,7 @@ export class Extensions extends React.Component { await Promise.all( requests.map(async req => { const tempFile = this.getExtensionPackageTemp(req.fileName); + try { const manifest = await this.validatePackage(tempFile); @@ -270,6 +280,7 @@ export class Extensions extends React.Component { } }) ); + return validatedRequests; } @@ -342,6 +353,7 @@ export class Extensions extends React.Component { Notifications.error(

Installing extension {displayName} has failed: {error}

); + // Remove install state on install failure if (this.extensionState.get(extensionId)?.state === "installing") { this.extensionState.delete(extensionId); @@ -378,6 +390,7 @@ export class Extensions extends React.Component { Notifications.error(

Uninstalling extension {displayName} has failed: {error?.message ?? ""}

); + // Remove uninstall state on uninstall failure if (this.extensionState.get(extension.id)?.state === "uninstalling") { this.extensionState.delete(extension.id); diff --git a/src/renderer/components/+landing-page/landing-page.tsx b/src/renderer/components/+landing-page/landing-page.tsx index 5c1e34297c..a1606e7499 100644 --- a/src/renderer/components/+landing-page/landing-page.tsx +++ b/src/renderer/components/+landing-page/landing-page.tsx @@ -14,6 +14,7 @@ export class LandingPage extends React.Component { const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId); const noClustersInScope = !clusters.length; const showStartupHint = this.showHint && noClustersInScope; + return (
{showStartupHint && ( diff --git a/src/renderer/components/+namespaces/add-namespace-dialog.tsx b/src/renderer/components/+namespaces/add-namespace-dialog.tsx index a31a44de94..6097a3089f 100644 --- a/src/renderer/components/+namespaces/add-namespace-dialog.tsx +++ b/src/renderer/components/+namespaces/add-namespace-dialog.tsx @@ -38,6 +38,7 @@ export class AddNamespaceDialog extends React.Component { addNamespace = async () => { const { namespace } = this; const { onSuccess, onError } = this.props; + try { await namespaceStore.create({ name: namespace }).then(onSuccess); this.close(); @@ -51,6 +52,7 @@ export class AddNamespaceDialog extends React.Component { const { ...dialogProps } = this.props; const { namespace } = this; const header =
Create Namespace
; + return ( { export class NamespaceDetails extends React.Component { @computed get quotas() { const namespace = this.props.object.getName(); + return resourceQuotaStore.getAllByNs(namespace); } @@ -31,8 +32,10 @@ export class NamespaceDetails extends React.Component { render() { const { object: namespace } = this.props; + if (!namespace) return; const status = namespace.getStatus(); + return (
diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index e1c64f8342..9bf3c3921c 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -46,16 +46,20 @@ export class NamespaceSelect extends React.Component { @computed get options(): SelectOption[] { const { customizeOptions, showClusterOption, clusterOptionLabel } = this.props; let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() })); + options = customizeOptions ? customizeOptions(options) : options; + if (showClusterOption) { options.unshift({ value: null, label: clusterOptionLabel }); } + return options; } formatOptionLabel = (option: SelectOption) => { const { showIcons } = this.props; const { value, label } = option; + return label || ( <> {showIcons && } @@ -66,6 +70,7 @@ export class NamespaceSelect extends React.Component { render() { const { className, showIcons, showClusterOption, clusterOptionLabel, customizeOptions, ...selectProps } = this.props; + return ( { } const { showNotification, resetSelection, getNotificationMessage, cssSelectorLimit } = this.props; const contentElem = this.rootElem.querySelector(cssSelectorLimit) || this.rootElem; + if (contentElem) { const { copiedText, copied } = copyToClipboard(contentElem, { resetSelection }); + if (copied && showNotification) { Notifications.ok(getNotificationMessage(copiedText)); } @@ -51,12 +53,14 @@ export class Clipboard extends React.Component { render() { try { const rootElem = this.rootReactElem; + return React.cloneElement(rootElem, { className: cssNames(Clipboard.displayName, rootElem.props.className), onClick: this.onClick, }); } catch (err) { logger.error(`Invalid usage components/CopyToClick usage. Children must contain root html element.`, { err: String(err) }); + return this.rootReactElem; } } diff --git a/src/renderer/components/cluster-icon/cluster-icon.tsx b/src/renderer/components/cluster-icon/cluster-icon.tsx index 42de2693e8..c1b0ef3577 100644 --- a/src/renderer/components/cluster-icon/cluster-icon.tsx +++ b/src/renderer/components/cluster-icon/cluster-icon.tsx @@ -60,6 +60,7 @@ export class ClusterIcon extends React.Component { interactive: interactive !== undefined ? interactive : !!this.props.onClick, active: isActive, }); + return (
{showTooltip && ( diff --git a/src/renderer/components/cluster-manager/bottom-bar.tsx b/src/renderer/components/cluster-manager/bottom-bar.tsx index cc79de800e..c70c25aeb9 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.tsx +++ b/src/renderer/components/cluster-manager/bottom-bar.tsx @@ -11,6 +11,7 @@ import { statusBarRegistry } from "../../../extensions/registries"; export class BottomBar extends React.Component { render() { const { currentWorkspace } = workspaceStore; + return (
@@ -23,6 +24,7 @@ export class BottomBar extends React.Component {
{statusBarRegistry.getItems().map(({ item }, index) => { if (!item) return; + return
{item}
; })}
diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index b27e088f84..80df472a64 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -46,6 +46,7 @@ export class ClusterManager extends React.Component { get startUrl() { const { activeClusterId } = clusterStore; + if (activeClusterId) { return clusterViewURL({ params: { @@ -53,6 +54,7 @@ export class ClusterManager extends React.Component { } }); } + return landingURL(); } diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index bd8be870c2..3e17eb3e4c 100644 --- a/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/src/renderer/components/cluster-manager/cluster-status.tsx @@ -39,6 +39,7 @@ export class ClusterStatus extends React.Component { error: res.error, }); }); + if (this.cluster.disconnected) { await this.activateCluster(); } @@ -62,6 +63,7 @@ export class ClusterStatus extends React.Component { renderContent() { const { authOutput, cluster, hasErrors } = this; const failureReason = cluster.failureReason; + if (!hasErrors || this.isReconnecting) { return ( <> @@ -75,6 +77,7 @@ export class ClusterStatus extends React.Component { ); } + return ( <> diff --git a/src/renderer/components/cluster-manager/cluster-view.tsx b/src/renderer/components/cluster-manager/cluster-view.tsx index bbf1ba6533..ddedf145e7 100644 --- a/src/renderer/components/cluster-manager/cluster-view.tsx +++ b/src/renderer/components/cluster-manager/cluster-view.tsx @@ -33,6 +33,7 @@ export class ClusterView extends React.Component { render() { const { cluster } = this; const showStatus = cluster && (!cluster.available || !hasLoadedView(cluster.id) || !cluster.ready); + return (
{showStatus && ( diff --git a/src/renderer/components/cluster-manager/clusters-menu.tsx b/src/renderer/components/cluster-manager/clusters-menu.tsx index c3c710f8d9..f0e537996a 100644 --- a/src/renderer/components/cluster-manager/clusters-menu.tsx +++ b/src/renderer/components/cluster-manager/clusters-menu.tsx @@ -53,6 +53,7 @@ export class ClustersMenu extends React.Component { })); } })); + if (cluster.online) { menu.append(new MenuItem({ label: _i18n._(t`Disconnect`), @@ -65,6 +66,7 @@ export class ClustersMenu extends React.Component { } })); } + if (!cluster.isManaged) { menu.append(new MenuItem({ label: _i18n._(t`Remove`), @@ -100,6 +102,7 @@ export class ClustersMenu extends React.Component { source: { index: from }, destination: { index: to }, } = result; + clusterStore.swapIconOrders(currentWorkspaceId, from, to); } } @@ -110,6 +113,7 @@ export class ClustersMenu extends React.Component { const workspace = workspaceStore.getById(workspaceStore.currentWorkspaceId); const clusters = clusterStore.getByWorkspaceId(workspace.id).filter(cluster => cluster.enabled); const activeClusterId = clusterStore.activeCluster; + return (
@@ -119,6 +123,7 @@ export class ClustersMenu extends React.Component {
{clusters.map((cluster, index) => { const isActive = cluster.id === activeClusterId; + return ( {({ draggableProps, dragHandleProps, innerRef }: DraggableProvided) => ( @@ -154,10 +159,12 @@ export class ClustersMenu extends React.Component {
{globalPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => { const registeredPage = globalPageRegistry.getByPageMenuTarget(target); + if (!registeredPage) return; const { extensionId, id: pageId } = registeredPage; const pageUrl = getExtensionPageUrl({ extensionId, pageId, params: target.params }); const isActive = pageUrl === navigation.location.pathname; + return ( { @@ -52,10 +54,12 @@ export async function autoCleanOnRemove(clusterId: ClusterId, iframe: HTMLIFrame export function refreshViews() { const cluster = clusterStore.getById(getMatchedClusterId()); + lensViews.forEach(({ clusterId, view, isLoaded }) => { const isCurrent = clusterId === cluster?.id; const isReady = cluster?.available && cluster?.ready; const isVisible = isCurrent && isLoaded && isReady; + view.style.display = isVisible ? "flex" : "none"; }); } diff --git a/src/renderer/components/confirm-dialog/confirm-dialog.tsx b/src/renderer/components/confirm-dialog/confirm-dialog.tsx index 491ba99012..f5cfbd42c3 100644 --- a/src/renderer/components/confirm-dialog/confirm-dialog.tsx +++ b/src/renderer/components/confirm-dialog/confirm-dialog.tsx @@ -74,6 +74,7 @@ export class ConfirmDialog extends React.Component { okButtonProps = {}, cancelButtonProps = {}, } = this.params; + return ( { componentDidUpdate(prevProps: DialogProps) { const { isOpen } = this.props; + if (isOpen !== prevProps.isOpen) { this.toggle(isOpen); } @@ -91,6 +92,7 @@ export class Dialog extends React.PureComponent { onOpen = () => { this.props.onOpen(); + if (!this.props.pinned) { if (this.elem) this.elem.addEventListener("click", this.onClickOutside); // Using document.body target to handle keydown event before Drawer does @@ -100,6 +102,7 @@ export class Dialog extends React.PureComponent { onClose = () => { this.props.onClose(); + if (!this.props.pinned) { if (this.elem) this.elem.removeEventListener("click", this.onClickOutside); document.body.removeEventListener("keydown", this.onEscapeKey); @@ -108,6 +111,7 @@ export class Dialog extends React.PureComponent { onEscapeKey = (evt: KeyboardEvent) => { const escapeKey = evt.code === "Escape"; + if (escapeKey) { this.close(); evt.stopPropagation(); @@ -116,6 +120,7 @@ export class Dialog extends React.PureComponent { onClickOutside = (evt: MouseEvent) => { const target = evt.target as HTMLElement; + if (!this.contentElem.contains(target)) { this.close(); evt.stopPropagation(); @@ -125,6 +130,7 @@ export class Dialog extends React.PureComponent { render() { const { modal, animated, pinned } = this.props; let { className } = this.props; + className = cssNames("Dialog flex center", className, { modal, pinned }); let dialog = (
@@ -133,6 +139,7 @@ export class Dialog extends React.PureComponent {
); + if (animated) { dialog = ( @@ -143,6 +150,7 @@ export class Dialog extends React.PureComponent { else if (!this.isOpen) { return null; } + return createPortal(dialog, document.body); } } diff --git a/src/renderer/components/dialog/logs-dialog.tsx b/src/renderer/components/dialog/logs-dialog.tsx index cfb7aecbe5..263fb6b814 100644 --- a/src/renderer/components/dialog/logs-dialog.tsx +++ b/src/renderer/components/dialog/logs-dialog.tsx @@ -39,6 +39,7 @@ export class LogsDialog extends React.Component {
); + return ( diff --git a/src/renderer/components/dock/create-resource.tsx b/src/renderer/components/dock/create-resource.tsx index 8fa20cad3e..b26a7385b2 100644 --- a/src/renderer/components/dock/create-resource.tsx +++ b/src/renderer/components/dock/create-resource.tsx @@ -44,6 +44,7 @@ export class CreateResource extends React.Component { .filter(v => !!v); // skip empty documents if "---" pasted at the beginning or end const createdResources: string[] = []; const errors: string[] = []; + await Promise.all( resources.map(data => { return resourceApplierApi.update(data) @@ -51,6 +52,7 @@ export class CreateResource extends React.Component { .catch((err: JsonApiErrorParsed) => errors.push(err.toString())); }) ); + if (errors.length) { errors.forEach(Notifications.error); if (!createdResources.length) throw errors[0]; @@ -61,13 +63,16 @@ export class CreateResource extends React.Component { {createdResources.join(", ")} successfully created

); + Notifications.ok(successMessage); + return successMessage; }; render() { const { tabId, data, error, create, onChange } = this; const { className } = this.props; + return (
{ // auto-save to local-storage if (storageName) { const storage = createStorage<[TabId, T][]>(storageName, []); + this.data.replace(storage.get()); reaction(() => this.serializeData(), (data: T | any) => storage.set(data)); } @@ -24,6 +25,7 @@ export class DockTabStore { // clear data for closed tabs autorun(() => { const currentTabs = dockStore.tabs.map(tab => tab.id); + Array.from(this.data.keys()).forEach(tabId => { if (!currentTabs.includes(tabId)) { this.clearData(tabId); @@ -34,8 +36,10 @@ export class DockTabStore { protected serializeData() { const { storageSerializer } = this.options; + return Array.from(this.data).map(([tabId, tabData]) => { if (storageSerializer) return [tabId, storageSerializer(tabData)]; + return [tabId, tabData]; }); } diff --git a/src/renderer/components/dock/dock-tab.tsx b/src/renderer/components/dock/dock-tab.tsx index ba73e42d92..ceaee18303 100644 --- a/src/renderer/components/dock/dock-tab.tsx +++ b/src/renderer/components/dock/dock-tab.tsx @@ -40,6 +40,7 @@ export class DockTab extends React.Component { )}
); + return ( this.maxHeight) { this.setHeight(this.maxHeight); } @@ -94,6 +96,7 @@ export class DockStore { @action open(fullSize?: boolean) { this.isOpen = true; + if (typeof fullSize === "boolean") { this.fullSize = fullSize; } @@ -125,8 +128,10 @@ export class DockStore { .filter(tab => tab.kind === kind) .map(tab => { const tabNumber = +tab.title.match(/\d+/); + return tabNumber === 0 ? 1 : tabNumber; // tab without a number is first }); + for (let i = 1; ; i++) { if (!tabNumbers.includes(i)) return i; } @@ -136,29 +141,36 @@ export class DockStore { createTab(anonTab: IDockTab, addNumber = true): IDockTab { const tabId = MD5(Math.random().toString() + Date.now()).toString(); const tab: IDockTab = { id: tabId, ...anonTab }; + if (addNumber) { const tabNumber = this.getNewTabNumber(tab.kind); + if (tabNumber > 1) tab.title += ` (${tabNumber})`; } this.tabs.push(tab); this.selectTab(tab.id); this.open(); + return tab; } @action async closeTab(tabId: TabId) { const tab = this.getTabById(tabId); + if (!tab || tab.pinned) { return; } this.tabs.remove(tab); + if (this.selectedTabId === tab.id) { if (this.tabs.length) { const newTab = this.tabs.slice(-1)[0]; // last + if (newTab.kind === TabKind.TERMINAL) { // close the dock when selected sibling inactive terminal tab const { terminalStore } = await import("./terminal.store"); + if (!terminalStore.isConnected(newTab.id)) this.close(); } this.selectTab(newTab.id); diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index dfdd792750..6f513e7586 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -33,11 +33,14 @@ interface Props { export class Dock extends React.Component { onKeydown = (evt: React.KeyboardEvent) => { const { close, closeTab, selectedTab } = dockStore; + if (!selectedTab) return; const { code, ctrlKey, shiftKey } = evt.nativeEvent; + if (shiftKey && code === "Escape") { close(); } + if (ctrlKey && code === "KeyW") { if (selectedTab.pinned) close(); else closeTab(selectedTab.id); @@ -46,6 +49,7 @@ export class Dock extends React.Component { onChangeTab = (tab: IDockTab) => { const { open, selectTab } = dockStore; + open(); selectTab(tab.id); }; @@ -55,12 +59,15 @@ export class Dock extends React.Component { if (isTerminalTab(tab)) { return ; } + if (isCreateResourceTab(tab) || isEditResourceTab(tab)) { return ; } + if (isInstallChartTab(tab) || isUpgradeChartTab(tab)) { return } />; } + if (isPodLogsTab(tab)) { return ; } @@ -68,7 +75,9 @@ export class Dock extends React.Component { renderTabContent() { const { isOpen, height, selectedTab: tab } = dockStore; + if (!isOpen || !tab) return; + return (
{isCreateResourceTab(tab) && } @@ -84,6 +93,7 @@ export class Dock extends React.Component { render() { const { className } = this.props; const { isOpen, toggle, tabs, toggleFillSize, selectedTab, hasTabs, fullSize } = dockStore; + return (
{ } this.watchers.set(tabId, autorun(() => { const store = apiManager.getStore(resource); + if (store) { const isActiveTab = dockStore.isOpen && dockStore.selectedTabId === tabId; const obj = store.getByPath(resource); + // preload resource for editing if (!obj && !store.isLoaded && !store.isLoading && isActiveTab) { store.loadFromPath(resource).catch(noop); @@ -50,6 +52,7 @@ export class EditResourceStore extends DockTabStore { const [tabId] = Array.from(this.data).find(([, { resource }]) => { return object.selfLink === resource; }) || []; + return dockStore.getTabById(tabId); } @@ -67,10 +70,12 @@ export const editResourceStore = new EditResourceStore(); export function editResourceTab(object: KubeObject, tabParams: Partial = {}) { // use existing tab if already opened let tab = editResourceStore.getTabByResource(object); + if (tab) { dockStore.open(); dockStore.selectTab(tab.id); } + // or create new tab if (!tab) { tab = dockStore.createTab({ @@ -82,6 +87,7 @@ export function editResourceTab(object: KubeObject, tabParams: Partial resource: object.selfLink, }); } + return tab; } diff --git a/src/renderer/components/dock/edit-resource.tsx b/src/renderer/components/dock/edit-resource.tsx index 478192b35a..b9daf4e697 100644 --- a/src/renderer/components/dock/edit-resource.tsx +++ b/src/renderer/components/dock/edit-resource.tsx @@ -28,6 +28,7 @@ export class EditResource extends React.Component { @disposeOnUnmount autoDumpResourceOnInit = autorun(() => { if (!this.tabData) return; + if (this.tabData.draft === undefined && this.resource) { this.saveDraft(this.resource); } @@ -44,6 +45,7 @@ export class EditResource extends React.Component { get resource(): KubeObject { const { resource } = this.tabData; const store = apiManager.getStore(resource); + if (store) { return store.getByPath(resource); } @@ -71,9 +73,11 @@ export class EditResource extends React.Component { const { resource, draft } = this.tabData; const store = apiManager.getStore(resource); const updatedResource = await store.update(this.resource, jsYaml.safeLoad(draft)); + this.saveDraft(updatedResource); // update with new resourceVersion to avoid further errors on save const resourceType = updatedResource.kind; const resourceName = updatedResource.getName(); + return (

{resourceType} {resourceName} updated. @@ -84,10 +88,12 @@ export class EditResource extends React.Component { render() { const { tabId, resource, tabData, error, onChange, save } = this; const { draft } = tabData; + if (!resource || draft === undefined) { return ; } const { kind, getNs, getName } = resource; + return (

{ onChange = (value: string) => { this.validate(value); + if (this.props.onChange) { this.props.onChange(value, this.yamlError); } @@ -65,8 +66,10 @@ export class EditorPanel extends React.Component { render() { const { value, tabId } = this.props; let { className } = this.props; + className = cssNames("EditorPanel", className); const cursorPos = EditorPanel.cursorPos.getData(tabId); + return ( { submit = async () => { const { showNotifications } = this.props; + this.waiting = true; + try { const result = await this.props.submit(); + if (showNotifications) Notifications.ok(result); } catch (error) { if (showNotifications) Notifications.error(error.toString()); @@ -81,6 +84,7 @@ export class InfoPanel extends Component { if (!this.props.showInlineInfo || !this.errorInfo) { return; } + return (
@@ -92,6 +96,7 @@ export class InfoPanel extends Component { const { className, controls, submitLabel, disableSubmit, error, submittingMessage, showButtons, showSubmitClose } = this.props; const { submit, close, submitAndClose, waiting } = this; const isDisabled = !!(disableSubmit || waiting || error); + return (
diff --git a/src/renderer/components/dock/install-chart.store.ts b/src/renderer/components/dock/install-chart.store.ts index 860c795c60..7415cb92bb 100644 --- a/src/renderer/components/dock/install-chart.store.ts +++ b/src/renderer/components/dock/install-chart.store.ts @@ -28,6 +28,7 @@ export class InstallChartStore extends DockTabStore { }); autorun(() => { const { selectedTab, isOpen } = dockStore; + if (isInstallChartTab(selectedTab) && isOpen) { this.loadData() .catch(err => Notifications.error(String(err))); @@ -53,9 +54,11 @@ export class InstallChartStore extends DockTabStore { @action async loadVersions(tabId: TabId) { const { repo, name, version } = this.getData(tabId); + this.versions.clearData(tabId); // reset const charts = await helmChartsApi.get(repo, name, version); const versions = charts.versions.map(chartVersion => chartVersion.version); + this.versions.setData(tabId, versions); } @@ -63,8 +66,8 @@ export class InstallChartStore extends DockTabStore { async loadValues(tabId: TabId, attempt = 0): Promise { const data = this.getData(tabId); const { repo, name, version } = data; - const values = await helmChartsApi.getValues(repo, name, version); + if (values) { this.setData(tabId, { ...data, values }); } else if (attempt < 4) { @@ -77,7 +80,6 @@ export const installChartStore = new InstallChartStore(); export function createInstallChartTab(chart: HelmChart, tabParams: Partial = {}) { const { name, repo, version } = chart; - const tab = dockStore.createTab({ kind: TabKind.INSTALL_CHART, title: _i18n._(t`Helm Install: ${repo}/${name}`), diff --git a/src/renderer/components/dock/install-chart.tsx b/src/renderer/components/dock/install-chart.tsx index 0c0713007d..10d1ddc4ed 100644 --- a/src/renderer/components/dock/install-chart.tsx +++ b/src/renderer/components/dock/install-chart.tsx @@ -54,6 +54,7 @@ export class InstallChart extends Component { @autobind() viewRelease() { const { release } = this.releaseDetails; + navigate(releaseURL({ params: { name: release.name, @@ -66,12 +67,14 @@ export class InstallChart extends Component { @autobind() save(data: Partial) { const chart = { ...this.chartData, ...data }; + installChartStore.setData(this.tabId, chart); } @autobind() onVersionChange(option: SelectOption) { const version = option.value; + this.save({ version, values: "" }); installChartStore.loadValues(this.tabId); } @@ -99,7 +102,9 @@ export class InstallChart extends Component { chart: name, repo, namespace, version, values, }); + installChartStore.details.setData(this.tabId, details); + return (

Chart Release {details.release.name} successfully created.

); @@ -107,6 +112,7 @@ export class InstallChart extends Component { render() { const { tabId, chartData, values, versions, install } = this; + if (chartData?.values === undefined || !versions) { return ; } diff --git a/src/renderer/components/dock/pod-log-controls.tsx b/src/renderer/components/dock/pod-log-controls.tsx index 24571362fc..1d91a9ca11 100644 --- a/src/renderer/components/dock/pod-log-controls.tsx +++ b/src/renderer/components/dock/pod-log-controls.tsx @@ -39,11 +39,13 @@ export const PodLogControls = observer((props: Props) => { const downloadLogs = () => { const fileName = selectedContainer ? selectedContainer.name : pod.getName(); + saveFileDialog(`${fileName}.log`, logs.join("\n"), "text/plain"); }; const onContainerChange = (option: SelectOption) => { const { containers, initContainers } = tabData; + save({ selectedContainer: containers .concat(initContainers) @@ -54,6 +56,7 @@ export const PodLogControls = observer((props: Props) => { const containerSelectOptions = () => { const { containers, initContainers } = tabData; + return [ { label: _i18n._(t`Containers`), @@ -72,6 +75,7 @@ export const PodLogControls = observer((props: Props) => { const formatOptionLabel = (option: SelectOption) => { const { value, label } = option; + return label || <> {value}; }; diff --git a/src/renderer/components/dock/pod-log-list.tsx b/src/renderer/components/dock/pod-log-list.tsx index e882b38a5b..392e0eefc0 100644 --- a/src/renderer/components/dock/pod-log-list.tsx +++ b/src/renderer/components/dock/pod-log-list.tsx @@ -41,24 +41,31 @@ export class PodLogList extends React.Component { componentDidUpdate(prevProps: Props) { const { logs, id } = this.props; + if (id != prevProps.id) { this.isLastLineVisible = true; + return; } if (logs == prevProps.logs || !this.virtualListDiv.current) return; const newLogsLoaded = prevProps.logs.length < logs.length; const scrolledToBeginning = this.virtualListDiv.current.scrollTop === 0; const fewLogsLoaded = logs.length < logRange; + if (this.isLastLineVisible) { this.scrollToBottom(); // Scroll down to keep user watching/reading experience + return; } + if (scrolledToBeginning && newLogsLoaded) { this.virtualListDiv.current.scrollTop = (logs.length - prevProps.logs.length) * this.lineHeight; } + if (fewLogsLoaded) { this.isJumpButtonVisible = false; } + if (!logs.length) { this.isLastLineVisible = false; } @@ -73,6 +80,7 @@ export class PodLogList extends React.Component { const offset = 100 * this.lineHeight; const { scrollHeight } = this.virtualListDiv.current; const { scrollOffset } = props; + if (scrollHeight - scrollOffset < offset) { this.isJumpButtonVisible = false; } else { @@ -88,6 +96,7 @@ export class PodLogList extends React.Component { setLastLineVisibility = (props: ListOnScrollProps) => { const { scrollHeight, clientHeight } = this.virtualListDiv.current; const { scrollOffset, scrollDirection } = props; + if (scrollDirection == "backward") { this.isLastLineVisible = false; } else { @@ -103,6 +112,7 @@ export class PodLogList extends React.Component { */ checkLoadIntent = (props: ListOnScrollProps) => { const { scrollOffset } = props; + if (scrollOffset === 0) { this.props.load(); } @@ -136,6 +146,7 @@ export class PodLogList extends React.Component { const item = this.props.logs[rowIndex]; const contents: React.ReactElement[] = []; const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi)); + if (searchQuery) { // If search is enabled, replace keyword with backgrounded // Case-insensitive search (lowercasing query and keywords in line) const regex = new RegExp(searchStore.escapeRegex(searchQuery), "gi"); @@ -143,6 +154,7 @@ export class PodLogList extends React.Component { const modified = item.replace(regex, match => match.toLowerCase()); // Splitting text line by keyword const pieces = modified.split(searchQuery.toLowerCase()); + pieces.forEach((piece, index) => { const active = isActiveOverlay(rowIndex, index); const lastItem = index === pieces.length - 1; @@ -153,6 +165,7 @@ export class PodLogList extends React.Component { dangerouslySetInnerHTML={{ __html: ansiToHtml(overlayValue) }} /> : null; + contents.push( @@ -161,6 +174,7 @@ export class PodLogList extends React.Component { ); }); } + return (
{contents.length > 1 ? contents : ( @@ -174,9 +188,11 @@ export class PodLogList extends React.Component { const { logs, isLoading } = this.props; const isInitLoading = isLoading && !logs.length; const rowHeights = new Array(logs.length).fill(this.lineHeight); + if (isInitLoading) { return ; } + if (!logs.length) { return (
@@ -184,6 +200,7 @@ export class PodLogList extends React.Component {
); } + return (
{ private refresher = interval(10, () => { const id = dockStore.selectedTabId; + if (!this.logs.get(id)) return; this.loadMore(id); }); @@ -39,6 +40,7 @@ export class PodLogsStore extends DockTabStore { }); autorun(() => { const { selectedTab, isOpen } = dockStore; + if (isPodLogsTab(selectedTab) && isOpen) { this.refresher.start(); } else { @@ -68,6 +70,7 @@ export class PodLogsStore extends DockTabStore { const logs = await this.loadLogs(tabId, { tailLines: this.lines + logRange }); + this.refresher.start(); this.logs.set(tabId, logs); } catch ({error}) { @@ -75,6 +78,7 @@ export class PodLogsStore extends DockTabStore { _i18n._(t`Failed to load logs: ${error.message}`), _i18n._(t`Reason: ${error.reason} (${error.code})`) ]; + this.refresher.stop(); this.logs.set(tabId, message); } @@ -92,6 +96,7 @@ export class PodLogsStore extends DockTabStore { const logs = await this.loadLogs(tabId, { sinceTime: this.getLastSinceTime(tabId) }); + // Add newly received logs to bottom this.logs.set(tabId, [...oldLogs, ...logs]); }; @@ -109,6 +114,7 @@ export class PodLogsStore extends DockTabStore { const pod = new Pod(data.pod); const namespace = pod.getNs(); const name = pod.getName(); + return podsApi.getLogs({ namespace, name }, { ...params, timestamps: true, // Always setting timestampt to separate old logs from new ones @@ -116,7 +122,9 @@ export class PodLogsStore extends DockTabStore { previous }).then(result => { const logs = [...result.split("\n")]; // Transform them into array + logs.pop(); // Remove last empty element + return logs; }); }; @@ -128,6 +136,7 @@ export class PodLogsStore extends DockTabStore { setNewLogSince(tabId: TabId) { if (!this.logs.has(tabId) || !this.logs.get(tabId).length || this.newLogSince.has(tabId)) return; const timestamp = this.getLastSinceTime(tabId); + this.newLogSince.set(tabId, timestamp.split(".")[0]); // Removing milliseconds from string } @@ -139,6 +148,7 @@ export class PodLogsStore extends DockTabStore { get lines() { const id = dockStore.selectedTabId; const logs = this.logs.get(id); + return logs ? logs.length : 0; } @@ -151,7 +161,9 @@ export class PodLogsStore extends DockTabStore { const logs = this.logs.get(tabId); const timestamps = this.getTimestamps(logs[logs.length - 1]); const stamp = new Date(timestamps ? timestamps[0] : null); + stamp.setSeconds(stamp.getSeconds() + 1); // avoid duplicates from last second + return stamp.toISOString(); } @@ -178,9 +190,11 @@ export const podLogsStore = new PodLogsStore(); export function createPodLogsTab(data: IPodLogsData, tabParams: Partial = {}) { const podId = data.pod.getId(); let tab = dockStore.getTabById(podId); + if (tab) { dockStore.open(); dockStore.selectTab(tab.id); + return; } // If no existent tab found @@ -191,6 +205,7 @@ export function createPodLogsTab(data: IPodLogsData, tabParams: Partial { @autobind() toOverlay() { const { activeOverlayLine } = searchStore; + if (!this.logListElement.current || activeOverlayLine === undefined) return; // Scroll vertically this.logListElement.current.scrollToItem(activeOverlayLine, "center"); // Scroll horizontally in timeout since virtual list need some time to prepare its contents setTimeout(() => { const overlay = document.querySelector(".PodLogs .list span.active"); + if (!overlay) return; overlay.scrollIntoViewIfNeeded(); }, 100); @@ -87,9 +89,11 @@ export class PodLogs extends React.Component { const logs = podLogsStore.logs.get(this.tabId); const { getData, removeTimestamps } = podLogsStore; const { showTimestamps } = getData(this.tabId); + if (!showTimestamps) { return logs.map(item => removeTimestamps(item)); } + return logs; } @@ -107,6 +111,7 @@ export class PodLogs extends React.Component { toNextOverlay={this.toOverlay} /> ); + return (
{ render() { const { className } = this.props; + return (
{ const { selectedTab, isOpen } = dockStore; + if (!isTerminalTab(selectedTab)) return; + if (isOpen) { this.connect(selectedTab.id); } @@ -40,6 +42,7 @@ export class TerminalStore { // disconnect closed tabs autorun(() => { const currentTabs = dockStore.tabs.map(tab => tab.id); + for (const [tabId] of this.connections) { if (!currentTabs.includes(tabId)) this.disconnect(tabId); } @@ -56,6 +59,7 @@ export class TerminalStore { node: tab.node, }); const terminal = new Terminal(tabId, api); + this.connections.set(tabId, api); this.terminals.set(tabId, terminal); } @@ -66,6 +70,7 @@ export class TerminalStore { } const terminal = this.terminals.get(tabId); const terminalApi = this.connections.get(tabId); + terminal.destroy(); terminalApi.destroy(); this.connections.delete(tabId); @@ -74,6 +79,7 @@ export class TerminalStore { reconnect(tabId: TabId) { const terminalApi = this.connections.get(tabId); + if (terminalApi) terminalApi.connect(); } @@ -83,6 +89,7 @@ export class TerminalStore { isDisconnected(tabId: TabId) { const terminalApi = this.connections.get(tabId); + if (terminalApi) { return terminalApi.readyState === WebSocketApiState.CLOSED; } @@ -91,12 +98,13 @@ export class TerminalStore { sendCommand(command: string, options: { enter?: boolean; newTab?: boolean; tabId?: TabId } = {}) { const { enter, newTab, tabId } = options; const { selectTab, getTabById } = dockStore; - const tab = tabId && getTabById(tabId); + if (tab) selectTab(tabId); if (newTab) createTerminalTab(); const terminalApi = this.connections.get(dockStore.selectedTabId); + if (terminalApi) { terminalApi.sendCommand(command + (enter ? "\r" : "")); } diff --git a/src/renderer/components/dock/terminal.ts b/src/renderer/components/dock/terminal.ts index 67a4f88168..a4246658fe 100644 --- a/src/renderer/components/dock/terminal.ts +++ b/src/renderer/components/dock/terminal.ts @@ -14,6 +14,7 @@ export class Terminal { // terminal element must be in DOM before attaching via xterm.open(elem) // https://xtermjs.org/docs/api/terminal/classes/terminal/#open const pool = document.createElement("div"); + pool.className = "terminal-init"; pool.style.cssText = "position: absolute; top: 0; left: 0; height: 0; visibility: hidden; overflow: hidden"; document.body.appendChild(pool); @@ -23,6 +24,7 @@ export class Terminal { static async preloadFonts() { const fontPath = require("../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires const fontFace = new FontFace("RobotoMono", `url(${fontPath})`); + await fontFace.load(); document.fonts.add(fontFace); } @@ -41,10 +43,13 @@ export class Terminal { .filter(([name]) => name.startsWith(colorPrefix)) .reduce((colors, [name, color]) => { const colorName = name.split("").slice(colorPrefix.length); + colorName[0] = colorName[0].toLowerCase(); colors[colorName.join("")] = color; + return colors; }, {}); + this.xterm.setOption("theme", terminalColors); } @@ -62,6 +67,7 @@ export class Terminal { get isActive() { const { isOpen, selectedTabId } = dockStore; + return isOpen && selectedTabId === this.tabId; } @@ -95,6 +101,7 @@ export class Terminal { // bind events const onDataHandler = this.xterm.onData(this.onData); + this.viewport.addEventListener("scroll", this.onScroll); this.api.onReady.addListener(this.onClear, { once: true }); // clear status logs (connecting..) this.api.onData.addListener(this.onApiData); @@ -125,6 +132,7 @@ export class Terminal { if (!this.isActive) return; this.fitAddon.fit(); const { cols, rows } = this.xterm; + this.api.sendTerminalSize(cols, rows); }; diff --git a/src/renderer/components/dock/upgrade-chart.store.ts b/src/renderer/components/dock/upgrade-chart.store.ts index 55574aa0a7..f06f423c0d 100644 --- a/src/renderer/components/dock/upgrade-chart.store.ts +++ b/src/renderer/components/dock/upgrade-chart.store.ts @@ -23,7 +23,9 @@ export class UpgradeChartStore extends DockTabStore { autorun(() => { const { selectedTab, isOpen } = dockStore; + if (!isUpgradeChartTab(selectedTab)) return; + if (isOpen) { this.loadData(selectedTab.id); } @@ -31,6 +33,7 @@ export class UpgradeChartStore extends DockTabStore { autorun(() => { const objects = [...this.data.values()]; + objects.forEach(({ releaseName }) => this.createReleaseWatcher(releaseName)); }); } @@ -41,13 +44,16 @@ export class UpgradeChartStore extends DockTabStore { } const dispose = reaction(() => { const release = releaseStore.getByName(releaseName); + if (release) return release.getRevision(); // watch changes only by revision }, release => { const releaseTab = this.getTabByRelease(releaseName); + if (!releaseStore.isLoaded || !releaseTab) { return; } + // auto-reload values if was loaded before if (release) { if (dockStore.selectedTab === releaseTab && this.values.getData(releaseTab.id)) { @@ -61,17 +67,20 @@ export class UpgradeChartStore extends DockTabStore { dockStore.closeTab(releaseTab.id); } }); + this.watchers.set(releaseName, dispose); } isLoading(tabId = dockStore.selectedTabId) { const values = this.values.getData(tabId); + return !releaseStore.isLoaded || values === undefined; } @action async loadData(tabId: TabId) { const values = this.values.getData(tabId); + await Promise.all([ !releaseStore.isLoaded && releaseStore.loadAll(), !values && this.loadValues(tabId) @@ -83,13 +92,16 @@ export class UpgradeChartStore extends DockTabStore { this.values.clearData(tabId); // reset const { releaseName, releaseNamespace } = this.getData(tabId); const values = await helmReleasesApi.getValues(releaseName, releaseNamespace); + this.values.setData(tabId, values); } getTabByRelease(releaseName: string): IDockTab { const item = [...this.data].find(item => item[1].releaseName === releaseName); + if (item) { const [tabId] = item; + return dockStore.getTabById(tabId); } } @@ -99,10 +111,12 @@ export const upgradeChartStore = new UpgradeChartStore(); export function createUpgradeChartTab(release: HelmRelease, tabParams: Partial = {}) { let tab = upgradeChartStore.getTabByRelease(release.getName()); + if (tab) { dockStore.open(); dockStore.selectTab(tab.id); } + if (!tab) { tab = dockStore.createTab({ kind: TabKind.UPGRADE_CHART, @@ -115,6 +129,7 @@ export function createUpgradeChartTab(release: HelmRelease, tabParams: Partial { get release(): HelmRelease { const tabData = upgradeChartStore.getData(this.tabId); + if (!tabData) return; + return releaseStore.getByName(tabData.releaseName); } @@ -55,6 +57,7 @@ export class UpgradeChart extends React.Component { this.version = null; this.versions.clear(); const versions = await helmChartStore.getVersions(this.release.getChart()); + this.versions.replace(versions); this.version = this.versions[0]; } @@ -69,11 +72,13 @@ export class UpgradeChart extends React.Component { const { version, repo } = this.version; const releaseName = this.release.getName(); const releaseNs = this.release.getNs(); + await releaseStore.update(releaseName, releaseNs, { chart: this.release.getChart(), values: this.value, repo, version, }); + return (

Release {releaseName} successfully upgraded to version {version} @@ -84,12 +89,14 @@ export class UpgradeChart extends React.Component { formatVersionLabel = ({ value }: SelectOption) => { const chartName = this.release.getChart(); const { repo, version } = value; + return `${repo}/${chartName}-${version}`; }; render() { const { tabId, release, value, error, onChange, upgrade, versions, version } = this; const { className } = this.props; + if (!release || upgradeChartStore.isLoading() || !version) { return ; } @@ -111,6 +118,7 @@ export class UpgradeChart extends React.Component { />

); + return (
{labels.map(label => )} diff --git a/src/renderer/components/drawer/drawer-item.tsx b/src/renderer/components/drawer/drawer-item.tsx index 6a0e1874cb..ff77e29c88 100644 --- a/src/renderer/components/drawer/drawer-item.tsx +++ b/src/renderer/components/drawer/drawer-item.tsx @@ -14,6 +14,7 @@ export interface DrawerItemProps extends React.HTMLAttributes { export class DrawerItem extends React.Component { render() { const { name, title, labelsOnly, children, hidden, className, renderBoolean, ...elemProps } = this.props; + if (hidden) return null; const classNames = cssNames("DrawerItem", className, { labelsOnly }); diff --git a/src/renderer/components/drawer/drawer-param-toggler.tsx b/src/renderer/components/drawer/drawer-param-toggler.tsx index 181965e848..902c5bae44 100644 --- a/src/renderer/components/drawer/drawer-param-toggler.tsx +++ b/src/renderer/components/drawer/drawer-param-toggler.tsx @@ -24,6 +24,7 @@ export class DrawerParamToggler extends React.Component
diff --git a/src/renderer/components/drawer/drawer-title.tsx b/src/renderer/components/drawer/drawer-title.tsx index 7e521c183e..74615ed175 100644 --- a/src/renderer/components/drawer/drawer-title.tsx +++ b/src/renderer/components/drawer/drawer-title.tsx @@ -10,6 +10,7 @@ export interface DrawerTitleProps { export class DrawerTitle extends React.Component { render() { const { title, children, className } = this.props; + return (
{title || children} diff --git a/src/renderer/components/drawer/drawer.tsx b/src/renderer/components/drawer/drawer.tsx index de4003990e..64098a8ed1 100644 --- a/src/renderer/components/drawer/drawer.tsx +++ b/src/renderer/components/drawer/drawer.tsx @@ -56,12 +56,14 @@ export class Drawer extends React.Component { saveScrollPos = () => { if (!this.scrollElem) return; const key = history.location.key; + this.scrollPos.set(key, this.scrollElem.scrollTop); }; restoreScrollPos = () => { if (!this.scrollElem) return; const key = history.location.key; + this.scrollElem.scrollTop = this.scrollPos.get(key) || 0; }; @@ -69,6 +71,7 @@ export class Drawer extends React.Component { if (!this.props.open) { return; } + if (evt.code === "Escape") { this.close(); } @@ -76,11 +79,13 @@ export class Drawer extends React.Component { onClickOutside = (evt: MouseEvent) => { const { contentElem, mouseDownTarget, close, props: { open } } = this; + if (!open || evt.defaultPrevented || contentElem.contains(mouseDownTarget)) { return; } const clickedElem = evt.target as HTMLElement; const isOutsideAnyDrawer = !clickedElem.closest(".Drawer"); + if (isOutsideAnyDrawer) { close(); } @@ -95,12 +100,14 @@ export class Drawer extends React.Component { close = () => { const { open, onClose } = this.props; + if (open) onClose(); }; render() { const { open, position, title, animation, children, toolbar, size, usePortal } = this.props; let { className, contentClass } = this.props; + className = cssNames("Drawer", className, position); contentClass = cssNames("drawer-content flex column box grow", contentClass); const style = size ? { "--size": size } as React.CSSProperties : undefined; @@ -120,6 +127,7 @@ export class Drawer extends React.Component {
); + return usePortal ? createPortal(drawer, document.body) : drawer; } } diff --git a/src/renderer/components/error-boundary/error-boundary.tsx b/src/renderer/components/error-boundary/error-boundary.tsx index 77708a281c..5cc70f09c2 100644 --- a/src/renderer/components/error-boundary/error-boundary.tsx +++ b/src/renderer/components/error-boundary/error-boundary.tsx @@ -37,10 +37,12 @@ export class ErrorBoundary extends React.Component { render() { const { error, errorInfo } = this.state; + if (error) { const slackLink = Slack; const githubLink = Github; const pageUrl = location.href; + return (
@@ -69,6 +71,7 @@ export class ErrorBoundary extends React.Component {
); } + return this.props.children; } } diff --git a/src/renderer/components/file-picker/file-picker.tsx b/src/renderer/components/file-picker/file-picker.tsx index 4f9a07c0cf..14cd6c07e8 100644 --- a/src/renderer/components/file-picker/file-picker.tsx +++ b/src/renderer/components/file-picker/file-picker.tsx @@ -83,6 +83,7 @@ export class FilePicker extends React.Component { handleFileCount(files: File[]): File[] { const { limit: [minLimit, maxLimit] = [0, Infinity], onOverLimit } = this.props; + if (files.length > maxLimit) { switch (onOverLimit) { case OverLimitStyle.CAP: @@ -92,6 +93,7 @@ export class FilePicker extends React.Component { throw `Too many files. Expected at most ${maxLimit}. Got ${files.length}.`; } } + if (files.length < minLimit) { throw `Too many files. Expected at most ${maxLimit}. Got ${files.length}.`; } @@ -107,6 +109,7 @@ export class FilePicker extends React.Component { return files.filter(file => file.size <= maxSize ); case OverSizeLimitStyle.REJECT: const firstFileToLarge = files.find(file => file.size > maxSize); + if (firstFileToLarge) { throw `${firstFileToLarge.name} is too large. Maximum size is ${maxSize}. Has size of ${firstFileToLarge.size}`; } @@ -119,6 +122,7 @@ export class FilePicker extends React.Component { const { maxTotalSize, onOverTotalSizeLimit } = this.props; const totalSize = _.sum(files.map(f => f.size)); + if (totalSize <= maxTotalSize) { return files; } @@ -131,10 +135,12 @@ export class FilePicker extends React.Component { for (;files.length > 0;) { newTotalSize -= files.pop().size; + if (newTotalSize <= maxTotalSize) { break; } } + return files; case OverTotalSizeLimitStyle.REJECT: throw `Total file size to upload is too large. Expected at most ${maxTotalSize}. Found ${totalSize}.`; @@ -151,11 +157,13 @@ export class FilePicker extends React.Component { if ("uploadDir" in this.props) { const { uploadDir } = this.props; + this.status = FileInputStatus.PROCESSING; const paths: string[] = []; const promises = totalSizeLimitedFiles.map(async file => { const destinationPath = path.join(uploadDir, file.name); + paths.push(destinationPath); return fse.copyFile(file.path, destinationPath); @@ -170,6 +178,7 @@ export class FilePicker extends React.Component { } catch (errorText) { this.status = FileInputStatus.ERROR; this.errorText = errorText; + return; } } diff --git a/src/renderer/components/icon/icon.tsx b/src/renderer/components/icon/icon.tsx index 3946737416..f74a7fa46d 100644 --- a/src/renderer/components/icon/icon.tsx +++ b/src/renderer/components/icon/icon.tsx @@ -32,6 +32,7 @@ export class Icon extends React.PureComponent { get isInteractive() { const { interactive, onClick, href, link } = this.props; + return interactive ?? !!(onClick || href || link); } @@ -40,6 +41,7 @@ export class Icon extends React.PureComponent { if (this.props.disabled) { return; } + if (this.props.onClick) { this.props.onClick(evt); } @@ -49,14 +51,17 @@ export class Icon extends React.PureComponent { onKeyDown(evt: React.KeyboardEvent) { switch (evt.nativeEvent.code) { case "Space": + case "Enter": { // eslint-disable-next-line react/no-find-dom-node const icon = findDOMNode(this) as HTMLElement; + setTimeout(() => icon.click()); evt.preventDefault(); break; } } + if (this.props.onKeyDown) { this.props.onKeyDown(evt); } @@ -90,6 +95,7 @@ export class Icon extends React.PureComponent { // render as inline svg-icon if (svg) { const svgIconText = require(`!!raw-loader!./${svg}.svg`).default; + iconContent = ; } @@ -110,9 +116,11 @@ export class Icon extends React.PureComponent { if (link) { return ; } + if (href) { return ; } + return ; } } diff --git a/src/renderer/components/input/drop-file-input.tsx b/src/renderer/components/input/drop-file-input.tsx index a99e61ef2b..a462a117d3 100644 --- a/src/renderer/components/input/drop-file-input.tsx +++ b/src/renderer/components/input/drop-file-input.tsx @@ -45,6 +45,7 @@ export class DropFileInput extends React.Component< } this.dropAreaActive = false; const files = Array.from(evt.dataTransfer.files); + if (files.length > 0) { this.props.onDropFiles(files, { evt }); } @@ -53,12 +54,15 @@ export class DropFileInput extends React.Component< render() { const { onDragEnter, onDragLeave, onDragOver, onDrop } = this; const { disabled, className } = this.props; + try { const contentElem = React.Children.only(this.props.children) as React.ReactElement>; + if (disabled) { return contentElem; } const isValidContentElem = React.isValidElement(contentElem); + if (isValidContentElem) { const contentElemProps: React.HTMLProps = { className: cssNames("DropFileInput", contentElem.props.className, className, { @@ -69,10 +73,12 @@ export class DropFileInput extends React.Component< onDragOver, onDrop, }; + return React.cloneElement(contentElem, contentElemProps); } } catch (err) { logger.error(`Error: must contain only single child element`); + return this.props.children; } } diff --git a/src/renderer/components/input/file-input.tsx b/src/renderer/components/input/file-input.tsx index f4d83bce14..f136aed3bb 100644 --- a/src/renderer/components/input/file-input.tsx +++ b/src/renderer/components/input/file-input.tsx @@ -28,14 +28,17 @@ export class FileInput extends React.Component { protected onChange = async (evt: React.ChangeEvent) => { const fileList = Array.from(evt.target.files); + if (!fileList.length) { return; } let selectedFiles: FileInputSelection[] = fileList.map(file => ({ file })); + if (this.props.readAsText) { const readingFiles: Promise[] = fileList.map(file => { return new Promise((resolve) => { const reader = new FileReader(); + reader.onloadend = () => { resolve({ file, @@ -46,6 +49,7 @@ export class FileInput extends React.Component { reader.readAsText(file); }); }); + selectedFiles = await Promise.all(readingFiles); } this.props.onSelectFiles(...selectedFiles); @@ -53,6 +57,7 @@ export class FileInput extends React.Component { render() { const { onSelectFiles, readAsText, ...props } = this.props; + return ( { setValue(value: string) { if (value !== this.getValue()) { const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set; + nativeInputValueSetter.call(this.input, value); const evt = new Event("input", { bubbles: true }); + this.input.dispatchEvent(evt); } } getValue(): string { const { value, defaultValue = "" } = this.props; + if (value !== undefined) return value; // controlled input if (this.input) return this.input.value; // uncontrolled input + return defaultValue as string; } @@ -97,6 +102,7 @@ export class Input extends React.Component { private autoFitHeight() { const { multiLine, rows, maxRows } = this.props; + if (!multiLine) { return; } @@ -104,6 +110,7 @@ export class Input extends React.Component { const lineHeight = parseFloat(window.getComputedStyle(textArea).lineHeight); const rowsCount = (this.getValue().match(/\n/g) || []).length + 1; const height = lineHeight * Math.min(Math.max(rowsCount, rows), maxRows); + textArea.style.height = `${height}px`; } @@ -121,6 +128,7 @@ export class Input extends React.Component { break; } const result = validator.validate(value, this.props); + if (isBoolean(result) && !result) { errors.push(this.getValidatorError(value, validator)); } else if (result instanceof Promise) { @@ -143,6 +151,7 @@ export class Input extends React.Component { if (asyncValidators.length > 0) { this.setState({ validating: true, valid: false }); const asyncErrors = await Promise.all(asyncValidators); + if (this.validationId === validationId) { this.setValidation(errors.concat(...asyncErrors.filter(err => err))); } @@ -161,6 +170,7 @@ export class Input extends React.Component { private getValidatorError(value: string, { message }: InputValidator) { if (isFunction(message)) return message(value, this.props); + return message || ""; } @@ -173,6 +183,7 @@ export class Input extends React.Component { // debounce async validators .map(({ debounce, ...validator }) => { if (debounce) validator.validate = debouncePromise(validator.validate, debounce); + return validator; }); // run validation @@ -187,6 +198,7 @@ export class Input extends React.Component { @autobind() onFocus(evt: React.FocusEvent) { const { onFocus, autoSelectOnFocus } = this.props; + if (onFocus) onFocus(evt); if (autoSelectOnFocus) this.select(); this.setState({ focused: true }); @@ -195,6 +207,7 @@ export class Input extends React.Component { @autobind() onBlur(evt: React.FocusEvent) { const { onBlur } = this.props; + if (onBlur) onBlur(evt); if (this.state.dirtyOnBlur) this.setState({ dirty: true, dirtyOnBlur: false }); this.setState({ focused: false }); @@ -238,6 +251,7 @@ export class Input extends React.Component { get showMaxLenIndicator() { const { maxLength, multiLine } = this.props; + return maxLength && multiLine; } @@ -252,13 +266,16 @@ export class Input extends React.Component { componentDidUpdate(prevProps: InputProps) { const { defaultValue, value, dirty, validators } = this.props; + if (prevProps.value !== value || defaultValue !== prevProps.defaultValue) { this.validate(); this.autoFitHeight(); } + if (prevProps.dirty !== dirty) { this.setDirty(dirty); } + if (prevProps.validators !== validators) { this.setupValidators(); } @@ -307,8 +324,10 @@ export class Input extends React.Component { ); const componentId = id || showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined; let tooltipError: React.ReactNode; + if (showErrorsAsTooltip && showErrors) { const tooltipProps = typeof showErrorsAsTooltip === "object" ? showErrorsAsTooltip : {}; + tooltipProps.className = cssNames("InputTooltipError", tooltipProps.className); tooltipError = ( @@ -319,6 +338,7 @@ export class Input extends React.Component { ); } + return (
{tooltipError} diff --git a/src/renderer/components/input/input_validators.ts b/src/renderer/components/input/input_validators.ts index e315127509..53ec426bbb 100644 --- a/src/renderer/components/input/input_validators.ts +++ b/src/renderer/components/input/input_validators.ts @@ -28,6 +28,7 @@ export const isNumber: InputValidator = { message: () => _i18n._(t`Invalid number`), validate: (value, { min, max }) => { const numVal = +value; + return !( isNaN(numVal) || (min != null && numVal < min) || @@ -61,6 +62,7 @@ export const maxLength: InputValidator = { }; const systemNameMatcher = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/; + export const systemName: InputValidator = { message: () => _i18n._(t`A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics.`), validate: value => !!value.match(systemNameMatcher), diff --git a/src/renderer/components/input/search-input-url.tsx b/src/renderer/components/input/search-input-url.tsx index 854adc1024..6a507128e0 100644 --- a/src/renderer/components/input/search-input-url.tsx +++ b/src/renderer/components/input/search-input-url.tsx @@ -30,6 +30,7 @@ export class SearchInputUrl extends React.Component { onChange = (val: string, evt: React.ChangeEvent) => { this.setValue(val); + if (this.props.onChange) { this.props.onChange(val, evt); } @@ -37,6 +38,7 @@ export class SearchInputUrl extends React.Component { render() { const { inputVal } = this; + return ( { @autobind() onGlobalKey(evt: KeyboardEvent) { const meta = evt.metaKey || evt.ctrlKey; + if (meta && evt.key === "f") { this.inputRef.current.focus(); } @@ -54,6 +55,7 @@ export class SearchInput extends React.Component { } // clear on escape-key const escapeKey = evt.nativeEvent.code === "Escape"; + if (escapeKey) { this.clear(); evt.stopPropagation(); @@ -72,9 +74,11 @@ export class SearchInput extends React.Component { render() { const { className, compact, onClear, showClearIcon, bindGlobalFocusHotkey, value, ...inputProps } = this.props; let rightIcon = ; + if (showClearIcon && value) { rightIcon = ; } + return ( { export function FilterIcon(props: Props) { const { type, ...iconProps } = props; + switch (type) { case FilterType.NAMESPACE: return ; diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index 9fbcb54add..86478faa03 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -104,6 +104,7 @@ export class ItemListLayout extends React.Component { // keep ui user settings in local storage const defaultUserSettings = toJS(this.userSettings); const storage = createStorage("items_list_layout", defaultUserSettings); + Object.assign(this.userSettings, storage.get()); // restore disposeOnUnmount(this, [ reaction(() => toJS(this.userSettings), settings => storage.set(settings)), @@ -113,10 +114,13 @@ export class ItemListLayout extends React.Component { async componentDidMount() { const { store, dependentStores, isClusterScoped } = this.props; const stores = [store, ...dependentStores]; + if (!isClusterScoped) stores.push(namespaceStore); + try { await Promise.all(stores.map(store => store.loadAll())); const subscriptions = stores.map(store => store.subscribe()); + await when(() => this.isUnmounting); subscriptions.forEach(dispose => dispose()); // unsubscribe all } catch (error) { @@ -127,6 +131,7 @@ export class ItemListLayout extends React.Component { componentWillUnmount() { this.isUnmounting = true; const { store, isSelectable } = this.props; + if (isSelectable) store.resetSelection(); } @@ -134,52 +139,64 @@ export class ItemListLayout extends React.Component { [FilterType.SEARCH]: items => { const { searchFilters, isSearchable } = this.props; const search = pageFilters.getValues(FilterType.SEARCH)[0] || ""; + if (search && isSearchable && searchFilters) { const normalizeText = (text: string) => String(text).toLowerCase(); const searchTexts = [search].map(normalizeText); + return items.filter(item => { return searchFilters.some(getTexts => { const sourceTexts: string[] = [getTexts(item)].flat().map(normalizeText); + return sourceTexts.some(source => searchTexts.some(search => source.includes(search))); }); }); } + return items; }, [FilterType.NAMESPACE]: items => { const filterValues = pageFilters.getValues(FilterType.NAMESPACE); + if (filterValues.length > 0) { return items.filter(item => filterValues.includes(item.getNs())); } + return items; }, }; @computed get isReady() { const { isReady, store } = this.props; + return typeof isReady == "boolean" ? isReady : store.isLoaded; } @computed get filters() { let { activeFilters } = pageFilters; const { isClusterScoped, isSearchable, searchFilters } = this.props; + if (isClusterScoped) { activeFilters = activeFilters.filter(({ type }) => type !== FilterType.NAMESPACE); } + if (!(isSearchable && searchFilters)) { activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH); } + return activeFilters; } applyFilters(filters: ItemsFilter[], items: T[]): T[] { if (!filters || !filters.length) return items; + return filters.reduce((items, filter) => filter(items), items); } @computed get allItems() { const { filterItems, store } = this.props; + return this.applyFilters(filterItems, store.items); } @@ -190,10 +207,12 @@ export class ItemListLayout extends React.Component { Object.entries(filterGroups).forEach(([type, filtersGroup]) => { const filterCallback = filterCallbacks[type]; + if (filterCallback && filtersGroup.length > 0) { filterItems.push(filterCallback); } }); + return this.applyFilters(filterItems, allItems); } @@ -206,8 +225,10 @@ export class ItemListLayout extends React.Component { } = this.props; const { isSelected } = store; const item = this.items.find(item => item.getId() == uid); + if (!item) return; const itemId = item.getId(); + return ( { renderTableContents(item) .map((content, index) => { const cellProps: TableCellProps = isReactNode(content) ? { children: content } : content; + if (copyClassNameFromHeadCells && renderTableHeader) { const headCell = renderTableHeader[index]; + if (headCell) { cellProps.className = cssNames(cellProps.className, headCell.className); } } + return ; }) } @@ -257,6 +281,7 @@ export class ItemListLayout extends React.Component { const selectedCount = selectedItems.length; const tailCount = selectedCount > visibleMaxNamesCount ? selectedCount - visibleMaxNamesCount : 0; const tail = tailCount > 0 ? and {tailCount} more : null; + ConfirmDialog.open({ ok: removeSelectedItems, labelOk: Remove, @@ -274,9 +299,11 @@ export class ItemListLayout extends React.Component { renderFilters() { const { hideFilters } = this.props; const { isReady, userSettings, filters } = this; + if (!isReady || !filters.length || hideFilters || !userSettings.showAppliedFilters) { return; } + return ; } @@ -285,6 +312,7 @@ export class ItemListLayout extends React.Component { const allItemsCount = allItems.length; const itemsCount = items.length; const isFiltered = filters.length > 0 && allItemsCount > itemsCount; + if (isFiltered) { return ( @@ -297,11 +325,13 @@ export class ItemListLayout extends React.Component { ); } + return ; } renderHeaderContent(placeholders: IHeaderPlaceholders): ReactNode { const { title, filters, search, info } = placeholders; + return ( <> {title} @@ -319,14 +349,17 @@ export class ItemListLayout extends React.Component { const allItemsCount = allItems.length; const itemsCount = items.length; const isFiltered = isReady && filters.length > 0; + if (isFiltered) { const toggleFilters = () => userSettings.showAppliedFilters = !userSettings.showAppliedFilters; + return ( Filtered: {itemsCount} / {allItemsCount} ); } + return ( { renderHeader() { const { showHeader, customizeHeader, renderHeaderTitle, headerClassName, isClusterScoped } = this.props; + if (!showHeader) return; const title = typeof renderHeaderTitle === "function" ? renderHeaderTitle(this) : renderHeaderTitle; const placeholders: IHeaderPlaceholders = { @@ -352,8 +386,10 @@ export class ItemListLayout extends React.Component { search: , }; let header = this.renderHeaderContent(placeholders); + if (customizeHeader) { const modifiedHeader = customizeHeader(placeholders, header); + if (isReactNode(modifiedHeader)) { header = modifiedHeader; } else { @@ -363,6 +399,7 @@ export class ItemListLayout extends React.Component { }); } } + return (
{header} @@ -378,6 +415,7 @@ export class ItemListLayout extends React.Component { const { isReady, removeItemsDialog, items } = this; const { selectedItems } = store; const selectedItemId = detailsItem && detailsItem.getId(); + return (
{!isReady && ( @@ -432,6 +470,7 @@ export class ItemListLayout extends React.Component { render() { const { className } = this.props; + return (
{this.renderHeader()} diff --git a/src/renderer/components/item-object-list/page-filters-list.tsx b/src/renderer/components/item-object-list/page-filters-list.tsx index 492dffe20a..f2bdb35bf0 100644 --- a/src/renderer/components/item-object-list/page-filters-list.tsx +++ b/src/renderer/components/item-object-list/page-filters-list.tsx @@ -25,9 +25,11 @@ export class PageFiltersList extends React.Component { renderContent() { const { filters } = this.props; + if (!filters.length) { return null; } + return ( <>
@@ -39,6 +41,7 @@ export class PageFiltersList extends React.Component {
{filters.map(filter => { const { value, type } = filter; + return ( { @computed get groupedOptions() { const options: GroupSelectOption[] = []; const { disableFilters } = this.props; + if (!disableFilters[FilterType.NAMESPACE]) { const selectedValues = pageFilters.getValues(FilterType.NAMESPACE); + options.push({ label: Namespace, options: namespaceStore.items.map(ns => { const name = ns.getName(); + return { type: FilterType.NAMESPACE, value: name, @@ -46,18 +49,21 @@ export class PageFiltersSelect extends React.Component { }) }); } + return options; } @computed get options(): SelectOptionFilter[] { return this.groupedOptions.reduce((options, optGroup) => { options.push(...optGroup.options); + return options; }, []); } private formatLabel = (option: SelectOptionFilter) => { const { label, value, type, selected } = option; + return (
@@ -71,6 +77,7 @@ export class PageFiltersSelect extends React.Component { const { type, value, selected } = option; const { addFilter, removeFilter } = pageFilters; const filter = { type, value }; + if (!selected) { addFilter(filter); } @@ -81,11 +88,13 @@ export class PageFiltersSelect extends React.Component { render() { const { groupedOptions, formatLabel, onSelect, options } = this; + if (!options.length && this.props.allowEmpty) { return null; } const { allowEmpty, disableFilters, ...selectProps } = this.props; const selectedOptions = options.filter(opt => opt.selected); + return (