diff --git a/frontend/src/components/viewers/Tensorboard.test.tsx b/frontend/src/components/viewers/Tensorboard.test.tsx index b5f71428bdc8..e92edd1667ab 100644 --- a/frontend/src/components/viewers/Tensorboard.test.tsx +++ b/frontend/src/components/viewers/Tensorboard.test.tsx @@ -23,8 +23,14 @@ import { ReactWrapper, ShallowWrapper, shallow, mount } from 'enzyme'; describe('Tensorboard', () => { let tree: ReactWrapper | ShallowWrapper; + const flushPromisesAndTimers = async () => { + jest.runOnlyPendingTimers(); + await TestUtils.flushPromises(); + }; + beforeEach(() => { jest.clearAllMocks(); + jest.useFakeTimers(); }); afterEach(async () => { @@ -102,9 +108,13 @@ describe('Tensorboard', () => { const config = { type: PlotType.TENSORBOARD, url: 'http://test/url' }; const getAppMock = () => Promise.resolve({ podAddress: 'test/address', tfVersion: '1.14.0' }); jest.spyOn(Apis, 'getTensorboardApp').mockImplementation(getAppMock); + jest.spyOn(Apis, 'isTensorboardPodReady').mockImplementation(() => Promise.resolve(true)); tree = shallow(); await TestUtils.flushPromises(); + await flushPromisesAndTimers(); + expect(Apis.isTensorboardPodReady).toHaveBeenCalledTimes(1); + expect(Apis.isTensorboardPodReady).toHaveBeenCalledWith('apis/v1beta1/_proxy/test/address'); expect(tree).toMatchSnapshot(); }); @@ -266,4 +276,44 @@ describe('Tensorboard', () => { expect(tree.findWhere(el => el.text() === 'Open Tensorboard').exists()).toBeTruthy(); expect(tree.findWhere(el => el.text() === 'Delete Tensorboard').exists()).toBeTruthy(); }); + + it('asks user to wait when Tensorboard status is not ready', async () => { + const getAppMock = jest.fn(() => + Promise.resolve({ podAddress: 'podaddress', tfVersion: '1.14.0' }), + ); + jest.spyOn(Apis, 'getTensorboardApp').mockImplementation(getAppMock); + jest.spyOn(Apis, 'isTensorboardPodReady').mockImplementation(() => Promise.resolve(false)); + jest.spyOn(Apis, 'deleteTensorboardApp').mockImplementation(jest.fn(() => Promise.resolve(''))); + const config = { type: PlotType.TENSORBOARD, url: 'http://test/url' }; + tree = mount(); + + await TestUtils.flushPromises(); + await flushPromisesAndTimers(); + tree.update(); + expect(Apis.isTensorboardPodReady).toHaveBeenCalledTimes(1); + expect(Apis.isTensorboardPodReady).toHaveBeenCalledWith('apis/v1beta1/_proxy/podaddress'); + expect(tree.findWhere(el => el.text() === 'Open Tensorboard').exists()).toBeTruthy(); + expect( + tree + .findWhere( + el => + el.text() === 'Tensorboard is starting, and you may need to wait for a few minutes.', + ) + .exists(), + ).toBeTruthy(); + expect(tree.findWhere(el => el.text() === 'Delete Tensorboard').exists()).toBeTruthy(); + + // After a while, it is ready and wait message is not shwon any more + jest.spyOn(Apis, 'isTensorboardPodReady').mockImplementation(() => Promise.resolve(true)); + await flushPromisesAndTimers(); + tree.update(); + expect( + tree + .findWhere( + el => + el.text() === `Tensorboard is starting, and you may need to wait for a few minutes.`, + ) + .exists(), + ).toEqual(false); + }); }); diff --git a/frontend/src/components/viewers/Tensorboard.tsx b/frontend/src/components/viewers/Tensorboard.tsx index dbab67659b69..7d0bc1386d05 100644 --- a/frontend/src/components/viewers/Tensorboard.tsx +++ b/frontend/src/components/viewers/Tensorboard.tsx @@ -55,6 +55,8 @@ export interface TensorboardViewerConfig extends ViewerConfig { interface TensorboardViewerProps { configs: TensorboardViewerConfig[]; + // Interval in ms. If not specified, default to 5000. + intervalOfCheckingTensorboardPodStatus?: number; } interface TensorboardViewerState { @@ -62,12 +64,16 @@ interface TensorboardViewerState { deleteDialogOpen: boolean; podAddress: string; tensorflowVersion: string; + // When podAddress is not null, we need to further tell whether the TensorBoard pod is accessible or not + tensorboardReady: boolean; } // TODO(jingzhang36): we'll later parse Tensorboard version from mlpipeline-ui-metadata.json file. const DEFAULT_TENSORBOARD_VERSION = '2.0.0'; class TensorboardViewer extends Viewer { + timerID: NodeJS.Timeout; + constructor(props: any) { super(props); @@ -76,6 +82,7 @@ class TensorboardViewer extends Viewer this._checkTensorboardPodStatus(), + this.props.intervalOfCheckingTensorboardPodStatus || 5000, + ); + } + + public componentWillUnmount(): void { + clearInterval(this.timerID); } public handleVersionSelect = (e: React.ChangeEvent<{ name?: string; value: unknown }>): void => { @@ -128,6 +143,11 @@ class TensorboardViewer extends Viewer Open Tensorboard + {this.state.tensorboardReady ? ( + `` + ) : ( +
Tensorboard is starting, and you may need to wait for a few minutes.
+ )}
@@ -235,6 +255,18 @@ class TensorboardViewer extends Viewer `Series${i + 1}:` + c).join(','); } + private async _checkTensorboardPodStatus(): Promise { + // If pod address is not null and tensorboard pod doesn't seem to be read, pull status again + if (this.state.podAddress && !this.state.tensorboardReady) { + // Remove protocol prefix bofore ":" from pod address if any. + Apis.isTensorboardPodReady( + 'apis/v1beta1/_proxy/' + this.state.podAddress.replace(/(^\w+:|^)\/\//, ''), + ).then(ready => { + this.setState(({ tensorboardReady }) => ({ tensorboardReady: tensorboardReady || ready })); + }); + } + } + private async _checkTensorboardApp(): Promise { this.setState({ busy: true }, async () => { const { podAddress, tfVersion } = await Apis.getTensorboardApp(this._buildUrl()); @@ -253,7 +285,7 @@ class TensorboardViewer extends Viewer { + this.setState({ busy: false, tensorboardReady: false }, () => { this._checkTensorboardApp(); }); }); @@ -269,6 +301,7 @@ class TensorboardViewer extends Viewer { return spy; }; +const failedFetchSpy = (response: string) => { + const spy = jest.fn(() => + Promise.resolve({ + ok: false, + text: () => response, + }), + ); + window.fetch = spy; + return spy; +}; + describe('Apis', () => { it('hosts a singleton experimentServiceApi', () => { expect(Apis.experimentServiceApi).toBe(Apis.experimentServiceApi); @@ -182,4 +193,18 @@ describe('Apis', () => { }, ); }); + + it('checks if Tensorboard pod is ready', async () => { + const spy = fetchSpy(''); + const ready = await Apis.isTensorboardPodReady('apis/v1beta1/_proxy/pod_address'); + expect(ready).toBe(true); + expect(spy).toHaveBeenCalledWith('apis/v1beta1/_proxy/pod_address', { method: 'HEAD' }); + }); + + it('checks if Tensorboard pod is not ready', async () => { + const spy = failedFetchSpy(''); + const ready = await Apis.isTensorboardPodReady('apis/v1beta1/_proxy/pod_address'); + expect(ready).toBe(false); + expect(spy).toHaveBeenCalledWith('apis/v1beta1/_proxy/pod_address', { method: 'HEAD' }); + }); }); diff --git a/frontend/src/lib/Apis.ts b/frontend/src/lib/Apis.ts index 36777c0e48df..681fba0f5ac3 100644 --- a/frontend/src/lib/Apis.ts +++ b/frontend/src/lib/Apis.ts @@ -204,6 +204,14 @@ export class Apis { ); } + /** + * Check if the underlying Tensorboard pod is actually up, given the pod address + */ + public static async isTensorboardPodReady(path: string): Promise { + const resp = await fetch(path, { method: 'HEAD' }); + return resp.ok; + } + /** * Delete a deployment and its service of the Tensorboard given the URL */