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

Merge branch 'master' into catalog-entity-pin-icons

This commit is contained in:
Alex Andreev 2021-10-15 08:48:40 +03:00
commit 62b45875c4
41 changed files with 915 additions and 830 deletions

View File

@ -113,19 +113,17 @@ docs:
.PHONY: clean-extensions
clean-extensions:
$(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), rm -rf $(dir)/dist)
$(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), rm -rf $(dir)/node_modules)
$(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), rm $(dir)/*.tgz || true)
rm -rf $(EXTENSIONS_DIR)/*/{dist,node_modules,*.tgz}
.PHONY: clean-npm
clean-npm:
rm -rf src/extensions/npm/extensions/dist
rm -rf src/extensions/npm/extensions/__mocks__
rm -rf src/extensions/npm/extensions/node_modules
rm -rf src/extensions/npm/extensions/{dist,__mocks__,node_modules}
.PHONY: clean
clean: clean-npm clean-extensions
rm -rf binaries/client
rm -rf dist/*
rm -rf static/build/*
rm -rf node_modules/
rm -rf dist
rm -rf static/build
rm -rf node_modules
rm -rf site
rm -rf docs/extensions/api

View File

@ -2,6 +2,6 @@
## APIs
- [Common](modules/common.md)
- [Main](modules/main.md)
- [Renderer](modules/renderer.md)
- [Common](modules/Common.md)
- [Main](modules/Main.md)
- [Renderer](modules/Renderer.md)

View File

@ -33,7 +33,7 @@ describe("preferences page tests", () => {
beforeEach(async () => {
let app: ElectronApplication;
({ window, cleanup, app } = await utils.start());
await utils.clickWelcomeButton(window);
@ -74,7 +74,7 @@ describe("preferences page tests", () => {
utils.itIf(process.platform !== "win32")("ensures helm repos", async () => {
await window.click("[data-testid=kubernetes-tab]");
await window.waitForSelector("[data-testid=repository-name]", {
timeout: 100_000,
timeout: 140_000,
});
await window.click("#HelmRepoSelect");
await window.waitForSelector("div.Select__option");

View File

@ -93,7 +93,7 @@ export async function start() {
}
export async function clickWelcomeButton(window: Page) {
await window.click("#hotbarIcon-catalog-entity .Icon");
await window.click("[data-testid=welcome-menu-container] li a");
}
function minikubeEntityId() {

View File

@ -3,7 +3,7 @@
"productName": "OpenLens",
"description": "OpenLens - Open Source IDE for Kubernetes",
"homepage": "https://github.com/lensapp/lens",
"version": "5.3.0-alpha.0",
"version": "5.3.0-alpha.2",
"main": "static/build/main.js",
"copyright": "© 2021 OpenLens Authors",
"license": "MIT",
@ -179,7 +179,7 @@
}
},
"dependencies": {
"@electron/remote": "^1.2.1",
"@electron/remote": "^1.2.2",
"@hapi/call": "^8.0.1",
"@hapi/subtext": "^7.0.3",
"@kubernetes/client-node": "^0.15.1",
@ -193,8 +193,7 @@
"byline": "^5.0.0",
"chalk": "^4.1.0",
"chokidar": "^3.4.3",
"command-exists": "1.2.9",
"conf": "^7.0.1",
"conf": "^7.1.2",
"crypto-js": "^4.1.1",
"electron-devtools-installer": "^3.2.0",
"electron-updater": "^4.6.0",
@ -251,7 +250,7 @@
"winston": "^3.3.3",
"winston-console-format": "^1.0.8",
"winston-transport-browserconsole": "^1.0.5",
"ws": "^7.4.6"
"ws": "^7.5.5"
},
"devDependencies": {
"@emeraldpay/hashicon-react": "^0.4.0",
@ -259,13 +258,13 @@
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.60",
"@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
"@sentry/react": "^6.8.0",
"@sentry/react": "^6.13.3",
"@sentry/types": "^6.8.0",
"@testing-library/dom": "^8.2.0",
"@testing-library/dom": "^8.9.0",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^11.2.6",
"@testing-library/user-event": "^13.2.1",
"@types/byline": "^4.2.32",
"@types/byline": "^4.2.33",
"@types/chart.js": "^2.9.34",
"@types/color": "^3.0.2",
"@types/crypto-js": "^3.1.47",
@ -279,7 +278,7 @@
"@types/http-proxy": "^1.17.7",
"@types/jest": "^26.0.24",
"@types/js-yaml": "^3.12.4",
"@types/jsdom": "^16.2.4",
"@types/jsdom": "^16.2.13",
"@types/jsonpath": "^0.2.0",
"@types/lodash": "^4.14.155",
"@types/marked": "^2.0.4",
@ -287,13 +286,13 @@
"@types/mini-css-extract-plugin": "^0.9.1",
"@types/mock-fs": "^4.13.1",
"@types/module-alias": "^2.0.1",
"@types/node": "14.17.14",
"@types/node": "14.17.26",
"@types/node-fetch": "^2.5.12",
"@types/npm": "^2.0.32",
"@types/progress-bar-webpack-plugin": "^2.1.2",
"@types/proper-lockfile": "^4.1.2",
"@types/randomcolor": "^0.5.6",
"@types/react": "^17.0.0",
"@types/react": "^17.0.29",
"@types/react-beautiful-dnd": "^13.1.1",
"@types/react-dom": "^17.0.9",
"@types/react-router-dom": "^5.3.1",
@ -301,7 +300,7 @@
"@types/react-table": "^7.7.6",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/readable-stream": "^2.3.9",
"@types/readable-stream": "^2.3.11",
"@types/request": "^2.48.7",
"@types/request-promise-native": "^1.0.18",
"@types/semver": "^7.2.0",
@ -327,7 +326,7 @@
"concurrently": "^5.2.0",
"css-loader": "^5.2.7",
"deepdash": "^5.3.9",
"dompurify": "^2.3.1",
"dompurify": "^2.3.3",
"electron": "^13.5.1",
"electron-builder": "^22.11.11",
"electron-notarize": "^0.3.0",
@ -335,7 +334,7 @@
"esbuild-loader": "^2.15.1",
"eslint": "^7.32.0",
"eslint-plugin-header": "^3.1.1",
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react": "^7.26.1",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-plugin-unused-imports": "^1.0.1",
"file-loader": "^6.2.0",
@ -353,7 +352,7 @@
"mini-css-extract-plugin": "^1.6.2",
"node-gyp": "7.1.2",
"node-loader": "^1.0.3",
"nodemon": "^2.0.12",
"nodemon": "^2.0.13",
"playwright": "^1.14.0",
"postcss": "^8.3.6",
"postcss-loader": "4.3.0",
@ -373,14 +372,14 @@
"sass-loader": "^8.0.2",
"sharp": "^0.29.1",
"style-loader": "^2.0.0",
"tailwindcss": "^2.2.4",
"tailwindcss": "^2.2.17",
"ts-jest": "26.5.6",
"ts-loader": "^7.0.5",
"ts-node": "^10.2.1",
"type-fest": "^1.0.2",
"typed-emitter": "^1.3.1",
"typedoc": "0.22.5",
"typedoc-plugin-markdown": "^3.9.0",
"typedoc-plugin-markdown": "^3.11.3",
"typeface-roboto": "^1.1.13",
"typescript": "^4.4.3",
"typescript-plugin-css-modules": "^3.4.0",

View File

@ -20,7 +20,7 @@
*/
import { KubeConfig } from "@kubernetes/client-node";
import { validateKubeConfig, loadConfigFromString, getNodeWarningConditions } from "../kube-helpers";
import { validateKubeConfig, loadConfigFromString } from "../kube-helpers";
const kubeconfig = `
apiVersion: v1
@ -120,38 +120,6 @@ describe("kube helpers", () => {
);
});
});
describe("with invalid exec command", () => {
it("returns an error", () => {
expect(String(validateKubeConfig(kc, "invalidExec"))).toEqual(
expect.stringContaining("User Exec command \"foo\" not found on host. Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig")
);
});
});
});
describe("with validateCluster as false", () => {
describe("with invalid cluster object", () => {
it("does not return an error", () => {
expect(validateKubeConfig(kc, "invalidCluster", { validateCluster: false })).toBeUndefined();
});
});
});
describe("with validateUser as false", () => {
describe("with invalid user object", () => {
it("does not return an error", () => {
expect(validateKubeConfig(kc, "invalidUser", { validateUser: false })).toBeUndefined();
});
});
});
describe("with validateExec as false", () => {
describe("with invalid exec object", () => {
it("does not return an error", () => {
expect(validateKubeConfig(kc, "invalidExec", { validateExec: false })).toBeUndefined();
});
});
});
});
@ -280,43 +248,4 @@ describe("kube helpers", () => {
});
});
});
describe("getNodeWarningConditions", () => {
it("should return an empty array if no status or no conditions", () => {
expect(getNodeWarningConditions({}).length).toBe(0);
});
it("should return an empty array if all conditions are good", () => {
expect(getNodeWarningConditions({
status: {
conditions: [
{
type: "Ready",
status: "foobar"
}
]
}
}).length).toBe(0);
});
it("should all not ready conditions", () => {
const conds = getNodeWarningConditions({
status: {
conditions: [
{
type: "Ready",
status: "foobar"
},
{
type: "NotReady",
status: "true"
},
]
}
});
expect(conds.length).toBe(1);
expect(conds[0].type).toBe("NotReady");
});
});
});

View File

@ -54,15 +54,24 @@ export interface KubernetesClusterSpec extends CatalogEntitySpec {
accessibleNamespaces?: string[];
}
export enum LensKubernetesClusterStatus {
DELETING = "deleting",
CONNECTING = "connecting",
CONNECTED = "connected",
DISCONNECTED = "disconnected"
}
export interface KubernetesClusterMetadata extends CatalogEntityMetadata {
distro?: string;
kubeVersion?: string;
}
/**
* @deprecated This is no longer used as it is incorrect. Other sources can add more values
*/
export type KubernetesClusterStatusPhase = "connected" | "connecting" | "disconnected" | "deleting";
export interface KubernetesClusterStatus extends CatalogEntityStatus {
phase: KubernetesClusterStatusPhase;
}
export class KubernetesCluster extends CatalogEntity<KubernetesClusterMetadata, KubernetesClusterStatus, KubernetesClusterSpec> {
@ -110,15 +119,15 @@ export class KubernetesCluster extends CatalogEntity<KubernetesClusterMetadata,
}
switch (this.status.phase) {
case "connected":
case "connecting":
case LensKubernetesClusterStatus.CONNECTED:
case LensKubernetesClusterStatus.CONNECTING:
context.menuItems.push({
title: "Disconnect",
icon: "link_off",
onClick: () => requestMain(clusterDisconnectHandler, this.metadata.uid)
});
break;
case "disconnected":
case LensKubernetesClusterStatus.DISCONNECTED:
context.menuItems.push({
title: "Connect",
icon: "link",

View File

@ -1,34 +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.
*/
export class ExecValidationNotFoundError extends Error {
constructor(execPath: string, isAbsolute: boolean) {
super(`User Exec command "${execPath}" not found on host.`);
let message = `User Exec command "${execPath}" not found on host.`;
if (!isAbsolute) {
message += ` Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig`;
}
this.message = message;
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}

View File

@ -20,92 +20,108 @@
*/
import { CustomResourceDefinition } from "../endpoints";
import type { KubeObjectMetadata } from "../kube-object";
describe("Crds", () => {
describe("getVersion", () => {
it("should get the first version name from the list of versions", () => {
it("should throw if none of the versions are served", () => {
const crd = new CustomResourceDefinition({
apiVersion: "foo",
apiVersion: "apiextensions.k8s.io/v1",
kind: "CustomResourceDefinition",
metadata: {} as KubeObjectMetadata,
metadata: {
name: "foo",
resourceVersion: "12345",
uid: "12345",
},
spec: {
versions: [
{
name: "123",
served: false,
storage: false,
},
{
name: "1234",
served: false,
storage: false,
},
],
},
});
crd.spec = {
versions: [
{
name: "123",
served: false,
storage: false,
}
]
} as any;
expect(() => crd.getVersion()).toThrowError("Failed to find a version for CustomResourceDefinition foo");
});
it("should should get the version that is both served and stored", () => {
const crd = new CustomResourceDefinition({
apiVersion: "apiextensions.k8s.io/v1",
kind: "CustomResourceDefinition",
metadata: {
name: "foo",
resourceVersion: "12345",
uid: "12345",
},
spec: {
versions: [
{
name: "123",
served: true,
storage: true,
},
{
name: "1234",
served: false,
storage: false,
},
],
},
});
expect(crd.getVersion()).toBe("123");
});
it("should get the first version name from the list of versions (length 2)", () => {
it("should should get the version that is both served and stored even with version field", () => {
const crd = new CustomResourceDefinition({
apiVersion: "foo",
apiVersion: "apiextensions.k8s.io/v1",
kind: "CustomResourceDefinition",
metadata: {} as KubeObjectMetadata,
metadata: {
name: "foo",
resourceVersion: "12345",
uid: "12345",
},
spec: {
version: "abc",
versions: [
{
name: "123",
served: true,
storage: true,
},
{
name: "1234",
served: false,
storage: false,
},
],
},
});
crd.spec = {
versions: [
{
name: "123",
served: false,
storage: false,
},
{
name: "1234",
served: false,
storage: false,
}
]
} as any;
expect(crd.getVersion()).toBe("123");
});
it("should get the first version name from the list of versions (length 2) even with version field", () => {
it("should get the version name from the version field", () => {
const crd = new CustomResourceDefinition({
apiVersion: "foo",
apiVersion: "apiextensions.k8s.io/v1beta1",
kind: "CustomResourceDefinition",
metadata: {} as KubeObjectMetadata,
metadata: {
name: "foo",
resourceVersion: "12345",
uid: "12345",
},
spec: {
version: "abc",
}
});
crd.spec = {
version: "abc",
versions: [
{
name: "123",
served: false,
storage: false,
},
{
name: "1234",
served: false,
storage: false,
}
]
} as any;
expect(crd.getVersion()).toBe("123");
});
it("should get the first version name from the version field", () => {
const crd = new CustomResourceDefinition({
apiVersion: "foo",
kind: "CustomResourceDefinition",
metadata: {} as KubeObjectMetadata,
});
crd.spec = {
version: "abc"
} as any;
expect(crd.getVersion()).toBe("abc");
});
});

View File

@ -19,10 +19,11 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { KubeObject } from "../kube-object";
import { KubeCreationError, KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
import { crdResourcesURL } from "../../routes";
import { isClusterPageContext } from "../../utils/cluster-id-url-parsing";
import type { KubeJsonApiData } from "../kube-json-api";
type AdditionalPrinterColumnsCommon = {
name: string;
@ -39,10 +40,21 @@ type AdditionalPrinterColumnsV1Beta = AdditionalPrinterColumnsCommon & {
JSONPath: string;
};
export interface CRDVersion {
name: string;
served: boolean;
storage: boolean;
schema?: object; // required in v1 but not present in v1beta
additionalPrinterColumns?: AdditionalPrinterColumnsV1[];
}
export interface CustomResourceDefinition {
spec: {
group: string;
version?: string; // deprecated in v1 api
/**
* @deprecated for apiextensions.k8s.io/v1 but used previously
*/
version?: string;
names: {
plural: string;
singular: string;
@ -50,19 +62,19 @@ export interface CustomResourceDefinition {
listKind: string;
};
scope: "Namespaced" | "Cluster" | string;
validation?: any;
versions?: {
name: string;
served: boolean;
storage: boolean;
schema?: unknown; // required in v1 but not present in v1beta
additionalPrinterColumns?: AdditionalPrinterColumnsV1[]
}[];
/**
* @deprecated for apiextensions.k8s.io/v1 but used previously
*/
validation?: object;
versions?: CRDVersion[];
conversion: {
strategy?: string;
webhook?: any;
};
additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[]; // removed in v1
/**
* @deprecated for apiextensions.k8s.io/v1 but used previously
*/
additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[];
};
status: {
conditions: {
@ -83,11 +95,23 @@ export interface CustomResourceDefinition {
};
}
export interface CRDApiData extends KubeJsonApiData {
spec: object; // TODO: make better
}
export class CustomResourceDefinition extends KubeObject {
static kind = "CustomResourceDefinition";
static namespaced = false;
static apiBase = "/apis/apiextensions.k8s.io/v1/customresourcedefinitions";
constructor(data: CRDApiData) {
super(data);
if (!data.spec || typeof data.spec !== "object") {
throw new KubeCreationError("Cannot create a CustomResourceDefinition from an object without spec", data);
}
}
getResourceUrl() {
return crdResourcesURL({
params: {
@ -125,9 +149,36 @@ export class CustomResourceDefinition extends KubeObject {
return this.spec.scope;
}
getPreferedVersion(): CRDVersion {
// Prefer the modern `versions` over the legacy `version`
if (this.spec.versions) {
for (const version of this.spec.versions) {
/**
* If the version is not served then 404 errors will occur
* We should also prefer the storage version
*/
if (version.served && version.storage) {
return version;
}
}
} else if (this.spec.version) {
const { additionalPrinterColumns: apc } = this.spec;
const additionalPrinterColumns = apc?.map(({ JSONPath, ...apc}) => ({ ...apc, jsonPath: JSONPath }));
return {
name: this.spec.version,
served: true,
storage: true,
schema: this.spec.validation,
additionalPrinterColumns,
};
}
throw new Error(`Failed to find a version for CustomResourceDefinition ${this.metadata.name}`);
}
getVersion() {
// v1 has removed the spec.version property, if it is present it must match the first version
return this.spec.versions?.[0]?.name ?? this.spec.version;
return this.getPreferedVersion().name;
}
isNamespaced() {
@ -147,17 +198,14 @@ export class CustomResourceDefinition extends KubeObject {
}
getPrinterColumns(ignorePriority = true): AdditionalPrinterColumnsV1[] {
const columns = this.spec.versions?.find(a => this.getVersion() == a.name)?.additionalPrinterColumns
?? this.spec.additionalPrinterColumns?.map(({ JSONPath, ...rest }) => ({ ...rest, jsonPath: JSONPath })) // map to V1 shape
?? [];
const columns = this.getPreferedVersion().additionalPrinterColumns ?? [];
return columns
.filter(column => column.name != "Age")
.filter(column => ignorePriority ? true : !column.priority);
.filter(column => column.name != "Age" && (ignorePriority || !column.priority));
}
getValidation() {
return JSON.stringify(this.spec.validation ?? this.spec.versions?.[0]?.schema, null, 2);
return JSON.stringify(this.getPreferedVersion().schema, null, 2);
}
getConditions() {

View File

@ -56,6 +56,21 @@ export interface INodeMetrics<T = IMetrics> {
fsSize: T;
}
export interface NodeTaint {
key: string;
value?: string;
effect: string;
timeAdded: string;
}
export function formatNodeTaint(taint: NodeTaint): string {
if (taint.value) {
return `${taint.key}=${taint.value}:${taint.effect}`;
}
return `${taint.key}:${taint.effect}`;
}
export interface Node {
spec: {
podCIDR?: string;
@ -65,12 +80,7 @@ export interface Node {
* @deprecated see https://issues.k8s.io/61966
*/
externalID?: string;
taints?: {
key: string;
value: string;
effect: string;
timeAdded: string;
}[];
taints?: NodeTaint[];
unschedulable?: boolean;
};
status: {

View File

@ -27,8 +27,7 @@ import { stringify } from "querystring";
import { EventEmitter } from "../../common/event-emitter";
import logger from "../../common/logger";
export interface JsonApiData {
}
export interface JsonApiData {}
export interface JsonApiError {
code?: number;

View File

@ -19,24 +19,16 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { KubeConfig, V1Node, V1Pod } from "@kubernetes/client-node";
import { KubeConfig } from "@kubernetes/client-node";
import fse from "fs-extra";
import path from "path";
import os from "os";
import yaml from "js-yaml";
import logger from "../main/logger";
import commandExists from "command-exists";
import { ExecValidationNotFoundError } from "./custom-errors";
import { Cluster, Context, newClusters, newContexts, newUsers, User } from "@kubernetes/client-node/dist/config_types";
import { resolvePath } from "./utils";
import Joi from "joi";
export type KubeConfigValidationOpts = {
validateCluster?: boolean;
validateUser?: boolean;
validateExec?: boolean;
};
export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config");
export function loadConfigFromFileSync(filePath: string): ConfigResult {
@ -86,35 +78,34 @@ const contextSchema = Joi.object({
}),
});
const kubeConfigSchema = Joi
.object({
users: Joi
.array()
.items(userSchema)
.optional(),
clusters: Joi
.array()
.items(clusterSchema)
.optional(),
contexts: Joi
.array()
.items(contextSchema)
.optional(),
"current-context": Joi
.string()
.min(1)
.optional(),
})
const kubeConfigSchema = Joi.object({
users: Joi
.array()
.items(userSchema)
.optional(),
clusters: Joi
.array()
.items(clusterSchema)
.optional(),
contexts: Joi
.array()
.items(contextSchema)
.optional(),
"current-context": Joi
.string()
.min(1)
.optional(),
})
.required();
export interface KubeConfigOptions {
interface KubeConfigOptions {
clusters: Cluster[];
users: User[];
contexts: Context[];
currentContext?: string;
}
export interface OptionsResult {
interface OptionsResult {
options: KubeConfigOptions;
error: Joi.ValidationError;
}
@ -132,7 +123,12 @@ function loadToOptions(rawYaml: string): OptionsResult {
arrays: true,
}
});
const { clusters: rawClusters, users: rawUsers, contexts: rawContexts, "current-context": currentContext } = value ?? {};
const {
clusters: rawClusters,
users: rawUsers,
contexts: rawContexts,
"current-context": currentContext,
} = value ?? {};
const clusters = newClusters(rawClusters);
const users = newUsers(rawUsers);
const contexts = newContexts(rawContexts);
@ -175,66 +171,78 @@ export interface SplitConfigEntry {
* Breaks kube config into several configs. Each context as it own KubeConfig object
*/
export function splitConfig(kubeConfig: KubeConfig): SplitConfigEntry[] {
const { contexts = [] } = kubeConfig;
return contexts.map(context => {
return kubeConfig.getContexts().map(ctx => {
const config = new KubeConfig();
const cluster = kubeConfig.getCluster(ctx.cluster);
const user = kubeConfig.getUser(ctx.user);
const context = kubeConfig.getContextObject(ctx.name);
config.clusters = [kubeConfig.getCluster(context.cluster)].filter(Boolean);
config.users = [kubeConfig.getUser(context.user)].filter(Boolean);
config.contexts = [kubeConfig.getContextObject(context.name)].filter(Boolean);
config.setCurrentContext(context.name);
if (cluster) {
config.addCluster(cluster);
}
if (user) {
config.addUser(user);
}
if (context) {
config.addContext(context);
}
config.setCurrentContext(ctx.name);
return {
config,
error: validateKubeConfig(config, context.name)?.toString(),
error: validateKubeConfig(config, ctx.name)?.toString(),
};
});
}
/**
* Pretty format the object as human readable yaml, such as would be on the filesystem
* @param kubeConfig The kubeconfig object to format as pretty yaml
* @returns The yaml representation of the kubeconfig object
*/
export function dumpConfigYaml(kubeConfig: Partial<KubeConfig>): string {
const clusters = kubeConfig.clusters.map(cluster => ({
name: cluster.name,
cluster: {
"certificate-authority-data": cluster.caData,
"certificate-authority": cluster.caFile,
server: cluster.server,
"insecure-skip-tls-verify": cluster.skipTLSVerify
}
}));
const contexts = kubeConfig.contexts.map(context => ({
name: context.name,
context: {
cluster: context.cluster,
user: context.user,
namespace: context.namespace
}
}));
const users = kubeConfig.users.map(user => ({
name: user.name,
user: {
"client-certificate-data": user.certData,
"client-certificate": user.certFile,
"client-key-data": user.keyData,
"client-key": user.keyFile,
"auth-provider": user.authProvider,
exec: user.exec,
token: user.token,
username: user.username,
password: user.password
}
}));
const config = {
apiVersion: "v1",
kind: "Config",
preferences: {},
"current-context": kubeConfig.currentContext,
clusters: kubeConfig.clusters.map(cluster => {
return {
name: cluster.name,
cluster: {
"certificate-authority-data": cluster.caData,
"certificate-authority": cluster.caFile,
server: cluster.server,
"insecure-skip-tls-verify": cluster.skipTLSVerify
}
};
}),
contexts: kubeConfig.contexts.map(context => {
return {
name: context.name,
context: {
cluster: context.cluster,
user: context.user,
namespace: context.namespace
}
};
}),
users: kubeConfig.users.map(user => {
return {
name: user.name,
user: {
"client-certificate-data": user.certData,
"client-certificate": user.certFile,
"client-key-data": user.keyData,
"client-key": user.keyFile,
"auth-provider": user.authProvider,
exec: user.exec,
token: user.token,
username: user.username,
password: user.password
}
};
})
clusters,
contexts,
users,
};
logger.debug("Dumping KubeConfig:", config);
@ -243,70 +251,25 @@ export function dumpConfigYaml(kubeConfig: Partial<KubeConfig>): string {
return yaml.safeDump(config, { skipInvalid: true });
}
export function podHasIssues(pod: V1Pod) {
// Logic adapted from dashboard
const notReady = !!pod.status.conditions.find(condition => {
return condition.type == "Ready" && condition.status !== "True";
});
return (
notReady ||
pod.status.phase !== "Running" ||
pod.spec.priority > 500000 // We're interested in high priority pods events regardless of their running status
);
}
export function getNodeWarningConditions(node: V1Node) {
return node.status?.conditions?.filter(c =>
c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades"
) ?? [];
}
/**
* Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required)
*
* Note: This function returns an error instead of throwing it, returning `undefined` if the validation passes
*/
export function validateKubeConfig(config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}): Error | undefined {
try {
// we only receive a single context, cluster & user object here so lets validate them as this
// will be called when we add a new cluster to Lens
export function validateKubeConfig(config: KubeConfig, contextName: string): Error | undefined {
const contextObject = config.getContextObject(contextName);
const { validateUser = true, validateCluster = true, validateExec = true } = validationOpts;
const contextObject = config.getContextObject(contextName);
// Validate the Context Object
if (!contextObject) {
return new Error(`No valid context object provided in kubeconfig for context '${contextName}'`);
}
// Validate the Cluster Object
if (validateCluster && !config.getCluster(contextObject.cluster)) {
return new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`);
}
const user = config.getUser(contextObject.user);
// Validate the User Object
if (validateUser && !user) {
return new Error(`No valid user object provided in kubeconfig for context '${contextName}'`);
}
// Validate exec command if present
if (validateExec && user?.exec) {
const execCommand = user.exec["command"];
// check if the command is absolute or not
const isAbsolute = path.isAbsolute(execCommand);
// validate the exec struct in the user object, start with the command field
if (!commandExists.sync(execCommand)) {
return new ExecValidationNotFoundError(execCommand, isAbsolute);
}
}
return undefined;
} catch (error) {
return error;
if (!contextObject) {
return new Error(`No valid context object provided in kubeconfig for context '${contextName}'`);
}
if (!config.getCluster(contextObject.cluster)) {
return new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`);
}
if (!config.getUser(contextObject.user)) {
return new Error(`No valid user object provided in kubeconfig for context '${contextName}'`);
}
return undefined;
}

View File

@ -23,9 +23,10 @@ import moment from "moment-timezone";
import path from "path";
import os from "os";
import { ThemeStore } from "../../renderer/theme.store";
import { ObservableToggleSet } from "../utils";
import { getAppVersion, ObservableToggleSet } from "../utils";
import type {monaco} from "react-monaco-editor";
import merge from "lodash/merge";
import { SemVer } from "semver";
export interface KubeconfigSyncEntry extends KubeconfigSyncValue {
filePath: string;
@ -260,6 +261,32 @@ const editorConfiguration: PreferenceDescription<EditorConfiguration, EditorConf
},
};
const updateChannels = new Map([
["latest", {
label: "Stable"
}],
["beta", {
label: "Beta"
}],
["alpha", {
label: "Alpha"
}],
]);
const defaultUpdateChannel = new SemVer(getAppVersion()).prerelease[0]?.toString() || "latest";
const updateChannel: PreferenceDescription<string> = {
fromStore(val) {
return updateChannels.has(val) ? val : defaultUpdateChannel;
},
toStore(val) {
if (!updateChannels.has(val) || val === defaultUpdateChannel) {
return undefined;
}
return val;
}
};
type PreferencesModelType<field extends keyof typeof DESCRIPTORS> = typeof DESCRIPTORS[field] extends PreferenceDescription<infer T, any> ? T : never;
type UserStoreModelType<field extends keyof typeof DESCRIPTORS> = typeof DESCRIPTORS[field] extends PreferenceDescription<any, infer T> ? T : never;
@ -288,4 +315,9 @@ export const DESCRIPTORS = {
syncKubeconfigEntries,
editorConfiguration,
terminalCopyOnSelect,
updateChannel,
};
export const CONSTANTS = {
updateChannels,
};

View File

@ -20,7 +20,7 @@
*/
import { app, ipcMain } from "electron";
import semver from "semver";
import semver, { SemVer } from "semver";
import { action, computed, observable, reaction, makeObservable } from "mobx";
import { BaseStore } from "../base-store";
import migrations from "../../migrations/user-store";
@ -75,6 +75,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
@observable downloadBinariesPath?: string;
@observable kubectlBinariesPath?: string;
@observable terminalCopyOnSelect: boolean;
@observable updateChannel?: string;
/**
* Download kubectl binaries matching cluster version
@ -106,6 +107,8 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
return this.shell || process.env.SHELL || process.env.PTYSHELL;
}
readonly isAllowedToDowngrade = new SemVer(getAppVersion()).prerelease[0] !== "latest";
startMainReactions() {
// track telemetry availability
reaction(() => this.allowTelemetry, allowed => {
@ -218,6 +221,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
this.syncKubeconfigEntries.replace(DESCRIPTORS.syncKubeconfigEntries.fromStore(preferences?.syncKubeconfigEntries));
this.editorConfiguration = DESCRIPTORS.editorConfiguration.fromStore(preferences?.editorConfiguration);
this.terminalCopyOnSelect = DESCRIPTORS.terminalCopyOnSelect.fromStore(preferences?.terminalCopyOnSelect);
this.updateChannel = DESCRIPTORS.updateChannel.fromStore(preferences?.updateChannel);
}
toJSON(): UserStoreModel {
@ -240,6 +244,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
syncKubeconfigEntries: DESCRIPTORS.syncKubeconfigEntries.toStore(this.syncKubeconfigEntries),
editorConfiguration: DESCRIPTORS.editorConfiguration.toStore(this.editorConfiguration),
terminalCopyOnSelect: DESCRIPTORS.terminalCopyOnSelect.toStore(this.terminalCopyOnSelect),
updateChannel: DESCRIPTORS.updateChannel.toStore(this.updateChannel),
},
};

View File

@ -19,5 +19,22 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
export * from "../../common/catalog-entities";
export {
KubernetesCluster,
kubernetesClusterCategory,
GeneralEntity,
WebLink,
} from "../../common/catalog-entities";
export type {
KubernetesClusterPrometheusMetrics,
KubernetesClusterSpec,
KubernetesClusterMetadata,
WebLinkSpec,
WebLinkStatus,
WebLinkStatusPhase,
KubernetesClusterStatusPhase,
KubernetesClusterStatus,
} from "../../common/catalog-entities";
export * from "../../common/catalog/catalog-entity";

View File

@ -27,8 +27,8 @@ import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMess
import { once } from "lodash";
import { ipcMain } from "electron";
import { nextUpdateChannel } from "./utils/update-channel";
import { UserStore } from "../common/user-store";
const updateChannel = autoUpdater.channel;
let installVersion: null | string = null;
export function isAutoUpdateEnabled() {
@ -58,9 +58,13 @@ export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24
return;
}
const userStore = UserStore.getInstance();
autoUpdater.logger = logger;
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.channel = userStore.updateChannel;
autoUpdater.allowDowngrade = userStore.isAllowedToDowngrade;
autoUpdater
.on("update-available", (info: UpdateInfo) => {
@ -104,7 +108,7 @@ export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24
}
})
.on("update-not-available", () => {
const nextChannel = nextUpdateChannel(updateChannel, autoUpdater.channel);
const nextChannel = nextUpdateChannel(userStore.updateChannel, autoUpdater.channel);
logger.info(`${AutoUpdateLogPrefix}: update not available from ${autoUpdater.channel}, will check ${nextChannel} channel next`);

View File

@ -27,7 +27,7 @@ import logger from "./logger";
import { apiKubePrefix } from "../common/vars";
import { getClusterIdFromHost, Singleton } from "../common/utils";
import { catalogEntityRegistry } from "./catalog";
import { KubernetesCluster, KubernetesClusterPrometheusMetrics, KubernetesClusterStatusPhase } from "../common/catalog-entities/kubernetes-cluster";
import { KubernetesCluster, KubernetesClusterPrometheusMetrics, LensKubernetesClusterStatus } from "../common/catalog-entities/kubernetes-cluster";
import { ipcMainOn } from "../common/ipc";
import { once } from "lodash";
import { ClusterStore } from "../common/cluster-store";
@ -35,6 +35,8 @@ import type { ClusterId } from "../common/cluster-types";
const logPrefix = "[CLUSTER-MANAGER]:";
const lensSpecificClusterStatuses: Set<string> = new Set(Object.values(LensKubernetesClusterStatus));
export class ClusterManager extends Singleton {
private store = ClusterStore.getInstance();
deleting = observable.set<ClusterId>();
@ -90,6 +92,8 @@ export class ClusterManager extends Singleton {
@action
protected updateCatalog(clusters: Cluster[]) {
logger.debug("[CLUSTER-MANAGER]: updating catalog from cluster store");
for (const cluster of clusters) {
this.updateEntityFromCluster(cluster);
}
@ -144,23 +148,28 @@ export class ClusterManager extends Singleton {
@action
protected updateEntityStatus(entity: KubernetesCluster, cluster?: Cluster) {
if (this.deleting.has(entity.getId())) {
entity.status.phase = "deleting";
entity.status.phase = LensKubernetesClusterStatus.DELETING;
entity.status.enabled = false;
} else {
entity.status.phase = ((): KubernetesClusterStatusPhase => {
entity.status.phase = (() => {
if (!cluster) {
return "disconnected";
return LensKubernetesClusterStatus.DISCONNECTED;
}
if (cluster.accessible) {
return "connected";
return LensKubernetesClusterStatus.CONNECTED;
}
if (!cluster.disconnected) {
return "connecting";
return LensKubernetesClusterStatus.CONNECTING;
}
return "disconnected";
// Extensions are not allowed to use the Lens specific status phases
if (!lensSpecificClusterStatuses.has(entity?.status?.phase)) {
return entity.status.phase;
}
return LensKubernetesClusterStatus.DISCONNECTED;
})();
entity.status.enabled = true;
@ -272,7 +281,9 @@ export function catalogEntityFromCluster(cluster: Cluster) {
icon: {}
},
status: {
phase: cluster.disconnected ? "disconnected" : "connected",
phase: cluster.disconnected
? LensKubernetesClusterStatus.DISCONNECTED
: LensKubernetesClusterStatus.CONNECTED,
reason: "",
message: "",
active: !cluster.disconnected

View File

@ -394,7 +394,6 @@ export class Cluster implements ClusterModel, ClusterState {
* @internal
*/
@action disconnect() {
logger.info(`[CLUSTER]: disconnect`, this.getMeta());
this.unbindEvents();
this.contextHandler?.stopServer();
this.disconnected = true;
@ -405,6 +404,7 @@ export class Cluster implements ClusterModel, ClusterState {
this.allowedNamespaces = [];
this.resourceAccessStatuses.clear();
this.pushState();
logger.info(`[CLUSTER]: disconnect`, this.getMeta());
}
/**

View File

@ -43,8 +43,8 @@ export async function listReleases(pathToKubeconfig: string, namespace?: string)
});
return output;
} catch ({ stderr }) {
throw stderr;
} catch (error) {
throw error?.stderr || error;
}
}
@ -72,8 +72,8 @@ export async function installChart(chart: string, values: any, name: string | un
namespace
}
};
} catch ({ stderr }) {
throw stderr;
} catch (error) {
throw error?.stderr || error;
} finally {
await fse.unlink(fileName);
}
@ -93,8 +93,8 @@ export async function upgradeRelease(name: string, chart: string, values: any, n
log: stdout,
release: getRelease(name, namespace, cluster)
};
} catch ({ stderr }) {
throw stderr;
} catch (error) {
throw error?.stderr || error;
} finally {
await fse.unlink(fileName);
}
@ -111,8 +111,8 @@ export async function getRelease(name: string, namespace: string, cluster: Clust
release.resources = await getResources(name, namespace, cluster);
return release;
} catch ({ stderr }) {
throw stderr;
} catch (error) {
throw error?.stderr || error;
}
}
@ -122,8 +122,8 @@ export async function deleteRelease(name: string, namespace: string, pathToKubec
const { stdout } = await promiseExec(`"${helm}" delete ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`);
return stdout;
} catch ({ stderr }) {
throw stderr;
} catch (error) {
throw error?.stderr || error;
}
}
@ -139,8 +139,8 @@ export async function getValues(name: string, { namespace, all = false, pathToKu
const { stdout } = await promiseExec(`"${helm}" get values ${name} ${all ? "--all" : ""} --output yaml --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`);
return stdout;
} catch ({ stderr }) {
throw stderr;
} catch (error) {
throw error?.stderr || error;
}
}
@ -150,8 +150,8 @@ export async function getHistory(name: string, namespace: string, pathToKubeconf
const { stdout } = await promiseExec(`"${helm}" history ${name} --output json --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`);
return JSON.parse(stdout);
} catch ({ stderr }) {
throw stderr;
} catch (error) {
throw error?.stderr || error;
}
}
@ -161,8 +161,8 @@ export async function rollback(name: string, namespace: string, revision: number
const { stdout } = await promiseExec(`"${helm}" rollback ${name} ${revision} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`);
return stdout;
} catch ({ stderr }) {
throw stderr;
} catch (error) {
throw error?.stderr || error;
}
}

View File

@ -147,13 +147,18 @@ export function buildMenu(windowManager: WindowManager) {
}
}
]),
{ type: "separator" },
{
role: "close",
label: "Close Window"
},
...(isMac ? [
{
role: "close",
label: "Close Window",
accelerator: "Shift+Cmd+W"
}
] as MenuItemConstructorOptions[] : []),
...ignoreOnMac([
{ type: "separator" },
{
label: "Exit",
accelerator: "Alt+F4",

View File

@ -41,7 +41,7 @@ export class HelmApiRoute {
respondJson(response, chart);
} catch (error) {
respondText(response, error, 422);
respondText(response, error?.toString() || "Error getting chart", 422);
}
}
@ -53,7 +53,7 @@ export class HelmApiRoute {
respondJson(response, values);
} catch (error) {
respondText(response, error, 422);
respondText(response, error?.toString() || "Error getting chart values", 422);
}
}
@ -66,7 +66,7 @@ export class HelmApiRoute {
respondJson(response, result, 201);
} catch (error) {
logger.debug(error);
respondText(response, error, 422);
respondText(response, error?.toString() || "Error installing chart", 422);
}
}
@ -79,7 +79,7 @@ export class HelmApiRoute {
respondJson(response, result);
} catch (error) {
logger.debug(error);
respondText(response, error, 422);
respondText(response, error?.toString() || "Error updating chart", 422);
}
}
@ -92,7 +92,7 @@ export class HelmApiRoute {
respondJson(response, result);
} catch (error) {
logger.debug(error);
respondText(response, error, 422);
respondText(response, error?.toString() || "Error rolling back chart", 422);
}
}
@ -105,7 +105,7 @@ export class HelmApiRoute {
respondJson(response, result);
} catch(error) {
logger.debug(error);
respondText(response, error, 422);
respondText(response, error?.toString() || "Error listing release", 422);
}
}
@ -118,7 +118,7 @@ export class HelmApiRoute {
respondJson(response, result);
} catch (error) {
logger.debug(error);
respondText(response, error, 422);
respondText(response, error?.toString() || "Error getting release", 422);
}
}
@ -132,7 +132,7 @@ export class HelmApiRoute {
respondText(response, result);
} catch (error) {
logger.debug(error);
respondText(response, error, 422);
respondText(response, error?.toString() || "Error getting release values", 422);
}
}
@ -145,7 +145,7 @@ export class HelmApiRoute {
respondJson(response, result);
} catch (error) {
logger.debug(error);
respondText(response, error, 422);
respondText(response, error?.toString() || "Error getting release history", 422);
}
}
@ -158,7 +158,7 @@ export class HelmApiRoute {
respondJson(response, result);
} catch (error) {
logger.debug(error);
respondText(response, error, 422);
respondText(response, error?.toString() || "Error deleting release", 422);
}
}
}

View File

@ -33,17 +33,17 @@ describe("nextUpdateChannel", () => {
expect(nextUpdateChannel("latest", "alpha")).toEqual("beta");
});
it("returns rc if current channel is beta", () => {
expect(nextUpdateChannel("alpha", "beta")).toEqual("rc");
expect(nextUpdateChannel("beta", "beta")).toEqual("rc");
expect(nextUpdateChannel("rc", "beta")).toEqual("rc");
expect(nextUpdateChannel("latest", "beta")).toEqual("rc");
it("returns latest if current channel is beta", () => {
expect(nextUpdateChannel("alpha", "beta")).toEqual("latest");
expect(nextUpdateChannel("beta", "beta")).toEqual("latest");
expect(nextUpdateChannel("rc", "beta")).toEqual("latest");
expect(nextUpdateChannel("latest", "beta")).toEqual("latest");
});
it("returns latest if current channel is rc", () => {
expect(nextUpdateChannel("alpha", "rc")).toEqual("latest");
expect(nextUpdateChannel("beta", "rc")).toEqual("latest");
expect(nextUpdateChannel("rc", "rc")).toEqual("latest");
it("returns default if current channel is unknown", () => {
expect(nextUpdateChannel("alpha", "rc")).toEqual("alpha");
expect(nextUpdateChannel("beta", "rc")).toEqual("beta");
expect(nextUpdateChannel("rc", "rc")).toEqual("rc");
expect(nextUpdateChannel("latest", "rc")).toEqual("latest");
});
});

View File

@ -19,14 +19,19 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/**
* Compute the next update channel from the current updating channel
* @param defaultChannel The default (initial) channel to check
* @param channel The current channel that did not have a new version associated with it
* @returns The channel name of the next release version
*/
export function nextUpdateChannel(defaultChannel: string, channel: string): string {
if (channel === "alpha") {
return "beta";
} else if (channel === "beta") {
return "rc";
} else if (channel === "rc") {
return "latest";
} else {
return defaultChannel;
switch (channel) {
case "alpha":
return "beta";
case "beta":
return "latest"; // there is no RC currently
default:
return defaultChannel;
}
}

View File

@ -23,8 +23,8 @@ import "./helm-chart-details.scss";
import React, { Component } from "react";
import { getChartDetails, HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api";
import { observable, autorun, makeObservable } from "mobx";
import { observer } from "mobx-react";
import { observable, makeObservable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { Drawer, DrawerItem } from "../drawer";
import { boundMethod, stopPropagation } from "../../utils";
import { MarkdownViewer } from "../markdown-viewer";
@ -64,20 +64,24 @@ export class HelmChartDetails extends Component<Props> {
this.abortController?.abort();
}
chartUpdater = autorun(() => {
this.selectedChart = null;
const { chart: { name, repo, version } } = this.props;
componentDidMount() {
disposeOnUnmount(this, [
reaction(() => this.props.chart, async ({ name, repo, version }) => {
try {
const { readme, versions } = await getChartDetails(repo, name, { version });
getChartDetails(repo, name, { version })
.then(result => {
this.readme = result.readme;
this.chartVersions = result.versions;
this.selectedChart = result.versions[0];
})
.catch(error => {
this.error = error;
});
});
this.readme = readme;
this.chartVersions = versions;
this.selectedChart = versions[0];
} catch (error) {
this.error = error;
this.selectedChart = null;
}
}, {
fireImmediately: true,
}),
]);
}
@boundMethod
async onVersionChange({ value: chart }: SelectOption<HelmChart>) {
@ -135,7 +139,7 @@ export class HelmChartDetails extends Component<Props> {
value: chart,
}))}
isOptionDisabled={({ value: chart }) => chart.deprecated}
value={selectedChart.getVersion()}
value={selectedChart}
onChange={onVersionChange}
/>
</DrawerItem>
@ -170,10 +174,6 @@ export class HelmChartDetails extends Component<Props> {
}
renderContent() {
if (!this.selectedChart) {
return <Spinner center />;
}
if (this.error) {
return (
<div className="box grow">
@ -182,6 +182,10 @@ export class HelmChartDetails extends Component<Props> {
);
}
if (!this.selectedChart) {
return <Spinner center />;
}
return (
<div className="box grow">
{this.renderIntroduction()}

View File

@ -26,6 +26,11 @@
align-items: center;
}
.loading-error {
color: red;
text-align: center;
}
.status {
@include release-status-bgs;
}

View File

@ -56,12 +56,13 @@ interface Props {
@observer
export class ReleaseDetails extends Component<Props> {
@observable details: IReleaseDetails;
@observable details: IReleaseDetails | null = null;
@observable values = "";
@observable valuesLoading = false;
@observable showOnlyUserSuppliedValues = true;
@observable saving = false;
@observable releaseSecret: Secret;
@observable error?: string = undefined;
componentDidMount() {
disposeOnUnmount(this, [
@ -96,9 +97,13 @@ export class ReleaseDetails extends Component<Props> {
async loadDetails() {
const { release } = this.props;
this.details = null;
this.details = await getRelease(release.getName(), release.getNs());
try {
this.details = null;
this.details = await getRelease(release.getName(), release.getNs());
} catch (error) {
this.error = `Failed to get release details: ${error}`;
}
}
async loadValues() {
@ -237,11 +242,18 @@ export class ReleaseDetails extends Component<Props> {
renderContent() {
const { release } = this.props;
const { details } = this;
if (!release) return null;
if (!details) {
if (this.error) {
return (
<div className="loading-error">
{this.error}
</div>
);
}
if (!this.details) {
return <Spinner center/>;
}

View File

@ -79,6 +79,7 @@ export class HelmReleaseMenu extends React.Component<Props> {
{...menuProps}
className={cssNames("HelmReleaseMenu", className)}
removeAction={this.remove}
removeConfirmationMessage={() => <p>Remove Helm Release <b>{release.name}</b>?</p>}
>
{this.renderContent()}
</MenuActions>

View File

@ -30,7 +30,7 @@ import { Badge } from "../badge";
import { ResourceMetrics } from "../resource-metrics";
import { podsStore } from "../+workloads-pods/pods.store";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { getMetricsByNodeNames, IClusterMetrics, Node } from "../../../common/k8s-api/endpoints";
import { formatNodeTaint, getMetricsByNodeNames, IClusterMetrics, Node } from "../../../common/k8s-api/endpoints";
import { NodeCharts } from "./node-charts";
import { makeObservable, observable, reaction } from "mobx";
import { PodDetailsList } from "../+workloads-pods/pod-details-list";
@ -132,11 +132,7 @@ export class NodeDetails extends React.Component<Props> {
/>
{taints.length > 0 && (
<DrawerItem name="Taints" labelsOnly>
{
taints.map(({ key, effect, value }) => (
<Badge key={key} label={`${key}=${value}:${effect}`} />
))
}
{taints.map(taint => <Badge key={taint.key} label={formatNodeTaint(taint)} />)}
</DrawerItem>
)}
{conditions &&

View File

@ -27,7 +27,7 @@ import { cssNames, interval } from "../../utils";
import { TabLayout } from "../layout/tab-layout";
import { nodesStore } from "./nodes.store";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import { getMetricsForAllNodes, INodeMetrics, Node } from "../../../common/k8s-api/endpoints/nodes.api";
import { formatNodeTaint, getMetricsForAllNodes, INodeMetrics, Node } from "../../../common/k8s-api/endpoints/nodes.api";
import { LineProgress } from "../line-progress";
import { bytesToUnits } from "../../../common/utils/convertMemory";
import { Tooltip, TooltipPosition } from "../tooltip";
@ -227,6 +227,7 @@ export class Nodes extends React.Component<Props> {
]}
renderTableContents={node => {
const tooltipId = `node-taints-${node.getId()}`;
const taints = node.getTaints();
return [
<Badge flat key="name" label={node.getName()} tooltip={node.getName()} />,
@ -235,9 +236,9 @@ export class Nodes extends React.Component<Props> {
this.renderMemoryUsage(node),
this.renderDiskUsage(node),
<>
<span id={tooltipId}>{node.getTaints().length}</span>
<span id={tooltipId}>{taints.length}</span>
<Tooltip targetId={tooltipId} tooltipOnParentHover={true} style={{ whiteSpace: "pre-line" }}>
{node.getTaints().map(({ key, value, effect }) => `${key}=${value}:${effect}`).join("\n")}
{taints.map(formatNodeTaint).join("\n")}
</Tooltip>
</>,
node.getRoleLabels(),

View File

@ -29,11 +29,16 @@ import { Input } from "../input";
import { isWindows } from "../../../common/vars";
import { FormSwitch, Switcher } from "../switch";
import moment from "moment-timezone";
import { CONSTANTS } from "../../../common/user-store/preferences-helpers";
const timezoneOptions: SelectOption<string>[] = moment.tz.names().map(zone => ({
label: zone,
value: zone,
}));
const updateChannelOptions: SelectOption<string>[] = Array.from(
CONSTANTS.updateChannels.entries(),
([value, { label }]) => ({ value, label }),
);
export const Application = observer(() => {
const defaultShell = process.env.SHELL
@ -104,6 +109,18 @@ export const Application = observer(() => {
<hr />
<section id="update-channel">
<SubTitle title="Update Channel"/>
<Select
options={updateChannelOptions}
value={UserStore.getInstance().updateChannel}
onChange={({ value }: SelectOption) => UserStore.getInstance().updateChannel = value}
themeName="lens"
/>
</section>
<hr />
<section id="locale">
<SubTitle title="Locale Timezone" />
<Select

View File

@ -19,14 +19,23 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
export = commandExists;
import { formatNodeTaint } from "../../../common/k8s-api/endpoints";
declare function commandExists(commandName: string): Promise<string>;
declare function commandExists(
commandName: string,
cb: (error: null, exists: boolean) => void
): void;
describe("formatNodeTaint tests", () => {
it("should use value if defined", () => {
expect(formatNodeTaint({
effect: "Foo",
key: "hello",
timeAdded: "pre",
value: "a"
})).toBe("hello=a:Foo");
});
declare namespace commandExists {
function sync(commandName: string): boolean;
}
it("should not use value if not defined", () => {
expect(formatNodeTaint({
effect: "Foo",
key: "hello",
timeAdded: "pre",
})).toBe("hello:Foo");
});
});

View File

@ -29,6 +29,7 @@ import { Tab, TabProps } from "../tabs";
import { Icon } from "../icon";
import { Menu, MenuItem } from "../menu";
import { observable, makeObservable } from "mobx";
import { isMac } from "../../../common/vars";
export interface DockTabProps extends TabProps<DockTabModel> {
moreActions?: React.ReactNode;
@ -94,7 +95,7 @@ export class DockTab extends React.Component<DockTabProps> {
{!pinned && (
<Icon
small material="close"
tooltip="Close (Ctrl+Shift+W)"
tooltip={`Close ${isMac ? "⌘+W" : "Ctrl+W"}`}
onClick={prevDefault(this.close)}
/>
)}

View File

@ -27,6 +27,16 @@
display: flex;
flex-direction: column;
&:not(:focus-within) .DockTab.active {
&::after {
color: var(--halfGray);
}
&:hover::after {
color: var(--line-color-active);
}
}
&.isOpen {
&.fullSize {
position: fixed;

View File

@ -46,19 +46,31 @@ interface Props {
@observer
export class Dock extends React.Component<Props> {
onKeydown = (evt: React.KeyboardEvent<HTMLElement>) => {
const { close, closeTab, selectedTab } = dockStore;
private element = React.createRef<HTMLDivElement>();
if (!selectedTab) return;
const { code, ctrlKey, shiftKey } = evt.nativeEvent;
componentDidMount() {
document.addEventListener("keydown", this.onKeyDown);
}
componentWillUnmount() {
document.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown = (evt: KeyboardEvent) => {
const { close, selectedTab, closeTab } = dockStore;
const { code, ctrlKey, metaKey, shiftKey } = evt;
// Determine if user working inside <Dock/> or using any other areas in app
const dockIsFocused = this.element?.current.contains(document.activeElement);
if (!selectedTab || !dockIsFocused) return;
if (shiftKey && code === "Escape") {
close();
}
if (ctrlKey && code === "KeyW") {
if (selectedTab.pinned) close();
else closeTab(selectedTab.id);
if ((ctrlKey && code === "KeyW") || (metaKey && code === "KeyW")) {
closeTab(selectedTab.id);
this.element?.current.focus(); // Avoid loosing focus when closing tab
}
};
@ -67,6 +79,7 @@ export class Dock extends React.Component<Props> {
open();
selectTab(tab.id);
this.element?.current.focus();
};
renderTab(tab: DockTab) {
@ -105,7 +118,7 @@ export class Dock extends React.Component<Props> {
return (
<div
className={cssNames("Dock", className, { isOpen, fullSize })}
onKeyDown={this.onKeydown}
ref={this.element}
tabIndex={-1}
>
<ResizingAnchor

View File

@ -222,7 +222,7 @@ export class Terminal {
};
keyHandler = (evt: KeyboardEvent): boolean => {
const { code, ctrlKey, type, metaKey } = evt;
const { code, ctrlKey, metaKey } = evt;
// Handle custom hotkey bindings
if (ctrlKey) {
@ -248,11 +248,6 @@ export class Terminal {
}
}
// Pass the event above in DOM for <Dock/> to handle common actions
if (!evt.defaultPrevented) {
this.elem.dispatchEvent(new KeyboardEvent(type, evt));
}
return true;
};
}

View File

@ -30,6 +30,7 @@ import { navigate } from "../../navigation";
import { cssNames, IClassName } from "../../utils";
import { Icon } from "../icon";
import { HotbarIcon } from "./hotbar-icon";
import { LensKubernetesClusterStatus } from "../../../common/catalog-entities/kubernetes-cluster";
interface Props extends DOMAttributes<HTMLElement> {
entity: CatalogEntity;
@ -77,7 +78,7 @@ export class HotbarEntityIcon extends React.Component<Props> {
return null;
}
const className = cssNames("led", { online: this.props.entity.status.phase == "connected"}); // TODO: make it more generic
const className = cssNames("led", { online: this.props.entity.status.phase === LensKubernetesClusterStatus.CONNECTED}); // TODO: make it more generic
return <div className={className} />;
}

View File

@ -193,7 +193,7 @@ export class Sidebar extends React.Component<Props> {
source={metadata.source}
src={spec.icon?.src}
className="mr-5"
onClick={() => navigate(routes.clusterURL())}
onClick={() => navigate("/")}
/>
<div className={styles.clusterName}>
{metadata.name}

View File

@ -25,7 +25,7 @@
grid-template-rows: var(--main-layout-header);
grid-template-areas: "title controls";
background-color: var(--layoutBackground);
z-index: 1;
z-index: 2;
width: 100%;
grid-area: topbar;
}

View File

@ -28,6 +28,7 @@ import { StorageHelper } from "./storageHelper";
import { ClusterStore } from "../../common/cluster-store";
import logger from "../../main/logger";
import { getHostedClusterId, getPath } from "../../common/utils";
import { isTestEnv } from "../../common/vars";
const storage = observable({
initialized: false,
@ -62,7 +63,9 @@ export function createAppStorage<T>(key: string, defaultValue: T, clusterId?: st
.then(data => storage.data = data)
.catch(() => null) // ignore empty / non-existing / invalid json files
.finally(() => {
logger.info(`${logPrefix} loading finished for ${filePath}`);
if (!isTestEnv) {
logger.info(`${logPrefix} loading finished for ${filePath}`);
}
storage.loaded = true;
});

675
yarn.lock

File diff suppressed because it is too large Load Diff