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

Add stateful set scale slider (#1406)

Signed-off-by: vshakirova <vshakirova@mirantis.com>
This commit is contained in:
vshakirova 2020-11-25 16:21:54 +04:00 committed by GitHub
parent c93ee4ea6d
commit 5b484ca692
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 476 additions and 10 deletions

View File

@ -738,6 +738,7 @@ msgstr "Current / Target"
msgid "Current Healthy"
msgstr "Current Healthy"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
msgid "Current replica scale: {currentReplicas}"
msgstr "Current replica scale: {currentReplicas}"
@ -828,6 +829,7 @@ msgstr "Description"
msgid "Desired Healthy"
msgstr "Desired Healthy"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
msgid "Desired number of replicas"
msgstr "Desired number of replicas"
@ -1091,6 +1093,7 @@ msgstr "Helm branch <0>{0}</0> already in use"
msgid "Hide"
msgstr "Hide"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
msgid "High number of replicas may cause cluster performance issues"
msgstr "High number of replicas may cause cluster performance issues"
@ -2298,6 +2301,7 @@ msgstr "Runtime Class"
msgid "Save"
msgstr "Save"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:155
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128
#: src/renderer/components/+workloads-deployments/deployments.tsx:83
#: src/renderer/components/+workloads-deployments/deployments.tsx:84
@ -2308,6 +2312,10 @@ msgstr "Scale"
msgid "Scale Deployment <0>{deploymentName}</0>"
msgstr "Scale Deployment <0>{deploymentName}</0>"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139
msgid "Scale Stateful Set <0>{statefulSetName}</0>"
msgstr "Scale Stateful Set <0>{statefulSetName}</0>"
#: src/renderer/components/+workloads-cronjobs/cronjob-details.tsx:45
#: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46
msgid "Schedule"

View File

@ -734,6 +734,7 @@ msgstr ""
msgid "Current Healthy"
msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
msgid "Current replica scale: {currentReplicas}"
msgstr ""
@ -824,6 +825,7 @@ msgstr ""
msgid "Desired Healthy"
msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
msgid "Desired number of replicas"
msgstr ""
@ -1082,6 +1084,7 @@ msgstr ""
msgid "Hide"
msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
msgid "High number of replicas may cause cluster performance issues"
msgstr ""
@ -2281,6 +2284,7 @@ msgstr ""
msgid "Save"
msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:155
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128
#: src/renderer/components/+workloads-deployments/deployments.tsx:83
#: src/renderer/components/+workloads-deployments/deployments.tsx:84
@ -2291,6 +2295,10 @@ msgstr ""
msgid "Scale Deployment <0>{deploymentName}</0>"
msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139
msgid "Scale Stateful Set <0>{statefulSetName}</0>"
msgstr ""
#: src/renderer/components/+workloads-cronjobs/cronjob-details.tsx:45
#: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46
msgid "Schedule"

View File

@ -739,6 +739,7 @@ msgstr "Текущее / Цель"
msgid "Current Healthy"
msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
msgid "Current replica scale: {currentReplicas}"
msgstr "Текущий размер реплики: {currentReplicas}"
@ -829,6 +830,7 @@ msgstr "Описание"
msgid "Desired Healthy"
msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
msgid "Desired number of replicas"
msgstr "Нужный уровень реплик"
@ -1092,6 +1094,7 @@ msgstr ""
msgid "Hide"
msgstr "Скрыть"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
msgid "High number of replicas may cause cluster performance issues"
msgstr "Большое количество реплик может вызвать проблемы с производительностью кластера"
@ -2299,6 +2302,7 @@ msgstr ""
msgid "Save"
msgstr "Сохранить"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:155
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128
#: src/renderer/components/+workloads-deployments/deployments.tsx:83
#: src/renderer/components/+workloads-deployments/deployments.tsx:84
@ -2309,6 +2313,10 @@ msgstr "Масштабировать"
msgid "Scale Deployment <0>{deploymentName}</0>"
msgstr "Масштабировать Deployment <0>{deploymentName}</0>"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139
msgid "Scale Stateful Set <0>{statefulSetName}</0>"
msgstr "Масштабировать Stateful Set <0>{statefulSetName}</0>"
#: src/renderer/components/+workloads-cronjobs/cronjob-details.tsx:45
#: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46
msgid "Schedule"

View File

@ -4,6 +4,29 @@ import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
export class StatefulSetApi extends KubeApi<StatefulSet> {
protected getScaleApiUrl(params: { namespace: string; name: string }) {
return this.getUrl(params) + "/scale";
}
getReplicas(params: { namespace: string; name: string }): Promise<number> {
return this.request
.get(this.getScaleApiUrl(params))
.then(({ status }: any) => status?.replicas);
}
scale(params: { namespace: string; name: string }, replicas: number) {
return this.request.put(this.getScaleApiUrl(params), {
data: {
metadata: params,
spec: {
replicas
}
}
});
}
}
@autobind()
export class StatefulSet extends WorkloadKubeObject {
static kind = "StatefulSet";
@ -67,17 +90,22 @@ export class StatefulSet extends WorkloadKubeObject {
observedGeneration: number;
replicas: number;
currentReplicas: number;
readyReplicas: number;
currentRevision: string;
updateRevision: string;
collisionCount: number;
};
getReplicas() {
return this.spec.replicas || 0;
}
getImages() {
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []);
return [...containers].map(container => container.image);
}
}
export const statefulSetApi = new KubeApi({
export const statefulSetApi = new StatefulSetApi({
objectConstructor: StatefulSet,
});

View File

@ -0,0 +1,49 @@
.StatefulSetScaleDialog {
.Wizard {
.header {
span {
color: #a0a0a0;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.WizardStep {
.step-content {
min-height: 90px;
overflow: hidden;
}
}
.current-scale {
font-weight: bold
}
.desired-scale {
flex: 1.1 0;
}
.slider-container {
flex: 1 0;
}
.plus-minus-container {
margin-left: $margin * 2;
.Icon {
--color-active: black;
}
}
.warning {
color: $colorSoftError;
font-size: small;
display: flex;
align-items: center;
.Icon {
margin: 0;
margin-right: $margin;
}
}
}
}

View File

@ -0,0 +1,167 @@
import '@testing-library/jest-dom/extend-expect';
jest.mock("../../api/endpoints");
import { statefulSetApi } from "../../api/endpoints";
import { StatefulSetScaleDialog } from "./statefulset-scale-dialog";
import { render, waitFor, fireEvent } from '@testing-library/react';
import React from 'react';
const dummyStatefulSet = {
apiVersion: 'v1',
kind: 'dummy',
metadata: {
uid: 'dummy',
name: 'dummy',
creationTimestamp: 'dummy',
resourceVersion: 'dummy',
selfLink: 'link',
},
selfLink: 'link',
spec: {
serviceName: 'dummy',
replicas: 1,
selector: {
matchLabels: { 'label': 'label' }
},
template: {
metadata: {
labels: {
app: 'app',
},
},
spec: {
containers: [{
name: 'dummy',
image: 'dummy',
ports: [{
containerPort: 1234,
name: 'dummy',
}],
volumeMounts: [{
name: 'dummy',
mountPath: 'dummy',
}],
}],
tolerations: [{
key: 'dummy',
operator: 'dummy',
effect: 'dummy',
tolerationSeconds: 1,
}],
},
},
volumeClaimTemplates: [{
metadata: {
name: 'dummy',
},
spec: {
accessModes: ['dummy'],
resources: {
requests: {
storage: 'dummy',
},
},
},
}],
},
status: {
observedGeneration: 1,
replicas: 1,
currentReplicas: 1,
readyReplicas: 1,
currentRevision: 'dummy',
updateRevision: 'dummy',
collisionCount: 1,
},
getImages: jest.fn(),
getReplicas: jest.fn(),
getSelectors: jest.fn(),
getTemplateLabels: jest.fn(),
getAffinity: jest.fn(),
getTolerations: jest.fn(),
getNodeSelectors: jest.fn(),
getAffinityNumber: jest.fn(),
getId: jest.fn(),
getResourceVersion: jest.fn(),
getName: jest.fn(),
getNs: jest.fn(),
getAge: jest.fn(),
getFinalizers: jest.fn(),
getLabels: jest.fn(),
getAnnotations: jest.fn(),
getOwnerRefs: jest.fn(),
getSearchFields: jest.fn(),
toPlainObject: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
describe('<StatefulSetScaleDialog />', () => {
it('renders w/o errors', () => {
const { container } = render(<StatefulSetScaleDialog/>);
expect(container).toBeInstanceOf(HTMLElement);
});
it('init with a dummy stateful set and mocked current/desired scale', async () => {
// mock statefulSetApi.getReplicas() which will be called
// when <StatefulSetScaleDialog /> rendered.
const initReplicas = 1;
statefulSetApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas);
const { getByTestId } = render(<StatefulSetScaleDialog/>);
StatefulSetScaleDialog.open(dummyStatefulSet);
// we need to wait for the StatefulSetScaleDialog to show up
// because there is an <Animate /> in <Dialog /> which renders null at start.
await waitFor(async () => {
const [currentScale, desiredScale] = await Promise.all([
getByTestId('current-scale'),
getByTestId('desired-scale'),
]);
expect(currentScale).toHaveTextContent(`${initReplicas}`);
expect(desiredScale).toHaveTextContent(`${initReplicas}`);
});
});
it('changes the desired scale when clicking the icon buttons +/-', async () => {
const initReplicas = 1;
statefulSetApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas);
const component = render(<StatefulSetScaleDialog/>);
StatefulSetScaleDialog.open(dummyStatefulSet);
await waitFor(async () => {
expect(await component.findByTestId('desired-scale')).toHaveTextContent(`${initReplicas}`);
expect(await component.findByTestId('current-scale')).toHaveTextContent(`${initReplicas}`);
expect((await component.baseElement.querySelector('input').value)).toBe(`${initReplicas}`);
});
const up = await component.findByTestId('desired-replicas-up');
const down = await component.findByTestId('desired-replicas-down');
fireEvent.click(up);
expect(await component.findByTestId('desired-scale')).toHaveTextContent(`${initReplicas + 1}`);
expect(await component.findByTestId('current-scale')).toHaveTextContent(`${initReplicas}`);
expect((await component.baseElement.querySelector('input').value)).toBe(`${initReplicas + 1}`);
fireEvent.click(down);
expect(await component.findByTestId('desired-scale')).toHaveTextContent(`${initReplicas}`);
expect(await component.findByTestId('current-scale')).toHaveTextContent(`${initReplicas}`);
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.findByTestId('desired-scale')).toHaveTextContent('0');
expect((await component.baseElement.querySelector('input').value)).toBe('0');
// edge case, desiredScale must <= scaleMax (100)
times = 120;
for (let i = 0; i < times; i++) {
fireEvent.click(up);
}
expect(await component.findByTestId('desired-scale')).toHaveTextContent('100');
expect((component.baseElement.querySelector("input").value)).toBe('100');
expect(await component.findByTestId('warning'))
.toHaveTextContent('High number of replicas may cause cluster performance issues');
});
});

View File

@ -0,0 +1,164 @@
import "./statefulset-scale-dialog.scss";
import { StatefulSet, statefulSetApi } from "../../api/endpoints";
import React, { Component } from "react";
import { computed, observable } from "mobx";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { Dialog, DialogProps } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { Icon } from "../icon";
import { Slider } from "../slider";
import { Notifications } from "../notifications";
import { cssNames } from "../../utils";
interface Props extends Partial<DialogProps> {
}
@observer
export class StatefulSetScaleDialog extends Component<Props> {
@observable static isOpen = false;
@observable static data: StatefulSet = null;
@observable ready = false;
@observable currentReplicas = 0;
@observable desiredReplicas = 0;
static open(statefulSet: StatefulSet) {
StatefulSetScaleDialog.isOpen = true;
StatefulSetScaleDialog.data = statefulSet;
}
static close() {
StatefulSetScaleDialog.isOpen = false;
}
get statefulSet() {
return StatefulSetScaleDialog.data;
}
close = () => {
StatefulSetScaleDialog.close();
};
onOpen = async () => {
const { statefulSet } = this;
this.currentReplicas = await statefulSetApi.getReplicas({
namespace: statefulSet.getNs(),
name: statefulSet.getName(),
});
this.desiredReplicas = this.currentReplicas;
this.ready = true;
};
onClose = () => {
this.ready = false;
};
onChange = (evt: React.ChangeEvent, value: number) => {
this.desiredReplicas = value;
};
@computed get scaleMax() {
const { currentReplicas } = this;
const defaultMax = 50;
return currentReplicas <= defaultMax
? defaultMax * 2
: currentReplicas * 2;
}
scale = async () => {
const { statefulSet } = this;
const { currentReplicas, desiredReplicas, close } = this;
try {
if (currentReplicas !== desiredReplicas) {
await statefulSetApi.scale({
name: statefulSet.getName(),
namespace: statefulSet.getNs(),
}, desiredReplicas);
}
close();
} catch (err) {
Notifications.error(err);
}
};
desiredReplicasUp = () => {
this.desiredReplicas < this.scaleMax && this.desiredReplicas++;
};
desiredReplicasDown = () => {
this.desiredReplicas > 0 && this.desiredReplicas--;
};
renderContents() {
const { currentReplicas, desiredReplicas, onChange, scaleMax } = this;
const warning = currentReplicas < 10 && desiredReplicas > 90;
return (
<>
<div className="current-scale" data-testid="current-scale">
<Trans>Current replica scale: {currentReplicas}</Trans>
</div>
<div className="flex gaps align-center">
<div className="desired-scale" data-testid="desired-scale">
<Trans>Desired number of replicas</Trans>: {desiredReplicas}
</div>
<div className="slider-container flex align-center" data-testid="slider">
<Slider value={desiredReplicas} max={scaleMax}
onChange={onChange as any /** see: https://github.com/mui-org/material-ui/issues/20191 */}
/>
</div>
<div className="plus-minus-container flex gaps">
<Icon
material="add_circle_outline"
onClick={this.desiredReplicasUp}
data-testid="desired-replicas-up"
/>
<Icon
material="remove_circle_outline"
onClick={this.desiredReplicasDown}
data-testid="desired-replicas-down"
/>
</div>
</div>
{warning &&
<div className="warning" data-testid="warning">
<Icon material="warning"/>
<Trans>High number of replicas may cause cluster performance issues</Trans>
</div>
}
</>
);
}
render() {
const { className, ...dialogProps } = this.props;
const statefulSetName = this.statefulSet ? this.statefulSet.getName() : "";
const header = (
<h5>
<Trans>Scale Stateful Set <span>{statefulSetName}</span></Trans>
</h5>
);
return (
<Dialog
{...dialogProps}
isOpen={StatefulSetScaleDialog.isOpen}
className={cssNames("StatefulSetScaleDialog", className)}
onOpen={this.onOpen}
onClose={this.onClose}
close={this.close}
>
<Wizard header={header} done={this.close}>
<WizardStep
contentClass="flex gaps column"
next={this.scale}
nextLabel={<Trans>Scale</Trans>}
disabledNext={!this.ready}
>
{this.renderContents()}
</WizardStep>
</Wizard>
</Dialog>
);
}
}

View File

@ -5,7 +5,7 @@
}
&.pods {
flex-grow: 0.3;
flex-grow: 1;
}
&.warning {

View File

@ -3,21 +3,27 @@ import "./statefulsets.scss";
import React from "react";
import { observer } from "mobx-react";
import { RouteComponentProps } from "react-router";
import { Trans } from "@lingui/macro";
import { StatefulSet } from "../../api/endpoints";
import { t, Trans } from "@lingui/macro";
import { StatefulSet, statefulSetApi } from "../../api/endpoints";
import { podsStore } from "../+workloads-pods/pods.store";
import { statefulSetStore } from "./statefulset.store";
import { nodesStore } from "../+nodes/nodes.store";
import { eventStore } from "../+events/event.store";
import { KubeObjectMenuProps } from "../kube-object/kube-object-menu";
import { KubeObjectListLayout } from "../kube-object";
import { IStatefulSetsRouteParams } from "../+workloads";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import { StatefulSetScaleDialog } from "./statefulset-scale-dialog";
import { MenuItem } from "../menu/menu";
import { _i18n } from "../../i18n";
import { Icon } from "../icon/icon";
import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry";
enum sortBy {
name = "name",
namespace = "namespace",
pods = "pods",
age = "age",
replicas = "replicas",
}
interface Props extends RouteComponentProps<IStatefulSetsRouteParams> {
@ -25,8 +31,9 @@ interface Props extends RouteComponentProps<IStatefulSetsRouteParams> {
@observer
export class StatefulSets extends React.Component<Props> {
getPodsLength(statefulSet: StatefulSet) {
return statefulSetStore.getChildPods(statefulSet).length;
renderPods(statefulSet: StatefulSet) {
const { readyReplicas, currentReplicas } = statefulSet.status;
return `${readyReplicas || 0}/${currentReplicas || 0}`;
}
render() {
@ -38,7 +45,7 @@ export class StatefulSets extends React.Component<Props> {
[sortBy.name]: (statefulSet: StatefulSet) => statefulSet.getName(),
[sortBy.namespace]: (statefulSet: StatefulSet) => statefulSet.getNs(),
[sortBy.age]: (statefulSet: StatefulSet) => statefulSet.metadata.creationTimestamp,
[sortBy.pods]: (statefulSet: StatefulSet) => this.getPodsLength(statefulSet),
[sortBy.replicas]: (statefulSet: StatefulSet) => statefulSet.getReplicas(),
}}
searchFilters={[
(statefulSet: StatefulSet) => statefulSet.getSearchFields(),
@ -47,18 +54,43 @@ export class StatefulSets extends React.Component<Props> {
renderTableHeader={[
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
{ title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
{ title: <Trans>Pods</Trans>, className: "pods", sortBy: sortBy.pods },
{ title: <Trans>Pods</Trans>, className: "pods" },
{ title: <Trans>Replicas</Trans>, className: "replicas", sortBy: sortBy.replicas },
{ className: "warning" },
{ title: <Trans>Age</Trans>, className: "age", sortBy: sortBy.age },
]}
renderTableContents={(statefulSet: StatefulSet) => [
statefulSet.getName(),
statefulSet.getNs(),
this.getPodsLength(statefulSet),
this.renderPods(statefulSet),
statefulSet.getReplicas(),
<KubeObjectStatusIcon object={statefulSet}/>,
statefulSet.getAge(),
]}
renderItemMenu={(item: StatefulSet) => {
return <StatefulSetMenu object={item}/>;
}}
/>
);
}
}
export function StatefulSetMenu(props: KubeObjectMenuProps<StatefulSet>) {
const { object, toolbar } = props;
return (
<>
<MenuItem onClick={() => StatefulSetScaleDialog.open(object)}>
<Icon material="open_with" title={_i18n._(t`Scale`)} interactive={toolbar}/>
<span className="title"><Trans>Scale</Trans></span>
</MenuItem>
</>
);
}
kubeObjectMenuRegistry.add({
kind: "StatefulSet",
apiVersions: ["apps/v1"],
components: {
MenuItem: StatefulSetMenu
}
});

View File

@ -43,6 +43,7 @@ import { clusterSetFrameIdHandler } from "../../common/cluster-ipc";
import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../extensions/registries";
import { TabLayoutRoute, TabLayout } from "./layout/tab-layout";
import { Trans } from "@lingui/macro";
import {StatefulSetScaleDialog} from "./+workloads-statefulsets/statefulset-scale-dialog";
@observer
export class App extends React.Component {
@ -150,6 +151,7 @@ export class App extends React.Component {
<KubeConfigDialog/>
<AddRoleBindingDialog/>
<DeploymentScaleDialog/>
<StatefulSetScaleDialog/>
<CronJobTriggerDialog/>
</ErrorBoundary>
</Router>