diff --git a/.github/workflows/check-docs.yml b/.github/workflows/check-docs.yml index cccb0222cb..0fb857488d 100644 --- a/.github/workflows/check-docs.yml +++ b/.github/workflows/check-docs.yml @@ -1,7 +1,8 @@ name: Check Documentation on: pull_request: - types: [opened, labeled, unlabeled, synchronize] + branches: + - "**" jobs: build: name: Check Docs diff --git a/.github/workflows/publish-master-npm.yml b/.github/workflows/publish-master-npm.yml index b448e58bde..464e2adbe7 100644 --- a/.github/workflows/publish-master-npm.yml +++ b/.github/workflows/publish-master-npm.yml @@ -32,4 +32,7 @@ jobs: - name: Publish NPM package run: | + npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" yarn lerna publish from-package --dist-tag master --no-push --no-git-tag-version --yes + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish-release-npm.yml b/.github/workflows/publish-release-npm.yml index 8b47b4c643..fca5c817bb 100644 --- a/.github/workflows/publish-release-npm.yml +++ b/.github/workflows/publish-release-npm.yml @@ -37,6 +37,13 @@ jobs: - name: Publish NPM packages run: | - yarn lerna publish from-package --no-push --no-git-tag-version --yes + npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" + DIST_TAG=$(cat lerna.json | jq '.version' --raw-output | xargs node ./packages/semver/dist/index.mjs --prerelease 0) + yarn lerna \ + publish from-package \ + --no-push \ + --no-git-tag-version \ + --yes \ + --dist-tag ${DIST_TAG:-latest} env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 911a103728..8b332ab11d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,8 +21,9 @@ jobs: - uses: butlerlogic/action-autotag@stable id: tagger with: - GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" - tag_prefix: "v" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + tag_prefix: v + root: /packages/core - uses: ncipollo/release-action@v1 if: ${{ steps.tagger.outputs.tagname != '' }} with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 380fa3916d..43cad88f6a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,15 +53,11 @@ jobs: retry_on: error command: yarn install --frozen-lockfile - - run: make test + - run: yarn run test:unit name: Run tests shell: bash if: ${{ matrix.type == 'unit' }} - - run: make ci-validate-dev - if: ${{ contains(github.event.pull_request.labels.*.name, 'dependencies') && matrix.type == 'unit' }} - name: Validate dev mode will work - - name: Install integration test dependencies id: minikube uses: medyagh/setup-minikube@master diff --git a/.yarnrc b/.yarnrc index a68592d594..ad210e5757 100644 --- a/.yarnrc +++ b/.yarnrc @@ -1,2 +1,3 @@ --install.check-files true ---install.network-timeout 100000 \ No newline at end of file +--install.network-timeout 100000 +--publish.access public diff --git a/Makefile b/Makefile deleted file mode 100644 index 5d7c29e942..0000000000 --- a/Makefile +++ /dev/null @@ -1,31 +0,0 @@ -CMD_ARGS = $(filter-out $@,$(MAKECMDGOALS)) - -%: - @: - -ELECTRON_BUILDER_EXTRA_ARGS ?= - -.PHONY: bootstrap -bootstrap: - yarn install - -.PHONY: lint -lint: node_modules - yarn lint - -.PHONY: test -test: node_modules - yarn run test:unit - -.PHONY: integration -integration: build - yarn test:integration - -.PHONY: build -build: - yarn lerna run build:app - -.PHONY: clean -clean: - yarn run clean - yarn run clean:node_modules diff --git a/lerna.json b/lerna.json index 5ad0e4f6da..7cfb6ab8ea 100644 --- a/lerna.json +++ b/lerna.json @@ -4,6 +4,6 @@ "packages": [ "packages/*" ], - "version": "6.4.0-alpha.2", + "version": "6.4.0-beta.8", "npmClient": "yarn" } diff --git a/nx.json b/nx.json index 9d7be8fbe7..460182e190 100644 --- a/nx.json +++ b/nx.json @@ -5,7 +5,8 @@ "options": { "cacheableOperations": [ "build", - "prepare:dev" + "prepare:dev", + "prepare:lint" ] } } @@ -16,10 +17,25 @@ "^build" ] }, + "build:docs": { + "dependsOn": [ + "^build" + ] + }, "dev": { "dependsOn": [ "prepare:dev" ] + }, + "lint": { + "dependsOn": [ + "^prepare:test" + ] + }, + "test:unit": { + "dependsOn": [ + "^prepare:test" + ] } } } diff --git a/package.json b/package.json index 113b8e2ab7..82a52a0dc2 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "build:docs": "lerna run --stream build:docs", "clean": "lerna run clean --stream", "clean:node_modules": "lerna clean -y && rm -rf node_modules", - "dev": "lerna run dev --stream", + "dev": "lerna run dev --stream --skip-nx-cache", "lint": "lerna run lint --stream", "mkdocs:serve-local": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -it -p 8000:8000 -v ${PWD}:/docs mkdocs-serve-local:latest", "mkdocs:verify": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -v ${PWD}:/docs mkdocs-serve-local:latest build --strict", @@ -24,6 +24,6 @@ }, "devDependencies": { "adr": "^1.4.3", - "lerna": "^6.3.0" + "lerna": "^6.4.1" } } diff --git a/packages/core/package.json b/packages/core/package.json index d215216181..c722b64718 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,9 +1,9 @@ { - "name": "@k8slens/open-lens", - "productName": "OpenLens", - "description": "OpenLens - Open Source IDE for Kubernetes", + "name": "@k8slens/core", + "productName": "", + "description": "Lens Desktop Core", "homepage": "https://github.com/lensapp/lens", - "version": "6.4.0-alpha.4", + "version": "6.4.0-beta.8", "repository": { "type": "git", "url": "git+https://github.com/lensapp/lens.git" @@ -12,6 +12,10 @@ "bugs": { "url": "https://github.com/lensapp/lens/issues" }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "main": "static/build/main.js", "exports": { "./main": "./static/build/library/main.js", @@ -33,25 +37,23 @@ } }, "files": [ - "build/download_binaries.ts", "build/*.plist", "build/installer.nsh", "build/notarize.js", "static/build/library/**/*", + "src/renderer/template.html", "templates/**/*", "types/*", "tsconfig.json" ], - "copyright": "© 2022 OpenLens Authors", + "copyright": "© 2023 OpenLens Authors", "license": "MIT", "author": "OpenLens Authors ", "scripts": { "build": "env NODE_ENV=production yarn run webpack --config webpack/library-bundle.ts", - "clean": "rm -rf dist webpack/build/ static/build", - "compile:node-fetch": "yarn run webpack --config webpack/node-fetch.ts", + "clean": "rm -rf dist static/build", "prepare:dev": "env NODE_ENV=development yarn run webpack --config webpack/library-bundle.ts --progress", "dev": "env NODE_ENV=development yarn run webpack --config webpack/library-bundle.ts --watch", - "prepare": "yarn run compile:node-fetch", "test:unit": "jest --testPathIgnorePatterns integration", "test:watch": "func() { jest ${1} --watch --testPathIgnorePatterns integration; }; func", "lint": "PROD=true yarn run eslint --ext js,ts,tsx --max-warnings=0 .", @@ -60,7 +62,7 @@ "config": { "k8sProxyVersion": "0.3.0", "bundledKubectlVersion": "1.23.3", - "bundledHelmVersion": "3.7.2", + "bundledHelmVersion": "3.11.0", "sentryDsn": "", "contentSecurityPolicy": "script-src 'unsafe-eval' 'self'; frame-src https://*.lens.app:*/; img-src * data:", "welcomeRoute": "/welcome" @@ -95,6 +97,9 @@ "setupFilesAfterEnv": [ "/src/jest-after-env.setup.ts" ], + "transformIgnorePatterns": [ + "node_modules/(?!jsonpath-plus)" + ], "runtime": "@side/jest-runtime" }, "build": {}, @@ -105,13 +110,11 @@ "^build" ], "outputs": [ - "{workspaceRoot}/build/webpack/", "{workspaceRoot}/static/build/" ] }, "dev": { "outputs": [ - "{workspaceRoot}/build/webpack/", "{workspaceRoot}/static/build/" ] } @@ -124,7 +127,8 @@ "@astronautlabs/jsonpath": "^1.1.0", "@hapi/call": "^9.0.0", "@hapi/subtext": "^7.0.4", - "@kubernetes/client-node": "^0.18.0", + "@k8slens/node-fetch": "^6.4.0-beta.8", + "@kubernetes/client-node": "^0.18.1", "@material-ui/styles": "^4.11.5", "@ogre-tools/fp": "^12.0.1", "@ogre-tools/injectable": "^12.0.1", @@ -133,7 +137,7 @@ "@ogre-tools/injectable-react": "^12.0.1", "@sentry/electron": "^3.0.8", "@sentry/integrations": "^6.19.3", - "@side/jest-runtime": "^1.0.1", + "@side/jest-runtime": "^1.1.0", "@testing-library/user-event": "^14.4.3", "abort-controller": "^3.0.0", "auto-bind": "^4.0.0", @@ -167,7 +171,6 @@ "mobx-utils": "^6.0.4", "moment": "^2.29.4", "moment-timezone": "^0.5.40", - "node-fetch": "^3.3.0", "node-pty": "0.10.1", "npm": "^8.19.3", "p-limit": "^3.1.0", @@ -202,7 +205,7 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", "@sentry/types": "^6.19.7", "@swc/cli": "^0.1.59", - "@swc/core": "^1.3.27", + "@swc/core": "^1.3.28", "@swc/jest": "^0.2.24", "@testing-library/dom": "^7.31.2", "@testing-library/jest-dom": "^5.16.5", @@ -211,7 +214,6 @@ "@types/byline": "^4.2.33", "@types/chart.js": "^2.9.36", "@types/circular-dependency-plugin": "5.0.5", - "@types/cli-progress": "^3.11.0", "@types/color": "^3.0.3", "@types/command-line-args": "^5.2.0", "@types/crypto-js": "^3.1.47", @@ -219,7 +221,6 @@ "@types/electron-devtools-installer": "^2.2.1", "@types/fs-extra": "^9.0.13", "@types/glob-to-regexp": "^0.4.1", - "@types/gunzip-maybe": "^1.4.0", "@types/hapi__call": "^9.0.0", "@types/hapi__subtext": "^7.0.0", "@types/html-webpack-plugin": "^3.2.6", @@ -248,7 +249,6 @@ "@types/semver": "^7.3.13", "@types/sharp": "^0.31.1", "@types/tar": "^6.1.3", - "@types/tar-stream": "^2.2.2", "@types/tcp-port-used": "^1.0.1", "@types/tempy": "^0.3.0", "@types/triple-beam": "^1.3.2", @@ -258,14 +258,13 @@ "@types/webpack-dev-server": "^4.7.2", "@types/webpack-env": "^1.18.0", "@types/webpack-node-externals": "^2.5.3", - "@typescript-eslint/eslint-plugin": "^5.48.2", - "@typescript-eslint/parser": "^5.48.2", + "@typescript-eslint/eslint-plugin": "^5.49.0", + "@typescript-eslint/parser": "^5.49.0", "adr": "^1.4.3", "ansi_up": "^5.1.0", "chalk": "^4.1.2", "chart.js": "^2.9.4", "circular-dependency-plugin": "^5.2.2", - "cli-progress": "^3.11.2", "color": "^3.2.1", "command-line-args": "^5.2.1", "concurrently": "^7.6.0", @@ -284,8 +283,7 @@ "eslint-plugin-react": "^7.32.1", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-unused-imports": "^2.0.0", - "fork-ts-checker-webpack-plugin": "^6.5.2", - "gunzip-maybe": "^1.4.2", + "fork-ts-checker-webpack-plugin": "^7.3.0", "html-webpack-plugin": "^5.5.0", "identity-obj-proxy": "^3.0.0", "ignore-loader": "^0.1.2", @@ -304,7 +302,7 @@ "node-gyp": "^8.3.0", "node-loader": "^2.0.0", "nodemon": "^2.0.20", - "playwright": "^1.29.2", + "playwright": "^1.30.0", "postcss": "^8.4.21", "postcss-loader": "^6.2.1", "query-string": "^7.1.3", @@ -322,7 +320,6 @@ "sharp": "^0.31.3", "style-loader": "^3.3.1", "tailwindcss": "^3.2.4", - "tar-stream": "^2.2.0", "ts-loader": "^9.4.2", "ts-node": "^10.9.1", "type-fest": "^2.14.0", diff --git a/packages/core/src/common/base-store/base-store.ts b/packages/core/src/common/base-store/base-store.ts index 0f310dadf5..58d1264c86 100644 --- a/packages/core/src/common/base-store/base-store.ts +++ b/packages/core/src/common/base-store/base-store.ts @@ -53,10 +53,16 @@ export abstract class BaseStore { readonly displayName = kebabCase(this.params.configName).toUpperCase(); + /** + * @ignore + */ + protected readonly dependencies: BaseStoreDependencies; + protected constructor( - protected readonly dependencies: BaseStoreDependencies, + dependencies: BaseStoreDependencies, protected readonly params: BaseStoreParams, ) { + this.dependencies = dependencies; makeObservable(this); } diff --git a/packages/core/src/common/catalog-entities/general.ts b/packages/core/src/common/catalog-entities/general.ts index e81e59cc16..40988a95c8 100644 --- a/packages/core/src/common/catalog-entities/general.ts +++ b/packages/core/src/common/catalog-entities/general.ts @@ -7,7 +7,7 @@ import type { CatalogEntityMetadata, CatalogEntitySpec, CatalogEntityStatus } fr import type { CatalogEntityActionContext } from "../catalog/catalog-entity"; import { CatalogCategory, CatalogEntity, categoryVersion } from "../catalog/catalog-entity"; -interface GeneralEntitySpec extends CatalogEntitySpec { +export interface GeneralEntitySpec extends CatalogEntitySpec { path: string; icon?: { material?: string; diff --git a/packages/core/src/common/catalog/catalog-entity.ts b/packages/core/src/common/catalog/catalog-entity.ts index 52da511560..cf77ceddaa 100644 --- a/packages/core/src/common/catalog/catalog-entity.ts +++ b/packages/core/src/common/catalog/catalog-entity.ts @@ -9,7 +9,9 @@ import { observable, makeObservable } from "mobx"; import { once } from "lodash"; import type { Disposer } from "../utils"; import { iter } from "../utils"; -import type { CategoryColumnRegistration } from "../../renderer/components/+catalog/custom-category-columns"; +import type { CategoryColumnRegistration, TitleCellProps } from "../../renderer/components/+catalog/custom-category-columns"; + +export type { CategoryColumnRegistration, TitleCellProps }; export type CatalogEntityDataFor = Entity extends CatalogEntity ? CatalogEntityData diff --git a/packages/core/src/common/cluster-types.ts b/packages/core/src/common/cluster-types.ts index 0cd447f0e2..3e904a385e 100644 --- a/packages/core/src/common/cluster-types.ts +++ b/packages/core/src/common/cluster-types.ts @@ -157,7 +157,7 @@ export enum ClusterStatus { */ export interface KubeAuthUpdate { message: string; - isError: boolean; + level: "info" | "warning" | "error"; } /** diff --git a/packages/core/src/common/cluster/cluster.ts b/packages/core/src/common/cluster/cluster.ts index 3f353dfee4..f00d9c3f89 100644 --- a/packages/core/src/common/cluster/cluster.ts +++ b/packages/core/src/common/cluster/cluster.ts @@ -356,7 +356,7 @@ export class Cluster implements ClusterModel { this.broadcastConnectUpdate("Starting connection ..."); await this.reconnect(); } catch (error) { - this.broadcastConnectUpdate(`Failed to start connection: ${error}`, true); + this.broadcastConnectUpdate(`Failed to start connection: ${error}`, "error"); return; } @@ -366,7 +366,7 @@ export class Cluster implements ClusterModel { this.broadcastConnectUpdate("Refreshing connection status ..."); await this.refreshConnectionStatus(); } catch (error) { - this.broadcastConnectUpdate(`Failed to connection status: ${error}`, true); + this.broadcastConnectUpdate(`Failed to connection status: ${error}`, "error"); return; } @@ -376,7 +376,7 @@ export class Cluster implements ClusterModel { this.broadcastConnectUpdate("Refreshing cluster accessibility ..."); await this.refreshAccessibility(); } catch (error) { - this.broadcastConnectUpdate(`Failed to refresh accessibility: ${error}`, true); + this.broadcastConnectUpdate(`Failed to refresh accessibility: ${error}`, "error"); return; } @@ -484,9 +484,20 @@ export class Cluster implements ClusterModel { resource: "*", }); this.allowedNamespaces.replace(await this.requestAllowedNamespaces(proxyConfig)); - this.knownResources.replace(await this.dependencies.requestApiResources(this)); + + const knownResources = await this.dependencies.requestApiResources(this); + + if (knownResources.callWasSuccessful) { + this.knownResources.replace(knownResources.response); + } else if (this.knownResources.length > 0) { + this.dependencies.logger.warn(`[CLUSTER]: failed to list KUBE resources, sticking with previous list`); + } else { + this.dependencies.logger.warn(`[CLUSTER]: failed to list KUBE resources for the first time, blocking connection to cluster...`); + this.broadcastConnectUpdate("Failed to list kube API resources, please reconnect...", "error"); + } + this.allowedResources.replace(await this.getAllowedResources(requestNamespaceListPermissions)); - this.ready = true; + this.ready = this.knownResources.length > 0; } /** @@ -536,37 +547,37 @@ export class Cluster implements ClusterModel { if (isRequestError(error)) { if (error.statusCode) { if (error.statusCode >= 400 && error.statusCode < 500) { - this.broadcastConnectUpdate("Invalid credentials", true); + this.broadcastConnectUpdate("Invalid credentials", "error"); return ClusterStatus.AccessDenied; } const message = String(error.error || error.message) || String(error); - this.broadcastConnectUpdate(message, true); + this.broadcastConnectUpdate(message, "error"); return ClusterStatus.Offline; } if (error.failed === true) { if (error.timedOut === true) { - this.broadcastConnectUpdate("Connection timed out", true); + this.broadcastConnectUpdate("Connection timed out", "error"); return ClusterStatus.Offline; } - this.broadcastConnectUpdate("Failed to fetch credentials", true); + this.broadcastConnectUpdate("Failed to fetch credentials", "error"); return ClusterStatus.AccessDenied; } const message = String(error.error || error.message) || String(error); - this.broadcastConnectUpdate(message, true); + this.broadcastConnectUpdate(message, "error"); } else if (error instanceof Error || typeof error === "string") { - this.broadcastConnectUpdate(`${error}`, true); + this.broadcastConnectUpdate(`${error}`, "error"); } else { - this.broadcastConnectUpdate("Unknown error has occurred", true); + this.broadcastConnectUpdate("Unknown error has occurred", "error"); } return ClusterStatus.Offline; @@ -636,8 +647,8 @@ export class Cluster implements ClusterModel { * broadcast an authentication update concerning this cluster * @internal */ - broadcastConnectUpdate(message: string, isError = false): void { - const update: KubeAuthUpdate = { message, isError }; + broadcastConnectUpdate(message: string, level: KubeAuthUpdate["level"] = "info"): void { + const update: KubeAuthUpdate = { message, level }; this.dependencies.logger.debug(`[CLUSTER]: broadcasting connection update`, { ...update, meta: this.getMeta() }); this.dependencies.broadcastMessage(`cluster:${this.id}:connection-update`, update); @@ -668,7 +679,7 @@ export class Cluster implements ClusterModel { } protected async getAllowedResources(requestNamespaceListPermissions: RequestNamespaceListPermissions) { - if (!this.allowedNamespaces.length) { + if (!this.allowedNamespaces.length || !this.knownResources.length) { return []; } diff --git a/packages/core/src/common/event-emitter.ts b/packages/core/src/common/event-emitter.ts index 03f8e2754b..982cbdadc8 100644 --- a/packages/core/src/common/event-emitter.ts +++ b/packages/core/src/common/event-emitter.ts @@ -5,23 +5,23 @@ // Custom event emitter -interface Options { +export interface EventEmitterOptions { once?: boolean; // call once and remove prepend?: boolean; // put listener to the beginning } -type Callback = (...data: D) => void | boolean; +export type EventEmitterCallback = (...data: D) => void | boolean; -export class EventEmitter { - protected listeners: [Callback, Options][] = []; +export class EventEmitter { + protected listeners: [EventEmitterCallback, EventEmitterOptions][] = []; - addListener(callback: Callback, options: Options = {}) { + addListener(callback: EventEmitterCallback, options: EventEmitterOptions = {}) { const fn = options.prepend ? "unshift" : "push"; this.listeners[fn]([callback, options]); } - removeListener(callback: Callback) { + removeListener(callback: EventEmitterCallback) { this.listeners = this.listeners.filter(([cb]) => cb !== callback); } diff --git a/packages/core/src/common/fetch/download-binary.injectable.ts b/packages/core/src/common/fetch/download-binary.injectable.ts index 27ef43d59b..b50c4e5782 100644 --- a/packages/core/src/common/fetch/download-binary.injectable.ts +++ b/packages/core/src/common/fetch/download-binary.injectable.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import type { RequestInit, Response } from "node-fetch"; +import type { RequestInit, Response } from "@k8slens/node-fetch"; import type { AsyncResult } from "../utils/async-result"; import fetchInjectable from "./fetch.injectable"; @@ -22,7 +22,6 @@ const downloadBinaryInjectable = getInjectable({ let result: Response; try { - // TODO: upgrade node-fetch once we switch to ESM result = await fetch(url, opts as RequestInit); } catch (error) { return { diff --git a/packages/core/src/common/fetch/download-json/impl.ts b/packages/core/src/common/fetch/download-json/impl.ts index 9faf9af124..bf5cf06a19 100644 --- a/packages/core/src/common/fetch/download-json/impl.ts +++ b/packages/core/src/common/fetch/download-json/impl.ts @@ -4,7 +4,7 @@ */ import type { AsyncResult } from "../../utils/async-result"; import type { Fetch } from "../fetch.injectable"; -import type { RequestInit, Response } from "node-fetch"; +import type { RequestInit, Response } from "@k8slens/node-fetch"; export interface DownloadJsonOptions { signal?: AbortSignal | null | undefined; diff --git a/packages/core/src/common/fetch/fetch-module.injectable.ts b/packages/core/src/common/fetch/fetch-module.injectable.ts deleted file mode 100644 index 444333f196..0000000000 --- a/packages/core/src/common/fetch/fetch-module.injectable.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import type * as FetchModule from "node-fetch"; - -const { NodeFetch } = require("../../../build/webpack/node-fetch.bundle") as { NodeFetch: typeof FetchModule }; - -/** - * NOTE: while using this module can cause side effects, this specific injectable is not marked as - * such since sometimes the request can be wholely within the perview of unit test - */ -const nodeFetchModuleInjectable = getInjectable({ - id: "node-fetch-module", - instantiate: () => NodeFetch, -}); - -export default nodeFetchModuleInjectable; diff --git a/packages/core/src/common/fetch/fetch.injectable.ts b/packages/core/src/common/fetch/fetch.injectable.ts index d4f51efe0d..82dd141964 100644 --- a/packages/core/src/common/fetch/fetch.injectable.ts +++ b/packages/core/src/common/fetch/fetch.injectable.ts @@ -3,18 +3,14 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import type { RequestInit, Response } from "node-fetch"; -import nodeFetchModuleInjectable from "./fetch-module.injectable"; +import type { RequestInit, Response } from "@k8slens/node-fetch"; +import fetch from "@k8slens/node-fetch"; export type Fetch = (url: string, init?: RequestInit) => Promise; const fetchInjectable = getInjectable({ id: "fetch", - instantiate: (di): Fetch => { - const { default: fetch } = di.inject(nodeFetchModuleInjectable); - - return (url, init) => fetch(url, init); - }, + instantiate: () => fetch as Fetch, causesSideEffects: true, }); diff --git a/packages/core/src/common/fetch/lens-fetch.injectable.ts b/packages/core/src/common/fetch/lens-fetch.injectable.ts index a90818e6cb..634081fda5 100644 --- a/packages/core/src/common/fetch/lens-fetch.injectable.ts +++ b/packages/core/src/common/fetch/lens-fetch.injectable.ts @@ -4,10 +4,10 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { Agent } from "https"; -import type { RequestInit, Response } from "node-fetch"; +import type { RequestInit, Response } from "@k8slens/node-fetch"; import lensProxyPortInjectable from "../../main/lens-proxy/lens-proxy-port.injectable"; import lensProxyCertificateInjectable from "../certificate/lens-proxy-certificate.injectable"; -import nodeFetchModuleInjectable from "./fetch-module.injectable"; +import fetch from "@k8slens/node-fetch"; export type LensRequestInit = Omit; @@ -16,7 +16,6 @@ export type LensFetch = (pathnameAndQuery: string, init?: LensRequestInit) => Pr const lensFetchInjectable = getInjectable({ id: "lens-fetch", instantiate: (di): LensFetch => { - const { default: fetch } = di.inject(nodeFetchModuleInjectable); const lensProxyPort = di.inject(lensProxyPortInjectable); const lensProxyCertificate = di.inject(lensProxyCertificateInjectable); diff --git a/packages/core/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts b/packages/core/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts index cd08ef4d5c..dd65f58762 100644 --- a/packages/core/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts +++ b/packages/core/src/common/k8s-api/__tests__/kube-api-version-detection.test.ts @@ -4,7 +4,7 @@ */ import type { ApiManager } from "../api-manager"; import type { IngressApi } from "../endpoints"; -import { Ingress } from "../endpoints"; +import { Ingress, HorizontalPodAutoscalerApi } from "../endpoints"; import { getDiForUnitTesting } from "../../../renderer/getDiForUnitTesting"; import type { Fetch } from "../../fetch/fetch.injectable"; import fetchInjectable from "../../fetch/fetch.injectable"; @@ -21,6 +21,8 @@ import directoryForKubeConfigsInjectable from "../../app-paths/directory-for-kub import apiManagerInjectable from "../api-manager/manager.injectable"; import type { DiContainer } from "@ogre-tools/injectable"; import ingressApiInjectable from "../endpoints/ingress.api.injectable"; +import loggerInjectable from "../../logger.injectable"; +import maybeKubeApiInjectable from "../maybe-kube-api.injectable"; describe("KubeApi", () => { let fetchMock: AsyncFnMock; @@ -705,4 +707,125 @@ describe("KubeApi", () => { }); }); }); + + describe("on first call to HorizontalPodAutoscalerApi.get()", () => { + let horizontalPodAutoscalerApi: HorizontalPodAutoscalerApi; + + beforeEach(async () => { + horizontalPodAutoscalerApi = new HorizontalPodAutoscalerApi({ + logger: di.inject(loggerInjectable), + maybeKubeApi: di.inject(maybeKubeApiInjectable), + }, { + allowedUsableVersions: { + autoscaling: [ + "v2", + "v2beta2", + "v2beta1", + "v1", + ], + }, + }); + horizontalPodAutoscalerApi.get({ + name: "foo", + namespace: "default", + }); + + // This is needed because of how JS promises work + await flushPromises(); + }); + + it("requests version list from the api group from the initial apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "https://127.0.0.1:12345/api-kube/apis/autoscaling", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the version list from the api group resolves with preferredVersion in allowed version", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["https://127.0.0.1:12345/api-kube/apis/autoscaling"], + createMockResponseFromString("https://127.0.0.1:12345/api-kube/apis/autoscaling", JSON.stringify({ + apiVersion: "v1", + kind: "APIGroup", + name: "autoscaling", + versions: [ + { + groupVersion: "autoscaling/v1", + version: "v1", + }, + { + groupVersion: "autoscaling/v1beta1", + version: "v2beta1", + }, + ], + preferredVersion: { + groupVersion: "autoscaling/v1", + version: "v1", + }, + })), + ); + }); + + it("requests resources from the preferred version api group from the initial apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "https://127.0.0.1:12345/api-kube/apis/autoscaling/v1", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + }); + + describe("when the version list from the api group resolves with preferredVersion not allowed version", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["https://127.0.0.1:12345/api-kube/apis/autoscaling"], + createMockResponseFromString("https://127.0.0.1:12345/api-kube/apis/autoscaling", JSON.stringify({ + apiVersion: "v1", + kind: "APIGroup", + name: "autoscaling", + versions: [ + { + groupVersion: "autoscaling/v2", + version: "v2", + }, + { + groupVersion: "autoscaling/v2beta1", + version: "v2beta1", + }, + { + groupVersion: "autoscaling/v3", + version: "v3", + }, + ], + preferredVersion: { + groupVersion: "autoscaling/v3", + version: "v3", + }, + })), + ); + }); + + it("requests resources from the non preferred version from the initial apiBase", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "https://127.0.0.1:12345/api-kube/apis/autoscaling/v2", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + }); + }); }); diff --git a/packages/core/src/common/k8s-api/create-json-api.injectable.ts b/packages/core/src/common/k8s-api/create-json-api.injectable.ts index 7f8559bf3a..be5b49246d 100644 --- a/packages/core/src/common/k8s-api/create-json-api.injectable.ts +++ b/packages/core/src/common/k8s-api/create-json-api.injectable.ts @@ -4,7 +4,7 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { Agent } from "https"; -import type { RequestInit } from "node-fetch"; +import type { RequestInit } from "@k8slens/node-fetch"; import lensProxyCertificateInjectable from "../certificate/lens-proxy-certificate.injectable"; import fetchInjectable from "../fetch/fetch.injectable"; import loggerInjectable from "../logger.injectable"; diff --git a/packages/core/src/common/k8s-api/create-kube-api-for-remote-cluster.injectable.ts b/packages/core/src/common/k8s-api/create-kube-api-for-remote-cluster.injectable.ts index 8b39387d1d..bd7d2b1630 100644 --- a/packages/core/src/common/k8s-api/create-kube-api-for-remote-cluster.injectable.ts +++ b/packages/core/src/common/k8s-api/create-kube-api-for-remote-cluster.injectable.ts @@ -5,7 +5,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import type { AgentOptions } from "https"; import { Agent } from "https"; -import type { RequestInit } from "node-fetch"; +import type { RequestInit } from "@k8slens/node-fetch"; import loggerInjectable from "../logger.injectable"; import isDevelopmentInjectable from "../vars/is-development.injectable"; import createKubeJsonApiInjectable from "./create-kube-json-api.injectable"; diff --git a/packages/core/src/common/k8s-api/create-kube-json-api.injectable.ts b/packages/core/src/common/k8s-api/create-kube-json-api.injectable.ts index f7b5a152ee..eb0a72cc5f 100644 --- a/packages/core/src/common/k8s-api/create-kube-json-api.injectable.ts +++ b/packages/core/src/common/k8s-api/create-kube-json-api.injectable.ts @@ -4,7 +4,7 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { Agent } from "https"; -import type { RequestInit } from "node-fetch"; +import type { RequestInit } from "@k8slens/node-fetch"; import lensProxyCertificateInjectable from "../certificate/lens-proxy-certificate.injectable"; import fetchInjectable from "../fetch/fetch.injectable"; import loggerInjectable from "../logger.injectable"; diff --git a/packages/core/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts b/packages/core/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts index ba5483da2c..12ef92165c 100644 --- a/packages/core/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts +++ b/packages/core/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts @@ -371,6 +371,14 @@ export class HorizontalPodAutoscaler extends KubeObject< export class HorizontalPodAutoscalerApi extends KubeApi { constructor(deps: KubeApiDependencies, opts?: DerivedKubeApiOptions) { super(deps, { + allowedUsableVersions: { + autoscaling: [ + "v2", + "v2beta2", + "v2beta1", + "v1", + ], + }, ...opts ?? {}, objectConstructor: HorizontalPodAutoscaler, checkPreferredVersion: true, diff --git a/packages/core/src/common/k8s-api/endpoints/pod-disruption-budget.api.injectable.ts b/packages/core/src/common/k8s-api/endpoints/pod-disruption-budget.api.injectable.ts index 28ecff01b1..68d830ee69 100644 --- a/packages/core/src/common/k8s-api/endpoints/pod-disruption-budget.api.injectable.ts +++ b/packages/core/src/common/k8s-api/endpoints/pod-disruption-budget.api.injectable.ts @@ -18,6 +18,14 @@ const podDisruptionBudgetApiInjectable = getInjectable({ return new PodDisruptionBudgetApi({ logger: di.inject(loggerInjectable), maybeKubeApi: di.inject(maybeKubeApiInjectable), + }, { + checkPreferredVersion: true, + allowedUsableVersions: { + policy: [ + "v1", + "v1beta1", + ], + }, }); }, diff --git a/packages/core/src/common/k8s-api/endpoints/pod-disruption-budget.api.ts b/packages/core/src/common/k8s-api/endpoints/pod-disruption-budget.api.ts index 0010bbb642..e94de01abd 100644 --- a/packages/core/src/common/k8s-api/endpoints/pod-disruption-budget.api.ts +++ b/packages/core/src/common/k8s-api/endpoints/pod-disruption-budget.api.ts @@ -7,20 +7,45 @@ import type { LabelSelector, NamespaceScopedMetadata } from "../kube-object"; import { KubeObject } from "../kube-object"; import type { DerivedKubeApiOptions, KubeApiDependencies } from "../kube-api"; import { KubeApi } from "../kube-api"; +import type { Condition } from "./types/condition"; -export interface PodDisruptionBudgetSpec { +export interface V1Beta1PodDisruptionBudgetSpec { minAvailable: string; maxUnavailable: string; selector: LabelSelector; } -export interface PodDisruptionBudgetStatus { +export interface V1PodDisruptionBudgetSpec { + maxUnavailable?: string | number; + minAvailable?: string | number; + selector?: LabelSelector; +} + +export type PodDisruptionBudgetSpec = + | V1Beta1PodDisruptionBudgetSpec + | V1PodDisruptionBudgetSpec; + +export interface V1Beta1PodDisruptionBudgetStatus { currentHealthy: number; desiredHealthy: number; disruptionsAllowed: number; expectedPods: number; } +export interface V1PodDisruptionBudgetStatus { + conditions?: Condition[]; + currentHealthy: number; + desiredHealthy: number; + disruptedPods?: Partial>; + disruptionsAllowed: number; + expectedPods: number; + observedGeneration?: number; +} + +export type PodDisruptionBudgetStatus = + | V1Beta1PodDisruptionBudgetStatus + | V1PodDisruptionBudgetStatus; + export class PodDisruptionBudget extends KubeObject< NamespaceScopedMetadata, PodDisruptionBudgetStatus, @@ -31,7 +56,7 @@ export class PodDisruptionBudget extends KubeObject< static readonly apiBase = "/apis/policy/v1beta1/poddisruptionbudgets"; getSelectors() { - return KubeObject.stringifyLabels(this.spec.selector.matchLabels); + return KubeObject.stringifyLabels(this.spec.selector?.matchLabels); } getMinAvailable() { diff --git a/packages/core/src/common/k8s-api/endpoints/types/condition.ts b/packages/core/src/common/k8s-api/endpoints/types/condition.ts new file mode 100644 index 0000000000..81bf191194 --- /dev/null +++ b/packages/core/src/common/k8s-api/endpoints/types/condition.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export interface Condition { + lastTransitionTime: string; + message: string; + observedGeneration?: number; + reason: string; + status: string; + type: string; +} diff --git a/packages/core/src/common/k8s-api/json-api.ts b/packages/core/src/common/k8s-api/json-api.ts index 988378641c..c99fddf4f6 100644 --- a/packages/core/src/common/k8s-api/json-api.ts +++ b/packages/core/src/common/k8s-api/json-api.ts @@ -8,7 +8,7 @@ import { Agent as HttpAgent } from "http"; import { Agent as HttpsAgent } from "https"; import { merge } from "lodash"; -import type { Response, RequestInit } from "node-fetch"; +import type { Response, RequestInit } from "@k8slens/node-fetch"; import { stringify } from "querystring"; import type { Patch } from "rfc6902"; import type { PartialDeep, ValueOf } from "type-fest"; diff --git a/packages/core/src/common/k8s-api/kube-api.ts b/packages/core/src/common/k8s-api/kube-api.ts index 8e6e86d7da..9db60b5484 100644 --- a/packages/core/src/common/k8s-api/kube-api.ts +++ b/packages/core/src/common/k8s-api/kube-api.ts @@ -15,7 +15,7 @@ import type { IKubeWatchEvent } from "./kube-watch-event"; import type { KubeJsonApiData, KubeJsonApi } from "./kube-json-api"; import type { Disposer } from "../utils"; import { isDefined, noop, WrappedAbortController } from "../utils"; -import type { RequestInit, Response } from "node-fetch"; +import type { RequestInit, Response } from "@k8slens/node-fetch"; import type { Patch } from "rfc6902"; import assert from "assert"; import type { PartialDeep } from "type-fest"; @@ -58,14 +58,32 @@ export interface DerivedKubeApiOptions { /** * If the API uses a different API endpoint (e.g. apiBase) depending on the cluster version, * fallback API bases can be listed individually. + * * The first (existing) API base is used in the requests, if apiBase is not found. - * This option only has effect if checkPreferredVersion is true. + * + * This option only has effect if {@link DerivedKubeApiOptions.checkPreferredVersion} is `true`. */ fallbackApiBases?: string[]; + /** + * This option is useful for protecting against newer versions on the same apiBase from being + * used. So that if a certain type only supports `v1`, or `v2` of some kind and then the `v3` + * version becomes the `preferredVersion` on the server but still has `v2` then the `v2` version + * will be used instead. + * + * This can help to prevent crashes in the future if the shape of a kind sufficently changes. + * + * The order is important. It should be sorted and the first entry should be the most preferable. + * + * This option only has effect if {@link DerivedKubeApiOptions.checkPreferredVersion} is `true` + */ + allowedUsableVersions?: Partial>; + /** * If `true` then will check all declared apiBases against the kube api server * for the first accepted one. + * + * @default false */ checkPreferredVersion?: boolean; @@ -133,10 +151,10 @@ export interface KubeApiResourceVersionList { const not = (fn: (val: T) => boolean) => (val: T) => !(fn(val)); -const getOrderedVersions = (list: KubeApiResourceVersionList): KubeApiResourceVersion[] => [ +const getOrderedVersions = (list: KubeApiResourceVersionList, allowedUsableVersions: string[] | undefined): KubeApiResourceVersion[] => [ list.preferredVersion, ...list.versions.filter(not(matches(list.preferredVersion))), -]; +].filter(({ version }) => !allowedUsableVersions || allowedUsableVersions.includes(version)); export type PropagationPolicy = undefined | "Orphan" | "Foreground" | "Background"; @@ -233,6 +251,7 @@ export class KubeApi< protected readonly doCheckPreferredVersion: boolean; protected readonly fullApiPathname: string; protected readonly fallbackApiBases: string[] | undefined; + protected readonly allowedUsableVersions: Partial> | undefined; constructor(protected readonly dependencies: KubeApiDependencies, opts: KubeApiOptions) { const { @@ -243,6 +262,7 @@ export class KubeApi< apiBase: fullApiPathname = objectConstructor.apiBase, checkPreferredVersion: doCheckPreferredVersion = false, fallbackApiBases, + allowedUsableVersions, } = opts; assert(fullApiPathname, "apiBase MUST be provied either via KubeApiOptions.apiBase or KubeApiOptions.objectConstructor.apiBase"); @@ -255,6 +275,7 @@ export class KubeApi< this.doCheckPreferredVersion = doCheckPreferredVersion; this.fallbackApiBases = fallbackApiBases; + this.allowedUsableVersions = allowedUsableVersions; this.fullApiPathname = fullApiPathname; this.kind = kind; this.isNamespaced = isNamespaced ?? objectConstructor.namespaced ?? false; @@ -291,7 +312,7 @@ export class KubeApi< try { const { apiPrefix, apiGroup, resource } = parseKubeApi(apiUrl); const list = await this.request.get(`${apiPrefix}/${apiGroup}`) as KubeApiResourceVersionList; - const resourceVersions = getOrderedVersions(list); + const resourceVersions = getOrderedVersions(list, this.allowedUsableVersions?.[apiGroup]); for (const resourceVersion of resourceVersions) { const { resources } = await this.request.get(`${apiPrefix}/${resourceVersion.groupVersion}`) as KubeApiResourceList; @@ -313,8 +334,8 @@ export class KubeApi< } protected async checkPreferredVersion() { - if (this.fallbackApiBases && !this.doCheckPreferredVersion) { - throw new Error("checkPreferredVersion must be enabled if fallbackApiBases is set in KubeApi"); + if (!this.doCheckPreferredVersion && (this.fallbackApiBases || this.allowedUsableVersions)) { + throw new Error("checkPreferredVersion must be enabled if either fallbackApiBases or allowedUsableVersions are set in KubeApi"); } if (this.doCheckPreferredVersion && this.apiVersionPreferred === undefined) { diff --git a/packages/core/src/common/k8s-api/kube-json-api.ts b/packages/core/src/common/k8s-api/kube-json-api.ts index 16ca5cda70..1a88495dfb 100644 --- a/packages/core/src/common/k8s-api/kube-json-api.ts +++ b/packages/core/src/common/k8s-api/kube-json-api.ts @@ -5,7 +5,7 @@ import type { JsonApiData, JsonApiError } from "./json-api"; import { JsonApi } from "./json-api"; -import type { Response } from "node-fetch"; +import type { Response } from "@k8slens/node-fetch"; import type { KubeJsonApiObjectMetadata } from "./kube-object"; export interface KubeJsonApiListMetadata { diff --git a/packages/core/src/common/k8s-api/kube-object.store.ts b/packages/core/src/common/k8s-api/kube-object.store.ts index ee6a32265e..571e395a7b 100644 --- a/packages/core/src/common/k8s-api/kube-object.store.ts +++ b/packages/core/src/common/k8s-api/kube-object.store.ts @@ -12,7 +12,7 @@ import type { IKubeWatchEvent } from "./kube-watch-event"; import { ItemStore } from "../item.store"; import type { KubeApiQueryParams, KubeApi, KubeApiWatchCallback } from "./kube-api"; import { parseKubeApi } from "./kube-api-parse"; -import type { RequestInit } from "node-fetch"; +import type { RequestInit } from "@k8slens/node-fetch"; import type { Patch } from "rfc6902"; import type { Logger } from "../logger"; import assert from "assert"; diff --git a/packages/core/src/common/utils/backoff-caller.ts b/packages/core/src/common/utils/backoff-caller.ts new file mode 100644 index 0000000000..131565bb09 --- /dev/null +++ b/packages/core/src/common/utils/backoff-caller.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { AsyncResult } from "./async-result"; +import { delay } from "./delay"; +import { noop } from "./noop"; + +/** + * @param error The error that resulted in the failure + * @param attempt The 1-index attempt count + */ +export type OnIntermediateError = (error: E, attempt: number) => void; + +export interface BackoffCallerOptions { + /** + * Called when an attempt fails + */ + onIntermediateError?: OnIntermediateError; + + /** + * @default 5 + */ + maxAttempts?: number; + + /** + * In miliseconds + * @default 1000 + */ + initialTimeout?: number; + + /** + * @default 2 + */ + scaleFactor?: number; +} + +/** + * Calls `fn` once and then again (with exponential delay between each attempt) up to `options.maxAttempts` times. + * @param fn The function to repeatedly attempt + * @returns The first success or the last failure + */ +export const backoffCaller = async >(fn: () => Promise, options?: BackoffCallerOptions): Promise => { + const { + initialTimeout = 1000, + maxAttempts = 5, + onIntermediateError = noop as OnIntermediateError, + scaleFactor = 2, + } = options ?? {}; + + let timeout = initialTimeout; + let attempt = 0; + let result: R; + + do { + result = await fn(); + + if (result.callWasSuccessful) { + return result; + } + + onIntermediateError(result.error, attempt + 1); + + await delay(timeout); + timeout *= scaleFactor; + } while (attempt += 1, attempt < maxAttempts); + + return result; +}; diff --git a/packages/core/src/extensions/common-api/app.ts b/packages/core/src/extensions/common-api/app.ts index 92ecd19ef9..dd9227cd0e 100644 --- a/packages/core/src/extensions/common-api/app.ts +++ b/packages/core/src/extensions/common-api/app.ts @@ -11,26 +11,17 @@ import isWindowsInjectable from "../../common/vars/is-windows.injectable"; import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; import { getLegacyGlobalDiForExtensionApi } from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import getEnabledExtensionsInjectable from "./get-enabled-extensions/get-enabled-extensions.injectable"; -import type { UserPreferenceExtensionItems } from "./user-preferences"; -import { Preferences } from "./user-preferences"; import { slackUrl, issuesTrackerUrl } from "../../common/vars"; import { buildVersionInjectionToken } from "../../common/vars/build-semantic-version.injectable"; +import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import userStoreInjectable from "../../common/user-store/user-store.injectable"; -export interface AppExtensionItems { - readonly Preferences: UserPreferenceExtensionItems; - readonly version: string; - readonly appName: string; - readonly slackUrl: string; - readonly issuesTrackerUrl: string; - readonly isSnap: boolean; - readonly isWindows: boolean; - readonly isMac: boolean; - readonly isLinux: boolean; - getEnabledExtensions: () => string[]; -} +const userStore = asLegacyGlobalForExtensionApi(userStoreInjectable); -export const App: AppExtensionItems = { - Preferences, +export const App = { + Preferences: { + getKubectlPath: () => userStore.kubectlBinariesPath, + }, getEnabledExtensions: asLegacyGlobalFunctionForExtensionApi(getEnabledExtensionsInjectable), get version() { const di = getLegacyGlobalDiForExtensionApi(); @@ -64,4 +55,4 @@ export const App: AppExtensionItems = { }, slackUrl, issuesTrackerUrl, -}; +} as const; diff --git a/packages/core/src/extensions/common-api/catalog.ts b/packages/core/src/extensions/common-api/catalog.ts index b31059d2c1..26064adc30 100644 --- a/packages/core/src/extensions/common-api/catalog.ts +++ b/packages/core/src/extensions/common-api/catalog.ts @@ -3,6 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { KubernetesClusterCategory } from "../../common/catalog-entities/kubernetes-cluster"; import kubernetesClusterCategoryInjectable from "../../common/catalog/categories/kubernetes-cluster.injectable"; import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; @@ -12,6 +13,10 @@ export { WebLink, } from "../../common/catalog-entities"; +export type { + KubernetesClusterCategory, +}; + export const kubernetesClusterCategory = asLegacyGlobalForExtensionApi(kubernetesClusterCategoryInjectable); export type { @@ -23,6 +28,7 @@ export type { WebLinkStatusPhase, KubernetesClusterStatusPhase, KubernetesClusterStatus, + GeneralEntitySpec, } from "../../common/catalog-entities"; export * from "../../common/catalog/catalog-entity"; diff --git a/packages/core/src/extensions/common-api/event-bus.ts b/packages/core/src/extensions/common-api/event-bus.ts index d95e3f49d4..31c5446295 100644 --- a/packages/core/src/extensions/common-api/event-bus.ts +++ b/packages/core/src/extensions/common-api/event-bus.ts @@ -5,7 +5,14 @@ import appEventBusInjectable from "../../common/app-event-bus/app-event-bus.injectable"; import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import type { AppEvent } from "../../common/app-event-bus/event-bus"; +import type { EventEmitter, EventEmitterCallback, EventEmitterOptions } from "../../common/event-emitter"; -export type { AppEvent } from "../../common/app-event-bus/event-bus"; +export type { + AppEvent, + EventEmitter, + EventEmitterCallback, + EventEmitterOptions, +}; export const appEventBus = asLegacyGlobalForExtensionApi(appEventBusInjectable); diff --git a/packages/core/src/extensions/common-api/index.ts b/packages/core/src/extensions/common-api/index.ts index 01e41ca11c..fb0bcf9fcb 100644 --- a/packages/core/src/extensions/common-api/index.ts +++ b/packages/core/src/extensions/common-api/index.ts @@ -13,6 +13,8 @@ import * as Types from "./types"; import * as Proxy from "./proxy"; import loggerInjectable from "../../common/logger.injectable"; import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import type { Logger } from "../../common/logger"; +import type { LensExtension, LensExtensionManifest } from "../lens-extension"; const logger = asLegacyGlobalForExtensionApi(loggerInjectable); @@ -25,4 +27,7 @@ export { Util, logger, Proxy, + Logger, + LensExtension, + LensExtensionManifest, }; diff --git a/packages/core/src/extensions/common-api/k8s-api.ts b/packages/core/src/extensions/common-api/k8s-api.ts index d4b0ab673f..c8b04e3ed9 100644 --- a/packages/core/src/extensions/common-api/k8s-api.ts +++ b/packages/core/src/extensions/common-api/k8s-api.ts @@ -30,7 +30,7 @@ import { storesAndApisCanBeCreatedInjectionToken } from "../../common/k8s-api/st import type { JsonApiConfig } from "../../common/k8s-api/json-api"; import type { KubeJsonApi as InternalKubeJsonApi } from "../../common/k8s-api/kube-json-api"; import createKubeJsonApiInjectable from "../../common/k8s-api/create-kube-json-api.injectable"; -import type { RequestInit } from "node-fetch"; +import type { RequestInit } from "@k8slens/node-fetch"; import createKubeJsonApiForClusterInjectable from "../../common/k8s-api/create-kube-json-api-for-cluster.injectable"; export const apiManager = asLegacyGlobalForExtensionApi(apiManagerInjectable); diff --git a/packages/core/src/extensions/common-api/stores.ts b/packages/core/src/extensions/common-api/stores.ts index b369579d70..023fc5c7a4 100644 --- a/packages/core/src/extensions/common-api/stores.ts +++ b/packages/core/src/extensions/common-api/stores.ts @@ -3,4 +3,10 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export { ExtensionStore } from "../extension-store"; +import type { BaseStoreParams } from "../../common/base-store/base-store"; +import { ExtensionStore } from "../extension-store"; + +export { + BaseStoreParams, + ExtensionStore, +}; diff --git a/packages/core/src/extensions/common-api/user-preferences.ts b/packages/core/src/extensions/common-api/user-preferences.ts deleted file mode 100644 index ed925bd05d..0000000000 --- a/packages/core/src/extensions/common-api/user-preferences.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import userStoreInjectable from "../../common/user-store/user-store.injectable"; -import { asLegacyGlobalForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; -export interface UserPreferenceExtensionItems { - /** - * Get the configured kubectl binaries path. - */ - getKubectlPath: () => string | undefined; -} - -const userStore = asLegacyGlobalForExtensionApi(userStoreInjectable); - -export const Preferences: UserPreferenceExtensionItems = { - getKubectlPath: () => userStore.kubectlBinariesPath, -}; diff --git a/packages/core/src/extensions/common-api/utils.ts b/packages/core/src/extensions/common-api/utils.ts index a6dfdee447..077ebe4ad5 100644 --- a/packages/core/src/extensions/common-api/utils.ts +++ b/packages/core/src/extensions/common-api/utils.ts @@ -9,20 +9,9 @@ import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for- import { getLegacyGlobalDiForExtensionApi } from "../as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import { Singleton } from "../../common/utils"; import { prevDefault, stopPropagation } from "../../renderer/utils/prevDefault"; -import type { IClassName } from "../../renderer/utils/cssNames"; import { cssNames } from "../../renderer/utils/cssNames"; -export interface UtilsExtensionItems { - Singleton: typeof Singleton; - prevDefault: (callback: (evt: E) => R) => (evt: E) => R; - stopPropagation: (evt: Event | React.SyntheticEvent) => void; - cssNames: (...classNames: IClassName[]) => string; - openExternal: (url: string) => Promise; - openBrowser: (url: string) => Promise; - getAppVersion: () => string; -} - -export const Util: UtilsExtensionItems = { +export const Util = { Singleton, prevDefault, stopPropagation, @@ -34,4 +23,4 @@ export const Util: UtilsExtensionItems = { return di.inject(buildVersionInjectable).get(); }, -}; +} as const; diff --git a/packages/core/src/extensions/lens-extension.ts b/packages/core/src/extensions/lens-extension.ts index 831822fd2b..77a9bf908c 100644 --- a/packages/core/src/extensions/lens-extension.ts +++ b/packages/core/src/extensions/lens-extension.ts @@ -32,7 +32,12 @@ export interface LensExtensionManifest extends PackageJson { export const lensExtensionDependencies = Symbol("lens-extension-dependencies"); export const Disposers = Symbol("disposers"); -export class LensExtension { +export class LensExtension< + /** + * @ignore + */ + Dependencies extends LensExtensionDependencies = LensExtensionDependencies, +> { readonly id: LensExtensionId; readonly manifest: LensExtensionManifest; readonly manifestPath: string; @@ -50,6 +55,9 @@ export class LensExtension { await proxy.run(); listeners.emit("error", { message: "foobarbat" }); - expect(broadcastMessageMock).toBeCalledWith("cluster:foobar:connection-update", { message: "foobarbat", isError: true }); + expect(broadcastMessageMock).toBeCalledWith("cluster:foobar:connection-update", { message: "foobarbat", level: "error" }); }); it("should call spawn and broadcast exit", async () => { await proxy.run(); listeners.emit("exit", 0); - expect(broadcastMessageMock).toBeCalledWith("cluster:foobar:connection-update", { message: "proxy exited with code: 0", isError: false }); + expect(broadcastMessageMock).toBeCalledWith("cluster:foobar:connection-update", { message: "proxy exited with code: 0", level: "info" }); }); it("should call spawn and broadcast errors from stderr", async () => { await proxy.run(); listeners.emit("stderr/data", "an error"); - expect(broadcastMessageMock).toBeCalledWith("cluster:foobar:connection-update", { message: "an error", isError: true }); + expect(broadcastMessageMock).toBeCalledWith("cluster:foobar:connection-update", { message: "an error", level: "error" }); }); it("should call spawn and broadcast stdout serving info", async () => { await proxy.run(); - expect(broadcastMessageMock).toBeCalledWith("cluster:foobar:connection-update", { message: "Authentication proxy started", isError: false }); + expect(broadcastMessageMock).toBeCalledWith("cluster:foobar:connection-update", { message: "Authentication proxy started", level: "info" }); }); it("should call spawn and broadcast stdout other info", async () => { await proxy.run(); listeners.emit("stdout/data", "some info"); - expect(broadcastMessageMock).toBeCalledWith("cluster:foobar:connection-update", { message: "some info", isError: false }); + expect(broadcastMessageMock).toBeCalledWith("cluster:foobar:connection-update", { message: "some info", level: "info" }); }); }); }); diff --git a/packages/core/src/main/cluster/request-api-resources.injectable.ts b/packages/core/src/main/cluster/request-api-resources.injectable.ts index bc996add60..552bd47800 100644 --- a/packages/core/src/main/cluster/request-api-resources.injectable.ts +++ b/packages/core/src/main/cluster/request-api-resources.injectable.ts @@ -10,8 +10,10 @@ import type { Cluster } from "../../common/cluster/cluster"; import { requestApiVersionsInjectionToken } from "./request-api-versions"; import { withConcurrencyLimit } from "../../common/utils/with-concurrency-limit"; import requestKubeApiResourcesForInjectable from "./request-kube-api-resources-for.injectable"; +import type { AsyncResult } from "../../common/utils/async-result"; +import { backoffCaller } from "../../common/utils/backoff-caller"; -export type RequestApiResources = (cluster: Cluster) => Promise; +export type RequestApiResources = (cluster: Cluster) => Promise>; export interface KubeResourceListGroup { group: string; @@ -25,23 +27,46 @@ const requestApiResourcesInjectable = getInjectable({ const apiVersionRequesters = di.injectMany(requestApiVersionsInjectionToken); const requestKubeApiResourcesFor = di.inject(requestKubeApiResourcesForInjectable); - return async (cluster) => { + return async (...args) => { + const [cluster] = args; const requestKubeApiResources = withConcurrencyLimit(5)(requestKubeApiResourcesFor(cluster)); - try { - const requests = await Promise.all(apiVersionRequesters.map(fn => fn(cluster))); - const resources = await Promise.all(( - requests - .flat() - .map(requestKubeApiResources) - )); + const groupLists: KubeResourceListGroup[] = []; - return resources.flat(); - } catch (error) { - logger.error(`[LIST-API-RESOURCES]: failed to list api resources: ${error}`); + for (const apiVersionRequester of apiVersionRequesters) { + const result = await backoffCaller(() => apiVersionRequester(cluster), { + onIntermediateError: (error, attempt) => { + cluster.broadcastConnectUpdate(`Failed to list kube API resource kinds, attempt ${attempt}: ${error}`, "warning"); + logger.warn(`[LIST-API-RESOURCES]: failed to list kube api resources: ${error}`, { attempt, clusterId: cluster.id }); + }, + }); - return []; + if (!result.callWasSuccessful) { + return result; + } + + groupLists.push(...result.response); } + + const apiResourceRequests = groupLists.map(async listGroup => ( + Object.assign(await requestKubeApiResources(listGroup), { listGroup }) + )); + const results = await Promise.all(apiResourceRequests); + const resources: KubeApiResource[] = []; + + for (const result of results) { + if (!result.callWasSuccessful) { + cluster.broadcastConnectUpdate(`Kube APIs under "${result.listGroup.path}" may not be displayed`, "warning"); + continue; + } + + resources.push(...result.response); + } + + return { + callWasSuccessful: true, + response: resources, + }; }; }, }); diff --git a/packages/core/src/main/cluster/request-api-versions.ts b/packages/core/src/main/cluster/request-api-versions.ts index af7bc7f232..be9ec2c21a 100644 --- a/packages/core/src/main/cluster/request-api-versions.ts +++ b/packages/core/src/main/cluster/request-api-versions.ts @@ -5,13 +5,14 @@ import { getInjectionToken } from "@ogre-tools/injectable"; import type { Cluster } from "../../common/cluster/cluster"; +import type { AsyncResult } from "../../common/utils/async-result"; export interface KubeResourceListGroup { group: string; path: string; } -export type RequestApiVersions = (cluster: Cluster) => Promise; +export type RequestApiVersions = (cluster: Cluster) => Promise>; export const requestApiVersionsInjectionToken = getInjectionToken({ id: "request-api-versions-token", diff --git a/packages/core/src/main/cluster/request-core-api-versions.injectable.ts b/packages/core/src/main/cluster/request-core-api-versions.injectable.ts index 4287eb4d3b..b709eb9356 100644 --- a/packages/core/src/main/cluster/request-core-api-versions.injectable.ts +++ b/packages/core/src/main/cluster/request-core-api-versions.injectable.ts @@ -13,12 +13,22 @@ const requestCoreApiVersionsInjectable = getInjectable({ const k8sRequest = di.inject(k8sRequestInjectable); return async (cluster) => { - const { versions } = await k8sRequest(cluster, "/api") as V1APIVersions; + try { + const { versions } = await k8sRequest(cluster, "/api") as V1APIVersions; - return versions.map(version => ({ - group: "", - path: `/api/${version}`, - })); + return { + callWasSuccessful: true, + response: versions.map(version => ({ + group: "", + path: `/api/${version}`, + })), + }; + } catch (error) { + return { + callWasSuccessful: false, + error: error as Error, + }; + } }; }, injectionToken: requestApiVersionsInjectionToken, diff --git a/packages/core/src/main/cluster/request-kube-api-resources-for.injectable.ts b/packages/core/src/main/cluster/request-kube-api-resources-for.injectable.ts index dfe1345db1..681f4ac013 100644 --- a/packages/core/src/main/cluster/request-kube-api-resources-for.injectable.ts +++ b/packages/core/src/main/cluster/request-kube-api-resources-for.injectable.ts @@ -6,10 +6,11 @@ import type { V1APIResourceList } from "@kubernetes/client-node"; import { getInjectable } from "@ogre-tools/injectable"; import type { Cluster } from "../../common/cluster/cluster"; import type { KubeApiResource } from "../../common/rbac"; +import type { AsyncResult } from "../../common/utils/async-result"; import k8sRequestInjectable from "../k8s-request.injectable"; import type { KubeResourceListGroup } from "./request-api-versions"; -export type RequestKubeApiResources = (grouping: KubeResourceListGroup) => Promise; +export type RequestKubeApiResources = (grouping: KubeResourceListGroup) => Promise>; export type RequestKubeApiResourcesFor = (cluster: Cluster) => RequestKubeApiResources; @@ -19,14 +20,24 @@ const requestKubeApiResourcesForInjectable = getInjectable({ const k8sRequest = di.inject(k8sRequestInjectable); return (cluster) => async ({ group, path }) => { - const { resources } = await k8sRequest(cluster, path) as V1APIResourceList; + try { + const { resources } = await k8sRequest(cluster, path) as V1APIResourceList; - return resources.map(resource => ({ - apiName: resource.name, - kind: resource.kind, - group, - namespaced: resource.namespaced, - })); + return { + callWasSuccessful: true, + response: resources.map(resource => ({ + apiName: resource.name, + kind: resource.kind, + group, + namespaced: resource.namespaced, + })), + }; + } catch (error) { + return { + callWasSuccessful: false, + error: error as Error, + }; + } }; }, }); diff --git a/packages/core/src/main/cluster/request-non-core-api-versions.injectable.ts b/packages/core/src/main/cluster/request-non-core-api-versions.injectable.ts index 5ca9e1495b..e5b241c75f 100644 --- a/packages/core/src/main/cluster/request-non-core-api-versions.injectable.ts +++ b/packages/core/src/main/cluster/request-non-core-api-versions.injectable.ts @@ -14,14 +14,24 @@ const requestNonCoreApiVersionsInjectable = getInjectable({ const k8sRequest = di.inject(k8sRequestInjectable); return async (cluster) => { - const { groups } = await k8sRequest(cluster, "/apis") as V1APIGroupList; + try { + const { groups } = await k8sRequest(cluster, "/apis") as V1APIGroupList; - return chain(groups.values()) - .filterMap(group => group.preferredVersion?.groupVersion && ({ - group: group.name, - path: `/apis/${group.preferredVersion.groupVersion}`, - })) - .collect(v => [...v]); + return { + callWasSuccessful: true, + response: chain(groups.values()) + .filterMap(group => group.preferredVersion?.groupVersion && ({ + group: group.name, + path: `/apis/${group.preferredVersion.groupVersion}`, + })) + .collect(v => [...v]), + }; + } catch (error) { + return { + callWasSuccessful: false, + error: error as Error, + }; + } }; }, injectionToken: requestApiVersionsInjectionToken, diff --git a/packages/core/src/main/extension-api.ts b/packages/core/src/main/extension-api.ts index 2fd6bfb5f1..5991911aa0 100644 --- a/packages/core/src/main/extension-api.ts +++ b/packages/core/src/main/extension-api.ts @@ -19,6 +19,10 @@ const LensExtensions = { Main: LensExtensionsMainApi, }; +export type { + LensExtensionsMainApi, +}; + const Pty = { spawn, }; diff --git a/packages/core/src/main/get-metrics.injectable.ts b/packages/core/src/main/get-metrics.injectable.ts index 7a17ce3884..3731435f6b 100644 --- a/packages/core/src/main/get-metrics.injectable.ts +++ b/packages/core/src/main/get-metrics.injectable.ts @@ -4,7 +4,7 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import type { Cluster } from "../common/cluster/cluster"; -import nodeFetchModuleInjectable from "../common/fetch/fetch-module.injectable"; +import { FormData } from "@k8slens/node-fetch"; import type { RequestMetricsParams } from "../common/k8s-api/endpoints/metrics.api/request-metrics.injectable"; import { object } from "../common/utils"; import k8sRequestInjectable from "./k8s-request.injectable"; @@ -16,7 +16,6 @@ const getMetricsInjectable = getInjectable({ instantiate: (di): GetMetrics => { const k8sRequest = di.inject(k8sRequestInjectable); - const { FormData } = di.inject(nodeFetchModuleInjectable); return async ( cluster, diff --git a/packages/core/src/main/kube-auth-proxy/kube-auth-proxy.ts b/packages/core/src/main/kube-auth-proxy/kube-auth-proxy.ts index 1d669f28c9..30e84988db 100644 --- a/packages/core/src/main/kube-auth-proxy/kube-auth-proxy.ts +++ b/packages/core/src/main/kube-auth-proxy/kube-auth-proxy.ts @@ -71,17 +71,17 @@ export class KubeAuthProxy { }, }); this.proxyProcess.on("error", (error) => { - this.cluster.broadcastConnectUpdate(error.message, true); + this.cluster.broadcastConnectUpdate(error.message, "error"); this.exit(); }); this.proxyProcess.on("exit", (code) => { - this.cluster.broadcastConnectUpdate(`proxy exited with code: ${code}`, code ? code > 0: false); + this.cluster.broadcastConnectUpdate(`proxy exited with code: ${code}`, code ? "error" : "info"); this.exit(); }); this.proxyProcess.on("disconnect", () => { - this.cluster.broadcastConnectUpdate("Proxy disconnected communications", true ); + this.cluster.broadcastConnectUpdate("Proxy disconnected communications", "error"); this.exit(); }); @@ -93,7 +93,7 @@ export class KubeAuthProxy { return; } - this.cluster.broadcastConnectUpdate(data.toString(), true); + this.cluster.broadcastConnectUpdate(data.toString(), "error"); }); this.proxyProcess.stdout.on("data", (data: Buffer) => { @@ -114,7 +114,7 @@ export class KubeAuthProxy { this.ready = true; } catch (error) { this.dependencies.logger.warn("[KUBE-AUTH-PROXY]: waitUntilUsed failed", error); - this.cluster.broadcastConnectUpdate("Proxy port failed to be used within timelimit, restarting...", true); + this.cluster.broadcastConnectUpdate("Proxy port failed to be used within timelimit, restarting...", "error"); this.exit(); return this.run(); diff --git a/packages/core/src/main/library.ts b/packages/core/src/main/library.ts index f10d36929d..f11cd0457c 100644 --- a/packages/core/src/main/library.ts +++ b/packages/core/src/main/library.ts @@ -11,7 +11,7 @@ import * as extensionApi from "./extension-api"; import { createApp } from "./create-app"; // @experimental -export { +export { createApp, extensionApi, afterApplicationIsLoadedInjectionToken, diff --git a/packages/core/src/renderer/components/+config-autoscalers/__snapshots__/hpa-details.test.tsx.snap b/packages/core/src/renderer/components/+config-autoscalers/__snapshots__/hpa-details.test.tsx.snap new file mode 100644 index 0000000000..a32a798b8b --- /dev/null +++ b/packages/core/src/renderer/components/+config-autoscalers/__snapshots__/hpa-details.test.tsx.snap @@ -0,0 +1,326 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders 1`] = ` + +
+
+
+ + Reference + + + Deployment + / + hpav2deployment + +
+
+ + Min Pods + + + 0 + +
+
+ + Max Pods + + + 10 + +
+
+ + Replicas + + + 0 + +
+
+ + Status + + +
+
+
+ +`; + +exports[` shows unknown metrics with lack of metric type 1`] = ` + +
+
+
+ + Reference + + + Deployment + / + hpav2deployment + +
+
+ + Min Pods + + + 0 + +
+
+ + Max Pods + + + 10 + +
+
+ + Replicas + + + 0 + +
+
+ + Status + + +
+
+ Metrics +
+
+
+
+
+ Name +
+
+ Current / Target +
+
+
+
+ unknown +
+
+ unknown / unknown +
+
+
+
+
+
+ +`; + +exports[` shows unknown metrics with with unusual type 1`] = ` + +
+
+
+ + Reference + + + Deployment + / + hpav2deployment + +
+
+ + Min Pods + + + 0 + +
+
+ + Max Pods + + + 10 + +
+
+ + Replicas + + + 0 + +
+
+ + Status + + +
+
+ Metrics +
+
+
+
+
+ Name +
+
+ Current / Target +
+
+
+
+ unknown +
+
+ unknown / unknown +
+
+
+
+
+
+ +`; diff --git a/packages/core/src/renderer/components/+config-autoscalers/get-hpa-metric-name.ts b/packages/core/src/renderer/components/+config-autoscalers/get-hpa-metric-name.ts index f92b249909..d45db05ec0 100644 --- a/packages/core/src/renderer/components/+config-autoscalers/get-hpa-metric-name.ts +++ b/packages/core/src/renderer/components/+config-autoscalers/get-hpa-metric-name.ts @@ -19,8 +19,8 @@ interface Metric extends MetricNames { type: HpaMetricType; } -export function getMetricName(metric: Metric): string | undefined { - switch (metric.type) { +export function getMetricName(metric: Metric | undefined): string | undefined { + switch (metric?.type) { case HpaMetricType.Resource: return metric.resource?.name; case HpaMetricType.Pods: diff --git a/packages/core/src/renderer/components/+config-autoscalers/hpa-details.test.tsx b/packages/core/src/renderer/components/+config-autoscalers/hpa-details.test.tsx new file mode 100644 index 0000000000..017b719841 --- /dev/null +++ b/packages/core/src/renderer/components/+config-autoscalers/hpa-details.test.tsx @@ -0,0 +1,392 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { RenderResult } from "@testing-library/react"; +import React from "react"; +import { HorizontalPodAutoscaler, HpaMetricType } from "../../../common/k8s-api/endpoints"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import type { DiRender } from "../test-utils/renderFor"; +import { renderFor } from "../test-utils/renderFor"; +import { HpaDetails } from "./hpa-details"; + +jest.mock("react-router-dom", () => ({ + Link: ({ children }: { children: React.ReactNode }) => children, +})); + +const hpaV2 = { + apiVersion: "autoscaling/v2", + kind: "HorizontalPodAutoscaler", + metadata: { + name: "hpav2", + resourceVersion: "1", + uid: "hpav2", + namespace: "default", + selfLink: "/apis/autoscaling/v2/namespaces/default/horizontalpodautoscalers/hpav2", + }, + spec: { + maxReplicas: 10, + scaleTargetRef: { + kind: "Deployment", + name: "hpav2deployment", + apiVersion: "apps/v1", + }, + }, +}; + +describe("", () => { + let result: RenderResult; + let render: DiRender; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + render = renderFor(di); + }); + + it("renders", () => { + const hpa = new HorizontalPodAutoscaler(hpaV2); + + result = render( + , + ); + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("does not show metrics table if no metrics found", () => { + const hpa = new HorizontalPodAutoscaler(hpaV2); + + result = render( + , + ); + + expect(result.queryByTestId("hpa-metrics")).toBeNull(); + }); + + it("shows proper metric name for autoscaling/v1", () => { + const hpa = new HorizontalPodAutoscaler({ + apiVersion: "autoscaling/v1", + kind: "HorizontalPodAutoscaler", + metadata: { + name: "hpav1", + resourceVersion: "1", + uid: "hpav1", + namespace: "default", + selfLink: "/apis/autoscaling/v1/namespaces/default/horizontalpodautoscalers/hpav1", + }, + spec: { + maxReplicas: 10, + scaleTargetRef: { + kind: "Deployment", + name: "hpav1deployment", + apiVersion: "apps/v1", + }, + targetCPUUtilizationPercentage: 80, + }, + }); + + result = render( + , + ); + + expect(result.getByText("CPU Utilization percentage")).toBeInTheDocument(); + }); + + it("shows proper metric name for container resource metrics", () => { + const hpa = new HorizontalPodAutoscaler( + { + ...hpaV2, + spec: { + ...hpaV2.spec, + metrics: [ + { + type: HpaMetricType.ContainerResource, + containerResource: { + name: "cpu", + container: "nginx", + target: { + type: "Utilization", + averageUtilization: 60, + }, + }, + }, + ], + }, + }, + ); + + result = render( + , + ); + + expect(result.getByText("Resource cpu on Pods")).toBeInTheDocument(); + }); + + it("shows proper metric name for resource metrics", () => { + const hpa = new HorizontalPodAutoscaler( + { + ...hpaV2, + spec: { + ...hpaV2.spec, + metrics: [ + { + type: HpaMetricType.Resource, + resource: { + name: "cpu", + target: { + type: "Utilization", + averageUtilization: 50, + }, + }, + }, + ], + }, + }, + ); + + result = render( + , + ); + + expect(result.getByText("Resource cpu on Pods")).toBeInTheDocument(); + }); + + it("shows proper metric name for pod metrics for hpa v2", () => { + const hpa = new HorizontalPodAutoscaler( + { + ...hpaV2, + spec: { + ...hpaV2.spec, + metrics: [ + { + type: HpaMetricType.Pods, + pods: { + metric: { + name: "packets-per-second", + }, + target: { + type: "AverageValue", + averageValue: "1k", + }, + }, + }, + ], + }, + }, + ); + + result = render( + , + ); + + expect(result.getByText("packets-per-second on Pods")).toBeInTheDocument(); + }); + + it("shows proper metric name for pod metrics for hpa v2beta1", () => { + const hpa = new HorizontalPodAutoscaler( + { + ...hpaV2, + spec: { + ...hpaV2.spec, + metrics: [ + { + type: HpaMetricType.Pods, + pods: { + metricName: "packets-per-second", + }, + }, + ], + }, + }, + ); + + result = render( + , + ); + + expect(result.getByText("packets-per-second on Pods")).toBeInTheDocument(); + }); + + it("shows proper metric name for object metrics for hpa v2", () => { + const hpa = new HorizontalPodAutoscaler( + { + ...hpaV2, + spec: { + ...hpaV2.spec, + metrics: [ + { + type: HpaMetricType.Object, + object: { + metric: { + name: "requests-per-second", + }, + target: { + type: "Value", + value: "10k", + }, + describedObject: { + kind: "Service", + name: "nginx", + apiVersion: "v1", + }, + }, + }, + ], + }, + }, + ); + + result = render( + , + ); + + expect(result.getByText(/requests-per-second/)).toHaveTextContent("requests-per-second onService/nginx"); + }); + + it("shows proper metric name for object metrics for hpa v2beta1", () => { + const hpa = new HorizontalPodAutoscaler( + { + ...hpaV2, + spec: { + ...hpaV2.spec, + metrics: [ + { + type: HpaMetricType.Object, + object: { + metricName: "requests-per-second", + }, + }, + ], + }, + }, + ); + + result = render( + , + ); + + expect(result.getByText("requests-per-second")).toBeInTheDocument(); + }); + + it("shows proper metric name for external metrics for hpa v2", () => { + const hpa = new HorizontalPodAutoscaler( + { + ...hpaV2, + spec: { + ...hpaV2.spec, + metrics: [ + { + type: HpaMetricType.External, + external: { + metric: { + name: "queue_messages_ready", + selector: { + matchLabels: { queue: "worker_tasks" }, + }, + }, + target: { + type: "AverageValue", + averageValue: "30", + }, + }, + }, + ], + }, + }, + ); + + result = render( + , + ); + + expect(result.getByText("queue_messages_ready on {\"matchLabels\":{\"queue\":\"worker_tasks\"}}")).toBeInTheDocument(); + }); + + it("shows proper metric name for external metrics for hpa v2beta1", () => { + const hpa = new HorizontalPodAutoscaler( + { + ...hpaV2, + spec: { + ...hpaV2.spec, + metrics: [ + { + type: HpaMetricType.External, + external: { + metricName: "queue_messages_ready", + metricSelector: { + matchLabels: { queue: "worker_tasks" }, + }, + }, + }, + ], + }, + }, + ); + + result = render( + , + ); + + expect(result.getByText("queue_messages_ready on {\"matchLabels\":{\"queue\":\"worker_tasks\"}}")).toBeInTheDocument(); + }); + + it("shows unknown metrics with lack of metric type", () => { + const hpa = new HorizontalPodAutoscaler( + { + ...hpaV2, + spec: { + ...hpaV2.spec, + metrics: [ + // @ts-ignore + { + resource: { + name: "cpu", + target: { + type: "Utilization", + averageUtilization: 50, + }, + }, + }, + ], + }, + }, + ); + + result = render( + , + ); + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("shows unknown metrics with with unusual type", () => { + const hpa = new HorizontalPodAutoscaler( + { + ...hpaV2, + spec: { + ...hpaV2.spec, + metrics: [ + { + // @ts-ignore + type: "Unusual", + resource: { + name: "cpu", + target: { + type: "Utilization", + averageUtilization: 50, + }, + }, + }, + ], + }, + }, + ); + + result = render( + , + ); + + expect(result.baseElement).toMatchSnapshot(); + }); +}); diff --git a/packages/core/src/renderer/components/+config-autoscalers/hpa-details.tsx b/packages/core/src/renderer/components/+config-autoscalers/hpa-details.tsx index 96c1a7e20b..49130991a5 100644 --- a/packages/core/src/renderer/components/+config-autoscalers/hpa-details.tsx +++ b/packages/core/src/renderer/components/+config-autoscalers/hpa-details.tsx @@ -62,7 +62,7 @@ class NonInjectedHpaDetails extends React.Component { const metricName = getMetricName(metric); - switch (metric.type) { + switch (metric?.type) { case HpaMetricType.ContainerResource: // fallthrough @@ -85,11 +85,13 @@ class NonInjectedHpaDetails extends React.Component + Name Current / Target @@ -162,10 +164,14 @@ class NonInjectedHpaDetails extends React.Component - Metrics -
- {this.renderMetrics()} -
+ {(hpa.getMetrics().length !== 0 || hpa.spec?.targetCPUUtilizationPercentage) && ( + <> + Metrics +
+ {this.renderMetrics()} +
+ + )} ); } diff --git a/packages/core/src/renderer/components/+config-maps/config-map-details.tsx b/packages/core/src/renderer/components/+config-maps/config-map-details.tsx index b03cdda2c9..569a525900 100644 --- a/packages/core/src/renderer/components/+config-maps/config-map-details.tsx +++ b/packages/core/src/renderer/components/+config-maps/config-map-details.tsx @@ -113,6 +113,11 @@ class NonInjectedConfigMapDetails extends React.Component this.data.set(name, v)} setInitialHeight + options={{ + scrollbar: { + alwaysConsumeMouseWheel: false, + }, + }} /> )) diff --git a/packages/core/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx b/packages/core/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx index a6b6e48342..1ce45e42d0 100644 --- a/packages/core/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx +++ b/packages/core/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx @@ -10,70 +10,44 @@ import { observer } from "mobx-react"; import { DrawerItem } from "../drawer"; import { Badge } from "../badge"; import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { PodDisruptionBudget } from "../../../common/k8s-api/endpoints"; -import type { Logger } from "../../../common/logger"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import loggerInjectable from "../../../common/logger.injectable"; +import type { PodDisruptionBudget } from "../../../common/k8s-api/endpoints"; export interface PodDisruptionBudgetDetailsProps extends KubeObjectDetailsProps { } -interface Dependencies { - logger: Logger; -} +export const PodDisruptionBudgetDetails = observer((props: PodDisruptionBudgetDetailsProps) => { + const { object: pdb } = props; -@observer -class NonInjectedPodDisruptionBudgetDetails extends React.Component { - - render() { - const { object: pdb } = this.props; - - if (!pdb) { - return null; - } - - if (!(pdb instanceof PodDisruptionBudget)) { - this.props.logger.error("[PodDisruptionBudgetDetails]: passed object that is not an instanceof PodDisruptionBudget", pdb); - - return null; - } - - const selectors = pdb.getSelectors(); - - return ( -
- {selectors.length > 0 && ( - - { - selectors.map(label => ) - } - - )} - - - {pdb.getMinAvailable()} - - - - {pdb.getMaxUnavailable()} - - - - {pdb.getCurrentHealthy()} - - - - {pdb.getDesiredHealthy()} - - -
- ); + if (!pdb) { + return null; } -} -export const PodDisruptionBudgetDetails = withInjectables(NonInjectedPodDisruptionBudgetDetails, { - getProps: (di, props) => ({ - ...props, - logger: di.inject(loggerInjectable), - }), + const selectors = pdb.getSelectors(); + + return ( +
+ {selectors.length > 0 && ( + + {selectors.map(label => )} + + )} + + + {pdb.getMinAvailable()} + + + + {pdb.getMaxUnavailable()} + + + + {pdb.getCurrentHealthy()} + + + + {pdb.getDesiredHealthy()} + + +
+ ); }); diff --git a/packages/core/src/renderer/components/cluster-manager/cluster-status.tsx b/packages/core/src/renderer/components/cluster-manager/cluster-status.tsx index 1183b4f52b..940218ecad 100644 --- a/packages/core/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/packages/core/src/renderer/components/cluster-manager/cluster-status.tsx @@ -11,7 +11,7 @@ import React from "react"; import { ipcRendererOn } from "../../../common/ipc"; import type { Cluster } from "../../../common/cluster/cluster"; import type { IClassName } from "../../utils"; -import { isBoolean, hasTypedProperty, isObject, isString, cssNames } from "../../utils"; +import { hasTypedProperty, isObject, isString, cssNames } from "../../utils"; import { Button } from "../button"; import { Icon } from "../icon"; import { Spinner } from "../spinner"; @@ -51,8 +51,8 @@ class NonInjectedClusterStatus extends React.Component isError); + @computed get hasErrorsOrWarnings(): boolean { + return this.authOutput.some(({ level }) => level !== "info"); } componentDidMount() { @@ -61,7 +61,7 @@ class NonInjectedClusterStatus extends React.Component { - this.authOutput.map(({ message, isError }, index) => ( -

+ this.authOutput.map(({ message, level }, index) => ( +

{message.trim()}

)) @@ -113,7 +113,7 @@ class NonInjectedClusterStatus extends React.Component; } @@ -131,7 +131,7 @@ class NonInjectedClusterStatus extends React.Component