diff --git a/frontend/mock-backend/mock-api-middleware.ts b/frontend/mock-backend/mock-api-middleware.ts index cbc1a2b4090..dd541326bc5 100644 --- a/frontend/mock-backend/mock-api-middleware.ts +++ b/frontend/mock-backend/mock-api-middleware.ts @@ -66,11 +66,18 @@ export default (app: express.Application) => { app.use(express.json()); app.get(v1beta1Prefix + '/healthz', (_, res) => { - res.send({ apiServerReady: true }); + res.header('Content-Type', 'application/json'); + res.send({ + apiServerCommitHash: 'd3c4add0a95e930c70a330466d0923827784eb9a', + apiServerReady: true, + buildDate: 'Wed Jan 9 19:40:24 UTC 2019', + frontendCommitHash: '8efb2fcff9f666ba5b101647e909dc9c6889cecb' + }); }); app.get('/hub/', (_, res) => { res.sendStatus(200); + // res.send({ ok: true }); }); function getSortKeyAndOrder(defaultSortKey: string, queryParam?: string): { desc: boolean, key: string } { diff --git a/frontend/src/components/SideNav.test.tsx b/frontend/src/components/SideNav.test.tsx index 139fa18a04a..32711ac8013 100644 --- a/frontend/src/components/SideNav.test.tsx +++ b/frontend/src/components/SideNav.test.tsx @@ -17,9 +17,11 @@ import * as React from 'react'; import SideNav, { css } from './SideNav'; -import { shallow, ShallowWrapper } from 'enzyme'; -import { RoutePage } from './Router'; +import TestUtils from '../TestUtils'; +import { Apis } from '../lib/Apis'; import { LocalStorage } from '../lib/LocalStorage'; +import { ReactWrapper, ShallowWrapper, shallow, } from 'enzyme'; +import { RoutePage } from './Router'; import { RouterProps } from 'react-router'; const wideWidth = 1000; @@ -29,117 +31,148 @@ const isCollapsed = (tree: ShallowWrapper) => const routerProps: RouterProps = { history: {} as any }; describe('SideNav', () => { + let tree: ReactWrapper | ShallowWrapper; + + const consoleErrorSpy = jest.spyOn(console, 'error'); + const buildInfoSpy = jest.spyOn(Apis, 'getBuildInfo'); + const checkHubSpy = jest.spyOn(Apis, 'isJupyterHubAvailable'); + const localStorageHasKeySpy = jest.spyOn(LocalStorage, 'hasKey'); + const localStorageIsCollapsedSpy = jest.spyOn(LocalStorage, 'isNavbarCollapsed'); + + beforeEach(() => { + jest.clearAllMocks(); + + consoleErrorSpy.mockImplementation(() => null); + + buildInfoSpy.mockImplementation(() => ({ + apiServerCommitHash: 'd3c4add0a95e930c70a330466d0923827784eb9a', + apiServerReady: true, + buildDate: 'Wed Jan 9 19:40:24 UTC 2019', + frontendCommitHash: '8efb2fcff9f666ba5b101647e909dc9c6889cecb' + })); + checkHubSpy.mockImplementation(() => ({ ok: true })); + + localStorageHasKeySpy.mockImplementation(() => false); + localStorageIsCollapsedSpy.mockImplementation(() => false); + }); + + afterEach(() => { + jest.resetAllMocks(); + tree.unmount(); + (window as any).innerWidth = wideWidth; + }); + it('renders expanded state', () => { - jest.spyOn(LocalStorage, 'hasKey').mockImplementationOnce(() => false); + localStorageHasKeySpy.mockImplementationOnce(() => false); (window as any).innerWidth = wideWidth; - const tree = shallow(); + tree = shallow(); expect(tree).toMatchSnapshot(); }); it('renders collapsed state', () => { - jest.spyOn(LocalStorage, 'hasKey').mockImplementationOnce(() => false); + localStorageHasKeySpy.mockImplementationOnce(() => false); (window as any).innerWidth = narrowWidth; - const tree = shallow(); + tree = shallow(); expect(tree).toMatchSnapshot(); }); it('renders Pipelines as active page', () => { - const tree = shallow(); + tree = shallow(); expect(tree).toMatchSnapshot(); }); it('renders Pipelines as active when on PipelineDetails page', () => { - const tree = shallow(); + tree = shallow(); expect(tree).toMatchSnapshot(); }); it('renders experiments as active page', () => { - const tree = shallow(); + tree = shallow(); expect(tree).toMatchSnapshot(); }); it('renders experiments as active when on ExperimentDetails page', () => { - const tree = shallow(); + tree = shallow(); expect(tree).toMatchSnapshot(); }); it('renders experiments as active page when on NewExperiment page', () => { - const tree = shallow(); + tree = shallow(); expect(tree).toMatchSnapshot(); }); it('renders experiments as active page when on Compare page', () => { - const tree = shallow(); + tree = shallow(); expect(tree).toMatchSnapshot(); }); it('renders experiments as active page when on AllRuns page', () => { - const tree = shallow(); + tree = shallow(); expect(tree).toMatchSnapshot(); }); it('renders experiments as active page when on RunDetails page', () => { - const tree = shallow(); + tree = shallow(); expect(tree).toMatchSnapshot(); }); it('renders experiments as active page when on RecurringRunDetails page', () => { - const tree = shallow(); + tree = shallow(); expect(tree).toMatchSnapshot(); }); it('renders experiments as active page when on NewRun page', () => { - const tree = shallow(); + tree = shallow(); expect(tree).toMatchSnapshot(); }); it('show jupyterhub link if accessible', () => { - const tree = shallow(); + tree = shallow(); tree.setState({ jupyterHubAvailable: true }); expect(tree).toMatchSnapshot(); }); it('collapses if collapse state is true localStorage', () => { - jest.spyOn(LocalStorage, 'isNavbarCollapsed').mockImplementationOnce(() => true); - jest.spyOn(LocalStorage, 'hasKey').mockImplementationOnce(() => true); + localStorageIsCollapsedSpy.mockImplementationOnce(() => true); + localStorageHasKeySpy.mockImplementationOnce(() => true); (window as any).innerWidth = wideWidth; - const tree = shallow(); + tree = shallow(); expect(isCollapsed(tree)).toBe(true); }); it('expands if collapse state is false in localStorage', () => { - jest.spyOn(LocalStorage, 'isNavbarCollapsed').mockImplementationOnce(() => false); - jest.spyOn(LocalStorage, 'hasKey').mockImplementationOnce(() => true); + localStorageIsCollapsedSpy.mockImplementationOnce(() => false); + localStorageHasKeySpy.mockImplementationOnce(() => true); - const tree = shallow(); + tree = shallow(); expect(isCollapsed(tree)).toBe(false); }); it('collapses if no collapse state in localStorage, and window is too narrow', () => { - jest.spyOn(LocalStorage, 'isNavbarCollapsed').mockImplementationOnce(() => false); - jest.spyOn(LocalStorage, 'hasKey').mockImplementationOnce(() => false); + localStorageIsCollapsedSpy.mockImplementationOnce(() => false); + localStorageHasKeySpy.mockImplementationOnce(() => false); (window as any).innerWidth = narrowWidth; - const tree = shallow(); + tree = shallow(); expect(isCollapsed(tree)).toBe(true); }); it('expands if no collapse state in localStorage, and window is wide', () => { - jest.spyOn(LocalStorage, 'isNavbarCollapsed').mockImplementationOnce(() => false); - jest.spyOn(LocalStorage, 'hasKey').mockImplementationOnce(() => false); + localStorageIsCollapsedSpy.mockImplementationOnce(() => false); + localStorageHasKeySpy.mockImplementationOnce(() => false); (window as any).innerWidth = wideWidth; - const tree = shallow(); + tree = shallow(); expect(isCollapsed(tree)).toBe(false); }); it('collapses if no collapse state in localStorage, and window goes from wide to narrow', () => { - jest.spyOn(LocalStorage, 'isNavbarCollapsed').mockImplementationOnce(() => false); - jest.spyOn(LocalStorage, 'hasKey').mockImplementationOnce(() => false); + localStorageIsCollapsedSpy.mockImplementationOnce(() => false); + localStorageHasKeySpy.mockImplementationOnce(() => false); (window as any).innerWidth = wideWidth; - const tree = shallow(); + tree = shallow(); expect(isCollapsed(tree)).toBe(false); (window as any).innerWidth = narrowWidth; @@ -149,11 +182,11 @@ describe('SideNav', () => { }); it('expands if no collapse state in localStorage, and window goes from narrow to wide', () => { - jest.spyOn(LocalStorage, 'isNavbarCollapsed').mockImplementationOnce(() => false); - jest.spyOn(LocalStorage, 'hasKey').mockImplementationOnce(() => false); + localStorageIsCollapsedSpy.mockImplementationOnce(() => false); + localStorageHasKeySpy.mockImplementationOnce(() => false); (window as any).innerWidth = narrowWidth; - const tree = shallow(); + tree = shallow(); expect(isCollapsed(tree)).toBe(true); (window as any).innerWidth = wideWidth; @@ -163,12 +196,12 @@ describe('SideNav', () => { }); it('saves state in localStorage if chevron is clicked', () => { - jest.spyOn(LocalStorage, 'isNavbarCollapsed').mockImplementationOnce(() => false); - jest.spyOn(LocalStorage, 'hasKey').mockImplementationOnce(() => false); + localStorageIsCollapsedSpy.mockImplementationOnce(() => false); + localStorageHasKeySpy.mockImplementationOnce(() => false); const spy = jest.spyOn(LocalStorage, 'saveNavbarCollapsed'); (window as any).innerWidth = narrowWidth; - const tree = shallow(); + tree = shallow(); expect(isCollapsed(tree)).toBe(true); tree.find('WithStyles(IconButton)').simulate('click'); @@ -176,11 +209,11 @@ describe('SideNav', () => { }); it('does not collapse if collapse state is saved in localStorage, and window resizes', () => { - jest.spyOn(LocalStorage, 'isNavbarCollapsed').mockImplementationOnce(() => false); - jest.spyOn(LocalStorage, 'hasKey').mockImplementationOnce(() => true); + localStorageIsCollapsedSpy.mockImplementationOnce(() => false); + localStorageHasKeySpy.mockImplementationOnce(() => true); (window as any).innerWidth = wideWidth; - const tree = shallow(); + tree = shallow(); expect(isCollapsed(tree)).toBe(false); (window as any).innerWidth = narrowWidth; @@ -188,4 +221,130 @@ describe('SideNav', () => { window.dispatchEvent(resizeEvent); expect(isCollapsed(tree)).toBe(false); }); + + it('populates the display build information using the response from the healthz endpoint', async () => { + const buildInfo = { + apiServerCommitHash: '0a7b9e38f2b9bcdef4bbf3234d971e1635b50cd5', + apiServerReady: true, + buildDate: 'Tue Oct 23 14:23:53 UTC 2018', + frontendCommitHash: '302e93ce99099173f387c7e0635476fe1b69ea98' + }; + buildInfoSpy.mockImplementationOnce(() => (buildInfo)); + + tree = shallow(); + await TestUtils.flushPromises(); + expect(tree).toMatchSnapshot(); + + expect(tree.state('displayBuildInfo')).toEqual({ + commitHash: buildInfo.apiServerCommitHash.substring(0, 7), + commitUrl: 'https://www.github.com/kubeflow/pipelines/commit/' + buildInfo.apiServerCommitHash, + date: new Date(buildInfo.buildDate).toLocaleDateString(), + }); + }); + + it('displays the frontend commit hash if the api server hash is not returned', async () => { + const buildInfo = { + apiServerReady: true, + // No apiServerCommitHash + buildDate: 'Tue Oct 23 14:23:53 UTC 2018', + frontendCommitHash: '302e93ce99099173f387c7e0635476fe1b69ea98' + }; + buildInfoSpy.mockImplementationOnce(() => (buildInfo)); + + tree = shallow(); + await TestUtils.flushPromises(); + + expect(tree.state('displayBuildInfo')).toEqual(expect.objectContaining({ + commitHash: buildInfo.frontendCommitHash.substring(0, 7), + })); + }); + + it('uses the frontend commit hash for the link URL if the api server hash is not returned', async () => { + const buildInfo = { + apiServerReady: true, + // No apiServerCommitHash + buildDate: 'Tue Oct 23 14:23:53 UTC 2018', + frontendCommitHash: '302e93ce99099173f387c7e0635476fe1b69ea98' + }; + buildInfoSpy.mockImplementationOnce(() => (buildInfo)); + + tree = shallow(); + await TestUtils.flushPromises(); + + expect(tree.state('displayBuildInfo')).toEqual(expect.objectContaining({ + commitUrl: 'https://www.github.com/kubeflow/pipelines/commit/' + buildInfo.frontendCommitHash, + })); + }); + + it('displays \'unknown\' if the frontend and api server commit hashes are not returned', async () => { + const buildInfo = { + apiServerReady: true, + // No apiServerCommitHash + buildDate: 'Tue Oct 23 14:23:53 UTC 2018', + // No frontendCommitHash + }; + buildInfoSpy.mockImplementationOnce(() => (buildInfo)); + + tree = shallow(); + await TestUtils.flushPromises(); + + expect(tree.state('displayBuildInfo')).toEqual(expect.objectContaining({ + commitHash: 'unknown', + commitUrl: 'https://www.github.com/kubeflow/pipelines', + })); + }); + + it('links to the github repo root if the frontend and api server commit hashes are not returned', async () => { + const buildInfo = { + apiServerReady: true, + // No apiServerCommitHash + buildDate: 'Tue Oct 23 14:23:53 UTC 2018', + // No frontendCommitHash + }; + buildInfoSpy.mockImplementationOnce(() => (buildInfo)); + + tree = shallow(); + await TestUtils.flushPromises(); + + expect(tree.state('displayBuildInfo')).toEqual(expect.objectContaining({ + commitUrl: 'https://www.github.com/kubeflow/pipelines', + })); + }); + + it('displays \'unknown\' if the date is not returned', async () => { + const buildInfo = { + apiServerCommitHash: '0a7b9e38f2b9bcdef4bbf3234d971e1635b50cd5', + apiServerReady: true, + // No buildDate + frontendCommitHash: '302e93ce99099173f387c7e0635476fe1b69ea98' + }; + buildInfoSpy.mockImplementationOnce(() => (buildInfo)); + + tree = shallow(); + await TestUtils.flushPromises(); + + expect(tree.state('displayBuildInfo')).toEqual(expect.objectContaining({ + date: 'unknown', + })); + }); + + it('logs an error if the call getBuildInfo fails', async () => { + TestUtils.makeErrorResponseOnce(buildInfoSpy, 'Uh oh!'); + + tree = shallow(); + await TestUtils.flushPromises(); + + expect(tree.state('displayBuildInfo')).toBeUndefined(); + expect(consoleErrorSpy.mock.calls[0][0]).toBe('Failed to retrieve build info'); + }); + + it('logs an error if the call isJupyterHubAvailable fails', async () => { + TestUtils.makeErrorResponseOnce(checkHubSpy, 'Uh oh!'); + + tree = shallow(); + await TestUtils.flushPromises(); + + expect(tree.state('jupyterHubAvailable')).toEqual(false); + expect(consoleErrorSpy.mock.calls[0][0]).toBe('Failed to reach Jupyter Hub'); + }); }); diff --git a/frontend/src/components/SideNav.tsx b/frontend/src/components/SideNav.tsx index 96affb7f8f3..4785e9dc97f 100644 --- a/frontend/src/components/SideNav.tsx +++ b/frontend/src/components/SideNav.tsx @@ -23,13 +23,15 @@ import JupyterhubIcon from '@material-ui/icons/Code'; import KubeflowLogo from '../icons/kubeflowLogo'; import OpenInNewIcon from '@material-ui/icons/OpenInNew'; import PipelinesIcon from '../icons/pipelines'; +import Tooltip from '@material-ui/core/Tooltip'; +import { Apis } from '../lib/Apis'; import { Link } from 'react-router-dom'; import { LocalStorage, LocalStorageKey } from '../lib/LocalStorage'; import { RoutePage } from '../components/Router'; import { RouterProps } from 'react-router'; import { classes, stylesheet } from 'typestyle'; import { fontsize, dimension, commonCss } from '../Css'; -import Tooltip from '@material-ui/core/Tooltip'; +import { logger } from '../lib/Utils'; export const sideNavColors = { bg: '#0f4471', @@ -45,6 +47,22 @@ export const css = stylesheet({ backgroundColor: sideNavColors.hover + ' !important', color: sideNavColors.fgActive + ' !important', }, + buildHash: { + color: '#b7d1e8' + }, + buildInfo: { + color: sideNavColors.fgDefault, + marginBottom: 30, + marginLeft: 30, + opacity: 'initial', + transition: 'opacity 0.2s', + transitionDelay: '0.3s', + }, + buildInfoHidden: { + opacity: 0, + transition: 'opacity 0s', + transitionDelay: '0s', + }, button: { borderRadius: dimension.base / 2, color: sideNavColors.fgDefault, @@ -79,7 +97,7 @@ export const css = stylesheet({ }, collapsedLabel: { // Hide text when collapsing, but do it with a transition - color: `${sideNavColors.fgActiveInvisible} !important`, + opacity: 0, }, collapsedRoot: { width: '72px !important', @@ -91,7 +109,7 @@ export const css = stylesheet({ fontSize: fontsize.base, letterSpacing: 0.25, marginLeft: 20, - transition: 'color 0.3s', + transition: 'opacity 0.3s', verticalAlign: 'super', }, logo: { @@ -121,17 +139,25 @@ export const css = stylesheet({ }, }); +interface DisplayBuildInfo { + commitHash: string; + commitUrl: string; + date: string; +} + interface SideNavProps extends RouterProps { page: string; } interface SideNavState { + displayBuildInfo?: DisplayBuildInfo; collapsed: boolean; jupyterHubAvailable: boolean; manualCollapseState: boolean; } -class SideNav extends React.Component { +export default class SideNav extends React.Component { + private _isMounted = true; private readonly _AUTO_COLLAPSE_WIDTH = 800; private readonly _HUB_ADDRESS = '/hub/'; @@ -151,77 +177,115 @@ class SideNav extends React.Component { window.addEventListener('resize', this._maybeResize.bind(this)); this._maybeResize(); - const hub = await fetch(this._HUB_ADDRESS); - if (hub.ok) { - this.setState({ jupyterHubAvailable: true }); + // Fetch build info + let displayBuildInfo: DisplayBuildInfo | undefined; + try { + const buildInfo = await Apis.getBuildInfo(); + const commitHash = buildInfo.apiServerCommitHash || buildInfo.frontendCommitHash || ''; + displayBuildInfo = { + commitHash: commitHash ? commitHash.substring(0, 7) : 'unknown', + commitUrl: 'https://www.github.com/kubeflow/pipelines' + + (commitHash ? `/commit/${commitHash}` : ''), + date: buildInfo.buildDate ? new Date(buildInfo.buildDate).toLocaleDateString() : 'unknown', + }; + } catch (err) { + logger.error('Failed to retrieve build info', err); + } + + // Verify Jupyter Hub is reachable + let jupyterHubAvailable = false; + try { + jupyterHubAvailable = await Apis.isJupyterHubAvailable(); + } catch (err) { + logger.error('Failed to reach Jupyter Hub', err); } + + this.setStateSafe({ displayBuildInfo, jupyterHubAvailable }); + } + + public componentWillUnmount(): void { + this._isMounted = false; } public render(): JSX.Element { const page = this.props.page; - const { collapsed } = this.state; + const { collapsed, displayBuildInfo} = this.state; const iconColor = { active: sideNavColors.fgActive, inactive: sideNavColors.fgDefault, }; return ( -
- - - - - Kubeflow - - - - - - - - - - - - - - {this.state.jupyterHubAvailable && ( - + Pipelines + + + + - + - + + + {this.state.jupyterHubAvailable && ( + + + + + + )} +
+ + + +
+ {displayBuildInfo && ( + + )} -
- - - ); } @@ -236,7 +300,7 @@ class SideNav extends React.Component { } private _toggleNavClicked(): void { - this.setState({ + this.setStateSafe({ collapsed: !this.state.collapsed, manualCollapseState: true, }, () => LocalStorage.saveNavbarCollapsed(this.state.collapsed)); @@ -244,7 +308,7 @@ class SideNav extends React.Component { } private _toggleNavCollapsed(shouldCollapse?: boolean): void { - this.setState({ + this.setStateSafe({ collapsed: shouldCollapse !== undefined ? shouldCollapse : !this.state.collapsed, }); } @@ -254,6 +318,10 @@ class SideNav extends React.Component { this._toggleNavCollapsed(window.innerWidth < this._AUTO_COLLAPSE_WIDTH); } } -} -export default SideNav; + private setStateSafe(newState: Partial, cb?: () => void): void { + if (this._isMounted) { + this.setState(newState as any, cb); + } + } +} diff --git a/frontend/src/components/__snapshots__/SideNav.test.tsx.snap b/frontend/src/components/__snapshots__/SideNav.test.tsx.snap index cbda7ea9eb4..b76dd93dc0d 100644 --- a/frontend/src/components/__snapshots__/SideNav.test.tsx.snap +++ b/frontend/src/components/__snapshots__/SideNav.test.tsx.snap @@ -1,971 +1,1656 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SideNav renders Pipelines as active page 1`] = ` +exports[`SideNav populates the display build information using the response from the healthz endpoint 1`] = `
- + + + + + Kubeflow + + + + + + + + + Pipelines + + + + + + + + + + Experiments + + + + + + + + + + Notebooks + + + + + +
- - Kubeflow - + +
- - - - - Pipelines + + Build commit: - - - - - - - Experiments - - - -
- - - + 0a7b9e3 + + +
+ `; -exports[`SideNav renders Pipelines as active when on PipelineDetails page 1`] = ` +exports[`SideNav renders Pipelines as active page 1`] = `
- + + + + + Kubeflow + + + + + + + + + Pipelines + + + + + + + + + + Experiments + + + + +
- - Kubeflow - + +
- +`; + +exports[`SideNav renders Pipelines as active when on PipelineDetails page 1`] = ` +
+
- - - - Pipelines - - - - - + + Kubeflow + + + + - - - Experiments - - - -
- - - + + + + Pipelines + + + + + + + + + + Experiments + + + + +
+ + + +
`; exports[`SideNav renders collapsed state 1`] = `
- - - Kubeflow - -
- - - - - Pipelines - - - - - + + Kubeflow + + + + - - - Experiments - - - -
- - - + + + + Pipelines + + + + + + + + + + Experiments + + + + +
+ + + +
`; exports[`SideNav renders expanded state 1`] = `
- - - Kubeflow - -
- - - - - Pipelines - - - - - + + Kubeflow + + + + - - - Experiments - - - -
- - - + + + + Pipelines + + + + + + + + + + Experiments + + + + +
+ + + +
`; exports[`SideNav renders experiments as active page 1`] = `
- - - Kubeflow - -
- - - - - Pipelines - - - - - + + Kubeflow + + + + - - - Experiments - - - -
- - - + + + + Pipelines + + + + + + + + + + Experiments + + + + +
+ + + +
`; exports[`SideNav renders experiments as active page when on AllRuns page 1`] = `
- - - Kubeflow - -
- - - - - Pipelines - - - - - + + Kubeflow + + + + - - - Experiments - - - -
- - - + + + + Pipelines + + + + + + + + + + Experiments + + + + +
+ + + +
`; exports[`SideNav renders experiments as active page when on Compare page 1`] = `
- - - Kubeflow - -
- - - - - Pipelines - - - - - + + Kubeflow + + + + - - - Experiments - - - -
- - - + + + + Pipelines + + + + + + + + + + Experiments + + + + +
+ + + +
`; exports[`SideNav renders experiments as active page when on NewExperiment page 1`] = `
- - - Kubeflow - -
- - - - - Pipelines - - - - - + + Kubeflow + + + + - - - Experiments - - - -
- - - + + + + Pipelines + + + + + + + + + + Experiments + + + + +
+ + + +
`; exports[`SideNav renders experiments as active page when on NewRun page 1`] = `
- - - Kubeflow - -
- - - - - Pipelines - - - - - + + Kubeflow + + + + - - - Experiments - - - -
- - - + + + + Pipelines + + + + + + + + + + Experiments + + + + +
+ + + +
`; exports[`SideNav renders experiments as active page when on RecurringRunDetails page 1`] = `
- - - Kubeflow - -
- - - - - Pipelines - - - - - + + Kubeflow + + + + - - - Experiments - - - -
- - - + + + + Pipelines + + + + + + + + + + Experiments + + + + +
+ + + +
`; exports[`SideNav renders experiments as active page when on RunDetails page 1`] = `
- - - Kubeflow - -
- - - - - Pipelines - - - - - + + Kubeflow + + + + - - - Experiments - - - -
- - - + + + + Pipelines + + + + + + + + + + Experiments + + + + +
+ + + +
`; exports[`SideNav renders experiments as active when on ExperimentDetails page 1`] = `
- - - Kubeflow - -
- - - - - Pipelines - - - - - + + Kubeflow + + + + - - - Experiments - - - -
- - - + + + + Pipelines + + + + + + + + + + Experiments + + + + +
+ + + +
`; exports[`SideNav show jupyterhub link if accessible 1`] = `
- - - Kubeflow - -
- - - - - Pipelines - - - - - + + Kubeflow + + + + - - - Experiments - - - - - - - + + + Pipelines + + + + + + - Notebooks - - - - -
- - - + + + + Experiments + + + + + + + + + + Notebooks + + + + + +
+ + + +
`; diff --git a/frontend/src/lib/Apis.test.ts b/frontend/src/lib/Apis.test.ts index 015e24e013d..b20c48e5c54 100644 --- a/frontend/src/lib/Apis.test.ts +++ b/frontend/src/lib/Apis.test.ts @@ -57,19 +57,32 @@ describe('Apis', () => { expect(Apis.getPodLogs('some-pod-name')).rejects.toThrowError('bad response'); }); - it('isApiServerReady returns true', async () => { - fetchSpy(JSON.stringify({ apiServerReady: true })); - expect(await Apis.isApiServerReady()).toEqual(true); + it('getBuildInfo returns build information', async () => { + const expectedBuildInfo = { + apiServerCommitHash: 'd3c4add0a95e930c70a330466d0923827784eb9a', + apiServerReady: true, + buildDate: 'Wed Jan 9 19:40:24 UTC 2019', + frontendCommitHash: '8efb2fcff9f666ba5b101647e909dc9c6889cecb' + }; + const spy = fetchSpy(JSON.stringify(expectedBuildInfo)); + const actualBuildInfo = await Apis.getBuildInfo(); + expect(spy).toHaveBeenCalledWith('apis/v1beta1/healthz', { credentials: 'same-origin' }); + expect(actualBuildInfo).toEqual(expectedBuildInfo); }); - it('isApiServerReady returns false on unreachable', async () => { - window.fetch = () => { throw new Error('nope!'); }; - expect(await Apis.isApiServerReady()).toEqual(false); + it('isJupyterHubAvailable returns true if the response for the /hub/ url was ok', async () => { + const spy = fetchSpy('{}'); + const isJupyterHubAvailable = await Apis.isJupyterHubAvailable(); + expect(spy).toHaveBeenCalledWith('/hub/', { credentials: 'same-origin' }); + expect(isJupyterHubAvailable).toEqual(true); }); - it('isApiServerReady returns false on bad json', async () => { - fetchSpy('bad json'); - expect(await Apis.isApiServerReady()).toEqual(false); + it('isJupyterHubAvailable returns false if the response for the /hub/ url was not ok', async () => { + const spy = jest.fn(() => Promise.resolve({ ok: false })); + window.fetch = spy; + const isJupyterHubAvailable = await Apis.isJupyterHubAvailable(); + expect(spy).toHaveBeenCalledWith('/hub/', { credentials: 'same-origin' }); + expect(isJupyterHubAvailable).toEqual(false); }); it('readFile', async () => { diff --git a/frontend/src/lib/Apis.ts b/frontend/src/lib/Apis.ts index 2930c824f41..d0b41322e48 100644 --- a/frontend/src/lib/Apis.ts +++ b/frontend/src/lib/Apis.ts @@ -29,6 +29,13 @@ export interface ListRequest { sortBy?: string; } +export interface BuildInfo { + apiServerCommitHash?: string; + apiServerReady?: boolean; + buildDate?: string; + frontendCommitHash?: string; +} + export class Apis { /** @@ -73,15 +80,18 @@ export class Apis { } /** - * Checks if the API server is ready for traffic. + * Retrieve various information about the build. */ - public static async isApiServerReady(): Promise { - try { - const healthStats = await this._fetchAndParse('/healthz', v1beta1Prefix); - return healthStats.apiServerReady; - } catch (_) { - return false; - } + public static async getBuildInfo(): Promise { + return await this._fetchAndParse('/healthz', v1beta1Prefix); + } + + /** + * Verifies that Jupyter Hub is reachable. + */ + public static async isJupyterHubAvailable(): Promise { + const response = await fetch('/hub/', { credentials: 'same-origin' }); + return response ? response.ok : false; } /**