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
|
||||
|
||||
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
|
||||
|
||||
@ -15,7 +15,10 @@ export default class SurveyRendererExtension extends LensRendererExtension {
|
||||
}
|
||||
];
|
||||
async onActivate() {
|
||||
await surveyPreferencesStore.loadExtension(this);
|
||||
survey.start();
|
||||
// Activate extension only on main renderer
|
||||
if (window.location.hostname === "localhost") {
|
||||
await surveyPreferencesStore.loadExtension(this);
|
||||
survey.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "kontena-lens",
|
||||
"productName": "Lens",
|
||||
"description": "Lens - The Kubernetes IDE",
|
||||
"version": "4.1.0-beta.1",
|
||||
"version": "4.1.0",
|
||||
"main": "static/build/main.js",
|
||||
"copyright": "© 2020, Mirantis, Inc.",
|
||||
"license": "MIT",
|
||||
@ -231,7 +231,7 @@
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-router": "^5.2.0",
|
||||
"readable-web-to-node-stream": "^3.0.1",
|
||||
"readable-stream": "^3.6.0",
|
||||
"request": "^2.88.2",
|
||||
"request-promise-native": "^1.0.8",
|
||||
"semver": "^7.3.2",
|
||||
@ -287,6 +287,7 @@
|
||||
"@types/react-router-dom": "^5.1.6",
|
||||
"@types/react-select": "^3.0.13",
|
||||
"@types/react-window": "^1.8.2",
|
||||
"@types/readable-stream": "^2.3.9",
|
||||
"@types/request": "^2.48.5",
|
||||
"@types/request-promise-native": "^1.0.17",
|
||||
"@types/semver": "^7.2.0",
|
||||
@ -369,7 +370,7 @@
|
||||
"webpack-dev-server": "^3.11.0",
|
||||
"webpack-node-externals": "^1.7.2",
|
||||
"what-input": "^5.2.10",
|
||||
"xterm": "^4.6.0",
|
||||
"xterm": "^4.10.0",
|
||||
"xterm-addon-fit": "^0.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import fs from "fs";
|
||||
import mockFs from "mock-fs";
|
||||
import yaml from "js-yaml";
|
||||
import { Cluster } from "../../main/cluster";
|
||||
import { ClusterStore } from "../cluster-store";
|
||||
import { ClusterStore, getClusterIdFromHost } from "../cluster-store";
|
||||
import { workspaceStore } from "../workspace-store";
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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 function getClusterIdFromHost(hostname: string): ClusterId {
|
||||
const subDomains = hostname.split(":")[0].split(".");
|
||||
export function getClusterIdFromHost(host: string): ClusterId | undefined {
|
||||
// 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) {
|
||||
@ -365,7 +366,7 @@ export function getClusterFrameUrl(clusterId: ClusterId) {
|
||||
}
|
||||
|
||||
export function getHostedClusterId() {
|
||||
return getClusterIdFromHost(location.hostname);
|
||||
return getClusterIdFromHost(location.host);
|
||||
}
|
||||
|
||||
export function getHostedCluster(): Cluster {
|
||||
|
||||
@ -10,9 +10,8 @@ function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: Upd
|
||||
if (arg.doUpdate) {
|
||||
if (arg.now) {
|
||||
logger.info(`${AutoUpdateLogPrefix}: User chose to update now`);
|
||||
autoUpdater.downloadUpdate()
|
||||
.then(() => autoUpdater.quitAndInstall())
|
||||
.catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download or install update`, { error }));
|
||||
autoUpdater.on("update-downloaded", () => autoUpdater.quitAndInstall());
|
||||
autoUpdater.downloadUpdate().catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download or install update`, { error }));
|
||||
} else {
|
||||
logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`);
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
@ -399,6 +399,7 @@ export class Cluster implements ClusterModel, ClusterState {
|
||||
this.accessible = false;
|
||||
this.ready = false;
|
||||
this.activated = false;
|
||||
this.allowedNamespaces = [];
|
||||
this.resourceAccessStatuses.clear();
|
||||
this.pushState();
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import "../common/system-ca";
|
||||
import "../common/prometheus-providers";
|
||||
import * as Mobx from "mobx";
|
||||
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 path from "path";
|
||||
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) => {
|
||||
// Quit app on Cmd+Q (MacOS)
|
||||
logger.info("APP:QUIT");
|
||||
appEventBus.emit({name: "app", action: "close"});
|
||||
event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.)
|
||||
|
||||
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) => {
|
||||
|
||||
@ -108,7 +108,7 @@ export class ShellSession extends EventEmitter {
|
||||
|
||||
if(isWindows) {
|
||||
env["SystemRoot"] = process.env.SystemRoot;
|
||||
env["PTYSHELL"] = "powershell.exe";
|
||||
env["PTYSHELL"] = process.env.SHELL || "powershell.exe";
|
||||
env["PATH"] = pathStr;
|
||||
} else if(typeof(process.env.SHELL) != "undefined") {
|
||||
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) {
|
||||
if (!this.apis.has(apiBase)) {
|
||||
this.stores.forEach((store) => {
|
||||
if(store.api === api) {
|
||||
this.stores.set(apiBase, store);
|
||||
}
|
||||
});
|
||||
|
||||
this.apis.set(apiBase, api);
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,7 +28,7 @@ export class KubeEvent extends KubeObject {
|
||||
firstTimestamp: string;
|
||||
lastTimestamp: string;
|
||||
count: number;
|
||||
type: string;
|
||||
type: "Normal" | "Warning" | string;
|
||||
eventTime: null;
|
||||
reportingComponent: string;
|
||||
reportingInstance: string;
|
||||
|
||||
@ -8,10 +8,10 @@ import { apiManager } from "./api-manager";
|
||||
import { apiKube } from "./index";
|
||||
import { createKubeApiURL, parseKubeApi } from "./kube-api-parse";
|
||||
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 { ReadableWebToNodeStream } from "readable-web-to-node-stream";
|
||||
import { IKubeWatchEvent } from "./kube-watch-api";
|
||||
import { ReadableWebToNodeStream } from "../utils/readableStream";
|
||||
|
||||
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;
|
||||
callback?: (data: IKubeWatchEvent) => void;
|
||||
callback?: KubeApiWatchCallback;
|
||||
abortController?: AbortController
|
||||
};
|
||||
|
||||
@ -370,8 +372,14 @@ export class KubeApi<T extends KubeObject = any> {
|
||||
if (!opts.abortController) {
|
||||
opts.abortController = new AbortController();
|
||||
}
|
||||
let errorReceived = false;
|
||||
let timedRetry: NodeJS.Timeout;
|
||||
const { abortController, namespace, callback } = opts;
|
||||
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
clearTimeout(timedRetry);
|
||||
});
|
||||
|
||||
const watchUrl = this.getWatchUrl(namespace);
|
||||
const responsePromise = this.request.getResponse(watchUrl, null, {
|
||||
signal: abortController.signal
|
||||
@ -379,49 +387,53 @@ export class KubeApi<T extends KubeObject = any> {
|
||||
|
||||
responsePromise.then((response) => {
|
||||
if (!response.ok && !abortController.signal.aborted) {
|
||||
if (response.status === 410) { // resourceVersion has gone
|
||||
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);
|
||||
}
|
||||
callback?.(null, response);
|
||||
|
||||
return;
|
||||
}
|
||||
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);
|
||||
|
||||
stream.on("data", (line) => {
|
||||
try {
|
||||
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);
|
||||
|
||||
if (callback) {
|
||||
callback(event);
|
||||
callback(event, null);
|
||||
}
|
||||
} catch (ignore) {
|
||||
// ignore parse errors
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
setTimeout(() => {
|
||||
if (!abortController.signal.aborted) this.watch({...opts, namespace, callback});
|
||||
}, 1000);
|
||||
});
|
||||
}, (error) => {
|
||||
if (error instanceof DOMException) return; // AbortController rejects, we can ignore it
|
||||
|
||||
console.error("watch rejected", error);
|
||||
callback?.(null, error);
|
||||
}).catch((error) => {
|
||||
console.error("watch error", error);
|
||||
callback?.(null, error);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@autobind()
|
||||
@ -99,12 +122,15 @@ export class KubeObject implements ItemObject {
|
||||
return this.metadata.namespace || undefined;
|
||||
}
|
||||
|
||||
// todo: refactor with named arguments
|
||||
getAge(humanize = true, compact = true, fromNow = false) {
|
||||
getTimeDiffFromNow(): number {
|
||||
return 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();
|
||||
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) {
|
||||
return formatDuration(diff, compact);
|
||||
|
||||
@ -12,7 +12,7 @@ import { KubeJsonApiData } from "./kube-json-api";
|
||||
import { isDebugging, isProduction } from "../../common/vars";
|
||||
|
||||
export interface IKubeWatchEvent<T = KubeJsonApiData> {
|
||||
type: "ADDED" | "MODIFIED" | "DELETED";
|
||||
type: "ADDED" | "MODIFIED" | "DELETED" | "ERROR";
|
||||
object?: T;
|
||||
}
|
||||
|
||||
@ -32,19 +32,9 @@ export interface IKubeWatchLog {
|
||||
@autobind()
|
||||
export class KubeWatchApi {
|
||||
@observable context: ClusterContext = null;
|
||||
@observable subscribers = observable.map<KubeApi, number>();
|
||||
@observable isConnected = false;
|
||||
|
||||
contextReady = when(() => Boolean(this.context));
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
private async init() {
|
||||
await this.contextReady;
|
||||
}
|
||||
|
||||
isAllowedApi(api: KubeApi): boolean {
|
||||
return Boolean(this.context?.cluster.isAllowedResource(api.kind));
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
|
||||
});
|
||||
|
||||
if (amountChanged || labelsChanged) {
|
||||
this.loadAll();
|
||||
this.loadFromContextNamespaces();
|
||||
}
|
||||
this.releaseSecrets = [...secrets];
|
||||
});
|
||||
@ -58,7 +58,7 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
|
||||
}
|
||||
|
||||
@action
|
||||
async loadAll(namespaces = namespaceStore.allowedNamespaces) {
|
||||
async loadAll(namespaces: string[]) {
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
@ -78,9 +78,16 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
|
||||
}
|
||||
|
||||
async loadItems(namespaces: string[]) {
|
||||
return Promise
|
||||
.all(namespaces.map(namespace => helmReleasesApi.list(namespace)))
|
||||
.then(items => items.flat());
|
||||
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
|
||||
.all(namespaces.map(namespace => helmReleasesApi.list(namespace)))
|
||||
.then(items => items.flat());
|
||||
}
|
||||
}
|
||||
|
||||
async create(payload: IReleaseCreatePayload) {
|
||||
|
||||
@ -13,8 +13,6 @@ import { ClusterIssues } from "./cluster-issues";
|
||||
import { ClusterMetrics } from "./cluster-metrics";
|
||||
import { clusterOverviewStore } from "./cluster-overview.store";
|
||||
import { ClusterPieCharts } from "./cluster-pie-charts";
|
||||
import { eventStore } from "../+events/event.store";
|
||||
import { kubeWatchApi } from "../../api/kube-watch-api";
|
||||
|
||||
@observer
|
||||
export class ClusterOverview extends React.Component {
|
||||
@ -28,10 +26,6 @@ export class ClusterOverview extends React.Component {
|
||||
this.metricPoller.start(true);
|
||||
|
||||
disposeOnUnmount(this, [
|
||||
kubeWatchApi.subscribeStores([nodesStore, podsStore, eventStore], {
|
||||
preload: true,
|
||||
}),
|
||||
|
||||
reaction(
|
||||
() => clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher
|
||||
() => this.metricPoller.restart(true)
|
||||
|
||||
@ -20,8 +20,8 @@ export class EventStore extends KubeObjectStore<KubeEvent> {
|
||||
|
||||
protected sortItems(items: KubeEvent[]) {
|
||||
return super.sortItems(items, [
|
||||
event => event.metadata.creationTimestamp
|
||||
], "desc");
|
||||
event => event.getTimeDiffFromNow(), // keep events order as timeline ("fresh" on top)
|
||||
], "asc");
|
||||
}
|
||||
|
||||
getEventsByObject(obj: KubeObject): KubeEvent[] {
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
import "./events.scss";
|
||||
|
||||
import React, { Fragment } from "react";
|
||||
import { computed, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { orderBy } from "lodash";
|
||||
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 { KubeEvent } from "../../api/endpoints/events.api";
|
||||
import { TableSortCallbacks, TableSortParams, TableProps } from "../table";
|
||||
import { IHeaderPlaceholders } from "../item-object-list";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cssNames, IClassName, stopPropagation } from "../../utils";
|
||||
import { Icon } from "../icon";
|
||||
import { lookupApiLink } from "../../api/kube-api";
|
||||
import { eventsURL } from "./events.route";
|
||||
|
||||
enum columnId {
|
||||
message = "message",
|
||||
@ -36,62 +41,116 @@ const defaultProps: Partial<Props> = {
|
||||
export class Events extends React.Component<Props> {
|
||||
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() {
|
||||
const { store, visibleItems } = this;
|
||||
const { compact, compactLimit, className, ...layoutProps } = this.props;
|
||||
|
||||
const events = (
|
||||
<KubeObjectListLayout
|
||||
{...layoutProps}
|
||||
isConfigurable
|
||||
tableId="events"
|
||||
store={store}
|
||||
className={cssNames("Events", className, { compact })}
|
||||
store={eventStore}
|
||||
renderHeaderTitle="Events"
|
||||
customizeHeader={this.customizeHeader}
|
||||
isSelectable={false}
|
||||
sortingCallbacks={{
|
||||
[columnId.namespace]: (event: KubeEvent) => event.getNs(),
|
||||
[columnId.type]: (event: KubeEvent) => event.involvedObject.kind,
|
||||
[columnId.object]: (event: KubeEvent) => event.involvedObject.name,
|
||||
[columnId.count]: (event: KubeEvent) => event.count,
|
||||
[columnId.age]: (event: KubeEvent) => event.metadata.creationTimestamp,
|
||||
}}
|
||||
items={visibleItems}
|
||||
virtual={!compact}
|
||||
tableProps={this.tableConfiguration}
|
||||
sortingCallbacks={this.sortingCallbacks}
|
||||
searchFilters={[
|
||||
(event: KubeEvent) => event.getSearchFields(),
|
||||
(event: KubeEvent) => event.message,
|
||||
(event: KubeEvent) => event.getSource(),
|
||||
(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={[
|
||||
{ title: "Type", className: "type", sortBy: columnId.type, id: columnId.type },
|
||||
{ title: "Message", className: "message", id: columnId.message },
|
||||
{ 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: "Source", className: "source", id: columnId.source },
|
||||
{ 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) => {
|
||||
const { involvedObject, type, message } = event;
|
||||
const { kind, name } = involvedObject;
|
||||
const tooltipId = `message-${event.getId()}`;
|
||||
const isWarning = type === "Warning";
|
||||
const detailsUrl = getDetailsUrl(lookupApiLink(involvedObject, event));
|
||||
const isWarning = event.isWarning();
|
||||
|
||||
return [
|
||||
type, // type of event: "Normal" or "Warning"
|
||||
{
|
||||
className: { warning: isWarning },
|
||||
title: (
|
||||
@ -104,17 +163,14 @@ export class Events extends React.Component<Props> {
|
||||
)
|
||||
},
|
||||
event.getNs(),
|
||||
kind,
|
||||
<Link key="link" to={detailsUrl} title={name} onClick={stopPropagation}>{name}</Link>,
|
||||
<Link key="link" to={getDetailsUrl(lookupApiLink(involvedObject, event))} onClick={stopPropagation}>
|
||||
{involvedObject.kind}: {involvedObject.name}
|
||||
</Link>,
|
||||
event.getSource(),
|
||||
event.count,
|
||||
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 { Icon } from "../icon";
|
||||
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";
|
||||
|
||||
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;
|
||||
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";
|
||||
jest.mock("../../api/endpoints");
|
||||
import { deploymentApi } from "../../api/endpoints";
|
||||
import { Deployment, deploymentApi } from "../../api/endpoints";
|
||||
|
||||
const dummyDeployment = {
|
||||
const dummyDeployment: Deployment = {
|
||||
apiVersion: "v1",
|
||||
kind: "dummy",
|
||||
metadata: {
|
||||
@ -83,6 +83,7 @@ const dummyDeployment = {
|
||||
getName: jest.fn(),
|
||||
getNs: jest.fn(),
|
||||
getAge: jest.fn(),
|
||||
getTimeDiffFromNow: jest.fn(),
|
||||
getFinalizers: jest.fn(),
|
||||
getLabels: jest.fn(),
|
||||
getAnnotations: jest.fn(),
|
||||
|
||||
@ -6,7 +6,7 @@ import { OverviewWorkloadStatus } from "./overview-workload-status";
|
||||
import { Link } from "react-router-dom";
|
||||
import { workloadURL, workloadStores } from "../+workloads";
|
||||
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 { ResourceNames } from "../../utils/rbac";
|
||||
import { autobind } from "../../utils";
|
||||
|
||||
@ -4,9 +4,9 @@ jest.mock("../../api/endpoints");
|
||||
import { ReplicaSetScaleDialog } from "./replicaset-scale-dialog";
|
||||
import { render, waitFor, fireEvent } from "@testing-library/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",
|
||||
kind: "dummy",
|
||||
metadata: {
|
||||
@ -67,7 +67,6 @@ const dummyReplicaSet = {
|
||||
getCurrent: jest.fn(),
|
||||
getReady: jest.fn(),
|
||||
getImages: jest.fn(),
|
||||
getReplicas: jest.fn(),
|
||||
getSelectors: jest.fn(),
|
||||
getTemplateLabels: jest.fn(),
|
||||
getAffinity: jest.fn(),
|
||||
@ -79,6 +78,7 @@ const dummyReplicaSet = {
|
||||
getName: jest.fn(),
|
||||
getNs: jest.fn(),
|
||||
getAge: jest.fn(),
|
||||
getTimeDiffFromNow: jest.fn(),
|
||||
getFinalizers: jest.fn(),
|
||||
getLabels: jest.fn(),
|
||||
getAnnotations: jest.fn(),
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import "@testing-library/jest-dom/extend-expect";
|
||||
|
||||
jest.mock("../../api/endpoints");
|
||||
import { statefulSetApi } from "../../api/endpoints";
|
||||
import { StatefulSet, statefulSetApi } from "../../api/endpoints";
|
||||
import { StatefulSetScaleDialog } from "./statefulset-scale-dialog";
|
||||
import { render, waitFor, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
const dummyStatefulSet = {
|
||||
const dummyStatefulSet: StatefulSet = {
|
||||
apiVersion: "v1",
|
||||
kind: "dummy",
|
||||
metadata: {
|
||||
@ -88,6 +88,7 @@ const dummyStatefulSet = {
|
||||
getName: jest.fn(),
|
||||
getNs: jest.fn(),
|
||||
getAge: jest.fn(),
|
||||
getTimeDiffFromNow: jest.fn(),
|
||||
getFinalizers: jest.fn(),
|
||||
getLabels: jest.fn(),
|
||||
getAnnotations: jest.fn(),
|
||||
|
||||
@ -52,7 +52,7 @@ export class ClusterIcon extends React.Component<Props> {
|
||||
cluster, showErrors, showTooltip, errorClass, options, interactive, isActive,
|
||||
children, ...elemProps
|
||||
} = this.props;
|
||||
const { name, preferences, id: clusterId } = cluster;
|
||||
const { name, preferences, id: clusterId, online } = cluster;
|
||||
const eventCount = this.eventCount;
|
||||
const { icon } = preferences;
|
||||
const clusterIconId = `cluster-icon-${clusterId}`;
|
||||
@ -68,7 +68,7 @@ export class ClusterIcon extends React.Component<Props> {
|
||||
)}
|
||||
{icon && <img src={icon} alt={name}/>}
|
||||
{!icon && <Hashicon value={clusterId} options={options}/>}
|
||||
{showErrors && eventCount > 0 && !isActive && (
|
||||
{showErrors && eventCount > 0 && !isActive && online && (
|
||||
<Badge
|
||||
className={cssNames("events-count", errorClass)}
|
||||
label={eventCount >= 1000 ? `${Math.ceil(eventCount / 1000)}k+` : eventCount}
|
||||
|
||||
@ -24,6 +24,9 @@ export async function initView(clusterId: ClusterId) {
|
||||
if (!cluster) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[LENS-VIEW]: waiting cluster to be ready, clusterId=${clusterId}`);
|
||||
await cluster.whenReady;
|
||||
logger.info(`[LENS-VIEW]: init dashboard, clusterId=${clusterId}`);
|
||||
const parentElem = document.getElementById("lens-views");
|
||||
const iframe = document.createElement("iframe");
|
||||
@ -40,7 +43,15 @@ export async function initView(clusterId: ClusterId) {
|
||||
}
|
||||
|
||||
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}`);
|
||||
lensViews.delete(clusterId);
|
||||
|
||||
|
||||
@ -14,7 +14,22 @@ export const clusterContext: ClusterContext = {
|
||||
},
|
||||
|
||||
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[] {
|
||||
|
||||
@ -4,6 +4,17 @@
|
||||
padding: var(--flex-gap);
|
||||
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 {
|
||||
max-height: none;
|
||||
border: 5px solid $borderFaintColor;
|
||||
|
||||
@ -49,7 +49,7 @@ export class ErrorBoundary extends React.Component<Props, State> {
|
||||
<p>
|
||||
To help us improve the product please report bugs to {slackLink} community or {githubLink} issues tracker.
|
||||
</p>
|
||||
<div className="flex gaps">
|
||||
<div className="wrapper">
|
||||
<code className="block">
|
||||
<p className="contrast">Component stack:</p>
|
||||
{errorInfo.componentStack}
|
||||
|
||||
@ -15,7 +15,7 @@ import { SearchInputUrl } from "../input";
|
||||
import { Filter, FilterType, pageFilters } from "./page-filters.store";
|
||||
import { PageFiltersList } from "./page-filters-list";
|
||||
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 { MenuActions } from "../menu/menu-actions";
|
||||
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 ItemsFilter<T extends ItemObject = any> = (items: T[]) => T[];
|
||||
|
||||
interface IHeaderPlaceholders {
|
||||
export interface IHeaderPlaceholders {
|
||||
title: ReactNode;
|
||||
search: ReactNode;
|
||||
filters: ReactNode;
|
||||
@ -282,8 +282,8 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
|
||||
const dialogCustomProps = customizeRemoveDialog ? customizeRemoveDialog(selectedItems) : {};
|
||||
const selectedCount = selectedItems.length;
|
||||
const tailCount = selectedCount > visibleMaxNamesCount ? selectedCount - visibleMaxNamesCount : 0;
|
||||
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 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>;
|
||||
|
||||
ConfirmDialog.open({
|
||||
ok: removeSelectedItems,
|
||||
@ -372,7 +372,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
|
||||
let header = this.renderHeaderContent(placeholders);
|
||||
|
||||
if (customizeHeader) {
|
||||
const modifiedHeader = customizeHeader(placeholders, header);
|
||||
const modifiedHeader = customizeHeader(placeholders, header) ?? {};
|
||||
|
||||
if (isReactNode(modifiedHeader)) {
|
||||
header = modifiedHeader;
|
||||
|
||||
@ -13,11 +13,21 @@ import { CrdResourceDetails } from "../+custom-resources";
|
||||
import { KubeObjectMenu } from "./kube-object-menu";
|
||||
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({
|
||||
name: "kube-details",
|
||||
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({
|
||||
name: "kube-selected",
|
||||
isSystem: true,
|
||||
@ -26,8 +36,8 @@ export const kubeSelectedUrlParam = createPageParam({
|
||||
}
|
||||
});
|
||||
|
||||
export function showDetails(details = "", resetSelected = true) {
|
||||
const detailsUrl = getDetailsUrl(details, resetSelected);
|
||||
export function showDetails(selfLink = "", resetSelected = true) {
|
||||
const detailsUrl = getDetailsUrl(selfLink, resetSelected);
|
||||
|
||||
navigation.merge({ search: detailsUrl });
|
||||
}
|
||||
@ -36,18 +46,18 @@ export function hideDetails() {
|
||||
showDetails();
|
||||
}
|
||||
|
||||
export function getDetailsUrl(details: string, resetSelected = false) {
|
||||
const detailsUrl = kubeDetailsUrlParam.toSearchString({ value: details });
|
||||
export function getDetailsUrl(selfLink: string, resetSelected = false, mergeGlobals = true) {
|
||||
const params = new URLSearchParams(mergeGlobals ? navigation.searchParams : "");
|
||||
|
||||
params.set(kubeDetailsUrlParam.urlName, selfLink);
|
||||
|
||||
if (resetSelected) {
|
||||
const params = new URLSearchParams(detailsUrl);
|
||||
|
||||
params.delete(kubeSelectedUrlParam.name);
|
||||
|
||||
return `?${params.toString()}`;
|
||||
params.delete(kubeSelectedUrlParam.urlName);
|
||||
} else {
|
||||
params.set(kubeSelectedUrlParam.urlName, kubeSelectedUrlParam.get());
|
||||
}
|
||||
|
||||
return detailsUrl;
|
||||
return `?${params}`;
|
||||
}
|
||||
|
||||
export interface KubeObjectDetailsProps<T = KubeObject> {
|
||||
|
||||
@ -42,13 +42,13 @@ export class KubeObjectListLayout extends React.Component<KubeObjectListLayoutPr
|
||||
};
|
||||
|
||||
render() {
|
||||
const items = this.props.store.contextItems;
|
||||
const { className, ...layoutProps } = this.props;
|
||||
const { className, store, items = store.contextItems, ...layoutProps } = this.props;
|
||||
|
||||
return (
|
||||
<ItemListLayout
|
||||
{...layoutProps}
|
||||
className={cssNames("KubeObjectListLayout", className)}
|
||||
store={store}
|
||||
items={items}
|
||||
preloadStores={false} // loading handled in kubeWatchApi.subscribeStores()
|
||||
detailsItem={this.selectedItem}
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
|
||||
.notification {
|
||||
flex: 0 0;
|
||||
padding: $padding;
|
||||
padding: $padding * 1.5;
|
||||
border-radius: 3px;
|
||||
min-width: 350px;
|
||||
max-width: 35vw;
|
||||
|
||||
@ -16,6 +16,7 @@ export type TableSortBy = string;
|
||||
export type TableOrderBy = "asc" | "desc" | string;
|
||||
export type TableSortParams = { sortBy: TableSortBy; orderBy: TableOrderBy };
|
||||
export type TableSortCallback<D = any> = (data: D) => string | number | (string | number)[];
|
||||
export type TableSortCallbacks = { [columnId: string]: TableSortCallback };
|
||||
|
||||
export interface TableProps extends React.DOMAttributes<HTMLDivElement> {
|
||||
items?: ItemObject[]; // Raw items data
|
||||
@ -24,11 +25,11 @@ export interface TableProps extends React.DOMAttributes<HTMLDivElement> {
|
||||
selectable?: boolean; // Highlight rows on hover
|
||||
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
|
||||
sortable?: {
|
||||
// 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}/>
|
||||
[sortBy: string]: TableSortCallback;
|
||||
};
|
||||
/**
|
||||
* 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}/>
|
||||
*/
|
||||
sortable?: TableSortCallbacks;
|
||||
sortSyncWithUrl?: boolean; // sorting state is managed globally from url params
|
||||
sortByDefault?: Partial<TableSortParams>; // default sorting params
|
||||
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(
|
||||
(
|
||||
<>
|
||||
<div className="flex column gaps">
|
||||
<b>Update Available</b>
|
||||
<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">
|
||||
<RenderYesButtons backchannel={backchannel} notificationId={notificationId} />
|
||||
<Button active outlined label="No" onClick={() => sendToBackchannel(backchannel, notificationId, { doUpdate: false })} />
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
), {
|
||||
id: notificationId,
|
||||
onClose() {
|
||||
|
||||
@ -2,7 +2,7 @@ import type { ClusterContext } from "./components/context";
|
||||
|
||||
import { action, computed, observable, reaction, when } from "mobx";
|
||||
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 { ItemStore } from "./item.store";
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
this.loadedNamespaces = [];
|
||||
@ -149,8 +151,10 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
|
||||
if (merge) {
|
||||
this.mergeItems(items, { replace: false });
|
||||
} else {
|
||||
return items;
|
||||
this.mergeItems(items, { replace: true });
|
||||
}
|
||||
|
||||
return items;
|
||||
} catch (error) {
|
||||
console.error("Loading store items failed", { error, store: this });
|
||||
this.resetOnError(error);
|
||||
@ -176,10 +180,10 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
|
||||
|
||||
// update existing items
|
||||
if (!replace) {
|
||||
const partialIds = partialItems.map(item => item.getId());
|
||||
const namespaces = partialItems.map(item => item.getNs());
|
||||
|
||||
items = [
|
||||
...this.items.filter(existingItem => !partialIds.includes(existingItem.getId())),
|
||||
...this.items.filter(existingItem => !namespaces.includes(existingItem.getNs())),
|
||||
...partialItems,
|
||||
];
|
||||
}
|
||||
@ -267,31 +271,80 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
|
||||
}
|
||||
|
||||
subscribe(apis = this.getSubscribeApis()) {
|
||||
let disposers: {(): void}[] = [];
|
||||
const abortController = new AbortController();
|
||||
const namespaces = [...this.loadedNamespaces];
|
||||
|
||||
const callback = (data: IKubeWatchEvent) => {
|
||||
if (!this.isLoaded) return;
|
||||
|
||||
this.eventsBuffer.push(data);
|
||||
};
|
||||
|
||||
if (this.context.cluster?.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) {
|
||||
disposers = apis.map(api => api.watch({
|
||||
namespace: "",
|
||||
callback: (data) => callback(data),
|
||||
}));
|
||||
if (this.context.cluster?.isGlobalWatchEnabled && namespaces.length === 0) {
|
||||
apis.forEach(api => this.watchNamespace(api, "", abortController));
|
||||
} else {
|
||||
apis.map(api => {
|
||||
apis.forEach(api => {
|
||||
this.loadedNamespaces.forEach((namespace) => {
|
||||
disposers.push(api.watch({
|
||||
namespace,
|
||||
callback: (data) => callback(data)
|
||||
}));
|
||||
this.watchNamespace(api, namespace, abortController);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -20,7 +20,7 @@ export class PageParam<V = any> {
|
||||
static SYSTEM_PREFIX = "lens-";
|
||||
|
||||
readonly name: string;
|
||||
protected urlName: string;
|
||||
readonly urlName: string;
|
||||
|
||||
constructor(readonly init: PageParamInit<V> | PageSystemParamInit<V>, protected history: IObservableHistory) {
|
||||
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!
|
||||
|
||||
## 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
|
||||
- Generic logs view with Pod selector
|
||||
- In-app survey extension
|
||||
- Auto-update notifications and confirmation
|
||||
- Possibility to add custom Helm repository through Lens
|
||||
- Possibility to change visibility of common resource list columns
|
||||
- Suspend / resume buttons for CronJobs
|
||||
|
||||
16
yarn.lock
16
yarn.lock
@ -11489,14 +11489,6 @@ readable-stream@~1.1.10:
|
||||
isarray "0.0.1"
|
||||
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:
|
||||
version "1.1.0"
|
||||
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"
|
||||
integrity sha512-p4BESuV/g2L6pZzFHpeNLLnep9mp/DkF3qrPglMiucSFtD8iJxtMufEoEJbN8LZwB4i+8PFpFvVuFrGOSpW05w==
|
||||
|
||||
xterm@^4.6.0:
|
||||
version "4.6.0"
|
||||
resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.6.0.tgz#1b49b32e546409c110fbe8ece0b4a388504a937d"
|
||||
integrity sha512-98211RIDrAECqpsxs6gbilwMcxLtxSDIvtzZUIqP1xIByXtuccJ4pmMhHGJATZeEGe/reARPMqwPINK8T7jGZg==
|
||||
xterm@^4.10.0:
|
||||
version "4.10.0"
|
||||
resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.10.0.tgz#fc4f554e3e718aff9b83622e858e64b0953067bb"
|
||||
integrity sha512-Wn66I8YpSVkgP3R95GjABC6Eb21pFfnCSnyIqKIIoUI13ohvwd0KGVzUDfyEFfSAzKbPJfrT2+vt7SfUXBZQKQ==
|
||||
|
||||
y18n@^3.2.1:
|
||||
version "3.2.1"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user