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

Merge pull request #2274 from lensapp/release/v4.1.4

Release v4.1.4
This commit is contained in:
Jari Kolehmainen 2021-03-04 18:49:18 +02:00 committed by GitHub
commit 76f9f9ffaa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 441 additions and 233 deletions

View File

@ -2,7 +2,7 @@
"name": "kontena-lens",
"productName": "Lens",
"description": "Lens - The Kubernetes IDE",
"version": "4.1.3",
"version": "4.1.4",
"main": "static/build/main.js",
"copyright": "© 2020, Mirantis, Inc.",
"license": "MIT",

View File

@ -6,6 +6,29 @@ import { ClusterStore, getClusterIdFromHost } from "../cluster-store";
import { workspaceStore } from "../workspace-store";
const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png");
const kubeconfig = `
apiVersion: v1
clusters:
- cluster:
server: https://localhost
name: test
contexts:
- context:
cluster: test
user: test
name: foo
- context:
cluster: test
user: test
name: foo2
current-context: test
kind: Config
preferences: {}
users:
- name: test
user:
token: kubeconfig-user-q4lm4:xxxyyyy
`;
jest.mock("electron", () => {
return {
@ -47,13 +70,13 @@ describe("empty config", () => {
clusterStore.addCluster(
new Cluster({
id: "foo",
contextName: "minikube",
contextName: "foo",
preferences: {
terminalCWD: "/tmp",
icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5",
clusterName: "minikube"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", "fancy foo config"),
kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig),
workspace: workspaceStore.currentWorkspaceId
})
);
@ -91,20 +114,20 @@ describe("empty config", () => {
clusterStore.addClusters(
new Cluster({
id: "prod",
contextName: "prod",
contextName: "foo",
preferences: {
clusterName: "prod"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", "fancy config"),
kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig),
workspace: "workstation"
}),
new Cluster({
id: "dev",
contextName: "dev",
contextName: "foo2",
preferences: {
clusterName: "dev"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", "fancy config"),
kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig),
workspace: "workstation"
})
);
@ -177,20 +200,20 @@ describe("config with existing clusters", () => {
clusters: [
{
id: "cluster1",
kubeConfig: "foo",
kubeConfigPath: kubeconfig,
contextName: "foo",
preferences: { terminalCWD: "/foo" },
workspace: "default"
},
{
id: "cluster2",
kubeConfig: "foo2",
kubeConfigPath: kubeconfig,
contextName: "foo2",
preferences: { terminalCWD: "/foo2" }
},
{
id: "cluster3",
kubeConfig: "foo",
kubeConfigPath: kubeconfig,
contextName: "foo",
preferences: { terminalCWD: "/foo" },
workspace: "foo",
@ -247,6 +270,78 @@ describe("config with existing clusters", () => {
});
});
describe("config with invalid cluster kubeconfig", () => {
beforeEach(() => {
const invalidKubeconfig = `
apiVersion: v1
clusters:
- cluster:
server: https://localhost
name: test2
contexts:
- context:
cluster: test
user: test
name: test
current-context: test
kind: Config
preferences: {}
users:
- name: test
user:
token: kubeconfig-user-q4lm4:xxxyyyy
`;
ClusterStore.resetInstance();
const mockOpts = {
"tmp": {
"lens-cluster-store.json": JSON.stringify({
__internal__: {
migrations: {
version: "99.99.99"
}
},
clusters: [
{
id: "cluster1",
kubeConfigPath: invalidKubeconfig,
contextName: "test",
preferences: { terminalCWD: "/foo" },
workspace: "foo",
},
{
id: "cluster2",
kubeConfigPath: kubeconfig,
contextName: "foo",
preferences: { terminalCWD: "/foo" },
workspace: "default"
},
]
})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
afterEach(() => {
mockFs.restore();
});
it("does not enable clusters with invalid kubeconfig", () => {
const storedClusters = clusterStore.clustersList;
expect(storedClusters.length).toBe(2);
expect(storedClusters[0].enabled).toBeFalsy;
expect(storedClusters[1].id).toBe("cluster2");
expect(storedClusters[1].enabled).toBeTruthy;
});
});
describe("pre 2.0 config with an existing cluster", () => {
beforeEach(() => {
ClusterStore.resetInstance();

View File

@ -0,0 +1,101 @@
import { KubeConfig } from "@kubernetes/client-node";
import { validateKubeConfig } from "../kube-helpers";
const kubeconfig = `
apiVersion: v1
clusters:
- cluster:
server: https://localhost
name: test
contexts:
- context:
cluster: test
user: test
name: valid
- context:
cluster: test2
user: test
name: invalidCluster
- context:
cluster: test
user: test2
name: invalidUser
- context:
cluster: test
user: invalidExec
name: invalidExec
current-context: test
kind: Config
preferences: {}
users:
- name: test
user:
exec:
command: echo
- name: invalidExec
user:
exec:
command: foo
`;
const kc = new KubeConfig();
describe("validateKubeconfig", () => {
beforeAll(() => {
kc.loadFromString(kubeconfig);
});
describe("with default validation options", () => {
describe("with valid kubeconfig", () => {
it("does not raise exceptions", () => {
expect(() => { validateKubeConfig(kc, "valid");}).not.toThrow();
});
});
describe("with invalid context object", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalid");}).toThrow("No valid context object provided in kubeconfig for context 'invalid'");
});
});
describe("with invalid cluster object", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalidCluster");}).toThrow("No valid cluster object provided in kubeconfig for context 'invalidCluster'");
});
});
describe("with invalid user object", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalidUser");}).toThrow("No valid user object provided in kubeconfig for context 'invalidUser'");
});
});
describe("with invalid exec command", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalidExec");}).toThrow("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 raise exception", () => {
expect(() => { validateKubeConfig(kc, "invalidCluster", { validateCluster: false });}).not.toThrow();
});
});
});
describe("with validateUser as false", () => {
describe("with invalid user object", () => {
it("does not raise excpetions", () => {
expect(() => { validateKubeConfig(kc, "invalidUser", { validateUser: false });}).not.toThrow();
});
});
});
describe("with validateExec as false", () => {
describe("with invalid exec object", () => {
it("does not raise excpetions", () => {
expect(() => { validateKubeConfig(kc, "invalidExec", { validateExec: false });}).not.toThrow();
});
});
});
});

View File

@ -323,7 +323,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
} else {
cluster = new Cluster(clusterModel);
if (!cluster.isManaged) {
if (!cluster.isManaged && cluster.apiUrl) {
cluster.enabled = true;
}
}
@ -337,7 +337,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
}
});
this.activeCluster = newClusters.has(activeCluster) ? activeCluster : null;
this.activeCluster = newClusters.get(activeCluster)?.enabled ? activeCluster : null;
this.clusters.replace(newClusters);
this.removedClusters.replace(removedClusters);
}

View File

@ -1,3 +1,4 @@
export * from "./ipc";
export * from "./invalid-kubeconfig";
export * from "./update-available";
export * from "./type-enforced-ipc";

View File

@ -0,0 +1,3 @@
export const InvalidKubeconfigChannel = "invalid-kubeconfig";
export type InvalidKubeConfigArgs = [clusterId: string];

View File

@ -7,6 +7,12 @@ import logger from "../main/logger";
import commandExists from "command-exists";
import { ExecValidationNotFoundError } from "./custom-errors";
export type KubeConfigValidationOpts = {
validateCluster?: boolean;
validateUser?: boolean;
validateExec?: boolean;
};
export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config");
function resolveTilde(filePath: string) {
@ -151,27 +157,42 @@ export function getNodeWarningConditions(node: V1Node) {
}
/**
* Validates kubeconfig supplied in the add clusters screen. At present this will just validate
* the User struct, specifically the command passed to the exec substructure.
* Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required)
*/
export function validateKubeConfig (config: KubeConfig) {
export function validateKubeConfig (config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}) {
// 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
logger.debug(`validateKubeConfig: validating kubeconfig - ${JSON.stringify(config)}`);
const { validateUser = true, validateCluster = true, validateExec = true } = validationOpts;
const contextObject = config.getContextObject(contextName);
// Validate the Context Object
if (!contextObject) {
throw new Error(`No valid context object provided in kubeconfig for context '${contextName}'`);
}
// Validate the Cluster Object
if (validateCluster && !config.getCluster(contextObject.cluster)) {
throw new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`);
}
const user = config.getUser(contextObject.user);
// Validate the User Object
const user = config.getCurrentUser();
if (validateUser && !user) {
throw new Error(`No valid user object provided in kubeconfig for context '${contextName}'`);
}
if (user.exec) {
// 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
logger.debug(`validateKubeConfig: validating user exec command - ${JSON.stringify(execCommand)}`);
if (!commandExists.sync(execCommand)) {
logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${config.currentContext} not found`);
logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${contextName} not found`);
throw new ExecValidationNotFoundError(execCommand, isAbsolute);
}
}

View File

@ -31,7 +31,7 @@ export const apiResources: KubeApiResource[] = [
{ kind: "PersistentVolume", apiName: "persistentvolumes" },
{ kind: "PersistentVolumeClaim", apiName: "persistentvolumeclaims" },
{ kind: "Pod", apiName: "pods" },
{ kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets" },
{ kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets", group: "policy" },
{ kind: "PodSecurityPolicy", apiName: "podsecuritypolicies" },
{ kind: "ResourceQuota", apiName: "resourcequotas" },
{ kind: "ReplicaSet", apiName: "replicasets", group: "apps" },

View File

@ -4,12 +4,12 @@ import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
import type { WorkspaceId } from "../common/workspace-store";
import { action, comparer, computed, observable, reaction, toJS, when } from "mobx";
import { apiKubePrefix } from "../common/vars";
import { broadcastMessage } from "../common/ipc";
import { broadcastMessage, InvalidKubeconfigChannel } from "../common/ipc";
import { ContextHandler } from "./context-handler";
import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node";
import { Kubectl } from "./kubectl";
import { KubeconfigManager } from "./kubeconfig-manager";
import { loadConfig } from "../common/kube-helpers";
import { loadConfig, validateKubeConfig } from "../common/kube-helpers";
import request, { RequestPromiseOptions } from "request-promise-native";
import { apiResources, KubeApiResource } from "../common/rbac";
import logger from "./logger";
@ -177,6 +177,7 @@ export class Cluster implements ClusterModel, ClusterState {
* @observable
*/
@observable isAdmin = false;
/**
* Global watch-api accessibility , e.g. "/api/v1/services?watch=1"
*
@ -256,10 +257,16 @@ export class Cluster implements ClusterModel, ClusterState {
constructor(model: ClusterModel) {
this.updateModel(model);
try {
const kubeconfig = this.getKubeconfig();
if (kubeconfig.getContextObject(this.contextName)) {
validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false});
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server;
} catch(err) {
logger.error(err);
logger.error(`[CLUSTER] Failed to load kubeconfig for the cluster '${this.name || this.contextName}' (context: ${this.contextName}, kubeconfig: ${this.kubeConfigPath}).`);
broadcastMessage(InvalidKubeconfigChannel, model.id);
}
}

View File

@ -120,12 +120,18 @@ export class LensProxy {
protected createProxy(): httpProxy {
const proxy = httpProxy.createProxyServer();
proxy.on("proxyRes", (proxyRes, req) => {
proxy.on("proxyRes", (proxyRes, req, res) => {
const retryCounterId = this.getRequestId(req);
if (this.retryCounters.has(retryCounterId)) {
this.retryCounters.delete(retryCounterId);
}
if (!res.headersSent && req.url) {
const url = new URL(req.url, "http://localhost");
if (url.searchParams.has("watch")) res.flushHeaders();
}
});
proxy.on("error", (error, req, res, target) => {

View File

@ -110,6 +110,14 @@ export class ShellSession extends EventEmitter {
env["SystemRoot"] = process.env.SystemRoot;
env["PTYSHELL"] = process.env.SHELL || "powershell.exe";
env["PATH"] = pathStr;
env["LENS_SESSION"] = "true";
const lensWslEnv = "KUBECONFIG/up:LENS_SESSION/u";
if (process.env.WSLENV != undefined) {
env["WSLENV"] = `${process.env["WSLENV"]}:${lensWslEnv}`;
} else {
env["WSLENV"] = lensWslEnv;
}
} else if(typeof(process.env.SHELL) != "undefined") {
env["PTYSHELL"] = process.env.SHELL;
env["PATH"] = pathStr;

View File

@ -187,7 +187,7 @@ export class HelmRelease implements ItemObject {
}
getVersion() {
const versions = this.chart.match(/(v?\d+)[^-].*$/);
const versions = this.chart.match(/(?<=-)(v?\d+)[^-].*$/);
if (versions) {
return versions[0];

View File

@ -147,7 +147,7 @@ export class AddCluster extends React.Component {
try {
const kubeConfig = this.kubeContexts.get(context);
validateKubeConfig(kubeConfig);
validateKubeConfig(kubeConfig, context);
return true;
} catch (err) {

View File

@ -1,12 +1,21 @@
import { RouteProps } from "react-router";
import { Config } from "./config";
import { IURLParams } from "../../../common/utils/buildUrl";
import { configMapsURL } from "../+config-maps/config-maps.route";
import { configMapsRoute, configMapsURL } from "../+config-maps/config-maps.route";
import { hpaRoute } from "../+config-autoscalers";
import { limitRangesRoute } from "../+config-limit-ranges";
import { pdbRoute } from "../+config-pod-disruption-budgets";
import { resourceQuotaRoute } from "../+config-resource-quotas";
import { secretsRoute } from "../+config-secrets";
export const configRoute: RouteProps = {
get path() {
return Config.tabRoutes.map(({ routePath }) => routePath).flat();
}
path: [
configMapsRoute,
secretsRoute,
resourceQuotaRoute,
limitRangesRoute,
hpaRoute,
pdbRoute
].map(route => route.path.toString())
};
export const configURL = (params?: IURLParams) => configMapsURL(params);

View File

@ -1,12 +1,17 @@
import { RouteProps } from "react-router";
import { Network } from "./network";
import { servicesURL } from "../+network-services";
import { endpointRoute } from "../+network-endpoints";
import { ingressRoute } from "../+network-ingresses";
import { networkPoliciesRoute } from "../+network-policies";
import { servicesRoute, servicesURL } from "../+network-services";
import { IURLParams } from "../../../common/utils/buildUrl";
export const networkRoute: RouteProps = {
get path() {
return Network.tabRoutes.map(({ routePath }) => routePath).flat();
}
path: [
servicesRoute,
endpointRoute,
ingressRoute,
networkPoliciesRoute
].map(route => route.path.toString())
};
export const networkURL = (params?: IURLParams) => servicesURL(params);

View File

@ -1,3 +1,2 @@
export * from "./pod-security-policies.route";
export * from "./pod-security-policies";
export * from "./pod-security-policy-details";

View File

@ -1,8 +0,0 @@
import type { RouteProps } from "react-router";
import { buildURL } from "../../../common/utils/buildUrl";
export const podSecurityPoliciesRoute: RouteProps = {
path: "/pod-security-policies"
};
export const podSecurityPoliciesURL = buildURL(podSecurityPoliciesRoute.path);

View File

@ -1,12 +1,15 @@
import { RouteProps } from "react-router";
import { volumeClaimsURL } from "../+storage-volume-claims";
import { Storage } from "./storage";
import { storageClassesRoute } from "../+storage-classes";
import { volumeClaimsRoute, volumeClaimsURL } from "../+storage-volume-claims";
import { volumesRoute } from "../+storage-volumes";
import { IURLParams } from "../../../common/utils/buildUrl";
export const storageRoute: RouteProps = {
get path() {
return Storage.tabRoutes.map(({ routePath }) => routePath).flat();
}
path: [
volumeClaimsRoute,
volumesRoute,
storageClassesRoute
].map(route => route.path.toString())
};
export const storageURL = (params?: IURLParams) => volumeClaimsURL(params);

View File

@ -1,12 +1,5 @@
import type { RouteProps } from "react-router";
import { buildURL, IURLParams } from "../../../common/utils/buildUrl";
import { UserManagement } from "./user-management";
export const usersManagementRoute: RouteProps = {
get path() {
return UserManagement.tabRoutes.map(({ routePath }) => routePath).flat();
}
};
// Routes
export const serviceAccountsRoute: RouteProps = {
@ -18,6 +11,18 @@ export const rolesRoute: RouteProps = {
export const roleBindingsRoute: RouteProps = {
path: "/role-bindings"
};
export const podSecurityPoliciesRoute: RouteProps = {
path: "/pod-security-policies"
};
export const usersManagementRoute: RouteProps = {
path: [
serviceAccountsRoute,
roleBindingsRoute,
rolesRoute,
podSecurityPoliciesRoute
].map(route => route.path.toString())
};
// Route params
export interface IServiceAccountsRouteParams {
@ -34,3 +39,4 @@ export const usersManagementURL = (params?: IURLParams) => serviceAccountsURL(pa
export const serviceAccountsURL = buildURL<IServiceAccountsRouteParams>(serviceAccountsRoute.path);
export const roleBindingsURL = buildURL<IRoleBindingsRouteParams>(roleBindingsRoute.path);
export const rolesURL = buildURL<IRoleBindingsRouteParams>(rolesRoute.path);
export const podSecurityPoliciesURL = buildURL(podSecurityPoliciesRoute.path);

View File

@ -5,9 +5,9 @@ import { TabLayout, TabLayoutRoute } from "../layout/tab-layout";
import { Roles } from "../+user-management-roles";
import { RoleBindings } from "../+user-management-roles-bindings";
import { ServiceAccounts } from "../+user-management-service-accounts";
import { roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route";
import { podSecurityPoliciesRoute, podSecurityPoliciesURL, roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route";
import { namespaceUrlParam } from "../+namespaces/namespace.store";
import { PodSecurityPolicies, podSecurityPoliciesRoute, podSecurityPoliciesURL } from "../+pod-security-policies";
import { PodSecurityPolicies } from "../+pod-security-policies";
import { isAllowedResource } from "../../../common/rbac";
@observer

View File

@ -1,7 +1,7 @@
.PodDetailsSecrets {
a {
> * {
display: block;
margin-bottom: $margin;
margin-bottom: var(--margin);
&:last-child {
margin-bottom: 0;

View File

@ -13,33 +13,49 @@ interface Props {
@observer
export class PodDetailsSecrets extends Component<Props> {
@observable secrets: Secret[] = [];
@observable secrets: Map<string, Secret> = observable.map<string, Secret>();
@disposeOnUnmount
secretsLoader = autorun(async () => {
const { pod } = this.props;
this.secrets = await Promise.all(
const secrets = await Promise.all(
pod.getSecrets().map(secretName => secretsApi.get({
name: secretName,
namespace: pod.getNs(),
}))
);
secrets.forEach(secret => secret && this.secrets.set(secret.getName(), secret));
});
render() {
const { pod } = this.props;
return (
<div className="PodDetailsSecrets">
{
this.secrets.map(secret => {
pod.getSecrets().map(secretName => {
const secret = this.secrets.get(secretName);
if (secret) {
return this.renderSecretLink(secret);
} else {
return (
<Link key={secret.getId()} to={getDetailsUrl(secret.selfLink)}>
{secret.getName()}
</Link>
<span key={secretName}>{secretName}</span>
);
}
})
}
</div>
);
}
protected renderSecretLink(secret: Secret) {
return (
<Link key={secret.getId()} to={getDetailsUrl(secret.selfLink)}>
{secret.getName()}
</Link>
);
}
}

View File

@ -1,13 +1,6 @@
import type { RouteProps } from "react-router";
import { buildURL, IURLParams } from "../../../common/utils/buildUrl";
import { KubeResource } from "../../../common/rbac";
import { Workloads } from "./workloads";
export const workloadsRoute: RouteProps = {
get path() {
return Workloads.tabRoutes.map(({ routePath }) => routePath).flat();
}
};
// Routes
export const overviewRoute: RouteProps = {
@ -35,6 +28,19 @@ export const cronJobsRoute: RouteProps = {
path: "/cronjobs"
};
export const workloadsRoute: RouteProps = {
path: [
overviewRoute,
podsRoute,
deploymentsRoute,
daemonSetsRoute,
statefulSetsRoute,
replicaSetsRoute,
jobsRoute,
cronJobsRoute
].map(route => route.path.toString())
};
// Route params
export interface IWorkloadsOverviewRouteParams {
}

View File

@ -91,27 +91,15 @@ export class App extends React.Component {
reaction(() => this.warningsTotal, (count: number) => {
broadcastMessage(`cluster-warning-event-count:${getHostedCluster().id}`, count);
}),
reaction(() => clusterPageMenuRegistry.getRootItems(), (rootItems) => {
this.generateExtensionTabLayoutRoutes(rootItems);
}, {
fireImmediately: true
})
]);
}
@observable startUrl = isAllowedResource(["events", "nodes", "pods"]) ? clusterURL() : workloadsURL();
@computed get warningsTotal(): number {
return nodesStore.getWarningsCount() + eventStore.getWarningsCount();
}
get startURL() {
if (isAllowedResource(["events", "nodes", "pods"])) {
return clusterURL();
}
return workloadsURL();
}
getTabLayoutRoutes(menuItem: ClusterPageMenuRegistration) {
const routes: TabLayoutRoute[] = [];
@ -152,38 +140,6 @@ export class App extends React.Component {
});
}
@observable extensionRoutes: Map<ClusterPageMenuRegistration, React.ReactNode> = new Map();
generateExtensionTabLayoutRoutes(rootItems: ClusterPageMenuRegistration[]) {
rootItems.forEach((menu, index) => {
let route = this.extensionRoutes.get(menu);
if (!route) {
const tabRoutes = this.getTabLayoutRoutes(menu);
if (tabRoutes.length > 0) {
const pageComponent = () => <TabLayout tabs={tabRoutes}/>;
route = <Route key={`extension-tab-layout-route-${index}`} component={pageComponent} path={tabRoutes.map((tab) => tab.routePath)}/>;
this.extensionRoutes.set(menu, route);
} else {
const page = clusterPageRegistry.getByPageTarget(menu.target);
if (page) {
route = <Route key={`extension-tab-layout-route-${index}`} path={page.url} component={page.components.Page}/>;
this.extensionRoutes.set(menu, route);
}
}
}
});
for (const menu of this.extensionRoutes.keys()) {
if (!rootItems.includes(menu)) {
this.extensionRoutes.delete(menu);
}
}
}
renderExtensionRoutes() {
return clusterPageRegistry.getItems().map((page, index) => {
const menu = clusterPageMenuRegistry.getByPage(page);
@ -195,8 +151,6 @@ export class App extends React.Component {
}
render() {
const cluster = getHostedCluster();
return (
<Router history={history}>
<ErrorBoundary>
@ -215,7 +169,7 @@ export class App extends React.Component {
<Route component={Apps} {...appsRoute}/>
{this.renderExtensionTabLayoutRoutes()}
{this.renderExtensionRoutes()}
<Redirect exact from="/" to={this.startURL}/>
<Redirect exact from="/" to={this.startUrl}/>
<Route component={NotFound}/>
</Switch>
</MainLayout>
@ -228,7 +182,7 @@ export class App extends React.Component {
<StatefulSetScaleDialog/>
<ReplicaSetScaleDialog/>
<CronJobTriggerDialog/>
<CommandContainer cluster={cluster}/>
<CommandContainer clusterId={getHostedCluster()?.id}/>
</ErrorBoundary>
</Router>
);

View File

@ -1,42 +0,0 @@
import ChartJS from "chart.js";
import get from "lodash/get";
const defaultOptions = {
interval: 61,
coverBars: 3,
borderColor: "#44474A",
backgroundColor: "#00000033"
};
export const BackgroundBlock = {
options: {},
getOptions(chart: ChartJS) {
return get(chart, "options.plugins.BackgroundBlock");
},
afterInit(chart: ChartJS) {
this.options = {
...defaultOptions,
...this.getOptions(chart)
};
},
beforeDraw(chart: ChartJS) {
if (!chart.chartArea) return;
const { interval, coverBars, borderColor, backgroundColor } = this.options;
const { ctx, chartArea } = chart;
const { left, right, top, bottom } = chartArea;
const blockWidth = (right - left) / interval * coverBars;
ctx.save();
ctx.fillStyle = backgroundColor;
ctx.strokeStyle = borderColor;
ctx.fillRect(right - blockWidth, top, blockWidth, bottom - top);
ctx.beginPath();
ctx.moveTo(right - blockWidth + 1.5, top);
ctx.lineTo(right - blockWidth + 1.5, bottom);
ctx.stroke();
ctx.restore();
}
};

View File

@ -1,45 +0,0 @@
import moment from "moment";
import { useState, useEffect } from "react";
import { useInterval } from "../../hooks";
type IMetricValues = [number, string][];
type IChartData = { x: number; y: string }[];
const defaultParams = {
fetchInterval: 15,
updateInterval: 5
};
export function useRealTimeMetrics(metrics: IMetricValues, chartData: IChartData, params = defaultParams) {
const [index, setIndex] = useState(0);
const { fetchInterval, updateInterval } = params;
const rangeMetrics = metrics.slice(-updateInterval);
const steps = fetchInterval / updateInterval;
const data = [...chartData];
useEffect(() => {
setIndex(0);
}, [metrics]);
useInterval(() => {
if (index < steps + 1) {
setIndex(index + steps - 1);
}
}, updateInterval * 1000);
if (data.length && metrics.length) {
const lastTime = data[data.length - 1].x;
const values = [];
for (let i = 0; i < 3; i++) {
values[i] = moment.unix(lastTime).add(i + 1, "m").unix();
}
data.push(
{ x: values[0], y: "0" },
{ x: values[1], y: parseFloat(rangeMetrics[index][1]).toFixed(3) },
{ x: values[2], y: "0" }
);
}
return data;
}

View File

@ -10,7 +10,6 @@ import { CommandDialog } from "./command-dialog";
import { CommandRegistration, commandRegistry } from "../../../extensions/registries/command-registry";
import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
import { Cluster } from "../../../main/cluster";
export type CommandDialogEvent = {
component: React.ReactElement
@ -29,7 +28,7 @@ export class CommandOverlay {
}
@observer
export class CommandContainer extends React.Component<{cluster?: Cluster}> {
export class CommandContainer extends React.Component<{ clusterId?: string }> {
@observable.ref commandComponent: React.ReactElement;
private escHandler(event: KeyboardEvent) {
@ -56,8 +55,8 @@ export class CommandContainer extends React.Component<{cluster?: Cluster}> {
}
componentDidMount() {
if (this.props.cluster) {
subscribeToBroadcast(`command-palette:run-action:${this.props.cluster.id}`, (event, commandId: string) => {
if (this.props.clusterId) {
subscribeToBroadcast(`command-palette:run-action:${this.props.clusterId}`, (event, commandId: string) => {
const command = this.findCommandById(commandId);
if (command) {

View File

@ -52,7 +52,7 @@ export class CreateResource extends React.Component<Props> {
);
if (errors.length) {
errors.forEach(Notifications.error);
errors.forEach(error => Notifications.error(error));
if (!createdResources.length) throw errors[0];
}
const successMessage = (

View File

@ -89,7 +89,8 @@ const defaultProps: Partial<ItemListLayoutProps> = {
filterItems: [],
hasDetailsView: true,
onDetails: noop,
virtual: true
virtual: true,
customizeTableRowProps: () => ({} as TableRowProps),
};
interface ItemListLayoutUserSettings {
@ -241,7 +242,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
sortItem={item}
selected={detailsItem && detailsItem.getId() === itemId}
onClick={hasDetailsView ? prevDefault(() => onDetails(item)) : undefined}
{...(customizeTableRowProps ? customizeTableRowProps(item) : {})}
{...customizeTableRowProps(item)}
>
{isSelectable && (
<TableCell
@ -392,19 +393,21 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
}
renderTableHeader() {
const { renderTableHeader, isSelectable, isConfigurable, store } = this.props;
const { customizeTableRowProps, renderTableHeader, isSelectable, isConfigurable, store } = this.props;
if (!renderTableHeader) {
return;
}
const enabledItems = this.items.filter(item => !customizeTableRowProps(item).disabled);
return (
<TableHead showTopLine nowrap>
{isSelectable && (
<TableCell
checkbox
isChecked={store.isSelectedAll(this.items)}
onClick={prevDefault(() => store.toggleSelectionAll(this.items))}
isChecked={store.isSelectedAll(enabledItems)}
onClick={prevDefault(() => store.toggleSelectionAll(enabledItems))}
/>
)}
{renderTableHeader.map((cellProps, index) => {

View File

@ -1,3 +1,4 @@
import { observer } from "mobx-react";
import React from "react";
import { clusterSettingsURL } from "../+cluster-settings";
@ -11,7 +12,7 @@ interface Props {
className?: string
}
export function MainLayoutHeader({ cluster, className }: Props) {
export const MainLayoutHeader = observer(({ cluster, className }: Props) => {
return (
<header className={cssNames("flex gaps align-center justify-space-between", className)}>
<span className="cluster">{cluster.name}</span>
@ -29,4 +30,4 @@ export function MainLayoutHeader({ cluster, className }: Props) {
/>
</header>
);
}
});

View File

@ -21,11 +21,12 @@ export class Notifications extends React.Component {
});
}
static error(message: NotificationMessage) {
static error(message: NotificationMessage, customOpts: Partial<Notification> = {}) {
notificationsStore.add({
message,
timeout: 10000,
status: NotificationStatus.ERROR
status: NotificationStatus.ERROR,
...customOpts
});
}

View File

@ -5,6 +5,7 @@ import { Notifications, notificationsStore } from "../components/notifications";
import { Button } from "../components/button";
import { isMac } from "../../common/vars";
import * as uuid from "uuid";
import { invalidKubeconfigHandler } from "./invalid-kubeconfig-handler";
function sendToBackchannel(backchannel: string, notificationId: string, data: BackchannelArg): void {
notificationsStore.remove(notificationId);
@ -58,4 +59,5 @@ export function registerIpcHandlers() {
listener: UpdateAvailableHandler,
verifier: areArgsUpdateAvailableFromMain,
});
onCorrect(invalidKubeconfigHandler);
}

View File

@ -0,0 +1,46 @@
import React from "react";
import { ipcRenderer, IpcRendererEvent, shell } from "electron";
import { clusterStore } from "../../common/cluster-store";
import { InvalidKubeConfigArgs, InvalidKubeconfigChannel } from "../../common/ipc/invalid-kubeconfig";
import { Notifications, notificationsStore } from "../components/notifications";
import { Button } from "../components/button";
export const invalidKubeconfigHandler = {
source: ipcRenderer,
channel: InvalidKubeconfigChannel,
listener: InvalidKubeconfigListener,
verifier: (args: [unknown]): args is InvalidKubeConfigArgs => {
return args.length === 1 && typeof args[0] === "string" && !!clusterStore.getById(args[0]);
},
};
function InvalidKubeconfigListener(event: IpcRendererEvent, ...[clusterId]: InvalidKubeConfigArgs): void {
const notificationId = `invalid-kubeconfig:${clusterId}`;
const cluster = clusterStore.getById(clusterId);
const contextName = cluster.name !== cluster.contextName ? `(context: ${cluster.contextName})` : "";
Notifications.error(
(
<div className="flex column gaps">
<b>Cluster with Invalid Kubeconfig Detected!</b>
<p>Cluster <b>{cluster.name}</b> has invalid kubeconfig {contextName} and cannot be displayed.
Please fix the <a href="#" onClick={(e) => { e.preventDefault(); shell.showItemInFolder(cluster.kubeConfigPath); }}>kubeconfig</a> manually and restart Lens
or remove the cluster.</p>
<p>Do you want to remove the cluster now?</p>
<div className="flex gaps row align-left box grow">
<Button active outlined label="Remove" onClick={()=> {
clusterStore.removeById(clusterId);
notificationsStore.remove(notificationId);
}} />
<Button active outlined label="Cancel" onClick={() => notificationsStore.remove(notificationId)} />
</div>
</div>
),
{
id: notificationId,
timeout: 0
}
);
}

View File

@ -2,7 +2,18 @@
Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights!
## 4.1.3 (current version)
## 4.1.4 (current version)
- Ignore clusters with invalid kubeconfig
- Render only secret name on pod details without access to secrets
- Pass Lens wslenvs to terminal session on Windows
- Prevent top-level re-rendering on cluster refresh
- Extract chart version ignoring numbers in chart name
- The select all checkbox should not select disabled items
- Fix: Pdb should have policy group
- Fix: kubectl rollout not exiting properly on Lens terminal
## 4.1.3
- Don't reset selected namespaces to defaults in case of "All namespaces" on page reload
- Fix loading all namespaces for users with limited cluster access