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:
parent
238756be72
commit
b3a01fac2f
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>();
|
||||
@ -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;
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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[] {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,4 +18,8 @@
|
||||
--size: 70px;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
p.monospace {
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export * from './notifications';
|
||||
export * from './notifications.store';
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user