diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index a5e8c4fe73..ac2fbc3b61 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -15,7 +15,7 @@ categories: - 'area/tests' - 'dependencies' - 'area/documentation' - +change-template: '* #$NUMBER @$AUTHOR' template: | ## Changes since $PREVIOUS_TAG diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000000..d38572c7dc --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,14 @@ +name: Cull Stale Issues +on: + schedule: + - cron: "30 1 * * *" +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@4.0.0 + with: + only-issue-labels: "needs-information" + repo-token: "${{ secrets.GITHUB_TOKEN }}" + # -1 here means that PRs will never be marked as stale + days-before-pr-stale: -1 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000000..b58b603fea --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000000..7abc908e60 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,73 @@ + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000000..79ee123c2b --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000000..97626ba454 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000000..03d9549ea8 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/jsLinters/eslint.xml b/.idea/jsLinters/eslint.xml new file mode 100644 index 0000000000..541945bb08 --- /dev/null +++ b/.idea/jsLinters/eslint.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/lens.iml b/.idea/lens.iml new file mode 100644 index 0000000000..079203f10b --- /dev/null +++ b/.idea/lens.iml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000000..039e6a7313 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000000..4af03d0504 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml new file mode 100644 index 0000000000..8004cebd99 --- /dev/null +++ b/.idea/prettier.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000000..94a25f7f4c --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/webResources.xml b/.idea/webResources.xml new file mode 100644 index 0000000000..717d9d6656 --- /dev/null +++ b/.idea/webResources.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build/build_theme_vars.ts b/build/build_theme_vars.ts new file mode 100644 index 0000000000..f7c8a2f66b --- /dev/null +++ b/build/build_theme_vars.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import fs from "fs-extra"; +import path from "path"; +import defaultBaseLensTheme from "../src/renderer/themes/lens-dark.json"; + +const outputCssFile = path.resolve("src/renderer/themes/theme-vars.css"); + +const banner = `/* + Generated Lens theme CSS-variables, don't edit manually. + To refresh file run $: yarn run ts-node build/${path.basename(__filename)} +*/`; + +const themeCssVars = Object.entries(defaultBaseLensTheme.colors) + .map(([varName, value]) => `--${varName}: ${value};`); + +const content = ` +${banner} + +:root { +${themeCssVars.join("\n")} +} +`; + +// Run +console.info(`"Saving default Lens theme css-variables to "${outputCssFile}""`); +fs.ensureFileSync(outputCssFile); +fs.writeFile(outputCssFile, content); diff --git a/extensions/metrics-cluster-feature/src/metrics-settings.tsx b/extensions/metrics-cluster-feature/src/metrics-settings.tsx index 238d71bbf0..de0edd5be3 100644 --- a/extensions/metrics-cluster-feature/src/metrics-settings.tsx +++ b/extensions/metrics-cluster-feature/src/metrics-settings.tsx @@ -61,14 +61,14 @@ export class MetricsSettings extends React.Component { persistence: { enabled: false, storageClass: null, - size: "20G", + size: "20GiB", }, nodeExporter: { enabled: false, }, retention: { time: "2d", - size: "5GB", + size: "5GiB", }, kubeStateMetrics: { enabled: false, diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 36173c0e9d..64ca60380d 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -331,6 +331,14 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { await cleanup(); }, 10*60*1000); + it("shows cluster context menu in sidebar", async () => { + await frame.click(`[data-testid="sidebar-cluster-dropdown"]`); + await frame.waitForSelector(`.Menu >> text="Add to Hotbar"`); + await frame.waitForSelector(`.Menu >> text="Settings"`); + await frame.waitForSelector(`.Menu >> text="Disconnect"`); + await frame.waitForSelector(`.Menu >> text="Delete"`); + }); + it("should navigate around common cluster pages", async () => { for (const test of commonPageTests) { if (isTopPageTest(test)) { @@ -362,6 +370,8 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { } }, 10*60*1000); + + it("show logs and highlight the log search entries", async () => { await frame.click(`a[href="/workloads"]`); await frame.click(`a[href="/pods"]`); diff --git a/package.json b/package.json index a551ed34d5..2cf1c004ef 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "OpenLens", "description": "OpenLens - Open Source IDE for Kubernetes", "homepage": "https://github.com/lensapp/lens", - "version": "5.3.0-alpha.9", + "version": "5.3.0-alpha.11", "main": "static/build/main.js", "copyright": "© 2021 OpenLens Authors", "license": "MIT", @@ -35,6 +35,7 @@ "download:kubectl": "yarn run ts-node build/download_kubectl.ts", "download:helm": "yarn run ts-node build/download_helm.ts", "build:tray-icons": "yarn run ts-node build/build_tray_icon.ts", + "build:theme-vars": "yarn run ts-node build/build_theme_vars.ts", "lint": "PROD=true yarn run eslint --ext js,ts,tsx --max-warnings=0 .", "lint:fix": "yarn run lint --fix", "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", @@ -220,7 +221,7 @@ "moment-timezone": "^0.5.33", "monaco-editor": "^0.29.1", "monaco-editor-webpack-plugin": "^5.0.0", - "node-fetch": "^2.6.6", + "node-fetch": "lensapp/node-fetch#2.x", "node-pty": "^0.10.1", "npm": "^6.14.15", "p-limit": "^3.1.0", @@ -278,13 +279,13 @@ "@types/md5-file": "^4.0.2", "@types/mini-css-extract-plugin": "^0.9.1", "@types/mock-fs": "^4.13.1", - "@types/node": "14.17.27", + "@types/node": "14.17.33", "@types/node-fetch": "^2.5.12", "@types/npm": "^2.0.32", "@types/progress-bar-webpack-plugin": "^2.1.2", "@types/proper-lockfile": "^4.1.2", "@types/randomcolor": "^0.5.6", - "@types/react": "^17.0.33", + "@types/react": "^17.0.34", "@types/react-beautiful-dnd": "^13.1.2", "@types/react-dom": "^17.0.9", "@types/react-router-dom": "^5.3.2", @@ -361,7 +362,7 @@ "sass-loader": "^8.0.2", "sharp": "^0.29.2", "style-loader": "^2.0.0", - "tailwindcss": "^2.2.17", + "tailwindcss": "^2.2.19", "ts-jest": "26.5.6", "ts-loader": "^7.0.5", "ts-node": "^10.4.0", diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 26806ae239..d5dfdadfd1 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -26,7 +26,7 @@ import { Cluster } from "../main/cluster"; import migrations from "../migrations/cluster-store"; import logger from "../main/logger"; import { appEventBus } from "./event-bus"; -import { ipcMainHandle, ipcMainOn, ipcRendererOn, requestMain } from "./ipc"; +import { ipcMainHandle, requestMain } from "./ipc"; import { disposer, toJS } from "./utils"; import type { ClusterModel, ClusterId, ClusterState } from "./cluster-types"; @@ -37,8 +37,6 @@ export interface ClusterStoreModel { const initialStates = "cluster:states"; export class ClusterStore extends BaseStore { - private static StateChannel = "cluster:state"; - clusters = observable.map(); protected disposer = disposer(); @@ -83,21 +81,13 @@ export class ClusterStore extends BaseStore { } } - handleStateChange = (event: any, clusterId: string, state: ClusterState) => { - logger.silly(`[CLUSTER-STORE]: received push-state at ${location.host} (${webFrame.routingId})`, clusterId, state); - this.getById(clusterId)?.setState(state); - }; - registerIpcListener() { logger.info(`[CLUSTER-STORE] start to listen (${webFrame.routingId})`); + const ipc = ipcMain ?? ipcRenderer; - if (ipcMain) { - this.disposer.push(ipcMainOn(ClusterStore.StateChannel, this.handleStateChange)); - } - - if (ipcRenderer) { - this.disposer.push(ipcRendererOn(ClusterStore.StateChannel, this.handleStateChange)); - } + ipc?.on("cluster:state", (event, clusterId: ClusterId, state: ClusterState) => { + this.getById(clusterId)?.setState(state); + }); } unregisterIpcListener() { diff --git a/src/common/cluster-types.ts b/src/common/cluster-types.ts index 0c40f9ebed..cb958953f6 100644 --- a/src/common/cluster-types.ts +++ b/src/common/cluster-types.ts @@ -122,6 +122,14 @@ export enum ClusterStatus { Offline = 0, } +/** + * The message format for the "cluster::connection-update" channels + */ +export interface KubeAuthUpdate { + message: string; + isError: boolean; +} + /** * The OpenLens known static metadata keys */ @@ -173,7 +181,6 @@ export interface ClusterState { disconnected: boolean; accessible: boolean; ready: boolean; - failureReason: string; isAdmin: boolean; allowedNamespaces: string[] allowedResources: string[] diff --git a/src/common/k8s-api/__tests__/crd.test.ts b/src/common/k8s-api/__tests__/crd.test.ts index 7e4d255d15..0eba45e2cd 100644 --- a/src/common/k8s-api/__tests__/crd.test.ts +++ b/src/common/k8s-api/__tests__/crd.test.ts @@ -51,7 +51,7 @@ describe("Crds", () => { expect(() => crd.getVersion()).toThrowError("Failed to find a version for CustomResourceDefinition foo"); }); - it("should should get the version that is both served and stored", () => { + it("should get the version that is both served and stored", () => { const crd = new CustomResourceDefinition({ apiVersion: "apiextensions.k8s.io/v1", kind: "CustomResourceDefinition", @@ -79,7 +79,35 @@ describe("Crds", () => { expect(crd.getVersion()).toBe("123"); }); - it("should should get the version that is both served and stored even with version field", () => { + it("should get the version that is only stored", () => { + const crd = new CustomResourceDefinition({ + apiVersion: "apiextensions.k8s.io/v1", + kind: "CustomResourceDefinition", + metadata: { + name: "foo", + resourceVersion: "12345", + uid: "12345", + }, + spec: { + versions: [ + { + name: "123", + served: false, + storage: true, + }, + { + name: "1234", + served: false, + storage: false, + }, + ], + }, + }); + + expect(crd.getVersion()).toBe("123"); + }); + + it("should get the version that is both served and stored even with version field", () => { const crd = new CustomResourceDefinition({ apiVersion: "apiextensions.k8s.io/v1", kind: "CustomResourceDefinition", diff --git a/src/common/k8s-api/__tests__/kube-api.test.ts b/src/common/k8s-api/__tests__/kube-api.test.ts index 3cd0768689..dd79b9c386 100644 --- a/src/common/k8s-api/__tests__/kube-api.test.ts +++ b/src/common/k8s-api/__tests__/kube-api.test.ts @@ -19,13 +19,21 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { Pod } from "../endpoints/pods.api"; +import type { Request } from "node-fetch"; import { forRemoteCluster, KubeApi } from "../kube-api"; import { KubeJsonApi } from "../kube-json-api"; import { KubeObject } from "../kube-object"; +class TestKubeObject extends KubeObject { + static kind = "Pod"; + static namespaced = true; + static apiBase = "/api/v1/pods"; +} + +class TestKubeApi extends KubeApi {} + describe("forRemoteCluster", () => { - it("builds api client", async (done) => { + it("builds api client for KubeObject", async () => { const api = forRemoteCluster({ cluster: { server: "https://127.0.0.1:6443", @@ -33,18 +41,44 @@ describe("forRemoteCluster", () => { user: { token: "daa", }, - }, Pod); + }, TestKubeObject); + + expect(api).toBeInstanceOf(KubeApi); + }); + + it("builds api client for given KubeApi", async () => { + const api = forRemoteCluster({ + cluster: { + server: "https://127.0.0.1:6443", + }, + user: { + token: "daa", + }, + }, TestKubeObject, TestKubeApi); + + expect(api).toBeInstanceOf(TestKubeApi); + }); + + it("calls right api endpoint", async () => { + const api = forRemoteCluster({ + cluster: { + server: "https://127.0.0.1:6443", + }, + user: { + token: "daa", + }, + }, TestKubeObject); (fetch as any).mockResponse(async (request: any) => { expect(request.url).toEqual("https://127.0.0.1:6443/api/v1/pods"); - done(); - return { - body: "", + body: "hello", }; }); + expect.hasAssertions(); + await api.list(); }); }); @@ -141,4 +175,122 @@ describe("KubeApi", () => { expect(kubeApi.apiPrefix).toEqual("/apis"); expect(kubeApi.apiGroup).toEqual("extensions"); }); + + describe("patch", () => { + let api: TestKubeApi; + + beforeEach(() => { + api = new TestKubeApi({ + request, + objectConstructor: TestKubeObject, + }); + }); + + it("sends strategic patch by default", async () => { + expect.hasAssertions(); + + (fetch as any).mockResponse(async (request: Request) => { + expect(request.method).toEqual("PATCH"); + expect(request.headers.get("content-type")).toMatch("strategic-merge-patch"); + expect(request.body.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); + + return {}; + }); + + await api.patch({ name: "test", namespace: "default" }, { + spec: { replicas: 2 }, + }); + }); + + it("allows to use merge patch", async () => { + expect.hasAssertions(); + + (fetch as any).mockResponse(async (request: Request) => { + expect(request.method).toEqual("PATCH"); + expect(request.headers.get("content-type")).toMatch("merge-patch"); + expect(request.body.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); + + return {}; + }); + + await api.patch({ name: "test", namespace: "default" }, { + spec: { replicas: 2 }, + }, "merge"); + }); + + it("allows to use json patch", async () => { + expect.hasAssertions(); + + (fetch as any).mockResponse(async (request: Request) => { + expect(request.method).toEqual("PATCH"); + expect(request.headers.get("content-type")).toMatch("json-patch"); + expect(request.body.toString()).toEqual(JSON.stringify([{ op: "replace", path: "/spec/replicas", value: 2 }])); + + return {}; + }); + + await api.patch({ name: "test", namespace: "default" }, [ + { op: "replace", path: "/spec/replicas", value: 2 }, + ], "json"); + }); + }); + + describe("delete", () => { + let api: TestKubeApi; + + beforeEach(() => { + api = new TestKubeApi({ + request, + objectConstructor: TestKubeObject, + }); + }); + + it("sends correct request with empty namespace", async () => { + expect.hasAssertions(); + (fetch as any).mockResponse(async (request: Request) => { + expect(request.method).toEqual("DELETE"); + expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/pods/foo?propagationPolicy=Background"); + + return {}; + }); + + await api.delete({ name: "foo", namespace: "" }); + }); + + it("sends correct request without namespace", async () => { + expect.hasAssertions(); + (fetch as any).mockResponse(async (request: Request) => { + expect(request.method).toEqual("DELETE"); + expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background"); + + return {}; + }); + + await api.delete({ name: "foo" }); + }); + + it("sends correct request with namespace", async () => { + expect.hasAssertions(); + (fetch as any).mockResponse(async (request: Request) => { + expect(request.method).toEqual("DELETE"); + expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods/foo?propagationPolicy=Background"); + + return {}; + }); + + await api.delete({ name: "foo", namespace: "kube-system" }); + }); + + it("allows to change propagationPolicy", async () => { + expect.hasAssertions(); + (fetch as any).mockResponse(async (request: Request) => { + expect(request.method).toEqual("DELETE"); + expect(request.url).toMatch("propagationPolicy=Orphan"); + + return {}; + }); + + await api.delete({ name: "foo", namespace: "default", propagationPolicy: "Orphan" }); + }); + }); }); diff --git a/src/common/k8s-api/endpoints/crd.api.ts b/src/common/k8s-api/endpoints/crd.api.ts index 1e9044e31a..4e2244d8a8 100644 --- a/src/common/k8s-api/endpoints/crd.api.ts +++ b/src/common/k8s-api/endpoints/crd.api.ts @@ -153,11 +153,7 @@ export class CustomResourceDefinition extends KubeObject { // Prefer the modern `versions` over the legacy `version` if (this.spec.versions) { for (const version of this.spec.versions) { - /** - * If the version is not served then 404 errors will occur - * We should also prefer the storage version - */ - if (version.served && version.storage) { + if (version.storage) { return version; } } diff --git a/src/common/k8s-api/endpoints/ingress.api.ts b/src/common/k8s-api/endpoints/ingress.api.ts index 277b47536c..7b80dca29e 100644 --- a/src/common/k8s-api/endpoints/ingress.api.ts +++ b/src/common/k8s-api/endpoints/ingress.api.ts @@ -25,6 +25,7 @@ import { IMetrics, metricsApi } from "./metrics.api"; import { KubeApi } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { RequireExactlyOne } from "type-fest"; export class IngressApi extends KubeApi { } @@ -58,7 +59,7 @@ export interface ILoadBalancerIngress { // extensions/v1beta1 interface IExtensionsBackend { serviceName: string; - servicePort: number; + servicePort: number | string; } // networking.k8s.io/v1 @@ -101,14 +102,18 @@ export interface Ingress { }[]; // extensions/v1beta1 backend?: IExtensionsBackend; - // networking.k8s.io/v1 - defaultBackend?: INetworkingBackend & { + /** + * The default backend which is exactly on of: + * - service + * - resource + */ + defaultBackend?: RequireExactlyOne }; status: { loadBalancer: { @@ -153,10 +158,11 @@ export class Ingress extends KubeObject { return routes; } - getServiceNamePort() { - const { spec } = this; - const serviceName = spec?.defaultBackend?.service.name ?? spec?.backend?.serviceName; - const servicePort = spec?.defaultBackend?.service.port.number ?? spec?.defaultBackend?.service.port.name ?? spec?.backend?.servicePort; + getServiceNamePort(): IExtensionsBackend { + const { spec: { backend, defaultBackend } = {}} = this; + + const serviceName = defaultBackend?.service?.name ?? backend?.serviceName; + const servicePort = defaultBackend?.service?.port.number ?? defaultBackend?.service?.port.name ?? backend?.servicePort; return { serviceName, diff --git a/src/common/k8s-api/endpoints/job.api.ts b/src/common/k8s-api/endpoints/job.api.ts index a1c4b733db..4907e6c796 100644 --- a/src/common/k8s-api/endpoints/job.api.ts +++ b/src/common/k8s-api/endpoints/job.api.ts @@ -24,7 +24,6 @@ import { autoBind } from "../../utils"; import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; import { KubeApi } from "../kube-api"; import { metricsApi } from "./metrics.api"; -import type { JsonApiParams } from "../json-api"; import type { KubeJsonApiData } from "../kube-json-api"; import type { IPodContainer, IPodMetrics } from "./pods.api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; @@ -121,14 +120,6 @@ export class Job extends WorkloadKubeObject { return [...containers].map(container => container.image); } - - delete() { - const params: JsonApiParams = { - query: { propagationPolicy: "Background" }, - }; - - return super.delete(params); - } } export class JobApi extends KubeApi { diff --git a/src/common/k8s-api/endpoints/nodes.api.ts b/src/common/k8s-api/endpoints/nodes.api.ts index 3f4e0fb412..f46eaa8811 100644 --- a/src/common/k8s-api/endpoints/nodes.api.ts +++ b/src/common/k8s-api/endpoints/nodes.api.ts @@ -236,13 +236,10 @@ 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]; - } - - return "linux"; + return this.metadata?.labels?.["kubernetes.io/os"] + || this.metadata?.labels?.["beta.kubernetes.io/os"] + || this.status?.nodeInfo?.operatingSystem + || "linux"; } isUnschedulable() { diff --git a/src/common/k8s-api/endpoints/pods.api.ts b/src/common/k8s-api/endpoints/pods.api.ts index bdcddf035d..361e72ca54 100644 --- a/src/common/k8s-api/endpoints/pods.api.ts +++ b/src/common/k8s-api/endpoints/pods.api.ts @@ -165,40 +165,40 @@ interface IContainerProbe { failureThreshold?: number; } +export interface ContainerStateRunning { + startedAt: string; +} + +export interface ContainerStateWaiting { + reason: string; + message: string; +} + +export interface ContainerStateTerminated { + startedAt: string; + finishedAt: string; + exitCode: number; + reason: string; + containerID?: string; + message?: string; + signal?: number; +} + +/** + * ContainerState holds a possible state of container. Only one of its members + * may be specified. If none of them is specified, the default one is + * `ContainerStateWaiting`. + */ +export interface ContainerState { + running?: ContainerStateRunning; + waiting?: ContainerStateWaiting; + terminated?: ContainerStateTerminated; +} + export interface IPodContainerStatus { name: string; - state?: { - [index: string]: object; - running?: { - startedAt: string; - }; - waiting?: { - reason: string; - message: string; - }; - terminated?: { - startedAt: string; - finishedAt: string; - exitCode: number; - reason: string; - }; - }; - lastState?: { - [index: string]: object; - running?: { - startedAt: string; - }; - waiting?: { - reason: string; - message: string; - }; - terminated?: { - startedAt: string; - finishedAt: string; - exitCode: number; - reason: string; - }; - }; + state?: ContainerState; + lastState?: ContainerState; ready: boolean; restartCount: number; image: string; @@ -373,23 +373,16 @@ export class Pod extends WorkloadKubeObject { } // Returns pod phase or container error if occurred - getStatusMessage() { - if (this.getReason() === PodStatus.EVICTED) return "Evicted"; - if (this.metadata.deletionTimestamp) return "Terminating"; - - const statuses = this.getContainerStatuses(false); // not including initContainers - - for (const { state } of statuses.reverse()) { - if (state.waiting) { - return state.waiting.reason || "Waiting"; - } - - if (state.terminated) { - return state.terminated.reason || "Terminated"; - } + getStatusMessage(): string { + if (this.getReason() === PodStatus.EVICTED) { + return "Evicted"; } - return this.getStatusPhase(); + if (this.metadata.deletionTimestamp) { + return "Terminating"; + } + + return this.getStatusPhase() || "Waiting"; } getStatusPhase() { diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index 850d5dcb05..840187f047 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -37,6 +37,7 @@ import { noop } from "../utils"; import type { RequestInit } from "node-fetch"; import AbortController from "abort-controller"; import { Agent, AgentOptions } from "https"; +import type { Patch } from "rfc6902"; export interface IKubeApiOptions { /** @@ -97,6 +98,8 @@ export interface ILocalKubeApiConfig { } } +export type PropagationPolicy = undefined | "Orphan" | "Foreground" | "Background"; + /** * @deprecated */ @@ -115,7 +118,7 @@ export interface IRemoteKubeApiConfig { } } -export function forCluster(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor): KubeApi { +export function forCluster = KubeApi>(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: IKubeApiOptions) => Y = null): KubeApi { const url = new URL(apiBase.config.serverAddress); const request = new KubeJsonApi({ serverAddress: apiBase.config.serverAddress, @@ -127,15 +130,18 @@ export function forCluster(cluster: ILocalKubeApiConfig, k }, }); - return new KubeApi({ + if (!apiClass) { + apiClass = KubeApi as new (apiOpts: IKubeApiOptions) => Y; + } + + return new apiClass({ objectConstructor: kubeClass, request, }); } -export function forRemoteCluster(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor): KubeApi { +export function forRemoteCluster = KubeApi>(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: IKubeApiOptions) => Y = null): Y { const reqInit: RequestInit = {}; - const agentOptions: AgentOptions = {}; if (config.cluster.skipTLSVerify === true) { @@ -172,8 +178,12 @@ export function forRemoteCluster(config: IRemoteKubeApiCon } : {}), }, reqInit); - return new KubeApi({ - objectConstructor: kubeClass, + if (!apiClass) { + apiClass = KubeApi as new (apiOpts: IKubeApiOptions) => Y; + } + + return new apiClass({ + objectConstructor: kubeClass as KubeObjectConstructor, request, }); } @@ -200,6 +210,14 @@ export type KubeApiWatchOptions = { retry?: boolean; }; +export type KubeApiPatchType = "merge" | "json" | "strategic"; + +const patchTypeHeaders: Record = { + "merge": "application/merge-patch+json", + "json": "application/json-patch+json", + "strategic": "application/strategic-merge-patch+json", +}; + export class KubeApi { readonly kind: string; readonly apiBase: string; @@ -224,10 +242,7 @@ export class KubeApi { isNamespaced = options.objectConstructor?.namespaced, } = options || {}; - if (!options.apiBase) { - options.apiBase = objectConstructor.apiBase; - } - const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(options.apiBase); + const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(options.apiBase || objectConstructor.apiBase); this.kind = kind; this.isNamespaced = isNamespaced; @@ -471,11 +486,34 @@ export class KubeApi { return parsed; } - async delete({ name = "", namespace = "default" }) { + async patch({ name = "", namespace = "default" } = {}, data?: Partial | Patch, strategy: KubeApiPatchType = "strategic"): Promise { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace, name }); - return this.request.del(apiUrl); + const res = await this.request.patch(apiUrl, { data }, { + headers: { + "content-type": patchTypeHeaders[strategy], + }, + }); + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + throw new Error(`PATCH request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; + } + + async delete({ name = "", namespace = "default", propagationPolicy = "Background" }: { name: string, namespace?: string, propagationPolicy?: PropagationPolicy }) { + await this.checkPreferredVersion(); + const apiUrl = this.getUrl({ namespace, name }); + const reqInit = { + query: { + propagationPolicy, + }, + }; + + return this.request.del(apiUrl, reqInit); } getWatchUrl(namespace = "", query: IKubeApiQueryParams = {}) { diff --git a/src/common/k8s-api/kube-json-api.ts b/src/common/k8s-api/kube-json-api.ts index 2e6e892b21..3776634a47 100644 --- a/src/common/k8s-api/kube-json-api.ts +++ b/src/common/k8s-api/kube-json-api.ts @@ -21,6 +21,9 @@ import { JsonApi, JsonApiData, JsonApiError } from "./json-api"; import type { Response } from "node-fetch"; +import type { Cluster } from "../../main/cluster"; +import { LensProxy } from "../../main/lens-proxy"; +import { apiKubePrefix, isDebugging } from "../vars"; export interface KubeJsonApiListMetadata { resourceVersion: string; @@ -70,6 +73,20 @@ export interface KubeJsonApiError extends JsonApiError { } export class KubeJsonApi extends JsonApi { + static forCluster(cluster: Cluster): KubeJsonApi { + const port = LensProxy.getInstance().port; + + return new this({ + serverAddress: `http://127.0.0.1:${port}`, + apiBase: apiKubePrefix, + debug: isDebugging, + }, { + headers: { + "Host": `${cluster.id}.localhost:${port}`, + }, + }); + } + protected parseError(error: KubeJsonApiError | any, res: Response): string[] { const { status, reason, message } = error; @@ -80,4 +97,3 @@ export class KubeJsonApi extends JsonApi { return super.parseError(error, res); } } - diff --git a/src/common/k8s-api/kube-object.store.ts b/src/common/k8s-api/kube-object.store.ts index f4e0542f66..885d4b9347 100644 --- a/src/common/k8s-api/kube-object.store.ts +++ b/src/common/k8s-api/kube-object.store.ts @@ -295,16 +295,30 @@ export abstract class KubeObjectStore extends ItemStore } async patch(item: T, patch: Patch): Promise { - return this.postUpdate(await item.patch(patch)); + return this.postUpdate( + await this.api.patch( + { + name: item.getName(), namespace: item.getNs(), + }, + patch, + "json", + ), + ); } async update(item: T, data: Partial): Promise { - return this.postUpdate(await item.update(data)); + return this.postUpdate( + await this.api.update( + { + name: item.getName(), namespace: item.getNs(), + }, + data, + ), + ); } async remove(item: T) { - await item.delete(); - this.items.remove(item); + await this.api.delete({ name: item.getName(), namespace: item.getNs() }); this.selectedItemsIds.delete(item.getId()); } diff --git a/src/common/k8s-api/kube-object.ts b/src/common/k8s-api/kube-object.ts index 0cb4ac15f6..ffed59b3df 100644 --- a/src/common/k8s-api/kube-object.ts +++ b/src/common/k8s-api/kube-object.ts @@ -306,6 +306,9 @@ export class KubeObject { for (const op of patch) { if (KubeObject.nonEditablePaths.has(op.path)) { @@ -328,6 +331,8 @@ export class KubeObject): Promise { // use unified resource-applier api for updating all k8s objects @@ -337,6 +342,9 @@ export class KubeObject(...args: T): void { export * from "./app-version"; export * from "./autobind"; -export * from "./base64"; export * from "./camelCase"; export * from "./cloneJson"; export * from "./cluster-id-url-parsing"; @@ -62,9 +61,11 @@ export * from "./types"; import * as iter from "./iter"; import * as array from "./array"; import * as tuple from "./tuple"; +import * as base64 from "./base64"; export { iter, array, tuple, + base64, }; diff --git a/src/extensions/main-api/k8s-api.ts b/src/extensions/main-api/k8s-api.ts index d48a14422e..6352184e79 100644 --- a/src/extensions/main-api/k8s-api.ts +++ b/src/extensions/main-api/k8s-api.ts @@ -23,7 +23,7 @@ export { isAllowedResource } from "../../common/utils/allowed-resource"; export { ResourceStack } from "../../common/k8s/resource-stack"; export { apiManager } from "../../common/k8s-api/api-manager"; export { KubeApi, forCluster, forRemoteCluster } from "../../common/k8s-api/kube-api"; -export { KubeObject } from "../../common/k8s-api/kube-object"; +export { KubeObject, KubeStatus } from "../../common/k8s-api/kube-object"; export { KubeObjectStore } from "../../common/k8s-api/kube-object.store"; export { Pod, podsApi, PodsApi } from "../../common/k8s-api/endpoints/pods.api"; export { Node, nodesApi, NodesApi } from "../../common/k8s-api/endpoints/nodes.api"; diff --git a/src/extensions/renderer-api/k8s-api.ts b/src/extensions/renderer-api/k8s-api.ts index aeb4cb02ba..4a084e6373 100644 --- a/src/extensions/renderer-api/k8s-api.ts +++ b/src/extensions/renderer-api/k8s-api.ts @@ -24,7 +24,7 @@ export { ResourceStack } from "../../common/k8s/resource-stack"; export { apiManager } from "../../common/k8s-api/api-manager"; export { KubeObjectStore } from "../../common/k8s-api/kube-object.store"; export { KubeApi, forCluster, forRemoteCluster } from "../../common/k8s-api/kube-api"; -export { KubeObject } from "../../common/k8s-api/kube-object"; +export { KubeObject, KubeStatus } from "../../common/k8s-api/kube-object"; export { Pod, podsApi, PodsApi } from "../../common/k8s-api/endpoints"; export { Node, nodesApi, NodesApi } from "../../common/k8s-api/endpoints"; export { Deployment, deploymentApi, DeploymentApi } from "../../common/k8s-api/endpoints"; diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index f04b948861..a2ba4cb453 100644 --- a/src/main/__test__/kube-auth-proxy.test.ts +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -55,7 +55,7 @@ import { ChildProcess, spawn } from "child_process"; import { bundledKubectlPath, Kubectl } from "../kubectl"; import { mock, MockProxy } from "jest-mock-extended"; import { waitUntilUsed } from "tcp-port-used"; -import type { Readable } from "stream"; +import { EventEmitter, Readable } from "stream"; import { UserStore } from "../../common/user-store"; import { Console } from "console"; import { stdout, stderr } from "process"; @@ -134,30 +134,73 @@ describe("kube auth proxy tests", () => { describe("spawn tests", () => { let mockedCP: MockProxy; - let listeners: Record void>; + let listeners: EventEmitter; let proxy: KubeAuthProxy; beforeEach(async () => { mockedCP = mock(); - listeners = {}; + listeners = new EventEmitter(); jest.spyOn(Kubectl.prototype, "checkBinary").mockReturnValueOnce(Promise.resolve(true)); jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false)); mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => { - listeners[event] = listener; + listeners.on(event, listener); return mockedCP; }); mockedCP.stderr = mock(); mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners[`stderr/${event}`] = listener; + listeners.on(`stderr/${event}`, listener); + + return mockedCP.stderr; + }); + mockedCP.stderr.off.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stderr/${event}`, listener); + + return mockedCP.stderr; + }); + mockedCP.stderr.removeListener.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stderr/${event}`, listener); + + return mockedCP.stderr; + }); + mockedCP.stderr.once.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.once(`stderr/${event}`, listener); + + return mockedCP.stderr; + }); + mockedCP.stderr.removeAllListeners.mockImplementation((event?: string): Readable => { + listeners.removeAllListeners(event ?? `stderr/${event}`); return mockedCP.stderr; }); mockedCP.stdout = mock(); mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { - listeners[`stdout/${event}`] = listener; - listeners[`stdout/${event}`]("Starting to serve on 127.0.0.1:9191"); + listeners.on(`stdout/${event}`, listener); + + if (event === "data") { + listeners.emit("stdout/data", "Starting to serve on 127.0.0.1:9191"); + } + + return mockedCP.stdout; + }); + mockedCP.stdout.once.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.once(`stdout/${event}`, listener); + + return mockedCP.stdout; + }); + mockedCP.stdout.off.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stdout/${event}`, listener); + + return mockedCP.stdout; + }); + mockedCP.stdout.removeListener.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { + listeners.off(`stdout/${event}`, listener); + + return mockedCP.stdout; + }); + mockedCP.stdout.removeAllListeners.mockImplementation((event?: string): Readable => { + listeners.removeAllListeners(event ?? `stdout/${event}`); return mockedCP.stdout; }); @@ -175,36 +218,36 @@ describe("kube auth proxy tests", () => { it("should call spawn and broadcast errors", async () => { await proxy.run(); - listeners["error"]({ message: "foobarbat" }); + listeners.emit("error", { message: "foobarbat" }); - expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "foobarbat", error: true }); + expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "foobarbat", isError: true }); }); it("should call spawn and broadcast exit", async () => { await proxy.run(); - listeners["exit"](0); + listeners.emit("exit", 0); - expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "proxy exited with code: 0", error: false }); + expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "proxy exited with code: 0", isError: false }); }); it("should call spawn and broadcast errors from stderr", async () => { await proxy.run(); - listeners["stderr/data"]("an error"); + listeners.emit("stderr/data", "an error"); - expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "an error", error: true }); + expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "an error", isError: true }); }); it("should call spawn and broadcast stdout serving info", async () => { await proxy.run(); - expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "Authentication proxy started\n" }); + expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "Authentication proxy started", isError: false }); }); it("should call spawn and broadcast stdout other info", async () => { await proxy.run(); - listeners["stdout/data"]("some info"); + listeners.emit("stdout/data", "some info"); - expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "some info" }); + expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "some info", isError: false }); }); }); }); diff --git a/src/main/catalog/catalog-entity-registry.ts b/src/main/catalog/catalog-entity-registry.ts index 063f6f2356..e09af5501c 100644 --- a/src/main/catalog/catalog-entity-registry.ts +++ b/src/main/catalog/catalog-entity-registry.ts @@ -20,7 +20,7 @@ */ import { action, computed, IComputedValue, IObservableArray, makeObservable, observable } from "mobx"; -import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityKindData } from "../../common/catalog"; +import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityConstructor, CatalogEntityKindData } from "../../common/catalog"; import { iter } from "../../common/utils"; export class CatalogEntityRegistry { @@ -59,7 +59,7 @@ export class CatalogEntityRegistry { return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[]; } - getItemsByEntityClass({ apiVersion, kind }: CatalogEntityKindData): T[] { + getItemsByEntityClass({ apiVersion, kind }: CatalogEntityKindData & CatalogEntityConstructor): T[] { return this.getItemsForApiKind(apiVersion, kind); } } diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 53c79fda7c..ee1707b726 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -41,6 +41,8 @@ export class ClusterManager extends Singleton { private store = ClusterStore.getInstance(); deleting = observable.set(); + @observable visibleCluster: ClusterId | undefined = undefined; + constructor() { super(); makeObservable(this); @@ -61,8 +63,22 @@ export class ClusterManager extends Singleton { { fireImmediately: false }, ); - reaction(() => catalogEntityRegistry.getItemsForApiKind("entity.k8slens.dev/v1alpha1", "KubernetesCluster"), (entities) => { - this.syncClustersFromCatalog(entities); + reaction( + () => catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster), + entities => this.syncClustersFromCatalog(entities), + ); + + reaction(() => [ + catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster), + this.visibleCluster, + ] as const, ([entities, visibleCluster]) => { + for (const entity of entities) { + if (entity.getId() === visibleCluster) { + entity.status.active = true; + } else { + entity.status.active = false; + } + } }); observe(this.deleting, change => { @@ -240,27 +256,20 @@ export class ClusterManager extends Singleton { } getClusterForRequest(req: http.IncomingMessage): Cluster { - let cluster: Cluster = null; - // 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 = this.store.getById(clusterId); + const cluster = this.store.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); } - } else if (req.headers["x-cluster-id"]) { - cluster = this.store.getById(req.headers["x-cluster-id"].toString()); - } else { - const clusterId = getClusterIdFromHost(req.headers.host); - cluster = this.store.getById(clusterId); + return cluster; } - return cluster; + return this.store.getById(getClusterIdFromHost(req.headers.host)); } } diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 6f465c909b..1cbf4f980c 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -32,7 +32,7 @@ import logger from "./logger"; import { VersionDetector } from "./cluster-detectors/version-detector"; import { DetectorRegistry } from "./cluster-detectors/detector-registry"; import plimit from "p-limit"; -import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel } from "../common/cluster-types"; +import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate } from "../common/cluster-types"; import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../common/cluster-types"; import { storedKubeConfigFolder, toJS } from "../common/utils"; import type { Response } from "request"; @@ -117,12 +117,6 @@ export class Cluster implements ClusterModel, ClusterState { * @observable */ @observable disconnected = true; - /** - * Connection failure reason - * - * @observable - */ - @observable failureReason: string; /** * Does user have admin like access * @@ -358,14 +352,20 @@ export class Cluster implements ClusterModel, ClusterState { if (this.disconnected || !this.accessible) { await this.reconnect(); } + + this.broadcastConnectUpdate("Refreshing connection status ..."); await this.refreshConnectionStatus(); if (this.accessible) { + this.broadcastConnectUpdate("Refreshing cluster accessibility ..."); await this.refreshAccessibility(); - this.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard + // download kubectl in background, so it's not blocking dashboard + this.ensureKubectl() + .catch(error => logger.warn(`[CLUSTER]: failed to download kubectl for clusterId=${this.id}`, error)); + this.broadcastConnectUpdate("Connected, waiting for view to load ..."); } - this.activated = true; + this.activated = true; this.pushState(); } @@ -445,9 +445,8 @@ export class Cluster implements ClusterModel, ClusterState { private async refreshAccessibility(): Promise { this.isAdmin = await this.isClusterAdmin(); this.isGlobalWatchEnabled = await this.canUseWatchApi({ resource: "*" }); - - await this.refreshAllowedResources(); - + this.allowedNamespaces = await this.getAllowedNamespaces(); + this.allowedResources = await this.getAllowedResources(); this.ready = true; } @@ -462,15 +461,6 @@ export class Cluster implements ClusterModel, ClusterState { this.accessible = connectionStatus == ClusterStatus.AccessGranted; } - /** - * @internal - */ - @action - async refreshAllowedResources() { - this.allowedNamespaces = await this.getAllowedNamespaces(); - this.allowedResources = await this.getAllowedResources(); - } - async getKubeconfig(): Promise { const { config } = await loadConfigFromFile(this.kubeConfigPath); @@ -501,34 +491,35 @@ export class Cluster implements ClusterModel, ClusterState { this.metadata.version = versionData.value; - this.failureReason = null; - return ClusterStatus.AccessGranted; } catch (error) { - logger.error(`Failed to connect cluster "${this.contextName}": ${error}`); + logger.error(`[CLUSTER]: Failed to connect to "${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"; + this.broadcastConnectUpdate("Invalid credentials", true); return ClusterStatus.AccessDenied; } + + this.broadcastConnectUpdate(error.error || error.message, true); + + return ClusterStatus.Offline; } - this.failureReason = error.message; + + if (error.failed === true) { + if (error.timedOut === true) { + this.broadcastConnectUpdate("Connection timed out", true); + + return ClusterStatus.Offline; + } + + this.broadcastConnectUpdate("Failed to fetch credentials", true); + + return ClusterStatus.AccessDenied; + } + + this.broadcastConnectUpdate(error.message, true); return ClusterStatus.Offline; } @@ -579,7 +570,7 @@ export class Cluster implements ClusterModel, ClusterState { } toJSON(): ClusterModel { - const model: ClusterModel = { + return toJS({ id: this.id, contextName: this.contextName, kubeConfigPath: this.kubeConfigPath, @@ -589,29 +580,24 @@ export class Cluster implements ClusterModel, ClusterState { metadata: this.metadata, accessibleNamespaces: this.accessibleNamespaces, labels: this.labels, - }; - - return toJS(model); + }); } /** * Serializable cluster-state used for sync btw main <-> renderer */ getState(): ClusterState { - const state: ClusterState = { + return toJS({ apiUrl: this.apiUrl, online: this.online, ready: this.ready, disconnected: this.disconnected, accessible: this.accessible, - failureReason: this.failureReason, isAdmin: this.isAdmin, allowedNamespaces: this.allowedNamespaces, allowedResources: this.allowedResources, isGlobalWatchEnabled: this.isGlobalWatchEnabled, - }; - - return toJS(state); + }); } /** @@ -643,6 +629,17 @@ export class Cluster implements ClusterModel, ClusterState { }; } + /** + * broadcast an authentication update concerning this cluster + * @internal + */ + broadcastConnectUpdate(message: string, isError = false): void { + const update: KubeAuthUpdate = { message, isError }; + + logger.debug(`[CLUSTER]: broadcasting connection update`, { ...update, meta: this.getMeta() }); + broadcastMessage(`cluster:${this.id}:connection-update`, update); + } + protected async getAllowedNamespaces() { if (this.accessibleNamespaces.length) { return this.accessibleNamespaces; diff --git a/src/main/context-handler.ts b/src/main/context-handler.ts index 76458bdd30..dddb45228e 100644 --- a/src/main/context-handler.ts +++ b/src/main/context-handler.ts @@ -146,7 +146,7 @@ export class ContextHandler { proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy; } this.kubeAuthProxy = new KubeAuthProxy(this.cluster, proxyEnv); - this.kubeAuthProxy.run(); + await this.kubeAuthProxy.run(); } await this.kubeAuthProxy.whenReady; @@ -157,8 +157,4 @@ export class ContextHandler { this.kubeAuthProxy = undefined; this.apiTarget = undefined; } - - get proxyLastError(): string { - return this.kubeAuthProxy?.lastError || ""; - } } diff --git a/src/main/index.ts b/src/main/index.ts index 439d2e6471..e5b7f3d9f2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -63,8 +63,9 @@ import { ensureDir } from "fs-extra"; import { Router } from "./router"; import { initMenu } from "./menu"; import { initTray } from "./tray"; -import { kubeApiRequest, shellApiRequest } from "./proxy-functions"; +import { kubeApiRequest, shellApiRequest, ShellRequestAuthenticator } from "./proxy-functions"; import { AppPaths } from "../common/app-paths"; +import { ShellSession } from "./shell-session/shell-session"; injectSystemCAs(); @@ -122,7 +123,7 @@ app.on("second-instance", (event, argv) => { WindowManager.getInstance(false)?.ensureMainWindow(); }); -app.on("ready", async () => { +app.on("ready", async () => { logger.info(`🚀 Starting ${productName} from "${AppPaths.get("exe")}"`); logger.info("🐚 Syncing shell environment"); await shellSync(); @@ -134,6 +135,7 @@ app.on("ready", async () => { registerFileProtocol("static", __static); PrometheusProviderRegistry.createInstance(); + ShellRequestAuthenticator.createInstance().init(); initializers.initPrometheusProviderRegistry(); /** @@ -227,6 +229,7 @@ app.on("ready", async () => { onQuitCleanup.push( initMenu(windowManager), initTray(windowManager), + () => ShellSession.cleanup(), ); installDeveloperTools(); diff --git a/src/main/initializers/ipc.ts b/src/main/initializers/ipc.ts index ff79bd091b..206a1673db 100644 --- a/src/main/initializers/ipc.ts +++ b/src/main/initializers/ipc.ts @@ -20,13 +20,12 @@ */ import { BrowserWindow, dialog, IpcMainInvokeEvent } from "electron"; -import { KubernetesCluster } from "../../common/catalog-entities"; import { clusterFrameMap } from "../../common/cluster-frames"; import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler, clusterSetDeletingHandler, clusterClearDeletingHandler } from "../../common/cluster-ipc"; import type { ClusterId } from "../../common/cluster-types"; import { ClusterStore } from "../../common/cluster-store"; import { appEventBus } from "../../common/event-bus"; -import { dialogShowOpenDialogHandler, ipcMainHandle } from "../../common/ipc"; +import { dialogShowOpenDialogHandler, ipcMainHandle, ipcMainOn } from "../../common/ipc"; import { catalogEntityRegistry } from "../catalog"; import { pushCatalogToRenderer } from "../catalog-pusher"; import { ClusterManager } from "../cluster-manager"; @@ -54,16 +53,8 @@ export function initIpcMainHandlers() { } }); - ipcMainHandle(clusterVisibilityHandler, (event: IpcMainInvokeEvent, clusterId: ClusterId, visible: boolean) => { - const entity = catalogEntityRegistry.getById(clusterId); - - for (const kubeEntity of catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster)) { - kubeEntity.status.active = false; - } - - if (entity) { - entity.status.active = visible; - } + ipcMainOn(clusterVisibilityHandler, (event, clusterId?: ClusterId) => { + ClusterManager.getInstance().visibleCluster = clusterId; }); ipcMainHandle(clusterRefreshHandler, (event, clusterId: ClusterId) => { diff --git a/src/main/kube-auth-proxy.ts b/src/main/kube-auth-proxy.ts index c1e76e40fb..c52f97d4b1 100644 --- a/src/main/kube-auth-proxy.ts +++ b/src/main/kube-auth-proxy.ts @@ -22,7 +22,6 @@ import { ChildProcess, spawn } from "child_process"; import { waitUntilUsed } from "tcp-port-used"; import { randomBytes } from "crypto"; -import { broadcastMessage } from "../common/ipc"; import type { Cluster } from "./cluster"; import { Kubectl } from "./kubectl"; import logger from "./logger"; @@ -30,22 +29,16 @@ import * as url from "url"; import { getPortFrom } from "./utils/get-port"; import { makeObservable, observable, when } from "mobx"; -export interface KubeAuthProxyLog { - data: string; - error?: boolean; // stream=stderr -} - const startingServeRegex = /^starting to serve on (?
.+)/i; export class KubeAuthProxy { - public lastError: string; public readonly apiPrefix: string; public get port(): number { return this._port; } - protected _port: number; + protected _port?: number; protected cluster: Cluster; protected env: NodeJS.ProcessEnv = null; protected proxyProcess: ChildProcess; @@ -92,67 +85,56 @@ export class KubeAuthProxy { this.proxyProcess = spawn(proxyBin, args, { env: this.env }); this.proxyProcess.on("error", (error) => { - this.sendIpcLogMessage({ data: error.message, error: true }); + this.cluster.broadcastConnectUpdate(error.message, true); this.exit(); }); this.proxyProcess.on("exit", (code) => { - this.sendIpcLogMessage({ data: `proxy exited with code: ${code}`, error: code > 0 }); + this.cluster.broadcastConnectUpdate(`proxy exited with code: ${code}`, code > 0); + this.exit(); + }); + + this.proxyProcess.on("disconnect", () => { + this.cluster.broadcastConnectUpdate("Proxy disconnected communications", true ); this.exit(); }); this.proxyProcess.stderr.on("data", (data) => { - this.lastError = this.parseError(data.toString()); - this.sendIpcLogMessage({ data: data.toString(), error: true }); + this.cluster.broadcastConnectUpdate(data.toString(), true); + }); + + this.proxyProcess.stdout.on("data", (data: any) => { + if (typeof this._port === "number") { + this.cluster.broadcastConnectUpdate(data.toString()); + } }); this._port = await getPortFrom(this.proxyProcess.stdout, { lineRegex: startingServeRegex, - onFind: () => this.sendIpcLogMessage({ data: "Authentication proxy started\n" }), + onFind: () => this.cluster.broadcastConnectUpdate("Authentication proxy started"), }); - this.proxyProcess.stdout.on("data", (data: any) => { - this.sendIpcLogMessage({ data: data.toString() }); - }); + try { + await waitUntilUsed(this.port, 500, 10000); + this.ready = true; + } catch (error) { + this.cluster.broadcastConnectUpdate("Proxy port failed to be used within timelimit, restarting...", true); + this.exit(); - await waitUntilUsed(this.port, 500, 10000); - - this.ready = true; - } - - protected parseError(data: string) { - 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 this.run(); } - - return errorMsg; - } - - protected 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); } public exit() { this.ready = false; - if (!this.proxyProcess) return; - logger.debug("[KUBE-AUTH]: stopping local proxy", this.cluster.getMeta()); - this.proxyProcess.kill(); - this.proxyProcess.removeAllListeners(); - this.proxyProcess.stderr.removeAllListeners(); - this.proxyProcess.stdout.removeAllListeners(); - this.proxyProcess = null; + + if (this.proxyProcess) { + logger.debug("[KUBE-AUTH]: stopping local proxy", this.cluster.getMeta()); + this.proxyProcess.removeAllListeners(); + this.proxyProcess.stderr.removeAllListeners(); + this.proxyProcess.stdout.removeAllListeners(); + this.proxyProcess.kill(); + this.proxyProcess = null; + } } } diff --git a/src/main/kubeconfig-manager.ts b/src/main/kubeconfig-manager.ts index 9ad90a2dc5..c3b5f3f63b 100644 --- a/src/main/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager.ts @@ -49,7 +49,7 @@ export class KubeconfigManager { try { this.tempFile = await this.createProxyKubeconfig(); } catch (err) { - logger.error(`Failed to created temp config for auth-proxy`, { err }); + logger.error(`[KUBECONFIG-MANAGER]: Failed to created temp config for auth-proxy`, { err }); } } @@ -61,7 +61,7 @@ export class KubeconfigManager { return; } - logger.info(`Deleting temporary kubeconfig: ${this.tempFile}`); + logger.info(`[KUBECONFIG-MANAGER]: Deleting temporary kubeconfig: ${this.tempFile}`); await fs.unlink(this.tempFile); } @@ -70,7 +70,7 @@ export class KubeconfigManager { return; } - logger.info(`Deleting temporary kubeconfig: ${this.tempFile}`); + logger.info(`[KUBECONFIG-MANAGER]: Deleting temporary kubeconfig: ${this.tempFile}`); await fs.unlink(this.tempFile); this.tempFile = undefined; } @@ -80,8 +80,7 @@ export class KubeconfigManager { await this.contextHandler.ensureServer(); this.tempFile = await this.createProxyKubeconfig(); } catch (err) { - console.log(err); - logger.error(`Failed to created temp config for auth-proxy`, { err }); + logger.error(`[KUBECONFIG-MANAGER]: Failed to created temp config for auth-proxy`, err); } } @@ -124,7 +123,7 @@ export class KubeconfigManager { await fs.ensureDir(path.dirname(tempFile)); await fs.writeFile(tempFile, configYaml, { mode: 0o600 }); - logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`); + logger.debug(`[KUBECONFIG-MANAGER]: Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`); return tempFile; } diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index a3bee528cd..bfaaab27ef 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -60,12 +60,10 @@ export class LensProxy extends Singleton { public port: number; - constructor(protected router: Router, functions: LensProxyFunctions) { + constructor(protected router: Router, { shellApiRequest, kubeApiRequest, getClusterForRequest }: LensProxyFunctions) { super(); - const { shellApiRequest, kubeApiRequest } = functions; - - this.getClusterForRequest = functions.getClusterForRequest; + this.getClusterForRequest = getClusterForRequest; this.proxyServer = spdy.createServer({ spdy: { @@ -79,10 +77,16 @@ export class LensProxy extends Singleton { this.proxyServer .on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { const isInternal = req.url.startsWith(`${apiPrefix}?`); + const cluster = getClusterForRequest(req); + + if (!cluster) { + return void logger.error(`[LENS-PROXY]: Could not find cluster for upgrade request from url=${req.url}`); + } + const reqHandler = isInternal ? shellApiRequest : kubeApiRequest; - (async () => reqHandler({ req, socket, head }))() - .catch(error => logger.error(logger.error(`[LENS-PROXY]: failed to handle proxy upgrade: ${error}`))); + (async () => reqHandler({ req, socket, head, cluster }))() + .catch(error => logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error)); }); } @@ -142,6 +146,10 @@ export class LensProxy extends Singleton { res.flushHeaders(); } } + + proxyRes.on("aborted", () => { // happens when proxy target aborts connection + res.end(); + }); }); proxy.on("error", (error, req, res, target) => { diff --git a/src/main/proxy-functions/kube-api-request.ts b/src/main/proxy-functions/kube-api-request.ts index cbd18f9beb..c5c299e41d 100644 --- a/src/main/proxy-functions/kube-api-request.ts +++ b/src/main/proxy-functions/kube-api-request.ts @@ -23,18 +23,11 @@ import { chunk } from "lodash"; import net from "net"; import url from "url"; import { apiKubePrefix } from "../../common/vars"; -import { ClusterManager } from "../cluster-manager"; import type { ProxyApiRequestArgs } from "./types"; const skipRawHeaders = new Set(["Host", "Authorization"]); -export async function kubeApiRequest({ req, socket, head }: ProxyApiRequestArgs) { - const cluster = ClusterManager.getInstance().getClusterForRequest(req); - - if (!cluster) { - return; - } - +export async function kubeApiRequest({ req, socket, head, cluster }: ProxyApiRequestArgs) { const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); const apiUrl = url.parse(cluster.apiUrl); const pUrl = url.parse(proxyUrl); diff --git a/src/main/proxy-functions/shell-api-request.ts b/src/main/proxy-functions/shell-api-request.ts index 3edaecff81..76340585e1 100644 --- a/src/main/proxy-functions/shell-api-request.ts +++ b/src/main/proxy-functions/shell-api-request.ts @@ -19,29 +19,81 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type http from "http"; -import url from "url"; import logger from "../logger"; -import * as WebSocket from "ws"; +import { Server as WebSocketServer } from "ws"; import { NodeShellSession, LocalShellSession } from "../shell-session"; import type { ProxyApiRequestArgs } from "./types"; import { ClusterManager } from "../cluster-manager"; +import URLParse from "url-parse"; +import { ExtendedMap, Singleton } from "../../common/utils"; +import type { ClusterId } from "../../common/cluster-types"; +import { ipcMainHandle } from "../../common/ipc"; +import crypto from "crypto"; +import { promisify } from "util"; -export function shellApiRequest({ req, socket, head }: ProxyApiRequestArgs) { - const ws = new WebSocket.Server({ noServer: true }); +const randomBytes = promisify(crypto.randomBytes); - ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => { - const cluster = ClusterManager.getInstance().getClusterForRequest(req); - const nodeParam = url.parse(req.url, true).query["node"]?.toString(); - const shell = nodeParam - ? new NodeShellSession(socket, cluster, nodeParam) - : new LocalShellSession(socket, cluster); +export class ShellRequestAuthenticator extends Singleton { + private tokens = new ExtendedMap>(); + + init() { + ipcMainHandle("cluster:shell-api", async (event, clusterId, tabId) => { + const authToken = Uint8Array.from(await randomBytes(128)); + + this.tokens + .getOrInsert(clusterId, () => new Map()) + .set(tabId, authToken); + + return authToken; + }); + } + + /** + * Authenticates a single use token for creating a new shell + * @param clusterId The `ClusterId` for the shell + * @param tabId The ID for the shell + * @param token The value that is being presented as a one time authentication token + * @returns `true` if `token` was valid, false otherwise + */ + authenticate(clusterId: ClusterId, tabId: string, token: string): boolean { + const clusterTokens = this.tokens.get(clusterId); + + if (!clusterTokens) { + return false; + } + + const authToken = clusterTokens.get(tabId); + const buf = Uint8Array.from(Buffer.from(token, "base64")); + + if (authToken instanceof Uint8Array && authToken.length === buf.length && crypto.timingSafeEqual(authToken, buf)) { + // remove the token because it is a single use token + clusterTokens.delete(tabId); + + return true; + } + + return false; + } +} + +export function shellApiRequest({ req, socket, head }: ProxyApiRequestArgs): void { + const cluster = ClusterManager.getInstance().getClusterForRequest(req); + const { query: { node, shellToken, id: tabId }} = new URLParse(req.url, true); + + if (!cluster || !ShellRequestAuthenticator.getInstance().authenticate(cluster.id, tabId, shellToken)) { + socket.write("Invalid shell request"); + + return void socket.end(); + } + + const ws = new WebSocketServer({ noServer: true }); + + ws.handleUpgrade(req, socket, head, (webSocket) => { + const shell = node + ? new NodeShellSession(webSocket, cluster, node, tabId) + : new LocalShellSession(webSocket, cluster, tabId); shell.open() - .catch(error => logger.error(`[SHELL-SESSION]: failed to open: ${error}`, { error })); - })); - - ws.handleUpgrade(req, socket, head, (con) => { - ws.emit("connection", con, req); + .catch(error => logger.error(`[SHELL-SESSION]: failed to open a ${node ? "node" : "local"} shell`, error)); }); } diff --git a/src/main/proxy-functions/types.ts b/src/main/proxy-functions/types.ts index 57d3db1b7c..2a41b9b97f 100644 --- a/src/main/proxy-functions/types.ts +++ b/src/main/proxy-functions/types.ts @@ -21,9 +21,11 @@ import type http from "http"; import type net from "net"; +import type { Cluster } from "../cluster"; export interface ProxyApiRequestArgs { req: http.IncomingMessage, socket: net.Socket, head: Buffer, + cluster: Cluster, } diff --git a/src/main/routes/metrics-route.ts b/src/main/routes/metrics-route.ts index b130670213..242bc9d167 100644 --- a/src/main/routes/metrics-route.ts +++ b/src/main/routes/metrics-route.ts @@ -46,7 +46,7 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa return await getMetrics(cluster, prometheusPath, { query, ...queryParams }); } catch (error) { if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) { - logger.error("[Metrics]: metrics not available", { error }); + logger.error("[Metrics]: metrics not available", error); throw new Error("Metrics not available"); } diff --git a/src/main/shell-session/local-shell-session.ts b/src/main/shell-session/local-shell-session.ts index ed1208fcbd..1223ea7c3e 100644 --- a/src/main/shell-session/local-shell-session.ts +++ b/src/main/shell-session/local-shell-session.ts @@ -40,7 +40,7 @@ export class LocalShellSession extends ShellSession { const shell = env.PTYSHELL; const args = await this.getShellArgs(shell); - super.open(env.PTYSHELL, args, env); + await this.openShellProcess(env.PTYSHELL, args, env); } protected async getShellArgs(shell: string): Promise { diff --git a/src/main/shell-session/node-shell-session.ts b/src/main/shell-session/node-shell-session.ts index 494137de7d..4ab1d97745 100644 --- a/src/main/shell-session/node-shell-session.ts +++ b/src/main/shell-session/node-shell-session.ts @@ -19,26 +19,27 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type * as WebSocket from "ws"; +import type WebSocket from "ws"; import { v4 as uuid } from "uuid"; import * as k8s from "@kubernetes/client-node"; import type { KubeConfig } from "@kubernetes/client-node"; import type { Cluster } from "../cluster"; import { ShellOpenError, ShellSession } from "./shell-session"; import { get } from "lodash"; +import { Node, NodesApi } from "../../common/k8s-api/endpoints"; +import { KubeJsonApi } from "../../common/k8s-api/kube-json-api"; +import logger from "../logger"; export class NodeShellSession extends ShellSession { ShellType = "node-shell"; - protected podId = `node-shell-${uuid()}`; + protected readonly podName = `node-shell-${uuid()}`; protected kc: KubeConfig; - protected get cwd(): string | undefined { - return undefined; - } + protected readonly cwd: string | undefined = undefined; - constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string) { - super(socket, cluster); + constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string, terminalId: string) { + super(socket, cluster, terminalId); } public async open() { @@ -55,10 +56,27 @@ export class NodeShellSession extends ShellSession { throw new ShellOpenError("failed to create node pod", error); } - const args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"]; const env = await this.getCachedShellEnv(); + const args = ["exec", "-i", "-t", "-n", "kube-system", this.podName, "--"]; + const nodeApi = new NodesApi({ + objectConstructor: Node, + request: KubeJsonApi.forCluster(this.cluster), + }); + const node = await nodeApi.get({ name: this.nodeName }); + const nodeOs = node.getOperatingSystem(); - await super.open(shell, args, env); + switch (nodeOs) { + default: + logger.warn(`[NODE-SHELL-SESSION]: could not determine node OS, falling back with assumption of linux`); + case "linux": + args.push("sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"); + break; + case "windows": + args.push("powershell"); + break; + } + + await this.openShellProcess(shell, args, env); } protected createNodeShellPod() { @@ -73,7 +91,7 @@ export class NodeShellSession extends ShellSession { .makeApiClient(k8s.CoreV1Api) .createNamespacedPod("kube-system", { metadata: { - name: this.podId, + name: this.podName, namespace: "kube-system", }, spec: { @@ -101,33 +119,39 @@ export class NodeShellSession extends ShellSession { } protected waitForRunningPod(): Promise { - return new Promise((resolve, reject) => { - const watch = new k8s.Watch(this.kc); + logger.debug(`[NODE-SHELL]: waiting for ${this.podName} to be running`); - watch + return new Promise((resolve, reject) => { + new k8s.Watch(this.kc) .watch(`/api/v1/namespaces/kube-system/pods`, {}, // callback is called for each received object. - (type, obj) => { - if (obj.metadata.name == this.podId && obj.status.phase === "Running") { - resolve(); + (type, { metadata: { name }, status }) => { + if (name === this.podName) { + switch (status.phase) { + case "Running": + return resolve(); + case "Failed": + return reject(`Failed to be created: ${status.message || "unknown error"}`); + } } }, // done callback is called if the watch terminates normally (err) => { - console.log(err); + logger.error(`[NODE-SHELL]: ${this.podName} was not created in time`); reject(err); }, ) .then(req => { setTimeout(() => { - console.log("aborting"); + logger.error(`[NODE-SHELL]: aborting wait for ${this.podName}, timing out`); req.abort(); - }, 2 * 60 * 1000); + reject("Pod creation timed out"); + }, 2 * 60 * 1000); // 2 * 60 * 1000 }) - .catch(err => { - console.log("watch failed"); - reject(err); + .catch(error => { + logger.error(`[NODE-SHELL]: waiting for ${this.podName} failed: ${error}`); + reject(error); }); }); } @@ -141,6 +165,7 @@ export class NodeShellSession extends ShellSession { this .kc .makeApiClient(k8s.CoreV1Api) - .deleteNamespacedPod(this.podId, "kube-system"); + .deleteNamespacedPod(this.podName, "kube-system") + .catch(error => logger.warn(`[NODE-SHELL]: failed to remove pod shell`, error)); } } diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts index 987a00afdd..bfd9a1601e 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -22,7 +22,7 @@ import fse from "fs-extra"; import type { Cluster } from "../cluster"; import { Kubectl } from "../kubectl"; -import type * as WebSocket from "ws"; +import type WebSocket from "ws"; import { shellEnv } from "../utils/shell-env"; import { app } from "electron"; import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; @@ -31,6 +31,7 @@ import { isWindows } from "../../common/vars"; import { UserStore } from "../../common/user-store"; import * as pty from "node-pty"; import { appEventBus } from "../../common/event-bus"; +import logger from "../logger"; export class ShellOpenError extends Error { constructor(message: string, public cause: Error) { @@ -40,41 +41,140 @@ export class ShellOpenError extends Error { } } +export enum WebSocketCloseEvent { + /** + * The connection successfully completed the purpose for which it was created. + */ + NormalClosure = 1000, + /** + * The endpoint is going away, either because of a server failure or because + * the browser is navigating away from the page that opened the connection. + */ + GoingAway = 1001, + /** + * The endpoint is terminating the connection due to a protocol error. + */ + ProtocolError = 1002, + /** + * The connection is being terminated because the endpoint received data of a + * type it cannot accept. (For example, a text-only endpoint received binary + * data.) + */ + UnsupportedData = 1003, + /** + * Indicates that no status code was provided even though one was expected. + */ + NoStatusReceived = 1005, + /** + * Indicates that a connection was closed abnormally (that is, with no close + * frame being sent) when a status code is expected. + */ + AbnormalClosure = 1006, + /** + * The endpoint is terminating the connection because a message was received + * that contained inconsistent data (e.g., non-UTF-8 data within a text message). + */ + InvalidFramePayloadData = 1007, + /** + * The endpoint is terminating the connection because it received a message + * that violates its policy. This is a generic status code, used when codes + * 1003 and 1009 are not suitable. + */ + PolicyViolation = 1008, + /** + * The endpoint is terminating the connection because a data frame was + * received that is too large. + */ + MessageTooBig = 1009, + /** + * The client is terminating the connection because it expected the server to + * negotiate one or more extension, but the server didn't. + */ + MissingExtension = 1010, + /** + * The server is terminating the connection because it encountered an + * unexpected condition that prevented it from fulfilling the request. + */ + InternalError = 1011, + /** + * The server is terminating the connection because it is restarting. + */ + ServiceRestart = 1012, + /** + * The server is terminating the connection due to a temporary condition, + * e.g. it is overloaded and is casting off some of its clients. + */ + TryAgainLater = 1013, + /** + * The server was acting as a gateway or proxy and received an invalid + * response from the upstream server. This is similar to 502 HTTP Status Code. + */ + BadGateway = 1014, + /** + * Indicates that the connection was closed due to a failure to perform a TLS + * handshake (e.g., the server certificate can't be verified). + */ + TlsHandshake = 1015, +} + export abstract class ShellSession { abstract ShellType: string; - static shellEnvs: Map> = new Map(); + private static shellEnvs = new Map>(); + private static processes = new Map(); + + /** + * Kill all remaining shell backing processes. Should be called when about to + * quit + */ + public static cleanup(): void { + for (const shellProcess of this.processes.values()) { + try { + process.kill(shellProcess.pid); + } catch {} + } + + this.processes.clear(); + } protected kubectl: Kubectl; protected running = false; - protected shellProcess: pty.IPty; protected kubectlBinDirP: Promise; protected kubeconfigPathP: Promise; + protected readonly terminalId: string; protected abstract get cwd(): string | undefined; - constructor(protected websocket: WebSocket, protected cluster: Cluster) { + protected ensureShellProcess(shell: string, args: string[], env: Record, cwd: string): pty.IPty { + if (!ShellSession.processes.has(this.terminalId)) { + ShellSession.processes.set(this.terminalId, pty.spawn(shell, args, { + cols: 80, + cwd, + env, + name: "xterm-256color", + rows: 30, + })); + } + + return ShellSession.processes.get(this.terminalId); + } + + constructor(protected websocket: WebSocket, protected cluster: Cluster, terminalId: string) { this.kubectl = new Kubectl(cluster.version); this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath(); this.kubectlBinDirP = this.kubectl.binDir(); + this.terminalId = `${cluster.id}:${terminalId}`; } - protected async open(shell: string, args: string[], env: Record) { + protected async openShellProcess(shell: string, args: string[], env: Record) { const cwd = (this.cwd && await fse.pathExists(this.cwd)) ? this.cwd : env.HOME; + const shellProcess = this.ensureShellProcess(shell, args, env, cwd); - this.shellProcess = pty.spawn(shell, args, { - cols: 80, - cwd, - env, - name: "xterm-256color", - rows: 30, - }); this.running = true; - - this.shellProcess.onData(data => this.sendResponse(data)); - this.shellProcess.onExit(({ exitCode }) => { + shellProcess.onData(data => this.sendResponse(data)); + shellProcess.onExit(({ exitCode }) => { this.running = false; if (exitCode > 0) { @@ -95,24 +195,28 @@ export abstract class ShellSession { switch (data[0]) { case "0": - this.shellProcess.write(message); + shellProcess.write(message); break; case "4": const { Width, Height } = JSON.parse(message); - this.shellProcess.resize(Width, Height); + shellProcess.resize(Width, Height); break; } }) - .on("close", () => { - if (this.running) { + .on("close", (code) => { + logger.debug(`[SHELL-SESSION]: websocket for ${this.terminalId} closed with code=${code}`); + + if (this.running && code !== WebSocketCloseEvent.AbnormalClosure) { + // This code is the one that gets sent when the network is turned off try { - process.kill(this.shellProcess.pid); + logger.info(`[SHELL-SESSION]: Killing shell process for ${this.terminalId}`); + process.kill(shellProcess.pid); + ShellSession.processes.delete(this.terminalId); } catch (e) { } + this.running = false; } - - this.running = false; }); appEventBus.emit({ name: this.ShellType, action: "open" }); @@ -191,7 +295,7 @@ export abstract class ShellSession { return env; } - protected exit(code = 1000) { + protected exit(code = WebSocketCloseEvent.NormalClosure) { if (this.websocket.readyState == this.websocket.OPEN) { this.websocket.close(code); } diff --git a/src/main/shell-sync.ts b/src/main/shell-sync.ts index d699969224..bb2352e51d 100644 --- a/src/main/shell-sync.ts +++ b/src/main/shell-sync.ts @@ -22,10 +22,8 @@ import { shellEnv } from "./utils/shell-env"; import os from "os"; import { app } from "electron"; - -interface Env { - [key: string]: string; -} +import logger from "./logger"; +import { isSnap } from "../common/vars"; /** * shellSync loads what would have been the environment if this application was @@ -33,12 +31,7 @@ interface Env { * useful on macos where this always needs to be done. */ export async function shellSync() { - const { shell } = os.userInfo(); - let envVars = {}; - - envVars = await shellEnv(shell); - - const env: Env = JSON.parse(JSON.stringify(envVars)); + const env = await shellEnv(os.userInfo().shell); if (!env.LANG) { // the LANG env var expects an underscore instead of electron's dash @@ -47,9 +40,8 @@ export async function shellSync() { env.LANG += ".UTF-8"; } - // Overwrite PATH on darwin - if (process.env.NODE_ENV === "production" && process.platform === "darwin") { - process.env["PATH"] = env.PATH; + if (!isSnap) { + process.env.PATH = env.PATH; } // The spread operator allows joining of objects. The precedence is last to first. @@ -57,4 +49,6 @@ export async function shellSync() { ...env, ...process.env, }; + + logger.debug(`[SHELL-SYNC]: Synced shell env, and updating`, env, process.env); } diff --git a/src/main/utils/shell-env.ts b/src/main/utils/shell-env.ts index eaaaa33a5e..644121c3ac 100644 --- a/src/main/utils/shell-env.ts +++ b/src/main/utils/shell-env.ts @@ -22,9 +22,7 @@ import shellEnvironment from "shell-env"; import logger from "../logger"; -export interface EnvironmentVariables { - readonly [key: string]: string; -} +export type EnvironmentVariables = Record; let shellSyncFailed = false; @@ -40,17 +38,15 @@ let shellSyncFailed = false; * returned if the call fails. */ export async function shellEnv(shell?: string, forceRetry = false) : Promise { - let envVars = {}; - if (forceRetry) { shellSyncFailed = false; } if (!shellSyncFailed) { try { - envVars = await Promise.race([ + return await Promise.race([ shellEnvironment(shell), - new Promise((_resolve, reject) => setTimeout(() => { + new Promise((_resolve, reject) => setTimeout(() => { reject(new Error("Resolving shell environment is taking very long. Please review your shell configuration.")); }, 30_000)), ]); @@ -62,5 +58,5 @@ export async function shellEnv(shell?: string, forceRetry = false) : Promise { - logger.info("[WINDOW-MANAGER]: Attaching webview"); + logger.debug("[WINDOW-MANAGER]: Attaching webview"); // Following is security recommendations because we allow webview tag (webviewTag: true) // suggested by https://www.electronjs.org/docs/tutorial/security#11-verify-webview-options-before-creation // and https://www.electronjs.org/docs/tutorial/security#10-do-not-use-allowpopups diff --git a/src/renderer/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index 1a66b25f46..779d729934 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -19,13 +19,13 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { boundMethod, base64, EventEmitter } from "../utils"; +import { boundMethod, base64, EventEmitter, getHostedClusterId } from "../utils"; import { WebSocketApi } from "./websocket-api"; import isEqual from "lodash/isEqual"; import { isDevelopment } from "../../common/vars"; import url from "url"; import { makeObservable, observable } from "mobx"; -import type { ParsedUrlQueryInput } from "querystring"; +import { ipcRenderer } from "electron"; export enum TerminalChannels { STDIN = 0, @@ -50,7 +50,7 @@ enum TerminalColor { export type TerminalApiQuery = Record & { id: string; node?: string; - type?: string | "node"; + type?: string; }; export class TerminalApi extends WebSocketApi { @@ -58,9 +58,8 @@ export class TerminalApi extends WebSocketApi { public onReady = new EventEmitter<[]>(); @observable public isReady = false; - public readonly url: string; - constructor(protected options: TerminalApiQuery) { + constructor(protected query: TerminalApiQuery) { super({ logging: isDevelopment, flushOnOpen: false, @@ -68,30 +67,41 @@ export class TerminalApi extends WebSocketApi { }); makeObservable(this); - const { hostname, protocol, port } = location; - const query: ParsedUrlQueryInput = { - id: options.id, - }; + if (query.node) { + query.type ||= "node"; + } + } - if (options.node) { - query.node = options.node; - query.type = options.type || "node"; + async connect() { + if (!this.socket) { + /** + * Only emit this message if we are not "reconnecting", so as to keep the + * output display clean when the computer wakes from sleep + */ + this.emitStatus("Connecting ..."); } - this.url = url.format({ + const authTokenArray = await ipcRenderer.invoke("cluster:shell-api", getHostedClusterId(), this.query.id); + + if (!(authTokenArray instanceof Uint8Array)) { + throw new TypeError("ShellApi token is not a Uint8Array"); + } + + const { hostname, protocol, port } = location; + const socketUrl = url.format({ protocol: protocol.includes("https") ? "wss" : "ws", hostname, port, pathname: "/api", - query, + query: { + ...this.query, + shellToken: Buffer.from(authTokenArray).toString("base64"), + }, slashes: true, }); - } - connect() { - this.emitStatus("Connecting ..."); this.onData.addListener(this._onReady, { prepend: true }); - super.connect(this.url); + super.connect(socketUrl); } destroy() { diff --git a/src/renderer/api/websocket-api.ts b/src/renderer/api/websocket-api.ts index 99917f2f30..fbe5142360 100644 --- a/src/renderer/api/websocket-api.ts +++ b/src/renderer/api/websocket-api.ts @@ -37,11 +37,11 @@ interface IMessage { } export enum WebSocketApiState { - PENDING = -1, - OPEN, - CONNECTING, - RECONNECTING, - CLOSED, + PENDING = "pending", + OPEN = "open", + CONNECTING = "connecting", + RECONNECTING = "reconnecting", + CLOSED = "closed", } export class WebSocketApi { diff --git a/src/renderer/components/+apps-helm-charts/helm-chart-details.scss b/src/renderer/components/+apps-helm-charts/helm-chart-details.scss index 7bee3b7f10..c8d0ac6560 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart-details.scss +++ b/src/renderer/components/+apps-helm-charts/helm-chart-details.scss @@ -22,7 +22,7 @@ .HelmChartDetails { .intro-logo { margin-right: $margin * 2; - background: $helmLogoBackground; + background: var(--helmLogoBackground); border-radius: $radius; max-width: 150px; max-height: 100px; @@ -39,7 +39,7 @@ .intro-contents { .description { font-weight: bold; - color: $textColorAccent; + color: var(--textColorAccent); padding-bottom: $padding; .Button { diff --git a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx index ae27987f8b..739d8082e3 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx @@ -49,9 +49,9 @@ const LargeTooltip = withStyles({ @observer export class HelmChartDetails extends Component { @observable chartVersions: HelmChart[]; - @observable selectedChart: HelmChart; - @observable readme: string = null; - @observable error: string = null; + @observable selectedChart?: HelmChart; + @observable readme?: string; + @observable error?: string; private abortController?: AbortController; @@ -68,6 +68,10 @@ export class HelmChartDetails extends Component { disposeOnUnmount(this, [ reaction(() => this.props.chart, async ({ name, repo, version }) => { try { + this.selectedChart = undefined; + this.chartVersions = undefined; + this.readme = undefined; + const { readme, versions } = await getChartDetails(repo, name, { version }); this.readme = readme; diff --git a/src/renderer/components/+apps-helm-charts/helm-charts.scss b/src/renderer/components/+apps-helm-charts/helm-charts.scss index afa998d944..1779d8b5b3 100644 --- a/src/renderer/components/+apps-helm-charts/helm-charts.scss +++ b/src/renderer/components/+apps-helm-charts/helm-charts.scss @@ -48,7 +48,7 @@ width: $iconSize; height: $iconSize; border-radius: 50%; - background: $helmImgBackground url(helm-placeholder.svg) center center no-repeat; + background: var(--helmImgBackground) url(helm-placeholder.svg) center center no-repeat; background-size: 72%; // bg size looks same as image on top of it margin: auto; @@ -58,7 +58,7 @@ height: inherit; visibility: hidden; border-radius: inherit; - background-color: $helmImgBackground; + background-color: var(--helmImgBackground); padding: $padding * 0.5; &.visible { @@ -74,11 +74,11 @@ &.repository { &.stable { - color: $helmStableRepo; + color: var(--helmStableRepo); } &.incubator { - color: $helmIncubatorRepo; + color: var(--helmIncubatorRepo); } } } diff --git a/src/renderer/components/+apps-releases/release.mixins.scss b/src/renderer/components/+apps-releases/release.mixins.scss index dc4c97ccaf..3b9ee6ab0a 100644 --- a/src/renderer/components/+apps-releases/release.mixins.scss +++ b/src/renderer/components/+apps-releases/release.mixins.scss @@ -20,12 +20,12 @@ */ $release-status-color-list: ( - deployed: $colorSuccess, - failed: $colorError, - deleting: $colorWarning, - pendingInstall: $colorInfo, - pendingUpgrade: $colorInfo, - pendingRollback: $colorInfo, + deployed: var(--colorSuccess), + failed: var(--colorError), + deleting: var(--colorWarning), + pendingInstall: var(--colorInfo), + pendingUpgrade: var(--colorInfo), + pendingRollback: var(--colorInfo), ); @mixin release-status-bgs { diff --git a/src/renderer/components/+apps-releases/releases.scss b/src/renderer/components/+apps-releases/releases.scss index 8d823ac271..e7995b3aa9 100644 --- a/src/renderer/components/+apps-releases/releases.scss +++ b/src/renderer/components/+apps-releases/releases.scss @@ -32,7 +32,7 @@ margin-left: $margin; &.new-version { - color: $colorInfo; + color: var(--colorInfo); } } } diff --git a/src/renderer/components/+catalog/catalog-entity-item.tsx b/src/renderer/components/+catalog/catalog-entity-item.tsx index 1489ef9efc..56b12c9ad1 100644 --- a/src/renderer/components/+catalog/catalog-entity-item.tsx +++ b/src/renderer/components/+catalog/catalog-entity-item.tsx @@ -26,12 +26,9 @@ import type { ItemObject } from "../../../common/item.store"; import { Badge } from "../badge"; import { navigation } from "../../navigation"; import { searchUrlParam } from "../input"; -import { makeCss } from "../../../common/utils/makeCss"; import { KubeObject } from "../../../common/k8s-api/kube-object"; import type { CatalogEntityRegistry } from "../../api/catalog-entity-registry"; -const css = makeCss(styles); - export class CatalogEntityItem implements ItemObject { constructor(public entity: T, private registry: CatalogEntityRegistry) { if (!(entity instanceof CatalogEntity)) { @@ -79,7 +76,7 @@ export class CatalogEntityItem implements ItemObject { return this.labels .map(label => ( span { overflow: hidden; - text-overflow: ellipsis; - padding-right: 24px; + text-overflow: ellipsis; + padding-left: var(--padding); + } - .pinIcon { - position: absolute; - right: 0; - opacity: 0; - transition: none; + :global(.HotbarIcon){ + align-self: center; - &:hover { - /* Drop styles defined for */ - background-color: transparent; - box-shadow: none; - } + div { + /* icons with plain text */ + font-size: var(--unit); + } + + .Icon { + /* icons with font-icon */ + font-size: var(--small-size); } } - &:hover .pinIcon { - opacity: 1; + .pinIcon { + position: absolute; + right: 0; + transition: none; + display: none; + + &:hover { + /* Drop styles defined for */ + background-color: transparent; + box-shadow: none; + } } } -.iconCell { - @apply flex items-center max-w-[40px]; -} - -.iconCell > div * { - font-size: var(--unit); -} - -.nameCell { -} - .sourceCell { max-width: 100px; } .statusCell { max-width: 100px; -} -.connected, .available { - color: var(--colorSuccess); -} + :global { + .connected, .available { + color: var(--colorSuccess); + } -.disconnected, .deleting, .unavailable { - color: var(--halfGray); + .disconnected, .deleting, .unavailable { + color: var(--halfGray); + } + } } .labelsCell { diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 1c1da5e32c..62b3258f6d 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -37,8 +37,7 @@ import { CatalogAddButton } from "./catalog-add-button"; import type { RouteComponentProps } from "react-router"; import { Notifications } from "../notifications"; import { MainLayout } from "../layout/main-layout"; -import { createStorage, cssNames, prevDefault } from "../../utils"; -import { makeCss } from "../../../common/utils/makeCss"; +import { createStorage, prevDefault } from "../../utils"; import { CatalogEntityDetails } from "./catalog-entity-details"; import { browseCatalogTab, catalogURL, CatalogViewRouteParam } from "../../../common/routes"; import { CatalogMenu } from "./catalog-menu"; @@ -56,8 +55,6 @@ enum sortBy { status = "status", } -const css = makeCss(styles); - interface Props extends RouteComponentProps { catalogEntityStore?: CatalogEntityStore; } @@ -73,6 +70,7 @@ export class Catalog extends React.Component { makeObservable(this); this.catalogEntityStore = props.catalogEntityStore; } + static defaultProps = { catalogEntityStore: new CatalogEntityStore(), }; @@ -105,7 +103,7 @@ export class Catalog extends React.Component { this.activeTab = routeTab; this.catalogEntityStore.activeCategory = item; }); - } catch(error) { + } catch (error) { console.error(error); Notifications.error(

Unknown category: {routeTab}

); } @@ -177,7 +175,7 @@ export class Catalog extends React.Component { renderNavigation() { return ( - + ); } @@ -214,23 +212,7 @@ export class Catalog extends React.Component { const isItemInHotbar = HotbarStore.getInstance().isAddedToActive(item.entity); return ( -
- {item.name} - isItemInHotbar ? this.removeFromHotbar(item) : this.addToHotbar(item))} - /> -
- ); - } - - renderIcon(item: CatalogEntityItem) { - return ( - + <> { background={item.entity.spec.icon?.background} size={24} /> - + {item.name} + isItemInHotbar ? this.removeFromHotbar(item) : this.addToHotbar(item))} + /> + ); } @@ -254,11 +245,11 @@ export class Catalog extends React.Component { return ( item.name, @@ -270,23 +261,21 @@ export class Catalog extends React.Component { entity => entity.searchFields, ]} renderTableHeader={[ - { title: "", className: css.iconCell, id: "icon" }, - { title: "Name", className: css.nameCell, sortBy: sortBy.name, id: "name" }, - !activeCategory && { title: "Kind", className: css.kindCell, sortBy: sortBy.kind, id: "kind" }, - { title: "Source", className: css.sourceCell, sortBy: sortBy.source, id: "source" }, - { title: "Labels", className: css.labelsCell, id: "labels" }, - { title: "Status", className: css.statusCell, sortBy: sortBy.status, id: "status" }, + { title: "Name", className: styles.entityName, sortBy: sortBy.name, id: "name" }, + !activeCategory && { title: "Kind", sortBy: sortBy.kind, id: "kind" }, + { title: "Source", className: styles.sourceCell, sortBy: sortBy.source, id: "source" }, + { title: "Labels", className: styles.labelsCell, id: "labels" }, + { title: "Status", className: styles.statusCell, sortBy: sortBy.status, id: "status" }, ].filter(Boolean)} customizeTableRowProps={item => ({ disabled: !item.enabled, })} renderTableContents={item => [ - this.renderIcon(item), this.renderName(item), !activeCategory && item.kind, item.source, item.getLabelBadges(), - { title: item.phase, className: cssNames(css[item.phase]) }, + {item.phase}, ].filter(Boolean)} onDetails={this.onDetails} renderItemMenu={this.renderItemMenu} @@ -302,7 +291,7 @@ export class Catalog extends React.Component { return (
- { this.renderList() } + {this.renderList()}
{ this.catalogEntityStore.selectedItem diff --git a/src/renderer/components/+config-autoscalers/autoscaler.mixins.scss b/src/renderer/components/+config-autoscalers/autoscaler.mixins.scss index 8e4ae50e35..0cc8936ee0 100644 --- a/src/renderer/components/+config-autoscalers/autoscaler.mixins.scss +++ b/src/renderer/components/+config-autoscalers/autoscaler.mixins.scss @@ -20,9 +20,9 @@ */ $hpa-status-colors: ( - abletoscale: $colorOk, - scalingactive: $colorInfo, - scalinglimited: $colorSoftError, + abletoscale: var(--colorOk), + scalingactive: var(--colorInfo), + scalinglimited: var(--colorSoftError), ); @mixin hpa-status-bgc { diff --git a/src/renderer/components/+config-autoscalers/hpa.tsx b/src/renderer/components/+config-autoscalers/hpa.tsx index 428a84b369..5133aa1d4e 100644 --- a/src/renderer/components/+config-autoscalers/hpa.tsx +++ b/src/renderer/components/+config-autoscalers/hpa.tsx @@ -50,11 +50,14 @@ interface Props extends RouteComponentProps { export class HorizontalPodAutoscalers extends React.Component { getTargets(hpa: HorizontalPodAutoscaler) { const metrics = hpa.getMetrics(); - const metricsRemainCount = metrics.length - 1; - const metricsRemain = metrics.length > 1 ? <>{metricsRemainCount} more... : null; - const metricValues = hpa.getMetricValues(metrics[0]); - return

{metricValues} {metricsRemain && "+"}{metricsRemain}

; + if (metrics.length === 0) { + return

--

; + } + + const metricsRemain = metrics.length > 1 ? `+${metrics.length - 1} more...` : ""; + + return

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

; } render() { diff --git a/src/renderer/components/+config-maps/config-map-details.scss b/src/renderer/components/+config-maps/config-map-details.scss index 7a69ff0b8f..632fbc2875 100644 --- a/src/renderer/components/+config-maps/config-map-details.scss +++ b/src/renderer/components/+config-maps/config-map-details.scss @@ -24,7 +24,7 @@ margin-bottom: $margin * 2; .name { - color: $textColorSecondary; + color: var(--textColorSecondary); font-weight: $font-weight-bold; padding-bottom: $padding * 0.5; } diff --git a/src/renderer/components/+config-resource-quotas/add-quota-dialog.scss b/src/renderer/components/+config-resource-quotas/add-quota-dialog.scss index ac91aa794e..57d6665001 100644 --- a/src/renderer/components/+config-resource-quotas/add-quota-dialog.scss +++ b/src/renderer/components/+config-resource-quotas/add-quota-dialog.scss @@ -33,14 +33,14 @@ .quota { --flex-gap: #{$padding}; - border: 1px solid $halfGray; + border: 1px solid var(--halfGray); border-radius: $radius; margin: $margin * 0.5; padding: $padding * 0.5 $padding; transition: all 150ms ease; &:hover { - box-shadow: inset 0 0 0 1px $borderColor; + box-shadow: inset 0 0 0 1px var(--borderColor); } .name { @@ -48,7 +48,7 @@ } .value { - color: $contentColor; + color: var(--contentColor); } .Icon:hover { diff --git a/src/renderer/components/+config-resource-quotas/resource-quota-details.scss b/src/renderer/components/+config-resource-quotas/resource-quota-details.scss index 54cb915b3e..056741fcf7 100644 --- a/src/renderer/components/+config-resource-quotas/resource-quota-details.scss +++ b/src/renderer/components/+config-resource-quotas/resource-quota-details.scss @@ -30,7 +30,7 @@ .LineProgress { margin-top: 3px; width: 100%; - color: $colorInfo; + color: var(--colorInfo); } } } diff --git a/src/renderer/components/+config-secrets/secret-details.scss b/src/renderer/components/+config-secrets/secret-details.scss index ed879ce5a1..98d4643532 100644 --- a/src/renderer/components/+config-secrets/secret-details.scss +++ b/src/renderer/components/+config-secrets/secret-details.scss @@ -24,7 +24,7 @@ margin-bottom: $margin * 2; .name { - color: $textColorSecondary; + color: var(--textColorSecondary); font-weight: $font-weight-bold; padding-bottom: $padding * 0.5; } diff --git a/src/renderer/components/+custom-resources/crd-list.tsx b/src/renderer/components/+custom-resources/crd-list.tsx index e257289008..67e2be4db1 100644 --- a/src/renderer/components/+custom-resources/crd-list.tsx +++ b/src/renderer/components/+custom-resources/crd-list.tsx @@ -92,6 +92,8 @@ export class CrdList extends React.Component { tableId="crd" className="CrdList" store={crdStore} + // Don't subscribe the `crdStore` because already has and is always mounted + subscribeStores={false} items={items} sortingCallbacks={sortingCallbacks} searchFilters={Object.values(sortingCallbacks)} diff --git a/src/renderer/components/+custom-resources/crd-resource-details.scss b/src/renderer/components/+custom-resources/crd-resource-details.scss index b7e57a0295..81ef027cc0 100644 --- a/src/renderer/components/+custom-resources/crd-resource-details.scss +++ b/src/renderer/components/+custom-resources/crd-resource-details.scss @@ -23,7 +23,7 @@ .status { .ready { color: white; - background-color: $colorOk; + background-color: var(--colorOk); } } } \ No newline at end of file diff --git a/src/renderer/components/+custom-resources/crd-resources.tsx b/src/renderer/components/+custom-resources/crd-resources.tsx index e8f5caa21b..d0eb4779c7 100644 --- a/src/renderer/components/+custom-resources/crd-resources.tsx +++ b/src/renderer/components/+custom-resources/crd-resources.tsx @@ -78,6 +78,17 @@ export class CrdResources extends React.Component { sortingCallbacks[column.name] = item => jsonPath.value(item, parseJsonPath(column.jsonPath.slice(1))); }); + const version = crd.getPreferedVersion(); + const loadFailedPrefix =

Failed to load {crd.getPluralName()}

; + const failedToLoadMessage = version.served + ? loadFailedPrefix + : ( + <> + {loadFailedPrefix} +

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

+ + ); + return ( { }), crdInstance.getAge(), ]} + failedToLoadMessage={failedToLoadMessage} /> ); } diff --git a/src/renderer/components/+custom-resources/crd.mixins.scss b/src/renderer/components/+custom-resources/crd.mixins.scss index 89c7c86acc..07700c2f8e 100644 --- a/src/renderer/components/+custom-resources/crd.mixins.scss +++ b/src/renderer/components/+custom-resources/crd.mixins.scss @@ -21,11 +21,11 @@ // CRD conditions from here https://github.com/kubernetes/apiextensions-apiserver/blob/master/pkg/apis/apiextensions/types.go $crd-condition-colors: ( - Established: $colorSuccess, - NamesAccepted: $colorOk, - NonStructuralSchema: $colorError, - Terminating: $colorTerminated, - KubernetesAPIApprovalPolicyConformant: $colorWarning + Established: var(--colorSuccess), + NamesAccepted: var(--colorOk), + NonStructuralSchema: var(--colorError), + Terminating: var(--colorTerminated), + KubernetesAPIApprovalPolicyConformant: var(--colorWarning) ); @mixin crd-condition-bgc { diff --git a/src/renderer/components/+events/event-details.scss b/src/renderer/components/+events/event-details.scss index 6d3544965d..3aa1fa9ed0 100644 --- a/src/renderer/components/+events/event-details.scss +++ b/src/renderer/components/+events/event-details.scss @@ -22,7 +22,7 @@ .EventDetails { .type { .warning { - color: $colorError; + color: var(--colorError); } } } \ No newline at end of file diff --git a/src/renderer/components/+events/events.scss b/src/renderer/components/+events/events.scss index 28544e8cb2..bdc6e0aaab 100644 --- a/src/renderer/components/+events/events.scss +++ b/src/renderer/components/+events/events.scss @@ -26,7 +26,7 @@ flex-grow: 3; &.warning { - color: $colorError; + color: var(--colorError); } } diff --git a/src/renderer/components/+events/kube-event-details.scss b/src/renderer/components/+events/kube-event-details.scss index d8a14a3ec2..ed3f4b5d77 100644 --- a/src/renderer/components/+events/kube-event-details.scss +++ b/src/renderer/components/+events/kube-event-details.scss @@ -34,7 +34,7 @@ word-break: break-word; &.warning { - color: $colorError; + color: var(--colorError); } } } diff --git a/src/renderer/components/+events/kube-event-icon.scss b/src/renderer/components/+events/kube-event-icon.scss index e49b2f5604..d43099cf27 100644 --- a/src/renderer/components/+events/kube-event-icon.scss +++ b/src/renderer/components/+events/kube-event-icon.scss @@ -21,7 +21,7 @@ .KubeEventIcon { &.warning { - color: $golden; + color: var(--golden); } } diff --git a/src/renderer/components/+namespaces/namespaces-mixins.scss b/src/renderer/components/+namespaces/namespaces-mixins.scss index 6cb2f382f3..45be5c3764 100644 --- a/src/renderer/components/+namespaces/namespaces-mixins.scss +++ b/src/renderer/components/+namespaces/namespaces-mixins.scss @@ -22,9 +22,9 @@ @mixin namespaceStatus { &.active { - color: $colorOk; + color: var(--colorOk); } &.terminating { - color: $colorError; + color: var(--colorError); } } diff --git a/src/renderer/components/+network-ingresses/ingress-details.scss b/src/renderer/components/+network-ingresses/ingress-details.scss index d228fd639f..cfd61e54b1 100644 --- a/src/renderer/components/+network-ingresses/ingress-details.scss +++ b/src/renderer/components/+network-ingresses/ingress-details.scss @@ -20,7 +20,7 @@ */ .IngressDetails { - --titles-color: #{$textColorSecondary}; + --titles-color: var(--textColorSecondary); .rules { margin-bottom: $margin * 2 diff --git a/src/renderer/components/+network-services/service-port-component.scss b/src/renderer/components/+network-services/service-port-component.scss index c22f41d1e0..59c43f1a3e 100644 --- a/src/renderer/components/+network-services/service-port-component.scss +++ b/src/renderer/components/+network-services/service-port-component.scss @@ -31,7 +31,7 @@ span { cursor: pointer; - color: $primary; + color: var(--primary); text-decoration: underline; padding-right: 1em; } diff --git a/src/renderer/components/+network-services/service-port-component.tsx b/src/renderer/components/+network-services/service-port-component.tsx index 301b21f1f3..95355d0f0d 100644 --- a/src/renderer/components/+network-services/service-port-component.tsx +++ b/src/renderer/components/+network-services/service-port-component.tsx @@ -164,7 +164,7 @@ export class ServicePortComponent extends React.Component { this.portForward()}> {port.toString()} - + {this.waiting && ( )} diff --git a/src/renderer/components/+network/network-mixins.scss b/src/renderer/components/+network/network-mixins.scss index edbf0fa73e..3fa05ac072 100644 --- a/src/renderer/components/+network/network-mixins.scss +++ b/src/renderer/components/+network/network-mixins.scss @@ -20,8 +20,8 @@ */ $service-status-color-list: ( - active: $colorOk, - pending: $colorWarning + active: var(--colorOk), + pending: var(--colorWarning) ); @mixin service-status-colors { @@ -33,7 +33,7 @@ $service-status-color-list: ( } $port-forward-status-color-list: ( - active: $colorOk, + active: var(--colorOk), ); @mixin port-forward-status-colors { diff --git a/src/renderer/components/+nodes/node-details.scss b/src/renderer/components/+nodes/node-details.scss index 8d7672fde5..8d33626300 100644 --- a/src/renderer/components/+nodes/node-details.scss +++ b/src/renderer/components/+nodes/node-details.scss @@ -20,8 +20,8 @@ */ .NodeDetails { - --status-ready-bg: #{$colorOk}; - --status-default-bg: #{$colorError}; + --status-ready-bg: var(--colorOk); + --status-default-bg: var(--colorError); .conditions { @include node-status-bgs; diff --git a/src/renderer/components/+nodes/nodes-mixins.scss b/src/renderer/components/+nodes/nodes-mixins.scss index 74979c7a9f..0127eec3e1 100644 --- a/src/renderer/components/+nodes/nodes-mixins.scss +++ b/src/renderer/components/+nodes/nodes-mixins.scss @@ -28,7 +28,7 @@ $node-status-color-list: ( ready: #4caf50, scheduling-disabled: #ff9800, invalid-license: #ce3933, - cordoned: $colorWarning + cordoned: var(--colorWarning) ); @mixin node-status-bgs { diff --git a/src/renderer/components/+nodes/nodes.scss b/src/renderer/components/+nodes/nodes.scss index 1d6ed266e7..5919bac691 100644 --- a/src/renderer/components/+nodes/nodes.scss +++ b/src/renderer/components/+nodes/nodes.scss @@ -37,7 +37,7 @@ align-self: center; .LineProgress { - color: $lensBlue; + color: var(--blue); } } @@ -46,7 +46,7 @@ align-self: center; .LineProgress { - color: $lensMagenta; + color: var(--magenta); } } @@ -55,7 +55,7 @@ align-self: center; .LineProgress { - color: $golden; + color: var(--golden); } } diff --git a/src/renderer/components/+storage/storage-mixins.scss b/src/renderer/components/+storage/storage-mixins.scss index febd128d07..afea85348a 100644 --- a/src/renderer/components/+storage/storage-mixins.scss +++ b/src/renderer/components/+storage/storage-mixins.scss @@ -20,15 +20,15 @@ */ // PersistentVolumes -$pv-bound: $colorOk; -$pv-available: $colorSuccess; -$pv-released: $colorWarning; -$pv-failed: $colorError; +$pv-bound: var(--colorOk); +$pv-available: var(--colorSuccess); +$pv-released: var(--colorWarning); +$pv-failed: var(--colorError); // PersistentVolumeClaims -$pvc-bound: $colorOk; -$pvc-pending: $colorWarning; -$pvc-lost: $colorError; +$pvc-bound: var(--colorOk); +$pvc-pending: var(--colorWarning); +$pvc-lost: var(--colorError); // PersistentVolume Statuses $pv-status-color-list: ( diff --git a/src/renderer/components/+user-management/+cluster-roles/details.scss b/src/renderer/components/+user-management/+cluster-roles/details.scss index 1a68357d89..f558674c5f 100644 --- a/src/renderer/components/+user-management/+cluster-roles/details.scss +++ b/src/renderer/components/+user-management/+cluster-roles/details.scss @@ -25,12 +25,12 @@ grid-template-columns: min-content auto; gap: $margin; - border: 1px solid $borderColor; + border: 1px solid var(--borderColor); border-radius: $radius; padding: $padding * 1.5; > .name { - color: $textColorSecondary; + color: var(--textColorSecondary); text-align: right; white-space: nowrap; } diff --git a/src/renderer/components/+user-management/+roles/details.scss b/src/renderer/components/+user-management/+roles/details.scss index 91ed3471c6..c93ac17687 100644 --- a/src/renderer/components/+user-management/+roles/details.scss +++ b/src/renderer/components/+user-management/+roles/details.scss @@ -4,12 +4,12 @@ grid-template-columns: min-content auto; gap: $margin; - border: 1px solid $borderColor; + border: 1px solid var(--borderColor); border-radius: $radius; padding: $padding * 1.5; > .name { - color: $textColorSecondary; + color: var(--textColorSecondary); text-align: right; white-space: nowrap; } diff --git a/src/renderer/components/+user-management/+service-accounts/secret.scss b/src/renderer/components/+user-management/+service-accounts/secret.scss index 21cd9a106d..babc57650e 100644 --- a/src/renderer/components/+user-management/+service-accounts/secret.scss +++ b/src/renderer/components/+user-management/+service-accounts/secret.scss @@ -30,7 +30,7 @@ .secret-row { display: flex; - border-bottom: 1px solid $borderFaintColor; + border-bottom: 1px solid var(--borderFaintColor); padding: $padding 0; &:first-child { @@ -40,12 +40,12 @@ .name { flex-basis: 23%; - color: $drawerItemNameColor; + color: var(--drawerItemNameColor); } .value { flex-basis: 76%; - color: $drawerItemValueColor; + color: var(--drawerItemValueColor); word-break: break-all; &:empty:after { diff --git a/src/renderer/components/+workloads-cronjobs/cronjob-details.scss b/src/renderer/components/+workloads-cronjobs/cronjob-details.scss index f5e3c76ec8..d845b16013 100644 --- a/src/renderer/components/+workloads-cronjobs/cronjob-details.scss +++ b/src/renderer/components/+workloads-cronjobs/cronjob-details.scss @@ -27,7 +27,7 @@ margin-bottom: $margin; a { - color: $colorInfo; + color: var(--colorInfo); } } } diff --git a/src/renderer/components/+workloads-deployments/deployment-scale-dialog.scss b/src/renderer/components/+workloads-deployments/deployment-scale-dialog.scss index 31b5c6898c..cd4f6cb287 100644 --- a/src/renderer/components/+workloads-deployments/deployment-scale-dialog.scss +++ b/src/renderer/components/+workloads-deployments/deployment-scale-dialog.scss @@ -56,7 +56,7 @@ } .warning { - color: $colorSoftError; + color: var(--colorSoftError); font-size: small; display: flex; align-items: center; diff --git a/src/renderer/components/+workloads-overview/overview-statuses.scss b/src/renderer/components/+workloads-overview/overview-statuses.scss index dccc5366d9..366839bbe8 100644 --- a/src/renderer/components/+workloads-overview/overview-statuses.scss +++ b/src/renderer/components/+workloads-overview/overview-statuses.scss @@ -23,14 +23,14 @@ position: relative; width: 100%; min-width: $unit * 75; - background: $contentColor; + background: var(--contentColor); > .header { position: relative; padding: $padding * 2; h5 { - color: $textColorPrimary; + color: var(--textColorPrimary); } } @@ -49,7 +49,7 @@ text-align: center; a { - color: $colorInfo; + color: var(--colorInfo); } } } diff --git a/src/renderer/components/+workloads-pods/pod-container-env.scss b/src/renderer/components/+workloads-pods/pod-container-env.scss index c56f394ec1..0129a3c663 100644 --- a/src/renderer/components/+workloads-pods/pod-container-env.scss +++ b/src/renderer/components/+workloads-pods/pod-container-env.scss @@ -35,7 +35,7 @@ } .var-name { - color: $textColorPrimary + color: var(--textColorPrimary) } } } \ No newline at end of file diff --git a/src/renderer/components/+workloads-pods/pod-container-port.scss b/src/renderer/components/+workloads-pods/pod-container-port.scss index efd5592065..7aeac5a269 100644 --- a/src/renderer/components/+workloads-pods/pod-container-port.scss +++ b/src/renderer/components/+workloads-pods/pod-container-port.scss @@ -31,7 +31,7 @@ span { cursor: pointer; - color: $primary; + color: var(--primary); text-decoration: underline; position: relative; padding-right: 1em; diff --git a/src/renderer/components/+workloads-pods/pod-container-port.tsx b/src/renderer/components/+workloads-pods/pod-container-port.tsx index c8beecc334..50417ce9dd 100644 --- a/src/renderer/components/+workloads-pods/pod-container-port.tsx +++ b/src/renderer/components/+workloads-pods/pod-container-port.tsx @@ -170,7 +170,7 @@ export class PodContainerPort extends React.Component { this.portForward()}> {text} - + {this.waiting && ( )} diff --git a/src/renderer/components/+workloads-pods/pod-details-container.scss b/src/renderer/components/+workloads-pods/pod-details-container.scss index fca42c9433..a2c004ce72 100644 --- a/src/renderer/components/+workloads-pods/pod-details-container.scss +++ b/src/renderer/components/+workloads-pods/pod-details-container.scss @@ -30,8 +30,8 @@ display: block; font-family: $font-monospace; font-size: 90%; - background: $colorVague; - color: $textColorSecondary; + background: var(--colorVague); + color: var(--textColorSecondary); border-radius: $radius; padding: .2em .4em; margin-top: $margin; @@ -42,7 +42,7 @@ margin-bottom: $margin; .StatusBrick { - background: $colorTerminated; + background: var(--colorTerminated); margin-right: $margin; @include pod-status-bgs; @@ -54,7 +54,7 @@ } .status { - color: $colorTerminated; + color: var(--colorTerminated); @include pod-status-colors; } diff --git a/src/renderer/components/+workloads-pods/pod-details-list.scss b/src/renderer/components/+workloads-pods/pod-details-list.scss index 04ccd2d05e..43177d6c86 100644 --- a/src/renderer/components/+workloads-pods/pod-details-list.scss +++ b/src/renderer/components/+workloads-pods/pod-details-list.scss @@ -55,7 +55,7 @@ align-self: center; .LineProgress { - color: $lensBlue; + color: var(--blue); } } @@ -63,7 +63,7 @@ align-self: center; .LineProgress { - color: $lensMagenta; + color: var(--magenta); } } diff --git a/src/renderer/components/+workloads-pods/pods.tsx b/src/renderer/components/+workloads-pods/pods.tsx index a7f3a46a62..7688861cdc 100644 --- a/src/renderer/components/+workloads-pods/pods.tsx +++ b/src/renderer/components/+workloads-pods/pods.tsx @@ -69,7 +69,7 @@ export class Pods extends React.Component { formatters: { tableView: true, }, - children: Object.keys(state).map(status => ( + children: Object.keys(state).map((status: keyof typeof state) => (
{name} ({status}{ready ? ", ready" : ""}) diff --git a/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.scss b/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.scss index aede1adc69..d0aa33ef0a 100644 --- a/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.scss +++ b/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.scss @@ -56,7 +56,7 @@ } .warning { - color: $colorSoftError; + color: var(--colorSoftError); font-size: small; display: flex; align-items: center; diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.scss b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.scss index 59f63f0470..f1f5ef877e 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.scss +++ b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.scss @@ -56,7 +56,7 @@ } .warning { - color: $colorSoftError; + color: var(--colorSoftError); font-size: small; display: flex; align-items: center; diff --git a/src/renderer/components/+workloads/workloads-mixins.scss b/src/renderer/components/+workloads/workloads-mixins.scss index 03c05c2d48..9afc0a3d35 100644 --- a/src/renderer/components/+workloads/workloads-mixins.scss +++ b/src/renderer/components/+workloads/workloads-mixins.scss @@ -20,36 +20,36 @@ */ // Pods -$pod-status-running-color: $colorOk; -$pod-status-pending-color: $colorWarning; -$pod-status-evicted-color: $colorError; -$pod-status-succeeded-color: $colorSuccess; -$pod-status-failed-color: $colorError; -$pod-status-terminated-color: $colorTerminated; -$pod-status-terminating-color: $colorTerminated; -$pod-status-unknown-color: $colorVague; -$pod-status-complete-color: $colorSuccess; -$pod-status-crash-loop-color: $colorError; -$pod-scheduled: $colorOk; -$pod-ready: $colorOk; -$pod-initialized: $colorOk; -$pod-unschedulable: $colorError; -$pod-containers-ready: $colorInfo; -$pod-error: $colorError; -$pod-container-creating: $colorInfo; +$pod-status-running-color: var(--colorOk); +$pod-status-pending-color: var(--colorWarning); +$pod-status-evicted-color: var(--colorError); +$pod-status-succeeded-color: var(--colorSuccess); +$pod-status-failed-color: var(--colorError); +$pod-status-terminated-color: var(--colorTerminated); +$pod-status-terminating-color: var(--colorTerminated); +$pod-status-unknown-color: var(--colorVague); +$pod-status-complete-color: var(--colorSuccess); +$pod-status-crash-loop-color: var(--colorError); +$pod-scheduled: var(--colorOk); +$pod-ready: var(--colorOk); +$pod-initialized: var(--colorOk); +$pod-unschedulable: var(--colorError); +$pod-containers-ready: var(--colorInfo); +$pod-error: var(--colorError); +$pod-container-creating: var(--colorInfo); // Deployments -$deployment-available: $colorOk; -$deployment-progressing: $colorInfo; -$deployment-replicafailure: $colorError; +$deployment-available: var(--colorOk); +$deployment-progressing: var(--colorInfo); +$deployment-replicafailure: var(--colorError); // Jobs -$job-complete: $colorSuccess; -$job-failed: $colorError; +$job-complete: var(--colorSuccess); +$job-failed: var(--colorError); // Cronjob -$cronjob-scheduled: $colorSuccess; -$cronjob-suspended: $colorTerminated; +$cronjob-scheduled: var(--colorSuccess); +$cronjob-suspended: var(--colorTerminated); // Pod Statuses $pod-status-color-list: ( diff --git a/src/renderer/components/add-remove-buttons/add-remove-buttons.scss b/src/renderer/components/add-remove-buttons/add-remove-buttons.scss index 0042f8384c..abf7ffd198 100644 --- a/src/renderer/components/add-remove-buttons/add-remove-buttons.scss +++ b/src/renderer/components/add-remove-buttons/add-remove-buttons.scss @@ -29,8 +29,8 @@ .Button { &.remove-button { - background-color: $borderFaintColor; - color: $textColorPrimary; + background-color: var(--borderFaintColor); + color: var(--textColorPrimary); } } } diff --git a/src/renderer/components/app.scss b/src/renderer/components/app.scss index d974aebc7f..79903a8ca9 100755 --- a/src/renderer/components/app.scss +++ b/src/renderer/components/app.scss @@ -22,6 +22,7 @@ @import "tailwindcss/utilities"; @import "~flex.box"; @import "fonts"; +@import "../themes/theme-vars"; :root { --unit: 8px; @@ -67,18 +68,18 @@ } ::selection { - background: $primary; + background: var(--primary); color: white; } *:target { - color: $textColorAccent; + color: var(--textColorAccent); } html { font-size: 62.5%; // 1 rem == 10px - color: $textColorPrimary; - background-color: $mainBackground; + color: var(--textColorPrimary); + background-color: var(--mainBackground); --flex-gap: #{$padding}; } @@ -118,7 +119,7 @@ fieldset { } label { - color: $textColorSecondary; + color: var(--textColorSecondary); } ol, ul { @@ -127,7 +128,7 @@ ol, ul { } h1 { - color: $textColorPrimary; + color: var(--textColorPrimary); font-size: 28px; font-weight: normal; letter-spacing: -.010em; @@ -198,27 +199,27 @@ a { // colors .success { - color: $colorSuccess; + color: var(--colorSuccess); } .info { - color: $colorInfo; + color: var(--colorInfo); } .error { - color: $colorError; + color: var(--colorError); } .warning { - color: $colorWarning; + color: var(--colorWarning); } .contrast { - color: $textColorAccent; + color: var(--textColorAccent); } .text-secondary { - color: $textColorSecondary; + color: var(--textColorSecondary); } .nobr { diff --git a/src/renderer/components/button/button.scss b/src/renderer/components/button/button.scss index 0b8fd90d99..ebcb4b6c34 100644 --- a/src/renderer/components/button/button.scss +++ b/src/renderer/components/button/button.scss @@ -29,7 +29,7 @@ text-decoration: none; cursor: pointer; border-radius: $radius; - background: $buttonDefaultBackground; + background: var(--buttonDefaultBackground); padding: round($padding * .9) $padding * 1.5; flex-shrink: 0; line-height: 1; @@ -41,15 +41,15 @@ } &.primary { - background: $buttonPrimaryBackground; + background: var(--buttonPrimaryBackground); } &.accent { - background: $buttonAccentBackground; + background: var(--buttonAccentBackground); } &.light { - background-color: $buttonLightBackground; + background-color: var(--buttonLightBackground); color: #505050; } @@ -65,7 +65,7 @@ &.active, &:focus:not(:active) { - color: $buttonPrimaryBackground; + color: var(--buttonPrimaryBackground); box-shadow: 0 0 0 1px inset; } } diff --git a/src/renderer/components/chart/chart.scss b/src/renderer/components/chart/chart.scss index 554d62b883..6b264c0403 100644 --- a/src/renderer/components/chart/chart.scss +++ b/src/renderer/components/chart/chart.scss @@ -35,7 +35,7 @@ white-space: normal; &:hover { - background: $colorVague; + background: var(--colorVague); cursor: default; } } diff --git a/src/renderer/components/checkbox/checkbox.scss b/src/renderer/components/checkbox/checkbox.scss index cd0be0074a..edc605c925 100644 --- a/src/renderer/components/checkbox/checkbox.scss +++ b/src/renderer/components/checkbox/checkbox.scss @@ -21,9 +21,9 @@ .Checkbox { - --checkbox-color: #{$textColorPrimary}; // tick color [√] - --checkbox-color-active: #{$contentColor}; - --checkbox-bgc-active: #{$lensBlue}; + --checkbox-color: var(--textColorPrimary); // tick color [√] + --checkbox-color-active: var(--contentColor); + --checkbox-bgc-active: var(--blue); flex-shrink: 0; @@ -32,7 +32,7 @@ // default } &-light { - --checkbox-color-active: #{$lensBlue}; + --checkbox-color-active: var(--blue); --checkbox-bgc-active: none; } } diff --git a/src/renderer/components/cluster-manager/cluster-manager.scss b/src/renderer/components/cluster-manager/cluster-manager.scss index cc6a4a8e37..3b9e74c1ac 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.scss +++ b/src/renderer/components/cluster-manager/cluster-manager.scss @@ -53,7 +53,7 @@ right: 0; bottom: 0; display: flex; - background-color: $mainBackground; + background-color: var(--mainBackground); iframe { flex: 1; diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index 7c7cdd2714..70fbfab30c 100644 --- a/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/src/renderer/components/cluster-manager/cluster-status.tsx @@ -21,9 +21,8 @@ import styles from "./cluster-status.module.css"; -import { ipcRenderer } from "electron"; import { computed, observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import React from "react"; import { clusterActivateHandler } from "../../../common/cluster-ipc"; import { ClusterStore } from "../../../common/cluster-store"; @@ -33,10 +32,9 @@ import { cssNames, IClassName } from "../../utils"; import { Button } from "../button"; import { Icon } from "../icon"; import { Spinner } from "../spinner"; -import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy"; import { navigate } from "../../navigation"; import { entitySettingsURL } from "../../../common/routes"; -import type { ClusterId } from "../../../common/cluster-types"; +import type { ClusterId, KubeAuthUpdate } from "../../../common/cluster-types"; interface Props { className?: IClassName; @@ -45,7 +43,7 @@ interface Props { @observer export class ClusterStatus extends React.Component { - @observable authOutput: KubeAuthProxyLog[] = []; + @observable authOutput: KubeAuthUpdate[] = []; @observable isReconnecting = false; constructor(props: Props) { @@ -58,31 +56,31 @@ export class ClusterStatus extends React.Component { } @computed get hasErrors(): boolean { - return this.authOutput.some(({ error }) => error) || !!this.cluster.failureReason; + return this.authOutput.some(({ isError }) => isError); } - async componentDidMount() { - ipcRendererOn(`kube-auth:${this.cluster.id}`, (evt, res: KubeAuthProxyLog) => { - this.authOutput.push({ - data: res.data.trimRight(), - error: res.error, - }); - }); + componentDidMount() { + disposeOnUnmount(this, [ + ipcRendererOn(`cluster:${this.cluster.id}:connection-update`, (evt, res: KubeAuthUpdate) => { + this.authOutput.push(res); + }), + ]); } - componentWillUnmount() { - ipcRenderer.removeAllListeners(`kube-auth:${this.props.clusterId}`); - } - - activateCluster = async (force = false) => { - await requestMain(clusterActivateHandler, this.props.clusterId, force); - }; - reconnect = async () => { this.authOutput = []; this.isReconnecting = true; - await this.activateCluster(true); - this.isReconnecting = false; + + try { + await requestMain(clusterActivateHandler, this.props.clusterId, true); + } catch (error) { + this.authOutput.push({ + message: error.toString(), + isError: true, + }); + } finally { + this.isReconnecting = false; + } }; manageProxySettings = () => { @@ -94,58 +92,68 @@ export class ClusterStatus extends React.Component { })); }; - renderContent() { - const { authOutput, cluster, hasErrors } = this; - const failureReason = cluster.failureReason; + renderAuthenticationOutput() { + return ( +
+        {
+          this.authOutput.map(({ message, isError }, index) => (
+            

+ {message.trim()} +

+ )) + } +
+ ); + } - if (!hasErrors || this.isReconnecting) { - return ( -
- -
-            

{this.isReconnecting ? "Reconnecting" : "Connecting"}…

- {authOutput.map(({ data, error }, index) => { - return

{data}

; - })} -
-
- ); + renderStatusIcon() { + if (this.hasErrors) { + return ; } return ( -
- -

- {cluster.preferences.clusterName} -

-
-          {authOutput.map(({ data, error }, index) => {
-            return 

{data}

; - })} + <> + +
+          

{this.isReconnecting ? "Reconnecting" : "Connecting"}…

- {failureReason && ( -
{failureReason}
- )} -
+ ); } + renderReconnectionHelp() { + if (this.hasErrors && !this.isReconnecting) { + return ( + <> +