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

mark uncreatable Cluster objects in cluster-store as dead

- Display a special error for each of the currently 2 error types from
  loading the kubeconfig

- Special handling of dead clusters in proxy handler so that there are
  no unhandled promise rejections

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2020-11-18 17:03:18 -05:00
parent 238756be72
commit b3a01fac2f
23 changed files with 424 additions and 210 deletions

View File

@ -21,9 +21,7 @@ module.exports = {
sourceType: 'module',
},
rules: {
"indent": ["error", 2, {
"SwitchCase": 1,
}],
"indent": ["error", 2],
"no-unused-vars": "off",
"semi": ["error", "always"],
"object-shorthand": "error",

View File

@ -9,7 +9,7 @@ import { comparer } from "mobx";
export class Tracker extends Util.Singleton {
static readonly GA_ID = "UA-159377374-1";
static readonly SEGMENT_KEY = "YENwswyhlOgz8P7EFKUtIZ2MfON7Yxqb";
protected eventHandlers: Array<(ev: EventBus.AppEvent ) => void> = [];
protected eventHandlers: Array<(ev: EventBus.AppEvent) => void> = [];
protected started = false;
protected visitor: ua.Visitor;
protected analytics: Analytics;
@ -124,30 +124,28 @@ export class Tracker extends Util.Singleton {
}
protected resolveOS() {
let os = "";
if (App.isMac) {
os = "MacOS";
} else if(App.isWindows) {
os = "Windows";
} else if (App.isLinux) {
os = "Linux";
if (App.isSnap) {
os += "; Snap";
} else {
os += "; AppImage";
}
} else {
os = "Unknown";
return "MacOS";
}
return os;
if (App.isWindows) {
return "Windows";
}
if (App.isLinux) {
return `Linux; ${App.isSnap ? "Snap" : "AppImage"}`;
}
return "Unknown";
}
protected async event(eventCategory: string, eventAction: string, otherParams = {}) {
const allowed = await this.isTelemetryAllowed();
if (!allowed) {
return;
}
try {
const allowed = await this.isTelemetryAllowed();
if (!allowed) {
return;
}
this.visitor.event({
ec: eventCategory,
ea: eventAction,

View File

@ -8,13 +8,14 @@ import { Cluster, ClusterState } from "../main/cluster";
import migrations from "../migrations/cluster-store";
import logger from "../main/logger";
import { appEventBus } from "./event-bus";
import { dumpConfigYaml } from "./kube-helpers";
import { dumpConfigYaml, LoadKubeError } from "./kube-helpers";
import { saveToAppFiles } from "./utils/saveToAppFiles";
import { KubeConfig } from "@kubernetes/client-node";
import { subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc";
import _ from "lodash";
import move from "array-move";
import type { WorkspaceId } from "./workspace-store";
import { notificationsStore, NotificationStatus } from "./notifications.store";
export interface ClusterIconUpload {
clusterId: string;
@ -73,6 +74,14 @@ export interface ClusterPrometheusPreferences {
};
}
export interface ClusterRenderInfo extends ClusterModel {
DeadError?: LoadKubeError; // this != undefined => dead
isAdmin: boolean;
name: string;
eventCount: number;
online: boolean;
}
export class ClusterStore extends BaseStore<ClusterStoreModel> {
static getCustomKubeConfigPath(clusterId: ClusterId): string {
return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs", clusterId);
@ -88,6 +97,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
@observable activeCluster: ClusterId;
@observable removedClusters = observable.map<ClusterId, Cluster>();
@observable clusters = observable.map<ClusterId, Cluster>();
@observable deadClusters = observable.map<ClusterId, [ClusterModel, LoadKubeError]>();
private constructor() {
super({
@ -137,6 +147,10 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return Array.from(this.clusters.values());
}
@computed get deadClustersList(): [ClusterModel, LoadKubeError][] {
return Array.from(this.deadClusters.values());
}
@computed get enabledClustersList(): Cluster[] {
return this.clustersList.filter((c) => c.enabled);
}
@ -182,10 +196,32 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return this.clusters.get(id);
}
getByWorkspaceId(workspaceId: string): Cluster[] {
const clusters = Array.from(this.clusters.values())
.filter(cluster => cluster.workspace === workspaceId);
return _.sortBy(clusters, cluster => cluster.preferences.iconOrder);
getDeadById(id: ClusterId): [ClusterModel, LoadKubeError] {
return this.deadClusters.get(id);
}
getByWorkspaceId(workspaceId: string): ClusterRenderInfo[] {
const aliveClusters: ClusterRenderInfo[] = this.clustersList.filter(c => c.workspace === workspaceId);
const deadClusters: ClusterRenderInfo[] = this.deadClustersList
.filter(([c]) => c.workspace === workspaceId)
.map(([cluster, error]) => ({
DeadError: error,
name: cluster.contextName,
isAdmin: false,
eventCount: 0,
online: false,
...cluster,
}));
return _.sortBy([...aliveClusters, ...deadClusters], c => c.preferences?.iconOrder);
}
getCountInWorkspace(workspaceId: string): number {
const aliveCount = this.clustersList.filter(c => c.workspace === workspaceId).length;
const deadCount = this.deadClustersList.filter(([c]) => c.workspace === workspaceId).length;
return aliveCount + deadCount;
}
@action
@ -241,6 +277,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
const currentClusters = this.clusters.toJS();
const newClusters = new Map<ClusterId, Cluster>();
const removedClusters = new Map<ClusterId, Cluster>();
const deadClusters = new Map<ClusterId, [ClusterModel, LoadKubeError]>();
// update new clusters
for (const clusterModel of clusters) {
@ -248,7 +285,29 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
if (cluster) {
cluster.updateModel(clusterModel);
} else {
cluster = new Cluster(clusterModel);
try {
cluster = new Cluster(clusterModel);
} catch (err) {
if (err instanceof LoadKubeError) {
deadClusters.set(clusterModel.id, [clusterModel, err]);
logger.error(`[CLUSTER-STORE]: marking cluster as dead`, {
err,
name: clusterModel.contextName,
id: clusterModel.id,
});
notificationsStore.add({
message: `Cluster ${clusterModel.contextName} is reporting an error: ${err.toString()}`,
id: `${clusterModel.id}_IS_DEAD_NOTIFICATION`, // this **should** prevent double errors because we are loading the stores twice
status: NotificationStatus.ERROR,
timeout: 10000,
});
continue;
}
throw err;
}
if (!cluster.isManaged) {
cluster.enabled = true;
}
@ -264,14 +323,22 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
});
this.activeCluster = newClusters.has(activeCluster) ? activeCluster : null;
this.deadClusters.replace(deadClusters);
this.clusters.replace(newClusters);
this.removedClusters.replace(removedClusters);
}
@computed private get clustersAsJson(): ClusterModel[] {
const clusters = this.clustersList.map(c => c.toJSON());
const dead = Array.from(this.deadClusters.values()).map(([c]) => c);
return [...clusters, ...dead];
}
toJSON(): ClusterStoreModel {
return toJS({
activeCluster: this.activeCluster,
clusters: this.clustersList.map(cluster => cluster.toJSON()),
clusters: this.clustersAsJson,
}, {
recurseEverything: true
});

View File

@ -16,13 +16,55 @@ function resolveTilde(filePath: string) {
return filePath;
}
export function loadConfig(pathOrContent?: string): KubeConfig {
const kc = new KubeConfig();
export class LoadKubeError extends Error {
public type: string;
}
if (fse.pathExistsSync(pathOrContent)) {
kc.loadFromFile(path.resolve(resolveTilde(pathOrContent)));
} else {
kc.loadFromString(pathOrContent);
export class AccessError extends LoadKubeError {
type = "AccessError";
constructor(public pathname: string) {
super();
}
toString() {
return `Failed to loading the kube config. Permission denied.\n\n${this.pathname}`;
}
}
export class ExistError extends LoadKubeError {
type = "ExistError";
constructor(public pathname: string) {
super();
}
toString() {
return `Failed to loading the kube config. No such file.\n\n${this.pathname}`;
}
}
export function loadConfig(pathOrContent: string): KubeConfig {
const kc = new KubeConfig();
try {
kc.loadFromFile(pathOrContent);
} catch (err) {
switch (err.code) {
case "ENOENT": { // No such file or directory
const dir = path.dirname(pathOrContent);
const dirStat = fse.statSync(dir);
if (dirStat.isDirectory()) {
throw new ExistError(pathOrContent);
}
kc.loadFromString(pathOrContent);
break;
}
case "EACCES":
throw new AccessError(pathOrContent);
default:
kc.loadFromString(pathOrContent);
}
}
return kc;
@ -145,15 +187,15 @@ 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.
*/
export function validateKubeConfig (config: KubeConfig) {
* the User struct, specifically the command passed to the exec substructure.
*/
export function validateKubeConfig(config: KubeConfig) {
// 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)}`);
// Validate the User Object
const user = config.getCurrentUser();
const user = config.getCurrentUser();
if (user.exec) {
const execCommand = user.exec["command"];
// check if the command is absolute or not
@ -166,4 +208,4 @@ export function validateKubeConfig (config: KubeConfig) {
throw new ExecValidationNotFoundError(execCommand, isAbsolute);
}
}
}
}

View File

@ -1,8 +1,8 @@
import React from "react";
import { action, observable } from "mobx";
import { autobind } from "../../utils";
import uniqueId from "lodash/uniqueId";
import { JsonApiErrorParsed } from "../../api/json-api";
import { autobind, Singleton } from "./utils";
import _ from "lodash";
import { JsonApiErrorParsed } from "../renderer/api/json-api";
export type NotificationId = string | number;
export type NotificationMessage = React.ReactNode | React.ReactNode[] | JsonApiErrorParsed;
@ -21,10 +21,10 @@ export interface Notification {
}
@autobind()
export class NotificationsStore {
export class NotificationsStore extends Singleton {
public notifications = observable.array<Notification>([], { deep: false });
protected autoHideTimers = new Map<NotificationId, number>();
protected autoHideTimers = new Map<NotificationId, NodeJS.Timeout>();
getById(id: NotificationId): Notification | null {
return this.notifications.find(item => item.id === id) ?? null;
@ -35,7 +35,7 @@ export class NotificationsStore {
if (!notification) return;
this.removeAutoHideTimer(id);
if (notification?.timeout) {
const timer = window.setTimeout(() => this.remove(id), notification.timeout);
const timer = setTimeout(() => this.remove(id), notification.timeout);
this.autoHideTimers.set(id, timer);
}
}
@ -50,7 +50,7 @@ export class NotificationsStore {
@action
add(notification: Notification): () => void {
const id = notification.id ?? (
notification.id = uniqueId("notification_")
notification.id = _.uniqueId("notification_")
);
const index = this.notifications.findIndex(item => item.id === id);
if (index > -1) {
@ -69,4 +69,4 @@ export class NotificationsStore {
}
}
export const notificationsStore = new NotificationsStore();
export const notificationsStore = NotificationsStore.getInstance<NotificationsStore>();

View File

@ -11,6 +11,10 @@ type Constructor<T = {}> = new (...args: any[]) => T;
class Singleton {
private static instances = new WeakMap<object, Singleton>();
protected constructor() {
// to prevent constucting
}
// todo: improve types inferring
static getInstance<T>(...args: ConstructorParameters<Constructor<T>>): T {
if (!Singleton.instances.has(this)) {
@ -25,4 +29,4 @@ class Singleton {
}
export { Singleton };
export default Singleton;
export default Singleton;

View File

@ -27,6 +27,7 @@ jest.mock("../../common/ipc");
jest.mock("child_process");
jest.mock("tcp-port-used");
import mockFs from "mock-fs";
import { Cluster } from "../cluster";
import { KubeAuthProxy } from "../kube-auth-proxy";
import { getFreePort } from "../port";
@ -36,6 +37,10 @@ import { bundledKubectlPath, Kubectl } from "../kubectl";
import { mock, MockProxy } from 'jest-mock-extended';
import { waitUntilUsed } from 'tcp-port-used';
import { Readable } from "stream";
import { app, remote } from "electron";
import { Console } from "console";
console = new Console(process.stdout, process.stderr); // fix mockFS
const mockBroadcastIpc = broadcastMessage as jest.MockedFunction<typeof broadcastMessage>;
const mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
@ -44,6 +49,14 @@ const mockWaitUntilUsed = waitUntilUsed as jest.MockedFunction<typeof waitUntilU
describe("kube auth proxy tests", () => {
beforeEach(() => {
jest.clearAllMocks();
const mockOpts = {
'fake-path.yml': JSON.stringify({})
};
mockFs(mockOpts);
});
afterEach(() => {
mockFs.restore();
});
it("calling exit multiple times shouldn't throw", async () => {

View File

@ -51,9 +51,9 @@ export interface ClusterState {
export class Cluster implements ClusterModel, ClusterState {
public id: ClusterId;
public kubeCtl: Kubectl;
public contextHandler: ContextHandler;
public contextHandler?: ContextHandler;
public ownerRef: string;
protected kubeconfigManager: KubeconfigManager;
protected kubeconfigManager?: KubeconfigManager;
protected eventDisposers: Function[] = [];
protected activated = false;
@ -85,7 +85,7 @@ export class Cluster implements ClusterModel, ClusterState {
}
@computed get name() {
return this.preferences.clusterName || this.contextName;
return this.preferences.clusterName || this.contextName;
}
@computed get prometheusPreferences(): ClusterPrometheusPreferences {
@ -101,9 +101,12 @@ export class Cluster implements ClusterModel, ClusterState {
constructor(model: ClusterModel) {
this.updateModel(model);
const kubeconfig = this.getKubeconfig();
if (kubeconfig.getContextObject(this.contextName)) {
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server;
const contextObj = kubeconfig.getContextObject(this.contextName);
if (contextObj) {
const clusterObj = kubeconfig.getCluster(contextObj.cluster);
this.apiUrl = clusterObj.server;
}
}
@ -386,6 +389,7 @@ export class Cluster implements ClusterModel, ClusterState {
online: this.online,
accessible: this.accessible,
disconnected: this.disconnected,
activated: this.activated,
};
}

View File

@ -10,6 +10,8 @@ import { Router } from "./router";
import { ClusterManager } from "./cluster-manager";
import { ContextHandler } from "./context-handler";
import logger from "./logger";
import _ from "lodash";
import { clusterStore } from "../common/cluster-store";
export class LensProxy {
protected origin: string;
@ -64,50 +66,55 @@ export class LensProxy {
protected async handleProxyUpgrade(proxy: httpProxy, req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
const cluster = this.clusterManager.getClusterForRequest(req);
if (cluster) {
const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, "");
const apiUrl = url.parse(cluster.apiUrl);
const pUrl = url.parse(proxyUrl);
const connectOpts = { port: parseInt(pUrl.port), host: pUrl.hostname };
const proxySocket = new net.Socket();
proxySocket.connect(connectOpts, () => {
proxySocket.write(`${req.method} ${pUrl.path} HTTP/1.1\r\n`);
proxySocket.write(`Host: ${apiUrl.host}\r\n`);
for (let i = 0; i < req.rawHeaders.length; i += 2) {
const key = req.rawHeaders[i];
if (key !== "Host" && key !== "Authorization") {
proxySocket.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i+1]}\r\n`);
}
}
proxySocket.write("\r\n");
proxySocket.write(head);
});
proxySocket.setKeepAlive(true);
socket.setKeepAlive(true);
proxySocket.setTimeout(0);
socket.setTimeout(0);
proxySocket.on('data', function (chunk) {
socket.write(chunk);
});
proxySocket.on('end', function () {
socket.end();
});
proxySocket.on('error', function (err) {
socket.write("HTTP/" + req.httpVersion + " 500 Connection error\r\n\r\n");
socket.end();
});
socket.on('data', function (chunk) {
proxySocket.write(chunk);
});
socket.on('end', function () {
proxySocket.end();
});
socket.on('error', function () {
proxySocket.end();
});
if (!cluster) {
return;
}
const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, "");
const apiUrl = url.parse(cluster.apiUrl);
const pUrl = url.parse(proxyUrl);
const connectOpts = { port: parseInt(pUrl.port), host: pUrl.hostname };
const proxySocket = new net.Socket();
proxySocket.connect(connectOpts, () => {
proxySocket.write(`${req.method} ${pUrl.path} HTTP/1.1\r\n`);
proxySocket.write(`Host: ${apiUrl.host}\r\n`);
for (const [key, value] of _.chunk(req.rawHeaders, 2)) {
if (["Host", "Authorization"].includes(key)) {
continue;
}
proxySocket.write(`${key}: ${value}\r\n`);
}
proxySocket.write("\r\n");
proxySocket.write(head);
});
proxySocket.setKeepAlive(true);
socket.setKeepAlive(true);
proxySocket.setTimeout(0);
socket.setTimeout(0);
proxySocket.on('data', function (chunk) {
socket.write(chunk);
});
proxySocket.on('end', function () {
socket.end();
});
proxySocket.on('error', function (err) {
socket.write(`HTTP/${req.httpVersion} 500 Connection error\r\n\r\n`);
socket.end();
});
socket.on('data', function (chunk) {
proxySocket.write(chunk);
});
socket.on('end', function () {
proxySocket.end();
});
socket.on('error', function () {
proxySocket.end();
});
}
protected createProxy(): httpProxy {
@ -166,6 +173,10 @@ export class LensProxy {
protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) {
const cluster = this.clusterManager.getClusterForRequest(req);
if (cluster) {
if (!cluster.initialized) {
return res.writeHead(404).end(`cluster: ${cluster.name} is not initialized`);
}
const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler);
if (proxyTarget) {
// allow to fetch apis in "clusterId.localhost:port" from "localhost:port"

View File

@ -1,12 +1,12 @@
import path from "path";
import packageInfo from "../../package.json";
import { dialog, Menu, NativeImage, nativeTheme, Tray } from "electron";
import { dialog, Menu, MenuItemConstructorOptions, NativeImage, nativeTheme, Tray } from "electron";
import { autorun } from "mobx";
import { showAbout } from "./menu";
import { AppUpdater } from "./app-updater";
import { WindowManager } from "./window-manager";
import { clusterStore } from "../common/cluster-store";
import { workspaceStore } from "../common/workspace-store";
import { ClusterRenderInfo, clusterStore } from "../common/cluster-store";
import { Workspace, workspaceStore } from "../common/workspace-store";
import { preferencesURL } from "../renderer/components/+preferences/preferences.route";
import { clusterViewURL } from "../renderer/components/cluster-manager/cluster-view.route";
import logger from "./logger";
@ -33,7 +33,7 @@ export function initTray(windowManager: WindowManager) {
const menu = createTrayMenu(windowManager);
buildTray(getTrayIcon(), menu);
} catch (err) {
logger.error(`[TRAY]: building failed: ${err}`);
logger.error(`[TRAY]: building failed: `, err);
}
});
return () => {
@ -82,25 +82,23 @@ export function createTrayMenu(windowManager: WindowManager): Menu {
{
label: "Clusters",
submenu: workspaceStore.enabledWorkspacesList
.filter(workspace => clusterStore.getByWorkspaceId(workspace.id).length > 0) // hide empty workspaces
.map(workspace => {
const clusters = clusterStore.getByWorkspaceId(workspace.id);
return {
label: workspace.name,
toolTip: workspace.description,
submenu: clusters.map(cluster => {
const { id: clusterId, name: label, online, workspace } = cluster;
return {
label: `${online ? '✓' : '\x20'.repeat(3)/*offset*/}${label}`,
toolTip: clusterId,
async click() {
workspaceStore.setActive(workspace);
windowManager.navigate(clusterViewURL({ params: { clusterId } }));
}
};
})
};
}),
.map((workspace): [Workspace, ClusterRenderInfo[]] => [workspace, clusterStore.getByWorkspaceId(workspace.id)])
.filter(([, clusters]) => clusters.length > 0)
.map(([workspace, clusters]): MenuItemConstructorOptions => ({
label: workspace.name,
toolTip: workspace.description,
submenu: clusters.map(({ id: clusterId, name, online, workspace, DeadError }) => ({
label: name,
enabled: !!!DeadError,
checked: online,
toolTip: clusterId,
type: "radio",
async click() {
workspaceStore.setActive(workspace);
windowManager.navigate(clusterViewURL({ params: { clusterId } }));
}
}))
})),
},
{
label: "Check for updates",

View File

@ -2,7 +2,7 @@
import { stringify } from "querystring";
import { EventEmitter } from "../../common/event-emitter";
import { cancelableFetch } from "../utils/cancelableFetch";
import { cancelableFetch, CancelablePromise } from "../utils/cancelableFetch";
export interface JsonApiData {
}
@ -71,7 +71,7 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
return this.request<T>(path, params, { ...reqInit, method: "delete" });
}
protected request<D>(path: string, params?: P, init: RequestInit = {}) {
protected request<D>(path: string, params?: P, init: RequestInit = {}): CancelablePromise<D> {
let reqUrl = this.config.apiBase + path;
const reqInit: RequestInit = { ...this.reqInit, ...init };
const { data, query } = params || {} as P;
@ -92,28 +92,27 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
});
}
protected parseResponse<D>(res: Response, log: JsonApiLog): Promise<D> {
protected async parseResponse<D>(res: Response, log: JsonApiLog): Promise<D> {
const { status } = res;
return res.text().then(text => {
let data;
try {
data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body
} catch (e) {
data = text;
}
if (status >= 200 && status < 300) {
this.onData.emit(data, res);
this.writeLog({ ...log, data });
return data;
} else if (log.method === "GET" && res.status === 403) {
this.writeLog({ ...log, data });
} else {
const error = new JsonApiErrorParsed(data, this.parseError(data, res));
this.onError.emit(error, res);
this.writeLog({ ...log, error });
throw error;
}
});
const text = await res.text();
let data;
try {
data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body
} catch (e) {
data = text;
}
if (status >= 200 && status < 300) {
this.onData.emit(data, res);
this.writeLog({ ...log, data });
return data;
} else if (log.method === "GET" && res.status === 403) {
this.writeLog({ ...log, data });
} else {
const error = new JsonApiErrorParsed(data, this.parseError(data, res));
this.onError.emit(error, res);
this.writeLog({ ...log, error });
throw error;
}
}
protected parseError(error: JsonApiError | string, res: Response): string[] {

View File

@ -11,8 +11,7 @@ export class LandingPage extends React.Component {
@observable showHint = true;
render() {
const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId);
const noClustersInScope = !clusters.length;
const noClustersInScope = clusterStore.getCountInWorkspace(workspaceStore.currentWorkspaceId) === 0;
const showStartupHint = this.showHint && noClustersInScope;
return (
<div className="LandingPage flex">

View File

@ -26,6 +26,15 @@
height: var(--size);
}
.Icon.dead-error {
position: absolute;
border-radius: 50%;
right: 0px;
top: 0px;
color: $colorError;
background-color: black;
}
.Badge {
position: absolute;
right: 0;
@ -35,4 +44,4 @@
background: $colorError;
color: white;
}
}
}

View File

@ -4,17 +4,16 @@ import React, { DOMAttributes } from "react";
import { disposeOnUnmount, observer } from "mobx-react";
import { Params as HashiconParams } from "@emeraldpay/hashicon";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { Cluster } from "../../../main/cluster";
import { cssNames, IClassName } from "../../utils";
import { Badge } from "../badge";
import { Tooltip } from "../tooltip";
import { eventStore } from "../+events/event.store";
import { forCluster } from "../../api/kube-api";
import { subscribeToBroadcast, unsubscribeAllFromBroadcast } from "../../../common/ipc";
import { observable, when } from "mobx";
import { subscribeToBroadcast } from "../../../common/ipc";
import { observable } from "mobx";
import { ClusterRenderInfo } from "../../../common/cluster-store";
import { Icon } from "../icon";
interface Props extends DOMAttributes<HTMLElement> {
cluster: Cluster;
cluster: ClusterRenderInfo;
className?: IClassName;
errorClass?: IClassName;
showErrors?: boolean;
@ -51,24 +50,32 @@ export class ClusterIcon extends React.Component<Props> {
render() {
const {
cluster, showErrors, showTooltip, errorClass, options, interactive, isActive,
cluster, showErrors, showTooltip, errorClass, options, interactive, isActive, className,
children, ...elemProps
} = this.props;
const { name, preferences, id: clusterId } = cluster;
const eventCount = this.eventCount;
const { icon } = preferences;
const { name, preferences: { icon }, id: clusterId, DeadError } = cluster;
const clusterIconId = `cluster-icon-${clusterId}`;
const className = cssNames("ClusterIcon flex inline", this.props.className, {
interactive: interactive !== undefined ? interactive : !!this.props.onClick,
const isDead = !!DeadError;
const classNames = cssNames("ClusterIcon flex inline", className, {
interactive: interactive ?? !!this.props.onClick,
active: isActive,
});
return (
<div {...elemProps} className={className} id={showTooltip ? clusterIconId : null}>
<div {...elemProps} className={classNames} id={showTooltip ? clusterIconId : null}>
{showTooltip && (
<Tooltip targetId={clusterIconId}>{name}</Tooltip>
)}
{icon && <img src={icon} alt={name}/>}
{!icon && <Hashicon value={clusterId} options={options}/>}
{
icon
? <img src={icon} alt={name} />
: <Hashicon value={clusterId} options={options} />
}
{
isDead && (
<Icon className="dead-error" material="error" />
)
}
{showErrors && eventCount > 0 && !isActive && (
<Badge
className={cssNames("events-count", errorClass)}

View File

@ -0,0 +1,57 @@
import React from "react";
import { Trans } from "@lingui/macro";
import { observer } from "mobx-react";
import { ClusterModel } from "../../../common/cluster-store";
import { AccessError, ExistError, LoadKubeError } from "../../../common/kube-helpers";
import { cssNames } from "../../utils";
import { Icon } from "../icon";
import { isMac } from "../../../common/vars";
export interface ClusterDeadStatusProps {
cluster: ClusterModel,
error: LoadKubeError,
}
@observer
export class ClusterDeadStatus extends React.Component<ClusterDeadStatusProps> {
renderError(error: LoadKubeError) {
if (error instanceof AccessError) {
return <>
<p><Trans>The kube config file related to this cluster is no longer accessible by Lens.</Trans></p>
<p><Trans>Lens cannot connect to this cluster.</Trans></p>
{isMac && (
<p><Trans>This may have resulted in a recent macOS update locking down files.</Trans></p>
)}
<p><Trans>If you allow Lens to access your home directory, it may rectify the problem.</Trans></p>
<p className="monospace">{error.pathname}</p>
</>;
}
if (error instanceof ExistError) {
return <>
<p><Trans>The kube config file related to this cluster no longer exists.</Trans></p>
<p><Trans>Either it has been moved by another process or it was deleted.</Trans></p>
<p><Trans>To fix this error, either recreate the config file in the same location or delete and re-add this cluster.</Trans></p>
<p className="monospace">{error.pathname}</p>
</>;
}
return <>
<p><Trans>An unknown error of type "{error.type}" occured while loading this cluster's kube config.</Trans></p>
</>;
}
render() {
const classNames = cssNames("ClusterStatus flex column gaps box center align-center justify-center");
const { cluster, error } = this.props;
return <div className={classNames}>
<Icon material="cloud_off" className="error" />
<h2>
{cluster.preferences.clusterName || cluster.contextName}
</h2>
{this.renderError(error)}
</div>;
}
}

View File

@ -4,7 +4,7 @@ import React from "react";
import { Redirect, Route, Switch } from "react-router";
import { comparer, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { ClustersMenu } from "./clusters-menu";
import { ClusterMenu } from "./clusters-menu";
import { BottomBar } from "./bottom-bar";
import { LandingPage, landingRoute, landingURL } from "../+landing-page";
import { Preferences, preferencesRoute } from "../+preferences";
@ -60,7 +60,7 @@ export class ClusterManager extends React.Component {
return (
<div className="ClusterManager">
<main>
<div id="lens-views"/>
<div id="lens-views" />
<Switch>
<Route component={LandingPage} {...landingRoute} />
<Route component={Preferences} {...preferencesRoute} />
@ -70,13 +70,13 @@ export class ClusterManager extends React.Component {
<Route component={ClusterView} {...clusterViewRoute} />
<Route component={ClusterSettings} {...clusterSettingsRoute} />
{globalPageRegistry.getItems().map(({ routePath, exact, components: { Page } }) => {
return <Route key={routePath} path={routePath} component={Page} exact={exact}/>;
return <Route key={routePath} path={routePath} component={Page} exact={exact} />;
})}
<Redirect exact to={this.startUrl}/>
<Redirect exact to={this.startUrl} />
</Switch>
</main>
<ClustersMenu/>
<BottomBar/>
<ClusterMenu />
<BottomBar />
</div>
);
}

View File

@ -18,4 +18,8 @@
--size: 70px;
margin: auto;
}
}
p.monospace {
font-family: monospace;
}
}

View File

@ -7,7 +7,9 @@ import { IClusterViewRouteParams } from "./cluster-view.route";
import { ClusterStatus } from "./cluster-status";
import { hasLoadedView } from "./lens-views";
import { Cluster } from "../../../main/cluster";
import { clusterStore } from "../../../common/cluster-store";
import { ClusterModel, clusterStore } from "../../../common/cluster-store";
import { LoadKubeError } from "../../../common/kube-helpers";
import { ClusterDeadStatus } from "./cluster-dead-status";
interface Props extends RouteComponentProps<IClusterViewRouteParams> {
}
@ -22,6 +24,10 @@ export class ClusterView extends React.Component<Props> {
return clusterStore.getById(this.clusterId);
}
get deadCluster(): [ClusterModel, LoadKubeError] {
return clusterStore.getDeadById(this.clusterId);
}
async componentDidMount() {
disposeOnUnmount(this, [
reaction(() => this.clusterId, clusterId => clusterStore.setActive(clusterId), {
@ -31,13 +37,16 @@ export class ClusterView extends React.Component<Props> {
}
render() {
const { cluster } = this;
const { cluster, deadCluster } = this;
const showStatus = cluster && (!cluster.available || !hasLoadedView(cluster.id) || !cluster.ready);
return (
<div className="ClusterView flex align-center">
{showStatus && (
<ClusterStatus key={cluster.id} clusterId={cluster.id} className="box center"/>
)}
{showStatus
? <ClusterStatus key={cluster.id} clusterId={cluster.id} className="box center" />
: deadCluster && (
<ClusterDeadStatus cluster={deadCluster[0]} error={deadCluster[1]} />
)
}
</div>
);
}

View File

@ -3,13 +3,12 @@ import "./clusters-menu.scss";
import React from "react";
import { remote } from "electron";
import { requestMain } from "../../../common/ipc";
import type { Cluster } from "../../../main/cluster";
import { DragDropContext, Draggable, DraggableProvided, Droppable, DroppableProvided, DropResult } from "react-beautiful-dnd";
import { observer } from "mobx-react";
import { _i18n } from "../../i18n";
import { t, Trans } from "@lingui/macro";
import { userStore } from "../../../common/user-store";
import { ClusterId, clusterStore } from "../../../common/cluster-store";
import { ClusterId, ClusterRenderInfo, clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
import { ClusterIcon } from "../cluster-icon";
import { Icon } from "../icon";
@ -30,7 +29,7 @@ interface Props {
}
@observer
export class ClustersMenu extends React.Component<Props> {
export class ClusterMenu extends React.Component<Props> {
showCluster = (clusterId: ClusterId) => {
navigate(clusterViewURL({ params: { clusterId } }));
};
@ -39,7 +38,7 @@ export class ClustersMenu extends React.Component<Props> {
navigate(addClusterURL());
};
showContextMenu = (cluster: Cluster) => {
showContextMenu = (cluster: ClusterRenderInfo) => {
const { Menu, MenuItem } = remote;
const menu = new Menu();
@ -115,25 +114,22 @@ export class ClustersMenu extends React.Component<Props> {
<Droppable droppableId="cluster-menu" type="CLUSTER">
{({ innerRef, droppableProps, placeholder }: DroppableProvided) => (
<div ref={innerRef} {...droppableProps}>
{clusters.map((cluster, index) => {
const isActive = cluster.id === activeClusterId;
return (
<Draggable draggableId={cluster.id} index={index} key={cluster.id}>
{({ draggableProps, dragHandleProps, innerRef }: DraggableProvided) => (
<div ref={innerRef} {...draggableProps} {...dragHandleProps}>
<ClusterIcon
key={cluster.id}
showErrors={true}
cluster={cluster}
isActive={isActive}
onClick={() => this.showCluster(cluster.id)}
onContextMenu={() => this.showContextMenu(cluster)}
/>
</div>
)}
</Draggable>
);
})}
{clusters.map((cluster, index) => (
<Draggable draggableId={cluster.id} index={index} key={cluster.id}>
{({ draggableProps, dragHandleProps, innerRef }: DraggableProvided) => (
<div ref={innerRef} {...draggableProps} {...dragHandleProps}>
<ClusterIcon
key={cluster.id}
showErrors={true}
cluster={cluster}
isActive={cluster.id === activeClusterId}
onClick={() => this.showCluster(cluster.id)}
onContextMenu={() => this.showContextMenu(cluster)}
/>
</div>
)}
</Draggable>
))}
{placeholder}
</div>
)}
@ -144,9 +140,9 @@ export class ClustersMenu extends React.Component<Props> {
<Tooltip targetId="add-cluster-icon">
<Trans>Add Cluster</Trans>
</Tooltip>
<Icon big material="add" id="add-cluster-icon" disabled={workspace.isManaged} onClick={this.addCluster}/>
<Icon big material="add" id="add-cluster-icon" disabled={workspace.isManaged} onClick={this.addCluster} />
{newContexts.size > 0 && (
<Badge className="counter" label={newContexts.size} tooltip={<Trans>new</Trans>}/>
<Badge className="counter" label={newContexts.size} tooltip={<Trans>new</Trans>} />
)}
</div>
<div className="extensions">

View File

@ -40,24 +40,23 @@ export class Icon extends React.PureComponent<IconProps> {
if (this.props.disabled) {
return;
}
if (this.props.onClick) {
this.props.onClick(evt);
}
this.props.onClick?.(evt);
}
@autobind()
onKeyDown(evt: React.KeyboardEvent<any>) {
switch (evt.nativeEvent.code) {
case "Space":
case "Enter":
case "Enter": {
const icon = findDOMNode(this) as HTMLElement;
setTimeout(() => icon.click());
evt.preventDefault();
break;
}
}
if (this.props.onKeyDown) {
this.props.onKeyDown(evt);
}
this.props.onKeyDown?.(evt);
}
render() {
@ -88,7 +87,7 @@ export class Icon extends React.PureComponent<IconProps> {
// render as inline svg-icon
if (svg) {
const svgIconText = require("!!raw-loader!./" + svg + ".svg").default;
iconContent = <span className="icon" dangerouslySetInnerHTML={{ __html: svgIconText }}/>;
iconContent = <span className="icon" dangerouslySetInnerHTML={{ __html: svgIconText }} />;
}
// render as material-icon
@ -106,10 +105,10 @@ export class Icon extends React.PureComponent<IconProps> {
// render icon type
if (link) {
return <NavLink {...iconProps} to={link}/>;
return <NavLink {...iconProps} to={link} />;
}
if (href) {
return <a {...iconProps} href={href}/>;
return <a {...iconProps} href={href} />;
}
return <i {...iconProps} />;
}

View File

@ -1,2 +1 @@
export * from './notifications';
export * from './notifications.store';

View File

@ -5,7 +5,7 @@ import { reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { JsonApiErrorParsed } from "../../api/json-api";
import { cssNames, prevDefault } from "../../utils";
import { Notification, NotificationMessage, notificationsStore, NotificationStatus } from "./notifications.store";
import { NotificationMessage, Notification, notificationsStore, NotificationStatus } from "../../../common/notifications.store";
import { Animate } from "../animate";
import { Icon } from "../icon";

View File

@ -175,7 +175,7 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
switch (type) {
case "ADDED":
case "MODIFIED":
case "MODIFIED": {
const newItem = new api.objectConstructor(object);
if (!item) {
items.push(newItem);
@ -183,6 +183,7 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
items.splice(index, 1, newItem);
}
break;
}
case "DELETED":
if (item) {
items.splice(index, 1);