mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
commit
76f9f9ffaa
@ -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",
|
||||
|
||||
@ -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();
|
||||
|
||||
101
src/common/__tests__/kube-helpers.test.ts
Normal file
101
src/common/__tests__/kube-helpers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./ipc";
|
||||
export * from "./invalid-kubeconfig";
|
||||
export * from "./update-available";
|
||||
export * from "./type-enforced-ipc";
|
||||
|
||||
3
src/common/ipc/invalid-kubeconfig/index.ts
Normal file
3
src/common/ipc/invalid-kubeconfig/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const InvalidKubeconfigChannel = "invalid-kubeconfig";
|
||||
|
||||
export type InvalidKubeConfigArgs = [clusterId: string];
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1,3 +1,2 @@
|
||||
export * from "./pod-security-policies.route";
|
||||
export * from "./pod-security-policies";
|
||||
export * from "./pod-security-policy-details";
|
||||
|
||||
@ -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);
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
.PodDetailsSecrets {
|
||||
a {
|
||||
> * {
|
||||
display: block;
|
||||
margin-bottom: $margin;
|
||||
margin-bottom: var(--margin);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
46
src/renderer/ipc/invalid-kubeconfig-handler.tsx
Normal file
46
src/renderer/ipc/invalid-kubeconfig-handler.tsx
Normal 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
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user