From 504b89620e0965fffdaf4fb883bc7228314856ca Mon Sep 17 00:00:00 2001 From: Riley Bauer <34456002+rileyjbauer@users.noreply.github.com> Date: Mon, 14 Jan 2019 10:08:22 -0800 Subject: [PATCH] Add build version to side nav (#670) * Makes KF logo a button and adds tooltips to sidenav when collapsed * Adds build version, date, and link to side nav. Still needs tests * Cleanup and PR comments --- frontend/mock-backend/mock-api-middleware.ts | 10 +- frontend/src/components/SideNav.test.tsx | 242 +- frontend/src/components/SideNav.tsx | 191 +- .../__snapshots__/SideNav.test.tsx.snap | 2283 +++++++++++------ frontend/src/lib/Apis.test.ts | 31 +- frontend/src/lib/Apis.ts | 26 +- 6 files changed, 1864 insertions(+), 919 deletions(-) diff --git a/frontend/mock-backend/mock-api-middleware.ts b/frontend/mock-backend/mock-api-middleware.ts index cbc1a2b409..c05b730556 100644 --- a/frontend/mock-backend/mock-api-middleware.ts +++ b/frontend/mock-backend/mock-api-middleware.ts @@ -66,12 +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); - }); + }); function getSortKeyAndOrder(defaultSortKey: string, queryParam?: string): { desc: boolean, key: string } { let key = defaultSortKey; diff --git a/frontend/src/components/SideNav.test.tsx b/frontend/src/components/SideNav.test.tsx index 139fa18a04..317d94cadf 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', () => { - it('renders expanded state', () => { - jest.spyOn(LocalStorage, 'hasKey').mockImplementationOnce(() => false); + 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; - const tree = shallow(); + }); + + it('renders expanded state', () => { + localStorageHasKeySpy.mockImplementationOnce(() => false); + (window as any).innerWidth = wideWidth; + 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,129 @@ 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', + })); + }); + + 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 3776e82acc..abd9d4ad50 100644 --- a/frontend/src/components/SideNav.tsx +++ b/frontend/src/components/SideNav.tsx @@ -23,12 +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 { logger } from '../lib/Utils'; export const sideNavColors = { bg: '#0f4471', @@ -44,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, @@ -78,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', @@ -90,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: { @@ -107,6 +126,14 @@ export const css = stylesheet({ justifyContent: 'center', marginLeft: 12, }, + marginBottom8: { + marginBottom: 8, + }, + openInNewTabIcon: { + height: 12, + marginLeft: 5, + width: 12, + }, root: { background: sideNavColors.bg, paddingTop: 12, @@ -120,17 +147,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/'; @@ -150,61 +185,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 - +
+
+ + + + + Kubeflow + + + + + + + + + + + + + + {this.state.jupyterHubAvailable && ( + + + + + + )} +
+ + +
- - - - - - - {this.state.jupyterHubAvailable && ( - - - + {displayBuildInfo && ( + + + )} -
- - -
); } @@ -219,7 +308,7 @@ class SideNav extends React.Component { } private _toggleNavClicked(): void { - this.setState({ + this.setStateSafe({ collapsed: !this.state.collapsed, manualCollapseState: true, }, () => LocalStorage.saveNavbarCollapsed(this.state.collapsed)); @@ -227,7 +316,7 @@ class SideNav extends React.Component { } private _toggleNavCollapsed(shouldCollapse?: boolean): void { - this.setState({ + this.setStateSafe({ collapsed: shouldCollapse !== undefined ? shouldCollapse : !this.state.collapsed, }); } @@ -237,6 +326,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 cbda7ea9eb..7a49f918a6 100644 --- a/frontend/src/components/__snapshots__/SideNav.test.tsx.snap +++ b/frontend/src/components/__snapshots__/SideNav.test.tsx.snap @@ -1,971 +1,1636 @@ // 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: - - - + 0a7b9e3 + + +
+ +
+`; + +exports[`SideNav renders Pipelines as active page 1`] = ` +
+
- - - - Experiments - - - -
- - - + + + Kubeflow + + + + + + + + + Pipelines + + + + + + + + + + Experiments + + + + +
+ + + +
`; exports[`SideNav renders Pipelines as active when on PipelineDetails page 1`] = `
- + + + + + Kubeflow + + + + + + + + + Pipelines + + + + + + + + + + Experiments + + + + +
- - Kubeflow - + +
- - - - - Pipelines - - - - - - - - Experiments - - - -
- - -
`; exports[`SideNav renders collapsed state 1`] = `
- + + + + + Kubeflow + + + + + + + + + Pipelines + + + + + + + + + + Experiments + + + + +
- - Kubeflow - + +
- - - - - Pipelines - - - - - - - - Experiments - - - -
- - -
`; exports[`SideNav renders expanded state 1`] = `
- + + + + + Kubeflow + + + + + + + + + Pipelines + + + + + + + + + + Experiments + + + + +
- - Kubeflow - + +
- - - - - Pipelines - - - - - - - - Experiments - - - -
- - -
`; exports[`SideNav renders experiments as active page 1`] = `
- + + + + + Kubeflow + + + + + + + + + Pipelines + + + + + + + + + + Experiments + + + + +
- - Kubeflow - + +
- - - - - Pipelines - - - - - - - - Experiments - - - -
- - -
`; exports[`SideNav renders experiments as active page when on AllRuns page 1`] = `
- + + + + + Kubeflow + + + + + + + + + Pipelines + + + + + + + + + + Experiments + + + + +
- - Kubeflow - + +
- - - - - Pipelines - - - - - - - - Experiments - - - -
- - -
`; exports[`SideNav renders experiments as active page when on Compare page 1`] = `
- + + + + + Kubeflow + + + + + + + + + Pipelines + + + + + + + + + + Experiments + + + + +
- - Kubeflow - + +
- - - - - Pipelines - - - - - - - - Experiments - - - -
- - -
`; exports[`SideNav renders experiments as active page when on NewExperiment page 1`] = `
- + + + + + Kubeflow + + + + + + + + + Pipelines + + + + + + + + + + Experiments + + + + +
- - Kubeflow - + +
- - - - - Pipelines - - - - - - - - Experiments - - - -
- - -
`; exports[`SideNav renders experiments as active page when on NewRun page 1`] = `
- + + + + + Kubeflow + + + + + + + + + Pipelines + + + + + + + + + + Experiments + + + + +
- - Kubeflow - + +
- - - - - Pipelines - - - - - - - - Experiments - - - -
- - -
`; exports[`SideNav renders experiments as active page when on RecurringRunDetails page 1`] = `
- + + + + + Kubeflow + + + + + + + + + Pipelines + + + + + + + + + + Experiments + + + + +
- - Kubeflow - + +
- - - - - Pipelines - - - - - - - - Experiments - - - -
- - -
`; exports[`SideNav renders experiments as active page when on RunDetails page 1`] = `
- + + + + + Kubeflow + + + + + + + + + Pipelines + + + + + + + + + + Experiments + + + + +
- - Kubeflow - + +
- - - - - Pipelines - - - - - - - - Experiments - - - -
- - -
`; exports[`SideNav renders experiments as active when on ExperimentDetails page 1`] = `
- + + + + + Kubeflow + + + + + + + + + Pipelines + + + + + + + + + + Experiments + + + + +
- - Kubeflow - + +
- - - - - Pipelines - - - - - - - - Experiments - - - -
- - -
`; exports[`SideNav show jupyterhub link if accessible 1`] = `
- + + + + + Kubeflow + + + + + + + + + Pipelines + + + + + + + + + + Experiments + + + + + + + + + + Notebooks + + + + + +
- - Kubeflow - + +
- - - - - Pipelines - - - - - - - - Experiments - - - - - - - - Notebooks - - - - -
- - -
`; diff --git a/frontend/src/lib/Apis.test.ts b/frontend/src/lib/Apis.test.ts index 015e24e013..b20c48e5c5 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 2930c824f4..d0b41322e4 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; } /**