1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Merge branch 'master' into feature/port-forward-custom-port-input

This commit is contained in:
Saumya Shovan Roy (Deep) 2021-07-19 20:29:13 -04:00 committed by GitHub
commit 394ca5432d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
162 changed files with 2063 additions and 1481 deletions

View File

@ -60,6 +60,8 @@ build: node_modules binaries/client
$(MAKE) build-extensions -B $(MAKE) build-extensions -B
yarn run compile yarn run compile
ifeq "$(DETECTED_OS)" "Windows" ifeq "$(DETECTED_OS)" "Windows"
# https://github.com/ukoloff/win-ca#clear-pem-folder-on-publish
rm -rf node_modules/win-ca/pem
yarn run electron-builder --publish onTag --x64 --ia32 yarn run electron-builder --publish onTag --x64 --ia32
else else
yarn run electron-builder --publish onTag yarn run electron-builder --publish onTag

View File

@ -6,7 +6,7 @@ In this section you will learn how this extension works under the hood.
The Hello World sample extension does three things: The Hello World sample extension does three things:
- Implements `onActivate()` and outputs a message to the console. - Implements `onActivate()` and outputs a message to the console.
- Implements `onDectivate()` and outputs a message to the console. - Implements `onDeactivate()` and outputs a message to the console.
- Registers `ClusterPage` so that the page is visible in the left-side menu of the cluster dashboard. - Registers `ClusterPage` so that the page is visible in the left-side menu of the cluster dashboard.
Let's take a closer look at our Hello World sample's source code and see how these three things are achieved. Let's take a closer look at our Hello World sample's source code and see how these three things are achieved.

View File

