mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Merge branch 'master' of github.com:lensapp/lens into feature/protocol-handler
Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
commit
65274f0d5f
@ -5,7 +5,7 @@ Lens is lightweight and simple to install. You'll be up and running in just a fe
|
|||||||
|
|
||||||
## System Requirements
|
## System Requirements
|
||||||
|
|
||||||
Review the [System Requirements](/supporting/requirements/) to check if your computer configuration is supported.
|
Review the [System Requirements](../supporting/requirements.md) to check if your computer configuration is supported.
|
||||||
|
|
||||||
|
|
||||||
## macOS
|
## macOS
|
||||||
|
|||||||
@ -15,7 +15,10 @@ export default class SurveyRendererExtension extends LensRendererExtension {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
async onActivate() {
|
async onActivate() {
|
||||||
await surveyPreferencesStore.loadExtension(this);
|
// Activate extension only on main renderer
|
||||||
survey.start();
|
if (window.location.hostname === "localhost") {
|
||||||
|
await surveyPreferencesStore.loadExtension(this);
|
||||||
|
survey.start();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"name": "kontena-lens",
|
"name": "kontena-lens",
|
||||||
"productName": "Lens",
|
"productName": "Lens",
|
||||||
"description": "Lens - The Kubernetes IDE",
|
"description": "Lens - The Kubernetes IDE",
|
||||||
"version": "4.1.0-beta.1",
|
"version": "4.1.0",
|
||||||
"main": "static/build/main.js",
|
"main": "static/build/main.js",
|
||||||
"copyright": "© 2020, Mirantis, Inc.",
|
"copyright": "© 2020, Mirantis, Inc.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -231,7 +231,7 @@
|
|||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-dom": "^17.0.1",
|
"react-dom": "^17.0.1",
|
||||||
"react-router": "^5.2.0",
|
"react-router": "^5.2.0",
|
||||||
"readable-web-to-node-stream": "^3.0.1",
|
"readable-stream": "^3.6.0",
|
||||||
"request": "^2.88.2",
|
"request": "^2.88.2",
|
||||||
"request-promise-native": "^1.0.8",
|
"request-promise-native": "^1.0.8",
|
||||||
"semver": "^7.3.2",
|
"semver": "^7.3.2",
|
||||||
@ -287,6 +287,7 @@
|
|||||||
"@types/react-router-dom": "^5.1.6",
|
"@types/react-router-dom": "^5.1.6",
|
||||||
"@types/react-select": "^3.0.13",
|
"@types/react-select": "^3.0.13",
|
||||||
"@types/react-window": "^1.8.2",
|
"@types/react-window": "^1.8.2",
|
||||||
|
"@types/readable-stream": "^2.3.9",
|
||||||
"@types/request": "^2.48.5",
|
"@types/request": "^2.48.5",
|
||||||
"@types/request-promise-native": "^1.0.17",
|
"@types/request-promise-native": "^1.0.17",
|
||||||
"@types/semver": "^7.2.0",
|
"@types/semver": "^7.2.0",
|
||||||
@ -369,7 +370,7 @@
|
|||||||
"webpack-dev-server": "^3.11.0",
|
"webpack-dev-server": "^3.11.0",
|
||||||
"webpack-node-externals": "^1.7.2",
|
"webpack-node-externals": "^1.7.2",
|
||||||
"what-input": "^5.2.10",
|
"what-input": "^5.2.10",
|
||||||
"xterm": "^4.6.0",
|
"xterm": "^4.10.0",
|
||||||
"xterm-addon-fit": "^0.4.0"
|
"xterm-addon-fit": "^0.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import fs from "fs";
|
|||||||
import mockFs from "mock-fs";
|
import mockFs from "mock-fs";
|
||||||
import yaml from "js-yaml";
|
import yaml from "js-yaml";
|
||||||
import { Cluster } from "../../main/cluster";
|
import { Cluster } from "../../main/cluster";
|
||||||
import { ClusterStore } from "../cluster-store";
|
import { ClusterStore, getClusterIdFromHost } from "../cluster-store";
|
||||||
import { workspaceStore } from "../workspace-store";
|
import { workspaceStore } from "../workspace-store";
|
||||||
|
|
||||||
const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png");
|
const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png");
|
||||||
@ -446,3 +446,27 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
|
|||||||
expect(icon.startsWith("data:;base64,")).toBe(true);
|
expect(icon.startsWith("data:;base64,")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getClusterIdFromHost", () => {
|
||||||
|
const clusterFakeId = "fe540901-0bd6-4f6c-b472-bce1559d7c4a";
|
||||||
|
|
||||||
|
it("should return undefined for non cluster frame hosts", () => {
|
||||||
|
expect(getClusterIdFromHost("localhost:45345")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return ClusterId for cluster frame hosts", () => {
|
||||||
|
expect(getClusterIdFromHost(`${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return ClusterId for cluster frame hosts with additional subdomains", () => {
|
||||||
|
expect(getClusterIdFromHost(`abc.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
|
||||||
|
expect(getClusterIdFromHost(`abc.def.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
|
||||||
|
expect(getClusterIdFromHost(`abc.def.ghi.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
|
||||||
|
expect(getClusterIdFromHost(`abc.def.ghi.jkl.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
|
||||||
|
expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
|
||||||
|
expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
|
||||||
|
expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
|
||||||
|
expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.vwx.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
|
||||||
|
expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.vwx.yz.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -354,10 +354,11 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
|
|
||||||
export const clusterStore = ClusterStore.getInstance<ClusterStore>();
|
export const clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||||
|
|
||||||
export function getClusterIdFromHost(hostname: string): ClusterId {
|
export function getClusterIdFromHost(host: string): ClusterId | undefined {
|
||||||
const subDomains = hostname.split(":")[0].split(".");
|
// e.g host == "%clusterId.localhost:45345"
|
||||||
|
const subDomains = host.split(":")[0].split(".");
|
||||||
|
|
||||||
return subDomains.slice(-2)[0]; // e.g host == "%clusterId.localhost:45345"
|
return subDomains.slice(-2, -1)[0]; // ClusterId or undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getClusterFrameUrl(clusterId: ClusterId) {
|
export function getClusterFrameUrl(clusterId: ClusterId) {
|
||||||
@ -365,7 +366,7 @@ export function getClusterFrameUrl(clusterId: ClusterId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getHostedClusterId() {
|
export function getHostedClusterId() {
|
||||||
return getClusterIdFromHost(location.hostname);
|
return getClusterIdFromHost(location.host);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHostedCluster(): Cluster {
|
export function getHostedCluster(): Cluster {
|
||||||
|
|||||||
@ -10,9 +10,8 @@ function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: Upd
|
|||||||
if (arg.doUpdate) {
|
if (arg.doUpdate) {
|
||||||
if (arg.now) {
|
if (arg.now) {
|
||||||
logger.info(`${AutoUpdateLogPrefix}: User chose to update now`);
|
logger.info(`${AutoUpdateLogPrefix}: User chose to update now`);
|
||||||
autoUpdater.downloadUpdate()
|
autoUpdater.on("update-downloaded", () => autoUpdater.quitAndInstall());
|
||||||
.then(() => autoUpdater.quitAndInstall())
|
autoUpdater.downloadUpdate().catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download or install update`, { error }));
|
||||||
.catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download or install update`, { error }));
|
|
||||||
} else {
|
} else {
|
||||||
logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`);
|
logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`);
|
||||||
autoUpdater.autoInstallOnAppQuit = true;
|
autoUpdater.autoInstallOnAppQuit = true;
|
||||||
|
|||||||
@ -399,6 +399,7 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
this.accessible = false;
|
this.accessible = false;
|
||||||
this.ready = false;
|
this.ready = false;
|
||||||
this.activated = false;
|
this.activated = false;
|
||||||
|
this.allowedNamespaces = [];
|
||||||
this.resourceAccessStatuses.clear();
|
this.resourceAccessStatuses.clear();
|
||||||
this.pushState();
|
this.pushState();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import "../common/system-ca";
|
|||||||
import "../common/prometheus-providers";
|
import "../common/prometheus-providers";
|
||||||
import * as Mobx from "mobx";
|
import * as Mobx from "mobx";
|
||||||
import * as LensExtensions from "../extensions/core-api";
|
import * as LensExtensions from "../extensions/core-api";
|
||||||
import { app, dialog, ipcMain, powerMonitor } from "electron";
|
import { app, autoUpdater, ipcMain, dialog, powerMonitor } from "electron";
|
||||||
import { appName } from "../common/vars";
|
import { appName } from "../common/vars";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { LensProxy } from "./lens-proxy";
|
import { LensProxy } from "./lens-proxy";
|
||||||
@ -204,14 +204,25 @@ app.on("activate", (event, hasVisibleWindows) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This variable should is used so that `autoUpdater.installAndQuit()` works
|
||||||
|
*/
|
||||||
|
let blockQuit = true;
|
||||||
|
|
||||||
|
autoUpdater.on("before-quit-for-update", () => blockQuit = false);
|
||||||
|
|
||||||
app.on("will-quit", (event) => {
|
app.on("will-quit", (event) => {
|
||||||
// Quit app on Cmd+Q (MacOS)
|
// Quit app on Cmd+Q (MacOS)
|
||||||
logger.info("APP:QUIT");
|
logger.info("APP:QUIT");
|
||||||
appEventBus.emit({name: "app", action: "close"});
|
appEventBus.emit({name: "app", action: "close"});
|
||||||
event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.)
|
|
||||||
clusterManager?.stop(); // close cluster connections
|
clusterManager?.stop(); // close cluster connections
|
||||||
|
|
||||||
return; // skip exit to make tray work, to quit go to app's global menu or tray's menu
|
if (blockQuit) {
|
||||||
|
event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.)
|
||||||
|
|
||||||
|
return; // skip exit to make tray work, to quit go to app's global menu or tray's menu
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("open-url", (event, rawUrl) => {
|
app.on("open-url", (event, rawUrl) => {
|
||||||
|
|||||||
@ -108,7 +108,7 @@ export class ShellSession extends EventEmitter {
|
|||||||
|
|
||||||
if(isWindows) {
|
if(isWindows) {
|
||||||
env["SystemRoot"] = process.env.SystemRoot;
|
env["SystemRoot"] = process.env.SystemRoot;
|
||||||
env["PTYSHELL"] = "powershell.exe";
|
env["PTYSHELL"] = process.env.SHELL || "powershell.exe";
|
||||||
env["PATH"] = pathStr;
|
env["PATH"] = pathStr;
|
||||||
} else if(typeof(process.env.SHELL) != "undefined") {
|
} else if(typeof(process.env.SHELL) != "undefined") {
|
||||||
env["PTYSHELL"] = process.env.SHELL;
|
env["PTYSHELL"] = process.env.SHELL;
|
||||||
|
|||||||
40
src/renderer/api/__tests__/api-manager.test.ts
Normal file
40
src/renderer/api/__tests__/api-manager.test.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { ingressStore } from "../../components/+network-ingresses/ingress.store";
|
||||||
|
import { apiManager } from "../api-manager";
|
||||||
|
import { KubeApi } from "../kube-api";
|
||||||
|
|
||||||
|
class TestApi extends KubeApi {
|
||||||
|
|
||||||
|
protected async checkPreferredVersion() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ApiManager", () => {
|
||||||
|
describe("registerApi", () => {
|
||||||
|
it("re-register store if apiBase changed", async () => {
|
||||||
|
const apiBase = "apis/v1/foo";
|
||||||
|
const fallbackApiBase = "/apis/extensions/v1beta1/foo";
|
||||||
|
const kubeApi = new TestApi({
|
||||||
|
apiBase,
|
||||||
|
fallbackApiBases: [fallbackApiBase],
|
||||||
|
checkPreferredVersion: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
apiManager.registerApi(apiBase, kubeApi);
|
||||||
|
|
||||||
|
// Define to use test api for ingress store
|
||||||
|
Object.defineProperty(ingressStore, "api", { value: kubeApi });
|
||||||
|
apiManager.registerStore(ingressStore, [kubeApi]);
|
||||||
|
|
||||||
|
// Test that store is returned with original apiBase
|
||||||
|
expect(apiManager.getStore(kubeApi)).toBe(ingressStore);
|
||||||
|
|
||||||
|
// Change apiBase similar as checkPreferredVersion does
|
||||||
|
Object.defineProperty(kubeApi, "apiBase", { value: fallbackApiBase });
|
||||||
|
apiManager.registerApi(fallbackApiBase, kubeApi);
|
||||||
|
|
||||||
|
// Test that store is returned with new apiBase
|
||||||
|
expect(apiManager.getStore(kubeApi)).toBe(ingressStore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -23,6 +23,12 @@ export class ApiManager {
|
|||||||
|
|
||||||
registerApi(apiBase: string, api: KubeApi) {
|
registerApi(apiBase: string, api: KubeApi) {
|
||||||
if (!this.apis.has(apiBase)) {
|
if (!this.apis.has(apiBase)) {
|
||||||
|
this.stores.forEach((store) => {
|
||||||
|
if(store.api === api) {
|
||||||
|
this.stores.set(apiBase, store);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.apis.set(apiBase, api);
|
this.apis.set(apiBase, api);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export class KubeEvent extends KubeObject {
|
|||||||
firstTimestamp: string;
|
firstTimestamp: string;
|
||||||
lastTimestamp: string;
|
lastTimestamp: string;
|
||||||
count: number;
|
count: number;
|
||||||
type: string;
|
type: "Normal" | "Warning" | string;
|
||||||
eventTime: null;
|
eventTime: null;
|
||||||
reportingComponent: string;
|
reportingComponent: string;
|
||||||
reportingInstance: string;
|
reportingInstance: string;
|
||||||
|
|||||||
@ -8,10 +8,10 @@ import { apiManager } from "./api-manager";
|
|||||||
import { apiKube } from "./index";
|
import { apiKube } from "./index";
|
||||||
import { createKubeApiURL, parseKubeApi } from "./kube-api-parse";
|
import { createKubeApiURL, parseKubeApi } from "./kube-api-parse";
|
||||||
import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
|
import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
|
||||||
import { IKubeObjectConstructor, KubeObject } from "./kube-object";
|
import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object";
|
||||||
import byline from "byline";
|
import byline from "byline";
|
||||||
import { ReadableWebToNodeStream } from "readable-web-to-node-stream";
|
|
||||||
import { IKubeWatchEvent } from "./kube-watch-api";
|
import { IKubeWatchEvent } from "./kube-watch-api";
|
||||||
|
import { ReadableWebToNodeStream } from "../utils/readableStream";
|
||||||
|
|
||||||
export interface IKubeApiOptions<T extends KubeObject> {
|
export interface IKubeApiOptions<T extends KubeObject> {
|
||||||
/**
|
/**
|
||||||
@ -93,9 +93,11 @@ export function ensureObjectSelfLink(api: KubeApi, object: KubeJsonApiData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type KubeApiWatchOptions = {
|
export type KubeApiWatchCallback = (data: IKubeWatchEvent, error: any) => void;
|
||||||
|
|
||||||
|
export type KubeApiWatchOptions = {
|
||||||
namespace: string;
|
namespace: string;
|
||||||
callback?: (data: IKubeWatchEvent) => void;
|
callback?: KubeApiWatchCallback;
|
||||||
abortController?: AbortController
|
abortController?: AbortController
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -370,8 +372,14 @@ export class KubeApi<T extends KubeObject = any> {
|
|||||||
if (!opts.abortController) {
|
if (!opts.abortController) {
|
||||||
opts.abortController = new AbortController();
|
opts.abortController = new AbortController();
|
||||||
}
|
}
|
||||||
|
let errorReceived = false;
|
||||||
|
let timedRetry: NodeJS.Timeout;
|
||||||
const { abortController, namespace, callback } = opts;
|
const { abortController, namespace, callback } = opts;
|
||||||
|
|
||||||
|
abortController.signal.addEventListener("abort", () => {
|
||||||
|
clearTimeout(timedRetry);
|
||||||
|
});
|
||||||
|
|
||||||
const watchUrl = this.getWatchUrl(namespace);
|
const watchUrl = this.getWatchUrl(namespace);
|
||||||
const responsePromise = this.request.getResponse(watchUrl, null, {
|
const responsePromise = this.request.getResponse(watchUrl, null, {
|
||||||
signal: abortController.signal
|
signal: abortController.signal
|
||||||
@ -379,49 +387,53 @@ export class KubeApi<T extends KubeObject = any> {
|
|||||||
|
|
||||||
responsePromise.then((response) => {
|
responsePromise.then((response) => {
|
||||||
if (!response.ok && !abortController.signal.aborted) {
|
if (!response.ok && !abortController.signal.aborted) {
|
||||||
if (response.status === 410) { // resourceVersion has gone
|
callback?.(null, response);
|
||||||
setTimeout(() => {
|
|
||||||
this.refreshResourceVersion().then(() => {
|
|
||||||
this.watch({...opts, abortController});
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
} else if (response.status >= 500) { // k8s is having hard time
|
|
||||||
setTimeout(() => {
|
|
||||||
this.watch({...opts, abortController});
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nodeStream = new ReadableWebToNodeStream(response.body);
|
const nodeStream = new ReadableWebToNodeStream(response.body);
|
||||||
|
|
||||||
|
["end", "close", "error"].forEach((eventName) => {
|
||||||
|
nodeStream.on(eventName, () => {
|
||||||
|
if (errorReceived) return; // kubernetes errors should be handled in a callback
|
||||||
|
|
||||||
|
clearTimeout(timedRetry);
|
||||||
|
timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
|
||||||
|
this.watch({...opts, namespace, callback});
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const stream = byline(nodeStream);
|
const stream = byline(nodeStream);
|
||||||
|
|
||||||
stream.on("data", (line) => {
|
stream.on("data", (line) => {
|
||||||
try {
|
try {
|
||||||
const event: IKubeWatchEvent = JSON.parse(line);
|
const event: IKubeWatchEvent = JSON.parse(line);
|
||||||
|
|
||||||
|
if (event.type === "ERROR" && event.object.kind === "Status") {
|
||||||
|
errorReceived = true;
|
||||||
|
callback(null, new KubeStatus(event.object as any));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.modifyWatchEvent(event);
|
this.modifyWatchEvent(event);
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback(event);
|
callback(event, null);
|
||||||
}
|
}
|
||||||
} catch (ignore) {
|
} catch (ignore) {
|
||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on("close", () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!abortController.signal.aborted) this.watch({...opts, namespace, callback});
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
if (error instanceof DOMException) return; // AbortController rejects, we can ignore it
|
if (error instanceof DOMException) return; // AbortController rejects, we can ignore it
|
||||||
|
|
||||||
console.error("watch rejected", error);
|
callback?.(null, error);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error("watch error", error);
|
callback?.(null, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
const disposer = () => {
|
const disposer = () => {
|
||||||
|
|||||||
@ -40,6 +40,29 @@ export interface IKubeObjectMetadata {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IKubeStatus {
|
||||||
|
kind: string;
|
||||||
|
apiVersion: string;
|
||||||
|
code: number;
|
||||||
|
message?: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KubeStatus {
|
||||||
|
public readonly kind = "Status";
|
||||||
|
public readonly apiVersion: string;
|
||||||
|
public readonly code: number;
|
||||||
|
public readonly message: string;
|
||||||
|
public readonly reason: string;
|
||||||
|
|
||||||
|
constructor(data: IKubeStatus) {
|
||||||
|
this.apiVersion = data.apiVersion;
|
||||||
|
this.code = data.code;
|
||||||
|
this.message = data.message || "";
|
||||||
|
this.reason = data.reason || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type IKubeMetaField = keyof IKubeObjectMetadata;
|
export type IKubeMetaField = keyof IKubeObjectMetadata;
|
||||||
|
|
||||||
@autobind()
|
@autobind()
|
||||||
@ -99,12 +122,15 @@ export class KubeObject implements ItemObject {
|
|||||||
return this.metadata.namespace || undefined;
|
return this.metadata.namespace || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: refactor with named arguments
|
getTimeDiffFromNow(): number {
|
||||||
getAge(humanize = true, compact = true, fromNow = false) {
|
return new Date().getTime() - new Date(this.metadata.creationTimestamp).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
getAge(humanize = true, compact = true, fromNow = false): string | number {
|
||||||
if (fromNow) {
|
if (fromNow) {
|
||||||
return moment(this.metadata.creationTimestamp).fromNow();
|
return moment(this.metadata.creationTimestamp).fromNow(); // "string", getTimeDiffFromNow() cannot be used
|
||||||
}
|
}
|
||||||
const diff = new Date().getTime() - new Date(this.metadata.creationTimestamp).getTime();
|
const diff = this.getTimeDiffFromNow();
|
||||||
|
|
||||||
if (humanize) {
|
if (humanize) {
|
||||||
return formatDuration(diff, compact);
|
return formatDuration(diff, compact);
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { KubeJsonApiData } from "./kube-json-api";
|
|||||||
import { isDebugging, isProduction } from "../../common/vars";
|
import { isDebugging, isProduction } from "../../common/vars";
|
||||||
|
|
||||||
export interface IKubeWatchEvent<T = KubeJsonApiData> {
|
export interface IKubeWatchEvent<T = KubeJsonApiData> {
|
||||||
type: "ADDED" | "MODIFIED" | "DELETED";
|
type: "ADDED" | "MODIFIED" | "DELETED" | "ERROR";
|
||||||
object?: T;
|
object?: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,19 +32,9 @@ export interface IKubeWatchLog {
|
|||||||
@autobind()
|
@autobind()
|
||||||
export class KubeWatchApi {
|
export class KubeWatchApi {
|
||||||
@observable context: ClusterContext = null;
|
@observable context: ClusterContext = null;
|
||||||
@observable subscribers = observable.map<KubeApi, number>();
|
|
||||||
@observable isConnected = false;
|
|
||||||
|
|
||||||
contextReady = when(() => Boolean(this.context));
|
contextReady = when(() => Boolean(this.context));
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async init() {
|
|
||||||
await this.contextReady;
|
|
||||||
}
|
|
||||||
|
|
||||||
isAllowedApi(api: KubeApi): boolean {
|
isAllowedApi(api: KubeApi): boolean {
|
||||||
return Boolean(this.context?.cluster.isAllowedResource(api.kind));
|
return Boolean(this.context?.cluster.isAllowedResource(api.kind));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (amountChanged || labelsChanged) {
|
if (amountChanged || labelsChanged) {
|
||||||
this.loadAll();
|
this.loadFromContextNamespaces();
|
||||||
}
|
}
|
||||||
this.releaseSecrets = [...secrets];
|
this.releaseSecrets = [...secrets];
|
||||||
});
|
});
|
||||||
@ -58,7 +58,7 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async loadAll(namespaces = namespaceStore.allowedNamespaces) {
|
async loadAll(namespaces: string[]) {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -78,9 +78,16 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadItems(namespaces: string[]) {
|
async loadItems(namespaces: string[]) {
|
||||||
return Promise
|
const isLoadingAll = namespaceStore.allowedNamespaces.every(ns => namespaces.includes(ns));
|
||||||
.all(namespaces.map(namespace => helmReleasesApi.list(namespace)))
|
const noAccessibleNamespaces = namespaceStore.context.cluster.accessibleNamespaces.length === 0;
|
||||||
.then(items => items.flat());
|
|
||||||
|
if (isLoadingAll && noAccessibleNamespaces) {
|
||||||
|
return helmReleasesApi.list();
|
||||||
|
} else {
|
||||||
|
return Promise
|
||||||
|
.all(namespaces.map(namespace => helmReleasesApi.list(namespace)))
|
||||||
|
.then(items => items.flat());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(payload: IReleaseCreatePayload) {
|
async create(payload: IReleaseCreatePayload) {
|
||||||
|
|||||||
@ -13,8 +13,6 @@ import { ClusterIssues } from "./cluster-issues";
|
|||||||
import { ClusterMetrics } from "./cluster-metrics";
|
import { ClusterMetrics } from "./cluster-metrics";
|
||||||
import { clusterOverviewStore } from "./cluster-overview.store";
|
import { clusterOverviewStore } from "./cluster-overview.store";
|
||||||
import { ClusterPieCharts } from "./cluster-pie-charts";
|
import { ClusterPieCharts } from "./cluster-pie-charts";
|
||||||
import { eventStore } from "../+events/event.store";
|
|
||||||
import { kubeWatchApi } from "../../api/kube-watch-api";
|
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class ClusterOverview extends React.Component {
|
export class ClusterOverview extends React.Component {
|
||||||
@ -28,10 +26,6 @@ export class ClusterOverview extends React.Component {
|
|||||||
this.metricPoller.start(true);
|
this.metricPoller.start(true);
|
||||||
|
|
||||||
disposeOnUnmount(this, [
|
disposeOnUnmount(this, [
|
||||||
kubeWatchApi.subscribeStores([nodesStore, podsStore, eventStore], {
|
|
||||||
preload: true,
|
|
||||||
}),
|
|
||||||
|
|
||||||
reaction(
|
reaction(
|
||||||
() => clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher
|
() => clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher
|
||||||
() => this.metricPoller.restart(true)
|
() => this.metricPoller.restart(true)
|
||||||
|
|||||||
@ -20,8 +20,8 @@ export class EventStore extends KubeObjectStore<KubeEvent> {
|
|||||||
|
|
||||||
protected sortItems(items: KubeEvent[]) {
|
protected sortItems(items: KubeEvent[]) {
|
||||||
return super.sortItems(items, [
|
return super.sortItems(items, [
|
||||||
event => event.metadata.creationTimestamp
|
event => event.getTimeDiffFromNow(), // keep events order as timeline ("fresh" on top)
|
||||||
], "desc");
|
], "asc");
|
||||||
}
|
}
|
||||||
|
|
||||||
getEventsByObject(obj: KubeObject): KubeEvent[] {
|
getEventsByObject(obj: KubeObject): KubeEvent[] {
|
||||||
|
|||||||
@ -1,16 +1,21 @@
|
|||||||
import "./events.scss";
|
import "./events.scss";
|
||||||
|
|
||||||
import React, { Fragment } from "react";
|
import React, { Fragment } from "react";
|
||||||
|
import { computed, observable } from "mobx";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { orderBy } from "lodash";
|
||||||
import { TabLayout } from "../layout/tab-layout";
|
import { TabLayout } from "../layout/tab-layout";
|
||||||
import { eventStore } from "./event.store";
|
import { EventStore, eventStore } from "./event.store";
|
||||||
import { getDetailsUrl, KubeObjectListLayout, KubeObjectListLayoutProps } from "../kube-object";
|
import { getDetailsUrl, KubeObjectListLayout, KubeObjectListLayoutProps } from "../kube-object";
|
||||||
import { KubeEvent } from "../../api/endpoints/events.api";
|
import { KubeEvent } from "../../api/endpoints/events.api";
|
||||||
|
import { TableSortCallbacks, TableSortParams, TableProps } from "../table";
|
||||||
|
import { IHeaderPlaceholders } from "../item-object-list";
|
||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { cssNames, IClassName, stopPropagation } from "../../utils";
|
import { cssNames, IClassName, stopPropagation } from "../../utils";
|
||||||
import { Icon } from "../icon";
|
import { Icon } from "../icon";
|
||||||
import { lookupApiLink } from "../../api/kube-api";
|
import { lookupApiLink } from "../../api/kube-api";
|
||||||
|
import { eventsURL } from "./events.route";
|
||||||
|
|
||||||
enum columnId {
|
enum columnId {
|
||||||
message = "message",
|
message = "message",
|
||||||
@ -36,62 +41,116 @@ const defaultProps: Partial<Props> = {
|
|||||||
export class Events extends React.Component<Props> {
|
export class Events extends React.Component<Props> {
|
||||||
static defaultProps = defaultProps as object;
|
static defaultProps = defaultProps as object;
|
||||||
|
|
||||||
|
@observable sorting: TableSortParams = {
|
||||||
|
sortBy: columnId.age,
|
||||||
|
orderBy: "asc",
|
||||||
|
};
|
||||||
|
|
||||||
|
private sortingCallbacks: TableSortCallbacks = {
|
||||||
|
[columnId.namespace]: (event: KubeEvent) => event.getNs(),
|
||||||
|
[columnId.type]: (event: KubeEvent) => event.type,
|
||||||
|
[columnId.object]: (event: KubeEvent) => event.involvedObject.name,
|
||||||
|
[columnId.count]: (event: KubeEvent) => event.count,
|
||||||
|
[columnId.age]: (event: KubeEvent) => event.getTimeDiffFromNow(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private tableConfiguration: TableProps = {
|
||||||
|
sortSyncWithUrl: false,
|
||||||
|
sortByDefault: this.sorting,
|
||||||
|
onSort: params => this.sorting = params,
|
||||||
|
};
|
||||||
|
|
||||||
|
get store(): EventStore {
|
||||||
|
return eventStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed get items(): KubeEvent[] {
|
||||||
|
const items = this.store.contextItems;
|
||||||
|
const { sortBy, orderBy: order } = this.sorting;
|
||||||
|
|
||||||
|
// we must sort items before passing to "KubeObjectListLayout -> Table"
|
||||||
|
// to make it work with "compact=true" (proper table sorting actions + initial items)
|
||||||
|
return orderBy(items, this.sortingCallbacks[sortBy], order as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed get visibleItems(): KubeEvent[] {
|
||||||
|
const { compact, compactLimit } = this.props;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return this.items.slice(0, compactLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
customizeHeader = ({ info, title }: IHeaderPlaceholders) => {
|
||||||
|
const { compact } = this.props;
|
||||||
|
const { store, items, visibleItems } = this;
|
||||||
|
const allEventsAreShown = visibleItems.length === items.length;
|
||||||
|
|
||||||
|
// handle "compact"-mode header
|
||||||
|
if (compact) {
|
||||||
|
if (allEventsAreShown) return title; // title == "Events"
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{title}
|
||||||
|
<span> ({visibleItems.length} of <Link to={eventsURL()}>{items.length}</Link>)</span>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
info: <>
|
||||||
|
{info}
|
||||||
|
<Icon
|
||||||
|
small
|
||||||
|
material="help_outline"
|
||||||
|
className="help-icon"
|
||||||
|
tooltip={`Limited to ${store.limit}`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const { store, visibleItems } = this;
|
||||||
const { compact, compactLimit, className, ...layoutProps } = this.props;
|
const { compact, compactLimit, className, ...layoutProps } = this.props;
|
||||||
|
|
||||||
const events = (
|
const events = (
|
||||||
<KubeObjectListLayout
|
<KubeObjectListLayout
|
||||||
{...layoutProps}
|
{...layoutProps}
|
||||||
isConfigurable
|
isConfigurable
|
||||||
tableId="events"
|
tableId="events"
|
||||||
|
store={store}
|
||||||
className={cssNames("Events", className, { compact })}
|
className={cssNames("Events", className, { compact })}
|
||||||
store={eventStore}
|
renderHeaderTitle="Events"
|
||||||
|
customizeHeader={this.customizeHeader}
|
||||||
isSelectable={false}
|
isSelectable={false}
|
||||||
sortingCallbacks={{
|
items={visibleItems}
|
||||||
[columnId.namespace]: (event: KubeEvent) => event.getNs(),
|
virtual={!compact}
|
||||||
[columnId.type]: (event: KubeEvent) => event.involvedObject.kind,
|
tableProps={this.tableConfiguration}
|
||||||
[columnId.object]: (event: KubeEvent) => event.involvedObject.name,
|
sortingCallbacks={this.sortingCallbacks}
|
||||||
[columnId.count]: (event: KubeEvent) => event.count,
|
|
||||||
[columnId.age]: (event: KubeEvent) => event.metadata.creationTimestamp,
|
|
||||||
}}
|
|
||||||
searchFilters={[
|
searchFilters={[
|
||||||
(event: KubeEvent) => event.getSearchFields(),
|
(event: KubeEvent) => event.getSearchFields(),
|
||||||
(event: KubeEvent) => event.message,
|
(event: KubeEvent) => event.message,
|
||||||
(event: KubeEvent) => event.getSource(),
|
(event: KubeEvent) => event.getSource(),
|
||||||
(event: KubeEvent) => event.involvedObject.name,
|
(event: KubeEvent) => event.involvedObject.name,
|
||||||
]}
|
]}
|
||||||
renderHeaderTitle="Events"
|
|
||||||
customizeHeader={({ title, info }) => (
|
|
||||||
compact ? title : ({
|
|
||||||
info: (
|
|
||||||
<>
|
|
||||||
{info}
|
|
||||||
<Icon
|
|
||||||
small
|
|
||||||
material="help_outline"
|
|
||||||
className="help-icon"
|
|
||||||
tooltip={`Limited to ${eventStore.limit}`}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
renderTableHeader={[
|
renderTableHeader={[
|
||||||
|
{ title: "Type", className: "type", sortBy: columnId.type, id: columnId.type },
|
||||||
{ title: "Message", className: "message", id: columnId.message },
|
{ title: "Message", className: "message", id: columnId.message },
|
||||||
{ title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
|
{ title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
|
||||||
{ title: "Type", className: "type", sortBy: columnId.type, id: columnId.type },
|
|
||||||
{ title: "Involved Object", className: "object", sortBy: columnId.object, id: columnId.object },
|
{ title: "Involved Object", className: "object", sortBy: columnId.object, id: columnId.object },
|
||||||
{ title: "Source", className: "source", id: columnId.source },
|
{ title: "Source", className: "source", id: columnId.source },
|
||||||
{ title: "Count", className: "count", sortBy: columnId.count, id: columnId.count },
|
{ title: "Count", className: "count", sortBy: columnId.count, id: columnId.count },
|
||||||
{ title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
|
{ title: "Last Seen", className: "age", sortBy: columnId.age, id: columnId.age },
|
||||||
]}
|
]}
|
||||||
renderTableContents={(event: KubeEvent) => {
|
renderTableContents={(event: KubeEvent) => {
|
||||||
const { involvedObject, type, message } = event;
|
const { involvedObject, type, message } = event;
|
||||||
const { kind, name } = involvedObject;
|
|
||||||
const tooltipId = `message-${event.getId()}`;
|
const tooltipId = `message-${event.getId()}`;
|
||||||
const isWarning = type === "Warning";
|
const isWarning = event.isWarning();
|
||||||
const detailsUrl = getDetailsUrl(lookupApiLink(involvedObject, event));
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
type, // type of event: "Normal" or "Warning"
|
||||||
{
|
{
|
||||||
className: { warning: isWarning },
|
className: { warning: isWarning },
|
||||||
title: (
|
title: (
|
||||||
@ -104,17 +163,14 @@ export class Events extends React.Component<Props> {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
event.getNs(),
|
event.getNs(),
|
||||||
kind,
|
<Link key="link" to={getDetailsUrl(lookupApiLink(involvedObject, event))} onClick={stopPropagation}>
|
||||||
<Link key="link" to={detailsUrl} title={name} onClick={stopPropagation}>{name}</Link>,
|
{involvedObject.kind}: {involvedObject.name}
|
||||||
|
</Link>,
|
||||||
event.getSource(),
|
event.getSource(),
|
||||||
event.count,
|
event.count,
|
||||||
event.getAge(),
|
event.getAge(),
|
||||||
];
|
];
|
||||||
}}
|
}}
|
||||||
virtual={!compact}
|
|
||||||
filterItems={[
|
|
||||||
items => compact ? items.slice(0, compactLimit) : items,
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,77 @@
|
|||||||
|
import "./namespace-select.scss";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { components, PlaceholderProps } from "react-select";
|
||||||
|
|
||||||
|
import { Icon } from "../icon";
|
||||||
|
import { FilterIcon } from "../item-object-list/filter-icon";
|
||||||
|
import { FilterType } from "../item-object-list/page-filters.store";
|
||||||
|
import { SelectOption } from "../select";
|
||||||
|
import { NamespaceSelect } from "./namespace-select";
|
||||||
|
import { namespaceStore } from "./namespace.store";
|
||||||
|
|
||||||
|
const Placeholder = observer((props: PlaceholderProps<any>) => {
|
||||||
|
const getPlaceholder = (): React.ReactNode => {
|
||||||
|
const namespaces = namespaceStore.contextNamespaces;
|
||||||
|
|
||||||
|
switch (namespaces.length) {
|
||||||
|
case 0:
|
||||||
|
case namespaceStore.allowedNamespaces.length:
|
||||||
|
return <>All namespaces</>;
|
||||||
|
case 1:
|
||||||
|
return <>Namespace: {namespaces[0]}</>;
|
||||||
|
default:
|
||||||
|
return <>Namespaces: {namespaces.join(", ")}</>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<components.Placeholder {...props}>
|
||||||
|
{getPlaceholder()}
|
||||||
|
</components.Placeholder>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class NamespaceSelectFilter extends React.Component {
|
||||||
|
formatOptionLabel({ value: namespace, label }: SelectOption) {
|
||||||
|
if (namespace) {
|
||||||
|
const isSelected = namespaceStore.hasContext(namespace);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gaps align-center">
|
||||||
|
<FilterIcon type={FilterType.NAMESPACE}/>
|
||||||
|
<span>{namespace}</span>
|
||||||
|
{isSelected && <Icon small material="check" className="box right"/>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange([{ value: namespace }]: SelectOption[]) {
|
||||||
|
if (namespace) {
|
||||||
|
namespaceStore.toggleContext(namespace);
|
||||||
|
} else {
|
||||||
|
namespaceStore.resetContext(); // "All namespaces" clicked, empty list considered as "all"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<NamespaceSelect
|
||||||
|
isMulti={true}
|
||||||
|
components={{ Placeholder }}
|
||||||
|
showAllNamespacesOption={true}
|
||||||
|
closeMenuOnSelect={false}
|
||||||
|
controlShouldRenderValue={false}
|
||||||
|
placeholder={""}
|
||||||
|
onChange={this.onChange}
|
||||||
|
formatOptionLabel={this.formatOptionLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,8 +7,6 @@ import { Select, SelectOption, SelectProps } from "../select";
|
|||||||
import { cssNames } from "../../utils";
|
import { cssNames } from "../../utils";
|
||||||
import { Icon } from "../icon";
|
import { Icon } from "../icon";
|
||||||
import { namespaceStore } from "./namespace.store";
|
import { namespaceStore } from "./namespace.store";
|
||||||
import { FilterIcon } from "../item-object-list/filter-icon";
|
|
||||||
import { FilterType } from "../item-object-list/page-filters.store";
|
|
||||||
import { kubeWatchApi } from "../../api/kube-watch-api";
|
import { kubeWatchApi } from "../../api/kube-watch-api";
|
||||||
|
|
||||||
interface Props extends SelectProps {
|
interface Props extends SelectProps {
|
||||||
@ -35,7 +33,7 @@ export class NamespaceSelect extends React.Component<Props> {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed get options(): SelectOption[] {
|
@computed.struct get options(): SelectOption[] {
|
||||||
const { customizeOptions, showClusterOption, showAllNamespacesOption } = this.props;
|
const { customizeOptions, showClusterOption, showAllNamespacesOption } = this.props;
|
||||||
let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() }));
|
let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() }));
|
||||||
|
|
||||||
@ -78,59 +76,3 @@ export class NamespaceSelect extends React.Component<Props> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
|
||||||
export class NamespaceSelectFilter extends React.Component {
|
|
||||||
@computed get placeholder(): React.ReactNode {
|
|
||||||
const namespaces = namespaceStore.contextNamespaces;
|
|
||||||
|
|
||||||
switch (namespaces.length) {
|
|
||||||
case 0:
|
|
||||||
case namespaceStore.allowedNamespaces.length:
|
|
||||||
return <>All namespaces</>;
|
|
||||||
case 1:
|
|
||||||
return <>Namespace: {namespaces[0]}</>;
|
|
||||||
default:
|
|
||||||
return <>Namespaces: {namespaces.join(", ")}</>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formatOptionLabel = ({ value: namespace, label }: SelectOption) => {
|
|
||||||
if (namespace) {
|
|
||||||
const isSelected = namespaceStore.hasContext(namespace);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex gaps align-center">
|
|
||||||
<FilterIcon type={FilterType.NAMESPACE}/>
|
|
||||||
<span>{namespace}</span>
|
|
||||||
{isSelected && <Icon small material="check" className="box right"/>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return label;
|
|
||||||
};
|
|
||||||
|
|
||||||
onChange = ([{ value: namespace }]: SelectOption[]) => {
|
|
||||||
if (namespace) {
|
|
||||||
namespaceStore.toggleContext(namespace);
|
|
||||||
} else {
|
|
||||||
namespaceStore.resetContext(); // "All namespaces" clicked, empty list considered as "all"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<NamespaceSelect
|
|
||||||
isMulti={true}
|
|
||||||
showAllNamespacesOption={true}
|
|
||||||
closeMenuOnSelect={false}
|
|
||||||
isOptionSelected={() => false}
|
|
||||||
controlShouldRenderValue={false}
|
|
||||||
placeholder={this.placeholder}
|
|
||||||
onChange={this.onChange}
|
|
||||||
formatOptionLabel={this.formatOptionLabel}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import "@testing-library/jest-dom/extend-expect";
|
|||||||
|
|
||||||
import { DeploymentScaleDialog } from "./deployment-scale-dialog";
|
import { DeploymentScaleDialog } from "./deployment-scale-dialog";
|
||||||
jest.mock("../../api/endpoints");
|
jest.mock("../../api/endpoints");
|
||||||
import { deploymentApi } from "../../api/endpoints";
|
import { Deployment, deploymentApi } from "../../api/endpoints";
|
||||||
|
|
||||||
const dummyDeployment = {
|
const dummyDeployment: Deployment = {
|
||||||
apiVersion: "v1",
|
apiVersion: "v1",
|
||||||
kind: "dummy",
|
kind: "dummy",
|
||||||
metadata: {
|
metadata: {
|
||||||
@ -83,6 +83,7 @@ const dummyDeployment = {
|
|||||||
getName: jest.fn(),
|
getName: jest.fn(),
|
||||||
getNs: jest.fn(),
|
getNs: jest.fn(),
|
||||||
getAge: jest.fn(),
|
getAge: jest.fn(),
|
||||||
|
getTimeDiffFromNow: jest.fn(),
|
||||||
getFinalizers: jest.fn(),
|
getFinalizers: jest.fn(),
|
||||||
getLabels: jest.fn(),
|
getLabels: jest.fn(),
|
||||||
getAnnotations: jest.fn(),
|
getAnnotations: jest.fn(),
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { OverviewWorkloadStatus } from "./overview-workload-status";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { workloadURL, workloadStores } from "../+workloads";
|
import { workloadURL, workloadStores } from "../+workloads";
|
||||||
import { namespaceStore } from "../+namespaces/namespace.store";
|
import { namespaceStore } from "../+namespaces/namespace.store";
|
||||||
import { NamespaceSelectFilter } from "../+namespaces/namespace-select";
|
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
|
||||||
import { isAllowedResource, KubeResource } from "../../../common/rbac";
|
import { isAllowedResource, KubeResource } from "../../../common/rbac";
|
||||||
import { ResourceNames } from "../../utils/rbac";
|
import { ResourceNames } from "../../utils/rbac";
|
||||||
import { autobind } from "../../utils";
|
import { autobind } from "../../utils";
|
||||||
|
|||||||
@ -4,9 +4,9 @@ jest.mock("../../api/endpoints");
|
|||||||
import { ReplicaSetScaleDialog } from "./replicaset-scale-dialog";
|
import { ReplicaSetScaleDialog } from "./replicaset-scale-dialog";
|
||||||
import { render, waitFor, fireEvent } from "@testing-library/react";
|
import { render, waitFor, fireEvent } from "@testing-library/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { replicaSetApi } from "../../api/endpoints/replica-set.api";
|
import { ReplicaSet, replicaSetApi } from "../../api/endpoints/replica-set.api";
|
||||||
|
|
||||||
const dummyReplicaSet = {
|
const dummyReplicaSet: ReplicaSet = {
|
||||||
apiVersion: "v1",
|
apiVersion: "v1",
|
||||||
kind: "dummy",
|
kind: "dummy",
|
||||||
metadata: {
|
metadata: {
|
||||||
@ -67,7 +67,6 @@ const dummyReplicaSet = {
|
|||||||
getCurrent: jest.fn(),
|
getCurrent: jest.fn(),
|
||||||
getReady: jest.fn(),
|
getReady: jest.fn(),
|
||||||
getImages: jest.fn(),
|
getImages: jest.fn(),
|
||||||
getReplicas: jest.fn(),
|
|
||||||
getSelectors: jest.fn(),
|
getSelectors: jest.fn(),
|
||||||
getTemplateLabels: jest.fn(),
|
getTemplateLabels: jest.fn(),
|
||||||
getAffinity: jest.fn(),
|
getAffinity: jest.fn(),
|
||||||
@ -79,6 +78,7 @@ const dummyReplicaSet = {
|
|||||||
getName: jest.fn(),
|
getName: jest.fn(),
|
||||||
getNs: jest.fn(),
|
getNs: jest.fn(),
|
||||||
getAge: jest.fn(),
|
getAge: jest.fn(),
|
||||||
|
getTimeDiffFromNow: jest.fn(),
|
||||||
getFinalizers: jest.fn(),
|
getFinalizers: jest.fn(),
|
||||||
getLabels: jest.fn(),
|
getLabels: jest.fn(),
|
||||||
getAnnotations: jest.fn(),
|
getAnnotations: jest.fn(),
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import "@testing-library/jest-dom/extend-expect";
|
import "@testing-library/jest-dom/extend-expect";
|
||||||
|
|
||||||
jest.mock("../../api/endpoints");
|
jest.mock("../../api/endpoints");
|
||||||
import { statefulSetApi } from "../../api/endpoints";
|
import { StatefulSet, statefulSetApi } from "../../api/endpoints";
|
||||||
import { StatefulSetScaleDialog } from "./statefulset-scale-dialog";
|
import { StatefulSetScaleDialog } from "./statefulset-scale-dialog";
|
||||||
import { render, waitFor, fireEvent } from "@testing-library/react";
|
import { render, waitFor, fireEvent } from "@testing-library/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
const dummyStatefulSet = {
|
const dummyStatefulSet: StatefulSet = {
|
||||||
apiVersion: "v1",
|
apiVersion: "v1",
|
||||||
kind: "dummy",
|
kind: "dummy",
|
||||||
metadata: {
|
metadata: {
|
||||||
@ -88,6 +88,7 @@ const dummyStatefulSet = {
|
|||||||
getName: jest.fn(),
|
getName: jest.fn(),
|
||||||
getNs: jest.fn(),
|
getNs: jest.fn(),
|
||||||
getAge: jest.fn(),
|
getAge: jest.fn(),
|
||||||
|
getTimeDiffFromNow: jest.fn(),
|
||||||
getFinalizers: jest.fn(),
|
getFinalizers: jest.fn(),
|
||||||
getLabels: jest.fn(),
|
getLabels: jest.fn(),
|
||||||
getAnnotations: jest.fn(),
|
getAnnotations: jest.fn(),
|
||||||
|
|||||||
@ -52,7 +52,7 @@ export class ClusterIcon extends React.Component<Props> {
|
|||||||
cluster, showErrors, showTooltip, errorClass, options, interactive, isActive,
|
cluster, showErrors, showTooltip, errorClass, options, interactive, isActive,
|
||||||
children, ...elemProps
|
children, ...elemProps
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { name, preferences, id: clusterId } = cluster;
|
const { name, preferences, id: clusterId, online } = cluster;
|
||||||
const eventCount = this.eventCount;
|
const eventCount = this.eventCount;
|
||||||
const { icon } = preferences;
|
const { icon } = preferences;
|
||||||
const clusterIconId = `cluster-icon-${clusterId}`;
|
const clusterIconId = `cluster-icon-${clusterId}`;
|
||||||
@ -68,7 +68,7 @@ export class ClusterIcon extends React.Component<Props> {
|
|||||||
)}
|
)}
|
||||||
{icon && <img src={icon} alt={name}/>}
|
{icon && <img src={icon} alt={name}/>}
|
||||||
{!icon && <Hashicon value={clusterId} options={options}/>}
|
{!icon && <Hashicon value={clusterId} options={options}/>}
|
||||||
{showErrors && eventCount > 0 && !isActive && (
|
{showErrors && eventCount > 0 && !isActive && online && (
|
||||||
<Badge
|
<Badge
|
||||||
className={cssNames("events-count", errorClass)}
|
className={cssNames("events-count", errorClass)}
|
||||||
label={eventCount >= 1000 ? `${Math.ceil(eventCount / 1000)}k+` : eventCount}
|
label={eventCount >= 1000 ? `${Math.ceil(eventCount / 1000)}k+` : eventCount}
|
||||||
|
|||||||
@ -24,6 +24,9 @@ export async function initView(clusterId: ClusterId) {
|
|||||||
if (!cluster) {
|
if (!cluster) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`[LENS-VIEW]: waiting cluster to be ready, clusterId=${clusterId}`);
|
||||||
|
await cluster.whenReady;
|
||||||
logger.info(`[LENS-VIEW]: init dashboard, clusterId=${clusterId}`);
|
logger.info(`[LENS-VIEW]: init dashboard, clusterId=${clusterId}`);
|
||||||
const parentElem = document.getElementById("lens-views");
|
const parentElem = document.getElementById("lens-views");
|
||||||
const iframe = document.createElement("iframe");
|
const iframe = document.createElement("iframe");
|
||||||
@ -40,7 +43,15 @@ export async function initView(clusterId: ClusterId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function autoCleanOnRemove(clusterId: ClusterId, iframe: HTMLIFrameElement) {
|
export async function autoCleanOnRemove(clusterId: ClusterId, iframe: HTMLIFrameElement) {
|
||||||
await when(() => !clusterStore.getById(clusterId));
|
await when(() => {
|
||||||
|
const cluster = clusterStore.getById(clusterId);
|
||||||
|
|
||||||
|
if (!cluster) return true;
|
||||||
|
|
||||||
|
const view = lensViews.get(clusterId);
|
||||||
|
|
||||||
|
return cluster.disconnected && view?.isLoaded;
|
||||||
|
});
|
||||||
logger.info(`[LENS-VIEW]: remove dashboard, clusterId=${clusterId}`);
|
logger.info(`[LENS-VIEW]: remove dashboard, clusterId=${clusterId}`);
|
||||||
lensViews.delete(clusterId);
|
lensViews.delete(clusterId);
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,22 @@ export const clusterContext: ClusterContext = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
get allNamespaces(): string[] {
|
get allNamespaces(): string[] {
|
||||||
return this.cluster?.allowedNamespaces ?? [];
|
if (!this.cluster) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// user given list of namespaces
|
||||||
|
if (this.cluster?.accessibleNamespaces.length) {
|
||||||
|
return this.cluster.accessibleNamespaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (namespaceStore.items.length > 0) {
|
||||||
|
// namespaces from kubernetes api
|
||||||
|
return namespaceStore.items.map((namespace) => namespace.getName());
|
||||||
|
} else {
|
||||||
|
// fallback to cluster resolved namespaces because we could not load list
|
||||||
|
return this.cluster.allowedNamespaces || [];
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
get contextNamespaces(): string[] {
|
get contextNamespaces(): string[] {
|
||||||
|
|||||||
@ -4,6 +4,17 @@
|
|||||||
padding: var(--flex-gap);
|
padding: var(--flex-gap);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(300px, 1fr) minmax(300px, 1fr);
|
||||||
|
column-gap: 12px;
|
||||||
|
row-gap: 12px;
|
||||||
|
|
||||||
|
@media screen and (max-width: 900px) {
|
||||||
|
grid-template-columns: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
border: 5px solid $borderFaintColor;
|
border: 5px solid $borderFaintColor;
|
||||||
|
|||||||
@ -49,7 +49,7 @@ export class ErrorBoundary extends React.Component<Props, State> {
|
|||||||
<p>
|
<p>
|
||||||
To help us improve the product please report bugs to {slackLink} community or {githubLink} issues tracker.
|
To help us improve the product please report bugs to {slackLink} community or {githubLink} issues tracker.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gaps">
|
<div className="wrapper">
|
||||||
<code className="block">
|
<code className="block">
|
||||||
<p className="contrast">Component stack:</p>
|
<p className="contrast">Component stack:</p>
|
||||||
{errorInfo.componentStack}
|
{errorInfo.componentStack}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import { SearchInputUrl } from "../input";
|
|||||||
import { Filter, FilterType, pageFilters } from "./page-filters.store";
|
import { Filter, FilterType, pageFilters } from "./page-filters.store";
|
||||||
import { PageFiltersList } from "./page-filters-list";
|
import { PageFiltersList } from "./page-filters-list";
|
||||||
import { PageFiltersSelect } from "./page-filters-select";
|
import { PageFiltersSelect } from "./page-filters-select";
|
||||||
import { NamespaceSelectFilter } from "../+namespaces/namespace-select";
|
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
|
||||||
import { themeStore } from "../../theme.store";
|
import { themeStore } from "../../theme.store";
|
||||||
import { MenuActions } from "../menu/menu-actions";
|
import { MenuActions } from "../menu/menu-actions";
|
||||||
import { MenuItem } from "../menu";
|
import { MenuItem } from "../menu";
|
||||||
@ -28,7 +28,7 @@ import { namespaceStore } from "../+namespaces/namespace.store";
|
|||||||
export type SearchFilter<T extends ItemObject = any> = (item: T) => string | number | (string | number)[];
|
export type SearchFilter<T extends ItemObject = any> = (item: T) => string | number | (string | number)[];
|
||||||
export type ItemsFilter<T extends ItemObject = any> = (items: T[]) => T[];
|
export type ItemsFilter<T extends ItemObject = any> = (items: T[]) => T[];
|
||||||
|
|
||||||
interface IHeaderPlaceholders {
|
export interface IHeaderPlaceholders {
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
search: ReactNode;
|
search: ReactNode;
|
||||||
filters: ReactNode;
|
filters: ReactNode;
|
||||||
@ -282,8 +282,8 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
|
|||||||
const dialogCustomProps = customizeRemoveDialog ? customizeRemoveDialog(selectedItems) : {};
|
const dialogCustomProps = customizeRemoveDialog ? customizeRemoveDialog(selectedItems) : {};
|
||||||
const selectedCount = selectedItems.length;
|
const selectedCount = selectedItems.length;
|
||||||
const tailCount = selectedCount > visibleMaxNamesCount ? selectedCount - visibleMaxNamesCount : 0;
|
const tailCount = selectedCount > visibleMaxNamesCount ? selectedCount - visibleMaxNamesCount : 0;
|
||||||
const tail = tailCount > 0 ? "and <b>{tailCount}</b> more" : null;
|
const tail = tailCount > 0 ? <>, and <b>{tailCount}</b> more</> : null;
|
||||||
const message = selectedCount <= 1 ? <p>Remove item <b>{selectedNames}</b>?</p> : <p>Remove <b>{selectedCount}</b> items <b>{selectedNames}</b> {tail}?</p>;
|
const message = selectedCount <= 1 ? <p>Remove item <b>{selectedNames}</b>?</p> : <p>Remove <b>{selectedCount}</b> items <b>{selectedNames}</b>{tail}?</p>;
|
||||||
|
|
||||||
ConfirmDialog.open({
|
ConfirmDialog.open({
|
||||||
ok: removeSelectedItems,
|
ok: removeSelectedItems,
|
||||||
@ -372,7 +372,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
|
|||||||
let header = this.renderHeaderContent(placeholders);
|
let header = this.renderHeaderContent(placeholders);
|
||||||
|
|
||||||
if (customizeHeader) {
|
if (customizeHeader) {
|
||||||
const modifiedHeader = customizeHeader(placeholders, header);
|
const modifiedHeader = customizeHeader(placeholders, header) ?? {};
|
||||||
|
|
||||||
if (isReactNode(modifiedHeader)) {
|
if (isReactNode(modifiedHeader)) {
|
||||||
header = modifiedHeader;
|
header = modifiedHeader;
|
||||||
|
|||||||
@ -13,11 +13,21 @@ import { CrdResourceDetails } from "../+custom-resources";
|
|||||||
import { KubeObjectMenu } from "./kube-object-menu";
|
import { KubeObjectMenu } from "./kube-object-menu";
|
||||||
import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry";
|
import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to store `object.selfLink` to show more info about resource in the details panel.
|
||||||
|
*/
|
||||||
export const kubeDetailsUrlParam = createPageParam({
|
export const kubeDetailsUrlParam = createPageParam({
|
||||||
name: "kube-details",
|
name: "kube-details",
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to highlight last active/selected table row with the resource.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* If we go to "Nodes (page) -> Node (details) -> Pod (details)",
|
||||||
|
* last clicked Node should be "active" while Pod details are shown).
|
||||||
|
*/
|
||||||
export const kubeSelectedUrlParam = createPageParam({
|
export const kubeSelectedUrlParam = createPageParam({
|
||||||
name: "kube-selected",
|
name: "kube-selected",
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
@ -26,8 +36,8 @@ export const kubeSelectedUrlParam = createPageParam({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export function showDetails(details = "", resetSelected = true) {
|
export function showDetails(selfLink = "", resetSelected = true) {
|
||||||
const detailsUrl = getDetailsUrl(details, resetSelected);
|
const detailsUrl = getDetailsUrl(selfLink, resetSelected);
|
||||||
|
|
||||||
navigation.merge({ search: detailsUrl });
|
navigation.merge({ search: detailsUrl });
|
||||||
}
|
}
|
||||||
@ -36,18 +46,18 @@ export function hideDetails() {
|
|||||||
showDetails();
|
showDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDetailsUrl(details: string, resetSelected = false) {
|
export function getDetailsUrl(selfLink: string, resetSelected = false, mergeGlobals = true) {
|
||||||
const detailsUrl = kubeDetailsUrlParam.toSearchString({ value: details });
|
const params = new URLSearchParams(mergeGlobals ? navigation.searchParams : "");
|
||||||
|
|
||||||
|
params.set(kubeDetailsUrlParam.urlName, selfLink);
|
||||||
|
|
||||||
if (resetSelected) {
|
if (resetSelected) {
|
||||||
const params = new URLSearchParams(detailsUrl);
|
params.delete(kubeSelectedUrlParam.urlName);
|
||||||
|
} else {
|
||||||
params.delete(kubeSelectedUrlParam.name);
|
params.set(kubeSelectedUrlParam.urlName, kubeSelectedUrlParam.get());
|
||||||
|
|
||||||
return `?${params.toString()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return detailsUrl;
|
return `?${params}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KubeObjectDetailsProps<T = KubeObject> {
|
export interface KubeObjectDetailsProps<T = KubeObject> {
|
||||||
|
|||||||
@ -42,13 +42,13 @@ export class KubeObjectListLayout extends React.Component<KubeObjectListLayoutPr
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const items = this.props.store.contextItems;
|
const { className, store, items = store.contextItems, ...layoutProps } = this.props;
|
||||||
const { className, ...layoutProps } = this.props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ItemListLayout
|
<ItemListLayout
|
||||||
{...layoutProps}
|
{...layoutProps}
|
||||||
className={cssNames("KubeObjectListLayout", className)}
|
className={cssNames("KubeObjectListLayout", className)}
|
||||||
|
store={store}
|
||||||
items={items}
|
items={items}
|
||||||
preloadStores={false} // loading handled in kubeWatchApi.subscribeStores()
|
preloadStores={false} // loading handled in kubeWatchApi.subscribeStores()
|
||||||
detailsItem={this.selectedItem}
|
detailsItem={this.selectedItem}
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
.notification {
|
.notification {
|
||||||
flex: 0 0;
|
flex: 0 0;
|
||||||
padding: $padding;
|
padding: $padding * 1.5;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
min-width: 350px;
|
min-width: 350px;
|
||||||
max-width: 35vw;
|
max-width: 35vw;
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export type TableSortBy = string;
|
|||||||
export type TableOrderBy = "asc" | "desc" | string;
|
export type TableOrderBy = "asc" | "desc" | string;
|
||||||
export type TableSortParams = { sortBy: TableSortBy; orderBy: TableOrderBy };
|
export type TableSortParams = { sortBy: TableSortBy; orderBy: TableOrderBy };
|
||||||
export type TableSortCallback<D = any> = (data: D) => string | number | (string | number)[];
|
export type TableSortCallback<D = any> = (data: D) => string | number | (string | number)[];
|
||||||
|
export type TableSortCallbacks = { [columnId: string]: TableSortCallback };
|
||||||
|
|
||||||
export interface TableProps extends React.DOMAttributes<HTMLDivElement> {
|
export interface TableProps extends React.DOMAttributes<HTMLDivElement> {
|
||||||
items?: ItemObject[]; // Raw items data
|
items?: ItemObject[]; // Raw items data
|
||||||
@ -24,11 +25,11 @@ export interface TableProps extends React.DOMAttributes<HTMLDivElement> {
|
|||||||
selectable?: boolean; // Highlight rows on hover
|
selectable?: boolean; // Highlight rows on hover
|
||||||
scrollable?: boolean; // Use scrollbar if content is bigger than parent's height
|
scrollable?: boolean; // Use scrollbar if content is bigger than parent's height
|
||||||
storageKey?: string; // Keep some data in localStorage & restore on page reload, e.g sorting params
|
storageKey?: string; // Keep some data in localStorage & restore on page reload, e.g sorting params
|
||||||
sortable?: {
|
/**
|
||||||
// Define sortable callbacks for every column in <TableHead><TableCell sortBy="someCol"><TableHead>
|
* Define sortable callbacks for every column in <TableHead><TableCell sortBy="someCol"><TableHead>
|
||||||
// @sortItem argument in the callback is an object, provided in <TableRow sortItem={someColDataItem}/>
|
* @sortItem argument in the callback is an object, provided in <TableRow sortItem={someColDataItem}/>
|
||||||
[sortBy: string]: TableSortCallback;
|
*/
|
||||||
};
|
sortable?: TableSortCallbacks;
|
||||||
sortSyncWithUrl?: boolean; // sorting state is managed globally from url params
|
sortSyncWithUrl?: boolean; // sorting state is managed globally from url params
|
||||||
sortByDefault?: Partial<TableSortParams>; // default sorting params
|
sortByDefault?: Partial<TableSortParams>; // default sorting params
|
||||||
onSort?: (params: TableSortParams) => void; // callback on sort change, default: global sync with url
|
onSort?: (params: TableSortParams) => void; // callback on sort change, default: global sync with url
|
||||||
|
|||||||
@ -34,14 +34,14 @@ function UpdateAvailableHandler(event: IpcRendererEvent, ...[backchannel, update
|
|||||||
|
|
||||||
Notifications.info(
|
Notifications.info(
|
||||||
(
|
(
|
||||||
<>
|
<div className="flex column gaps">
|
||||||
<b>Update Available</b>
|
<b>Update Available</b>
|
||||||
<p>Version {updateInfo.version} of Lens IDE is now available. Would you like to update?</p>
|
<p>Version {updateInfo.version} of Lens IDE is now available. Would you like to update?</p>
|
||||||
<div className="flex gaps row align-left box grow">
|
<div className="flex gaps row align-left box grow">
|
||||||
<RenderYesButtons backchannel={backchannel} notificationId={notificationId} />
|
<RenderYesButtons backchannel={backchannel} notificationId={notificationId} />
|
||||||
<Button active outlined label="No" onClick={() => sendToBackchannel(backchannel, notificationId, { doUpdate: false })} />
|
<Button active outlined label="No" onClick={() => sendToBackchannel(backchannel, notificationId, { doUpdate: false })} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
), {
|
), {
|
||||||
id: notificationId,
|
id: notificationId,
|
||||||
onClose() {
|
onClose() {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { ClusterContext } from "./components/context";
|
|||||||
|
|
||||||
import { action, computed, observable, reaction, when } from "mobx";
|
import { action, computed, observable, reaction, when } from "mobx";
|
||||||
import { autobind } from "./utils";
|
import { autobind } from "./utils";
|
||||||
import { KubeObject } from "./api/kube-object";
|
import { KubeObject, KubeStatus } from "./api/kube-object";
|
||||||
import { IKubeWatchEvent } from "./api/kube-watch-api";
|
import { IKubeWatchEvent } from "./api/kube-watch-api";
|
||||||
import { ItemStore } from "./item.store";
|
import { ItemStore } from "./item.store";
|
||||||
import { apiManager } from "./api/api-manager";
|
import { apiManager } from "./api/api-manager";
|
||||||
@ -109,7 +109,9 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
|
|||||||
return api.list({}, this.query);
|
return api.list({}, this.query);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLoadingAll = this.context.allNamespaces.every(ns => namespaces.includes(ns));
|
const isLoadingAll = this.context.allNamespaces?.length > 1
|
||||||
|
&& this.context.cluster.accessibleNamespaces.length === 0
|
||||||
|
&& this.context.allNamespaces.every(ns => namespaces.includes(ns));
|
||||||
|
|
||||||
if (isLoadingAll) {
|
if (isLoadingAll) {
|
||||||
this.loadedNamespaces = [];
|
this.loadedNamespaces = [];
|
||||||
@ -149,8 +151,10 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
|
|||||||
if (merge) {
|
if (merge) {
|
||||||
this.mergeItems(items, { replace: false });
|
this.mergeItems(items, { replace: false });
|
||||||
} else {
|
} else {
|
||||||
return items;
|
this.mergeItems(items, { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Loading store items failed", { error, store: this });
|
console.error("Loading store items failed", { error, store: this });
|
||||||
this.resetOnError(error);
|
this.resetOnError(error);
|
||||||
@ -176,10 +180,10 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
|
|||||||
|
|
||||||
// update existing items
|
// update existing items
|
||||||
if (!replace) {
|
if (!replace) {
|
||||||
const partialIds = partialItems.map(item => item.getId());
|
const namespaces = partialItems.map(item => item.getNs());
|
||||||
|
|
||||||
items = [
|
items = [
|
||||||
...this.items.filter(existingItem => !partialIds.includes(existingItem.getId())),
|
...this.items.filter(existingItem => !namespaces.includes(existingItem.getNs())),
|
||||||
...partialItems,
|
...partialItems,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -267,31 +271,80 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
subscribe(apis = this.getSubscribeApis()) {
|
subscribe(apis = this.getSubscribeApis()) {
|
||||||
let disposers: {(): void}[] = [];
|
const abortController = new AbortController();
|
||||||
|
const namespaces = [...this.loadedNamespaces];
|
||||||
|
|
||||||
const callback = (data: IKubeWatchEvent) => {
|
if (this.context.cluster?.isGlobalWatchEnabled && namespaces.length === 0) {
|
||||||
if (!this.isLoaded) return;
|
apis.forEach(api => this.watchNamespace(api, "", abortController));
|
||||||
|
|
||||||
this.eventsBuffer.push(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.context.cluster?.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) {
|
|
||||||
disposers = apis.map(api => api.watch({
|
|
||||||
namespace: "",
|
|
||||||
callback: (data) => callback(data),
|
|
||||||
}));
|
|
||||||
} else {
|
} else {
|
||||||
apis.map(api => {
|
apis.forEach(api => {
|
||||||
this.loadedNamespaces.forEach((namespace) => {
|
this.loadedNamespaces.forEach((namespace) => {
|
||||||
disposers.push(api.watch({
|
this.watchNamespace(api, namespace, abortController);
|
||||||
namespace,
|
|
||||||
callback: (data) => callback(data)
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => disposers.forEach(dispose => dispose());
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private watchNamespace(api: KubeApi<T>, namespace: string, abortController: AbortController) {
|
||||||
|
let timedRetry: NodeJS.Timeout;
|
||||||
|
|
||||||
|
abortController.signal.addEventListener("abort", () => clearTimeout(timedRetry));
|
||||||
|
|
||||||
|
const callback = (data: IKubeWatchEvent, error: any) => {
|
||||||
|
if (!this.isLoaded || abortController.signal.aborted) return;
|
||||||
|
|
||||||
|
if (error instanceof Response) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
// api has gone, let's not retry
|
||||||
|
return;
|
||||||
|
} else { // not sure what to do, best to retry
|
||||||
|
if (timedRetry) clearTimeout(timedRetry);
|
||||||
|
timedRetry = setTimeout(() => {
|
||||||
|
api.watch({
|
||||||
|
namespace,
|
||||||
|
abortController,
|
||||||
|
callback
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
} else if (error instanceof KubeStatus && error.code === 410) {
|
||||||
|
if (timedRetry) clearTimeout(timedRetry);
|
||||||
|
// resourceVersion has gone, let's try to reload
|
||||||
|
timedRetry = setTimeout(() => {
|
||||||
|
(namespace === "" ? this.loadAll({ merge: false }) : this.loadAll({namespaces: [namespace]})).then(() => {
|
||||||
|
api.watch({
|
||||||
|
namespace,
|
||||||
|
abortController,
|
||||||
|
callback
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
} else if(error) { // not sure what to do, best to retry
|
||||||
|
if (timedRetry) clearTimeout(timedRetry);
|
||||||
|
|
||||||
|
timedRetry = setTimeout(() => {
|
||||||
|
api.watch({
|
||||||
|
namespace,
|
||||||
|
abortController,
|
||||||
|
callback
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
this.eventsBuffer.push(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
api.watch({
|
||||||
|
namespace,
|
||||||
|
abortController,
|
||||||
|
callback: (data, error) => callback(data, error)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export class PageParam<V = any> {
|
|||||||
static SYSTEM_PREFIX = "lens-";
|
static SYSTEM_PREFIX = "lens-";
|
||||||
|
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
protected urlName: string;
|
readonly urlName: string;
|
||||||
|
|
||||||
constructor(readonly init: PageParamInit<V> | PageSystemParamInit<V>, protected history: IObservableHistory) {
|
constructor(readonly init: PageParamInit<V> | PageSystemParamInit<V>, protected history: IObservableHistory) {
|
||||||
const { isSystem, name } = init as PageSystemParamInit;
|
const { isSystem, name } = init as PageSystemParamInit;
|
||||||
|
|||||||
87
src/renderer/utils/readableStream.ts
Normal file
87
src/renderer/utils/readableStream.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { Readable } from "readable-stream";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReadableWebToNodeStream
|
||||||
|
*
|
||||||
|
* Copied from https://github.com/Borewit/readable-web-to-node-stream
|
||||||
|
*
|
||||||
|
* Adds read error handler
|
||||||
|
*
|
||||||
|
* */
|
||||||
|
export class ReadableWebToNodeStream extends Readable {
|
||||||
|
|
||||||
|
public bytesRead = 0;
|
||||||
|
public released = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default web API stream reader
|
||||||
|
* https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader
|
||||||
|
*/
|
||||||
|
private reader: ReadableStreamReader;
|
||||||
|
private pendingRead: Promise<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param stream ReadableStream: https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream
|
||||||
|
*/
|
||||||
|
constructor(stream: ReadableStream) {
|
||||||
|
super();
|
||||||
|
this.reader = stream.getReader();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of readable._read(size).
|
||||||
|
* When readable._read() is called, if data is available from the resource,
|
||||||
|
* the implementation should begin pushing that data into the read queue
|
||||||
|
* https://nodejs.org/api/stream.html#stream_readable_read_size_1
|
||||||
|
*/
|
||||||
|
public async _read() {
|
||||||
|
// Should start pushing data into the queue
|
||||||
|
// Read data from the underlying Web-API-readable-stream
|
||||||
|
if (this.released) {
|
||||||
|
this.push(null); // Signal EOF
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.pendingRead = this.reader.read();
|
||||||
|
const data = await this.pendingRead;
|
||||||
|
|
||||||
|
// clear the promise before pushing pushing new data to the queue and allow sequential calls to _read()
|
||||||
|
delete this.pendingRead;
|
||||||
|
|
||||||
|
if (data.done || this.released) {
|
||||||
|
this.push(null); // Signal EOF
|
||||||
|
} else {
|
||||||
|
this.bytesRead += data.value.length;
|
||||||
|
this.push(data.value); // Push new data to the queue
|
||||||
|
}
|
||||||
|
} catch(error) {
|
||||||
|
this.push(null); // Signal EOF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If there is no unresolved read call to Web-API ReadableStream immediately returns;
|
||||||
|
* otherwise will wait until the read is resolved.
|
||||||
|
*/
|
||||||
|
public async waitForReadToComplete() {
|
||||||
|
if (this.pendingRead) {
|
||||||
|
await this.pendingRead;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close wrapper
|
||||||
|
*/
|
||||||
|
public async close(): Promise<void> {
|
||||||
|
await this.syncAndRelease();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncAndRelease() {
|
||||||
|
this.released = true;
|
||||||
|
await this.waitForReadToComplete();
|
||||||
|
await this.reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
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!
|
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.0-beta.1 (current version)
|
## 4.1.0 (current version)
|
||||||
|
|
||||||
- Change: list views default to a namespace (insted of listing resources from all namespaces)
|
**Upgrade note:** Where have all my pods gone? Namespaced Kubernetes resources are now initially shown only for the "default" namespace. Use the namespaces selector to add more.
|
||||||
|
|
||||||
|
- Change: list views default to a namespace (instead of listing resources from all namespaces)
|
||||||
- Command palette
|
- Command palette
|
||||||
- Generic logs view with Pod selector
|
- Generic logs view with Pod selector
|
||||||
- In-app survey extension
|
- In-app survey extension
|
||||||
|
- Auto-update notifications and confirmation
|
||||||
- Possibility to add custom Helm repository through Lens
|
- Possibility to add custom Helm repository through Lens
|
||||||
- Possibility to change visibility of common resource list columns
|
- Possibility to change visibility of common resource list columns
|
||||||
- Suspend / resume buttons for CronJobs
|
- Suspend / resume buttons for CronJobs
|
||||||
|
|||||||
16
yarn.lock
16
yarn.lock
@ -11489,14 +11489,6 @@ readable-stream@~1.1.10:
|
|||||||
isarray "0.0.1"
|
isarray "0.0.1"
|
||||||
string_decoder "~0.10.x"
|
string_decoder "~0.10.x"
|
||||||
|
|
||||||
readable-web-to-node-stream@^3.0.1:
|
|
||||||
version "3.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.1.tgz#3f619b1bc5dd73a4cfe5c5f9b4f6faba55dff845"
|
|
||||||
integrity sha512-4zDC6CvjUyusN7V0QLsXVB7pJCD9+vtrM9bYDRv6uBQ+SKfx36rp5AFNPRgh9auKRul/a1iFZJYXcCbwRL+SaA==
|
|
||||||
dependencies:
|
|
||||||
"@types/readable-stream" "^2.3.9"
|
|
||||||
readable-stream "^3.6.0"
|
|
||||||
|
|
||||||
readdir-scoped-modules@^1.0.0, readdir-scoped-modules@^1.1.0:
|
readdir-scoped-modules@^1.0.0, readdir-scoped-modules@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309"
|
resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309"
|
||||||
@ -14417,10 +14409,10 @@ xterm-addon-fit@^0.4.0:
|
|||||||
resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.4.0.tgz#06e0c5d0a6aaacfb009ef565efa1c81e93d90193"
|
resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.4.0.tgz#06e0c5d0a6aaacfb009ef565efa1c81e93d90193"
|
||||||
integrity sha512-p4BESuV/g2L6pZzFHpeNLLnep9mp/DkF3qrPglMiucSFtD8iJxtMufEoEJbN8LZwB4i+8PFpFvVuFrGOSpW05w==
|
integrity sha512-p4BESuV/g2L6pZzFHpeNLLnep9mp/DkF3qrPglMiucSFtD8iJxtMufEoEJbN8LZwB4i+8PFpFvVuFrGOSpW05w==
|
||||||
|
|
||||||
xterm@^4.6.0:
|
xterm@^4.10.0:
|
||||||
version "4.6.0"
|
version "4.10.0"
|
||||||
resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.6.0.tgz#1b49b32e546409c110fbe8ece0b4a388504a937d"
|
resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.10.0.tgz#fc4f554e3e718aff9b83622e858e64b0953067bb"
|
||||||
integrity sha512-98211RIDrAECqpsxs6gbilwMcxLtxSDIvtzZUIqP1xIByXtuccJ4pmMhHGJATZeEGe/reARPMqwPINK8T7jGZg==
|
integrity sha512-Wn66I8YpSVkgP3R95GjABC6Eb21pFfnCSnyIqKIIoUI13ohvwd0KGVzUDfyEFfSAzKbPJfrT2+vt7SfUXBZQKQ==
|
||||||
|
|
||||||
y18n@^3.2.1:
|
y18n@^3.2.1:
|
||||||
version "3.2.1"
|
version "3.2.1"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user