diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000000..4055a26a99 --- /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 + 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/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 54a336b5fc..8ec42160d4 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.10", "main": "static/build/main.js", "copyright": "© 2021 OpenLens Authors", "license": "MIT", @@ -220,7 +220,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", @@ -254,7 +254,7 @@ "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.60", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", - "@sentry/types": "^6.13.3", + "@sentry/types": "^6.14.1", "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^11.2.7", "@testing-library/user-event": "^13.5.0", @@ -278,13 +278,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 +361,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/k8s-api/__tests__/kube-api.test.ts b/src/common/k8s-api/__tests__/kube-api.test.ts index 0b9a8ea4aa..850b310551 100644 --- a/src/common/k8s-api/__tests__/kube-api.test.ts +++ b/src/common/k8s-api/__tests__/kube-api.test.ts @@ -20,7 +20,7 @@ */ import type { Request } from "node-fetch"; -import { Pod } from "../endpoints/pods.api"; +import { Pod, PodsApi } from "../endpoints/pods.api"; import { forRemoteCluster, KubeApi } from "../kube-api"; import { KubeJsonApi } from "../kube-json-api"; import { KubeObject } from "../kube-object"; @@ -34,7 +34,33 @@ class TestKubeObject extends KubeObject { 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", + }, + user: { + token: "daa", + }, + }, Pod); + + 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", + }, + }, Pod, PodsApi); + + expect(api).toBeInstanceOf(PodsApi); + }); + + it("calls right api endpoint", async () => { const api = forRemoteCluster({ cluster: { server: "https://127.0.0.1:6443", @@ -47,13 +73,13 @@ describe("forRemoteCluster", () => { (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(); }); }); 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/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 1726bf1bbb..c9930a148c 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -116,7 +116,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, @@ -128,15 +128,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) { @@ -173,8 +176,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, }); } @@ -233,10 +240,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; 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/utils/base64.ts b/src/common/utils/base64.ts index 8484e128b1..9bae5f79bf 100755 --- a/src/common/utils/base64.ts +++ b/src/common/utils/base64.ts @@ -23,11 +23,20 @@ import * as Base64 from "crypto-js/enc-base64"; import * as Utf8 from "crypto-js/enc-utf8"; -export const base64 = { - decode(data: string) { - return Base64.parse(data).toString(Utf8); - }, - encode(data: string) { - return Utf8.parse(data).toString(Base64); - }, -}; +/** + * Computes utf-8 from base64 + * @param data A Base64 encoded string + * @returns The original utf-8 string + */ +export function decode(data: string): string { + return Base64.parse(data).toString(Utf8); +} + +/** + * Computes base64 from utf-8 + * @param data A normal string + * @returns A base64 encoded version + */ +export function encode(data: string): string { + return Utf8.parse(data).toString(Base64); +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 2875877a16..733867f1cb 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -28,7 +28,6 @@ export function noop(...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/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/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/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/port-forward-route.ts b/src/main/routes/port-forward-route.ts index 17e4b1146e..fe425df48d 100644 --- a/src/main/routes/port-forward-route.ts +++ b/src/main/routes/port-forward-route.ts @@ -95,7 +95,7 @@ class PortForward { }); this.process.stderr.on("data", (data) => { - logger.warn(`[PORT-FORWARD-ROUTE]: kubectl port-forward process stderr: ${data}`); + logger.debug(`[PORT-FORWARD-ROUTE]: kubectl port-forward process stderr: ${data}`); }); const internalPort = await getPortFrom(this.process.stdout, { 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/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.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/+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/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/+network-services/service-port-component.tsx b/src/renderer/components/+network-services/service-port-component.tsx index 05eedeaece..301b21f1f3 100644 --- a/src/renderer/components/+network-services/service-port-component.tsx +++ b/src/renderer/components/+network-services/service-port-component.tsx @@ -28,7 +28,7 @@ import { observable, makeObservable, reaction } from "mobx"; import { cssNames } from "../../utils"; import { Notifications } from "../notifications"; import { Button } from "../button"; -import { addPortForward, getPortForward, openPortForward, PortForwardDialog, portForwardStore, predictProtocol, removePortForward } from "../../port-forward"; +import { aboutPortForwarding, addPortForward, getPortForward, getPortForwards, openPortForward, PortForwardDialog, portForwardStore, predictProtocol, removePortForward } from "../../port-forward"; import type { ForwardedPort } from "../../port-forward"; import { Spinner } from "../spinner"; @@ -93,12 +93,20 @@ export class ServicePortComponent extends React.Component { this.waiting = true; try { + // determine how many port-forwards are already active + const { length } = await getPortForwards(); + this.forwardPort = await addPortForward(portForward); if (this.forwardPort) { portForward.forwardPort = this.forwardPort; openPortForward(portForward); this.isPortForwarded = true; + + // if this is the first port-forward show the about notification + if (!length) { + aboutPortForwarding(); + } } } catch (error) { Notifications.error(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); diff --git a/src/renderer/components/+workloads-pods/pod-container-port.tsx b/src/renderer/components/+workloads-pods/pod-container-port.tsx index 0ff1645f65..c8beecc334 100644 --- a/src/renderer/components/+workloads-pods/pod-container-port.tsx +++ b/src/renderer/components/+workloads-pods/pod-container-port.tsx @@ -28,7 +28,7 @@ import { observable, makeObservable, reaction } from "mobx"; import { cssNames } from "../../utils"; import { Notifications } from "../notifications"; import { Button } from "../button"; -import { addPortForward, getPortForward, openPortForward, PortForwardDialog, portForwardStore, predictProtocol, removePortForward } from "../../port-forward"; +import { aboutPortForwarding, addPortForward, getPortForward, getPortForwards, openPortForward, PortForwardDialog, portForwardStore, predictProtocol, removePortForward } from "../../port-forward"; import type { ForwardedPort } from "../../port-forward"; import { Spinner } from "../spinner"; @@ -97,12 +97,20 @@ export class PodContainerPort extends React.Component { this.waiting = true; try { + // determine how many port-forwards are already active + const { length } = await getPortForwards(); + this.forwardPort = await addPortForward(portForward); if (this.forwardPort) { portForward.forwardPort = this.forwardPort; openPortForward(portForward); this.isPortForwarded = true; + + // if this is the first port-forward show the about notification + if (!length) { + aboutPortForwarding(); + } } } catch (error) { Notifications.error(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); 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/cluster-manager/cluster-view.tsx b/src/renderer/components/cluster-manager/cluster-view.tsx index 78626cccb1..73cc479455 100644 --- a/src/renderer/components/cluster-manager/cluster-view.tsx +++ b/src/renderer/components/cluster-manager/cluster-view.tsx @@ -25,7 +25,7 @@ import { computed, makeObservable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import type { RouteComponentProps } from "react-router"; import { ClusterStatus } from "./cluster-status"; -import { hasLoadedView, initView, refreshViews } from "./lens-views"; +import { ClusterFrameHandler } from "./lens-views"; import type { Cluster } from "../../../main/cluster"; import { ClusterStore } from "../../../common/cluster-store"; import { requestMain } from "../../../common/ipc"; @@ -57,7 +57,7 @@ export class ClusterView extends React.Component { @computed get isReady(): boolean { const { cluster, clusterId } = this; - return cluster?.ready && cluster?.available && hasLoadedView(clusterId); + return cluster?.ready && cluster?.available && ClusterFrameHandler.getInstance().hasLoadedView(clusterId); } componentDidMount() { @@ -65,25 +65,23 @@ export class ClusterView extends React.Component { } componentWillUnmount() { - refreshViews(); + ClusterFrameHandler.getInstance().clearVisibleCluster(); catalogEntityRegistry.activeEntity = null; } bindEvents() { disposeOnUnmount(this, [ reaction(() => this.clusterId, async (clusterId) => { - refreshViews(clusterId); // refresh visibility of active cluster - initView(clusterId); // init cluster-view (iframe), requires parent container #lens-views to be in DOM + ClusterFrameHandler.getInstance().setVisibleCluster(clusterId); + ClusterFrameHandler.getInstance().initView(clusterId); requestMain(clusterActivateHandler, clusterId, false); // activate and fetch cluster's state from main - catalogEntityRegistry.activeEntity = catalogEntityRegistry.getById(clusterId); + catalogEntityRegistry.activeEntity = clusterId; }, { fireImmediately: true, }), - reaction(() => [this.cluster?.ready, this.cluster?.disconnected], (values) => { - const disconnected = values[1]; - - if (hasLoadedView(this.clusterId) && disconnected) { + reaction(() => [this.cluster?.ready, this.cluster?.disconnected], ([, disconnected]) => { + if (ClusterFrameHandler.getInstance().hasLoadedView(this.clusterId) && disconnected) { navigate(catalogURL()); // redirect to catalog when active cluster get disconnected/not available } }), diff --git a/src/renderer/components/cluster-manager/lens-views.ts b/src/renderer/components/cluster-manager/lens-views.ts index b4f8415e71..947fa387f1 100644 --- a/src/renderer/components/cluster-manager/lens-views.ts +++ b/src/renderer/components/cluster-manager/lens-views.ts @@ -19,82 +19,117 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { observable, when } from "mobx"; +import { action, IReactionDisposer, makeObservable, observable, reaction, when } from "mobx"; import logger from "../../../main/logger"; -import { requestMain } from "../../../common/ipc"; import { clusterVisibilityHandler } from "../../../common/cluster-ipc"; import { ClusterStore } from "../../../common/cluster-store"; import type { ClusterId } from "../../../common/cluster-types"; -import { getClusterFrameUrl } from "../../utils"; +import { getClusterFrameUrl, Singleton } from "../../utils"; +import { ipcRenderer } from "electron"; export interface LensView { - isLoaded?: boolean - clusterId: ClusterId; - view: HTMLIFrameElement + isLoaded: boolean; + frame: HTMLIFrameElement; } -export const lensViews = observable.map(); +export class ClusterFrameHandler extends Singleton { + private views = observable.map(); + @observable private visibleCluster: string | null = null; -export function hasLoadedView(clusterId: ClusterId): boolean { - return !!lensViews.get(clusterId)?.isLoaded; -} - -export async function initView(clusterId: ClusterId) { - const cluster = ClusterStore.getInstance().getById(clusterId); - - if (!cluster || lensViews.has(clusterId)) { - return; + constructor() { + super(); + makeObservable(this); + reaction(() => this.visibleCluster, this.handleVisibleClusterChange); } - logger.info(`[LENS-VIEW]: init dashboard, clusterId=${clusterId}`); - const parentElem = document.getElementById("lens-views"); - const iframe = document.createElement("iframe"); - - iframe.id = `cluster-frame-${cluster.id}`; - iframe.name = cluster.contextName; - iframe.setAttribute("src", getClusterFrameUrl(clusterId)); - iframe.addEventListener("load", () => { - logger.info(`[LENS-VIEW]: loaded from ${iframe.src}`); - lensViews.get(clusterId).isLoaded = true; - }, { once: true }); - lensViews.set(clusterId, { clusterId, view: iframe }); - parentElem.appendChild(iframe); - - logger.info(`[LENS-VIEW]: waiting cluster to be ready, clusterId=${clusterId}`); - - try { - await when(() => cluster.ready, { timeout: 5_000 }); // we cannot wait forever because cleanup would be blocked for broken cluster connections - logger.info(`[LENS-VIEW]: cluster is ready, clusterId=${clusterId}`); - } finally { - await autoCleanOnRemove(clusterId, iframe); + public hasLoadedView(clusterId: string): boolean { + return Boolean(this.views.get(clusterId)?.isLoaded); } -} -export async function autoCleanOnRemove(clusterId: ClusterId, iframe: HTMLIFrameElement) { - await when(() => { + @action + public initView(clusterId: ClusterId) { const cluster = ClusterStore.getInstance().getById(clusterId); - return !cluster || (cluster.disconnected && lensViews.get(clusterId)?.isLoaded); - }); - logger.info(`[LENS-VIEW]: remove dashboard, clusterId=${clusterId}`); - lensViews.delete(clusterId); + if (!cluster || this.views.has(clusterId)) { + return; + } - iframe.parentNode.removeChild(iframe); -} - -export function refreshViews(visibleClusterId?: string) { - logger.info(`[LENS-VIEW]: refreshing iframe views, visible cluster id=${visibleClusterId}`); - const cluster = ClusterStore.getInstance().getById(visibleClusterId); - - lensViews.forEach(({ clusterId, view, isLoaded }) => { - const isCurrent = clusterId === cluster?.id; - const isReady = cluster?.available && cluster?.ready; - const isVisible = isCurrent && isLoaded && isReady; - - view.style.display = isVisible ? "flex" : "none"; - - requestMain(clusterVisibilityHandler, clusterId, isVisible).catch(() => { - logger.error(`[LENS-VIEW]: failed to set cluster visibility, clusterId=${clusterId}`); - }); - }); + logger.info(`[LENS-VIEW]: init dashboard, clusterId=${clusterId}`); + const parentElem = document.getElementById("lens-views"); + const iframe = document.createElement("iframe"); + + iframe.id = `cluster-frame-${cluster.id}`; + iframe.name = cluster.contextName; + iframe.style.display = "none"; + iframe.setAttribute("src", getClusterFrameUrl(clusterId)); + iframe.addEventListener("load", () => { + logger.info(`[LENS-VIEW]: loaded from ${iframe.src}`); + this.views.get(clusterId).isLoaded = true; + }, { once: true }); + this.views.set(clusterId, { frame: iframe, isLoaded: false }); + parentElem.appendChild(iframe); + + logger.info(`[LENS-VIEW]: waiting cluster to be ready, clusterId=${clusterId}`); + + const dispose = when( + () => cluster.ready, + () => logger.info(`[LENS-VIEW]: cluster is ready, clusterId=${clusterId}`), + ); + + when( + // cluster.disconnect is set to `false` when the cluster starts to connect + () => !cluster.disconnected, + () => { + when( + () => { + const cluster = ClusterStore.getInstance().getById(clusterId); + + return !cluster || (cluster.disconnected && this.views.get(clusterId)?.isLoaded); + }, + () => { + logger.info(`[LENS-VIEW]: remove dashboard, clusterId=${clusterId}`); + this.views.delete(clusterId); + iframe.parentNode.removeChild(iframe); + dispose(); + }, + ); + }, + ); + } + + public setVisibleCluster(clusterId: ClusterId) { + this.visibleCluster = clusterId; + } + + public clearVisibleCluster() { + this.visibleCluster = null; + } + + private prevVisibleClusterChange?: IReactionDisposer; + + private handleVisibleClusterChange = (clusterId: ClusterId | undefined) => { + logger.info(`[LENS-VIEW]: refreshing iframe views, visible cluster id=${clusterId}`); + + ipcRenderer.send(clusterVisibilityHandler); + + const cluster = ClusterStore.getInstance().getById(clusterId); + + for (const { frame: view } of this.views.values()) { + view.style.display = "none"; + } + + if (cluster) { + const lensView = this.views.get(clusterId); + + this.prevVisibleClusterChange?.(); + this.prevVisibleClusterChange = when( + () => cluster.available && cluster.ready && lensView.isLoaded, + () => { + logger.info(`[LENS-VIEW]: cluster id=${clusterId} should now be visible`); + lensView.frame.style.display = "flex"; + ipcRenderer.send(clusterVisibilityHandler, clusterId); + }, + ); + } + }; } diff --git a/src/renderer/components/dock/terminal-window.tsx b/src/renderer/components/dock/terminal-window.tsx index 233f74859a..58508a11a5 100644 --- a/src/renderer/components/dock/terminal-window.tsx +++ b/src/renderer/components/dock/terminal-window.tsx @@ -53,7 +53,7 @@ export class TerminalWindow extends React.Component { } activate(tabId: TabId) { - if (this.terminal) this.terminal.detach(); // detach previous + this.terminal?.detach(); // detach previous this.terminal = TerminalStore.getInstance().getTerminal(tabId); this.terminal.attachTo(this.elem); } diff --git a/src/renderer/components/dock/terminal.ts b/src/renderer/components/dock/terminal.ts index da54c5b98a..b9f5855203 100644 --- a/src/renderer/components/dock/terminal.ts +++ b/src/renderer/components/dock/terminal.ts @@ -26,16 +26,15 @@ import { FitAddon } from "xterm-addon-fit"; import { dockStore, TabId } from "./dock.store"; import type { TerminalApi } from "../../api/terminal-api"; import { ThemeStore } from "../../theme.store"; -import { boundMethod } from "../../utils"; +import { boundMethod, disposer } from "../../utils"; import { isMac } from "../../../common/vars"; import { camelCase } from "lodash"; import { UserStore } from "../../../common/user-store"; import { clipboard } from "electron"; +import logger from "../../../common/logger"; export class Terminal { - static spawningPool: HTMLElement; - - static init() { + public static readonly spawningPool = (() => { // terminal element must be in DOM before attaching via xterm.open(elem) // https://xtermjs.org/docs/api/terminal/classes/terminal/#open const pool = document.createElement("div"); @@ -43,8 +42,9 @@ export class Terminal { pool.className = "terminal-init"; pool.style.cssText = "position: absolute; top: 0; left: 0; height: 0; visibility: hidden; overflow: hidden"; document.body.appendChild(pool); - Terminal.spawningPool = pool; - } + + return pool; + })(); static async preloadFonts() { const fontPath = require("../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires @@ -54,13 +54,22 @@ export class Terminal { document.fonts.add(fontFace); } - public xterm: XTerm; - public fitAddon: FitAddon; - public scrollPos = 0; - public disposers: Function[] = []; + private xterm: XTerm | null = new XTerm({ + cursorBlink: true, + cursorStyle: "bar", + fontSize: 13, + fontFamily: "RobotoMono", + }); + private readonly fitAddon = new FitAddon(); + private scrollPos = 0; + private disposer = disposer(); @boundMethod protected setTheme(colors: Record) { + if (!this.xterm) { + return; + } + // Replacing keys stored in styles to format accepted by terminal // E.g. terminalBrightBlack -> brightBlack const colorPrefix = "terminal"; @@ -73,17 +82,13 @@ export class Terminal { } get elem() { - return this.xterm.element; + return this.xterm?.element; } get viewport() { return this.xterm.element.querySelector(".xterm-viewport"); } - constructor(public tabId: TabId, protected api: TerminalApi) { - this.init(); - } - get isActive() { const { isOpen, selectedTabId } = dockStore; @@ -96,22 +101,15 @@ export class Terminal { } detach() { - Terminal.spawningPool.appendChild(this.elem); + const { elem } = this; + + if (elem) { + Terminal.spawningPool.appendChild(elem); + } } - async init() { - if (this.xterm) { - return; - } - this.xterm = new XTerm({ - cursorBlink: true, - cursorStyle: "bar", - fontSize: 13, - fontFamily: "RobotoMono", - }); - + constructor(public tabId: TabId, protected api: TerminalApi) { // enable terminal addons - this.fitAddon = new FitAddon(); this.xterm.loadAddon(this.fitAddon); this.xterm.open(Terminal.spawningPool); @@ -128,7 +126,7 @@ export class Terminal { this.api.onData.addListener(this.onApiData); window.addEventListener("resize", this.onResize); - this.disposers.push( + this.disposer.push( reaction(() => ThemeStore.getInstance().activeTheme.colors, this.setTheme, { fireImmediately: true, }), @@ -142,16 +140,18 @@ export class Terminal { } destroy() { - if (!this.xterm) return; - this.disposers.forEach(dispose => dispose()); - this.disposers = []; - this.xterm.dispose(); - this.xterm = null; + if (this.xterm) { + this.disposer(); + this.xterm.dispose(); + this.xterm = null; + } } fit = () => { // Since this function is debounced we need to read this value as late as possible - if (!this.isActive) return; + if (!this.isActive || !this.xterm) { + return; + } try { this.fitAddon.fit(); @@ -159,9 +159,8 @@ export class Terminal { this.api.sendTerminalSize(cols, rows); } catch (error) { - console.error(error); - - return; // see https://github.com/lensapp/lens/issues/1891 + // see https://github.com/lensapp/lens/issues/1891 + logger.error(`[TERMINAL]: failed to resize terminal to fit`, error); } }; @@ -204,19 +203,21 @@ export class Terminal { }; onContextMenu = () => { - const { terminalCopyOnSelect } = UserStore.getInstance(); - const textFromClipboard = clipboard.readText(); + if ( + // don't paste if user hasn't turned on the feature + UserStore.getInstance().terminalCopyOnSelect - if (terminalCopyOnSelect) { - this.xterm.paste(textFromClipboard); + // don't paste if the clipboard doesn't have text + && clipboard.availableFormats().includes("text/plain") + ) { + this.xterm.paste(clipboard.readText()); } }; onSelectionChange = () => { - const { terminalCopyOnSelect } = UserStore.getInstance(); const selection = this.xterm.getSelection().trim(); - if (terminalCopyOnSelect && selection !== "") { + if (UserStore.getInstance().terminalCopyOnSelect && selection) { clipboard.writeText(selection); } }; @@ -251,5 +252,3 @@ export class Terminal { return true; }; } - -Terminal.init(); diff --git a/src/renderer/components/icon/push-pin.svg b/src/renderer/components/icon/push_pin.svg similarity index 100% rename from src/renderer/components/icon/push-pin.svg rename to src/renderer/components/icon/push_pin.svg diff --git a/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx b/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx new file mode 100644 index 0000000000..195368408d --- /dev/null +++ b/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx @@ -0,0 +1,74 @@ +/** + * 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 React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { render, fireEvent } from "@testing-library/react"; +import { SidebarCluster } from "../sidebar-cluster"; +import { KubernetesCluster } from "../../../../common/catalog-entities"; + +jest.mock("../../../../common/hotbar-store", () => ({ + HotbarStore: { + getInstance: () => ({ + isAddedToActive: jest.fn(), + }), + }, +})); + +const clusterEntity = new KubernetesCluster({ + metadata: { + uid: "test-uid", + name: "test-cluster", + source: "local", + labels: {}, + }, + spec: { + kubeconfigPath: "", + kubeconfigContext: "", + }, + status: { + phase: "connected", + }, +}); + +describe("", () => { + it("renders w/o errors", () => { + const { container } = render(); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("renders cluster avatar and name", () => { + const { getByText } = render(); + + expect(getByText("tc")).toBeInTheDocument(); + expect(getByText("test-cluster")).toBeInTheDocument(); + }); + + it("renders cluster menu", async () => { + const { getByTestId, getByText } = render(); + const link = getByTestId("sidebar-cluster-dropdown"); + + fireEvent.click(link); + expect(await getByText("Add to Hotbar")).toBeInTheDocument(); + }); +}); + diff --git a/src/common/utils/makeCss.ts b/src/renderer/components/layout/sidebar-cluster.module.css similarity index 64% rename from src/common/utils/makeCss.ts rename to src/renderer/components/layout/sidebar-cluster.module.css index 0187f0196b..37a9aed9fa 100644 --- a/src/common/utils/makeCss.ts +++ b/src/renderer/components/layout/sidebar-cluster.module.css @@ -19,12 +19,39 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -/** - * Function expands generic CSS Modules literal types and adds dictionary with arbitrary - * indexes. - * @param styles Styles imported from CSS Module having only literal types - * @returns Passed style list with expanded typescript types - */ -export function makeCss(styles: T) { - return styles as typeof styles & { [key: string]: string }; +.SidebarCluster { + display: flex; + align-items: center; + padding: 1.25rem; + cursor: pointer; + + &:focus-visible { + background: var(--blue); + color: white; + } + + &:hover { + background: var(--sidebarLogoBackground); + } } + +.clusterName { + font-weight: bold; + overflow: hidden; + word-break: break-word; + color: var(--textColorAccent); + display: -webkit-box; + /* Simulate text-overflow:ellipsis styles but for multiple text lines */ + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.menu { + width: 185px; + margin-top: -10px; +} + +.avatar { + font-weight: 500; + margin-right: 1.25rem; +} \ No newline at end of file diff --git a/src/renderer/components/layout/sidebar-cluster.tsx b/src/renderer/components/layout/sidebar-cluster.tsx new file mode 100644 index 0000000000..8b47ac122f --- /dev/null +++ b/src/renderer/components/layout/sidebar-cluster.tsx @@ -0,0 +1,140 @@ +/** + * 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 styles from "./sidebar-cluster.module.css"; +import { observable } from "mobx"; +import React, { useState } from "react"; +import { HotbarStore } from "../../../common/hotbar-store"; +import { broadcastMessage } from "../../../common/ipc"; +import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; +import { IpcRendererNavigationEvents } from "../../navigation/events"; +import { Avatar } from "../avatar/avatar"; +import { Icon } from "../icon"; +import { navigate } from "../../navigation"; +import { Menu, MenuItem } from "../menu"; +import { ConfirmDialog } from "../confirm-dialog"; + +const contextMenu: CatalogEntityContextMenuContext = observable({ + menuItems: [], + navigate: (url: string, forceMainFrame = true) => { + if (forceMainFrame) { + broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, url); + } else { + navigate(url); + } + }, +}); + +function onMenuItemClick(menuItem: CatalogEntityContextMenu) { + if (menuItem.confirm) { + ConfirmDialog.open({ + okButtonProps: { + primary: false, + accent: true, + }, + ok: () => { + menuItem.onClick(); + }, + message: menuItem.confirm.message, + }); + } else { + menuItem.onClick(); + } +} + +export function SidebarCluster({ clusterEntity }: { clusterEntity: CatalogEntity }) { + const [opened, setOpened] = useState(false); + + if (!clusterEntity) { + return null; + } + + const onMenuOpen = () => { + const hotbarStore = HotbarStore.getInstance(); + const isAddedToActive = HotbarStore.getInstance().isAddedToActive(clusterEntity); + const title = isAddedToActive + ? "Remove from Hotbar" + : "Add to Hotbar"; + const onClick = isAddedToActive + ? () => hotbarStore.removeFromHotbar(metadata.uid) + : () => hotbarStore.addToHotbar(clusterEntity); + + contextMenu.menuItems = [{ title, onClick }]; + clusterEntity.onContextMenuOpen(contextMenu); + + toggle(); + }; + + const onKeyDown = (evt: React.KeyboardEvent) => { + if (evt.code == "Space") { + toggle(); + } + }; + + const toggle = () => { + setOpened(!opened); + }; + + const { metadata, spec } = clusterEntity; + const id = `cluster-${metadata.uid}`; + + return ( + + ); +} diff --git a/src/renderer/components/layout/sidebar.module.css b/src/renderer/components/layout/sidebar.module.css index cf1257627f..13f10c76a9 100644 --- a/src/renderer/components/layout/sidebar.module.css +++ b/src/renderer/components/layout/sidebar.module.css @@ -40,17 +40,3 @@ padding: 3px; border-radius: 50%; } - -.cluster { - @apply flex items-center m-5; -} - -.clusterName { - @apply font-bold overflow-hidden; - word-break: break-word; - color: var(--textColorAccent); - display: -webkit-box; - /* Simulate text-overflow:ellipsis styles but for multiple text lines */ - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; -} diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index f89b06e6d4..702a7a61d2 100644 --- a/src/renderer/components/layout/sidebar.tsx +++ b/src/renderer/components/layout/sidebar.tsx @@ -32,7 +32,7 @@ import { Storage } from "../+storage"; import { Network } from "../+network"; import { crdStore } from "../+custom-resources/crd.store"; import { CustomResources } from "../+custom-resources/custom-resources"; -import { isActiveRoute, navigate } from "../../navigation"; +import { isActiveRoute } from "../../navigation"; import { isAllowedResource } from "../../../common/utils/allowed-resource"; import { Spinner } from "../spinner"; import { ClusterPageMenuRegistration, ClusterPageMenuRegistry, ClusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries"; @@ -41,12 +41,7 @@ import { Apps } from "../+apps"; import * as routes from "../../../common/routes"; import { Config } from "../+config"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import { HotbarIcon } from "../hotbar/hotbar-icon"; -import { makeObservable, observable } from "mobx"; -import type { CatalogEntityContextMenuContext } from "../../../common/catalog"; -import { HotbarStore } from "../../../common/hotbar-store"; -import { broadcastMessage } from "../../../common/ipc"; -import { IpcRendererNavigationEvents } from "../../navigation/events"; +import { SidebarCluster } from "./sidebar-cluster"; interface Props { className?: string; @@ -55,21 +50,6 @@ interface Props { @observer export class Sidebar extends React.Component { static displayName = "Sidebar"; - @observable private contextMenu: CatalogEntityContextMenuContext = { - menuItems: [], - navigate: (url: string, forceMainFrame = true) => { - if (forceMainFrame) { - broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, url); - } else { - navigate(url); - } - }, - }; - - constructor(props: Props) { - super(props); - makeObservable(this); - } async componentDidMount() { crdStore.reloadAll(); @@ -198,44 +178,6 @@ export class Sidebar extends React.Component { }); } - renderCluster() { - if (!this.clusterEntity) { - return null; - } - - const { metadata, spec } = this.clusterEntity; - - return ( -
- navigate("/")} - menuItems={this.contextMenu.menuItems} - onMenuOpen={() => { - const hotbarStore = HotbarStore.getInstance(); - const isAddedToActive = HotbarStore.getInstance().isAddedToActive(this.clusterEntity); - const title = isAddedToActive - ? "Remove from Hotbar" - : "Add to Hotbar"; - const onClick = isAddedToActive - ? () => hotbarStore.removeFromHotbar(metadata.uid) - : () => hotbarStore.addToHotbar(this.clusterEntity); - - this.contextMenu.menuItems = [{ title, onClick }]; - this.clusterEntity.onContextMenuOpen(this.contextMenu); - }} - /> -
- {metadata.name} -
-
- ); - } - get clusterEntity() { return catalogEntityRegistry.activeEntity; } @@ -245,7 +187,7 @@ export class Sidebar extends React.Component { return (
- {this.renderCluster()} +
{ @@ -96,6 +96,9 @@ export class PortForwardDialog extends Component { const { currentPort, desiredPort, close } = this; try { + // determine how many port-forwards are already active + const { length } = await getPortForwards(); + let port: number; portForward.protocol = dialogState.useHttps ? "https" : "http"; @@ -105,6 +108,11 @@ export class PortForwardDialog extends Component { } else { portForward.forwardPort = desiredPort; port = await addPortForward(portForward); + + // if this is the first port-forward show the about notification + if (!length) { + aboutPortForwarding(); + } } if (dialogState.openInBrowser) { diff --git a/src/renderer/port-forward/port-forward-notify.tsx b/src/renderer/port-forward/port-forward-notify.tsx new file mode 100644 index 0000000000..4b26fb976c --- /dev/null +++ b/src/renderer/port-forward/port-forward-notify.tsx @@ -0,0 +1,58 @@ +/** + * 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 React from "react"; +import { portForwardsURL } from "../../common/routes/port-forwards"; +import { Button } from "../components/button"; +import { Notifications, notificationsStore } from "../components/notifications"; +import { navigate } from "../navigation"; +import { getHostedClusterId } from "../utils"; + + +export function aboutPortForwarding() { + const notificationId = `port-forward-notification-${getHostedClusterId()}`; + + Notifications.info( + ( +
+ Port Forwarding +

+ You can manage your port forwards on the Port Forwarding Page. +

+
+
+
+ ), + { + id: notificationId, + timeout: 10_000, + }, + ); +} diff --git a/src/renderer/port-forward/port-forward.store.ts b/src/renderer/port-forward/port-forward.store.ts index 4fc9c81540..8a8d7c6e62 100644 --- a/src/renderer/port-forward/port-forward.store.ts +++ b/src/renderer/port-forward/port-forward.store.ts @@ -47,7 +47,7 @@ export class PortForwardStore extends ItemStore { const savedPortForwards = this.storage.get(); // undefined on first load if (Array.isArray(savedPortForwards)) { - logger.info("[PORT_FORWARD] starting saved port-forwards"); + logger.info("[PORT-FORWARD-STORE] starting saved port-forwards"); await Promise.all(savedPortForwards.map(addPortForward)); } } @@ -108,11 +108,12 @@ export async function addPortForward(portForward: ForwardedPort): Promise(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}?port=${portForward.port}&forwardPort=${portForward.forwardPort}&protocol=${protocol}`); - if (response?.port && response.port != +portForward.forwardPort) { - logger.warn(`specified ${portForward.forwardPort} got ${response.port}`); + // expecting the received port to be the specified port, unless the specified port is 0, which indicates any available port is suitable + if (portForward.forwardPort && response?.port && response.port != +portForward.forwardPort) { + logger.warn(`[PORT-FORWARD-STORE] specified ${portForward.forwardPort} got ${response.port}`); } } catch (error) { - logger.warn("Error adding port-forward:", error, portForward); + logger.warn("[PORT-FORWARD-STORE] Error adding port-forward:", error, portForward); throw(error); } portForwardStore.reset(); @@ -134,7 +135,7 @@ export async function getPortForward(portForward: ForwardedPort): Promise(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}?port=${portForward.port}&forwardPort=${portForward.forwardPort}${getProtocolQuery(portForward.protocol)}`); } catch (error) { - logger.warn("Error getting port-forward:", error, portForward); + logger.warn("[PORT-FORWARD-STORE] Error getting port-forward:", error, portForward); throw(error); } @@ -159,7 +160,7 @@ export async function removePortForward(portForward: ForwardedPort) { await apiBase.del(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}?port=${portForward.port}&forwardPort=${portForward.forwardPort}`); await waitUntilFree(+portForward.forwardPort, 200, 1000); } catch (error) { - logger.warn("Error removing port-forward:", error, portForward); + logger.warn("[PORT-FORWARD-STORE] Error removing port-forward:", error, portForward); throw(error); } portForwardStore.reset(); @@ -171,7 +172,7 @@ export async function getPortForwards(): Promise { return response.portForwards; } catch (error) { - logger.warn("Error getting all port-forwards:", error); + logger.warn("[PORT-FORWARD-STORE] Error getting all port-forwards:", error); return []; } diff --git a/yarn.lock b/yarn.lock index c0e0931096..adb7d8ad93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1051,7 +1051,7 @@ "@sentry/utils" "6.7.1" tslib "^1.9.3" -"@sentry/types@6.13.3", "@sentry/types@^6.13.3": +"@sentry/types@6.13.3": version "6.13.3" resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.13.3.tgz#63ad5b6735b0dfd90b3a256a9f8e77b93f0f66b2" integrity sha512-Vrz5CdhaTRSvCQjSyIFIaV9PodjAVFkzJkTRxyY7P77RcegMsRSsG1yzlvCtA99zG9+e6MfoJOgbOCwuZids5A== @@ -1061,6 +1061,11 @@ resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.7.1.tgz#c8263e1886df5e815570c4668eb40a1cfaa1c88b" integrity sha512-9AO7HKoip2MBMNQJEd6+AKtjj2+q9Ze4ooWUdEvdOVSt5drg7BGpK221/p9JEOyJAZwEPEXdcMd3VAIMiOb4MA== +"@sentry/types@^6.14.1": + version "6.14.1" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.14.1.tgz#0d562a7aa91253b7843723344b4ba03a010e6376" + integrity sha512-RIk3ZwQKZnASrYWfV5i4wbzVveHz8xLFAS2ySIMqh+hICKnB0N4/r8a1Of/84j7pj+iAbf5vPS85639eIf+9qg== + "@sentry/utils@6.13.3": version "6.13.3" resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.13.3.tgz#188754d40afe693c3fcae410f9322531588a9926" @@ -1601,10 +1606,10 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node@*", "@types/node@14.17.27", "@types/node@^14.6.2": - version "14.17.27" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.27.tgz#5054610d37bb5f6e21342d0e6d24c494231f3b85" - integrity sha512-94+Ahf9IcaDuJTle/2b+wzvjmutxXAEXU6O81JHblYXUg2BDG+dnBy7VxIPHKAyEEDHzCMQydTJuWvrE+Aanzw== +"@types/node@*", "@types/node@14.17.33", "@types/node@^14.6.2": + version "14.17.33" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.33.tgz#011ee28e38dc7aee1be032ceadf6332a0ab15b12" + integrity sha512-noEeJ06zbn3lOh4gqe2v7NMGS33jrulfNqYFDjjEbhpDEHR5VTxgYNQSBqBlJIsBJW3uEYDgD6kvMnrrhGzq8g== "@types/node@^10.12.0": version "10.17.24" @@ -1766,10 +1771,10 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^17.0.33": - version "17.0.33" - resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.33.tgz#e01ae3de7613dac1094569880bb3792732203ad5" - integrity sha512-pLWntxXpDPaU+RTAuSGWGSEL2FRTNyRQOjSWDke/rxRg14ncsZvx8AKWMWZqvc1UOaJIAoObdZhAWvRaHFi5rw== +"@types/react@*", "@types/react@^17.0.34": + version "17.0.34" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.34.tgz#797b66d359b692e3f19991b6b07e4b0c706c0102" + integrity sha512-46FEGrMjc2+8XhHXILr+3+/sTe3OfzSPU9YGKILLrUYbQ1CLQC9Daqo1KzENGXAWwrFwiY0l4ZbF20gRvgpWTg== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" @@ -3783,23 +3788,7 @@ color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.5.2: - version "1.5.3" - resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc" - integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw== - dependencies: - color-name "^1.0.0" - simple-swizzle "^0.2.2" - -color-string@^1.5.4: - version "1.5.5" - resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.5.tgz#65474a8f0e7439625f3d27a6a19d89fc45223014" - integrity sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg== - dependencies: - color-name "^1.0.0" - simple-swizzle "^0.2.2" - -color-string@^1.6.0: +color-string@^1.5.2, color-string@^1.5.4, color-string@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312" integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA== @@ -4092,11 +4081,16 @@ core-js@^3.6.5: resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== -core-util-is@1.0.2, core-util-is@~1.0.0: +core-util-is@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + cosmiconfig@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" @@ -8617,9 +8611,9 @@ jss@10.6.0, jss@^10.5.1: object.assign "^4.1.1" jszip@^3.1.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.5.0.tgz#b4fd1f368245346658e781fec9675802489e15f6" - integrity sha512-WRtu7TPCmYePR1nazfrtuF216cIVon/3GWOvHS9QR5bIwSbnxtdpma6un3jyGGNhHsKCSzn5Ypk+EkDRvTGiFA== + version "3.7.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.7.1.tgz#bd63401221c15625a1228c556ca8a68da6fda3d9" + integrity sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg== dependencies: lie "~3.3.0" pako "~1.0.2" @@ -9767,10 +9761,9 @@ node-fetch@2.6.1: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -node-fetch@^2.6.6: +node-fetch@lensapp/node-fetch#2.x: version "2.6.6" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.6.tgz#1751a7c01834e8e1697758732e9efb6eeadfaf89" - integrity sha512-Z8/6vRlTUChSdIgMa51jxQ4lrw/Jy5SOW10ObaA47/RElsAN2c5Pn8bTgFGWn/ibwzXTE8qwr1Yzx28vsecXEA== + resolved "https://codeload.github.com/lensapp/node-fetch/tar.gz/c869d40ba7dd3bce392c34e36118c225b6ada8fb" dependencies: whatwg-url "^5.0.0" @@ -13218,10 +13211,10 @@ table@^6.0.9: string-width "^4.2.3" strip-ansi "^6.0.1" -tailwindcss@^2.2.17: - version "2.2.17" - resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-2.2.17.tgz#c6332731f9ff1b6628ff589c95c38685347775e3" - integrity sha512-WgRpn+Pxn7eWqlruxnxEbL9ByVRWi3iC10z4b6dW0zSdnkPVC4hPMSWLQkkW8GCyBIv/vbJ0bxIi9dVrl4CfoA== +tailwindcss@^2.2.19: + version "2.2.19" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-2.2.19.tgz#540e464832cd462bb9649c1484b0a38315c2653c" + integrity sha512-6Ui7JSVtXadtTUo2NtkBBacobzWiQYVjYW0ZnKaP9S1ZCKQ0w7KVNz+YSDI/j7O7KCMHbOkz94ZMQhbT9pOqjw== dependencies: arg "^5.0.1" bytes "^3.0.0"