@ -72,4 +72,4 @@ To dive deeper, consider looking at [Common Capabilities](../capabilities/common
If you find problems with the Lens Extension Generator, or have feature requests, you are welcome to raise an [issue](https://github.com/lensapp/generator-lens-ext/issues). If you find problems with the Lens Extension Generator, or have feature requests, you are welcome to raise an [issue](https://github.com/lensapp/generator-lens-ext/issues).
You can find the latest Lens contribution guidelines [here](https://docs.k8slens.dev/latest/contributing). You can find the latest Lens contribution guidelines [here](https://docs.k8slens.dev/latest/contributing).
The Generator source code is hosted at [Github](https://github.com/lensapp/generator-lens-ext). The Generator source code is hosted at [GitHub](https://github.com/lensapp/generator-lens-ext).

View File

@ -213,7 +213,7 @@ export class CertificatePage extends React.Component<{ extension: LensRendererEx
return ( return (
<TabLayout> <TabLayout>
<KubeObjectListLayout <KubeObjectListLayout
className="Certicates" store={certificatesStore} className="Certificates" store={certificatesStore}
sortingCallbacks={{ sortingCallbacks={{
[sortBy.name]: (certificate: Certificate) => certificate.getName(), [sortBy.name]: (certificate: Certificate) => certificate.getName(),
[sortBy.namespace]: (certificate: Certificate) => certificate.metadata.namespace, [sortBy.namespace]: (certificate: Certificate) => certificate.metadata.namespace,

View File

@ -94,7 +94,7 @@ export default class SamplePageMainExtension extends Main.LensExtension {
``` ```
When the menu item is clicked the `navigate()` method looks for and displays a global page with id `"myGlobalPage"`. When the menu item is clicked the `navigate()` method looks for and displays a global page with id `"myGlobalPage"`.
This page would be defined in your extension's `Renderer.LensExtension` implmentation (See [`Renderer.LensExtension`](renderer-extension.md)). This page would be defined in your extension's `Renderer.LensExtension` implementation (See [`Renderer.LensExtension`](renderer-extension.md)).
### `addCatalogSource()` and `removeCatalogSource()` Methods ### `addCatalogSource()` and `removeCatalogSource()` Methods

View File

@ -90,7 +90,7 @@ This is the cluster that the resource stack will be applied to, and the construc
Similarly, `ExampleClusterFeature` implements an `uninstall()` method which simply invokes the `kubectlDeleteFolder()` method of the `Renderer.K8sApi.ResourceStack` class. Similarly, `ExampleClusterFeature` implements an `uninstall()` method which simply invokes the `kubectlDeleteFolder()` method of the `Renderer.K8sApi.ResourceStack` class.
`kubectlDeleteFolder()` tries to delete from the cluster all kubernetes resources found in the folder passed to it, again in this case `../resources`. `kubectlDeleteFolder()` tries to delete from the cluster all kubernetes resources found in the folder passed to it, again in this case `../resources`.
`ExampleClusterFeature` also implements an `isInstalled()` method, which demonstrates how you can utiliize the kubernetes api to inspect the resource stack status. `ExampleClusterFeature` also implements an `isInstalled()` method, which demonstrates how you can utilize the kubernetes api to inspect the resource stack status.
`isInstalled()` simply tries to find a pod named `example-pod`, as a way to determine if the pod is already installed. `isInstalled()` simply tries to find a pod named `example-pod`, as a way to determine if the pod is already installed.
This method can be useful in creating a context-sensitive UI for installing/uninstalling the feature, as demonstrated in the next sample code. This method can be useful in creating a context-sensitive UI for installing/uninstalling the feature, as demonstrated in the next sample code.

View File

@ -148,8 +148,8 @@ export class ExamplePreferenceInput extends React.Component {
return ( return (
<Checkbox <Checkbox
label="I understand appPreferences" label="I understand appPreferences"
value={ExamplePreferencesStore.getInstace().enabled} value={ExamplePreferencesStore.getInstance().enabled}
onChange={v => { ExamplePreferencesStore.getInstace().enabled = v; }} onChange={v => { ExamplePreferencesStore.getInstance().enabled = v; }}
/> />
); );
} }

View File

@ -43,4 +43,4 @@ Say you have your project folder at `~/my-extension/` and you want to create an
npm pack npm pack
``` ```
This will create a NPM tarball that can be hosted on Github Releases or any other publicly available file hosting service. This will create a NPM tarball that can be hosted on GitHub Releases or any other publicly available file hosting service.

View File

@ -3,7 +3,7 @@
"productName": "OpenLens", "productName": "OpenLens",
"description": "OpenLens - Open Source IDE for Kubernetes", "description": "OpenLens - Open Source IDE for Kubernetes",
"homepage": "https://github.com/lensapp/lens", "homepage": "https://github.com/lensapp/lens",
"version": "5.0.3-beta.1", "version": "5.1.2",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2021 OpenLens Authors", "copyright": "© 2021 OpenLens Authors",
"license": "MIT", "license": "MIT",
@ -63,8 +63,7 @@
}, },
"moduleNameMapper": { "moduleNameMapper": {
"\\.(css|scss)$": "<rootDir>/__mocks__/styleMock.ts", "\\.(css|scss)$": "<rootDir>/__mocks__/styleMock.ts",
"\\.(svg)$": "<rootDir>/__mocks__/imageMock.ts", "\\.(svg)$": "<rootDir>/__mocks__/imageMock.ts"
"^@lingui/macro$": "<rootDir>/__mocks__/@linguiMacro.ts"
}, },
"modulePathIgnorePatterns": [ "modulePathIgnorePatterns": [
"<rootDir>/dist", "<rootDir>/dist",
@ -201,7 +200,6 @@
"electron-updater": "^4.3.1", "electron-updater": "^4.3.1",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"filehound": "^1.17.4", "filehound": "^1.17.4",
"filenamify": "^4.1.0",
"fs-extra": "^9.0.1", "fs-extra": "^9.0.1",
"grapheme-splitter": "^1.0.4", "grapheme-splitter": "^1.0.4",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
@ -218,7 +216,7 @@
"mobx": "^6.3.0", "mobx": "^6.3.0",
"mobx-observable-history": "^2.0.1", "mobx-observable-history": "^2.0.1",
"mobx-react": "^7.1.0", "mobx-react": "^7.1.0",
"mock-fs": "^4.12.0", "mock-fs": "^4.14.0",
"moment": "^2.29.1", "moment": "^2.29.1",
"moment-timezone": "^0.5.33", "moment-timezone": "^0.5.33",
"node-pty": "^0.9.0", "node-pty": "^0.9.0",
@ -279,7 +277,7 @@
"@types/marked": "^2.0.3", "@types/marked": "^2.0.3",
"@types/md5-file": "^4.0.2", "@types/md5-file": "^4.0.2",
"@types/mini-css-extract-plugin": "^0.9.1", "@types/mini-css-extract-plugin": "^0.9.1",
"@types/mock-fs": "^4.10.0", "@types/mock-fs": "^4.13.1",
"@types/module-alias": "^2.0.0", "@types/module-alias": "^2.0.0",
"@types/node": "12.20", "@types/node": "12.20",
"@types/npm": "^2.0.31", "@types/npm": "^2.0.31",
@ -339,7 +337,7 @@
"html-webpack-plugin": "^4.5.2", "html-webpack-plugin": "^4.5.2",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"include-media": "^1.4.9", "include-media": "^1.4.9",
"jest": "^26.0.1", "jest": "26.6.3",
"jest-canvas-mock": "^2.3.0", "jest-canvas-mock": "^2.3.0",
"jest-fetch-mock": "^3.0.3", "jest-fetch-mock": "^3.0.3",
"jest-mock-extended": "^1.0.16", "jest-mock-extended": "^1.0.16",
@ -355,7 +353,7 @@
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"progress-bar-webpack-plugin": "^2.1.0", "progress-bar-webpack-plugin": "^2.1.0",
"randomcolor": "^0.6.2", "randomcolor": "^0.6.2",
"raw-loader": "^4.0.1", "raw-loader": "^4.0.2",
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",
"react-refresh": "^0.9.0", "react-refresh": "^0.9.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
@ -377,9 +375,9 @@
"typedoc-plugin-markdown": "^3.9.0", "typedoc-plugin-markdown": "^3.9.0",
"typeface-roboto": "^1.1.13", "typeface-roboto": "^1.1.13",
"typescript": "^4.3.2", "typescript": "^4.3.2",
"typescript-plugin-css-modules": "^3.2.0", "typescript-plugin-css-modules": "^3.4.0",
"url-loader": "^4.1.0", "url-loader": "^4.1.0",
"webpack": "^4.44.2", "webpack": "^4.46.0",
"webpack-cli": "^3.3.11", "webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.11.0", "webpack-dev-server": "^3.11.0",
"webpack-node-externals": "^1.7.2", "webpack-node-externals": "^1.7.2",

View File

@ -56,7 +56,7 @@ class TestCatalogCategory2 extends CatalogCategory {
} }
describe("CatalogCategoryRegistry", () => { describe("CatalogCategoryRegistry", () => {
it("should remove only the category registered when running the disopser", () => { it("should remove only the category registered when running the disposer", () => {
const registry = new TestCatalogCategoryRegistry(); const registry = new TestCatalogCategoryRegistry();
expect(registry.items.length).toBe(0); expect(registry.items.length).toBe(0);

View File

@ -19,10 +19,6 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { Console } from "console";
console = new Console(process.stdout, process.stderr);
import mockFs from "mock-fs"; import mockFs from "mock-fs";
jest.mock("electron", () => { jest.mock("electron", () => {
@ -37,27 +33,27 @@ jest.mock("electron", () => {
}); });
import { UserStore } from "../user-store"; import { UserStore } from "../user-store";
import { Console } from "console";
import { SemVer } from "semver"; import { SemVer } from "semver";
import electron from "electron"; import electron from "electron";
import { stdout, stderr } from "process"; import { stdout, stderr } from "process";
import { beforeEachWrapped } from "../../../integration/helpers/utils"; import { beforeEachWrapped } from "../../../integration/helpers/utils";
import { ThemeStore } from "../../renderer/theme.store"; import { ThemeStore } from "../../renderer/theme.store";
import type { ClusterStoreModel } from "../cluster-store";
console = new Console(stdout, stderr); console = new Console(stdout, stderr);
describe("user store tests", () => { describe("user store tests", () => {
describe("for an empty config", () => { describe("for an empty config", () => {
beforeEachWrapped(() => { beforeEachWrapped(() => {
UserStore.resetInstance();
mockFs({ tmp: { "config.json": "{}", "kube_config": "{}" } }); mockFs({ tmp: { "config.json": "{}", "kube_config": "{}" } });
(UserStore.createInstance() as any).refreshNewContexts = jest.fn(() => Promise.resolve()); (UserStore.createInstance() as any).refreshNewContexts = jest.fn(() => Promise.resolve());
UserStore.getInstance();
}); });
afterEach(() => { afterEach(() => {
mockFs.restore(); mockFs.restore();
UserStore.resetInstance();
}); });
it("allows setting and retrieving lastSeenAppVersion", () => { it("allows setting and retrieving lastSeenAppVersion", () => {
@ -99,14 +95,31 @@ describe("user store tests", () => {
describe("migrations", () => { describe("migrations", () => {
beforeEachWrapped(() => { beforeEachWrapped(() => {
UserStore.resetInstance();
mockFs({ mockFs({
"tmp": { "tmp": {
"config.json": JSON.stringify({ "config.json": JSON.stringify({
user: { username: "foobar" }, user: { username: "foobar" },
preferences: { colorTheme: "light" }, preferences: { colorTheme: "light" },
lastSeenAppVersion: "1.2.3" lastSeenAppVersion: "1.2.3"
}) }),
"lens-cluster-store.json": JSON.stringify({
clusters: [
{
id: "foobar",
kubeConfigPath: "tmp/extension_data/foo/bar",
},
{
id: "barfoo",
kubeConfigPath: "some/other/path",
},
]
} as ClusterStoreModel),
"extension_data": {},
},
"some": {
"other": {
"path": "is file",
}
} }
}); });
@ -114,6 +127,7 @@ describe("user store tests", () => {
}); });
afterEach(() => { afterEach(() => {
UserStore.resetInstance();
mockFs.restore(); mockFs.restore();
}); });
@ -122,5 +136,12 @@ describe("user store tests", () => {
expect(us.lastSeenAppVersion).toBe("0.0.0"); expect(us.lastSeenAppVersion).toBe("0.0.0");
}); });
it.only("skips clusters for adding to kube-sync with files under extension_data/", () => {
const us = UserStore.getInstance();
expect(us.syncKubeconfigEntries.has("tmp/extension_data/foo/bar")).toBe(false);
expect(us.syncKubeconfigEntries.has("some/other/path")).toBe(true);
});
}); });
}); });

View File

@ -55,13 +55,18 @@ export interface KubernetesClusterSpec extends CatalogEntitySpec {
}; };
} }
export interface KubernetesClusterMetadata extends CatalogEntityMetadata {
distro?: string;
kubeVersion?: string;
}
export type KubernetesClusterStatusPhase = "connected" | "connecting" | "disconnected" | "deleting"; export type KubernetesClusterStatusPhase = "connected" | "connecting" | "disconnected" | "deleting";
export interface KubernetesClusterStatus extends CatalogEntityStatus { export interface KubernetesClusterStatus extends CatalogEntityStatus {
phase: KubernetesClusterStatusPhase; phase: KubernetesClusterStatusPhase;
} }
export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, KubernetesClusterStatus, KubernetesClusterSpec> { export class KubernetesCluster extends CatalogEntity<KubernetesClusterMetadata, KubernetesClusterStatus, KubernetesClusterSpec> {
public static readonly apiVersion = "entity.k8slens.dev/v1alpha1"; public static readonly apiVersion = "entity.k8slens.dev/v1alpha1";
public static readonly kind = "KubernetesCluster"; public static readonly kind = "KubernetesCluster";

View File

@ -101,6 +101,8 @@ export interface ClusterPreferences extends ClusterPrometheusPreferences {
icon?: string; icon?: string;
httpsProxy?: string; httpsProxy?: string;
hiddenMetrics?: string[]; hiddenMetrics?: string[];
nodeShellImage?: string;
imagePullSecret?: string;
} }
export interface ClusterPrometheusPreferences { export interface ClusterPrometheusPreferences {
@ -117,6 +119,8 @@ export interface ClusterPrometheusPreferences {
const initialStates = "cluster:states"; const initialStates = "cluster:states";
export const initialNodeShellImage = "docker.io/alpine:3.13";
export class ClusterStore extends BaseStore<ClusterStoreModel> { export class ClusterStore extends BaseStore<ClusterStoreModel> {
private static StateChannel = "cluster:state"; private static StateChannel = "cluster:state";

View File

@ -52,7 +52,7 @@ export interface HotbarStoreModel {
activeHotbarId: string; activeHotbarId: string;
} }
export const defaultHotbarCells = 12; // Number is choosen to easy hit any item with keyboard export const defaultHotbarCells = 12; // Number is chosen to easy hit any item with keyboard
export class HotbarStore extends BaseStore<HotbarStoreModel> { export class HotbarStore extends BaseStore<HotbarStoreModel> {
@observable hotbars: Hotbar[] = []; @observable hotbars: Hotbar[] = [];
@ -203,7 +203,7 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
} }
/** /**
* Remvove all hotbar items that reference the `uid`. * Remove all hotbar items that reference the `uid`.
* @param uid The `EntityId` that each hotbar item refers to * @param uid The `EntityId` that each hotbar item refers to
* @returns A function that will (in an action) undo the removing of the hotbar items. This function will not complete if the hotbar has changed. * @returns A function that will (in an action) undo the removing of the hotbar items. This function will not complete if the hotbar has changed.
*/ */

View File

@ -40,14 +40,14 @@ export interface KubeApiResourceData {
export const apiResourceRecord: Record<KubeResource, KubeApiResourceData> = { export const apiResourceRecord: Record<KubeResource, KubeApiResourceData> = {
"clusterroles": { kind: "ClusterRole", group: "rbac.authorization.k8s.io" }, "clusterroles": { kind: "ClusterRole", group: "rbac.authorization.k8s.io" },
"clusterrolebindings": { kind: "ClusterRoleBinding", group: "rbac.authorization.k8s.io" }, "clusterrolebindings": { kind: "ClusterRoleBinding", group: "rbac.authorization.k8s.io" },
"configmaps": { kind: "ConfigMap" }, "configmaps": { kind: "ConfigMap" }, //empty group means "core"
"cronjobs": { kind: "CronJob", group: "batch" }, "cronjobs": { kind: "CronJob", group: "batch" },
"customresourcedefinitions": { kind: "CustomResourceDefinition", group: "apiextensions.k8s.io" }, "customresourcedefinitions": { kind: "CustomResourceDefinition", group: "apiextensions.k8s.io" },
"daemonsets": { kind: "DaemonSet", group: "apps" }, "daemonsets": { kind: "DaemonSet", group: "apps" },
"deployments": { kind: "Deployment", group: "apps" }, "deployments": { kind: "Deployment", group: "apps" },
"endpoints": { kind: "Endpoint" }, "endpoints": { kind: "Endpoint" },
"events": { kind: "Event" }, "events": { kind: "Event" },
"horizontalpodautoscalers": { kind: "HorizontalPodAutoscaler" }, "horizontalpodautoscalers": { kind: "HorizontalPodAutoscaler", group: "autoscaling" },
"ingresses": { kind: "Ingress", group: "networking.k8s.io" }, "ingresses": { kind: "Ingress", group: "networking.k8s.io" },
"jobs": { kind: "Job", group: "batch" }, "jobs": { kind: "Job", group: "batch" },
"namespaces": { kind: "Namespace" }, "namespaces": { kind: "Namespace" },
@ -58,13 +58,13 @@ export const apiResourceRecord: Record<KubeResource, KubeApiResourceData> = {
"persistentvolumeclaims": { kind: "PersistentVolumeClaim" }, "persistentvolumeclaims": { kind: "PersistentVolumeClaim" },
"pods": { kind: "Pod" }, "pods": { kind: "Pod" },
"poddisruptionbudgets": { kind: "PodDisruptionBudget", group: "policy" }, "poddisruptionbudgets": { kind: "PodDisruptionBudget", group: "policy" },
"podsecuritypolicies": { kind: "PodSecurityPolicy" }, "podsecuritypolicies": { kind: "PodSecurityPolicy", group: "policy" },
"resourcequotas": { kind: "ResourceQuota" }, "resourcequotas": { kind: "ResourceQuota" },
"replicasets": { kind: "ReplicaSet", group: "apps" }, "replicasets": { kind: "ReplicaSet", group: "apps" },
"roles": { kind: "Role", group: "rbac.authorization.k8s.io" }, "roles": { kind: "Role", group: "rbac.authorization.k8s.io" },
"rolebindings": { kind: "RoleBinding", group: "rbac.authorization.k8s.io" }, "rolebindings": { kind: "RoleBinding", group: "rbac.authorization.k8s.io" },
"secrets": { kind: "Secret" }, "secrets": { kind: "Secret" },
"serviceaccounts": { kind: "ServiceAccount", group: "core" }, "serviceaccounts": { kind: "ServiceAccount" },
"services": { kind: "Service" }, "services": { kind: "Service" },
"statefulsets": { kind: "StatefulSet", group: "apps" }, "statefulsets": { kind: "StatefulSet", group: "apps" },
"storageclasses": { kind: "StorageClass", group: "storage.k8s.io" }, "storageclasses": { kind: "StorageClass", group: "storage.k8s.io" },

View File

@ -20,73 +20,58 @@
*/ */
import { CaptureConsole, Dedupe, Offline } from "@sentry/integrations"; import { CaptureConsole, Dedupe, Offline } from "@sentry/integrations";
import * as Sentry from "@sentry/electron";
import { sentryDsn, isProduction } from "./vars"; import { sentryDsn, isProduction } from "./vars";
import { UserStore } from "./user-store"; import { UserStore } from "./user-store";
import logger from "../main/logger"; import logger from "../main/logger";
export let sentryIsInitialized = false;
/** /**
* This function bypasses webpack issues. * "Translate" 'browser' to 'main' as Lens developer more familiar with the term 'main'
*
* See: https://docs.sentry.io/platforms/javascript/guides/electron/#webpack-configuration
*/ */
async function requireSentry() { function mapProcessName(processType: string) {
switch (process.type) { if (processType === "browser") {
case "browser": return "main";
return import("@sentry/electron/dist/main");
case "renderer":
return import("@sentry/electron/dist/renderer");
default:
throw new Error(`Unsupported process type ${process.type}`);
} }
return processType;
} }
/** /**
* Initialize Sentry for the current process so to send errors for debugging. * Initialize Sentry for the current process so to send errors for debugging.
*/ */
export async function SentryInit(): Promise<void> { export function SentryInit() {
try { const processName = mapProcessName(process.type);
const Sentry = await requireSentry();
try { Sentry.init({
Sentry.init({ beforeSend: (event) => {
beforeSend: event => { // default to false, in case instance of UserStore is not created (yet)
if (UserStore.getInstance().allowErrorReporting) { const allowErrorReporting = UserStore.getInstance(false)?.allowErrorReporting ?? false;
return event;
}
logger.info(`🔒 [SENTRY-BEFORE-SEND-HOOK]: allowErrorReporting: false. Sentry event is caught but not sent to server.`); if (allowErrorReporting) {
logger.info("🔒 [SENTRY-BEFORE-SEND-HOOK]: === START OF SENTRY EVENT ==="); return event;
logger.info(event); }
logger.info("🔒 [SENTRY-BEFORE-SEND-HOOK]: === END OF SENTRY EVENT ===");
// if return null, the event won't be sent logger.info(`🔒 [SENTRY-BEFORE-SEND-HOOK]: allowErrorReporting: ${allowErrorReporting}. Sentry event is caught but not sent to server.`);
// ref https://github.com/getsentry/sentry-javascript/issues/2039 logger.info("🔒 [SENTRY-BEFORE-SEND-HOOK]: === START OF SENTRY EVENT ===");
return null; logger.info(event);
}, logger.info("🔒 [SENTRY-BEFORE-SEND-HOOK]: === END OF SENTRY EVENT ===");
dsn: sentryDsn,
integrations: [
new CaptureConsole({ levels: ["error"] }),
new Dedupe(),
new Offline()
],
initialScope: {
tags: {
// "translate" browser to 'main' as Lens developer more familiar with the term 'main'
"process": process.type === "browser" ? "main" : "renderer"
}
},
environment: isProduction ? "production" : "development",
});
sentryIsInitialized = true; // if return null, the event won't be sent
// ref https://github.com/getsentry/sentry-javascript/issues/2039
return null;
},
dsn: sentryDsn,
integrations: [
new CaptureConsole({ levels: ["error"] }),
new Dedupe(),
new Offline()
],
initialScope: {
tags: {
logger.info(`✔️ [SENTRY-INIT]: Sentry for ${process.type} is initialized.`); "process": processName
} catch (error) { }
logger.warn(`⚠️ [SENTRY-INIT]: Sentry.init() error: ${error?.message ?? error}.`); },
} environment: isProduction ? "production" : "development",
} catch (error) { });
logger.warn(`⚠️ [SENTRY-INIT]: Error loading Sentry module ${error?.message ?? error}.`);
}
} }

View File

@ -33,5 +33,9 @@ if (isMac) {
} }
if (isWindows) { if (isWindows) {
winca.inject("+"); // see: https://github.com/ukoloff/win-ca#caveats try {
winca.inject("+"); // see: https://github.com/ukoloff/win-ca#caveats
} catch (error) {
logger.error(`[CA]: failed to force load: ${error}`);
}
} }

View File

@ -193,9 +193,9 @@ const hiddenTableColumns: PreferenceDescription<[string, string[]][], Map<string
toStore(val) { toStore(val) {
const res: [string, string[]][] = []; const res: [string, string[]][] = [];
for (const [table, columnes] of val) { for (const [table, columns] of val) {
if (columnes.size) { if (columns.size) {
res.push([table, Array.from(columnes)]); res.push([table, Array.from(columns)]);
} }
} }

View File

@ -0,0 +1,130 @@
/**
* 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 { describeIf } from "../../../../integration/helpers/utils";
import { isWindows } from "../../vars";
import { isLogicalChildPath } from "../paths";
describe("isLogicalChildPath", () => {
describeIf(isWindows)("windows tests", () => {
it.each([
{
parentPath: "C:\\Foo",
testPath: "C:\\Foo\\Bar",
expected: true,
},
{
parentPath: "C:\\Foo",
testPath: "C:\\Bar",
expected: false,
},
{
parentPath: "C:\\Foo",
testPath: "C:/Bar",
expected: false,
},
{
parentPath: "C:\\Foo",
testPath: "C:/Foo/Bar",
expected: true,
},
{
parentPath: "C:\\Foo",
testPath: "D:\\Foo\\Bar",
expected: false,
},
])("test %#", (testData) => {
expect(isLogicalChildPath(testData.parentPath, testData.testPath)).toBe(testData.expected);
});
});
describeIf(!isWindows)("posix tests", () => {
it.each([
{
parentPath: "/foo",
testPath: "/foo",
expected: false,
},
{
parentPath: "/foo",
testPath: "/bar",
expected: false,
},
{
parentPath: "/foo",
testPath: "/foobar",
expected: false,
},
{
parentPath: "/foo",
testPath: "/foo/bar",
expected: true,
},
{
parentPath: "/foo",
testPath: "/foo/../bar",
expected: false,
},
{
parentPath: "/foo",
testPath: "/foo/./bar",
expected: true,
},
{
parentPath: "/foo",
testPath: "/foo/.bar",
expected: true,
},
{
parentPath: "/foo",
testPath: "/foo/..bar",
expected: true,
},
{
parentPath: "/foo",
testPath: "/foo/...bar",
expected: true,
},
{
parentPath: "/foo",
testPath: "/foo/..\\.bar",
expected: true,
},
{
parentPath: "/bar/../foo",
testPath: "/foo/bar",
expected: true,
},
{
parentPath: "/foo",
testPath: "/foo/\\bar",
expected: true,
},
{
parentPath: "/foo",
testPath: "./bar",
expected: false,
},
])("test %#", (testData) => {
expect(isLogicalChildPath(testData.parentPath, testData.testPath)).toBe(testData.expected);
});
});
});

View File

@ -27,7 +27,7 @@ export class ExtendedMap<K, V> extends Map<K, V> {
} }
/** /**
* Get the value behind `key`. If it was not pressent, first insert the value returned by `getVal` * Get the value behind `key`. If it was not present, first insert the value returned by `getVal`
* @param key The key to insert into the map with * @param key The key to insert into the map with
* @param getVal A function that returns a new instance of `V`. * @param getVal A function that returns a new instance of `V`.
* @returns The value in the map * @returns The value in the map

View File

@ -33,3 +33,35 @@ function resolveTilde(filePath: string) {
export function resolvePath(filePath: string): string { export function resolvePath(filePath: string): string {
return path.resolve(resolveTilde(filePath)); return path.resolve(resolveTilde(filePath));
} }
/**
* Checks if `testPath` represents a potential filesystem entry that would be
* logically "within" the `parentPath` directory.
*
* This function will return `true` in the above case, and `false` otherwise.
* It will return `false` if the two paths are the same (after resolving them).
*
* The function makes no FS calls and is platform dependant. Meaning that the
* results are only guaranteed to be correct for the platform you are running
* on.
* @param parentPath The known path of a directory
* @param testPath The path that is to be tested
*/
export function isLogicalChildPath(parentPath: string, testPath: string): boolean {
parentPath = path.resolve(parentPath);
testPath = path.resolve(testPath);
if (parentPath === testPath) {
return false;
}
while (testPath.length >= parentPath.length) {
if (testPath === parentPath) {
return true;
}
testPath = path.dirname(testPath);
}
return false;
}

View File

@ -71,4 +71,4 @@ export const supportUrl = "https://docs.k8slens.dev/latest/support/" as string;
export const appSemVer = new SemVer(packageInfo.version); export const appSemVer = new SemVer(packageInfo.version);
export const docsUrl = "https://docs.k8slens.dev/main/" as string; export const docsUrl = "https://docs.k8slens.dev/main/" as string;
export const sentryDsn = packageInfo.config?.sentryDsn; export const sentryDsn = packageInfo.config?.sentryDsn ?? "";

View File

@ -0,0 +1,138 @@
/**
* 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 { isCompatibleExtension } from "../extension-compatibility";
import { Console } from "console";
import { stdout, stderr } from "process";
import type { LensExtensionManifest } from "../lens-extension";
import { appSemVer } from "../../common/vars";
console = new Console(stdout, stderr);
describe("extension compatibility", () => {
describe("appSemVer with no prerelease tag", () => {
beforeAll(() => {
appSemVer.major = 5;
appSemVer.minor = 0;
appSemVer.patch = 3;
appSemVer.prerelease = [];
});
it("has no extension comparator", () => {
const manifest = { name: "extensionName", version: "0.0.1"};
expect(isCompatibleExtension(manifest,)).toBe(false);
});
it.each([
{
comparator: "",
expected: false,
},
{
comparator: "bad comparator",
expected: false,
},
{
comparator: "^4.0.0",
expected: false,
},
{
comparator: "^5.0.0",
expected: true,
},
{
comparator: "^6.0.0",
expected: false,
},
{
comparator: "^4.0.0-alpha.1",
expected: false,
},
{
comparator: "^5.0.0-alpha.1",
expected: true,
},
{
comparator: "^6.0.0-alpha.1",
expected: false,
},
])("extension comparator test: %p", ({ comparator, expected }) => {
const manifest: LensExtensionManifest = { name: "extensionName", version: "0.0.1", engines: { lens: comparator}};
expect(isCompatibleExtension(manifest,)).toBe(expected);
});
});
describe("appSemVer with prerelease tag", () => {
beforeAll(() => {
appSemVer.major = 5;
appSemVer.minor = 0;
appSemVer.patch = 3;
appSemVer.prerelease = ["beta", 3];
});
it("has no extension comparator", () => {
const manifest = { name: "extensionName", version: "0.0.1"};
expect(isCompatibleExtension(manifest,)).toBe(false);
});
it.each([
{
comparator: "",
expected: false,
},
{
comparator: "bad comparator",
expected: false,
},
{
comparator: "^4.0.0",
expected: false,
},
{
comparator: "^5.0.0",
expected: true,
},
{
comparator: "^6.0.0",
expected: false,
},
{
comparator: "^4.0.0-alpha.1",
expected: false,
},
{
comparator: "^5.0.0-alpha.1",
expected: true,
},
{
comparator: "^6.0.0-alpha.1",
expected: false,
},
])("extension comparator test: %p", ({ comparator, expected }) => {
const manifest: LensExtensionManifest = { name: "extensionName", version: "0.0.1", engines: { lens: comparator}};
expect(isCompatibleExtension(manifest,)).toBe(expected);
});
});
});

View File

@ -84,7 +84,7 @@ jest.mock(
(channel: string, listener: (event: any, ...args: any[]) => void) => { (channel: string, listener: (event: any, ...args: any[]) => void) => {
if (channel === "extensions:main") { if (channel === "extensions:main") {
// First initialize with extensions 1 and 2 // First initialize with extensions 1 and 2
// and then broadcast event to remove extensioin 2 and add extension number 3 // and then broadcast event to remove extension 2 and add extension number 3
setTimeout(() => { setTimeout(() => {
listener({}, [ listener({}, [
[ [

View File

@ -19,33 +19,19 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import "./cube-spinner.scss"; import semver from "semver";
import React from "react"; import { appSemVer } from "../common/vars";
import { cssNames } from "../../utils"; import type { LensExtensionManifest } from "./lens-extension";
export interface CubeSpinnerProps { export function isCompatibleExtension(manifest: LensExtensionManifest): boolean {
className?: string; if (manifest.engines?.lens) {
center?: boolean; /* include Lens's prerelease tag in the matching so the extension's compatibility is not limited by it */
} return semver.satisfies(appSemVer, manifest.engines.lens, { includePrerelease: true });
export class CubeSpinner extends React.Component<CubeSpinnerProps> {
render() {
const { className, center } = this.props;
return (
<div className={cssNames("CubeSpinner ", className, { center })}>
<div className="sk-cube-grid">
<div className="sk-cube sk-cube1"></div>
<div className="sk-cube sk-cube2"></div>
<div className="sk-cube sk-cube3"></div>
<div className="sk-cube sk-cube4"></div>
<div className="sk-cube sk-cube5"></div>
<div className="sk-cube sk-cube6"></div>
<div className="sk-cube sk-cube7"></div>
<div className="sk-cube sk-cube8"></div>
<div className="sk-cube sk-cube9"></div>
</div>
</div>
);
} }
return false;
}
export function isCompatibleBundledExtension(manifest: LensExtensionManifest): boolean {
return manifest.version === appSemVer.raw;
} }

View File

@ -34,9 +34,8 @@ import { extensionInstaller } from "./extension-installer";
import { ExtensionsStore } from "./extensions-store"; import { ExtensionsStore } from "./extensions-store";
import { ExtensionLoader } from "./extension-loader"; import { ExtensionLoader } from "./extension-loader";
import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"; import type { LensExtensionId, LensExtensionManifest } from "./lens-extension";
import semver from "semver";
import { appSemVer } from "../common/vars";
import { isProduction } from "../common/vars"; import { isProduction } from "../common/vars";
import { isCompatibleBundledExtension, isCompatibleExtension } from "./extension-compatibility";
export interface InstalledExtension { export interface InstalledExtension {
id: LensExtensionId; id: LensExtensionId;
@ -362,17 +361,7 @@ export class ExtensionDiscovery extends Singleton {
const extensionDir = path.dirname(manifestPath); const extensionDir = path.dirname(manifestPath);
const npmPackage = path.join(extensionDir, `${manifest.name}-${manifest.version}.tgz`); const npmPackage = path.join(extensionDir, `${manifest.name}-${manifest.version}.tgz`);
const absolutePath = (isProduction && await fse.pathExists(npmPackage)) ? npmPackage : extensionDir; const absolutePath = (isProduction && await fse.pathExists(npmPackage)) ? npmPackage : extensionDir;
let isCompatible = isBundled; const isCompatible = (isBundled && isCompatibleBundledExtension(manifest)) || isCompatibleExtension(manifest);
if (manifest.engines?.lens) {
const appSemVerLatestImplied = appSemVer;
if (appSemVerLatestImplied.prerelease?.[0] === "latest") {
/* remove the "latest" prerelease tag so as not to require the extension to specify it */
appSemVerLatestImplied.prerelease = [];
}
isCompatible = semver.satisfies(appSemVerLatestImplied, manifest.engines.lens);
}
return { return {
id, id,

View File

@ -36,7 +36,7 @@ export abstract class IpcMain extends IpcRegistrar {
* Listen for broadcasts within your extension * Listen for broadcasts within your extension
* @param channel The channel to listen for broadcasts on * @param channel The channel to listen for broadcasts on
* @param listener The function that will be called with the arguments of the broadcast * @param listener The function that will be called with the arguments of the broadcast
* @returns An optional disopser, Lens will cleanup when the extension is disabled or uninstalled even if this is not called * @returns An optional disposer, Lens will cleanup when the extension is disabled or uninstalled even if this is not called
*/ */
listen(channel: string, listener: (event: Electron.IpcRendererEvent, ...args: any[]) => any): Disposer { listen(channel: string, listener: (event: Electron.IpcRendererEvent, ...args: any[]) => any): Disposer {
const prefixedChannel = `extensions@${this[IpcPrefix]}:${channel}`; const prefixedChannel = `extensions@${this[IpcPrefix]}:${channel}`;

View File

@ -74,7 +74,7 @@ export class LensExtension {
* getExtensionFileFolder returns the path to an already created folder. This * getExtensionFileFolder returns the path to an already created folder. This
* folder is for the sole use of this extension. * folder is for the sole use of this extension.
* *
* Note: there is no security done on this folder, only obfiscation of the * Note: there is no security done on this folder, only obfuscation of the
* folder name. * folder name.
*/ */
async getExtensionFileFolder(): Promise<string> { async getExtensionFileFolder(): Promise<string> {

View File

@ -23,6 +23,7 @@ import type * as registries from "./registries";
import type { Cluster } from "../main/cluster"; import type { Cluster } from "../main/cluster";
import { LensExtension } from "./lens-extension"; import { LensExtension } from "./lens-extension";
import { getExtensionPageUrl } from "./registries/page-registry"; import { getExtensionPageUrl } from "./registries/page-registry";
import type { CatalogEntity } from "../common/catalog";
export class LensRendererExtension extends LensExtension { export class LensRendererExtension extends LensExtension {
globalPages: registries.PageRegistration[] = []; globalPages: registries.PageRegistration[] = [];
@ -37,7 +38,7 @@ export class LensRendererExtension extends LensExtension {
kubeWorkloadsOverviewItems: registries.WorkloadsOverviewDetailRegistration[] = []; kubeWorkloadsOverviewItems: registries.WorkloadsOverviewDetailRegistration[] = [];
commands: registries.CommandRegistration[] = []; commands: registries.CommandRegistration[] = [];
welcomeMenus: registries.WelcomeMenuRegistration[] = []; welcomeMenus: registries.WelcomeMenuRegistration[] = [];
catalogEntityDetailItems: registries.CatalogEntityDetailRegistration[] = []; catalogEntityDetailItems: registries.CatalogEntityDetailRegistration<CatalogEntity>[] = [];
topBarItems: registries.TopBarRegistration[] = []; topBarItems: registries.TopBarRegistration[] = [];
async navigate<P extends object>(pageId?: string, params?: P) { async navigate<P extends object>(pageId?: string, params?: P) {

View File

@ -22,14 +22,24 @@
import { ClusterPageRegistry, getExtensionPageUrl, GlobalPageRegistry, PageParams } from "../page-registry"; import { ClusterPageRegistry, getExtensionPageUrl, GlobalPageRegistry, PageParams } from "../page-registry";
import { LensExtension } from "../../lens-extension"; import { LensExtension } from "../../lens-extension";
import React from "react"; import React from "react";
import fse from "fs-extra";
import { Console } from "console"; import { Console } from "console";
import { stdout, stderr } from "process"; import { stdout, stderr } from "process";
import { ThemeStore } from "../../../renderer/theme.store";
import { TerminalStore } from "../../renderer-api/components";
import { UserStore } from "../../../common/user-store";
jest.mock("electron", () => ({
app: {
getPath: () => "tmp",
},
}));
console = new Console(stdout, stderr); console = new Console(stdout, stderr);
let ext: LensExtension = null; let ext: LensExtension = null;
describe("getPageUrl", () => { describe("page registry tests", () => {
beforeEach(async () => { beforeEach(async () => {
ext = new LensExtension({ ext = new LensExtension({
manifest: { manifest: {
@ -43,6 +53,9 @@ describe("getPageUrl", () => {
isEnabled: true, isEnabled: true,
isCompatible: true isCompatible: true
}); });
UserStore.createInstance();
ThemeStore.createInstance();
TerminalStore.createInstance();
ClusterPageRegistry.createInstance(); ClusterPageRegistry.createInstance();
GlobalPageRegistry.createInstance().add({ GlobalPageRegistry.createInstance().add({
id: "page-with-params", id: "page-with-params",
@ -54,70 +67,6 @@ describe("getPageUrl", () => {
test2: "" // no default value, just declaration test2: "" // no default value, just declaration
}, },
}, ext); }, ext);
});
afterEach(() => {
GlobalPageRegistry.resetInstance();
ClusterPageRegistry.resetInstance();
});
it("returns a page url for extension", () => {
expect(getExtensionPageUrl({ extensionId: ext.name })).toBe("/extension/foo-bar");
});
it("allows to pass base url as parameter", () => {
expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "/test" })).toBe("/extension/foo-bar/test");
});
it("removes @ and replace `/` to `--`", () => {
expect(getExtensionPageUrl({ extensionId: "@foo/bar" })).toBe("/extension/foo--bar");
});
it("adds / prefix", () => {
expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "test" })).toBe("/extension/foo-bar/test");
});
it("normalize possible multi-slashes in page.id", () => {
expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "//test/" })).toBe("/extension/foo-bar/test");
});
it("gets page url with custom params", () => {
const params: PageParams = { test1: "one", test2: "2" };
const searchParams = new URLSearchParams(params);
const pageUrl = getExtensionPageUrl({
extensionId: ext.name,
pageId: "page-with-params",
params,
});
expect(pageUrl).toBe(`/extension/foo-bar/page-with-params?${searchParams}`);
});
it("gets page url with default custom params", () => {
const defaultPageUrl = getExtensionPageUrl({
extensionId: ext.name,
pageId: "page-with-params",
});
expect(defaultPageUrl).toBe(`/extension/foo-bar/page-with-params?test1=test1-default`);
});
});
describe("globalPageRegistry", () => {
beforeEach(async () => {
ext = new LensExtension({
manifest: {
name: "@acme/foo-bar",
version: "0.1.1"
},
id: "/this/is/fake/package.json",
absolutePath: "/absolute/fake/",
manifestPath: "/this/is/fake/package.json",
isBundled: false,
isEnabled: true,
isCompatible: true
});
ClusterPageRegistry.createInstance();
GlobalPageRegistry.createInstance().add([ GlobalPageRegistry.createInstance().add([
{ {
id: "test-page", id: "test-page",
@ -142,33 +91,82 @@ describe("globalPageRegistry", () => {
afterEach(() => { afterEach(() => {
GlobalPageRegistry.resetInstance(); GlobalPageRegistry.resetInstance();
ClusterPageRegistry.resetInstance(); ClusterPageRegistry.resetInstance();
TerminalStore.resetInstance();
ThemeStore.resetInstance();
UserStore.resetInstance();
fse.remove("tmp");
}); });
describe("getByPageTarget", () => { describe("getPageUrl", () => {
it("matching to first registered page without id", () => { it("returns a page url for extension", () => {
const page = GlobalPageRegistry.getInstance().getByPageTarget({ extensionId: ext.name }); expect(getExtensionPageUrl({ extensionId: ext.name })).toBe("/extension/foo-bar");
expect(page.id).toEqual(undefined);
expect(page.extensionId).toEqual(ext.name);
expect(page.url).toEqual(getExtensionPageUrl({ extensionId: ext.name }));
}); });
it("returns matching page", () => { it("allows to pass base url as parameter", () => {
const page = GlobalPageRegistry.getInstance().getByPageTarget({ expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "/test" })).toBe("/extension/foo-bar/test");
pageId: "test-page",
extensionId: ext.name
});
expect(page.id).toEqual("test-page");
}); });
it("returns null if target not found", () => { it("removes @ and replace `/` to `--`", () => {
const page = GlobalPageRegistry.getInstance().getByPageTarget({ expect(getExtensionPageUrl({ extensionId: "@foo/bar" })).toBe("/extension/foo--bar");
pageId: "wrong-page", });
extensionId: ext.name
it("adds / prefix", () => {
expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "test" })).toBe("/extension/foo-bar/test");
});
it("normalize possible multi-slashes in page.id", () => {
expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "//test/" })).toBe("/extension/foo-bar/test");
});
it("gets page url with custom params", () => {
const params: PageParams = { test1: "one", test2: "2" };
const searchParams = new URLSearchParams(params);
const pageUrl = getExtensionPageUrl({
extensionId: ext.name,
pageId: "page-with-params",
params,
}); });
expect(page).toBeNull(); expect(pageUrl).toBe(`/extension/foo-bar/page-with-params?${searchParams}`);
});
it("gets page url with default custom params", () => {
const defaultPageUrl = getExtensionPageUrl({
extensionId: ext.name,
pageId: "page-with-params",
});
expect(defaultPageUrl).toBe(`/extension/foo-bar/page-with-params?test1=test1-default`);
});
});
describe("globalPageRegistry", () => {
describe("getByPageTarget", () => {
it("matching to first registered page without id", () => {
const page = GlobalPageRegistry.getInstance().getByPageTarget({ extensionId: ext.name });
expect(page.id).toEqual(undefined);
expect(page.extensionId).toEqual(ext.name);
expect(page.url).toEqual(getExtensionPageUrl({ extensionId: ext.name }));
});
it("returns matching page", () => {
const page = GlobalPageRegistry.getInstance().getByPageTarget({
pageId: "test-page",
extensionId: ext.name
});
expect(page.id).toEqual("test-page");
});
it("returns null if target not found", () => {
const page = GlobalPageRegistry.getInstance().getByPageTarget({
pageId: "wrong-page",
extensionId: ext.name
});
expect(page).toBeNull();
});
}); });
}); });
}); });

View File

@ -20,20 +20,25 @@
*/ */
import type React from "react"; import type React from "react";
import type { CatalogEntity } from "../common-api/catalog";
import { BaseRegistry } from "./base-registry"; import { BaseRegistry } from "./base-registry";
export interface CatalogEntityDetailComponents { export interface CatalogEntityDetailsProps<T extends CatalogEntity> {
Details: React.ComponentType<any>; entity: T;
} }
export interface CatalogEntityDetailRegistration { export interface CatalogEntityDetailComponents<T extends CatalogEntity> {
Details: React.ComponentType<CatalogEntityDetailsProps<T>>;
}
export interface CatalogEntityDetailRegistration<T extends CatalogEntity> {
kind: string; kind: string;
apiVersions: string[]; apiVersions: string[];
components: CatalogEntityDetailComponents; components: CatalogEntityDetailComponents<T>;
priority?: number; priority?: number;
} }
export class CatalogEntityDetailRegistry extends BaseRegistry<CatalogEntityDetailRegistration> { export class CatalogEntityDetailRegistry extends BaseRegistry<CatalogEntityDetailRegistration<CatalogEntity>> {
getItemsForKind(kind: string, apiVersion: string) { getItemsForKind(kind: string, apiVersion: string) {
const items = this.getItems().filter((item) => { const items = this.getItems().filter((item) => {
return item.kind === kind && item.apiVersions.includes(apiVersion); return item.kind === kind && item.apiVersions.includes(apiVersion);

View File

@ -67,5 +67,5 @@ export * from "../../renderer/components/+events/kube-event-details";
// specific exports // specific exports
export * from "../../renderer/components/status-brick"; export * from "../../renderer/components/status-brick";
export { terminalStore, createTerminalTab } from "../../renderer/components/dock/terminal.store"; export { terminalStore, createTerminalTab, TerminalStore } from "../../renderer/components/dock/terminal.store";
export { logTabStore } from "../../renderer/components/dock/log-tab.store"; export { logTabStore } from "../../renderer/components/dock/log-tab.store";

View File

@ -207,7 +207,7 @@ describe("ContextHandler", () => {
expect(service.id === "id_0"); expect(service.id === "id_0");
}); });
it("shouldn't pick the second provider of 2 succcess(es) after 1 failure(s)", async () => { it("shouldn't pick the second provider of 2 success(es) after 1 failure(s)", async () => {
const reg = PrometheusProviderRegistry.getInstance(); const reg = PrometheusProviderRegistry.getInstance();
let count = 0; let count = 0;

View File

@ -23,7 +23,7 @@ import "../common/cluster-ipc";
import type http from "http"; import type http from "http";
import { action, autorun, makeObservable, observable, observe, reaction, toJS } from "mobx"; import { action, autorun, makeObservable, observable, observe, reaction, toJS } from "mobx";
import { ClusterId, ClusterStore, getClusterIdFromHost } from "../common/cluster-store"; import { ClusterId, ClusterStore, getClusterIdFromHost } from "../common/cluster-store";
import type { Cluster } from "./cluster"; import { Cluster } from "./cluster";
import logger from "./logger"; import logger from "./logger";
import { apiKubePrefix } from "../common/vars"; import { apiKubePrefix } from "../common/vars";
import { Singleton } from "../common/utils"; import { Singleton } from "../common/utils";
@ -32,6 +32,8 @@ import { KubernetesCluster, KubernetesClusterPrometheusMetrics, KubernetesCluste
import { ipcMainOn } from "../common/ipc"; import { ipcMainOn } from "../common/ipc";
import { once } from "lodash"; import { once } from "lodash";
const logPrefix = "[CLUSTER-MANAGER]:";
export class ClusterManager extends Singleton { export class ClusterManager extends Singleton {
private store = ClusterStore.getInstance(); private store = ClusterStore.getInstance();
deleting = observable.set<ClusterId>(); deleting = observable.set<ClusterId>();
@ -73,7 +75,7 @@ export class ClusterManager extends Singleton {
if (removedClusters.length > 0) { if (removedClusters.length > 0) {
const meta = removedClusters.map(cluster => cluster.getMeta()); const meta = removedClusters.map(cluster => cluster.getMeta());
logger.info(`[CLUSTER-MANAGER]: removing clusters`, meta); logger.info(`${logPrefix} removing clusters`, meta);
removedClusters.forEach(cluster => cluster.disconnect()); removedClusters.forEach(cluster => cluster.disconnect());
this.store.removedClusters.clear(); this.store.removedClusters.clear();
} }
@ -103,13 +105,13 @@ export class ClusterManager extends Singleton {
this.updateEntityStatus(entity, cluster); this.updateEntityStatus(entity, cluster);
entity.metadata.labels = Object.assign({}, cluster.labels, entity.metadata.labels); entity.metadata.labels = {
entity.metadata.labels.distro = cluster.distribution; ...entity.metadata.labels,
...cluster.labels,
if (cluster.preferences?.clusterName) { };
entity.metadata.name = cluster.preferences.clusterName; entity.metadata.distro = cluster.distribution;
} entity.metadata.kubeVersion = cluster.version;
entity.metadata.name = cluster.name;
entity.spec.metrics ||= { source: "local" }; entity.spec.metrics ||= { source: "local" };
if (entity.spec.metrics.source === "local") { if (entity.spec.metrics.source === "local") {
@ -161,14 +163,22 @@ export class ClusterManager extends Singleton {
const cluster = this.store.getById(entity.metadata.uid); const cluster = this.store.getById(entity.metadata.uid);
if (!cluster) { if (!cluster) {
this.store.addCluster({ try {
id: entity.metadata.uid, this.store.addCluster({
preferences: { id: entity.metadata.uid,
clusterName: entity.metadata.name preferences: {
}, clusterName: entity.metadata.name
kubeConfigPath: entity.spec.kubeconfigPath, },
contextName: entity.spec.kubeconfigContext kubeConfigPath: entity.spec.kubeconfigPath,
}); contextName: entity.spec.kubeconfigContext
});
} catch (error) {
if (error.code === "ENOENT" && error.path === entity.spec.kubeconfigPath) {
logger.warn(`${logPrefix} kubeconfig file disappeared`, { path: entity.spec.kubeconfigPath });
} else {
logger.error(`${logPrefix} failed to add cluster: ${error}`);
}
}
} else { } else {
cluster.kubeConfigPath = entity.spec.kubeconfigPath; cluster.kubeConfigPath = entity.spec.kubeconfigPath;
cluster.contextName = entity.spec.kubeconfigContext; cluster.contextName = entity.spec.kubeconfigContext;
@ -179,7 +189,7 @@ export class ClusterManager extends Singleton {
} }
protected onNetworkOffline = () => { protected onNetworkOffline = () => {
logger.info("[CLUSTER-MANAGER]: network is offline"); logger.info(`${logPrefix} network is offline`);
this.store.clustersList.forEach((cluster) => { this.store.clustersList.forEach((cluster) => {
if (!cluster.disconnected) { if (!cluster.disconnected) {
cluster.online = false; cluster.online = false;
@ -190,7 +200,7 @@ export class ClusterManager extends Singleton {
}; };
protected onNetworkOnline = () => { protected onNetworkOnline = () => {
logger.info("[CLUSTER-MANAGER]: network is online"); logger.info(`${logPrefix} network is online`);
this.store.clustersList.forEach((cluster) => { this.store.clustersList.forEach((cluster) => {
if (!cluster.disconnected) { if (!cluster.disconnected) {
cluster.refreshConnectionStatus().catch((e) => e); cluster.refreshConnectionStatus().catch((e) => e);
@ -236,8 +246,10 @@ export function catalogEntityFromCluster(cluster: Cluster) {
name: cluster.name, name: cluster.name,
source: "local", source: "local",
labels: { labels: {
distro: cluster.distribution, ...cluster.labels,
} },
distro: cluster.distribution,
kubeVersion: cluster.version,
}, },
spec: { spec: {
kubeconfigPath: cluster.kubeConfigPath, kubeconfigPath: cluster.kubeConfigPath,

View File

@ -34,6 +34,7 @@ import { VersionDetector } from "./cluster-detectors/version-detector";
import { detectorRegistry } from "./cluster-detectors/detector-registry"; import { detectorRegistry } from "./cluster-detectors/detector-registry";
import plimit from "p-limit"; import plimit from "p-limit";
import { toJS } from "../common/utils"; import { toJS } from "../common/utils";
import { initialNodeShellImage } from "../common/cluster-store";
export enum ClusterStatus { export enum ClusterStatus {
AccessGranted = 2, AccessGranted = 2,
@ -234,8 +235,18 @@ export class Cluster implements ClusterModel, ClusterState {
return this.preferences.clusterName || this.contextName; return this.preferences.clusterName || this.contextName;
} }
/**
* The detected kubernetes distribution
*/
@computed get distribution(): string { @computed get distribution(): string {
return this.metadata.distribution?.toString() || "unknown"; return this.metadata[ClusterMetadataKey.DISTRIBUTION]?.toString() || "unknown";
}
/**
* The detected kubernetes version
*/
@computed get version(): string {
return this.metadata[ClusterMetadataKey.VERSION]?.toString() || "unknown";
} }
/** /**
@ -250,13 +261,6 @@ export class Cluster implements ClusterModel, ClusterState {
return toJS({ prometheus, prometheusProvider }); return toJS({ prometheus, prometheusProvider });
} }
/**
* Kubernetes version
*/
get version(): string {
return String(this.metadata?.version ?? "");
}
constructor(model: ClusterModel) { constructor(model: ClusterModel) {
makeObservable(this); makeObservable(this);
this.id = model.id; this.id = model.id;
@ -745,4 +749,12 @@ export class Cluster implements ClusterModel, ClusterState {
isMetricHidden(resource: ClusterMetricsResourceType): boolean { isMetricHidden(resource: ClusterMetricsResourceType): boolean {
return Boolean(this.preferences.hiddenMetrics?.includes(resource)); return Boolean(this.preferences.hiddenMetrics?.includes(resource));
} }
get nodeShellImage(): string {
return this.preferences.nodeShellImage || initialNodeShellImage;
}
get imagePullSecret(): string {
return this.preferences.imagePullSecret || "";
}
} }

View File

@ -146,7 +146,7 @@ export class HelmRepoManager extends Singleton {
return stdout; return stdout;
} }
public static async addСustomRepo(repoAttributes : HelmRepo) { public static async addCustomRepo(repoAttributes : HelmRepo) {
logger.info(`[HELM]: adding repo "${repoAttributes.name}" from ${repoAttributes.url}`); logger.info(`[HELM]: adding repo "${repoAttributes.name}" from ${repoAttributes.url}`);
const helm = await helmCli.binaryPath(); const helm = await helmCli.binaryPath();

View File

@ -61,6 +61,10 @@ import { ExtensionsStore } from "../extensions/extensions-store";
import { FilesystemProvisionerStore } from "./extension-filesystem"; import { FilesystemProvisionerStore } from "./extension-filesystem";
import { SentryInit } from "../common/sentry"; import { SentryInit } from "../common/sentry";
// This has to be called before start using winton-based logger
// For example, before any logger.log
SentryInit();
const workingDir = path.join(app.getPath("appData"), appName); const workingDir = path.join(app.getPath("appData"), appName);
const cleanup = disposer(); const cleanup = disposer();
@ -133,7 +137,7 @@ app.on("ready", async () => {
/** /**
* The following sync MUST be done before HotbarStore creation, because that * The following sync MUST be done before HotbarStore creation, because that
* store has migrations that will remove items that previous migrations add * store has migrations that will remove items that previous migrations add
* if this is not presant * if this is not present
*/ */
syncGeneralEntities(); syncGeneralEntities();
@ -141,14 +145,12 @@ app.on("ready", async () => {
UserStore.createInstance().startMainReactions(); UserStore.createInstance().startMainReactions();
/** // ClusterStore depends on: UserStore
* There is no point setting up sentry before UserStore is initialized as
* `allowErrorReporting` will always be falsy.
*/
await SentryInit();
ClusterStore.createInstance().provideInitialFromMain(); ClusterStore.createInstance().provideInitialFromMain();
// HotbarStore depends on: ClusterStore
HotbarStore.createInstance(); HotbarStore.createInstance();
ExtensionsStore.createInstance(); ExtensionsStore.createInstance();
FilesystemProvisionerStore.createInstance(); FilesystemProvisionerStore.createInstance();
WeblinkStore.createInstance(); WeblinkStore.createInstance();

View File

@ -27,7 +27,7 @@ export class PrometheusHelm extends PrometheusLens {
readonly id: string = "helm"; readonly id: string = "helm";
readonly name: string = "Helm"; readonly name: string = "Helm";
readonly rateAccuracy: string = "5m"; readonly rateAccuracy: string = "5m";
readonly isConfigurable: boolean = false; readonly isConfigurable: boolean = true;
public async getPrometheusService(client: CoreV1Api): Promise<PrometheusService | undefined> { public async getPrometheusService(client: CoreV1Api): Promise<PrometheusService | undefined> {
return this.getFirstNamespacedServer(client, "app=prometheus,component=server,heritage=Helm"); return this.getFirstNamespacedServer(client, "app=prometheus,component=server,heritage=Helm");

View File

@ -28,7 +28,7 @@ export class PrometheusLens extends PrometheusProvider {
readonly id: string = "lens"; readonly id: string = "lens";
readonly name: string = "Lens"; readonly name: string = "Lens";
readonly rateAccuracy: string = "1m"; readonly rateAccuracy: string = "1m";
readonly isConfigurable: boolean = true; readonly isConfigurable: boolean = false;
public async getPrometheusService(client: CoreV1Api): Promise<PrometheusService | undefined> { public async getPrometheusService(client: CoreV1Api): Promise<PrometheusService | undefined> {
try { try {

View File

@ -27,7 +27,7 @@ export class PrometheusOperator extends PrometheusProvider {
readonly rateAccuracy: string = "1m"; readonly rateAccuracy: string = "1m";
readonly id: string = "operator"; readonly id: string = "operator";
readonly name: string = "Prometheus Operator"; readonly name: string = "Prometheus Operator";
readonly isConfigurable: boolean = false; readonly isConfigurable: boolean = true;
public async getPrometheusService(client: CoreV1Api): Promise<PrometheusService | undefined> { public async getPrometheusService(client: CoreV1Api): Promise<PrometheusService | undefined> {
return this.getFirstNamespacedServer(client, "operated-prometheus=true", "self-monitor=true"); return this.getFirstNamespacedServer(client, "operated-prometheus=true", "self-monitor=true");

View File

@ -28,7 +28,7 @@ export class PrometheusStacklight extends PrometheusProvider {
readonly id: string = "stacklight"; readonly id: string = "stacklight";
readonly name: string = "Stacklight"; readonly name: string = "Stacklight";
readonly rateAccuracy: string = "1m"; readonly rateAccuracy: string = "1m";
readonly isConfigurable: boolean = false; readonly isConfigurable: boolean = true;
public async getPrometheusService(client: CoreV1Api): Promise<PrometheusService | undefined> { public async getPrometheusService(client: CoreV1Api): Promise<PrometheusService | undefined> {
try { try {

View File

@ -61,7 +61,8 @@ export class LensProxy extends Singleton {
if (req.url.startsWith(`${apiPrefix}?`)) { if (req.url.startsWith(`${apiPrefix}?`)) {
handleWsUpgrade(req, socket, head); handleWsUpgrade(req, socket, head);
} else { } else {
this.handleProxyUpgrade(proxy, req, socket, head); this.handleProxyUpgrade(proxy, req, socket, head)
.catch(error => logger.error(`[LENS-PROXY]: failed to handle proxy upgrade: ${error}`));
} }
}); });
} }
@ -168,7 +169,10 @@ export class LensProxy extends Singleton {
if (!res.headersSent && req.url) { if (!res.headersSent && req.url) {
const url = new URL(req.url, "http://localhost"); const url = new URL(req.url, "http://localhost");
if (url.searchParams.has("watch")) res.flushHeaders(); if (url.searchParams.has("watch")) {
res.statusCode = proxyRes.statusCode;
res.flushHeaders();
}
} }
}); });
@ -191,7 +195,8 @@ export class LensProxy extends Singleton {
logger.debug(`Retrying proxy request to url: ${reqId}`); logger.debug(`Retrying proxy request to url: ${reqId}`);
setTimeout(() => { setTimeout(() => {
this.retryCounters.set(reqId, retryCount + 1); this.retryCounters.set(reqId, retryCount + 1);
this.handleRequest(proxy, req, res); this.handleRequest(proxy, req, res)
.catch(error => logger.error(`[LENS-PROXY]: failed to handle request on proxy error: ${error}`));
}, timeoutMs); }, timeoutMs);
} }
} }

View File

@ -31,8 +31,11 @@ export class LocalShellSession extends ShellSession {
return [helmCli.getBinaryDir()]; return [helmCli.getBinaryDir()];
} }
public async open() { protected get cwd(): string | undefined {
return this.cluster.preferences?.terminalCWD;
}
public async open() {
const env = await this.getCachedShellEnv(); const env = await this.getCachedShellEnv();
const shell = env.PTYSHELL; const shell = env.PTYSHELL;
const args = await this.getShellArgs(shell); const args = await this.getShellArgs(shell);

View File

@ -32,6 +32,10 @@ export class NodeShellSession extends ShellSession {
protected podId = `node-shell-${uuid()}`; protected podId = `node-shell-${uuid()}`;
protected kc: KubeConfig; protected kc: KubeConfig;
protected get cwd(): string | undefined {
return undefined;
}
constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string) { constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string) {
super(socket, cluster); super(socket, cluster);
} }
@ -77,13 +81,16 @@ export class NodeShellSession extends ShellSession {
}], }],
containers: [{ containers: [{
name: "shell", name: "shell",
image: "docker.io/alpine:3.13", image: this.cluster.nodeShellImage,
securityContext: { securityContext: {
privileged: true, privileged: true,
}, },
command: ["nsenter"], command: ["nsenter"],
args: ["-t", "1", "-m", "-u", "-i", "-n", "sleep", "14000"] args: ["-t", "1", "-m", "-u", "-i", "-n", "sleep", "14000"]
}], }],
imagePullSecrets: [{
name: this.cluster.imagePullSecret,
}]
} }
}); });
} }

View File

@ -19,6 +19,7 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import fse from "fs-extra";
import type { Cluster } from "../cluster"; import type { Cluster } from "../cluster";
import { Kubectl } from "../kubectl"; import { Kubectl } from "../kubectl";
import type * as WebSocket from "ws"; import type * as WebSocket from "ws";
@ -50,9 +51,7 @@ export abstract class ShellSession {
protected kubectlBinDirP: Promise<string>; protected kubectlBinDirP: Promise<string>;
protected kubeconfigPathP: Promise<string>; protected kubeconfigPathP: Promise<string>;
protected get cwd(): string | undefined { protected abstract get cwd(): string | undefined;
return this.cluster.preferences?.terminalCWD;
}
constructor(protected websocket: WebSocket, protected cluster: Cluster) { constructor(protected websocket: WebSocket, protected cluster: Cluster) {
this.kubectl = new Kubectl(cluster.version); this.kubectl = new Kubectl(cluster.version);
@ -60,10 +59,14 @@ export abstract class ShellSession {
this.kubectlBinDirP = this.kubectl.binDir(); this.kubectlBinDirP = this.kubectl.binDir();
} }
open(shell: string, args: string[], env: Record<string, any>): void { protected async open(shell: string, args: string[], env: Record<string, any>) {
const cwd = (this.cwd && await fse.pathExists(this.cwd))
? this.cwd
: env.HOME;
this.shellProcess = pty.spawn(shell, args, { this.shellProcess = pty.spawn(shell, args, {
cols: 80, cols: 80,
cwd: this.cwd || env.HOME, cwd,
env, env,
name: "xterm-256color", name: "xterm-256color",
rows: 30, rows: 30,

View File

@ -93,7 +93,7 @@ function createTrayMenu(windowManager: WindowManager): Menu {
click() { click() {
windowManager windowManager
.navigate(preferencesURL()) .navigate(preferencesURL())
.catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to nativate to Preferences`, { error })); .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to navigate to Preferences`, { error }));
}, },
} }
]; ];

View File

@ -21,6 +21,7 @@
import type { Readable } from "stream"; import type { Readable } from "stream";
import URLParse from "url-parse"; import URLParse from "url-parse";
import logger from "../logger";
interface GetPortArgs { interface GetPortArgs {
/** /**
@ -47,11 +48,15 @@ interface GetPortArgs {
* @returns A Promise for port number * @returns A Promise for port number
*/ */
export function getPortFrom(stream: Readable, args: GetPortArgs): Promise<number> { export function getPortFrom(stream: Readable, args: GetPortArgs): Promise<number> {
const logLines: string[] = [];
return new Promise<number>((resolve, reject) => { return new Promise<number>((resolve, reject) => {
const handler = (data: any) => { const handler = (data: any) => {
const logItem: string = data.toString(); const logItem: string = data.toString();
const match = logItem.match(args.lineRegex); const match = logItem.match(args.lineRegex);
logLines.push(logItem);
if (match) { if (match) {
// use unknown protocol so that there is no default port // use unknown protocol so that there is no default port
const addr = new URLParse(`s://${match.groups.address.trim()}`); const addr = new URLParse(`s://${match.groups.address.trim()}`);
@ -64,6 +69,7 @@ export function getPortFrom(stream: Readable, args: GetPortArgs): Promise<number
}; };
const timeoutID = setTimeout(() => { const timeoutID = setTimeout(() => {
stream.off("data", handler); stream.off("data", handler);
logger.warn(`[getPortFrom]: failed to retrieve port via ${args.lineRegex.toString()}: ${logLines}`);
reject(new Error("failed to retrieve port from stream")); reject(new Error("failed to retrieve port from stream"));
}, args.timeout ?? 5000); }, args.timeout ?? 5000);

View File

@ -45,7 +45,7 @@ export default {
workspaces.set(id, name); workspaces.set(id, name);
} }
const clusters: ClusterModel[] = store.get("clusters"); const clusters: ClusterModel[] = store.get("clusters") ?? [];
for (const cluster of clusters) { for (const cluster of clusters) {
if (cluster.workspace && workspaces.has(cluster.workspace)) { if (cluster.workspace && workspaces.has(cluster.workspace)) {

View File

@ -20,19 +20,21 @@
*/ */
import { app } from "electron"; import { app } from "electron";
import { existsSync, readJsonSync } from "fs-extra"; import { existsSync, readFileSync } from "fs";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import { ClusterStore, ClusterStoreModel } from "../../common/cluster-store"; import { ClusterStore, ClusterStoreModel } from "../../common/cluster-store";
import type { KubeconfigSyncEntry, UserPreferencesModel } from "../../common/user-store"; import type { KubeconfigSyncEntry, UserPreferencesModel } from "../../common/user-store";
import { MigrationDeclaration, migrationLog } from "../helpers"; import { MigrationDeclaration, migrationLog } from "../helpers";
import { isLogicalChildPath } from "../../common/utils";
export default { export default {
version: "5.0.3-beta.1", version: "5.0.3-beta.1",
run(store) { run(store) {
try { try {
const { syncKubeconfigEntries = [], ...preferences }: UserPreferencesModel = store.get("preferences") ?? {}; const { syncKubeconfigEntries = [], ...preferences }: UserPreferencesModel = store.get("preferences") ?? {};
const { clusters = [] }: ClusterStoreModel = readJsonSync(path.resolve(app.getPath("userData"), "lens-cluster-store.json")) ?? {}; const { clusters = [] }: ClusterStoreModel = JSON.parse(readFileSync(path.resolve(app.getPath("userData"), "lens-cluster-store.json"), "utf-8")) ?? {};
const extensionDataDir = path.resolve(app.getPath("userData"), "extension_data");
const syncPaths = new Set(syncKubeconfigEntries.map(s => s.filePath)); const syncPaths = new Set(syncKubeconfigEntries.map(s => s.filePath));
syncPaths.add(path.join(os.homedir(), ".kube")); syncPaths.add(path.join(os.homedir(), ".kube"));
@ -50,6 +52,11 @@ export default {
continue; continue;
} }
if (isLogicalChildPath(extensionDataDir, cluster.kubeConfigPath)) {
migrationLog(`Skipping ${cluster.id} because kubeConfigPath is placed under an extension_data folder`);
continue;
}
if (!existsSync(cluster.kubeConfigPath)) { if (!existsSync(cluster.kubeConfigPath)) {
migrationLog(`Skipping ${cluster.id} because kubeConfigPath no longer exists`); migrationLog(`Skipping ${cluster.id} because kubeConfigPath no longer exists`);
continue; continue;
@ -64,8 +71,6 @@ export default {
migrationLog("Final list of synced paths", updatedSyncEntries); migrationLog("Final list of synced paths", updatedSyncEntries);
store.set("preferences", { ...preferences, syncKubeconfigEntries: updatedSyncEntries }); store.set("preferences", { ...preferences, syncKubeconfigEntries: updatedSyncEntries });
} catch (error) { } catch (error) {
console.log(error);
if (error.code !== "ENOENT") { if (error.code !== "ENOENT") {
// ignore files being missing // ignore files being missing
throw error; throw error;

View File

@ -129,8 +129,18 @@ const tests: KubeApiParseTestData[] = [
}], }],
]; ];
const throwtests = [
undefined,
"",
"ajklsmh"
];
describe("parseApi unit tests", () => { describe("parseApi unit tests", () => {
it.each(tests)("testing %s", (url, expected) => { it.each(tests)("testing %j", (url, expected) => {
expect(parseKubeApi(url)).toStrictEqual(expected); expect(parseKubeApi(url)).toStrictEqual(expected);
}); });
it.each(throwtests)("testing %j should throw", (url) => {
expect(() => parseKubeApi(url)).toThrowError("invalid apiPath");
});
}); });

View File

@ -19,11 +19,20 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
.AppInit { import { Pod } from "../endpoints";
height: 100%;
.waiting-services { describe("Pod tests", () => {
font-size: small; it("getAllContainers() should never throw", () => {
opacity: .75; const pod = new Pod({
} apiVersion: "foo",
} kind: "Pod",
metadata: {
name: "foobar",
resourceVersion: "foobar",
uid: "foobar",
},
});
expect(pod.getAllContainers()).toStrictEqual([]);
});
});

View File

@ -58,8 +58,14 @@ export class ApiManager {
} }
} }
protected resolveApi(api: string | KubeApi): KubeApi { protected resolveApi(api?: string | KubeApi): KubeApi | undefined {
if (typeof api === "string") return this.getApi(api); if (!api) {
return undefined;
}
if (typeof api === "string") {
return this.getApi(api);
}
return api; return api;
} }

View File

@ -156,9 +156,13 @@ export class Node extends KubeObject {
} }
getRoleLabels() { getRoleLabels() {
const roleLabels = Object.keys(this.metadata.labels).filter(key => if (!this.metadata?.labels || typeof this.metadata.labels !== "object") {
key.includes("node-role.kubernetes.io") return "";
).map(key => key.match(/([^/]+$)/)[0]); // all after last slash }
const roleLabels = Object.keys(this.metadata.labels)
.filter(key => key.includes("node-role.kubernetes.io"))
.map(key => key.match(/([^/]+$)/)[0]); // all after last slash
if (this.metadata.labels["kubernetes.io/role"] != undefined) { if (this.metadata.labels["kubernetes.io/role"] != undefined) {
roleLabels.push(this.metadata.labels["kubernetes.io/role"]); roleLabels.push(this.metadata.labels["kubernetes.io/role"]);

View File

@ -213,7 +213,7 @@ export class Pod extends WorkloadKubeObject {
autoBind(this); autoBind(this);
} }
declare spec: { declare spec?: {
volumes?: { volumes?: {
name: string; name: string;
persistentVolumeClaim: { persistentVolumeClaim: {
@ -291,11 +291,11 @@ export class Pod extends WorkloadKubeObject {
}; };
getInitContainers() { getInitContainers() {
return this.spec.initContainers || []; return this.spec?.initContainers || [];
} }
getContainers() { getContainers() {
return this.spec.containers || []; return this.spec?.containers || [];
} }
getAllContainers() { getAllContainers() {

View File

@ -24,6 +24,9 @@
import type { KubeObject } from "./kube-object"; import type { KubeObject } from "./kube-object";
import { splitArray } from "../../common/utils"; import { splitArray } from "../../common/utils";
import { apiManager } from "./api-manager"; import { apiManager } from "./api-manager";
import { isDebugging } from "../../common/vars";
import logger from "../../main/logger";
import { inspect } from "util";
export interface IKubeObjectRef { export interface IKubeObjectRef {
kind: string; kind: string;
@ -47,8 +50,26 @@ export interface IKubeApiParsed extends IKubeApiLinkRef {
} }
export function parseKubeApi(path: string): IKubeApiParsed { export function parseKubeApi(path: string): IKubeApiParsed {
path = new URL(path, location.origin).pathname; if (!isDebugging) {
const [, prefix, ...parts] = path.split("/"); return _parseKubeApi(path);
}
try {
const res = _parseKubeApi(path);
logger.debug(`parseKubeApi(${inspect(path, false, null, false)}) -> ${inspect(res, false, null, false)}`);
return res;
} catch (error) {
logger.debug(`parseKubeApi(${inspect(path, false, null, false)}) threw: ${error}`);
throw error;
}
}
function _parseKubeApi(path: string): IKubeApiParsed {
const apiPath = new URL(path, location.origin).pathname;
const [, prefix, ...parts] = apiPath.split("/");
const apiPrefix = `/${prefix}`; const apiPrefix = `/${prefix}`;
const [left, right, namespaced] = splitArray(parts, "namespaces"); const [left, right, namespaced] = splitArray(parts, "namespaces");
let apiGroup, apiVersion, namespace, resource, name; let apiGroup, apiVersion, namespace, resource, name;
@ -70,12 +91,14 @@ export function parseKubeApi(path: string): IKubeApiParsed {
apiGroup = left.join("/"); apiGroup = left.join("/");
} else { } else {
switch (left.length) { switch (left.length) {
case 0:
throw new Error(`invalid apiPath: ${apiPath}`);
case 4: case 4:
[apiGroup, apiVersion, resource, name] = left; [apiGroup, apiVersion, resource, name] = left;
break; break;
case 2: case 2:
resource = left.pop(); resource = left.pop();
// fallthrough // fallthrough
case 1: case 1:
apiVersion = left.pop(); apiVersion = left.pop();
apiGroup = ""; apiGroup = "";
@ -91,7 +114,7 @@ export function parseKubeApi(path: string): IKubeApiParsed {
* There is no well defined selection from an array of items that were * There is no well defined selection from an array of items that were
* separated by '/' * separated by '/'
* *
* Solution is to create a huristic. Namely: * Solution is to create a heuristic. Namely:
* 1. if '.' in left[0] then apiGroup <- left[0] * 1. if '.' in left[0] then apiGroup <- left[0]
* 2. if left[1] matches /^v[0-9]/ then apiGroup, apiVersion <- left[0], left[1] * 2. if left[1] matches /^v[0-9]/ then apiGroup, apiVersion <- left[0], left[1]
* 3. otherwise assume apiVersion <- left[0] * 3. otherwise assume apiVersion <- left[0]
@ -113,7 +136,7 @@ export function parseKubeApi(path: string): IKubeApiParsed {
const apiBase = [apiPrefix, apiGroup, apiVersion, resource].filter(v => v).join("/"); const apiBase = [apiPrefix, apiGroup, apiVersion, resource].filter(v => v).join("/");
if (!apiBase) { if (!apiBase) {
throw new Error(`invalid apiPath: ${path}`); throw new Error(`invalid apiPath: ${apiPath}`);
} }
return { return {

View File

@ -254,12 +254,10 @@ export class KubeObject<Metadata extends IKubeObjectMetadata = IKubeObjectMetada
} }
getOwnerRefs() { getOwnerRefs() {
const refs = this.metadata.ownerReferences || []; const refs = this.metadata?.ownerReferences || [];
const namespace = this.getNs();
return refs.map(ownerRef => ({ return refs.map(ownerRef => ({ ...ownerRef, namespace }));
...ownerRef,
namespace: this.getNs(),
}));
} }
getSearchFields() { getSearchFields() {

View File

@ -26,6 +26,7 @@ export interface IToleration {
key?: string; key?: string;
operator?: string; operator?: string;
effect?: string; effect?: string;
value?: string;
tolerationSeconds?: number; tolerationSeconds?: number;
} }

View File

@ -48,11 +48,12 @@ import { ExtensionsStore } from "../extensions/extensions-store";
import { FilesystemProvisionerStore } from "../main/extension-filesystem"; import { FilesystemProvisionerStore } from "../main/extension-filesystem";
import { ThemeStore } from "./theme.store"; import { ThemeStore } from "./theme.store";
import { SentryInit } from "../common/sentry"; import { SentryInit } from "../common/sentry";
import { TerminalStore } from "./components/dock/terminal.store";
configurePackages(); configurePackages();
/** /**
* If this is a development buid, wait a second to attach * If this is a development build, wait a second to attach
* Chrome Debugger to renderer process * Chrome Debugger to renderer process
* https://stackoverflow.com/questions/52844870/debugging-electron-renderer-process-with-vscode * https://stackoverflow.com/questions/52844870/debugging-electron-renderer-process-with-vscode
*/ */
@ -79,6 +80,7 @@ export async function bootstrap(App: AppComponent) {
initializers.intiKubeObjectDetailRegistry(); initializers.intiKubeObjectDetailRegistry();
initializers.initWelcomeMenuRegistry(); initializers.initWelcomeMenuRegistry();
initializers.initWorkloadsOverviewDetailRegistry(); initializers.initWorkloadsOverviewDetailRegistry();
initializers.initCatalogEntityDetailRegistry();
initializers.initCatalog(); initializers.initCatalog();
initializers.initIpcRendererListeners(); initializers.initIpcRendererListeners();
@ -87,24 +89,30 @@ export async function bootstrap(App: AppComponent) {
UserStore.createInstance(); UserStore.createInstance();
/** SentryInit();
* There is no point setting up sentry before UserStore is initialized as
* `allowErrorReporting` will always be falsy.
*/
await SentryInit();
await ClusterStore.createInstance().loadInitialOnRenderer(); // ClusterStore depends on: UserStore
const cs = ClusterStore.createInstance();
await cs.loadInitialOnRenderer();
// HotbarStore depends on: ClusterStore
HotbarStore.createInstance(); HotbarStore.createInstance();
ExtensionsStore.createInstance(); ExtensionsStore.createInstance();
FilesystemProvisionerStore.createInstance(); FilesystemProvisionerStore.createInstance();
// ThemeStore depends on: UserStore
ThemeStore.createInstance(); ThemeStore.createInstance();
// TerminalStore depends on: ThemeStore
TerminalStore.createInstance();
WeblinkStore.createInstance(); WeblinkStore.createInstance();
ExtensionInstallationStateStore.bindIpcListeners(); ExtensionInstallationStateStore.bindIpcListeners();
HelmRepoManager.createInstance(); // initialize the manager HelmRepoManager.createInstance(); // initialize the manager
// Register additional store listeners // Register additional store listeners
ClusterStore.getInstance().registerIpcListener(); cs.registerIpcListener();
// init app's dependencies if any // init app's dependencies if any
if (App.init) { if (App.init) {

View File

@ -27,13 +27,7 @@
} }
.status { .status {
.Badge { @include release-status-bgs;
@include release-status-bgs;
&:first-child {
margin-left: 0;
}
}
} }
.chart { .chart {

View File

@ -272,7 +272,7 @@ export class ReleaseDetails extends Component<Props> {
<DrawerItem name="Status" className="status" labelsOnly> <DrawerItem name="Status" className="status" labelsOnly>
<Badge <Badge
label={release.getStatus()} label={release.getStatus()}
className={cssNames("status", kebabCase(release.getStatus()))} className={kebabCase(release.getStatus())}
/> />
</DrawerItem> </DrawerItem>
{this.renderValues()} {this.renderValues()}

View File

@ -58,7 +58,7 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
}); });
} }
watchSelecteNamespaces(): (() => void) { watchSelectedNamespaces(): (() => void) {
return reaction(() => namespaceStore.context.contextNamespaces, namespaces => { return reaction(() => namespaceStore.context.contextNamespaces, namespaces => {
this.loadAll(namespaces); this.loadAll(namespaces);
}); });
@ -91,7 +91,7 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
this.failedLoading = false; this.failedLoading = false;
} catch (error) { } catch (error) {
this.failedLoading = true; this.failedLoading = true;
console.error("Loading Helm Chart releases has failed", error); console.warn("Loading Helm Chart releases has failed", error);
if (error.error) { if (error.error) {
Notifications.error(error.error); Notifications.error(error.error);

View File

@ -56,7 +56,7 @@ export class HelmReleases extends Component<Props> {
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
releaseStore.watchAssociatedSecrets(), releaseStore.watchAssociatedSecrets(),
releaseStore.watchSelecteNamespaces(), releaseStore.watchSelectedNamespaces(),
]); ]);
} }

View File

@ -88,6 +88,7 @@ export class CatalogEntityItem<T extends CatalogEntity> implements ItemObject {
onClick?.(event); onClick?.(event);
event.stopPropagation(); event.stopPropagation();
}} }}
expandable={false}
/> />
)); ));
} }

View File

@ -22,7 +22,7 @@
@import "autoscaler.mixins"; @import "autoscaler.mixins";
.HpaDetails { .HpaDetails {
.Badge { .status {
@include hpa-status-bgc; @include hpa-status-bgc;
} }

View File

@ -125,7 +125,7 @@ export class HpaDetails extends React.Component<Props> {
{hpa.getReplicas()} {hpa.getReplicas()}
</DrawerItem> </DrawerItem>
<DrawerItem name="Status" labelsOnly> <DrawerItem name="Status" className="status" labelsOnly>
{hpa.getConditions().map(({ type, tooltip, isReady }) => { {hpa.getConditions().map(({ type, tooltip, isReady }) => {
if (!isReady) return null; if (!isReady) return null;

View File

@ -38,10 +38,7 @@
&.status { &.status {
flex: 1.5; flex: 1.5;
@include table-cell-labels-offsets; @include table-cell-labels-offsets;
@include hpa-status-bgc;
.Badge {
@include hpa-status-bgc;
}
} }
&.age { &.age {

View File

@ -104,6 +104,7 @@ export class HorizontalPodAutoscalers extends React.Component<Props> {
label={type} label={type}
tooltip={tooltip} tooltip={tooltip}
className={cssNames(type.toLowerCase())} className={cssNames(type.toLowerCase())}
expandable={false}
/> />
); );
}) })

View File

@ -218,7 +218,7 @@ export class AddQuotaDialog extends React.Component<Props> {
<div className="quota-entries"> <div className="quota-entries">
{this.quotaEntries.map(([quota, value]) => { {this.quotaEntries.map(([quota, value]) => {
return ( return (
<div key={quota} className="quota flex gaps inline align-center"> <div key={quota} className="quota gaps inline align-center">
<div className="name">{quota}</div> <div className="name">{quota}</div>
<div className="value">{value}</div> <div className="value">{value}</div>
<Icon material="clear" onClick={() => this.quotas[quota] = ""} /> <Icon material="clear" onClick={() => this.quotas[quota] = ""} />

View File

@ -79,7 +79,7 @@ export class Secrets extends React.Component<Props> {
secret.getName(), secret.getName(),
<KubeObjectStatusIcon key="icon" object={secret} />, <KubeObjectStatusIcon key="icon" object={secret} />,
secret.getNs(), secret.getNs(),
secret.getLabels().map(label => <Badge key={label} label={label}/>), secret.getLabels().map(label => <Badge key={label} label={label} expandable={false}/>),
secret.getKeys().join(", "), secret.getKeys().join(", "),
secret.type, secret.type,
secret.getAge(), secret.getAge(),

View File

@ -23,9 +23,7 @@
.CRDDetails { .CRDDetails {
.conditions { .conditions {
.Badge { @include crd-condition-bgc;
@include crd-condition-bgc;
}
} }
.Table { .Table {
@ -41,21 +39,8 @@
flex: 0.5; flex: 0.5;
} }
.description {
flex: 1.5;
.Badge {
background: transparent;
}
}
.json-path { .json-path {
flex: 3; flex: 3;
.Badge {
white-space: pre-line;
text-overflow: initial;
}
} }
} }
} }

View File

@ -25,7 +25,6 @@ import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { CustomResourceDefinition } from "../../api/endpoints/crd.api"; import type { CustomResourceDefinition } from "../../api/endpoints/crd.api";
import { cssNames } from "../../utils";
import { AceEditor } from "../ace-editor"; import { AceEditor } from "../ace-editor";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { DrawerItem, DrawerTitle } from "../drawer"; import { DrawerItem, DrawerTitle } from "../drawer";
@ -86,7 +85,8 @@ export class CRDDetails extends React.Component<Props> {
<Badge <Badge
key={type} key={type}
label={type} label={type}
className={cssNames({ disabled: status === "False" }, type)} disabled={status === "False"}
className={type}
tooltip={( tooltip={(
<> <>
<p>{message}</p> <p>{message}</p>

View File

@ -139,7 +139,7 @@ export class CrdList extends React.Component {
]} ]}
renderTableContents={(crd: CustomResourceDefinition) => [ renderTableContents={(crd: CustomResourceDefinition) => [
<Link key="link" to={crd.getResourceUrl()} onClick={stopPropagation}> <Link key="link" to={crd.getResourceUrl()} onClick={stopPropagation}>
{crd.getResourceTitle()} {crd.getResourceKind()}
</Link>, </Link>,
crd.getGroup(), crd.getGroup(),
crd.getVersion(), crd.getVersion(),

View File

@ -21,13 +21,9 @@
.CrdResourceDetails { .CrdResourceDetails {
.status { .status {
.value { .ready {
.Badge { color: white;
&.ready { background-color: $colorOk;
color: white;
background-color: $colorOk;
}
}
} }
} }
} }

View File

@ -90,7 +90,8 @@ export class CrdResourceDetails extends React.Component<Props> {
.map(({ kind, message, status }, index) => ( .map(({ kind, message, status }, index) => (
<Badge <Badge
key={kind + index} label={kind} key={kind + index} label={kind}
className={cssNames({ disabled: status === "False" }, kind.toLowerCase())} disabled={status === "False"}
className={kind.toLowerCase()}
tooltip={message} tooltip={message}
/> />
)); ));

View File

@ -30,7 +30,7 @@ $crd-condition-colors: (
@mixin crd-condition-bgc { @mixin crd-condition-bgc {
@each $status, $color in $crd-condition-colors { @each $status, $color in $crd-condition-colors {
&.#{$status} { .#{$status} {
background: $color; background: $color;
color: white; color: white;
} }

View File

@ -225,7 +225,7 @@ export class ExtensionInstallationStateStore {
} }
/** /**
* If there is at least one extension currently ininstalling * If there is at least one extension currently uninstalling
*/ */
static get anyUninstalling(): boolean { static get anyUninstalling(): boolean {
return ExtensionInstallationStateStore.uninstalling > 0; return ExtensionInstallationStateStore.uninstalling > 0;

View File

@ -49,7 +49,7 @@ import { docsUrl } from "../../../common/vars";
function getMessageFromError(error: any): string { function getMessageFromError(error: any): string {
if (!error || typeof error !== "object") { if (!error || typeof error !== "object") {
return "an error has occured"; return "an error has occurred";
} }
if (error.message) { if (error.message) {
@ -63,7 +63,7 @@ function getMessageFromError(error: any): string {
const rawMessage = String(error); const rawMessage = String(error);
if (rawMessage === String({})) { if (rawMessage === String({})) {
return "an error has occured"; return "an error has occurred";
} }
return rawMessage; return rawMessage;

View File

@ -30,6 +30,8 @@ import { NamespaceSelect } from "./namespace-select";
import { namespaceStore } from "./namespace.store"; import { namespaceStore } from "./namespace.store";
import type { SelectOption, SelectProps } from "../select"; import type { SelectOption, SelectProps } from "../select";
import { isLinux, isMac, isWindows } from "../../../common/vars";
import { observable } from "mobx";
const Placeholder = observer((props: PlaceholderProps<any, boolean>) => { const Placeholder = observer((props: PlaceholderProps<any, boolean>) => {
const getPlaceholder = (): React.ReactNode => { const getPlaceholder = (): React.ReactNode => {
@ -55,13 +57,16 @@ const Placeholder = observer((props: PlaceholderProps<any, boolean>) => {
@observer @observer
export class NamespaceSelectFilter extends React.Component<SelectProps> { export class NamespaceSelectFilter extends React.Component<SelectProps> {
static isMultiSelection = observable.box(false);
static isMenuOpen = observable.box(false);
formatOptionLabel({ value: namespace, label }: SelectOption) { formatOptionLabel({ value: namespace, label }: SelectOption) {
if (namespace) { if (namespace) {
const isSelected = namespaceStore.hasContext(namespace); const isSelected = namespaceStore.hasContext(namespace);
return ( return (
<div className="flex gaps align-center"> <div className="flex gaps align-center">
<Icon small material="layers" /> <Icon small material="layers"/>
<span>{namespace}</span> <span>{namespace}</span>
{isSelected && <Icon small material="check" className="box right"/>} {isSelected && <Icon small material="check" className="box right"/>}
</div> </div>
@ -72,26 +77,55 @@ export class NamespaceSelectFilter extends React.Component<SelectProps> {
} }
onChange([{ value: namespace }]: SelectOption[]) { onChange([{ value: namespace }]: SelectOption[]) {
if (namespace) { if (NamespaceSelectFilter.isMultiSelection.get() && namespace) {
namespaceStore.toggleContext(namespace); namespaceStore.toggleContext(namespace);
} else if (!NamespaceSelectFilter.isMultiSelection.get() && namespace) {
namespaceStore.toggleSingle(namespace);
} else { } else {
namespaceStore.toggleAll(false); // "All namespaces" clicked namespaceStore.toggleAll(true); // "All namespaces" clicked
} }
} }
onKeyDown = (e: React.KeyboardEvent<any>) => {
if (isMac && e.metaKey || (isWindows || isLinux) && e.ctrlKey) {
NamespaceSelectFilter.isMultiSelection.set(true);
}
};
onKeyUp = (e: React.KeyboardEvent<any>) => {
if (isMac && e.key === "Meta" || (isWindows || isLinux) && e.key === "Control") {
NamespaceSelectFilter.isMultiSelection.set(false);
}
};
onClick = () => {
if (!NamespaceSelectFilter.isMultiSelection.get()) {
NamespaceSelectFilter.isMenuOpen.set(!NamespaceSelectFilter.isMenuOpen.get());
}
};
reset = () => {
NamespaceSelectFilter.isMultiSelection.set(false);
NamespaceSelectFilter.isMenuOpen.set(false);
};
render() { render() {
return ( return (
<NamespaceSelect <div onKeyUp={this.onKeyUp} onKeyDown={this.onKeyDown} onClick={this.onClick}>
isMulti={true} <NamespaceSelect
components={{ Placeholder }} isMulti={true}
showAllNamespacesOption={true} menuIsOpen={NamespaceSelectFilter.isMenuOpen.get()}
closeMenuOnSelect={false} components={{ Placeholder }}
controlShouldRenderValue={false} showAllNamespacesOption={true}
placeholder={""} closeMenuOnSelect={false}
onChange={this.onChange} controlShouldRenderValue={false}
formatOptionLabel={this.formatOptionLabel} placeholder={""}
className="NamespaceSelectFilter" onChange={this.onChange}
/> onBlur={this.reset}
formatOptionLabel={this.formatOptionLabel}
className="NamespaceSelectFilter"
/>
</div>
); );
} }
} }

View File

@ -161,6 +161,12 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
} }
} }
@action
toggleSingle(namespace: string){
this.clearSelected();
this.selectNamespaces(namespace);
}
@action @action
toggleAll(showAll?: boolean) { toggleAll(showAll?: boolean) {
if (typeof showAll === "boolean") { if (typeof showAll === "boolean") {

View File

@ -54,7 +54,7 @@ export class NodeDetailsResources extends React.Component<Props> {
selectable selectable
scrollable={false} scrollable={false}
> >
<TableHead> <TableHead sticky={false}>
<TableCell className="cpu">CPU</TableCell> <TableCell className="cpu">CPU</TableCell>
<TableCell className="memory">Memory</TableCell> <TableCell className="memory">Memory</TableCell>
<TableCell className="ephemeral-storage">Ephemeral Storage</TableCell> <TableCell className="ephemeral-storage">Ephemeral Storage</TableCell>

View File

@ -24,9 +24,6 @@
--status-default-bg: #{$colorError}; --status-default-bg: #{$colorError};
.conditions { .conditions {
.Badge { @include node-status-bgs;
cursor: default;
@include node-status-bgs;
}
} }
} }

View File

@ -33,7 +33,7 @@ $node-status-color-list: (
@mixin node-status-bgs { @mixin node-status-bgs {
@each $status, $color in $node-status-color-list { @each $status, $color in $node-status-color-list {
&.#{$status} { .#{$status} {
background: $color; background: $color;
color: white; color: white;
} }

View File

@ -107,7 +107,7 @@ export class AddHelmRepoDialog extends React.Component<Props> {
async addCustomRepo() { async addCustomRepo() {
try { try {
await HelmRepoManager.addСustomRepo(this.helmRepo); await HelmRepoManager.addCustomRepo(this.helmRepo);
Notifications.ok(<>Helm repository <b>{this.helmRepo.name}</b> has added</>); Notifications.ok(<>Helm repository <b>{this.helmRepo.name}</b> has added</>);
this.props.onAddRepo(); this.props.onAddRepo();
this.close(); this.close();
@ -145,7 +145,7 @@ export class AddHelmRepoDialog extends React.Component<Props> {
/> />
{this.renderFileInput(`Key file`, FileType.KeyFile, AddHelmRepoDialog.keyExtensions)} {this.renderFileInput(`Key file`, FileType.KeyFile, AddHelmRepoDialog.keyExtensions)}
{this.renderFileInput(`Ca file`, FileType.CaFile, AddHelmRepoDialog.certExtensions)} {this.renderFileInput(`Ca file`, FileType.CaFile, AddHelmRepoDialog.certExtensions)}
{this.renderFileInput(`Cerificate file`, FileType.CertFile, AddHelmRepoDialog.certExtensions)} {this.renderFileInput(`Certificate file`, FileType.CertFile, AddHelmRepoDialog.certExtensions)}
<SubTitle title="Chart Repository Credentials" /> <SubTitle title="Chart Repository Credentials" />
<Input <Input
placeholder="Username" placeholder="Username"

View File

@ -41,7 +41,7 @@ import { FormSwitch, Switcher } from "../switch";
import { KubeconfigSyncs } from "./kubeconfig-syncs"; import { KubeconfigSyncs } from "./kubeconfig-syncs";
import { SettingLayout } from "../layout/setting-layout"; import { SettingLayout } from "../layout/setting-layout";
import { Checkbox } from "../checkbox"; import { Checkbox } from "../checkbox";
import { sentryIsInitialized } from "../../../common/sentry"; import { sentryDsn } from "../../../common/vars";
enum Pages { enum Pages {
Application = "application", Application = "application",
@ -81,7 +81,7 @@ export class Preferences extends React.Component {
const fragment = hash.slice(1); // hash is /^(#\w.)?$/ const fragment = hash.slice(1); // hash is /^(#\w.)?$/
if (fragment) { if (fragment) {
// ignore empty framents // ignore empty fragments
document.getElementById(fragment)?.scrollIntoView(); document.getElementById(fragment)?.scrollIntoView();
} }
}, { }, {
@ -259,7 +259,7 @@ export class Preferences extends React.Component {
<section id="telemetry"> <section id="telemetry">
<h2 data-testid="telemetry-header">Telemetry</h2> <h2 data-testid="telemetry-header">Telemetry</h2>
{telemetryExtensions.map(this.renderExtension)} {telemetryExtensions.map(this.renderExtension)}
{sentryIsInitialized ? ( {sentryDsn ? (
<React.Fragment key='sentry'> <React.Fragment key='sentry'>
<section id='sentry' className="small"> <section id='sentry' className="small">
<SubTitle title='Automatic Error Reporting' /> <SubTitle title='Automatic Error Reporting' />

View File

@ -33,8 +33,6 @@
} }
.conditions { .conditions {
.Badge { @include job-condition-bgs;
@include job-condition-bgs;
}
} }
} }

View File

@ -21,21 +21,19 @@
.DeploymentDetails { .DeploymentDetails {
.conditions { .conditions {
.Badge { .available {
&.available { color: white;
color: white; background-color: $deployment-available;
background-color: $deployment-available; }
}
&.progressing { .progressing {
color: white; color: white;
background-color: $deployment-progressing; background-color: $deployment-progressing;
} }
&.replica-failure { .replica-failure {
color: white; color: white;
background-color: $deployment-replicafailure; background-color: $deployment-replicafailure;
}
} }
} }
} }

View File

@ -27,7 +27,6 @@ import { disposeOnUnmount, observer } from "mobx-react";
import { DrawerItem } from "../drawer"; import { DrawerItem } from "../drawer";
import { Badge } from "../badge"; import { Badge } from "../badge";
import type { Deployment } from "../../api/endpoints"; import type { Deployment } from "../../api/endpoints";
import { cssNames } from "../../utils";
import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations"; import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations";
import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities"; import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities";
import { podsStore } from "../+workloads-pods/pods.store"; import { podsStore } from "../+workloads-pods/pods.store";
@ -118,7 +117,8 @@ export class DeploymentDetails extends React.Component<Props> {
<Badge <Badge
key={type} key={type}
label={type} label={type}
className={cssNames({ disabled: status === "False" }, kebabCase(type))} disabled={status === "False"}
className={kebabCase(type)}
tooltip={( tooltip={(
<> <>
<p>{message}</p> <p>{message}</p>

View File

@ -22,8 +22,6 @@
.JobDetails { .JobDetails {
.conditions { .conditions {
.Badge { @include job-condition-bgs;
@include job-condition-bgs;
}
} }
} }

View File

@ -27,6 +27,7 @@ import { isMetricsEmpty, normalizeMetrics } from "../../api/endpoints/metrics.ap
import { NoMetrics } from "../resource-metrics/no-metrics"; import { NoMetrics } from "../resource-metrics/no-metrics";
import { IResourceMetricsValue, ResourceMetricsContext } from "../resource-metrics"; import { IResourceMetricsValue, ResourceMetricsContext } from "../resource-metrics";
import { ThemeStore } from "../../theme.store"; import { ThemeStore } from "../../theme.store";
import { mapValues } from "lodash";
type IContext = IResourceMetricsValue<any, { metrics: IPodMetrics }>; type IContext = IResourceMetricsValue<any, { metrics: IPodMetrics }>;
@ -37,10 +38,7 @@ export const ContainerCharts = observer(() => {
if (!metrics) return null; if (!metrics) return null;
if (isMetricsEmpty(metrics)) return <NoMetrics/>; if (isMetricsEmpty(metrics)) return <NoMetrics/>;
const values = Object.values(metrics) const {
.map(normalizeMetrics)
.map(({ data }) => data.result[0].values);
const [
cpuUsage, cpuUsage,
cpuRequests, cpuRequests,
cpuLimits, cpuLimits,
@ -48,7 +46,7 @@ export const ContainerCharts = observer(() => {
memoryRequests, memoryRequests,
memoryLimits, memoryLimits,
fsUsage fsUsage
] = values; } = mapValues(metrics, metric => normalizeMetrics(metric).data.result[0].values);
const datasets = [ const datasets = [
// CPU // CPU

View File

@ -19,15 +19,16 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import React, { useContext } from "react"; import { mapValues } from "lodash";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { IPodMetrics } from "../../api/endpoints"; import React, { useContext } from "react";
import { BarChart, cpuOptions, memoryOptions } from "../chart";
import { isMetricsEmpty, normalizeMetrics } from "../../api/endpoints/metrics.api"; import { isMetricsEmpty, normalizeMetrics } from "../../api/endpoints/metrics.api";
import { NoMetrics } from "../resource-metrics/no-metrics"; import { BarChart, cpuOptions, memoryOptions } from "../chart";
import { IResourceMetricsValue, ResourceMetricsContext } from "../resource-metrics"; import { IResourceMetricsValue, ResourceMetricsContext } from "../resource-metrics";
import { NoMetrics } from "../resource-metrics/no-metrics";
import type { WorkloadKubeObject } from "../../api/workload-kube-object"; import type { WorkloadKubeObject } from "../../api/workload-kube-object";
import { ThemeStore } from "../../theme.store"; import type { IPodMetrics } from "../../api/endpoints";
export const podMetricTabs = [ export const podMetricTabs = [
"CPU", "CPU",
@ -40,27 +41,19 @@ type IContext = IResourceMetricsValue<WorkloadKubeObject, { metrics: IPodMetrics
export const PodCharts = observer(() => { export const PodCharts = observer(() => {
const { params: { metrics }, tabId, object } = useContext<IContext>(ResourceMetricsContext); const { params: { metrics }, tabId, object } = useContext<IContext>(ResourceMetricsContext);
const { chartCapacityColor } = ThemeStore.getInstance().activeTheme.colors;
const id = object.getId(); const id = object.getId();
if (!metrics) return null; if (!metrics) return null;
if (isMetricsEmpty(metrics)) return <NoMetrics/>; if (isMetricsEmpty(metrics)) return <NoMetrics/>;
const options = tabId == 0 ? cpuOptions : memoryOptions; const options = tabId == 0 ? cpuOptions : memoryOptions;
const values = Object.values(metrics) const {
.map(normalizeMetrics)
.map(({ data }) => data.result[0].values);
const [
cpuUsage, cpuUsage,
cpuRequests,
cpuLimits,
memoryUsage, memoryUsage,
memoryRequests,
memoryLimits,
fsUsage, fsUsage,
networkReceive, networkReceive,
networkTransmit networkTransmit
] = values; } = mapValues(metrics, metric => normalizeMetrics(metric).data.result[0].values);
const datasets = [ const datasets = [
// CPU // CPU
@ -71,20 +64,6 @@ export const PodCharts = observer(() => {
tooltip: `Container CPU cores usage`, tooltip: `Container CPU cores usage`,
borderColor: "#3D90CE", borderColor: "#3D90CE",
data: cpuUsage.map(([x, y]) => ({ x, y })) data: cpuUsage.map(([x, y]) => ({ x, y }))
},
{
id: `${id}-cpuRequests`,
label: `Requests`,
tooltip: `Container CPU requests`,
borderColor: "#30b24d",
data: cpuRequests.map(([x, y]) => ({ x, y }))
},
{
id: `${id}-cpuLimits`,
label: `Limits`,
tooltip: `CPU limits`,
borderColor: chartCapacityColor,
data: cpuLimits.map(([x, y]) => ({ x, y }))
} }
], ],
// Memory // Memory
@ -96,20 +75,6 @@ export const PodCharts = observer(() => {
borderColor: "#c93dce", borderColor: "#c93dce",
data: memoryUsage.map(([x, y]) => ({ x, y })) data: memoryUsage.map(([x, y]) => ({ x, y }))
}, },
{
id: `${id}-memoryRequests`,
label: `Requests`,
tooltip: `Container memory requests`,
borderColor: "#30b24d",
data: memoryRequests.map(([x, y]) => ({ x, y }))
},
{
id: `${id}-memoryLimits`,
label: `Limits`,
tooltip: `Container memory limits`,
borderColor: chartCapacityColor,
data: memoryLimits.map(([x, y]) => ({ x, y }))
}
], ],
// Network // Network
[ [

View File

@ -58,8 +58,4 @@
@include pod-status-colors; @include pod-status-colors;
} }
.Badge {
white-space: normal;
}
} }

View File

@ -28,6 +28,10 @@
&.virtual { &.virtual {
height: 500px; height: 500px;
} }
.TableHead.sticky {
top: calc(var(--spacing) * -1);
}
} }
.TableCell { .TableCell {

View File

@ -142,7 +142,7 @@ export class PodDetails extends React.Component<Props> {
<Badge <Badge
key={type} key={type}
label={type} label={type}
className={cssNames({ disabled: status === "False" })} disabled={status === "False"}
tooltip={`Last transition time: ${lastTransitionTime}`} tooltip={`Last transition time: ${lastTransitionTime}`}
/> />
); );

View File

@ -35,6 +35,7 @@ enum sortBy {
Operator = "operator", Operator = "operator",
Effect = "effect", Effect = "effect",
Seconds = "seconds", Seconds = "seconds",
Value = "value",
} }
const sortingCallbacks = { const sortingCallbacks = {
@ -45,7 +46,7 @@ const sortingCallbacks = {
}; };
const getTableRow = (toleration: IToleration) => { const getTableRow = (toleration: IToleration) => {
const { key, operator, effect, tolerationSeconds } = toleration; const { key, operator, effect, tolerationSeconds, value } = toleration;
return ( return (
<TableRow <TableRow
@ -55,6 +56,7 @@ const getTableRow = (toleration: IToleration) => {
> >
<TableCell className="key">{key}</TableCell> <TableCell className="key">{key}</TableCell>
<TableCell className="operator">{operator}</TableCell> <TableCell className="operator">{operator}</TableCell>
<TableCell className="value">{value}</TableCell>
<TableCell className="effect">{effect}</TableCell> <TableCell className="effect">{effect}</TableCell>
<TableCell className="seconds">{tolerationSeconds}</TableCell> <TableCell className="seconds">{tolerationSeconds}</TableCell>
</TableRow> </TableRow>
@ -74,6 +76,7 @@ export function PodTolerations({ tolerations }: Props) {
<TableHead sticky={false}> <TableHead sticky={false}>
<TableCell className="key" sortBy={sortBy.Key}>Key</TableCell> <TableCell className="key" sortBy={sortBy.Key}>Key</TableCell>
<TableCell className="operator" sortBy={sortBy.Operator}>Operator</TableCell> <TableCell className="operator" sortBy={sortBy.Operator}>Operator</TableCell>
<TableCell className="value" sortBy={sortBy.Value}>Value</TableCell>
<TableCell className="effect" sortBy={sortBy.Effect}>Effect</TableCell> <TableCell className="effect" sortBy={sortBy.Effect}>Effect</TableCell>
<TableCell className="seconds" sortBy={sortBy.Seconds}>Seconds</TableCell> <TableCell className="seconds" sortBy={sortBy.Seconds}>Seconds</TableCell>
</TableHead> </TableHead>

View File

@ -51,11 +51,9 @@ export class PodsStore extends KubeObjectStore<Pod> {
async loadKubeMetrics(namespace?: string) { async loadKubeMetrics(namespace?: string) {
try { try {
const metrics = await podMetricsApi.list({ namespace }); this.kubeMetrics.replace(await podMetricsApi.list({ namespace }));
this.kubeMetrics.replace(metrics);
} catch (error) { } catch (error) {
console.error("loadKubeMetrics failed", error); console.warn("loadKubeMetrics failed", error);
} }
} }

View File

@ -127,7 +127,7 @@ export class Pods extends React.Component<Props> {
{ title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status },
]} ]}
renderTableContents={(pod: Pod) => [ renderTableContents={(pod: Pod) => [
<Badge flat key="name" label={pod.getName()} tooltip={pod.getName()} />, <Badge flat key="name" label={pod.getName()} tooltip={pod.getName()} expandable={false} />,
<KubeObjectStatusIcon key="icon" object={pod} />, <KubeObjectStatusIcon key="icon" object={pod} />,
pod.getNs(), pod.getNs(),
this.renderContainersStatus(pod), this.renderContainersStatus(pod),
@ -145,7 +145,7 @@ export class Pods extends React.Component<Props> {
); );
}), }),
pod.getNodeName() ? pod.getNodeName() ?
<Badge flat key="node" className="node" tooltip={pod.getNodeName()}> <Badge flat key="node" className="node" tooltip={pod.getNodeName()} expandable={false}>
<Link to={getDetailsUrl(nodesApi.getUrl({ name: pod.getNodeName() }))} onClick={stopPropagation}> <Link to={getDetailsUrl(nodesApi.getUrl({ name: pod.getNodeName() }))} onClick={stopPropagation}>
{pod.getNodeName()} {pod.getNodeName()}
</Link> </Link>

View File

@ -98,7 +98,7 @@ $cronjob-condition-color-list: (
@mixin job-condition-bgs { @mixin job-condition-bgs {
@each $condition, $color in $job-condition-color-list { @each $condition, $color in $job-condition-color-list {
&.#{$condition} { .#{$condition} {
color: white; color: white;
background: $color; background: $color;
} }

View File

@ -1,73 +0,0 @@
/**
* 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 "./app-init.scss";
import React from "react";
import { render } from "react-dom";
import { CubeSpinner } from "../spinner";
import { apiBase } from "../../api";
interface Props {
serviceWaitingList?: string[];
}
export class AppInit extends React.Component<Props> {
static async start(rootElem: HTMLElement) {
render(<AppInit/>, rootElem); // show loading indicator asap
await AppInit.readyStateCheck(rootElem); // wait while all good to run
}
protected static async readyStateCheck(rootElem: HTMLElement) {
const waitingList = await apiBase.get<string[]>("/ready");
if (waitingList.length > 0) {
// update waiting state
render(<AppInit serviceWaitingList={waitingList}/>, rootElem);
// check again in 1-5 seconds
return new Promise(resolve => {
const timeoutDelay = 1000 + Math.random() * 4000;
setTimeout(() => resolve(AppInit.readyStateCheck(rootElem)), timeoutDelay);
});
}
}
render() {
const { serviceWaitingList = [] } = this.props;
const serviceNames = serviceWaitingList.join(", ");
return (
<div className="AppInit flex column center">
<div className="box flex column gaps">
<h5>Lens - Loading..</h5>
{serviceWaitingList.length > 0 && (
<p className="waiting-services">
Waiting services to be running: <em className="text-secondary">{serviceNames}</em>
</p>
)}
<CubeSpinner/>
</div>
</div>
);
}
}

View File

@ -19,7 +19,7 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import React from "react"; import React from "react";
import { observable, makeObservable } from "mobx"; import { observable, makeObservable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { Redirect, Route, Router, Switch } from "react-router"; import { Redirect, Route, Router, Switch } from "react-router";
import { history } from "../navigation"; import { history } from "../navigation";
@ -69,6 +69,7 @@ import { Nodes } from "./+nodes";
import { Workloads } from "./+workloads"; import { Workloads } from "./+workloads";
import { Config } from "./+config"; import { Config } from "./+config";
import { Storage } from "./+storage"; import { Storage } from "./+storage";
import { catalogEntityRegistry } from "../api/catalog-entity-registry";
@observer @observer
export class App extends React.Component { export class App extends React.Component {
@ -78,6 +79,7 @@ export class App extends React.Component {
} }
static async init() { static async init() {
catalogEntityRegistry.init();
const frameId = webFrame.routingId; const frameId = webFrame.routingId;
const clusterId = getHostedClusterId(); const clusterId = getHostedClusterId();
@ -86,6 +88,15 @@ export class App extends React.Component {
await requestMain(clusterSetFrameIdHandler, clusterId); await requestMain(clusterSetFrameIdHandler, clusterId);
await getHostedCluster().whenReady; // cluster.activate() is done at this point await getHostedCluster().whenReady; // cluster.activate() is done at this point
const activeEntityDisposer = reaction(() => catalogEntityRegistry.getById(clusterId), (entity) => {
if (!entity) {
return;
}
catalogEntityRegistry.activeEntity = entity;
activeEntityDisposer();
}, {fireImmediately: true});
ExtensionLoader.getInstance().loadOnClusterRenderer(); ExtensionLoader.getInstance().loadOnClusterRenderer();
setTimeout(() => { setTimeout(() => {
appEventBus.emit({ appEventBus.emit({

View File

@ -19,25 +19,41 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
.Badge { .badge {
position: relative;
display: inline-block; display: inline-block;
white-space: nowrap;
max-width: 100%; max-width: 100%;
}
.badge + .badge {
margin-left: 8px;
}
.badge.interactive:hover {
background-color: var(--mainBackground);
cursor: pointer;
}
.badge:not(.isExpanded) {
white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
}
&:not(.flat) {
background: $colorVague; .badge:not(.flat) {
color: $textColorSecondary; background: var(--colorVague);
border-radius: $radius; border-radius: 3px;
padding: .2em .4em; padding: .2em .4em;
} }
&.small { .small {
font-size: $font-size-small; font-size: var(--font-size-small);
} }
&.clickable { .clickable {
cursor: pointer; cursor: pointer;
} }
.disabled {
opacity: 0.5;
} }

Some files were not shown because too many files have changed in this diff Show More