Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add run termination controls to ui #1039

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/src/cmd/ml/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ func NewRunTerminateCmd(root *RootCommand) *cobra.Command {
if err != nil {
return util.ExtractErrorForCLI(err, root.Debug())
}
PrettyPrintResult(root.Writer(), root.OutputFormat(), "Run was terminated successfully.")
PrettyPrintResult(root.Writer(), root.OutputFormat(), "Run has been scheduled for termination.")
return nil
},
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/Css.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ export const color = {
strong: '#202124', // Google grey 900
success: '#34a853',
successWeak: '#e6f4ea', // Google green 50
terminated: '#80868b',
theme: '#1a73e8',
themeDarker: '#0b59dc',
warningBg: '#f9f9e1',
warningText: '#ee8100',
weak: '#9aa0a6',
};

Expand Down
74 changes: 74 additions & 0 deletions frontend/src/apis/run/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,42 @@ export const RunServiceApiFetchParamCreator = function (configuration?: Configur
options: localVarRequestOptions,
};
},
/**
*
* @param {string} run_id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
terminateRun(run_id: string, options: any = {}): FetchArgs {
// verify required parameter 'run_id' is not null or undefined
if (run_id === null || run_id === undefined) {
throw new RequiredError('run_id','Required parameter run_id was null or undefined when calling terminateRun.');
}
const localVarPath = `/apis/v1beta1/runs/{run_id}/terminate`
.replace(`{${"run_id"}}`, encodeURIComponent(String(run_id)));
const localVarUrlObj = url.parse(localVarPath, true);
const localVarRequestOptions = Object.assign({ method: 'POST' }, options);
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;

// authentication Bearer required
if (configuration && configuration.apiKey) {
const localVarApiKeyValue = typeof configuration.apiKey === 'function'
? configuration.apiKey("authorization")
: configuration.apiKey;
localVarHeaderParameter["authorization"] = localVarApiKeyValue;
}

localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query);
// fix override query string Detail: https://stackoverflow.com/a/7517673/1077943
delete localVarUrlObj.search;
localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers);

return {
url: url.format(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
Expand Down Expand Up @@ -1012,6 +1048,24 @@ export const RunServiceApiFp = function(configuration?: Configuration) {
});
};
},
/**
*
* @param {string} run_id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
terminateRun(run_id: string, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise<any> {
const localVarFetchArgs = RunServiceApiFetchParamCreator(configuration).terminateRun(run_id, options);
return (fetch: FetchAPI = portableFetch, basePath: string = BASE_PATH) => {
return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => {
if (response.status >= 200 && response.status < 300) {
return response.json();
} else {
throw response;
}
});
};
},
/**
*
* @param {string} id
Expand Down Expand Up @@ -1111,6 +1165,15 @@ export const RunServiceApiFactory = function (configuration?: Configuration, fet
reportRunMetrics(run_id: string, body: ApiReportRunMetricsRequest, options?: any) {
return RunServiceApiFp(configuration).reportRunMetrics(run_id, body, options)(fetch, basePath);
},
/**
*
* @param {string} run_id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
terminateRun(run_id: string, options?: any) {
return RunServiceApiFp(configuration).terminateRun(run_id, options)(fetch, basePath);
},
/**
*
* @param {string} id
Expand Down Expand Up @@ -1216,6 +1279,17 @@ export class RunServiceApi extends BaseAPI {
return RunServiceApiFp(this.configuration).reportRunMetrics(run_id, body, options)(this.fetch, this.basePath);
}

/**
*
* @param {} run_id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof RunServiceApi
*/
public terminateRun(run_id: string, options?: any) {
return RunServiceApiFp(this.configuration).terminateRun(run_id, options)(this.fetch, this.basePath);
}

