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

Merge pull request #1754 from lensapp/release/v4.0.2

Release v4.0.2
This commit is contained in:
Jari Kolehmainen 2020-12-11 18:30:21 +02:00 committed by GitHub
commit 9494933e4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 200 additions and 178 deletions

View File

@ -74,6 +74,7 @@ export class Tracker extends Util.Singleton {
} }
reportPeriodically() { reportPeriodically() {
this.reportData();
this.reportInterval = setInterval(() => { this.reportInterval = setInterval(() => {
this.reportData(); this.reportData();
}, 60 * 60 * 1000); // report every 1h }, 60 * 60 * 1000); // report every 1h

View File

@ -226,7 +226,7 @@ describe("Lens integration tests", () => {
pages: [{ pages: [{
name: "Cluster", name: "Cluster",
href: "cluster", href: "cluster",
expectedSelector: "div.Cluster div.label", expectedSelector: "div.ClusterOverview div.label",
expectedText: "Master" expectedText: "Master"
}] }]
}, },

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.0.1", "version": "4.0.2",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2020, Mirantis, Inc.", "copyright": "© 2020, Mirantis, Inc.",
"license": "MIT", "license": "MIT",

View File

@ -18,7 +18,7 @@ const mockedWatch = watch as jest.MockedFunction<typeof watch>;
describe("ExtensionDiscovery", () => { describe("ExtensionDiscovery", () => {
it("emits add for added extension", async done => { it("emits add for added extension", async done => {
globalThis.__non_webpack_require__.mockImplementationOnce(() => ({ globalThis.__non_webpack_require__.mockImplementation(() => ({
name: "my-extension" name: "my-extension"
})); }));
let addHandler: (filePath: string) => void; let addHandler: (filePath: string) => void;
@ -61,9 +61,6 @@ describe("ExtensionDiscovery", () => {
}); });
it("doesn't emit add for added file under extension", async done => { it("doesn't emit add for added file under extension", async done => {
globalThis.__non_webpack_require__.mockImplementationOnce(() => ({
name: "my-extension"
}));
let addHandler: (filePath: string) => void; let addHandler: (filePath: string) => void;
const mockWatchInstance: any = { const mockWatchInstance: any = {

View File

@ -155,23 +155,26 @@ export class ExtensionDiscovery {
.on("unlinkDir", this.handleWatchUnlinkDir); .on("unlinkDir", this.handleWatchUnlinkDir);
} }
handleWatchFileAdd = async (filePath: string) => { handleWatchFileAdd = async (manifestPath: string) => {
// e.g. "foo/package.json" // e.g. "foo/package.json"
const relativePath = path.relative(this.localFolderPath, filePath); const relativePath = path.relative(this.localFolderPath, manifestPath);
// Converts "foo/package.json" to ["foo", "package.json"], where length of 2 implies // Converts "foo/package.json" to ["foo", "package.json"], where length of 2 implies
// that the added file is in a folder under local folder path. // that the added file is in a folder under local folder path.
// This safeguards against a file watch being triggered under a sub-directory which is not an extension. // This safeguards against a file watch being triggered under a sub-directory which is not an extension.
const isUnderLocalFolderPath = relativePath.split(path.sep).length === 2; const isUnderLocalFolderPath = relativePath.split(path.sep).length === 2;
if (path.basename(filePath) === manifestFilename && isUnderLocalFolderPath) { if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
try { try {
const absPath = path.dirname(filePath); const absPath = path.dirname(manifestPath);
// this.loadExtensionFromPath updates this.packagesJson // this.loadExtensionFromPath updates this.packagesJson
const extension = await this.loadExtensionFromPath(absPath); const extension = await this.loadExtensionFromFolder(absPath);
if (extension) { if (extension) {
// Remove a broken symlink left by a previous installation if it exists.
await this.removeSymlinkByManifestPath(manifestPath);
// Install dependencies for the new extension // Install dependencies for the new extension
await this.installPackages(); await this.installPackages();
@ -199,6 +202,9 @@ export class ExtensionDiscovery {
.find(([, extensionFolder]) => filePath === extensionFolder)?.[0]; .find(([, extensionFolder]) => filePath === extensionFolder)?.[0];
if (extensionName !== undefined) { if (extensionName !== undefined) {
// If the extension is deleted manually while the application is running, also remove the symlink
await this.removeSymlinkByPackageName(extensionName);
delete this.packagesJson.dependencies[extensionName]; delete this.packagesJson.dependencies[extensionName];
// Reinstall dependencies to remove the extension from package.json // Reinstall dependencies to remove the extension from package.json
@ -216,6 +222,26 @@ export class ExtensionDiscovery {
} }
}; };
/**
* Remove the symlink under node_modules if exists.
* If we don't remove the symlink, the uninstall would leave a non-working symlink,
* which wouldn't be fixed if the extension was reinstalled, causing the extension not to work.
* @param name e.g. "@mirantis/lens-extension-cc"
*/
removeSymlinkByPackageName(name: string) {
return fs.remove(this.getInstalledPath(name));
}
/**
* Remove the symlink under node_modules if it exists.
* @param manifestPath Path to package.json
*/
removeSymlinkByManifestPath(manifestPath: string) {
const manifestJson = __non_webpack_require__(manifestPath);
return this.removeSymlinkByPackageName(manifestJson.name);
}
/** /**
* Uninstalls extension. * Uninstalls extension.
* The application will detect the folder unlink and remove the extension from the UI automatically. * The application will detect the folder unlink and remove the extension from the UI automatically.
@ -224,16 +250,7 @@ export class ExtensionDiscovery {
async uninstallExtension({ absolutePath, manifest }: InstalledExtension) { async uninstallExtension({ absolutePath, manifest }: InstalledExtension) {
logger.info(`${logModule} Uninstalling ${manifest.name}`); logger.info(`${logModule} Uninstalling ${manifest.name}`);
// remove the symlink under node_modules. await this.removeSymlinkByPackageName(manifest.name);
// If we don't remove the symlink, the uninstall would leave a non-working symlink,
// which wouldn't be fixed if the extension was reinstalled, causing the extension not to work.
await fs.remove(this.getInstalledPath(manifest.name));
const exists = await fs.pathExists(absolutePath);
if (!exists) {
throw new Error(`Extension path ${absolutePath} doesn't exist`);
}
// fs.remove does nothing if the path doesn't exist anymore // fs.remove does nothing if the path doesn't exist anymore
await fs.remove(absolutePath); await fs.remove(absolutePath);
@ -290,6 +307,10 @@ export class ExtensionDiscovery {
return path.join(this.getInstalledPath(name), manifestFilename); return path.join(this.getInstalledPath(name), manifestFilename);
} }
/**
* Returns InstalledExtension from path to package.json file.
* Also updates this.packagesJson.
*/
protected async getByManifest(manifestPath: string, { isBundled = false }: { protected async getByManifest(manifestPath: string, { isBundled = false }: {
isBundled?: boolean; isBundled?: boolean;
} = {}): Promise<InstalledExtension | null> { } = {}): Promise<InstalledExtension | null> {
@ -349,7 +370,7 @@ export class ExtensionDiscovery {
} }
const absPath = path.resolve(folderPath, fileName); const absPath = path.resolve(folderPath, fileName);
const extension = await this.loadExtensionFromPath(absPath, { isBundled: true }); const extension = await this.loadExtensionFromFolder(absPath, { isBundled: true });
if (extension) { if (extension) {
extensions.push(extension); extensions.push(extension);
@ -384,7 +405,7 @@ export class ExtensionDiscovery {
continue; continue;
} }
const extension = await this.loadExtensionFromPath(absPath); const extension = await this.loadExtensionFromFolder(absPath);
if (extension) { if (extension) {
extensions.push(extension); extensions.push(extension);
@ -398,8 +419,9 @@ export class ExtensionDiscovery {
/** /**
* Loads extension from absolute path, updates this.packagesJson to include it and returns the extension. * Loads extension from absolute path, updates this.packagesJson to include it and returns the extension.
* @param absPath Folder path to extension
*/ */
async loadExtensionFromPath(absPath: string, { isBundled = false }: { async loadExtensionFromFolder(absPath: string, { isBundled = false }: {
isBundled?: boolean; isBundled?: boolean;
} = {}): Promise<InstalledExtension | null> { } = {}): Promise<InstalledExtension | null> {
const manifestPath = path.resolve(absPath, manifestFilename); const manifestPath = path.resolve(absPath, manifestFilename);

View File

@ -1,17 +1,14 @@
.ClusterIssues { .ClusterIssues {
min-height: 350px; min-height: 350px;
position: relative; position: relative;
grid-column-start: 1;
grid-column-end: 3;
@include media("<1024px") { @include media("<1024px") {
grid-column-start: 1!important; grid-column-start: 1!important;
grid-column-end: 1!important; grid-column-end: 1!important;
} }
&.wide {
grid-column-start: 1;
grid-column-end: 3;
}
.SubHeader { .SubHeader {
.Icon { .Icon {
font-size: 130%; font-size: 130%;

View File

@ -6,10 +6,10 @@ import { observer } from "mobx-react";
import { nodesStore } from "../+nodes/nodes.store"; import { nodesStore } from "../+nodes/nodes.store";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Radio, RadioGroup } from "../radio"; import { Radio, RadioGroup } from "../radio";
import { clusterStore, MetricNodeRole, MetricType } from "./cluster.store"; import { clusterOverviewStore, MetricNodeRole, MetricType } from "./cluster-overview.store";
export const ClusterMetricSwitchers = observer(() => { export const ClusterMetricSwitchers = observer(() => {
const { metricType, metricNodeRole, getMetricsValues, metrics } = clusterStore; const { metricType, metricNodeRole, getMetricsValues, metrics } = clusterOverviewStore;
const { masterNodes, workerNodes } = nodesStore; const { masterNodes, workerNodes } = nodesStore;
const metricsValues = getMetricsValues(metrics); const metricsValues = getMetricsValues(metrics);
const disableRoles = !masterNodes.length || !workerNodes.length; const disableRoles = !masterNodes.length || !workerNodes.length;
@ -22,7 +22,7 @@ export const ClusterMetricSwitchers = observer(() => {
asButtons asButtons
className={cssNames("RadioGroup flex gaps", { disabled: disableRoles })} className={cssNames("RadioGroup flex gaps", { disabled: disableRoles })}
value={metricNodeRole} value={metricNodeRole}
onChange={(metric: MetricNodeRole) => clusterStore.metricNodeRole = metric} onChange={(metric: MetricNodeRole) => clusterOverviewStore.metricNodeRole = metric}
> >
<Radio label={<Trans>Master</Trans>} value={MetricNodeRole.MASTER}/> <Radio label={<Trans>Master</Trans>} value={MetricNodeRole.MASTER}/>
<Radio label={<Trans>Worker</Trans>} value={MetricNodeRole.WORKER}/> <Radio label={<Trans>Worker</Trans>} value={MetricNodeRole.WORKER}/>
@ -33,7 +33,7 @@ export const ClusterMetricSwitchers = observer(() => {
asButtons asButtons
className={cssNames("RadioGroup flex gaps", { disabled: disableMetrics })} className={cssNames("RadioGroup flex gaps", { disabled: disableMetrics })}
value={metricType} value={metricType}
onChange={(value: MetricType) => clusterStore.metricType = value} onChange={(value: MetricType) => clusterOverviewStore.metricType = value}
> >
<Radio label={<Trans>CPU</Trans>} value={MetricType.CPU}/> <Radio label={<Trans>CPU</Trans>} value={MetricType.CPU}/>
<Radio label={<Trans>Memory</Trans>} value={MetricType.MEMORY}/> <Radio label={<Trans>Memory</Trans>} value={MetricType.MEMORY}/>

View File

@ -3,7 +3,7 @@ import "./cluster-metrics.scss";
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { ChartOptions, ChartPoint } from "chart.js"; import { ChartOptions, ChartPoint } from "chart.js";
import { clusterStore, MetricType } from "./cluster.store"; import { clusterOverviewStore, MetricType } from "./cluster-overview.store";
import { BarChart } from "../chart"; import { BarChart } from "../chart";
import { bytesToUnits } from "../../utils"; import { bytesToUnits } from "../../utils";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
@ -13,10 +13,9 @@ import { ClusterMetricSwitchers } from "./cluster-metric-switchers";
import { getMetricLastPoints } from "../../api/endpoints/metrics.api"; import { getMetricLastPoints } from "../../api/endpoints/metrics.api";
export const ClusterMetrics = observer(() => { export const ClusterMetrics = observer(() => {
const { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics, liveMetrics } = clusterStore; const { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics } = clusterOverviewStore;
const { memoryCapacity, cpuCapacity } = getMetricLastPoints(clusterStore.metrics); const { memoryCapacity, cpuCapacity } = getMetricLastPoints(clusterOverviewStore.metrics);
const metricValues = getMetricsValues(metrics); const metricValues = getMetricsValues(metrics);
const liveMetricValues = getMetricsValues(liveMetrics);
const colors = { cpu: "#3D90CE", memory: "#C93DCE" }; const colors = { cpu: "#3D90CE", memory: "#C93DCE" };
const data = metricValues.map(value => ({ const data = metricValues.map(value => ({
x: value[0], x: value[0],
@ -70,7 +69,7 @@ export const ClusterMetrics = observer(() => {
const options = metricType === MetricType.CPU ? cpuOptions : memoryOptions; const options = metricType === MetricType.CPU ? cpuOptions : memoryOptions;
const renderMetrics = () => { const renderMetrics = () => {
if ((!metricValues.length || !liveMetricValues.length) && !metricsLoaded) { if (!metricValues.length && !metricsLoaded) {
return <Spinner center/>; return <Spinner center/>;
} }

View File

@ -1,4 +1,4 @@
.Cluster { .ClusterOverview {
$gridGap: $margin * 2; $gridGap: $margin * 2;
position: relative; position: relative;

View File

@ -1,4 +1,4 @@
import { observable, reaction, when } from "mobx"; import { action, observable, reaction, when } from "mobx";
import { KubeObjectStore } from "../../kube-object.store"; import { KubeObjectStore } from "../../kube-object.store";
import { Cluster, clusterApi, IClusterMetrics } from "../../api/endpoints"; import { Cluster, clusterApi, IClusterMetrics } from "../../api/endpoints";
import { autobind, createStorage } from "../../utils"; import { autobind, createStorage } from "../../utils";
@ -17,11 +17,10 @@ export enum MetricNodeRole {
} }
@autobind() @autobind()
export class ClusterStore extends KubeObjectStore<Cluster> { export class ClusterOverviewStore extends KubeObjectStore<Cluster> {
api = clusterApi; api = clusterApi;
@observable metrics: Partial<IClusterMetrics> = {}; @observable metrics: Partial<IClusterMetrics> = {};
@observable liveMetrics: Partial<IClusterMetrics> = {};
@observable metricsLoaded = false; @observable metricsLoaded = false;
@observable metricType: MetricType; @observable metricType: MetricType;
@observable metricNodeRole: MetricNodeRole; @observable metricNodeRole: MetricNodeRole;
@ -46,9 +45,8 @@ export class ClusterStore extends KubeObjectStore<Cluster> {
reaction(() => this.metricNodeRole, () => { reaction(() => this.metricNodeRole, () => {
if (!this.metricsLoaded) return; if (!this.metricsLoaded) return;
this.metrics = {}; this.metrics = {};
this.liveMetrics = {};
this.metricsLoaded = false; this.metricsLoaded = false;
this.getAllMetrics(); this.loadMetrics();
}); });
// check which node type to select // check which node type to select
@ -60,33 +58,16 @@ export class ClusterStore extends KubeObjectStore<Cluster> {
}); });
} }
@action
async loadMetrics(params?: IMetricsReqParams) { async loadMetrics(params?: IMetricsReqParams) {
await when(() => nodesStore.isLoaded); await when(() => nodesStore.isLoaded);
const { masterNodes, workerNodes } = nodesStore; const { masterNodes, workerNodes } = nodesStore;
const nodes = this.metricNodeRole === MetricNodeRole.MASTER && masterNodes.length ? masterNodes : workerNodes; const nodes = this.metricNodeRole === MetricNodeRole.MASTER && masterNodes.length ? masterNodes : workerNodes;
return clusterApi.getMetrics(nodes.map(node => node.getName()), params); this.metrics = await clusterApi.getMetrics(nodes.map(node => node.getName()), params);
}
async getAllMetrics() {
await this.getMetrics();
await this.getLiveMetrics();
this.metricsLoaded = true; this.metricsLoaded = true;
} }
async getMetrics() {
this.metrics = await this.loadMetrics();
}
async getLiveMetrics() {
const step = 3;
const range = 15;
const end = Date.now() / 1000;
const start = end - range;
this.liveMetrics = await this.loadMetrics({ start, end, step, range });
}
getMetricsValues(source: Partial<IClusterMetrics>): [number, string][] { getMetricsValues(source: Partial<IClusterMetrics>): [number, string][] {
switch (this.metricType) { switch (this.metricType) {
case MetricType.CPU: case MetricType.CPU:
@ -111,5 +92,5 @@ export class ClusterStore extends KubeObjectStore<Cluster> {
} }
} }
export const clusterStore = new ClusterStore(); export const clusterOverviewStore = new ClusterOverviewStore();
apiManager.registerStore(clusterStore); apiManager.registerStore(clusterOverviewStore);

View File

@ -0,0 +1,79 @@
import "./cluster-overview.scss";
import React from "react";
import { reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { eventStore } from "../+events/event.store";
import { nodesStore } from "../+nodes/nodes.store";
import { podsStore } from "../+workloads-pods/pods.store";
import { getHostedCluster } from "../../../common/cluster-store";
import { isAllowedResource } from "../../../common/rbac";
import { KubeObjectStore } from "../../kube-object.store";
import { interval } from "../../utils";
import { TabLayout } from "../layout/tab-layout";
import { Spinner } from "../spinner";
import { ClusterIssues } from "./cluster-issues";
import { ClusterMetrics } from "./cluster-metrics";
import { clusterOverviewStore } from "./cluster-overview.store";
import { ClusterPieCharts } from "./cluster-pie-charts";
@observer
export class ClusterOverview extends React.Component {
private stores: KubeObjectStore<any>[] = [];
private subscribers: Array<() => void> = [];
private metricPoller = interval(60, this.loadMetrics);
@disposeOnUnmount
fetchMetrics = reaction(
() => clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher
() => this.metricPoller.restart(true)
);
loadMetrics() {
getHostedCluster().available && clusterOverviewStore.loadMetrics();
}
async componentDidMount() {
if (isAllowedResource("nodes")) {
this.stores.push(nodesStore);
}
if (isAllowedResource("pods")) {
this.stores.push(podsStore);
}
if (isAllowedResource("events")) {
this.stores.push(eventStore);
}
await Promise.all(this.stores.map(store => store.loadAll()));
this.loadMetrics();
this.subscribers = this.stores.map(store => store.subscribe());
this.metricPoller.start();
}
componentWillUnmount() {
this.subscribers.forEach(dispose => dispose()); // unsubscribe all
this.metricPoller.stop();
}
render() {
const isLoaded = nodesStore.isLoaded && podsStore.isLoaded;
return (
<TabLayout>
<div className="ClusterOverview">
{!isLoaded ? <Spinner center/> : (
<>
<ClusterMetrics/>
<ClusterPieCharts/>
<ClusterIssues/>
</>
)}
</div>
</TabLayout>
);
}
}

View File

@ -4,7 +4,7 @@ import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { t, Trans } from "@lingui/macro"; import { t, Trans } from "@lingui/macro";
import { useLingui } from "@lingui/react"; import { useLingui } from "@lingui/react";
import { clusterStore, MetricNodeRole } from "./cluster.store"; import { clusterOverviewStore, MetricNodeRole } from "./cluster-overview.store";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { nodesStore } from "../+nodes/nodes.store"; import { nodesStore } from "../+nodes/nodes.store";
@ -27,7 +27,7 @@ export const ClusterPieCharts = observer(() => {
}; };
const renderCharts = () => { const renderCharts = () => {
const data = getMetricLastPoints(clusterStore.metrics); const data = getMetricLastPoints(clusterOverviewStore.metrics);
const { memoryUsage, memoryRequests, memoryCapacity, memoryLimits } = data; const { memoryUsage, memoryRequests, memoryCapacity, memoryLimits } = data;
const { cpuUsage, cpuRequests, cpuCapacity, cpuLimits } = data; const { cpuUsage, cpuRequests, cpuCapacity, cpuLimits } = data;
const { podUsage, podCapacity } = data; const { podUsage, podCapacity } = data;
@ -173,7 +173,7 @@ export const ClusterPieCharts = observer(() => {
const renderContent = () => { const renderContent = () => {
const { masterNodes, workerNodes } = nodesStore; const { masterNodes, workerNodes } = nodesStore;
const { metricNodeRole, metricsLoaded } = clusterStore; const { metricNodeRole, metricsLoaded } = clusterOverviewStore;
const nodes = metricNodeRole === MetricNodeRole.MASTER ? masterNodes : workerNodes; const nodes = metricNodeRole === MetricNodeRole.MASTER ? masterNodes : workerNodes;
if (!nodes.length) { if (!nodes.length) {
@ -192,7 +192,7 @@ export const ClusterPieCharts = observer(() => {
</div> </div>
); );
} }
const { memoryCapacity, cpuCapacity, podCapacity } = getMetricLastPoints(clusterStore.metrics); const { memoryCapacity, cpuCapacity, podCapacity } = getMetricLastPoints(clusterOverviewStore.metrics);
if (!memoryCapacity || !cpuCapacity || !podCapacity) { if (!memoryCapacity || !cpuCapacity || !podCapacity) {
return <ClusterNoMetrics className="empty"/>; return <ClusterNoMetrics className="empty"/>;

View File

@ -1,74 +0,0 @@
import "./cluster.scss";
import React from "react";
import { computed, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { TabLayout } from "../layout/tab-layout";
import { ClusterIssues } from "./cluster-issues";
import { Spinner } from "../spinner";
import { cssNames, interval, isElectron } from "../../utils";
import { ClusterPieCharts } from "./cluster-pie-charts";
import { ClusterMetrics } from "./cluster-metrics";
import { nodesStore } from "../+nodes/nodes.store";
import { podsStore } from "../+workloads-pods/pods.store";
import { clusterStore } from "./cluster.store";
import { eventStore } from "../+events/event.store";
import { isAllowedResource } from "../../../common/rbac";
import { getHostedCluster } from "../../../common/cluster-store";
@observer
export class Cluster extends React.Component {
private dependentStores = [nodesStore, podsStore];
private watchers = [
interval(60, () => { getHostedCluster().available && clusterStore.getMetrics();}),
interval(20, () => { getHostedCluster().available && eventStore.loadAll();})
];
@computed get isLoaded() {
return nodesStore.isLoaded && podsStore.isLoaded;
}
// todo: refactor
async componentDidMount() {
const { dependentStores } = this;
if (!isAllowedResource("nodes")) {
dependentStores.splice(dependentStores.indexOf(nodesStore), 1);
}
this.watchers.forEach(watcher => watcher.start(true));
await Promise.all([
...dependentStores.map(store => store.loadAll()),
clusterStore.getAllMetrics()
]);
disposeOnUnmount(this, [
...dependentStores.map(store => store.subscribe()),
() => this.watchers.forEach(watcher => watcher.stop()),
reaction(
() => clusterStore.metricNodeRole,
() => this.watchers.forEach(watcher => watcher.restart())
)
]);
}
render() {
const { isLoaded } = this;
return (
<TabLayout>
<div className="Cluster">
{!isLoaded && <Spinner center/>}
{isLoaded && (
<>
<ClusterMetrics/>
<ClusterPieCharts/>
<ClusterIssues className={cssNames({ wide: isElectron })}/>
</>
)}
</div>
</TabLayout>
);
}
}

View File

@ -134,7 +134,7 @@ export class HpaDetails extends React.Component<Props> {
kubeObjectDetailRegistry.add({ kubeObjectDetailRegistry.add({
kind: "HorizontalPodAutoscaler", kind: "HorizontalPodAutoscaler",
apiVersions: ["autoscaling/v1"], apiVersions: ["autoscaling/v2beta1"],
components: { components: {
Details: (props) => <HpaDetails {...props} /> Details: (props) => <HpaDetails {...props} />
} }
@ -142,7 +142,7 @@ kubeObjectDetailRegistry.add({
kubeObjectDetailRegistry.add({ kubeObjectDetailRegistry.add({
kind: "HorizontalPodAutoscaler", kind: "HorizontalPodAutoscaler",
apiVersions: ["autoscaling/v1"], apiVersions: ["autoscaling/v2beta1"],
priority: 5, priority: 5,
components: { components: {
Details: (props) => <KubeEventDetails {...props} /> Details: (props) => <KubeEventDetails {...props} />

View File

@ -26,9 +26,11 @@ export const NodeCharts = observer(() => {
const [ const [
memoryUsage, memoryUsage,
memoryRequests, memoryRequests,
_memoryLimits, // eslint-disable-line unused-imports/no-unused-vars-ts
memoryCapacity, memoryCapacity,
cpuUsage, cpuUsage,
cpuRequests, cpuRequests,
_cpuLimits, // eslint-disable-line unused-imports/no-unused-vars-ts
cpuCapacity, cpuCapacity,
podUsage, podUsage,
podCapacity, podCapacity,

View File

@ -91,14 +91,14 @@ export class CronJobDetails extends React.Component<Props> {
kubeObjectDetailRegistry.add({ kubeObjectDetailRegistry.add({
kind: "CronJob", kind: "CronJob",
apiVersions: ["batch/v1"], apiVersions: ["batch/v1beta1"],
components: { components: {
Details: (props) => <CronJobDetails {...props} /> Details: (props) => <CronJobDetails {...props} />
} }
}); });
kubeObjectDetailRegistry.add({ kubeObjectDetailRegistry.add({
kind: "CronJob", kind: "CronJob",
apiVersions: ["batch/v1"], apiVersions: ["batch/v1beta1"],
priority: 5, priority: 5,
components: { components: {
Details: (props) => <KubeEventDetails {...props} /> Details: (props) => <KubeEventDetails {...props} />

View File

@ -128,33 +128,45 @@ describe("<DeploymentScaleDialog />", () => {
const initReplicas = 1; const initReplicas = 1;
deploymentApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas); deploymentApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas);
const { getByTestId } = render(<DeploymentScaleDialog />); const component = render(<DeploymentScaleDialog />);
DeploymentScaleDialog.open(dummyDeployment); DeploymentScaleDialog.open(dummyDeployment);
await waitFor(async () => { await waitFor(async () => {
const desiredScale = await getByTestId("desired-scale"); expect(await component.getByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`);
expect(await component.getByTestId("current-scale")).toHaveTextContent(`${initReplicas}`);
expect(desiredScale).toHaveTextContent(`${initReplicas}`); expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas}`);
}); });
const up = await getByTestId("desired-replicas-up"); const up = await component.getByTestId("desired-replicas-up");
const down = await getByTestId("desired-replicas-down"); const down = await component.getByTestId("desired-replicas-down");
fireEvent.click(up); fireEvent.click(up);
expect(await getByTestId("desired-scale")).toHaveTextContent(`${initReplicas + 1}`); expect(await component.getByTestId("desired-scale")).toHaveTextContent(`${initReplicas + 1}`);
fireEvent.click(down); expect(await component.getByTestId("current-scale")).toHaveTextContent(`${initReplicas}`);
expect(await getByTestId("desired-scale")).toHaveTextContent("1"); expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas + 1}`);
// edge case, desiredScale must > 0
fireEvent.click(down); fireEvent.click(down);
fireEvent.click(down); expect(await component.getByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`);
expect(await getByTestId("desired-scale")).toHaveTextContent("1"); expect(await component.getByTestId("current-scale")).toHaveTextContent(`${initReplicas}`);
const times = 120; expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas}`);
// edge case, desiredScale must = 0
let times = 10;
for (let i = 0; i < times; i++) {
fireEvent.click(down);
}
expect(await component.getByTestId("desired-scale")).toHaveTextContent("0");
expect((await component.baseElement.querySelector("input").value)).toBe("0");
// edge case, desiredScale must = 100 scaleMax (100)
times = 120;
// edge case, desiredScale must < scaleMax (100)
for (let i = 0; i < times; i++) { for (let i = 0; i < times; i++) {
fireEvent.click(up); fireEvent.click(up);
} }
expect(await getByTestId("desired-scale")).toHaveTextContent("100"); expect(await component.getByTestId("desired-scale")).toHaveTextContent("100");
expect((component.baseElement.querySelector("input").value)).toBe("100");
expect(await component.getByTestId("warning"))
.toHaveTextContent("High number of replicas may cause cluster performance issues");
}); });
}); });

View File

@ -91,7 +91,7 @@ export class DeploymentScaleDialog extends Component<Props> {
}; };
desiredReplicasDown = () => { desiredReplicasDown = () => {
this.desiredReplicas > 1 && this.desiredReplicas--; this.desiredReplicas > 0 && this.desiredReplicas--;
}; };
renderContents() { renderContents() {
@ -124,7 +124,7 @@ export class DeploymentScaleDialog extends Component<Props> {
</div> </div>
</div> </div>
{warning && {warning &&
<div className="warning"> <div className="warning" data-testid="warning">
<Icon material="warning"/> <Icon material="warning"/>
<Trans>High number of replicas may cause cluster performance issues</Trans> <Trans>High number of replicas may cause cluster performance issues</Trans>
</div> </div>

View File

@ -16,7 +16,7 @@ import { Workloads, workloadsRoute, workloadsURL } from "./+workloads";
import { Namespaces, namespacesRoute } from "./+namespaces"; import { Namespaces, namespacesRoute } from "./+namespaces";
import { Network, networkRoute } from "./+network"; import { Network, networkRoute } from "./+network";
import { Storage, storageRoute } from "./+storage"; import { Storage, storageRoute } from "./+storage";
import { Cluster } from "./+cluster/cluster"; import { ClusterOverview } from "./+cluster/cluster-overview";
import { Config, configRoute } from "./+config"; import { Config, configRoute } from "./+config";
import { Events } from "./+events/events"; import { Events } from "./+events/events";
import { eventRoute } from "./+events"; import { eventRoute } from "./+events";
@ -180,7 +180,7 @@ export class App extends React.Component {
<ErrorBoundary> <ErrorBoundary>
<MainLayout> <MainLayout>
<Switch> <Switch>
<Route component={Cluster} {...clusterRoute}/> <Route component={ClusterOverview} {...clusterRoute}/>
<Route component={Nodes} {...nodesRoute}/> <Route component={Nodes} {...nodesRoute}/>
<Route component={Workloads} {...workloadsRoute}/> <Route component={Workloads} {...workloadsRoute}/>
<Route component={Config} {...configRoute}/> <Route component={Config} {...configRoute}/>

View File

@ -95,15 +95,9 @@ export class PodLogList extends React.Component<Props> {
@action @action
setLastLineVisibility = (props: ListOnScrollProps) => { setLastLineVisibility = (props: ListOnScrollProps) => {
const { scrollHeight, clientHeight } = this.virtualListDiv.current; const { scrollHeight, clientHeight } = this.virtualListDiv.current;
const { scrollOffset, scrollDirection } = props; const { scrollOffset } = props;
if (scrollDirection == "backward") { this.isLastLineVisible = (clientHeight + scrollOffset) === scrollHeight;
this.isLastLineVisible = false;
} else {
if (clientHeight + scrollOffset === scrollHeight) {
this.isLastLineVisible = true;
}
}
}; };
/** /**

View File

@ -2,7 +2,19 @@
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.0.1 (current version) ## 4.0.2 (current version)
We are aware some users are encountering issues and regressions from previous version. Many of these issues are something we have not seen as part of our automated or manual testing process. To make it worse, some of them are really difficult to reproduce. We want to ensure we are putting all our energy and effort trying to resolve these issues. We hope you are patient. Expect to see new patch releases still in the coming days! Fixes in this version:
- Fix: use correct apiversion for HPA details
- Fix: use correct apiversion fro CronJob details
- Fix: wrong values in node metrics
- Fix: Deployment scale button "minus"
- Fix: remove symlink on extension install and manual runtime uninstall
- Fix: logs autoscroll behaviour
- Performance fixes
## 4.0.1
- Extension install/uninstall fixes - Extension install/uninstall fixes
- Fix status brick styles in pod-menu-extension - Fix status brick styles in pod-menu-extension