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

Rollout support

Signed-off-by: vshakirova <vshakirova@mirantis.com>
This commit is contained in:
vshakirova 2021-07-30 13:37:18 +04:00
parent d6d66682f6
commit 32320ecb6d
26 changed files with 858 additions and 133 deletions

View File

@ -0,0 +1,53 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { WorkloadKubeObject } from "../workload-kube-object";
import { autoBind } from "../../utils/autobind";
import { KubeApi } from "../kube-api";
import type { KubeJsonApiData } from "../kube-json-api";
export class ControllerRevisionApi extends KubeApi<ControllerRevision> {
async getRevisions(params: { namespace: string; name: string }) {
return await this.list({ namespace: params.namespace }, { labelSelector: `name=${params.name}` });
}
}
export class ControllerRevision extends WorkloadKubeObject {
static kind = "ControllerRevision";
static namespaced = true;
static apiBase = "/apis/apps/v1/controllerrevisions";
constructor(data: KubeJsonApiData) {
super(data);
autoBind(this);
}
declare revision: number;
declare data: string;
getRevisionNumber() {
return this.revision;
}
}
export const controllerRevisionApi = new ControllerRevisionApi({
objectConstructor: ControllerRevision,
});

View File

@ -22,11 +22,14 @@
import get from "lodash/get";
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { autoBind } from "../../utils";
import { KubeApi } from "../kube-api";
import { metricsApi } from "./metrics.api";
import type { KubeJsonApiData } from "../kube-json-api";
import type { IPodContainer, IPodMetrics } from "./pods.api";
import { isClusterPageContext } from "../../utils/cluster-id-url-parsing";
import { WorkloadKubeApi } from "../../../renderer/components/+workloads/api/workload-kube-api";
export class DaemonSetApi extends WorkloadKubeApi<DaemonSet> {
}
export class DaemonSet extends WorkloadKubeObject {
static kind = "DaemonSet";
@ -99,9 +102,6 @@ export class DaemonSet extends WorkloadKubeObject {
}
}
export class DaemonSetApi extends KubeApi<DaemonSet> {
}
export function getMetricsForDaemonSets(daemonsets: DaemonSet[], namespace: string, selector = ""): Promise<IPodMetrics> {
const podSelector = daemonsets.map(daemonset => `${daemonset.getName()}-[[:alnum:]]{5}`).join("|");
const opts = { category: "pods", pods: podSelector, namespace, selector };

View File

@ -19,17 +19,15 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import moment from "moment";
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { autoBind } from "../../utils";
import { KubeApi } from "../kube-api";
import { metricsApi } from "./metrics.api";
import type { IPodMetrics } from "./pods.api";
import type { KubeJsonApiData } from "../kube-json-api";
import { isClusterPageContext } from "../../utils/cluster-id-url-parsing";
import { strategicMergePatch, WorkloadKubeApi } from "../../../renderer/components/+workloads/api/workload-kube-api";
export class DeploymentApi extends KubeApi<Deployment> {
export class DeploymentApi extends WorkloadKubeApi<Deployment> {
protected getScaleApiUrl(params: { namespace: string; name: string }) {
return `${this.getUrl(params)}/scale`;
}
@ -51,21 +49,32 @@ export class DeploymentApi extends KubeApi<Deployment> {
});
}
restart(params: { namespace: string; name: string }) {
pause(params: { namespace: string; name: string }) {
return this.request.patch(this.getUrl(params), {
data: {
spec: {
template: {
metadata: {
annotations: {"kubectl.kubernetes.io/restartedAt" : moment.utc().format()}
}
}
paused: true
}
}
},
{
headers: {
"content-type": "application/strategic-merge-patch+json"
"content-type": strategicMergePatch
}
});
}
resume(params: { namespace: string; name: string }) {
return this.request.patch(this.getUrl(params), {
data: {
spec: {
paused: false
}
}
},
{
headers: {
"content-type": strategicMergePatch
}
});
}
@ -117,6 +126,7 @@ export class Deployment extends WorkloadKubeObject {
}
declare spec: {
paused?: boolean;
replicas: number;
selector: { matchLabels: { [app: string]: string } };
template: {
@ -231,6 +241,10 @@ export class Deployment extends WorkloadKubeObject {
getReplicas() {
return this.spec.replicas || 0;
}
isPaused() {
return this.spec?.paused || false;
}
}
let deploymentApi: DeploymentApi;

View File

@ -66,6 +66,9 @@ export function getMetricsForReplicaSets(replicasets: ReplicaSet[], namespace: s
});
}
const changeCauseAnnotation = "kubernetes.io/change-cause";
const revisionAnnotation = "deployment.kubernetes.io/revision";
export class ReplicaSet extends WorkloadKubeObject {
static kind = "ReplicaSet";
static namespaced = true;
@ -117,6 +120,14 @@ export class ReplicaSet extends WorkloadKubeObject {
return this.status.readyReplicas || 0;
}
getRevisionNumber(): number {
return parseInt(this.metadata.annotations[revisionAnnotation]);
}
getChangeCause() {
return this.metadata.annotations[changeCauseAnnotation] || "";
}
getImages() {
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []);

View File

@ -21,13 +21,13 @@
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { autoBind } from "../../utils";
import { KubeApi } from "../kube-api";
import { metricsApi } from "./metrics.api";
import type { IPodMetrics } from "./pods.api";
import type { KubeJsonApiData } from "../kube-json-api";
import { isClusterPageContext } from "../../utils/cluster-id-url-parsing";
import { WorkloadKubeApi } from "../../../renderer/components/+workloads/api/workload-kube-api";
export class StatefulSetApi extends KubeApi<StatefulSet> {
export class StatefulSetApi extends WorkloadKubeApi<StatefulSet> {
protected getScaleApiUrl(params: { namespace: string; name: string }) {
return `${this.getUrl(params)}/scale`;
}

View File

@ -0,0 +1,31 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type { RouteProps } from "react-router";
import { buildURL } from "../utils/buildUrl";
export const controllerRevisionRoute: RouteProps = {
path: "/controllerrevisions"
};
export interface ControllerRevisionRouteParams {
}
export const controllerRevisionURL = buildURL<ControllerRevisionRouteParams>(controllerRevisionRoute.path);

View File

@ -34,12 +34,15 @@ import type { KubeObjectDetailsProps } from "../kube-object-details";
import { DaemonSet, getMetricsForDaemonSets, IPodMetrics } from "../../../common/k8s-api/endpoints";
import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics";
import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts";
import { makeObservable, observable, reaction } from "mobx";
import { autorun, makeObservable, observable, reaction } from "mobx";
import { PodDetailsList } from "../+workloads-pods/pod-details-list";
import { KubeObjectMeta } from "../kube-object-meta";
import { getActiveClusterEntity } from "../../api/catalog-entity-registry";
import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import { boundMethod } from "../../utils";
import { RevisionHistory } from "../+workloads/revision-history";
import type { ControllerRevision } from "../../../common/k8s-api/endpoints/controller-revision.api";
import { controllerRevisionApi } from "../../../common/k8s-api/endpoints/controller-revision.api";
interface Props extends KubeObjectDetailsProps<DaemonSet> {
}
@ -47,6 +50,7 @@ interface Props extends KubeObjectDetailsProps<DaemonSet> {
@observer
export class DaemonSetDetails extends React.Component<Props> {
@observable metrics: IPodMetrics = null;
@observable revisionHistory: ControllerRevision[] = observable.array<ControllerRevision>();
constructor(props: Props) {
super(props);
@ -58,6 +62,17 @@ export class DaemonSetDetails extends React.Component<Props> {
this.metrics = null;
});
@disposeOnUnmount
revisionLoader = autorun(async () => {
const { object: daemonSet } = this.props;
const revisionHistory = await controllerRevisionApi.getRevisions({
name: daemonSet.getName(),
namespace: daemonSet.getNs()
}) as ControllerRevision[];
this.revisionHistory = observable.array(revisionHistory);
});
componentDidMount() {
podsStore.reloadAll();
}
@ -121,6 +136,7 @@ export class DaemonSetDetails extends React.Component<Props> {
<PodDetailsStatuses pods={childPods}/>
</DrawerItem>
<ResourceMetricsText metrics={this.metrics}/>
<RevisionHistory object={this.revisionHistory}/>
<PodDetailsList pods={childPods} owner={daemonSet}/>
</div>
);

View File

@ -33,6 +33,8 @@ import { KubeObjectListLayout } from "../kube-object-list-layout";
import { Badge } from "../badge";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import type { DaemonSetsRouteParams } from "../../../common/routes";
import type { KubeObjectMenuProps } from "../kube-object-menu/kube-object-menu";
import { RolloutMenu } from "../+workloads/rollout-menu";
enum columnId {
name = "name",
@ -91,7 +93,18 @@ export class DaemonSets extends React.Component<Props> {
this.renderNodeSelector(daemonSet),
daemonSet.getAge(),
]}
renderItemMenu={item => <DaemonSetMenu object={item} />}
/>
);
}
}
export function DaemonSetMenu(props: KubeObjectMenuProps<DaemonSet>) {
const { object } = props;
return (
<>
<RolloutMenu workloadKubeObject={object}/>
</>
);
}

View File

@ -37,11 +37,10 @@ import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts";
import { makeObservable, observable, reaction } from "mobx";
import { PodDetailsList } from "../+workloads-pods/pod-details-list";
import { KubeObjectMeta } from "../kube-object-meta";
import { replicaSetStore } from "../+workloads-replicasets/replicasets.store";
import { DeploymentReplicaSets } from "./deployment-replicasets";
import { getActiveClusterEntity } from "../../api/catalog-entity-registry";
import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import { boundMethod } from "../../utils";
import { DeploymentRevisionHistory } from "./deployment-revision-history";
interface Props extends KubeObjectDetailsProps<Deployment> {
}
@ -62,7 +61,6 @@ export class DeploymentDetails extends React.Component<Props> {
componentDidMount() {
podsStore.reloadAll();
replicaSetStore.reloadAll();
}
@boundMethod
@ -80,7 +78,7 @@ export class DeploymentDetails extends React.Component<Props> {
const nodeSelector = deployment.getNodeSelectors();
const selectors = deployment.getSelectors();
const childPods = deploymentStore.getChildPods(deployment);
const replicaSets = replicaSetStore.getReplicaSetsByOwner(deployment);
const replicaSets = deploymentStore.getRelatedReplicas(deployment);
const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Deployment);
return (
@ -143,7 +141,7 @@ export class DeploymentDetails extends React.Component<Props> {
<PodDetailsTolerations workload={deployment}/>
<PodDetailsAffinities workload={deployment}/>
<ResourceMetricsText metrics={this.metrics}/>
<DeploymentReplicaSets replicaSets={replicaSets}/>
<DeploymentRevisionHistory object={replicaSets}/>
<PodDetailsList pods={childPods} owner={deployment}/>
</div>
);

View File

@ -1,36 +0,0 @@
.DeploymentDetails {
.ReplicaSets {
position: relative;
min-height: 80px;
.Table {
margin: 0 (-$margin * 3);
}
.TableCell {
&:first-child {
margin-left: $margin;
}
&:last-child {
margin-right: $margin;
}
&.name {
flex-grow: 2;
}
&.warning {
@include table-cell-warning;
}
&.namespace {
flex-grow: 1.2;
}
&.actions {
@include table-cell-action;
}
}
}
}

View File

@ -0,0 +1,64 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.RevisionHistory {
position: relative;
min-height: 80px;
.Table {
margin: 0 (-$margin * 3);
}
.TableCell {
&:first-child {
margin-left: $margin;
}
&:last-child {
margin-right: $margin;
}
&.revision {
flex-grow: 0.5;
}
&.name {
flex-grow: 2;
}
&.warning {
@include table-cell-warning;
}
&.namespace {
flex-grow: 1.2;
}
&.actions {
@include table-cell-action;
}
&.changeCause:empty:after {
content: '';
flex-grow: 1.5;
}
}
}

View File

@ -19,89 +19,90 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./deployment-replicasets.scss";
import "./deployment-revision-history.scss";
import React from "react";
import { observer } from "mobx-react";
import type { ReplicaSet } from "../../../common/k8s-api/endpoints";
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object-menu";
import { Spinner } from "../spinner";
import { prevDefault, stopPropagation } from "../../utils";
import { DrawerTitle } from "../drawer";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import { replicaSetStore } from "../+workloads-replicasets/replicasets.store";
import { showDetails } from "../kube-detail-params";
import { prevDefault } from "../../utils/prevDefault";
import { showDetails } from "../kube-detail-params/params";
import { Badge } from "../badge/badge";
enum sortBy {
name = "name",
namespace = "namespace",
pods = "pods",
age = "age",
revision = "revision",
}
interface Props {
replicaSets: ReplicaSet[];
object: ReplicaSet[];
}
@observer
export class DeploymentReplicaSets extends React.Component<Props> {
export class DeploymentRevisionHistory extends React.Component<Props> {
private sortingCallbacks = {
[sortBy.name]: (replicaSet: ReplicaSet) => replicaSet.getName(),
[sortBy.namespace]: (replicaSet: ReplicaSet) => replicaSet.getNs(),
[sortBy.age]: (replicaSet: ReplicaSet) => replicaSet.metadata.creationTimestamp,
[sortBy.pods]: (replicaSet: ReplicaSet) => this.getPodsLength(replicaSet),
[sortBy.name]: (object: ReplicaSet) => object.getName(),
[sortBy.namespace]: (object: ReplicaSet) => object.getNs(),
[sortBy.age]: (object: ReplicaSet) => object.metadata.creationTimestamp,
[sortBy.revision]: (object: ReplicaSet) => object.getRevisionNumber(),
};
getPodsLength(replicaSet: ReplicaSet) {
return replicaSetStore.getChildPods(replicaSet).length;
}
render() {
const { replicaSets } = this.props;
const { object } = this.props;
if (!replicaSets.length && !replicaSetStore.isLoaded) return (
<div className="ReplicaSets"><Spinner center/></div>
);
if (!replicaSets.length) return null;
if (!object.length) return null;
return (
<div className="ReplicaSets flex column">
<DrawerTitle title="Deploy Revisions"/>
<div className="RevisionHistory flex column">
<DrawerTitle title="Revision history"/>
<Table
tableId="revision_history"
selectable
scrollable={false}
sortable={this.sortingCallbacks}
sortByDefault={{ sortBy: sortBy.pods, orderBy: "desc" }}
sortByDefault={{ sortBy: sortBy.revision, orderBy: "desc" }}
sortSyncWithUrl={false}
className="box grow"
>
<TableHead>
<TableCell className="revision" sortBy={sortBy.revision}>Revision</TableCell>
<TableCell className="name" sortBy={sortBy.name}>Name</TableCell>
<TableCell className="warning"/>
<TableCell className="namespace" sortBy={sortBy.namespace}>Namespace</TableCell>
<TableCell className="pods" sortBy={sortBy.pods}>Pods</TableCell>
<TableCell className="changeCause">Change cause</TableCell>
<TableCell className="age" sortBy={sortBy.age}>Age</TableCell>
<TableCell className="actions"/>
</TableHead>
{
replicaSets.map(replica => {
object.map(object => {
return (
<TableRow
key={replica.getId()}
sortItem={replica}
key={object.getId()}
sortItem={object}
nowrap
onClick={prevDefault(() => showDetails(replica.selfLink, false))}
onClick={prevDefault(() => showDetails(object.selfLink, false))}
>
<TableCell className="name">{replica.getName()}</TableCell>
<TableCell className="warning"><KubeObjectStatusIcon key="icon" object={replica}/></TableCell>
<TableCell className="namespace">{replica.getNs()}</TableCell>
<TableCell className="pods">{this.getPodsLength(replica)}</TableCell>
<TableCell className="age">{replica.getAge()}</TableCell>
<TableCell className="actions" onClick={stopPropagation}>
<ReplicaSetMenu object={replica}/>
<TableCell className="revision">{object.getRevisionNumber()}</TableCell>
<TableCell className="name">{object.getName()}</TableCell>
<TableCell className="warning"><KubeObjectStatusIcon key="icon" object={object}/></TableCell>
<TableCell className="namespace">{object.getNs()}</TableCell>
<TableCell className="changeCause">{object.getChangeCause() &&
<Badge
key={object.getChangeCause()}
label={object.getChangeCause()}
tooltip={(
<>
<p>{object.getChangeCause()}</p>
</>
)}
/>
}
</TableCell>
<TableCell className="age">{object.getAge()}</TableCell>
</TableRow>
);
})
@ -111,9 +112,3 @@ export class DeploymentReplicaSets extends React.Component<Props> {
);
}
}
export function ReplicaSetMenu(props: KubeObjectMenuProps<ReplicaSet>) {
return (
<KubeObjectMenu {...props}/>
);
}

View File

@ -112,6 +112,7 @@ const dummyDeployment: Deployment = {
toPlainObject: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
isPaused: jest.fn(),
};
describe("<DeploymentScaleDialog />", () => {

View File

@ -25,6 +25,7 @@ import { apiManager } from "../../../common/k8s-api/api-manager";
import { Deployment, deploymentApi, PodStatus } from "../../../common/k8s-api/endpoints";
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import { autoBind } from "../../utils";
import { replicaSetStore } from "../+workloads-replicasets/replicasets.store";
export class DeploymentStore extends KubeObjectStore<Deployment> {
api = deploymentApi;
@ -67,6 +68,10 @@ export class DeploymentStore extends KubeObjectStore<Deployment> {
.getByLabel(deployment.getTemplateLabels())
.filter(pod => pod.getNs() === deployment.getNs());
}
getRelatedReplicas(deployment: Deployment) {
return replicaSetStore.getReplicaSetsByOwner(deployment);
}
}
export const deploymentStore = new DeploymentStore();

View File

@ -24,12 +24,11 @@ import "./deployments.scss";
import React from "react";
import { observer } from "mobx-react";
import type { RouteComponentProps } from "react-router";
import { Deployment, deploymentApi } from "../../../common/k8s-api/endpoints";
import type { Deployment } from "../../../common/k8s-api/endpoints";
import type { KubeObjectMenuProps } from "../kube-object-menu";
import { MenuItem } from "../menu";
import { Icon } from "../icon";
import { DeploymentScaleDialog } from "./deployment-scale-dialog";
import { ConfirmDialog } from "../confirm-dialog";
import { deploymentStore } from "./deployments.store";
import { replicaSetStore } from "../+workloads-replicasets/replicasets.store";
import { podsStore } from "../+workloads-pods/pods.store";
@ -40,8 +39,8 @@ import { cssNames } from "../../utils";
import kebabCase from "lodash/kebabCase";
import orderBy from "lodash/orderBy";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import { Notifications } from "../notifications";
import type { DeploymentsRouteParams } from "../../../common/routes";
import { RolloutMenu } from "../+workloads/rollout-menu";
enum columnId {
name = "name",
@ -125,28 +124,7 @@ export function DeploymentMenu(props: KubeObjectMenuProps<Deployment>) {
<Icon material="open_with" tooltip="Scale" interactive={toolbar}/>
<span className="title">Scale</span>
</MenuItem>
<MenuItem onClick={() => ConfirmDialog.open({
ok: async () =>
{
try {
await deploymentApi.restart({
namespace: object.getNs(),
name: object.getName(),
});
} catch (err) {
Notifications.error(err);
}
},
labelOk: `Restart`,
message: (
<p>
Are you sure you want to restart deployment <b>{object.getName()}</b>?
</p>
),
})}>
<Icon material="autorenew" tooltip="Restart" interactive={toolbar}/>
<span className="title">Restart</span>
</MenuItem>
<RolloutMenu workloadKubeObject={object}/>
</>
);
}

View File

@ -107,6 +107,8 @@ const dummyReplicaSet: ReplicaSet = {
toPlainObject: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
getChangeCause: jest.fn(),
getRevisionNumber: jest.fn(),
};
describe("<ReplicaSetScaleDialog />", () => {

View File

@ -23,7 +23,7 @@ import "./statefulset-details.scss";
import React from "react";
import { disposeOnUnmount, observer } from "mobx-react";
import { makeObservable, observable, reaction } from "mobx";
import { autorun, makeObservable, observable, reaction } from "mobx";
import { Badge } from "../badge";
import { DrawerItem } from "../drawer";
import { PodDetailsStatuses } from "../+workloads-pods/pod-details-statuses";
@ -40,6 +40,9 @@ import { KubeObjectMeta } from "../kube-object-meta";
import { getActiveClusterEntity } from "../../api/catalog-entity-registry";
import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import { boundMethod } from "../../utils";
import { RevisionHistory } from "../+workloads/revision-history";
import type { ControllerRevision } from "../../../common/k8s-api/endpoints/controller-revision.api";
import { controllerRevisionApi } from "../../../common/k8s-api/endpoints/controller-revision.api";
interface Props extends KubeObjectDetailsProps<StatefulSet> {
}
@ -47,6 +50,7 @@ interface Props extends KubeObjectDetailsProps<StatefulSet> {
@observer
export class StatefulSetDetails extends React.Component<Props> {
@observable metrics: IPodMetrics = null;
@observable revisionHistory: ControllerRevision[] = observable.array<ControllerRevision>();
constructor(props: Props) {
super(props);
@ -58,6 +62,17 @@ export class StatefulSetDetails extends React.Component<Props> {
this.metrics = null;
});
@disposeOnUnmount
revisionLoader = autorun(async () => {
const { object: statefulSet } = this.props;
const revisionHistory = await controllerRevisionApi.getRevisions({
name: statefulSet.getName(),
namespace: statefulSet.getNs()
}) as ControllerRevision[];
this.revisionHistory = observable.array(revisionHistory);
});
componentDidMount() {
podsStore.reloadAll();
}
@ -119,6 +134,7 @@ export class StatefulSetDetails extends React.Component<Props> {
<PodDetailsStatuses pods={childPods}/>
</DrawerItem>
<ResourceMetricsText metrics={this.metrics}/>
<RevisionHistory object={this.revisionHistory}/>
<PodDetailsList pods={childPods} owner={statefulSet}/>
</div>
);

View File

@ -32,10 +32,11 @@ import { eventStore } from "../+events/event.store";
import type { KubeObjectMenuProps } from "../kube-object-menu";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import { StatefulSetScaleDialog } from "./statefulset-scale-dialog";
import { MenuItem } from "../menu/menu";
import { Icon } from "../icon/icon";
import type { StatefulSetsRouteParams } from "../../../common/routes";
import { MenuItem } from "../menu/menu";
import { StatefulSetScaleDialog } from "./statefulset-scale-dialog";
import { Icon } from "../icon/icon";
import { RolloutMenu } from "../+workloads/rollout-menu";
enum columnId {
name = "name",
@ -89,7 +90,7 @@ export class StatefulSets extends React.Component<Props> {
<KubeObjectStatusIcon key="icon" object={statefulSet}/>,
statefulSet.getAge(),
]}
renderItemMenu={item => <StatefulSetMenu object={item} />}
renderItemMenu={item => <StatefulSetMenu object={item}/>}
/>
);
}
@ -104,6 +105,7 @@ export function StatefulSetMenu(props: KubeObjectMenuProps<StatefulSet>) {
<Icon material="open_with" tooltip="Scale" interactive={toolbar}/>
<span className="title">Scale</span>
</MenuItem>
<RolloutMenu workloadKubeObject={object}/>
</>
);
}

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import moment from "moment";
import { KubeApi } from "../../../../common/k8s-api/kube-api";
import type { WorkloadKubeObject } from "../../../../common/k8s-api/workload-kube-object";
export const strategicMergePatch = "application/strategic-merge-patch+json";
export const jsonPatch = "application/json-patch+json";
export abstract class WorkloadKubeApi<T extends WorkloadKubeObject> extends KubeApi<T> {
//TODO move scale api
restart(params: { namespace: string; name: string }) {
return this.request.patch(this.getUrl(params), {
data: {
spec: {
template: {
metadata: {
annotations: { "kubectl.kubernetes.io/restartedAt": moment.utc().format() }
}
}
}
}
},
{
headers: {
"content-type": strategicMergePatch
}
});
}
}

View File

@ -0,0 +1,55 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.RevisionHistory {
position: relative;
min-height: 80px;
.Table {
margin: 0 (-$margin * 3);
}
.TableCell {
&:first-child {
margin-left: $margin;
}
&:last-child {
margin-right: $margin;
}
&.name {
flex-grow: 2;
}
&.warning {
@include table-cell-warning;
}
&.namespace {
flex-grow: 1.2;
}
&.actions {
@include table-cell-action;
}
}
}

View File

@ -0,0 +1,97 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./revision-history.scss";
import React from "react";
import { observer } from "mobx-react";
import { DrawerTitle } from "../drawer";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import type { ControllerRevision } from "../../../common/k8s-api/endpoints/controller-revision.api";
enum sortBy {
name = "name",
namespace = "namespace",
pods = "pods",
age = "age",
revision = "revision",
}
interface Props {
object: ControllerRevision[];
}
@observer
export class RevisionHistory extends React.Component<Props> {
private sortingCallbacks = {
[sortBy.name]: (object: ControllerRevision) => object.getName(),
[sortBy.namespace]: (object: ControllerRevision) => object.getNs(),
[sortBy.age]: (object: ControllerRevision) => object.metadata.creationTimestamp,
[sortBy.revision]: (object: ControllerRevision) => object.getRevisionNumber(),
};
render() {
const { object } = this.props;
if (!object.length) return null;
return (
<div className="RevisionHistory flex column">
<DrawerTitle title="Revision history"/>
<Table
tableId="revision_history"
selectable
scrollable={false}
sortable={this.sortingCallbacks}
sortByDefault={{ sortBy: sortBy.revision, orderBy: "desc" }}
sortSyncWithUrl={false}
className="box grow"
>
<TableHead>
<TableCell className="revision" sortBy={sortBy.revision}>Revision</TableCell>
<TableCell className="name" sortBy={sortBy.name}>Name</TableCell>
<TableCell className="warning"/>
<TableCell className="namespace" sortBy={sortBy.namespace}>Namespace</TableCell>
<TableCell className="age" sortBy={sortBy.age}>Age</TableCell>
</TableHead>
{
object.map(object => {
return (
<TableRow
key={object.getId()}
sortItem={object}
nowrap
>
<TableCell className="revision">{object.getRevisionNumber()}</TableCell>
<TableCell className="name">{object.getName()}</TableCell>
<TableCell className="warning"><KubeObjectStatusIcon key="icon" object={object}/></TableCell>
<TableCell className="namespace">{object.getNs()}</TableCell>
<TableCell className="age">{object.getAge()}</TableCell>
</TableRow>
);
})
}
</Table>
</div>
);
}
}

View File

@ -0,0 +1,146 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React from "react";
import { MenuItem } from "../menu";
import { ConfirmDialog } from "../confirm-dialog";
import { Notifications } from "../notifications";
import { Icon } from "../icon";
import { WorkloadRollbackDialog } from "./workload-rollback-dialog";
import type { WorkloadKubeObject } from "../../../common/k8s-api/workload-kube-object";
import { apiManager } from "../../../common/k8s-api/api-manager";
import { Deployment, deploymentApi } from "../../../common/k8s-api/endpoints/deployment.api";
interface Props {
workloadKubeObject: WorkloadKubeObject;
}
export function renderRestart(workloadKubeObject: WorkloadKubeObject) {
const api: any = apiManager.getApiByKind(workloadKubeObject.kind, workloadKubeObject.apiVersion);
return (
<MenuItem onClick={() => ConfirmDialog.open({
ok: async () => {
try {
await api.restart({
namespace: workloadKubeObject.getNs(),
name: workloadKubeObject.getName(),
});
} catch (err) {
Notifications.error(err);
}
},
labelOk: `Restart`,
message: (
<p>
Are you sure you want to restart {workloadKubeObject.kind} <b>{workloadKubeObject.getName()}</b>?
</p>
),
})}>
<Icon material="autorenew" tooltip="Restart" interactive={true}/>
<span className="title">Restart</span>
</MenuItem>
);
}
export function renderRollback(workloadKubeObject: WorkloadKubeObject) {
return (
<MenuItem onClick={() => WorkloadRollbackDialog.open(workloadKubeObject)}>
<Icon material="history" tooltip="Rollback" interactive={true}/>
<span className="title">Rollback</span>
</MenuItem>
);
}
export function renderPause(workloadKubeObject: Deployment) {
return (
<MenuItem onClick={() => ConfirmDialog.open({
ok: async () => {
try {
await deploymentApi.pause({ namespace: workloadKubeObject.getNs(), name: workloadKubeObject.getName() });
} catch (err) {
Notifications.error(err);
}
},
labelOk: `Pause`,
message: (
<p>
Pause Deployment <b>{workloadKubeObject.getName()}</b>?
</p>),
})}>
<Icon material="pause_circle_filled" tooltip="Pause" interactive={true}/>
<span className="title">Pause</span>
</MenuItem>
);
}
export function renderResume(workloadKubeObject: Deployment) {
return (
<MenuItem onClick={() => ConfirmDialog.open({
ok: async () => {
try {
await deploymentApi.resume({ namespace: workloadKubeObject.getNs(), name: workloadKubeObject.getName() });
} catch (err) {
Notifications.error(err);
}
},
labelOk: `Resume`,
message: (
<p>
Resume Deployment <b>{workloadKubeObject.getName()}</b>?
</p>),
})}>
<Icon material="play_circle_outline" tooltip="Resume" interactive={true}/>
<span className="title">Resume</span>
</MenuItem>
);
}
export function pauseResumeMenu(workloadKubeObject: Deployment) {
return (
<>
{!workloadKubeObject.isPaused() ? renderPause(workloadKubeObject) : renderResume(workloadKubeObject)}
</>
);
}
export class RolloutMenu extends React.Component<Props> {
render() {
const { workloadKubeObject } = this.props;
if (!workloadKubeObject) {
return null;
}
return (
<>
{workloadKubeObject instanceof Deployment && pauseResumeMenu(workloadKubeObject)}
{renderRestart(workloadKubeObject)}
{renderRollback(workloadKubeObject)}
</>
);
}
}

View File

@ -0,0 +1,30 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.WorkloadRollbackDialog {
.WizardStep {
text-align: center;
.step-content {
padding: var(--wizard-spacing);
}
}
}

View File

@ -0,0 +1,174 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./workload-rollback-dialog.scss";
import type { DialogProps } from "../dialog/dialog";
import React from "react";
import { Select, SelectOption } from "../select/select";
import type { ControllerRevision } from "../../../common/k8s-api/endpoints/controller-revision.api";
import { Wizard, WizardStep } from "../wizard/wizard";
import { Dialog } from "../dialog/dialog";
import { createTerminalTab, TerminalStore } from "../dock/terminal.store";
import type { WorkloadKubeObject } from "../../../common/k8s-api/workload-kube-object";
import type { ReplicaSet } from "../../../common/k8s-api/endpoints/replica-set.api";
import { makeObservable, observable } from "mobx";
import { observer } from "mobx-react";
import orderBy from "lodash/orderBy";
import { Deployment } from "../../../common/k8s-api/endpoints/deployment.api";
import { deploymentStore } from "../+workloads-deployments/deployments.store";
import { controllerRevisionApi } from "../../../common/k8s-api/endpoints/controller-revision.api";
interface Props extends DialogProps {
}
const dialogState = observable.object({
isOpen: false,
workloadKubeObject: null as WorkloadKubeObject,
});
export interface KubeObjectHistory {
revision: number;
date: string;
name: string;
}
@observer
export class WorkloadRollbackDialog extends React.Component<Props> {
@observable workloadObject: WorkloadKubeObject;
@observable isLoading = false;
@observable revisions = observable.array<KubeObjectHistory>();
@observable revision: KubeObjectHistory;
constructor(props: Props) {
super(props);
makeObservable(this);
}
static open(workloadKubeObject: WorkloadKubeObject) {
dialogState.isOpen = true;
dialogState.workloadKubeObject = workloadKubeObject;
}
static close() {
dialogState.isOpen = false;
}
close = () => {
WorkloadRollbackDialog.close();
};
get workloadKubeObject(): WorkloadKubeObject {
return dialogState.workloadKubeObject;
}
getHistory(revisions: ReplicaSet[] | ControllerRevision[]) {
return revisions.map(revision => ({
revision: revision.getRevisionNumber(),
date: new Date(revision.metadata.creationTimestamp).toLocaleString(),
name: revision.getName(),
} as KubeObjectHistory));
}
onOpen = async () => {
this.isLoading = true;
let revisions: ReplicaSet[] | ControllerRevision[];
if(this.workloadKubeObject instanceof Deployment) {
revisions = deploymentStore.getRelatedReplicas(this.workloadKubeObject);
}
else {
revisions = await controllerRevisionApi.getRevisions({ namespace: this.workloadKubeObject.getNs(),
name: this.workloadKubeObject.getName() });
}
this.revisions = observable.array(orderBy(this.getHistory(revisions), "revision", "desc"));
this.revision = this.revisions[0];
this.isLoading = false;
};
rollback = async () => {
const shell = createTerminalTab({
title: `Rollback: ${this.workloadKubeObject.getName()} (namespace: ${this.workloadKubeObject.getNs()})`
});
TerminalStore.getInstance().sendCommand(`kubectl rollout undo ${this.workloadKubeObject.kind.toLocaleLowerCase()}` +
`/${this.workloadKubeObject.getName()} --to-revision=${this.revision.revision} -n=${this.workloadKubeObject.getNs()}`,
{
enter: true,
tabId: shell.id
});
this.close();
};
renderContent() {
const { revision, revisions } = this;
if (revisions.length < 2) {
return <p>No revisions to rollback.</p>;
}
return (
<div className="flex gaps align-center">
<b>Revision</b>
<Select
themeName="light"
value={revision}
options={revisions}
formatOptionLabel={({ value }: SelectOption<KubeObjectHistory>) => `${value.revision} - ${value.name},
created: ${value.date}`}
onChange={({ value }: SelectOption<KubeObjectHistory>) => this.revision = value}
/>
</div>
);
}
render() {
const { ...dialogProps } = this.props;
const kubeObjectName = this.workloadKubeObject ? this.workloadKubeObject.getName() : "";
const header = <h5>Rollback {this.workloadKubeObject?.kind.toLowerCase()} <b>{kubeObjectName}</b></h5>;
const {revisions} = this;
return (
<Dialog
{...dialogProps}
className="WorkloadRollbackDialog"
isOpen={dialogState.isOpen}
onOpen={this.onOpen}
close={this.close}
>
<Wizard header={header} done={this.close}>
<WizardStep
scrollable={false}
nextLabel="Rollback"
next={this.rollback}
loading={this.isLoading}
disabledNext={revisions.length < 2}
>
{this.renderContent()}
</WizardStep>
</Wizard>
</Dialog>
);
}
}

View File

@ -73,6 +73,7 @@ import { getHostedClusterId } from "../utils";
import { ClusterStore } from "../../common/cluster-store";
import type { ClusterId } from "../../common/cluster-types";
import { watchHistoryState } from "../remote-helpers/history-updater";
import { WorkloadRollbackDialog } from "./+workloads/workload-rollback-dialog";
@observer
export class App extends React.Component {
@ -222,6 +223,7 @@ export class App extends React.Component {
<StatefulSetScaleDialog/>
<ReplicaSetScaleDialog/>
<CronJobTriggerDialog/>
<WorkloadRollbackDialog/>
<CommandContainer clusterId={App.clusterId}/>
</ErrorBoundary>
</Router>

View File

@ -25,6 +25,7 @@ import { CronJobMenu } from "../components/+workloads-cronjobs";
import { DeploymentMenu } from "../components/+workloads-deployments";
import { ReplicaSetMenu } from "../components/+workloads-replicasets";
import { StatefulSetMenu } from "../components/+workloads-statefulsets";
import { DaemonSetMenu } from "../components/+workloads-daemonsets";
export function initKubeObjectMenuRegistry() {
KubeObjectMenuRegistry.getInstance()
@ -63,6 +64,13 @@ export function initKubeObjectMenuRegistry() {
components: {
MenuItem: StatefulSetMenu
}
},
{
kind: "DaemonSet",
apiVersions: ["apps/v1"],
components: {
MenuItem: DaemonSetMenu
}
}
]);
}