/**
*
* @param {} id
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ class Banner extends React.Component<BannerProps, BannerState> {
dialogTitle = 'An error occurred';
break;
case 'warning':
bannerModeCss = stylesheet({ mode: { backgroundColor: color.warningBg, color: color.errorText, } });
bannerModeCss = stylesheet({ mode: { backgroundColor: color.warningBg, color: color.warningText, } });
bannerIcon = <WarningIcon className={css.icon} />;
dialogTitle = 'Warning';
break;
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/icons/statusTerminated.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright 2019 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as React from 'react';
import { CSSProperties } from 'jss/css';
export default class StatusRunning extends React.Component<{ style: CSSProperties }> {
public render(): JSX.Element {
const { style } = this.props;
return (
<svg width={style.width as string} height={style.height as string} viewBox='0 0 18 18'>
<g stroke='none' strokeWidth='1' fill='none' fillRule='evenodd'>
<g transform='translate(-1.000000, -1.000000)'>
<polygon points='0 0 18 0 18 18 0 18' />
<path d='M8.9925,1.5 C4.8525,1.5 1.5,4.86 1.5,9 C1.5,13.14 4.8525,16.5 8.9925,16.5
C13.14,16.5 16.5,13.14 16.5,9 C16.5,4.86 13.14,1.5 8.9925,1.5 Z M9,15 C5.685,15
3,12.315 3,9 C3,5.685 5.685,3 9,3 C12.315,3 15,5.685 15,9 C15,12.315 12.315,15 9,15 Z'
fill={style.color as string} fillRule='nonzero' />
<polygon fill={style.color as string} fillRule='nonzero'
points='6 6 12 6 12 12 6 12' />
</g>
</g>
</svg>
);
}
}
26 changes: 26 additions & 0 deletions frontend/src/lib/Buttons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,18 @@ export default class Buttons {
};
}

public terminateRun(getSelectedIds: () => string[], useCurrentResource: boolean,
callback: (selectedIds: string[], success: boolean) => void): ToolbarActionConfig {
return {
action: () => this._terminateRun(getSelectedIds(), useCurrentResource, callback),
disabled: !useCurrentResource,
disabledTitle: useCurrentResource ? undefined : 'Select at least one run to terminate',
id: 'terminateRunBtn',
title: 'Terminate',
tooltip: 'Terminate execution of a run',
};
}

public upload(action: () => void): ToolbarActionConfig {
return {
action,
Expand Down Expand Up @@ -266,6 +278,20 @@ export default class Buttons {
);
}

private _terminateRun(ids: string[], useCurrentResource: boolean,
callback: (_: string[], success: boolean) => void): void {
this._dialogActionHandler(
ids,
'Do you want to terminate this run? This action cannot be undone. This will terminate any'
+ ' running pods, but they will not be deleted.',
useCurrentResource,
id => Apis.runServiceApi.terminateRun(id),
callback,
'Terminate',
'run',
);
}

private _dialogActionHandler(selectedIds: string[], content: string, useCurrentResource: boolean,
api: (id: string) => Promise<void>, callback: (selectedIds: string[], success: boolean) => void,
actionName: string, resourceName: string): void {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/lib/WorkflowParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ export default class WorkflowParser {
}
g.setNode(node.id, {
height: Constants.NODE_HEIGHT,
icon: statusToIcon(node.phase as NodePhase, node.startedAt, node.finishedAt),
icon: statusToIcon(node.phase as NodePhase, node.startedAt, node.finishedAt, node.message),
label: nodeLabel,
statusColoring: statusToBgColor(node.phase as NodePhase),
statusColoring: statusToBgColor(node.phase as NodePhase, node.message),
width: Constants.NODE_WIDTH,
...node,
});
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/pages/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export abstract class Page<P, S> extends React.Component<P & PageProps, S> {
this._isMounted = false;
}

public componentDidMount(): void {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious why this is needed, doesn't it mean cleanup isn't done properly somewhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the banner sticks when navigating from the RunDetails page back to the experiment/run list page using the toolbar nav arrow. We could pass the clearBanner function to the Toolbar and call it when the arrow is clicked, but is that better?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the unmount method on RunDetails clear the banner in that case?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that also fixes it and I'll add it there.

Any reason to remove this code though?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clearBanner is already called in the unmount method right? I was wondering why that doesn't take care of it.
It's just a little confusing to me to see two initialization code blocks (constructor and componentDidMount), I think it should be avoided, but it's not a strong reason.

Copy link
Contributor Author

@rileyjbauer rileyjbauer Mar 28, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'm not sure why it doesn't take care of it

this.clearBanner();
}

public clearBanner(): void {
this.props.updateBanner({});
}
Expand Down
52 changes: 9 additions & 43 deletions frontend/src/pages/RunDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,19 +120,6 @@ describe('RunDetails', () => {
expect(lastCall.pageTitle).toMatchSnapshot();
});

it('has a refresh button, clicking it calls get run API again', async () => {
tree = shallow(<RunDetails {...generateProps()} />);
await getRunSpy;
await TestUtils.flushPromises();
const instance = tree.instance() as RunDetails;
const refreshBtn = instance.getInitialToolbarState().actions.find(
b => b.title === 'Refresh');
expect(refreshBtn).toBeDefined();
expect(getRunSpy).toHaveBeenCalledTimes(1);
await refreshBtn!.action();
expect(getRunSpy).toHaveBeenCalledTimes(2);
});

it('has a clone button, clicking it navigates to new run page', async () => {
tree = shallow(<RunDetails {...generateProps()} />);
await getRunSpy;
Expand Down Expand Up @@ -555,7 +542,7 @@ describe('RunDetails', () => {
expect(tree).toMatchSnapshot();
});

it('keeps side pane open and on same tab when refresh is clicked', async () => {
it('keeps side pane open and on same tab when page is refreshed', async () => {
testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
status: { nodes: { node1: { id: 'node1', }, }, },
});
Expand All @@ -567,11 +554,8 @@ describe('RunDetails', () => {
expect(tree.state('selectedNodeDetails')).toHaveProperty('id', 'node1');
expect(tree.state('sidepanelSelectedTab')).toEqual(2);

const instance = tree.instance() as RunDetails;
const refreshBtn = instance.getInitialToolbarState().actions.find(
b => b.title === 'Refresh');
await refreshBtn!.action();
expect(getRunSpy).toHaveBeenCalledTimes(2);
await (tree.instance() as RunDetails).refresh();
expect (getRunSpy).toHaveBeenCalledTimes(2);
expect(tree.state('selectedNodeDetails')).toHaveProperty('id', 'node1');
expect(tree.state('sidepanelSelectedTab')).toEqual(2);
});
Expand All @@ -593,10 +577,7 @@ describe('RunDetails', () => {
expect(tree.state('selectedNodeDetails')).toHaveProperty('id', 'node1');
expect(tree.state('sidepanelSelectedTab')).toEqual(2);

const instance = tree.instance() as RunDetails;
const refreshBtn = instance.getInitialToolbarState().actions.find(
b => b.title === 'Refresh');
await refreshBtn!.action();
await (tree.instance() as RunDetails).refresh();
expect(getRunSpy).toHaveBeenCalledTimes(2);
expect(tree.state('selectedNodeDetails')).toHaveProperty('id', 'node1');
expect(tree.state('sidepanelSelectedTab')).toEqual(2);
Expand All @@ -619,10 +600,7 @@ describe('RunDetails', () => {
expect(thirdCall.pageTitle).toMatchSnapshot();

testRun.run!.status = 'Failed';
const instance = tree.instance() as RunDetails;
const refreshBtn = instance.getInitialToolbarState().actions.find(
b => b.title === 'Refresh');
await refreshBtn!.action();
await (tree.instance() as RunDetails).refresh();
const fourthCall = updateToolbarSpy.mock.calls[3][0];
expect(fourthCall.pageTitle).toMatchSnapshot();
});
Expand All @@ -640,10 +618,7 @@ describe('RunDetails', () => {
expect(tree.state('sidepanelSelectedTab')).toEqual(2);

getPodLogsSpy.mockImplementationOnce(() => 'new test logs');
const instance = tree.instance() as RunDetails;
const refreshBtn = instance.getInitialToolbarState().actions.find(
b => b.title === 'Refresh');
await refreshBtn!.action();
await (tree.instance() as RunDetails).refresh();
expect(tree).toMatchSnapshot();
});

Expand All @@ -666,10 +641,7 @@ describe('RunDetails', () => {
});

testRun.run!.status = 'Failed';
const instance = tree.instance() as RunDetails;
const refreshBtn = instance.getInitialToolbarState().actions.find(
b => b.title === 'Refresh');
await refreshBtn!.action();
await (tree.instance() as RunDetails).refresh();
expect(tree.state()).toMatchObject({
logsBannerAdditionalInfo: '',
logsBannerMessage: '',
Expand All @@ -690,10 +662,7 @@ describe('RunDetails', () => {
testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
status: { nodes: { node1: { id: 'node1', phase: 'Succeeded', message: 'some node message' } } },
});
const instance = tree.instance() as RunDetails;
const refreshBtn = instance.getInitialToolbarState().actions.find(
b => b.title === 'Refresh');
await refreshBtn!.action();
await (tree.instance() as RunDetails).refresh();
expect(tree.state('selectedNodeDetails')).toHaveProperty('phaseMessage',
'This step is in Succeeded state with this message: some node message');
});
Expand All @@ -713,10 +682,7 @@ describe('RunDetails', () => {
testRun.pipeline_runtime!.workflow_manifest = JSON.stringify({
status: { nodes: { node1: { id: 'node1' } } },
});
const instance = tree.instance() as RunDetails;
const refreshBtn = instance.getInitialToolbarState().actions.find(
b => b.title === 'Refresh');
await refreshBtn!.action();
await (tree.instance() as RunDetails).refresh();
expect(tree.state('selectedNodeDetails')).toHaveProperty('phaseMessage', undefined);
});

Expand Down
Loading