From 5b484ca692a729a9eaaf6532fc21ed5befb426ef Mon Sep 17 00:00:00 2001 From: vshakirova <38247153+vshakirova@users.noreply.github.com> Date: Wed, 25 Nov 2020 16:21:54 +0400 Subject: [PATCH] Add stateful set scale slider (#1406) Signed-off-by: vshakirova --- locales/en/messages.po | 8 + locales/fi/messages.po | 8 + locales/ru/messages.po | 8 + .../api/endpoints/stateful-set.api.ts | 30 +++- .../statefulset-scale-dialog.scss | 49 +++++ .../statefulset-scale-dialog.test.tsx | 167 ++++++++++++++++++ .../statefulset-scale-dialog.tsx | 164 +++++++++++++++++ .../+workloads-statefulsets/statefulsets.scss | 2 +- .../+workloads-statefulsets/statefulsets.tsx | 48 ++++- src/renderer/components/app.tsx | 2 + 10 files changed, 476 insertions(+), 10 deletions(-) create mode 100644 src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.scss create mode 100755 src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx create mode 100644 src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx diff --git a/locales/en/messages.po b/locales/en/messages.po index 02ddfdb227..c66819dd24 100644 --- a/locales/en/messages.po +++ b/locales/en/messages.po @@ -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} 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}" msgstr "Scale Deployment <0>{deploymentName}" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139 +msgid "Scale Stateful Set <0>{statefulSetName}" +msgstr "Scale Stateful Set <0>{statefulSetName}" + #: src/renderer/components/+workloads-cronjobs/cronjob-details.tsx:45 #: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46 msgid "Schedule" diff --git a/locales/fi/messages.po b/locales/fi/messages.po index 0b668b7605..c5192a7eb9 100644 --- a/locales/fi/messages.po +++ b/locales/fi/messages.po @@ -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}" msgstr "" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139 +msgid "Scale Stateful Set <0>{statefulSetName}" +msgstr "" + #: src/renderer/components/+workloads-cronjobs/cronjob-details.tsx:45 #: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46 msgid "Schedule" diff --git a/locales/ru/messages.po b/locales/ru/messages.po index dc947d724c..de4ee7c7fb 100644 --- a/locales/ru/messages.po +++ b/locales/ru/messages.po @@ -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}" msgstr "Масштабировать Deployment <0>{deploymentName}" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139 +msgid "Scale Stateful Set <0>{statefulSetName}" +msgstr "Масштабировать Stateful Set <0>{statefulSetName}" + #: src/renderer/components/+workloads-cronjobs/cronjob-details.tsx:45 #: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46 msgid "Schedule" diff --git a/src/renderer/api/endpoints/stateful-set.api.ts b/src/renderer/api/endpoints/stateful-set.api.ts index 6a6f8c151d..0f3728b218 100644 --- a/src/renderer/api/endpoints/stateful-set.api.ts +++ b/src/renderer/api/endpoints/stateful-set.api.ts @@ -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 { + protected getScaleApiUrl(params: { namespace: string; name: string }) { + return this.getUrl(params) + "/scale"; + } + + getReplicas(params: { namespace: string; name: string }): Promise { + 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, }); diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.scss b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.scss new file mode 100644 index 0000000000..1a91c4078a --- /dev/null +++ b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.scss @@ -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; + } + } + } +} diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx new file mode 100755 index 0000000000..7a7f484cbe --- /dev/null +++ b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx @@ -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('', () => { + it('renders w/o errors', () => { + const { container } = render(); + 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 rendered. + const initReplicas = 1; + statefulSetApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas); + const { getByTestId } = render(); + StatefulSetScaleDialog.open(dummyStatefulSet); + // we need to wait for the StatefulSetScaleDialog to show up + // because there is an in 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.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'); + }); +}); diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx new file mode 100644 index 0000000000..031490eb17 --- /dev/null +++ b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx @@ -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 { +} + +@observer +export class StatefulSetScaleDialog extends Component { + @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 ( + <> +
+ Current replica scale: {currentReplicas} +
+
+
+ Desired number of replicas: {desiredReplicas} +
+
+ +
+
+ + +
+
+ {warning && +
+ + High number of replicas may cause cluster performance issues +
+ } + + ); + } + + render() { + const { className, ...dialogProps } = this.props; + const statefulSetName = this.statefulSet ? this.statefulSet.getName() : ""; + const header = ( +
+ Scale Stateful Set {statefulSetName} +
+ ); + return ( + + + Scale} + disabledNext={!this.ready} + > + {this.renderContents()} + + + + ); + } +} diff --git a/src/renderer/components/+workloads-statefulsets/statefulsets.scss b/src/renderer/components/+workloads-statefulsets/statefulsets.scss index ec39b5d53f..8f7f665b34 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulsets.scss +++ b/src/renderer/components/+workloads-statefulsets/statefulsets.scss @@ -5,7 +5,7 @@ } &.pods { - flex-grow: 0.3; + flex-grow: 1; } &.warning { diff --git a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx b/src/renderer/components/+workloads-statefulsets/statefulsets.tsx index e6f25ad9fe..7bd4ea35ff 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx +++ b/src/renderer/components/+workloads-statefulsets/statefulsets.tsx @@ -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 { @@ -25,8 +31,9 @@ interface Props extends RouteComponentProps { @observer export class StatefulSets extends React.Component { - 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 { [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 { renderTableHeader={[ { title: Name, className: "name", sortBy: sortBy.name }, { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, - { title: Pods, className: "pods", sortBy: sortBy.pods }, + { title: Pods, className: "pods" }, + { title: Replicas, className: "replicas", sortBy: sortBy.replicas }, { className: "warning" }, { title: Age, className: "age", sortBy: sortBy.age }, ]} renderTableContents={(statefulSet: StatefulSet) => [ statefulSet.getName(), statefulSet.getNs(), - this.getPodsLength(statefulSet), + this.renderPods(statefulSet), + statefulSet.getReplicas(), , statefulSet.getAge(), ]} + renderItemMenu={(item: StatefulSet) => { + return ; + }} /> ); } } + +export function StatefulSetMenu(props: KubeObjectMenuProps) { + const { object, toolbar } = props; + return ( + <> + StatefulSetScaleDialog.open(object)}> + + Scale + + + ); +} + +kubeObjectMenuRegistry.add({ + kind: "StatefulSet", + apiVersions: ["apps/v1"], + components: { + MenuItem: StatefulSetMenu + } +}); diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 2be6949411..ca0cb92a73 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -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 { +