1
0
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:
Sebastian Malton 2021-02-18 10:04:15 -05:00
commit 65274f0d5f
41 changed files with 618 additions and 243 deletions

View File

@ -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

View File

@ -15,7 +15,10 @@ export default class SurveyRendererExtension extends LensRendererExtension {
} }
]; ];
async onActivate() { async onActivate() {
// Activate extension only on main renderer
if (window.location.hostname === "localhost") {
await surveyPreferencesStore.loadExtension(this); await surveyPreferencesStore.loadExtension(this);
survey.start(); survey.start();
} }
}
} }

View File

@ -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"
} }
} }

View File

@ -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);
});
});

View File

@ -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 {

View File

@ -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;

View File

@ -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();
} }

View File

@ -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
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 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) => {

View File

@ -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;

View 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);
});
});
});

View File

@ -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);
} }
} }

View File

@ -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;

View File

@ -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 = () => {

View File

@ -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();
if (fromNow) {
return moment(this.metadata.creationTimestamp).fromNow();
} }
const diff = new Date().getTime() - new Date(this.metadata.creationTimestamp).getTime();
getAge(humanize = true, compact = true, fromNow = false): string | number {
if (fromNow) {
return moment(this.metadata.creationTimestamp).fromNow(); // "string", getTimeDiffFromNow() cannot be used
}
const diff = this.getTimeDiffFromNow();
if (humanize) { if (humanize) {
return formatDuration(diff, compact); return formatDuration(diff, compact);

View File

@ -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));
} }

View File

@ -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,10 +78,17 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
} }
async loadItems(namespaces: string[]) { async loadItems(namespaces: string[]) {
const isLoadingAll = namespaceStore.allowedNamespaces.every(ns => namespaces.includes(ns));
const noAccessibleNamespaces = namespaceStore.context.cluster.accessibleNamespaces.length === 0;
if (isLoadingAll && noAccessibleNamespaces) {
return helmReleasesApi.list();
} else {
return Promise return Promise
.all(namespaces.map(namespace => helmReleasesApi.list(namespace))) .all(namespaces.map(namespace => helmReleasesApi.list(namespace)))
.then(items => items.flat()); .then(items => items.flat());
} }
}
async create(payload: IReleaseCreatePayload) { async create(payload: IReleaseCreatePayload) {
const response = await helmReleasesApi.create(payload); const response = await helmReleasesApi.create(payload);

View File

@ -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)

View File

@ -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[] {

View File

@ -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,
]}
/> />
); );

View File

@ -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}
/>
);
}
}

View File

@ -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}
/>
);
}
}

View File

@ -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(),

View File

@ -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";

View File

@ -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(),

View File

@ -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(),

View File

@ -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}

View File

@ -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);

View File

@ -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[] {

View File

@ -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;

View File

@ -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}

View File

@ -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;

View File

@ -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> {

View File

@ -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}

View File

@ -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;

View File

@ -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

View File

@ -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() {

View File

@ -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

View File

@ -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;

View 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();
}
}

View File

@ -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

View File

@ -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"